-
-
- Synchronize Jellyfin playback progress to TubeArchivist
-
+
+
Jellyfin to TubeArchivist
This is the numbering scheme for episodes in Jellyfin (IndexNumber).
Default is the same as no numbering which allows Jellyfin to use the fallback numbering scheme.
+
+
@@ -112,12 +165,20 @@
document.querySelector('#TubeArchivistUrl').value = config.TubeArchivistUrl;
document.querySelector('#TubeArchivistApiKey').value = config.TubeArchivistApiKey;
document.querySelector('#MaxDescriptionLength').value = config.MaxDescriptionLength;
- document.querySelector('#JFTASync').checked = config.JFTASync;
+ document.querySelector('#JFTAProgressSync').checked = config.JFTAProgressSync;
document.querySelector('#JFUsernamesTo').value = config.JFUsernamesTo;
- document.querySelector('#TAJFSync').checked = config.TAJFSync;
+ document.querySelector('#TAJFProgressSync').checked = config.TAJFProgressSync;
+ document.querySelector('#JFTAPlaylistsSync').checked = config.JFTAPlaylistsSync;
+ document.querySelector('#TAJFPlaylistsSync').checked = config.TAJFPlaylistsSync;
document.querySelector('#JFUsernameFrom').value = config.JFUsernameFrom;
+<<<<<<< HEAD
document.querySelector('#TAJFTaskInterval').value = config.TAJFTaskInterval;
document.querySelector('#EpisodeNumberingScheme').value = config.EpisodeNumberingScheme;
+=======
+ document.querySelector('#TAJFProgressTaskInterval').value = config.TAJFProgressTaskInterval;
+ document.querySelector('#JFTAPlaylistsSyncTaskInterval').value = config.JFTAPlaylistsSyncTaskInterval;
+ document.querySelector('#TAJFPlaylistsSyncTaskInterval').value = config.TAJFPlaylistsSyncTaskInterval;
+>>>>>>> parent/master
Dashboard.hideLoadingMsg();
});
});
@@ -130,12 +191,20 @@
config.TubeArchivistUrl = document.querySelector('#TubeArchivistUrl').value;
config.TubeArchivistApiKey = document.querySelector('#TubeArchivistApiKey').value;
config.MaxDescriptionLength = document.querySelector('#MaxDescriptionLength').value;
- config.JFTASync = document.querySelector('#JFTASync').checked;
+ config.JFTAProgressSync = document.querySelector('#JFTAProgressSync').checked;
config.JFUsernamesTo = document.querySelector('#JFUsernamesTo').value;
- config.TAJFSync = document.querySelector('#TAJFSync').checked;
+ config.TAJFProgressSync = document.querySelector('#TAJFProgressSync').checked;
+ config.JFTAPlaylistsSync = document.querySelector('#JFTAPlaylistsSync').checked;
+ config.TAJFPlaylistsSync = document.querySelector('#TAJFPlaylistsSync').checked;
config.JFUsernameFrom = document.querySelector('#JFUsernameFrom').value;
+<<<<<<< HEAD
config.TAJFTaskInterval = document.querySelector('#TAJFTaskInterval').value;
config.EpisodeNumberingScheme = document.querySelector('#EpisodeNumberingScheme').value;
+=======
+ config.TAJFProgressTaskInterval = document.querySelector('#TAJFProgressTaskInterval').value;
+ config.JFTAPlaylistsSyncTaskInterval = document.querySelector('#JFTAPlaylistsSyncTaskInterval').value;
+ config.TAJFPlaylistsSyncTaskInterval = document.querySelector('#TAJFPlaylistsSyncTaskInterval').value;
+>>>>>>> parent/master
ApiClient.updatePluginConfiguration(TAMetadataConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Jellyfin.Plugin.TubeArchivistMetadata.csproj b/Jellyfin.Plugin.TubeArchivistMetadata/Jellyfin.Plugin.TubeArchivistMetadata.csproj
index 96bc34c..233879e 100644
--- a/Jellyfin.Plugin.TubeArchivistMetadata/Jellyfin.Plugin.TubeArchivistMetadata.csproj
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/Jellyfin.Plugin.TubeArchivistMetadata.csproj
@@ -1,7 +1,7 @@
- net8.0
+ net9.0
Jellyfin.Plugin.TubeArchivistMetadata
true
true
@@ -11,8 +11,8 @@
-
-
+
+
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs
index c3d479a..7902298 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;
@@ -40,6 +41,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,
@@ -47,7 +49,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
ISessionManager sessionManager,
ILibraryManager libraryManager,
IUserManager userManager,
- IUserDataManager userDataManager)
+ IUserDataManager userDataManager,
+ IPlaylistManager playlistManager)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
@@ -161,7 +164,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
var topParent = eventArgs.Item.GetTopParent();
if (
- Instance!.Configuration.JFTASync &&
+ Instance!.Configuration.JFTAProgressSync &&
eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)) &&
eventArgs.PlaybackPositionTicks.HasValue &&
string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
@@ -188,13 +191,20 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
var topParent = eventArgs.Item.GetTopParent();
var user = _userManager.GetUserById(eventArgs.UserId);
if (
- Configuration.JFTASync &&
+ Configuration.JFTAProgressSync &&
user != null &&
Configuration.GetJFUsernamesToArray().Contains(user!.Username) &&
string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
)
{
- var isPlayed = eventArgs.Item.IsPlayed(user);
+ Logger.LogError("OnWatchedStatusChange callback called without user id for item {ItemName}", eventArgs.Item.Name);
+ return;
+ }
+
+ if (Configuration.JFTAProgressSync && user != null && Configuration.GetJFUsernamesToArray().Contains(user!.Username))
+ {
+ var userItemData = _userDataManager.GetUserData(user, eventArgs.Item);
+ var isPlayed = eventArgs.Item.IsPlayed(user, userItemData);
Logger.LogDebug("User {UserId} changed watched status to {Status} for the item {ItemName}", eventArgs.UserId, isPlayed, eventArgs.Item.Name);
string itemYTId;
try
@@ -228,12 +238,15 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
if (eventArgs.Item is Episode)
{
- var progress = _userDataManager.GetUserData(user, eventArgs.Item).PlaybackPositionTicks / TimeSpan.TicksPerSecond;
- var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
- statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress).ConfigureAwait(true);
- if (statusCode != System.Net.HttpStatusCode.OK)
+ var progress = _userDataManager.GetUserData(user, eventArgs.Item)?.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
+ if (progress != null)
{
- Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
+ var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
+ statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress.Value).ConfigureAwait(true);
+ if (statusCode != System.Net.HttpStatusCode.OK)
+ {
+ Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
+ }
}
}
}
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs
new file mode 100644
index 0000000..16b05a0
--- /dev/null
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistPlaylistsSyncTask.cs
@@ -0,0 +1,437 @@
+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.Enums;
+using Jellyfin.Database.Implementations.Entities;
+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 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] = new List(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 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.JFTAPlaylistsSync)
+ {
+ 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);
+ if (taPlaylists != null && Plugin.Instance!.Configuration.JFTAPlaylistsDelete)
+ {
+ 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 = 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);
+ 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);
+ taPlaylist.Entries.Insert(i, new PlaylistEntry(jfItems[i].ProviderIds[Constants.ProviderName], jfItems[i].Name, jfItems[i].Studios[0], i, true));
+ }
+ 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);
+ }
+
+ 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
+ var itemsToDelete = taPlaylist.Entries
+ .Where(e => !itemsToProcess
+ .Select(i => i.YoutubeId)
+ .Contains(e.YoutubeId))
+ .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)
+ {
+ _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 = Utils.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 = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFProgressTaskInterval).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; }
+ }
+ }
+}
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs
index d47bb04..0126395 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;
@@ -58,7 +58,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken)
{
progress.Report(0);
- if (Plugin.Instance!.Configuration.JFTASync)
+ if (Plugin.Instance!.Configuration.JFTAProgressSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting Jellyfin->TubeArchivist playback progresses synchronization.");
@@ -72,11 +72,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
return;
}
- var collectionItem = _libraryManager.GetItemList(new InternalItemsQuery
+ var items = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
- }).FirstOrDefault();
+ });
+
+ var collectionItem = items.Count > 0 ? items[0] : null;
if (collectionItem == null)
{
@@ -153,10 +155,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
var videoYTId = Utils.GetVideoNameFromPath(video.Path);
_logger.LogDebug("Current video extracted YouTube id: {VideoYtId}", videoYTId);
HttpStatusCode statusCode;
+ var userItemData = _userDataManager.GetUserData(user, channel);
- if (!isChannelCheckedForWatched && channel.IsPlayed(user))
+ if (!isChannelCheckedForWatched && channel.IsPlayed(user, userItemData))
{
- var isChannelPlayed = channel.IsPlayed(user);
+ var isChannelPlayed = channel.IsPlayed(user, userItemData);
statusCode = await taApi.SetWatchedStatus(channelYTId, isChannelPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
@@ -172,7 +175,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
if (!isChannelWatched)
{
- var isVideoPlayed = video.IsPlayed(user);
+ var isVideoPlayed = video.IsPlayed(user, userItemData);
statusCode = await taApi.SetWatchedStatus(videoYTId, isVideoPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
@@ -182,11 +185,14 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
_logger.LogDebug("{Message}", isVideoPlayed);
if (!isVideoPlayed)
{
- var playbackProgress = _userDataManager.GetUserData(user, video).PlaybackPositionTicks / TimeSpan.TicksPerSecond;
- statusCode = await taApi.SetProgress(videoYTId, playbackProgress).ConfigureAwait(true);
- if (statusCode != System.Net.HttpStatusCode.OK)
+ var playbackProgress = _userDataManager.GetUserData(user, video)?.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
+ if (playbackProgress != null)
{
- _logger.LogCritical("{Message}", $"POST /video/{videoYTId}/progress returned {statusCode} for video {video.Name} with progress {progress} seconds");
+ statusCode = await taApi.SetProgress(videoYTId, playbackProgress.Value).ConfigureAwait(true);
+ if (statusCode != System.Net.HttpStatusCode.OK)
+ {
+ _logger.LogCritical("{Message}", $"POST /video/{videoYTId}/progress returned {statusCode} for video {video.Name} with progress {progress} seconds");
+ }
}
}
}
@@ -215,7 +221,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
[
new TaskTriggerInfo
{
- Type = TaskTriggerInfo.TriggerStartup,
+ Type = TaskTriggerInfoType.StartupTrigger,
},
];
}
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs
new file mode 100644
index 0000000..a2eccba
--- /dev/null
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs
@@ -0,0 +1,201 @@
+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 playlists 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.TAJFPlaylistsSync)
+ {
+ 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).ToList();
+
+ if (Plugin.Instance!.Configuration.TAJFPlaylistsDelete)
+ {
+ 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)
+ {
+ 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 entries = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ HasAnyProviderId = new Dictionary()
+ {
+ {
+ Constants.ProviderName, taEntry.YoutubeId
+ }
+ }
+ });
+
+ var jfEntry = entries.Count > 0 ? entries[0] : null;
+
+ 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 playlists synchronization is currently disabled.");
+ }
+
+ progress.Report(100);
+ }
+
+ ///
+ public IEnumerable GetDefaultTriggers()
+ {
+ return
+ [
+ new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFProgressTaskInterval).Ticks
+ },
+ ];
+ }
+ }
+}
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinProgressSyncTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinProgressSyncTask.cs
index abcf3a7..ff3a7d3 100644
--- a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinProgressSyncTask.cs
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinProgressSyncTask.cs
@@ -57,7 +57,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken)
{
progress.Report(0);
- if (Plugin.Instance!.Configuration.TAJFSync)
+ if (Plugin.Instance!.Configuration.TAJFProgressSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting TubeArchivist->Jellyfin playback progresses synchronization.");
@@ -72,11 +72,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
continue;
}
- var collectionItem = _libraryManager.GetItemList(new InternalItemsQuery
+ var items = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
- }).FirstOrDefault();
+ });
+
+ var collectionItem = items.Count > 0 ? items[0] : null;
if (collectionItem == null)
{
@@ -127,11 +129,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
continue;
}
- var collectionItem = _libraryManager.GetItemList(new InternalItemsQuery
+ var items = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
- }).FirstOrDefault();
+ });
+
+ var collectionItem = items.Count > 0 ? items[0] : null;
if (collectionItem == null)
{
@@ -185,8 +189,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
}
_userDataManager.SaveUserData(user, video, userUpdateData, UserDataSaveReason.UpdateUserData);
- _logger.LogDebug("{Message}", $"Playback progress for video {video.Name} set to {userItemData.PlaybackPositionTicks / TimeSpan.TicksPerSecond} seconds for user {jfUsername}.");
- _logger.LogDebug("{Message}", $"Watched status for video {video.Name} set to {userItemData.Played} seconds for user {jfUsername}.");
+ _logger.LogInformation("{Message}", $"Playback progress for video {video.Name} set to {userItemData?.PlaybackPositionTicks / TimeSpan.TicksPerSecond} seconds for user {jfUsername}.");
+ _logger.LogInformation("{Message}", $"Watched status for video {video.Name} set to {userItemData?.Played} seconds for user {jfUsername}.");
processedVideosCount++;
progress.Report(processedVideosCount * 100 / videosCount);
@@ -214,8 +218,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
[
new TaskTriggerInfo
{
- Type = TaskTriggerInfo.TriggerInterval,
- IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFTaskInterval).Ticks
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFProgressTaskInterval).Ticks
},
];
}
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; }
+ }
+}
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; }
+ }
+}
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs
index fc10f6b..3b81f5d 100644
--- a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs
@@ -1,10 +1,12 @@
using System;
+using System.Collections.Generic;
using System.Data;
using System.Net;
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;
@@ -150,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.
@@ -167,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.
@@ -186,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.
@@ -201,5 +203,93 @@ 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;
+ }
+
+ ///
+ /// 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;
+ }
+
+ ///
+ /// 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 12add7a..7077d52 100644
--- a/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs
@@ -11,8 +11,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.
@@ -37,7 +41,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.
@@ -65,7 +69,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.
@@ -75,7 +79,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.
@@ -98,5 +102,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;
+ }
}
}
diff --git a/README.md b/README.md
index 4bf47b7..f947445 100644
--- a/README.md
+++ b/README.md
@@ -27,18 +27,23 @@ The plugin interacts with TubeArchivist APIs to fetch videos and channels metada
- Add images for videos (episodes), ie. thumb images
- Add images for channels (shows), ie. thumb, tvart and banner images
- Organize videos (episodes) by year (seasons)
+- Bidirectional playback progress synchronization
+- Bidirectional playlists synchronization
+
+> [!WARNING]
+> Enabling synchronization in both directions you can run in race conditions and unexpected results.
## Installation
### From official repository (recommended)
-1. Go to `Dashboard -> Plugins` and select the `Repositories` tab
+1. Go to `Dashboard -> Plugins` and click on the `Manage Repositories` button
2. Add a new repository with the following details:
- Repository name: `TubeArchivistMetadata`
- Repository URL: `https://github.com/tubearchivist/tubearchivist-jf-plugin/raw/master/manifest.json`
- 
+ 
-3. Go to the `Catalog` tab
-4. Find `TubeArchivistMetadata` in the `Metadata` section and install it
-
+3. Go back to the catalog
+4. Find `TubeArchivistMetadata` and install it
+
5. Restart Jellyfin to apply the changes
### From ZIP in GitHub releases
@@ -103,7 +108,7 @@ The options correlate with:
## Build
-1. To build this plugin you will need [.Net 8.x](https://dotnet.microsoft.com/download/dotnet/8.0).
+1. To build this plugin you will need [.Net 9.x](https://dotnet.microsoft.com/download/dotnet/9.0).
2. Build plugin with following command
```
diff --git a/build.yaml b/build.yaml
index 572b782..f97a326 100644
--- a/build.yaml
+++ b/build.yaml
@@ -2,9 +2,9 @@
name: "TubeArchivistMetadata"
guid: "dc97d0c6-28b0-4242-afb4-5833ae1b3715"
imageUrl: https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png
-version: "1.3.7.0"
-targetAbi: "10.10.0.0"
-framework: "net8.0"
+version: "1.4.1.0"
+targetAbi: "10.11.0.0"
+framework: "net9.0"
overview: "Metadata for your TubeArchivist library on Jellyfin"
description: >
TubeArchivistMetadata is a plugin that automatically manages
@@ -14,4 +14,4 @@ owner: "DarkFighterLuke"
artifacts:
- "Jellyfin.Plugin.TubeArchivistMetadata.dll"
changelog: >
- Fix JF->TA Sync
+ Introduce bidirectional playlists synchronization
diff --git a/manifest.json b/manifest.json
index 31cc5fa..b9a3dbb 100644
--- a/manifest.json
+++ b/manifest.json
@@ -8,6 +8,22 @@
"overview": "Metadata for your TubeArchivist library on Jellyfin",
"imageUrl": "https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png",
"versions": [
+ {
+ "version": "1.4.1.0",
+ "changelog": "Introduce bidirectional playlists synchronization\n",
+ "targetAbi": "10.11.0.0",
+ "sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.1/tubearchivistmetadata_1.4.1.0.zip",
+ "checksum": "2dc0b9140472a4f783f093a7ee425493",
+ "timestamp": "2025-10-22T07:00:27Z"
+ },
+ {
+ "version": "1.4.0.0",
+ "changelog": "Introduce bidirectional playlists synchronization\n",
+ "targetAbi": "10.11.0.0",
+ "sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.0/tubearchivistmetadata_1.4.0.0.zip",
+ "checksum": "4b81ab60022b532a64bb5eeb7be67dcc",
+ "timestamp": "2025-10-22T06:57:53Z"
+ },
{
"version": "1.3.7.0",
"changelog": "Fix JF->TA Sync\n",