Merge pull request #68 from tubearchivist/feature/playlists-sync

Playlists Sync
This commit is contained in:
DarkFighterLuke
2025-10-21 23:53:02 +02:00
committed by GitHub
17 changed files with 1199 additions and 52 deletions
+6 -5
View File
@@ -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,
}
+3 -3
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>1.4.0.0</Version>
<AssemblyVersion>1.4.0.0</AssemblyVersion>
<FileVersion>1.4.0.0</FileVersion>
<Version>1.4.1.0</Version>
<AssemblyVersion>1.4.1.0</AssemblyVersion>
<FileVersion>1.4.1.0</FileVersion>
</PropertyGroup>
</Project>
@@ -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<string>();
TAJFTaskInterval = 1;
TAJFProgressTaskInterval = 60;
JFTAPlaylistsSyncTaskInterval = 60;
TAJFPlaylistsSyncTaskInterval = 60;
}
/// <summary>
@@ -100,12 +106,32 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
/// <summary>
/// Gets or sets a value indicating whether to enable TA->JF playback progress synchronization.
/// </summary>
public bool TAJFSync { get; set; }
public bool TAJFProgressSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable JF->TA playback progress synchronization.
/// </summary>
public bool JFTASync { get; set; }
public bool JFTAProgressSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable JF->TA playlists synchronization.
/// </summary>
public bool JFTAPlaylistsSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to delete playlists from TA when not found on JF.
/// </summary>
public bool JFTAPlaylistsDelete { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable TA->JF playlists synchronization.
/// </summary>
public bool TAJFPlaylistsSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to delete playlists from JF when not found on TA.
/// </summary>
public bool TAJFPlaylistsDelete { get; set; }
/// <summary>
/// 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.
/// </summary>
public int TAJFTaskInterval { get; set; }
public int TAJFProgressTaskInterval { get; set; }
/// <summary>
/// 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.
/// </summary>
public int JFTAPlaylistsSyncTaskInterval { get; set; }
/// <summary>
/// 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.
/// </summary>
public int TAJFPlaylistsSyncTaskInterval { get; set; }
/// <summary>
/// Gets the playback progress owners Jellyfin usernames to synchronize data from TubeArchivist.
@@ -42,28 +42,43 @@
and episodes</div>
</div>
<div>
<h2>Playback synchronization</h2>
<h2>Synchronization</h2>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="JFTASync">
<input id="JFTASync" name="JFTASync" type="checkbox" is="emby-checkbox" />
<span>Synchronize Jellyfin playback progress to TubeArchivist</span>
</label>
<div>
<h3>Jellyfin to TubeArchivist</h3>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JFUsernameFrom">Jellyfin username to sync
playback status from</label>
from</label>
<input id="JFUsernameFrom" name="JFUsernameFrom" type="text" is="emby-input"
placeholder="Username1, username2, ..." />
<div class="fieldDescription">This is the Jellyfin username to synchronize data from on
placeholder="Username" />
<div class="fieldDescription">This is the Jellyfin username to synchronize data to
TubeArchivist</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFSync">
<input id="TAJFSync" name="TAJFSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize TubeArchivist playback progress to Jellyfin</span>
<label class="emby-checkbox-label" for="JFTAProgressSync">
<input id="JFTAProgressSync" name="JFTAProgressSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize Jellyfin playback progress to TubeArchivist</span>
</label>
</div>
<div style="display: flex; gap: 2em;">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="JFTAPlaylistsSync">
<input id="JFTAPlaylistsSync" name="JFTAPlaylistsSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize Jellyfin playlists to TubeArchivist</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="JFTAPlaylistsDelete">
<input id="JFTAPlaylistsDelete" name="JFTAPlaylistsDelete" type="checkbox" is="emby-checkbox" />
<span>Delete TubeArchivist playlists when no more present on Jellyfin</span>
</label>
</div>
</div>
<div>
<h3>TubeArchivist to Jellyfin</h3>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JFUsernamesTo">Jellyfin usernames to sync
playback status to</label>
@@ -72,13 +87,52 @@
<div class="fieldDescription">These are the (comma separated) Jellyfin usernames to synchronize
data from TubeArchivist</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFProgressSync">
<input id="TAJFProgressSync" name="TAJFProgressSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize TubeArchivist playback progress to Jellyfin</span>
</label>
</div>
<div style="display: flex; gap: 2em;">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFPlaylistsSync">
<input id="TAJFPlaylistsSync" name="TAJFPlaylistsSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize TubeArchivist playlists to Jellyfin</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFPlaylistsDelete">
<input id="TAJFPlaylistsDelete" name="TAJFPlaylistsDelete" type="checkbox" is="emby-checkbox" />
<span>Delete Jellyfin playlists when no more present on TubeArchivist</span>
</label>
</div>
</div>
<div>
<h2>Tasks intervals</h2>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TAJFTaskInterval">TubeArchivist to Jellyfin
<label class="inputLabel inputLabelUnfocused" for="TAJFProgressTaskInterval">TubeArchivist to Jellyfin
playback progress synchronization interval</label>
<input id="TAJFTaskInterval" name="TAJFTaskInterval" type="number" is="emby-input" min="1" />
<input id="TAJFProgressTaskInterval" name="TAJFProgressTaskInterval" type="number" is="emby-input" min="60" />
<div class="fieldDescription">This is the TubeArchivist to Jellyfin playback progress
synchronization interval in seconds (Jellyfin restart required).</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JFTAPlaylistsSyncTaskInterval">Jellyfin to TubeArchivist
playlists synchronization interval</label>
<input id="JFTAPlaylistsSyncTaskInterval" name="JFTAPlaylistsSyncTaskInterval" type="number" is="emby-input" min="60" />
<div class="fieldDescription">This is the Jellyfin to TubeArchivist playlists
synchronization interval in seconds (Jellyfin restart required).</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TAJFPlaylistsSyncTaskInterval">TubeArchivist to Jellyfin
playlists synchronization interval</label>
<input id="TAJFPlaylistsSyncTaskInterval" name="TAJFPlaylistsSyncTaskInterval" type="number" is="emby-input"
min="60" />
<div class="fieldDescription">This is the TubeArchivist to Jellyfin playlists
synchronization interval in seconds (Jellyfin restart required).</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
@@ -100,11 +154,15 @@
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;
document.querySelector('#TAJFTaskInterval').value = config.TAJFTaskInterval;
document.querySelector('#TAJFProgressTaskInterval').value = config.TAJFProgressTaskInterval;
document.querySelector('#JFTAPlaylistsSyncTaskInterval').value = config.JFTAPlaylistsSyncTaskInterval;
document.querySelector('#TAJFPlaylistsSyncTaskInterval').value = config.TAJFPlaylistsSyncTaskInterval;
Dashboard.hideLoadingMsg();
});
});
@@ -117,11 +175,15 @@
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;
config.TAJFTaskInterval = document.querySelector('#TAJFTaskInterval').value;
config.TAJFProgressTaskInterval = document.querySelector('#TAJFProgressTaskInterval').value;
config.JFTAPlaylistsSyncTaskInterval = document.querySelector('#JFTAPlaylistsSyncTaskInterval').value;
config.TAJFPlaylistsSyncTaskInterval = document.querySelector('#TAJFPlaylistsSyncTaskInterval').value;
ApiClient.updatePluginConfiguration(TAMetadataConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
@@ -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
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
/// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
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;
@@ -159,7 +162,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
return;
}
if (Instance!.Configuration.JFTASync && eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)))
if (Instance!.Configuration.JFTAProgressSync && eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)))
{
BaseItem? season = LibraryManager.GetItemById(eventArgs.Item.ParentId);
BaseItem? channel = LibraryManager.GetItemById(season!.ParentId);
@@ -193,7 +196,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
}
var userItemData = _userDataManager.GetUserData(user, eventArgs.Item);
if (Configuration.JFTASync && user != null && Configuration.GetJFUsernamesToArray().Contains(user!.Username))
if (Configuration.JFTAProgressSync && user != null && Configuration.GetJFUsernamesToArray().Contains(user!.Username))
{
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);
@@ -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
{
/// <summary>
/// Task to sync TubeArchivist playlists to Jellyfin.
/// </summary>
public class JFToTubeArchivistPlaylistsSyncTask : IScheduledTask
{
private readonly ILogger<Plugin> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IPlaylistManager _playlistManager;
/// <summary>
/// Initializes a new instance of the <see cref="JFToTubeArchivistPlaylistsSyncTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="userManager">User manager.</param>
/// <param name="playlistManager">Playlists manager.</param>
public JFToTubeArchivistPlaylistsSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IPlaylistManager playlistManager)
{
_logger = logger;
_libraryManager = libraryManager;
_userManager = userManager;
_playlistManager = playlistManager;
}
/// <inheritdoc/>
public string Name => "JFToTubeArchivistPlaylistsSyncTask";
/// <inheritdoc/>
public string Description => "This tasks syncs Jellyfin playlists to TubeArchivist";
/// <inheritdoc/>
public string Category => "TubeArchivistMetadata";
/// <inheritdoc/>
public string Key => "JFToTubeArchivistPlaylistsSyncTask";
private Dictionary<Guid, List<BaseItem>> GetAllVideos(IEnumerable<MediaBrowser.Controller.Playlists.Playlist> playlists, User user)
{
var items = new Dictionary<Guid, List<BaseItem>>();
foreach (var playlist in playlists)
{
items[playlist.Id] = new List<BaseItem>(playlist.GetChildren(user, true, new InternalItemsQuery()));
}
return items;
}
private int CountTotalVideos(Dictionary<Guid, List<BaseItem>> 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<PlaylistEntry> playlistEntries, int oldPosition, int newPosition)
{
var oldItem = playlistEntries[oldPosition];
playlistEntries.RemoveAt(oldPosition);
playlistEntries.Insert(newPosition, oldItem);
}
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> 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<PlaylistItemAction>();
// 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<bool> 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;
}
/// <inheritdoc/>
public IEnumerable<TaskTriggerInfo> 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; }
}
}
}
@@ -20,7 +20,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
/// <summary>
/// Task to sync Jellyfin playback progresses to TubeArchivist.
/// </summary>
public class JFToTubearchivistProgressSyncTask : IScheduledTask
public class JFToTubeArchivistProgressSyncTask : IScheduledTask
{
private readonly ILogger<Plugin> _logger;
private readonly ILibraryManager _libraryManager;
@@ -28,13 +28,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
private readonly IUserDataManager _userDataManager;
/// <summary>
/// Initializes a new instance of the <see cref="JFToTubearchivistProgressSyncTask"/> class.
/// Initializes a new instance of the <see cref="JFToTubeArchivistProgressSyncTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="userManager">User manager.</param>
/// <param name="userDataManager">User data manager.</param>
public JFToTubearchivistProgressSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager)
public JFToTubeArchivistProgressSyncTask(ILogger<Plugin> 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<double> 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.");
@@ -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
{
/// <summary>
/// Task to sync TubeArchivist playlists to Jellyfin.
/// </summary>
public class TAToJellyfinPlaylistsSyncTask : IScheduledTask
{
private readonly ILogger<Plugin> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IPlaylistManager _playlistManager;
/// <summary>
/// Initializes a new instance of the <see cref="TAToJellyfinPlaylistsSyncTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="userManager">User manager.</param>
/// <param name="playlistManager">Playlists manager.</param>
public TAToJellyfinPlaylistsSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IPlaylistManager playlistManager)
{
_logger = logger;
_libraryManager = libraryManager;
_userManager = userManager;
_playlistManager = playlistManager;
}
/// <inheritdoc/>
public string Name => "TAToJellyfinPlaylistsSyncTask";
/// <inheritdoc/>
public string Description => "This tasks syncs TubeArchivist playlists to Jellyfin";
/// <inheritdoc/>
public string Category => "TubeArchivistMetadata";
/// <inheritdoc/>
public string Key => "TAToJellyfinPlaylistsSyncTask";
private int CountTotalVideos(ISet<TubeArchivist.Playlist> taPlaylists)
{
var totalEntries = 0;
foreach (var taPlaylist in taPlaylists)
{
totalEntries += taPlaylist.Entries.Count;
}
return totalEntries;
}
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> 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<Guid>();
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<string, string>()
{
{
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);
}
/// <inheritdoc/>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return
[
new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFProgressTaskInterval).Ticks
},
];
}
}
}
@@ -57,7 +57,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
public async Task ExecuteAsync(IProgress<double> 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.");
@@ -219,7 +219,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFTaskInterval).Ticks
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFProgressTaskInterval).Ticks
},
];
}
@@ -0,0 +1,25 @@
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// A class representing TubeArchivist API custom playlist creation request.
/// </summary>
public class CustomPlaylistCreation
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomPlaylistCreation"/> class.
/// </summary>
/// <param name="playlistName">The name of the new custom playlist to create.</param>
public CustomPlaylistCreation(string playlistName)
{
this.PlaylistName = playlistName;
}
/// <summary>
/// Gets or sets the name of the new custom playlist to create.
/// </summary>
[JsonProperty(PropertyName = "playlist_name")]
public string PlaylistName { get; set; }
}
}
@@ -0,0 +1,72 @@
using System.Globalization;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// Enum representing the possible actions to execute on a TubeArchivist custom playlist video.
/// </summary>
public enum CustomPlaylistAction
{
/// <summary>
/// Create a new custom playlist video.
/// </summary>
Create,
/// <summary>
/// Removea custom playlist video.
/// </summary>
Remove,
/// <summary>
/// Move a custom playlist video to the top of the list.
/// </summary>
Top,
/// <summary>
/// Move a custom playlist video to the bottom of the list.
/// </summary>
Bottom,
/// <summary>
/// Move a custom playlist video up in the list.
/// </summary>
Up,
/// <summary>
/// Move a custom playlist video down in the list.
/// </summary>
Down
}
/// <summary>
/// A class representing TubeArchivist API custom playlist entry operation request.
/// </summary>
public class CustomPlaylistEntryAction
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomPlaylistEntryAction"/> class.
/// </summary>
/// <param name="action">The standard action to create a new entry.</param>
/// <param name="videoId">The entry YoutubeId.</param>
public CustomPlaylistEntryAction(
CustomPlaylistAction action,
string videoId)
{
this.Action = action.ToString().ToLower(CultureInfo.CurrentCulture);
this.VideoId = videoId;
}
/// <summary>
/// Gets or sets the standard action to create a new entry.
/// </summary>
[JsonProperty(PropertyName = "action")]
public string Action { get; set; }
/// <summary>
/// Gets or sets the entry YoutubeId.
/// </summary>
[JsonProperty(PropertyName = "video_id")]
public string VideoId { get; set; }
}
}
@@ -0,0 +1,116 @@
using System.Collections.ObjectModel;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// Enum representing whether the playlist on TubeArchivist belongs from YouTube or is created by the user.
/// </summary>
public enum PlaylistType
{
/// <summary>
/// Playlist belongs from YouTube.
/// </summary>
Regular,
/// <summary>
/// Playlist created by the user.
/// </summary>
Custom
}
/// <summary>
/// A class representing TubeArchivist API playlist data.
/// </summary>
public class Playlist
{
/// <summary>
/// Initializes a new instance of the <see cref="Playlist"/> class.
/// </summary>
/// <param name="isActive">URL of the channel banner image.</param>
/// <param name="channel">Playlist channel name.</param>
/// <param name="channelId">Playlist channel YouTube id.</param>
/// <param name="description">Playlist description.</param>
/// <param name="entries">Playlist videos.</param>
/// <param name="id">Playlist YouTube id.</param>
/// <param name="name">Playlist name.</param>
/// <param name="thumbnailUrl">URL of the playlist thumbnail.</param>
/// <param name="type">Type of the playlist. See <see cref="PlaylistType"/>.</param>
public Playlist(
bool isActive,
string channel,
string channelId,
string description,
Collection<PlaylistEntry> 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;
}
/// <summary>
/// Gets or sets a value indicating whether the playlist is active or not.
/// </summary>
[JsonProperty(PropertyName = "playlist_active")]
public bool IsActive { get; set; }
/// <summary>
/// Gets or sets the playlist channel name.
/// </summary>
[JsonProperty(PropertyName = "playlist_channel")]
public string Channel { get; set; }
/// <summary>
/// Gets or sets the playlist channel YouTube id.
/// </summary>
[JsonProperty(PropertyName = "playlist_channel_id")]
public string ChannelId { get; set; }
/// <summary>
/// Gets or sets the playlist description.
/// </summary>
[JsonProperty(PropertyName = "playlist_description")]
public string Description { get; set; }
/// <summary>
/// Gets the playlist videos.
/// </summary>
[JsonProperty(PropertyName = "playlist_entries")]
public Collection<PlaylistEntry> Entries { get; }
/// <summary>
/// Gets or sets the playlist YouTube id.
/// </summary>
[JsonProperty(PropertyName = "playlist_id")]
public string Id { get; set; }
/// <summary>
/// Gets or sets the playlist name.
/// </summary>
[JsonProperty(PropertyName = "playlist_name")]
public string Name { get; set; }
/// <summary>
/// Gets or sets the playlist thumbnail URL.
/// </summary>
[JsonProperty(PropertyName = "playlist_thumbnail")]
public string ThumbnailUrl { get; set; }
/// <summary>
/// Gets or sets the playlist type.
/// </summary>
/// <value>One of the <see cref="PlaylistType"/> values indicating the playlist's type.</value>
[JsonProperty(PropertyName = "playlist_type")]
public PlaylistType Type { get; set; }
}
}
@@ -0,0 +1,63 @@
using ICU4N.Text;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// A class representing TubeArchivist API playlist entries data.
/// </summary>
public class PlaylistEntry
{
/// <summary>
/// Initializes a new instance of the <see cref="PlaylistEntry"/> class.
/// </summary>
/// <param name="youtubeId">Video YouTube id.</param>
/// <param name="title">Video title.</param>
/// <param name="uploader">Video uploader.</param>
/// <param name="index">Video index in the playlist.</param>
/// <param name="isDownloaded">A value indicating whether the video has been already downloaded or not.</param>
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;
}
/// <summary>
/// Gets or sets the video YouTube id.
/// </summary>
[JsonProperty(PropertyName = "youtube_id")]
public string YoutubeId { get; set; }
/// <summary>
/// Gets or sets the video title.
/// </summary>
[JsonProperty(PropertyName = "title")]
public string Title { get; set; }
/// <summary>
/// Gets or sets the video uploader.
/// </summary>
[JsonProperty(PropertyName = "uploader")]
public string Uploader { get; set; }
/// <summary>
/// Gets or sets the video index in the playlist.
/// </summary>
[JsonProperty(PropertyName = "idx")]
public int Index { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the video has been already downloaded or not.
/// </summary>
[JsonProperty(PropertyName = "downloaded")]
public bool IsDownloaded { get; set; }
}
}
@@ -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
}
/// <summary>
/// Send video playback progress to TubeArchivist.
/// Sends video playback progress to TubeArchivist.
/// </summary>
/// <param name="videoId">Video id.</param>
/// <param name="progress">Progress in seconds.</param>
@@ -167,7 +169,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
}
/// <summary>
/// Send video playback progress to TubeArchivist.
/// Sends video playback progress to TubeArchivist.
/// </summary>
/// <param name="videoId">Video id.</param>
/// <returns>Video playback progress data.</returns>
@@ -186,7 +188,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
}
/// <summary>
/// Set video/channel/playlist as watched on TubeArchivist.
/// Sets video/channel/playlist as watched on TubeArchivist.
/// </summary>
/// <param name="itemId">Video/channel/playlist id.</param>
/// <param name="isWatched">Whether the item has been watched or not.</param>
@@ -201,5 +203,93 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
return response.StatusCode;
}
/// <summary>
/// Retrieves the playlists from TubeArchivist.
/// </summary>
/// <returns>A task.</returns>
public async Task<ISet<Playlist>?> GetPlaylists()
{
ResponseContainer<ISet<Playlist>?>? 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<ResponseContainer<ISet<Playlist>?>>(rawData);
}
return playlists?.Data;
}
/// <summary>
/// Creates a new custom playlist.
/// </summary>
/// <param name="creationRequest">Playlist creation request.</param>
/// <returns>The created <see cref="Playlist"/>.</returns>
public async Task<Playlist?> 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<Playlist>(rawData);
}
return playlist;
}
/// <summary>
/// Creates a new custom playlist.
/// </summary>
/// <param name="playlistId">Playlist id.</param>
/// <param name="entryAction">Playlist creation request.</param>
/// <returns>The response <see cref="HttpStatusCode"/>.</returns>
public async Task<HttpStatusCode> 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;
}
/// <summary>
/// Deletes a playlist.
/// </summary>
/// <param name="playlistId">Playlist id.</param>
/// <returns>Whether the playlist has been deleted successfully or not.</returns>
public async Task<bool> 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;
}
}
}
@@ -9,8 +9,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
/// </summary>
public static class Utils
{
private const string TAPlaylistIdRegex = @"^(.*)\((.*)\)$";
private const string YTTAPlaylistNameFormatRegex = @"^(.*)\s\-\s(.*)\s\((.*)\)$";
private const string TAPlaylistNameFormatRegex = @"^(.*)\s\((.*)\)$";
/// <summary>
/// Sanitize the given URL.
/// Sanitizes the given URL.
/// </summary>
/// <param name="inputUrl">An URL string.</param>
/// <returns>The URL string without spaces, doubled slashes and with a trailing slash.</returns>
@@ -35,7 +39,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
}
/// <summary>
/// Format episodes and series descriptions replacing newlines with br tags.
/// Formats episodes and series descriptions replacing newlines with br tags.
/// </summary>
/// <param name="description">String to format.</param>
/// <returns>A string with \n replaced by br tags.</returns>
@@ -57,7 +61,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
}
/// <summary>
/// Get video name from file path on the disk.
/// Gets video name from file path on the disk.
/// </summary>
/// <param name="path">File path on disk.</param>
/// <returns>The video name.</returns>
@@ -67,7 +71,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
}
/// <summary>
/// Get channel name from directory path on the disk.
/// Gets channel name from directory path on the disk.
/// </summary>
/// <param name="path">Directory path on disk.</param>
/// <returns>The channel name.</returns>
@@ -90,5 +94,35 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
return '/'; // Unix directory separator
}
}
/// <summary>
/// Gets the TubeArchivist playlist id from Jellyfin playlist name.
/// </summary>
/// <param name="playlistName">The Jellyfin playlist name.</param>
/// <returns>The TubeArchvist playlist id.</returns>
public static string? GetTAPlaylistIdFromName(string playlistName)
{
var regex = new Regex(TAPlaylistIdRegex);
return regex.Match(playlistName).Groups[2].ToString();
}
/// <summary>
/// Gets the TubeArchivist playlist name from Jellyfin playlist name.
/// </summary>
/// <param name="playlistName">The Jellyfin playlist name.</param>
/// <returns>The TubeArchivist playlist name.</returns>
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;
}
}
}
+5
View File
@@ -27,6 +27,11 @@ 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)
+2 -2
View File
@@ -2,7 +2,7 @@
name: "TubeArchivistMetadata"
guid: "dc97d0c6-28b0-4242-afb4-5833ae1b3715"
imageUrl: https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png
version: "1.4.0.0"
version: "1.4.1.0"
targetAbi: "10.11.0.0"
framework: "net9.0"
overview: "Metadata for your TubeArchivist library on Jellyfin"
@@ -14,4 +14,4 @@ owner: "DarkFighterLuke"
artifacts:
- "Jellyfin.Plugin.TubeArchivistMetadata.dll"
changelog: >
Align compatibility with Jellyfin 10.11.x
Introduce bidirectional playlists synchronization