From 86dd1769ec4d29483b73972ee848fc4ca3fbc6a2 Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Fri, 30 May 2025 15:34:42 +0200 Subject: [PATCH 01/15] Create entities to TA playlists --- .../TubeArchivist/Playlist/Playlist.cs | 116 ++++++++++++++++++ .../TubeArchivist/Playlist/PlaylistEntry.cs | 63 ++++++++++ 2 files changed, 179 insertions(+) create mode 100644 Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/Playlist.cs create mode 100644 Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/PlaylistEntry.cs diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/Playlist.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/Playlist.cs new file mode 100644 index 0000000..91e0a16 --- /dev/null +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/Playlist.cs @@ -0,0 +1,116 @@ +using System.Collections.ObjectModel; +using Newtonsoft.Json; + +namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist +{ + /// + /// Enum representing whether the playlist on TubeArchivist belongs from YouTube or is created by the user. + /// + public enum PlaylistType + { + /// + /// Playlist belongs from YouTube. + /// + Regular, + + /// + /// Playlist created by the user. + /// + Custom + } + + /// + /// A class representing TubeArchivist API playlist data. + /// + public class Playlist + { + /// + /// Initializes a new instance of the class. + /// + /// URL of the channel banner image. + /// Playlist channel name. + /// Playlist channel YouTube id. + /// Playlist description. + /// Playlist videos. + /// Playlist YouTube id. + /// Playlist name. + /// URL of the playlist thumbnail. + /// Type of the playlist. See . + public Playlist( + bool isActive, + string channel, + string channelId, + string description, + Collection entries, + string id, + string name, + string thumbnailUrl, // TODO: Check if settable on Jellyfin + PlaylistType type) + { + this.IsActive = isActive; + this.Channel = channel; + this.ChannelId = channelId; + this.Description = description; + this.Entries = entries; + this.Id = id; + this.Name = name; + this.ThumbnailUrl = thumbnailUrl; + this.Type = type; + } + + /// + /// Gets or sets a value indicating whether the playlist is active or not. + /// + [JsonProperty(PropertyName = "playlist_active")] + public bool IsActive { get; set; } + + /// + /// Gets or sets the playlist channel name. + /// + [JsonProperty(PropertyName = "playlist_channel")] + public string Channel { get; set; } + + /// + /// Gets or sets the playlist channel YouTube id. + /// + [JsonProperty(PropertyName = "playlist_channel_id")] + public string ChannelId { get; set; } + + /// + /// Gets or sets the playlist description. + /// + [JsonProperty(PropertyName = "playlist_description")] + public string Description { get; set; } + + /// + /// Gets the playlist videos. + /// + [JsonProperty(PropertyName = "playlist_entries")] + public Collection Entries { get; } + + /// + /// Gets or sets the playlist YouTube id. + /// + [JsonProperty(PropertyName = "playlist_id")] + public string Id { get; set; } + + /// + /// Gets or sets the playlist name. + /// + [JsonProperty(PropertyName = "playlist_name")] + public string Name { get; set; } + + /// + /// Gets or sets the playlist thumbnail URL. + /// + [JsonProperty(PropertyName = "playlist_thumbnail")] + public string ThumbnailUrl { get; set; } + + /// + /// Gets or sets the playlist type. + /// + /// One of the values indicating the playlist's type. + [JsonProperty(PropertyName = "playlist_type")] + public PlaylistType Type { get; set; } + } +} diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/PlaylistEntry.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/PlaylistEntry.cs new file mode 100644 index 0000000..5e9120d --- /dev/null +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/PlaylistEntry.cs @@ -0,0 +1,63 @@ +using ICU4N.Text; +using Newtonsoft.Json; + +namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist +{ + /// + /// A class representing TubeArchivist API playlist entries data. + /// + public class PlaylistEntry + { + /// + /// Initializes a new instance of the class. + /// + /// Video YouTube id. + /// Video title. + /// Video uploader. + /// Video index in the playlist. + /// A value indicating whether the video has been already downloaded or not. + public PlaylistEntry( + string youtubeId, + string title, + string uploader, + int index, + bool isDownloaded) + { + this.YoutubeId = youtubeId; + this.Title = title; + this.Uploader = uploader; + this.Index = index; + this.IsDownloaded = isDownloaded; + } + + /// + /// Gets or sets the video YouTube id. + /// + [JsonProperty(PropertyName = "youtube_id")] + public string YoutubeId { get; set; } + + /// + /// Gets or sets the video title. + /// + [JsonProperty(PropertyName = "title")] + public string Title { get; set; } + + /// + /// Gets or sets the video uploader. + /// + [JsonProperty(PropertyName = "uploader")] + public string Uploader { get; set; } + + /// + /// Gets or sets the video index in the playlist. + /// + [JsonProperty(PropertyName = "idx")] + public int Index { get; set; } + + /// + /// Gets or sets a value indicating whether the video has been already downloaded or not. + /// + [JsonProperty(PropertyName = "downloaded")] + public bool IsDownloaded { get; set; } + } +} From e28a3ea37b3907b739a3346d74be48be58632fcd Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Fri, 30 May 2025 15:35:28 +0200 Subject: [PATCH 02/15] Implement TA playlists API call --- .../TubeArchivist/TubeArchivistApi.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs index 18e4672..f9585f3 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Data; using System.Net; using System.Net.Http; @@ -201,5 +202,34 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist return response.StatusCode; } + + /// + /// Retrieves the playlists from TubeArchivist. + /// + /// A task. + public async Task?> GetPlaylists() + { + ResponseContainer?>? playlists = null; + + var playlistsEndpoint = "/api/playlist/"; + var url = new Uri(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + playlistsEndpoint)); + var response = await client.GetAsync(url).ConfigureAwait(true); + while (response.StatusCode == HttpStatusCode.Moved) + { + url = response.Headers.Location; + _logger.LogInformation("{Message}", "Received redirect to: " + url); + response = await client.GetAsync(url).ConfigureAwait(true); + } + + _logger.LogInformation("{Message}", url + ": " + response.StatusCode); + + if (response.IsSuccessStatusCode) + { + string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true); + playlists = JsonConvert.DeserializeObject?>>(rawData); + } + + return playlists?.Data; + } } } From a9c6611dcfe326760bcfbb5956f61ee14edb905f Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Fri, 30 May 2025 15:36:25 +0200 Subject: [PATCH 03/15] Implement TA-> JF playlist sync scheduled task --- .../Plugin.cs | 10 +- .../Tasks/TAToJellyfinPlaylistsSyncTask.cs | 189 ++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs index 3e3e95d..51d006b 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs @@ -14,6 +14,7 @@ using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; @@ -41,6 +42,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public Plugin( IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, @@ -49,7 +51,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata ILibraryManager libraryManager, ITaskManager taskManager, IUserManager userManager, - IUserDataManager userDataManager) + IUserDataManager userDataManager, + IPlaylistManager playlistManager) : base(applicationPaths, xmlSerializer) { Instance = this; @@ -68,6 +71,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata userDataManager.UserDataSaved += OnWatchedStatusChange; var taToJellyfinProgressSyncTask = new TAToJellyfinProgressSyncTask(logger, libraryManager, userManager, userDataManager); + var taToJellyfinPlaylistsSyncTask = new TAToJellyfinPlaylistsSyncTask(logger, libraryManager, userManager, playlistManager); var jfToTubearchivistProgressSyncTask = new JFToTubearchivistProgressSyncTask(logger, libraryManager, userManager, userDataManager); var isTAJFTaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(taToJellyfinProgressSyncTask.Name, StringComparison.Ordinal)); if (Instance!.Configuration.TAJFSync && !isTAJFTaskPresent) @@ -75,6 +79,10 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata logger.LogInformation("Queueing task {TaskName}.", taToJellyfinProgressSyncTask.Name); taskManager.AddTasks([taToJellyfinProgressSyncTask]); taskManager.Execute(); + + logger.LogInformation("Queueing task {TaskName}.", taToJellyfinPlaylistsSyncTask.Name); + taskManager.AddTasks([taToJellyfinProgressSyncTask]); + taskManager.Execute(); } var isJFTATaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(jfToTubearchivistProgressSyncTask.Name, StringComparison.Ordinal)); diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs new file mode 100644 index 0000000..17a86ed --- /dev/null +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +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 playback progresses to Jellyfin. + /// + public class TAToJellyfinPlaylistsSyncTask : IScheduledTask + { + 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 TAToJellyfinPlaylistsSyncTask(ILogger logger, ILibraryManager libraryManager, IUserManager userManager, IPlaylistManager playlistManager) + { + _logger = logger; + _libraryManager = libraryManager; + _userManager = userManager; + _playlistManager = playlistManager; + } + + /// + public string Name => "TAToJellyfinPlaylistsSyncTask"; + + /// + public string Description => "This tasks syncs TubeArchivist playlists to Jellyfin"; + + /// + public string Category => "TubeArchivistMetadata"; + + /// + public string Key => "TAToJellyfinPlaylistsSyncTask"; + + private int CountTotalVideos(ISet taPlaylists) + { + var totalEntries = 0; + foreach (var taPlaylist in taPlaylists) + { + totalEntries += taPlaylist.Entries.Count; + } + + return totalEntries; + } + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + progress.Report(0); + if (Plugin.Instance!.Configuration.TAJFSync) + { + var start = DateTime.Now; + _logger.LogInformation("Starting TubeArchivist->Jellyfin playlists synchronization."); + var taApi = TubeArchivistApi.GetInstance(); + _logger.LogInformation("Getting TubeArchivist playlists"); + var taPlaylists = await taApi.GetPlaylists().ConfigureAwait(true); + _logger.LogInformation("Received playlists:\n{Playlists}", taPlaylists); + if (taPlaylists != null) + { + var totalVideosCount = CountTotalVideos(taPlaylists); + var processedVideosCount = 0; + foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray()) + { + var user = _userManager.GetUserByName(jfUsername); + if (user == null) + { + _logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found"); + continue; + } + + var userPlaylists = _playlistManager.GetPlaylists(user.Id); + + foreach (var taPlaylist in taPlaylists) + { + var playlistName = taPlaylist.Type == PlaylistType.Regular ? $"{taPlaylist.Name} - {taPlaylist.Channel} ({taPlaylist.Id})" : $"{taPlaylist.Name} ({taPlaylist.Id})"; + var userPlaylist = userPlaylists.Where(up => up.Name == playlistName).FirstOrDefault(); + + var jfEntryIds = new List(); + var currentPlaylistVideos = 0; + foreach (var taEntry in taPlaylist.Entries) + { + currentPlaylistVideos++; + var taEntryStr = $"{taEntry.Uploader} - {taEntry.Title} ({taEntry.YoutubeId}) in playlist {playlistName}"; + if (!taEntry.IsDownloaded) + { + _logger.LogInformation("The entry {TAEntry} was skipped because has not been downloaded by TubeArchivist", taEntryStr); + continue; + } + + var jfEntry = _libraryManager.GetItemList(new InternalItemsQuery + { + HasAnyProviderId = new Dictionary() + { + { + Constants.ProviderName, taEntry.YoutubeId + } + } + }).FirstOrDefault(); + + if (jfEntry != null) + { + jfEntryIds.Add(jfEntry.Id); + } + else + { + _logger.LogWarning("The relative video for {TAEntry} was not found on Jellyfin", taEntryStr); + } + } + + if (userPlaylist != null) + { + var updateRequest = new PlaylistUpdateRequest + { + Id = userPlaylist.Id, + UserId = user.Id, + Name = playlistName, + Ids = jfEntryIds + }; + + var result = _playlistManager.UpdatePlaylist(updateRequest); + _logger.LogInformation("Updated playlist {PlaylistName} with id {Id}", playlistName, result.Id); + } + else + { + var creationRequest = new PlaylistCreationRequest + { + Name = playlistName, + ItemIdList = jfEntryIds, + MediaType = MediaType.Video, + UserId = user.Id + }; + + var result = await _playlistManager.CreatePlaylist(creationRequest).ConfigureAwait(true); + _logger.LogInformation("Created playlist {PlaylistName} with id {Id}", playlistName, result.Id); + } + + processedVideosCount += currentPlaylistVideos; + progress.Report(processedVideosCount * 100 / totalVideosCount); + } + } + } + + _logger.LogInformation("Time elapsed: {Time}", DateTime.Now - start); + } + else + { + _logger.LogInformation("TubeArchivist->Jellyfin playback synchronization is currently disabled."); + } + + progress.Report(100); + } + + /// + public IEnumerable GetDefaultTriggers() + { + return + [ + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFTaskInterval).Ticks + }, + ]; + } + } +} From 4317fdbf63b0a8b49a27bfef2ade7558366b9517 Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Sat, 31 May 2025 17:20:37 +0200 Subject: [PATCH 04/15] Fix log message and comment --- .../Tasks/TAToJellyfinPlaylistsSyncTask.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs index 17a86ed..edf35b8 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs @@ -19,7 +19,7 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks { /// - /// Task to sync TubeArchivist playback progresses to Jellyfin. + /// Task to sync TubeArchivist playlists to Jellyfin. /// public class TAToJellyfinPlaylistsSyncTask : IScheduledTask { @@ -167,7 +167,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks } else { - _logger.LogInformation("TubeArchivist->Jellyfin playback synchronization is currently disabled."); + _logger.LogInformation("TubeArchivist->Jellyfin playlists synchronization is currently disabled."); } progress.Report(100); From 3cb340c61a182a6edcf505c3485b7122f23b264a Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Sun, 15 Jun 2025 22:51:46 +0200 Subject: [PATCH 05/15] Create entities for TA playlists creation and edit --- .../Playlist/CustomPlaylistCreation.cs | 25 +++++++ .../Playlist/CustomPlaylistEntryAction.cs | 72 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistCreation.cs create mode 100644 Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistEntryAction.cs diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistCreation.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistCreation.cs new file mode 100644 index 0000000..5c9d848 --- /dev/null +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistCreation.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; + +namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist +{ + /// + /// A class representing TubeArchivist API custom playlist creation request. + /// + public class CustomPlaylistCreation + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the new custom playlist to create. + public CustomPlaylistCreation(string playlistName) + { + this.PlaylistName = playlistName; + } + + /// + /// Gets or sets the name of the new custom playlist to create. + /// + [JsonProperty(PropertyName = "playlist_name")] + public string PlaylistName { get; set; } + } +} diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistEntryAction.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistEntryAction.cs new file mode 100644 index 0000000..da879b4 --- /dev/null +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Playlist/CustomPlaylistEntryAction.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using Newtonsoft.Json; + +namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist +{ + /// + /// Enum representing the possible actions to execute on a TubeArchivist custom playlist video. + /// + public enum CustomPlaylistAction + { + /// + /// Create a new custom playlist video. + /// + Create, + + /// + /// Removea custom playlist video. + /// + Remove, + + /// + /// Move a custom playlist video to the top of the list. + /// + Top, + + /// + /// Move a custom playlist video to the bottom of the list. + /// + Bottom, + + /// + /// Move a custom playlist video up in the list. + /// + Up, + + /// + /// Move a custom playlist video down in the list. + /// + Down + } + + /// + /// A class representing TubeArchivist API custom playlist entry operation request. + /// + public class CustomPlaylistEntryAction + { + /// + /// Initializes a new instance of the class. + /// + /// The standard action to create a new entry. + /// The entry YoutubeId. + public CustomPlaylistEntryAction( + CustomPlaylistAction action, + string videoId) + { + this.Action = action.ToString().ToLower(CultureInfo.CurrentCulture); + this.VideoId = videoId; + } + + /// + /// Gets or sets the standard action to create a new entry. + /// + [JsonProperty(PropertyName = "action")] + public string Action { get; set; } + + /// + /// Gets or sets the entry YoutubeId. + /// + [JsonProperty(PropertyName = "video_id")] + public string VideoId { get; set; } + } +} From f3a9050311dcbfea81a67f4154b79307844e1085 Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Sun, 15 Jun 2025 22:52:42 +0200 Subject: [PATCH 06/15] Implement TA playlists management API calls --- .../TubeArchivist/TubeArchivistApi.cs | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs index f9585f3..30929d2 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; using Jellyfin.Plugin.TubeArchivistMetadata.Utilities; +using MediaBrowser.Model.Playlists; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -151,7 +152,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist } /// - /// Send video playback progress to TubeArchivist. + /// Sends video playback progress to TubeArchivist. /// /// Video id. /// Progress in seconds. @@ -168,7 +169,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist } /// - /// Send video playback progress to TubeArchivist. + /// Sends video playback progress to TubeArchivist. /// /// Video id. /// Video playback progress data. @@ -187,7 +188,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist } /// - /// Set video/channel/playlist as watched on TubeArchivist. + /// Sets video/channel/playlist as watched on TubeArchivist. /// /// Video/channel/playlist id. /// Whether the item has been watched or not. @@ -231,5 +232,49 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist return playlists?.Data; } + + /// + /// Creates a new custom playlist. + /// + /// Playlist creation request. + /// The created . + public async Task CreateCustomPlaylist(CustomPlaylistCreation creationRequest) + { + Playlist? playlist = null; + var customPlaylistEndpoint = $"/api/playlist/custom/"; + var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + customPlaylistEndpoint)); + var body = JsonConvert.SerializeObject(creationRequest); + _logger.LogInformation("{Message}", body); + + var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true); + _logger.LogInformation("POST {Message}", url + ": " + response.StatusCode); + + if (response.IsSuccessStatusCode) + { + string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true); + playlist = JsonConvert.DeserializeObject(rawData); + } + + return playlist; + } + + /// + /// Creates a new custom playlist. + /// + /// Playlist id. + /// Playlist creation request. + /// The response . + public async Task CustomPlaylistEntryAction(string playlistId, CustomPlaylistEntryAction entryAction) + { + var customPlaylistEndpoint = $"/api/playlist/custom/"; + var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + customPlaylistEndpoint + playlistId)); + var body = JsonConvert.SerializeObject(entryAction); + _logger.LogDebug("CustomPlaylistEntryAction body: {Message}", body); + + var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true); + _logger.LogDebug("Response code: {Message}", response.StatusCode); + + return response.StatusCode; + } } } From bff699fc3f6850f91be78975f00d3b786b1288dd Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Sun, 15 Jun 2025 22:53:17 +0200 Subject: [PATCH 07/15] Implement JF-> TA playlist sync scheduled task --- .../JFToTubeArchivistPlaylistsSyncTask.cs | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs 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; } + } + } +} From 8e4194f56b06379bec50c76cf6a573ca45dfc095 Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Sun, 15 Jun 2025 22:54:30 +0200 Subject: [PATCH 08/15] Schedule playlists tasks --- .../Plugin.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs index 51d006b..8a53487 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs @@ -72,7 +72,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata var taToJellyfinProgressSyncTask = new TAToJellyfinProgressSyncTask(logger, libraryManager, userManager, userDataManager); var taToJellyfinPlaylistsSyncTask = new TAToJellyfinPlaylistsSyncTask(logger, libraryManager, userManager, playlistManager); - var jfToTubearchivistProgressSyncTask = new JFToTubearchivistProgressSyncTask(logger, libraryManager, userManager, userDataManager); + var jfToTubeArchivistProgressSyncTask = new JFToTubeArchivistProgressSyncTask(logger, libraryManager, userManager, userDataManager); + var jfToTubeArchivistPlaylistsSyncTask = new JFToTubeArchivistPlaylistsSyncTask(logger, libraryManager, userManager, playlistManager); var isTAJFTaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(taToJellyfinProgressSyncTask.Name, StringComparison.Ordinal)); if (Instance!.Configuration.TAJFSync && !isTAJFTaskPresent) { @@ -81,16 +82,20 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata taskManager.Execute(); logger.LogInformation("Queueing task {TaskName}.", taToJellyfinPlaylistsSyncTask.Name); - taskManager.AddTasks([taToJellyfinProgressSyncTask]); - taskManager.Execute(); + taskManager.AddTasks([taToJellyfinPlaylistsSyncTask]); + taskManager.Execute(); } - var isJFTATaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(jfToTubearchivistProgressSyncTask.Name, StringComparison.Ordinal)); + var isJFTATaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(jfToTubeArchivistProgressSyncTask.Name, StringComparison.Ordinal)); if (Instance!.Configuration.JFTASync && !isJFTATaskPresent) { - logger.LogInformation("Queueing task {TaskName}.", jfToTubearchivistProgressSyncTask.Name); - taskManager.AddTasks([jfToTubearchivistProgressSyncTask]); - taskManager.Execute(); + logger.LogInformation("Queueing task {TaskName}.", jfToTubeArchivistProgressSyncTask.Name); + taskManager.AddTasks([jfToTubeArchivistProgressSyncTask]); + taskManager.Execute(); + + logger.LogInformation("Queueing task {TaskName}.", jfToTubeArchivistPlaylistsSyncTask.Name); + taskManager.AddTasks([jfToTubeArchivistPlaylistsSyncTask]); + taskManager.Execute(); } logger.LogInformation("{Message}", "Collection display name: " + Instance?.Configuration.CollectionTitle); From 532438d11069aa5cd2413d2b73789c4835a4501b Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Mon, 23 Jun 2025 14:58:50 +0200 Subject: [PATCH 09/15] Fix case --- .../Tasks/JFToTubeArchivistProgressSyncTask.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs index 01d9d33..60eec17 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs @@ -20,7 +20,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks /// /// Task to sync Jellyfin playback progresses to TubeArchivist. /// - public class JFToTubearchivistProgressSyncTask : IScheduledTask + public class JFToTubeArchivistProgressSyncTask : IScheduledTask { private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; @@ -28,13 +28,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks private readonly IUserDataManager _userDataManager; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Logger. /// Library manager. /// User manager. /// User data manager. - public JFToTubearchivistProgressSyncTask(ILogger logger, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager) + public JFToTubeArchivistProgressSyncTask(ILogger logger, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager) { _logger = logger; _libraryManager = libraryManager; @@ -153,7 +153,6 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks foreach (Episode video in videos) { var videoYTId = Utils.GetVideoNameFromPath(video.Path); - _logger.LogInformation("{VideoYtId}", videoYTId); HttpStatusCode statusCode; if (!isChannelCheckedForWatched && channel.IsPlayed(user)) @@ -181,7 +180,6 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks _logger.LogCritical("{Message}", $"POST /watched returned {statusCode} for video {video.Name} ({videoYTId}) with wacthed status {isVideoPlayed}"); } - _logger.LogInformation("{Message}", isVideoPlayed); if (!isVideoPlayed) { var playbackProgress = _userDataManager.GetUserData(user, video)?.PlaybackPositionTicks / TimeSpan.TicksPerSecond; From ff99517d1235d2b5afc5af63bd2f3ea992994725 Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Mon, 23 Jun 2025 14:59:41 +0200 Subject: [PATCH 10/15] Format file --- .vscode/settings.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b4c529b..1d8c9ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,16 @@ { // jellyfinDir : The directory of the cloned jellyfin server project // This needs to be built once before it can be used - "jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server", + "jellyfinDir": "${workspaceFolder}/../jellyfin/Jellyfin.Server", // jellyfinWebDir : The directory of the cloned jellyfin-web project // This needs to be built once before it can be used - "jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web", + "jellyfinWebDir": "${workspaceFolder}/../jellyfin-web", // jellyfinDataDir : the root data directory for a running jellyfin instance // This is where jellyfin stores its configs, plugins, metadata etc // This is platform specific by default, but on Windows defaults to // ${env:LOCALAPPDATA}/jellyfin - "jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin", + "jellyfinDataDir": "${env:LOCALAPPDATA}/jellyfin", // The name of the plugin - "pluginName" : "Jellyfin.Plugin.Template", -} + "pluginName": "Jellyfin.Plugin.Template", + "git.ignoreLimitWarning": true, +} \ No newline at end of file From 46dd4fcf5b0a0d515fa525107eda6dc0e738a228 Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Tue, 1 Jul 2025 22:16:47 +0200 Subject: [PATCH 11/15] Implement playlists deletion --- .../JFToTubeArchivistPlaylistsSyncTask.cs | 60 +++++++++---------- .../Tasks/TAToJellyfinPlaylistsSyncTask.cs | 21 +++++-- .../TubeArchivist/TubeArchivistApi.cs | 15 +++++ .../Utils/Utils.cs | 42 +++++++++++-- 4 files changed, 100 insertions(+), 38 deletions(-) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs index d0ea2d8..af2f6ae 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs @@ -7,8 +7,8 @@ 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.Database.Implementations.Entities; using Jellyfin.Extensions; using Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist; using Jellyfin.Plugin.TubeArchivistMetadata.Utilities; @@ -29,9 +29,6 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks /// 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; @@ -69,7 +66,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks var items = new Dictionary>(); foreach (var playlist in playlists) { - items[playlist.Id] = playlist.GetChildren(user, true, new InternalItemsQuery()); + items[playlist.Id] = new List(playlist.GetChildren(user, true, new InternalItemsQuery())); } return items; @@ -86,26 +83,6 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks 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); @@ -153,10 +130,21 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks var processedVideosCount = 0; _logger.LogInformation("Found a total of {PlaylistsCount} playlists to analyze with a total of {VideosCount} videos", userPlaylists.Count, totalVideosCount); + // TODO: Implement playlist deletion based on a configuration switch + if (taPlaylists != null && true) + { + var taPlaylistsToDelete = taPlaylists.Where(tp => !userPlaylists.Select(up => Utils.GetTAPlaylistIdFromName(up.Name)).Contains(tp.Id)); + foreach (var taPlaylistToDelete in taPlaylistsToDelete) + { + _logger.LogInformation("Deleting TubeArchivist playlist {PlaylistName} ({PlaylistId})", taPlaylistToDelete.Name, taPlaylistToDelete.Id); + await taApi.DeletePlaylist(taPlaylistToDelete.Id).ConfigureAwait(true); + } + } + foreach (var jfPlaylist in userPlaylists) { _logger.LogInformation("Analyzing playlist {PlaylistName}...", jfPlaylist.Name); - var taPlaylistId = GetTAPlaylistIdFromName(jfPlaylist.Name); + var taPlaylistId = Utils.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); @@ -193,6 +181,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks _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); + taPlaylist.Entries.Insert(i, new PlaylistEntry(jfItems[i].ProviderIds[Constants.ProviderName], jfItems[i].Name, jfItems[i].Studios[0], i, true)); } else { @@ -210,17 +199,28 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks 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); } + + var temp = taPlaylist.Entries[position]; + taPlaylist.Entries.RemoveAt(position); + taPlaylist.Entries.Insert(i, temp); } itemsToProcess.Add(action); } // Add videos to delete from the TA playlist - itemsToProcess.AddRange(taPlaylist.Entries + var itemsToDelete = taPlaylist.Entries .Where(e => !itemsToProcess .Select(i => i.YoutubeId) .Contains(e.YoutubeId)) - .Select(e => new PlaylistItemAction(e.YoutubeId, e.Title))); + .ToList(); + foreach (var item in itemsToDelete) + { + _logger.LogDebug("Video {VideoName} not found in JF playlist, marking for deletion", item.Title); + var ret = taPlaylist.Entries.Remove(item); + } + + itemsToProcess.AddRange(itemsToDelete.Select(e => new PlaylistItemAction(e.YoutubeId, e.Title))); foreach (var item in itemsToProcess) { @@ -268,7 +268,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks // 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); + var playlistName = Utils.GetTAPlaylistNameFromName(jfPlaylist.Name); if (string.IsNullOrEmpty(playlistName)) { playlistName = jfPlaylist.Name; @@ -379,7 +379,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks [ new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFTaskInterval).Ticks }, ]; diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs index edf35b8..16a78e0 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs @@ -91,7 +91,18 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks continue; } - var userPlaylists = _playlistManager.GetPlaylists(user.Id); + var userPlaylists = _playlistManager.GetPlaylists(user.Id).ToList(); + + // TODO: Implement playlist deletion based on a configuration switch + if (true) + { + var jfPlaylistsToDelete = userPlaylists.Where(up => !taPlaylists.Select(tp => tp.Id).Contains(Utils.GetTAPlaylistIdFromName(up.Name))); + foreach (var jfPlaylistToDelete in jfPlaylistsToDelete) + { + _logger.LogInformation("Deleting Jellyfin playlist {PlaylistName}", jfPlaylistToDelete.Name); + _libraryManager.DeleteItem(jfPlaylistToDelete, new DeleteOptions()); + } + } foreach (var taPlaylist in taPlaylists) { @@ -110,7 +121,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks continue; } - var jfEntry = _libraryManager.GetItemList(new InternalItemsQuery + var entries = _libraryManager.GetItemList(new InternalItemsQuery { HasAnyProviderId = new Dictionary() { @@ -118,7 +129,9 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks Constants.ProviderName, taEntry.YoutubeId } } - }).FirstOrDefault(); + }); + + var jfEntry = entries.Count > 0 ? entries[0] : null; if (jfEntry != null) { @@ -180,7 +193,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks [ new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFTaskInterval).Ticks }, ]; diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs index 30929d2..c05bf54 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs @@ -276,5 +276,20 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist return response.StatusCode; } + + /// + /// Deletes a playlist. + /// + /// Playlist id. + /// Whether the playlist has been deleted successfully or not. + public async Task DeletePlaylist(string playlistId) + { + var deletePlaylistEndpoint = $"/api/playlist/"; + var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + deletePlaylistEndpoint + playlistId)); + var response = await client.DeleteAsync(url).ConfigureAwait(true); + _logger.LogDebug("Response code: {Message}", response.StatusCode); + + return response.StatusCode == HttpStatusCode.NoContent; + } } } diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs index e4a1876..8263ff7 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs @@ -9,8 +9,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities /// public static class Utils { + private const string TAPlaylistIdRegex = @"^(.*)\((.*)\)$"; + private const string YTTAPlaylistNameFormatRegex = @"^(.*)\s\-\s(.*)\s\((.*)\)$"; + private const string TAPlaylistNameFormatRegex = @"^(.*)\s\((.*)\)$"; + /// - /// Sanitize the given URL. + /// Sanitizes the given URL. /// /// An URL string. /// The URL string without spaces, doubled slashes and with a trailing slash. @@ -35,7 +39,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities } /// - /// Format episodes and series descriptions replacing newlines with br tags. + /// Formats episodes and series descriptions replacing newlines with br tags. /// /// String to format. /// A string with \n replaced by br tags. @@ -57,7 +61,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities } /// - /// Get video name from file path on the disk. + /// Gets video name from file path on the disk. /// /// File path on disk. /// The video name. @@ -67,7 +71,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities } /// - /// Get channel name from directory path on the disk. + /// Gets channel name from directory path on the disk. /// /// Directory path on disk. /// The channel name. @@ -90,5 +94,35 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities return '/'; // Unix directory separator } } + + /// + /// Gets the TubeArchivist playlist id from Jellyfin playlist name. + /// + /// The Jellyfin playlist name. + /// The TubeArchvist playlist id. + public static string? GetTAPlaylistIdFromName(string playlistName) + { + var regex = new Regex(TAPlaylistIdRegex); + return regex.Match(playlistName).Groups[2].ToString(); + } + + /// + /// Gets the TubeArchivist playlist name from Jellyfin playlist name. + /// + /// The Jellyfin playlist name. + /// The TubeArchivist playlist name. + public static 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; + } } } From d386f79dfd6b16d1f412d8be4f09345ddc3d13bc Mon Sep 17 00:00:00 2001 From: DarkFighterLuke Date: Thu, 10 Jul 2025 22:32:22 +0200 Subject: [PATCH 12/15] Add configuration options --- .../Configuration/PluginConfiguration.cs | 50 +++++++-- .../Configuration/configPage.html | 102 ++++++++++++++---- .../Plugin.cs | 8 +- .../JFToTubeArchivistPlaylistsSyncTask.cs | 7 +- .../JFToTubeArchivistProgressSyncTask.cs | 2 +- .../Tasks/TAToJellyfinPlaylistsSyncTask.cs | 7 +- .../Tasks/TAToJellyfinProgressSyncTask.cs | 4 +- 7 files changed, 139 insertions(+), 41 deletions(-) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs index e946fba..85befd0 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs @@ -36,11 +36,17 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration _tubeArchivistUrl = string.Empty; _tubeArchivistApiKey = string.Empty; MaxDescriptionLength = 500; - JFTASync = false; + JFTAProgressSync = false; JFUsernameFrom = string.Empty; - TAJFSync = false; + TAJFProgressSync = false; + JFTAPlaylistsSync = false; + JFTAPlaylistsDelete = false; + TAJFPlaylistsSync = false; + TAJFPlaylistsDelete = false; _jfUsernamesTo = new HashSet(); - TAJFTaskInterval = 1; + TAJFProgressTaskInterval = 60; + JFTAPlaylistsSyncTaskInterval = 60; + TAJFPlaylistsSyncTaskInterval = 60; } /// @@ -100,12 +106,32 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration /// /// Gets or sets a value indicating whether to enable TA->JF playback progress synchronization. /// - public bool TAJFSync { get; set; } + public bool TAJFProgressSync { get; set; } /// /// Gets or sets a value indicating whether to enable JF->TA playback progress synchronization. /// - public bool JFTASync { get; set; } + public bool JFTAProgressSync { get; set; } + + /// + /// Gets or sets a value indicating whether to enable JF->TA playlists synchronization. + /// + public bool JFTAPlaylistsSync { get; set; } + + /// + /// Gets or sets a value indicating whether to delete playlists from TA when not found on JF. + /// + public bool JFTAPlaylistsDelete { get; set; } + + /// + /// Gets or sets a value indicating whether to enable TA->JF playlists synchronization. + /// + public bool TAJFPlaylistsSync { get; set; } + + /// + /// Gets or sets a value indicating whether to delete playlists from JF when not found on TA. + /// + public bool TAJFPlaylistsDelete { get; set; } /// /// Gets or sets the playback progress owner Jellyfin username to synchronize data to TubeArchivist. @@ -146,7 +172,19 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration /// Gets or sets the interval in seconds at which the TubeArchivist to Jellyfin playback progress synchronization task should run. /// It requires Jellyfin server restart to take effect. /// - public int TAJFTaskInterval { get; set; } + public int TAJFProgressTaskInterval { get; set; } + + /// + /// Gets or sets the interval in seconds at which the Jellyfin to TubeArchivist playlists synchronization task should run. + /// It requires Jellyfin server restart to take effect. + /// + public int JFTAPlaylistsSyncTaskInterval { get; set; } + + /// + /// Gets or sets the interval in seconds at which the TubeArchivist to Jellyfin playlists synchronization task should run. + /// It requires Jellyfin server restart to take effect. + /// + public int TAJFPlaylistsSyncTaskInterval { get; set; } /// /// Gets the playback progress owners Jellyfin usernames to synchronize data from TubeArchivist. diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/configPage.html b/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/configPage.html index d4e07d5..86f016d 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/configPage.html +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/configPage.html @@ -42,28 +42,43 @@ and episodes
-

Playback synchronization

+

Synchronization

-
- +
+

Jellyfin to TubeArchivist

+ from -
This is the Jellyfin username to synchronize data from on + placeholder="Username" /> +
This is the Jellyfin username to synchronize data to TubeArchivist
-
+
+
+ +
+ +
+ +
+
+
+

TubeArchivist to Jellyfin

+
@@ -72,13 +87,52 @@
These are the (comma separated) Jellyfin usernames to synchronize data from TubeArchivist
+
+ +
+
+
+ +
+ +
+ +
+
+
+

Tasks intervals

+
-
+
+ + +
This is the Jellyfin to TubeArchivist playlists + synchronization interval in seconds (Jellyfin restart required).
+
+
+ + +
This is the TubeArchivist to Jellyfin playlists + synchronization interval in seconds (Jellyfin restart required).
+