diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs new file mode 100644 index 0000000..d0ea2d8 --- /dev/null +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs @@ -0,0 +1,438 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using J2N.Collections.Generic.Extensions; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist; +using Jellyfin.Plugin.TubeArchivistMetadata.Utilities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Playlists; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks +{ + /// + /// Task to sync TubeArchivist playlists to Jellyfin. + /// + public class JFToTubeArchivistPlaylistsSyncTask : IScheduledTask + { + private const string TAPlaylistIdRegex = @"^(.*)\((.*)\)$"; + private const string YTTAPlaylistNameFormatRegex = @"^(.*)\s\-\s(.*)\s\((.*)\)$"; + private const string TAPlaylistNameFormatRegex = @"^(.*)\s\((.*)\)$"; + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IPlaylistManager _playlistManager; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + /// Library manager. + /// User manager. + /// Playlists manager. + public JFToTubeArchivistPlaylistsSyncTask(ILogger logger, ILibraryManager libraryManager, IUserManager userManager, IPlaylistManager playlistManager) + { + _logger = logger; + _libraryManager = libraryManager; + _userManager = userManager; + _playlistManager = playlistManager; + } + + /// + public string Name => "JFToTubeArchivistPlaylistsSyncTask"; + + /// + public string Description => "This tasks syncs Jellyfin playlists to TubeArchivist"; + + /// + public string Category => "TubeArchivistMetadata"; + + /// + public string Key => "JFToTubeArchivistPlaylistsSyncTask"; + + private Dictionary> GetAllVideos(IEnumerable playlists, User user) + { + var items = new Dictionary>(); + foreach (var playlist in playlists) + { + items[playlist.Id] = playlist.GetChildren(user, true, new InternalItemsQuery()); + } + + return items; + } + + private int CountTotalVideos(Dictionary> playlistsItems) + { + var totalVideosCount = 0; + foreach (var playlistId in playlistsItems.Keys) + { + totalVideosCount += playlistsItems[playlistId].Count; + } + + return totalVideosCount; + } + + private string? GetTAPlaylistIdFromName(string playlistName) + { + var regex = new Regex(TAPlaylistIdRegex); + return regex.Match(playlistName).Groups[2].ToString(); + } + + private string? GetTAPlaylistNameFromName(string playlistName) + { + var ytRegex = new Regex(YTTAPlaylistNameFormatRegex); + var regex = new Regex(TAPlaylistNameFormatRegex); + + var name = ytRegex.Match(playlistName).Groups[1].ToString(); + if (string.IsNullOrEmpty(name)) + { + name = regex.Match(playlistName).Groups[1].ToString(); + } + + return name; + } + + private string GetPlaylistUpdatedName(string playlistName, string newId) + { + var index = playlistName.LastIndexOf(" (", StringComparison.CurrentCulture); + if (index < 0) + { + return $"{playlistName} ({newId})"; + } + else + { + var authorAndName = playlistName.Substring(0, index); + return $"{authorAndName} ({newId})"; + } + } + + private void MoveElementToPosition(Collection playlistEntries, int oldPosition, int newPosition) + { + var oldItem = playlistEntries[oldPosition]; + playlistEntries.RemoveAt(oldPosition); + playlistEntries.Insert(newPosition, oldItem); + } + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + progress.Report(0); + if (Plugin.Instance!.Configuration.JFTASync) + { + var start = DateTime.Now; + _logger.LogInformation("Starting Jellyfin->TubeArchivist playlists synchronization."); + var taApi = TubeArchivistApi.GetInstance(); + var taPlaylists = await taApi.GetPlaylists().ConfigureAwait(true); + + var jfUsername = Plugin.Instance!.Configuration.JFUsernameFrom; + + var user = _userManager.GetUserByName(jfUsername); + if (user == null) + { + _logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found"); + return; + } + + var userPlaylists = _playlistManager.GetPlaylists(user.Id).ToList(); + var jfItemsToAnalyze = GetAllVideos(userPlaylists, user); + var totalVideosCount = CountTotalVideos(jfItemsToAnalyze); + var processedVideosCount = 0; + + _logger.LogInformation("Found a total of {PlaylistsCount} playlists to analyze with a total of {VideosCount} videos", userPlaylists.Count, totalVideosCount); + foreach (var jfPlaylist in userPlaylists) + { + _logger.LogInformation("Analyzing playlist {PlaylistName}...", jfPlaylist.Name); + var taPlaylistId = GetTAPlaylistIdFromName(jfPlaylist.Name); + if (taPlaylistId == null) + { + _logger.LogDebug("The playlist {PlaylistName} was not a TA playlist: could not find TA playlist id at the end", jfPlaylist.Name); + continue; + } + + // Try to find a TA playlist with matching id + // N.B.: only videos with TubeArchivist provider can be synced to TA, if there is a non matching video log a warn + var taPlaylist = taPlaylists?.Where(tp => tp.Id == taPlaylistId).FirstOrDefault(); + + if (taPlaylist != null) + { + // If it exists and is custom add new videos and update videos order + if (taPlaylist.Type == PlaylistType.Custom) + { + var jfItems = jfItemsToAnalyze[jfPlaylist.Id]; + _logger.LogInformation("Found {PlaylistVideosCount} videos in playlist {PlaylistName}", jfItems.Count, jfPlaylist.Name); + + var itemsToProcess = new List(); + + // Add videos to move/add to the TA playlist + for (var i = 0; i < jfItems.Count; i++) + { + if (!jfItems[i].ProviderIds.ContainsKey(Constants.ProviderName)) + { + _logger.LogError("Could not sync {JFItem} video from playlist {JFPlaylist} because it doesn't belong to TubeArchivist", jfItems[i].Name, jfPlaylist.Name); + continue; + } + + var position = taPlaylist.Entries.FindIndex(e => e.YoutubeId == jfItems[i].ProviderIds[Constants.ProviderName]); + PlaylistItemAction action; + if (position < 0) + { + _logger.LogDebug("Video {VideoName} from playlist {PlaylistName} not found in TA playlist", jfItems[i].Name, jfPlaylist.Name); + var positionsToMove = i - position; + action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], CustomPlaylistAction.Create, positionsToMove, jfItems[i].Name); + } + else + { + _logger.LogDebug("Found video {VideoName} at position {JFPosition} in playlist {PlaylistName} at position {TAPosition} in TA playlist", jfItems[i].Name, i, jfPlaylist.Name, position); + if (i == 0) + { + action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], CustomPlaylistAction.Top, jfItems[i].Name); + } + else if (i == jfItems.Count) + { + action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], CustomPlaylistAction.Bottom, jfItems[i].Name); + } + else + { + var positionsToMove = i - position; + action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], positionsToMove > 0 ? CustomPlaylistAction.Up : CustomPlaylistAction.Down, Math.Abs(positionsToMove), jfItems[i].Name); + } + } + + itemsToProcess.Add(action); + } + + // Add videos to delete from the TA playlist + itemsToProcess.AddRange(taPlaylist.Entries + .Where(e => !itemsToProcess + .Select(i => i.YoutubeId) + .Contains(e.YoutubeId)) + .Select(e => new PlaylistItemAction(e.YoutubeId, e.Title))); + + foreach (var item in itemsToProcess) + { + _logger.LogDebug("Analyzing video {VideoName} from playlist {PlaylistName} at position {Position}", item.Name, jfPlaylist.Name, item.Index); + switch (item.Action) + { + case CustomPlaylistAction.Create: + var success = await AddVideoToTAPlaylist(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true); + if (!success) + { + continue; + } + + break; + + case CustomPlaylistAction.Top: + case CustomPlaylistAction.Bottom: + _logger.LogDebug("Moving the video {VideoName} to the {Side} of the playlist {PlaylistName}", item.Name, item.Action, jfPlaylist.Name); + await MoveOrDeleteVideo(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true); + break; + + case CustomPlaylistAction.Remove: + _logger.LogDebug("Removing the video {VideoName} in playlist {PlaylistName}", item.Name, jfPlaylist.Name); + await MoveOrDeleteVideo(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true); + break; + + default: + _logger.LogDebug("Moving the video {VideoName} {Direction} in playlist {PlaylistName}", item.Name, item.Action, jfPlaylist.Name); + await MoveOrDeleteVideo(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true); + break; + } + + processedVideosCount++; + progress.Report(processedVideosCount * 100 / totalVideosCount); + } + } + else + { + _logger.LogWarning("Could not sync modifications on a YouTube downloaded TA playlist: {TAPlaylist}", $"{taPlaylist.Name} - {taPlaylist.Channel} ({taPlaylist.Id})"); + continue; + } + } + else + { + // If it doesn't, create a new custom playlist and add videos + _logger.LogInformation("Playlist {PlaylistName} was not found on TubeArchivist. Creating a new playlist...", jfPlaylist.Name); + + var playlistName = GetTAPlaylistNameFromName(jfPlaylist.Name); + if (string.IsNullOrEmpty(playlistName)) + { + playlistName = jfPlaylist.Name; + } + + _logger.LogDebug("Creating a new TubeArchivist playlist with name: {PlaylistName}", playlistName); + + var creationRequest = new CustomPlaylistCreation(playlistName); + var createdTAPlaylist = await taApi.CreateCustomPlaylist(creationRequest).ConfigureAwait(true); + if (createdTAPlaylist == null) + { + _logger.LogError("Failed to create the playlist {JFPlaylist}", playlistName); + continue; + } + + // Update the JF playlist name with the new TA playlist id + var newPlaylistName = GetPlaylistUpdatedName(jfPlaylist.Name, createdTAPlaylist.Id); + var updateRequest = new PlaylistUpdateRequest + { + Id = jfPlaylist.Id, + Name = newPlaylistName, + UserId = user.Id + }; + await _playlistManager.UpdatePlaylist(updateRequest).ConfigureAwait(true); + _logger.LogDebug("Updated the playlist name from {NewPlaylistName} to {NewPlaylistName}", playlistName, newPlaylistName); + + foreach (var jfItem in jfPlaylist.GetItemList(new InternalItemsQuery())) + { + _logger.LogDebug("Adding the video {VideoName} to the playlist {PlaylistName}", jfItem.Name, jfPlaylist.Name); + var action = new CustomPlaylistEntryAction(CustomPlaylistAction.Create, jfItem.ProviderIds[Constants.ProviderName]); + var response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true); + if (response != System.Net.HttpStatusCode.OK) + { + _logger.LogError("Failed to add the video {JFItem} to playlist {JFPlaylist}", $"{jfItem.Name} ({jfItem.ProviderIds[Constants.ProviderName]})", jfPlaylist.Name); + continue; + } + + processedVideosCount++; + progress.Report(processedVideosCount * 100 / totalVideosCount); + } + } + } + + _logger.LogInformation("Time elapsed: {Time}", DateTime.Now - start); + } + else + { + _logger.LogInformation("Jellyfin->TubeArchivist playlists synchronization is currently disabled."); + } + + progress.Report(100); + } + + private async Task MoveOrDeleteVideo(TubeArchivistApi taApi, MediaBrowser.Controller.Playlists.Playlist jfPlaylist, string taPlaylistId, PlaylistItemAction item) + { + var response = HttpStatusCode.OK; + for (var i = 0; i < item.PositionsToMove; i++) + { + var action = new CustomPlaylistEntryAction(item.Action, item.YoutubeId); + response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true); + if (response != System.Net.HttpStatusCode.OK) + { + _logger.LogError("Failed to execute the operation {Operation} for the video {JFItem} in playlist {PlaylistName}", item.Action, $"{item.Name} ({item.YoutubeId})", jfPlaylist.Name); + } + } + } + + private async Task AddVideoToTAPlaylist(TubeArchivistApi taApi, MediaBrowser.Controller.Playlists.Playlist jfPlaylist, string taPlaylistId, PlaylistItemAction item) + { + // TA playlist doesn't contain the JF video + _logger.LogDebug("Adding the video {VideoName} to the playlist {PlaylistName}", item.Name, jfPlaylist.Name); + var action = new CustomPlaylistEntryAction(CustomPlaylistAction.Create, item.YoutubeId); + var response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true); + if (response != System.Net.HttpStatusCode.OK) + { + _logger.LogError("Failed to add the video {JFItem} to playlist {JFPlaylist}", $"{item.Name} ({item.YoutubeId})", jfPlaylist.Name); + return false; + } + + // Since now we have the video added at the bottom of the TA playlist (previouse entries count + 1), + // we need to move the video to the correct position + var positionsToMove = item.PositionsToMove; + action = new CustomPlaylistEntryAction(CustomPlaylistAction.Up, item.YoutubeId); + if (item.PositionsToMove < 0) + { + positionsToMove = (int)Math.Abs((decimal)positionsToMove!); + action = new CustomPlaylistEntryAction(CustomPlaylistAction.Up, item.YoutubeId); + } + + _logger.LogDebug("Moving the video {VideoName} of the playlist {PlaylistName} {Positions} up", item.Name, jfPlaylist.Name, positionsToMove); + for (var i = 0; i < positionsToMove; i++) + { + response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true); + if (response != System.Net.HttpStatusCode.OK) + { + _logger.LogError("Failed to move {Direction} the video {JFItem} in playlist {JFPlaylist}", item.Action, $"{item.Name} ({item.YoutubeId})", jfPlaylist.Name); + continue; + } + } + + return true; + } + + /// + public IEnumerable GetDefaultTriggers() + { + return + [ + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFTaskInterval).Ticks + }, + ]; + } + + private sealed class PlaylistItemAction + { + public PlaylistItemAction( + int index, + string youtubeId, + CustomPlaylistAction action, + int positionsToMove, + string name) + { + Index = index; + YoutubeId = youtubeId; + Action = action; + PositionsToMove = positionsToMove; + Name = name; + } + + public PlaylistItemAction( + int index, + string youtubeId, + CustomPlaylistAction action, + string name) + { + Index = index; + YoutubeId = youtubeId; + Action = action; + Name = name; + PositionsToMove = 1; + } + + public PlaylistItemAction( + string youtubeId, + string name) + { + YoutubeId = youtubeId; + Action = CustomPlaylistAction.Remove; + Name = name; + PositionsToMove = 1; + } + + public int? Index { get; set; } + + public string YoutubeId { get; set; } + + public string Name { get; set; } + + public CustomPlaylistAction Action { get; set; } + + public int? PositionsToMove { get; set; } + } + } +}