diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs
index 256d3d6..c127a32 100644
--- a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs
@@ -25,10 +25,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
///
/// The main plugin.
///
- public class Plugin : BasePlugin, IHasWebPages
+ public class Plugin : BasePlugin, IHasWebPages, IDisposable
{
+ private bool _disposed = false;
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
+ private Guid? _tubeArchivistCollectionId;
///
/// Initializes a new instance of the class.
@@ -38,6 +40,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
+ /// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
public Plugin(
@@ -46,6 +49,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
ILogger logger,
ISessionManager sessionManager,
ILibraryManager libraryManager,
+ ITaskManager taskManager,
IUserManager userManager,
IUserDataManager userDataManager)
: base(applicationPaths, xmlSerializer)
@@ -65,6 +69,27 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
_userDataManager = userDataManager;
userDataManager.UserDataSaved += OnWatchedStatusChange;
+ // Cache the TubeArchivist collection ID for efficient lookups
+ CacheTubeArchivistCollectionId();
+
+ var taToJellyfinProgressSyncTask = new TAToJellyfinProgressSyncTask(logger, libraryManager, userManager, userDataManager);
+ var jfToTubearchivistProgressSyncTask = new JFToTubearchivistProgressSyncTask(logger, libraryManager, userManager, userDataManager);
+ var isTAJFTaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(taToJellyfinProgressSyncTask.Name, StringComparison.Ordinal));
+ if (Instance!.Configuration.TAJFSync && !isTAJFTaskPresent)
+ {
+ logger.LogInformation("Queueing task {TaskName}.", taToJellyfinProgressSyncTask.Name);
+ taskManager.AddTasks([taToJellyfinProgressSyncTask]);
+ taskManager.Execute();
+ }
+
+ var isJFTATaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(jfToTubearchivistProgressSyncTask.Name, StringComparison.Ordinal));
+ if (Instance!.Configuration.JFTASync && !isJFTATaskPresent)
+ {
+ logger.LogInformation("Queueing task {TaskName}.", jfToTubearchivistProgressSyncTask.Name);
+ taskManager.AddTasks([jfToTubearchivistProgressSyncTask]);
+ taskManager.Execute();
+ }
+
logger.LogInformation("{Message}", "Collection display name: " + Instance?.Configuration.CollectionTitle);
logger.LogInformation("{Message}", "TubeArchivist API URL: " + Instance?.Configuration.TubeArchivistUrl);
logger.LogInformation("{Message}", "Pinging TubeArchivist API...");
@@ -151,91 +176,299 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
TaskScheduler.Default);
}
+ private void CacheTubeArchivistCollectionId(string? collectionTitle = null)
+ {
+ try
+ {
+ string titleToSearch = collectionTitle ?? Configuration.CollectionTitle;
+
+ if (string.IsNullOrEmpty(titleToSearch))
+ {
+ Logger.LogWarning("TubeArchivist collection title not configured");
+ _tubeArchivistCollectionId = null;
+ return;
+ }
+
+ // Find the collection by name
+ var collections = LibraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.CollectionFolder, Jellyfin.Data.Enums.BaseItemKind.Folder },
+ Recursive = false
+ });
+
+ var tubeArchivistCollection = collections.FirstOrDefault(c =>
+ c.Name.Equals(titleToSearch, StringComparison.OrdinalIgnoreCase));
+
+ if (tubeArchivistCollection != null)
+ {
+ _tubeArchivistCollectionId = tubeArchivistCollection.Id;
+ Logger.LogInformation("Found TubeArchivist collection with ID: {CollectionId}", _tubeArchivistCollectionId);
+ }
+ else
+ {
+ Logger.LogWarning("Could not find TubeArchivist collection with name: {CollectionName}", titleToSearch);
+ _tubeArchivistCollectionId = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error caching TubeArchivist collection ID");
+ _tubeArchivistCollectionId = null;
+ }
+ }
+
+ ///
+ /// Refreshes the cached TubeArchivist collection ID.
+ /// Called when the collection title changes in configuration.
+ ///
+ /// Optional collection title to use. If null, reads from Configuration.
+ public void RefreshTubeArchivistCollectionId(string? collectionTitle = null)
+ {
+ Logger.LogInformation("Refreshing TubeArchivist collection cache due to configuration change");
+ CacheTubeArchivistCollectionId(collectionTitle ?? Configuration.CollectionTitle);
+ }
+
+ ///
+ /// Validates if an item belongs to the TubeArchivist collection.
+ /// Uses cached collection ID for efficient lookups without traversing hierarchy.
+ ///
+ /// The item to validate.
+ /// True if the item is a valid Episode in the TubeArchivist collection; otherwise, false.
+ private bool IsItemInTubeArchivistCollection(BaseItem item)
+ {
+ // Type check first (cheapest operation)
+ if (item is not Episode)
+ {
+ return false;
+ }
+
+ // If we couldn't cache the collection ID, fall back to name-based check
+ if (_tubeArchivistCollectionId == null)
+ {
+ // Fallback: traverse hierarchy to find collection
+ return IsItemInTubeArchivistCollectionByHierarchy(item);
+ }
+
+ // Efficient check: Walk up the parent chain looking for our cached collection ID
+ // This is much faster than multiple GetItemById calls
+ var currentItem = item;
+ for (int depth = 0; depth < 10; depth++) // Limit depth to prevent infinite loops
+ {
+ if (currentItem.Id == _tubeArchivistCollectionId)
+ {
+ return true;
+ }
+
+ if (currentItem.ParentId == Guid.Empty)
+ {
+ break;
+ }
+
+ var parent = LibraryManager.GetItemById(currentItem.ParentId);
+ if (parent == null)
+ {
+ break;
+ }
+
+ // Check if this parent is our target collection
+ if (parent.Id == _tubeArchivistCollectionId)
+ {
+ return true;
+ }
+
+ currentItem = parent;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Fallback method for validating collection membership when collection ID is not cached.
+ ///
+ private bool IsItemInTubeArchivistCollectionByHierarchy(BaseItem item)
+ {
+ // Early exit if parent ID is empty or null
+ if (item.ParentId == Guid.Empty)
+ {
+ return false;
+ }
+
+ // Traverse hierarchy safely with null checks
+ BaseItem? season = LibraryManager.GetItemById(item.ParentId);
+ if (season?.ParentId == null || season.ParentId == Guid.Empty)
+ {
+ return false;
+ }
+
+ BaseItem? channel = LibraryManager.GetItemById(season.ParentId);
+ if (channel?.ParentId == null || channel.ParentId == Guid.Empty)
+ {
+ return false;
+ }
+
+ BaseItem? collection = LibraryManager.GetItemById(channel.ParentId);
+
+ // Check if this is the configured TubeArchivist collection
+ return collection?.Name.Equals(Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase) ?? false;
+ }
+
private async void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs eventArgs)
{
- if (eventArgs == null || eventArgs.Item.Id == Guid.Empty)
+ // Early exit checks - ordered from cheapest to most expensive
+ // 1. Configuration check (memory access)
+ if (!Instance!.Configuration.JFTASync)
{
- Logger.LogDebug("Skipping progress synchronization: PlaybackProgress event triggered with null or empty Guid.");
return;
}
- if (Instance!.Configuration.JFTASync && eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)))
+ // 2. User check (list iteration)
+ if (!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);
- BaseItem? collection = LibraryManager.GetItemById(channel!.ParentId);
- if (collection?.Name.ToLower(CultureInfo.CurrentCulture) == Instance?.Configuration.CollectionTitle.ToLower(CultureInfo.CurrentCulture) && eventArgs.PlaybackPositionTicks != null)
+ return;
+ }
+
+ // 3. Playback position check (null check)
+ if (eventArgs.PlaybackPositionTicks == null)
+ {
+ return;
+ }
+
+ // 4. Type and collection check (type check + cached ID lookup)
+ // This is now much more efficient - single type check + ID comparison vs 3 database calls
+ if (!IsItemInTubeArchivistCollection(eventArgs.Item))
+ {
+ return;
+ }
+
+ // At this point, we know it's a valid TubeArchivist Episode - safe to proceed
+ try
+ {
+ long progress = (long)eventArgs.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
+ var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
+ var statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress).ConfigureAwait(true);
+
+ if (statusCode != System.Net.HttpStatusCode.OK)
{
- long progress = (long)eventArgs.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
+ Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error syncing playback progress for item {ItemName}", eventArgs.Item.Name);
+ }
+ }
+
+ private async void OnWatchedStatusChange(object? sender, UserDataSaveEventArgs eventArgs)
+ {
+ // Early exit checks
+ var user = _userManager.GetUserById(eventArgs.UserId);
+ if (!Configuration.JFTASync || user == null || !Configuration.GetJFUsernamesToArray().Contains(user.Username))
+ {
+ return;
+ }
+
+ var isPlayed = eventArgs.Item.IsPlayed(user);
+ Logger.LogDebug("User {UserId} changed watched status to {Status} for the item {ItemName}", eventArgs.UserId, isPlayed, eventArgs.Item.Name);
+
+ string itemYTId;
+ try
+ {
+ // Handle Series (YouTube Channels)
+ if (eventArgs.Item is Series)
+ {
+ // For Series, we still need to validate collection membership
+ // but can use the efficient cached ID method
+ if (_tubeArchivistCollectionId != null && eventArgs.Item.Id != _tubeArchivistCollectionId)
+ {
+ // Check if this Series is a child of the TubeArchivist collection
+ var parent = eventArgs.Item.ParentId != Guid.Empty
+ ? LibraryManager.GetItemById(eventArgs.Item.ParentId)
+ : null;
+
+ if (parent?.Id != _tubeArchivistCollectionId)
+ {
+ return;
+ }
+ }
+
+ itemYTId = Utils.GetChannelNameFromPath(eventArgs.Item.Path);
+ }
+
+ // Handle Episodes (YouTube Videos) - with collection validation
+ else if (eventArgs.Item is Episode)
+ {
+ // Validate this Episode belongs to TubeArchivist collection
+ if (!IsItemInTubeArchivistCollection(eventArgs.Item))
+ {
+ return;
+ }
+
+ itemYTId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
+ }
+ else
+ {
+ // Not a relevant item type for TubeArchivist
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error while processing item path: {ItemPath}", eventArgs.Item.Path ?? "null");
+ return;
+ }
+
+ try
+ {
+ var statusCode = await TubeArchivistApi.GetInstance().SetWatchedStatus(itemYTId, isPlayed).ConfigureAwait(true);
+ if (statusCode != System.Net.HttpStatusCode.OK)
+ {
+ Logger.LogCritical("POST /watched returned {StatusCode} for item {ItemName} ({VideoYTId}) with watched status {IsPlayed}", statusCode, eventArgs.Item.Name, itemYTId, isPlayed);
+ }
+
+ // Sync progress for Episodes only
+ if (eventArgs.Item is Episode)
+ {
+ var progress = _userDataManager.GetUserData(user, eventArgs.Item).PlaybackPositionTicks / TimeSpan.TicksPerSecond;
var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
- var statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress).ConfigureAwait(true);
+ statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress).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");
}
}
}
+ catch (Exception ex)
+ {
+ Logger.LogCritical("An exception occurred while calling POST /watched for item {ItemName} ({VideoYTId}) with watched status {IsPlayed}: {ExceptionMessage}", eventArgs.Item.Name, itemYTId, isPlayed, ex.Message);
+ }
+ }
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ // Unsubscribe from events to prevent memory leaks
+ if (SessionManager != null)
+ {
+ SessionManager.PlaybackProgress -= OnPlaybackProgress;
+ }
+ if (_userDataManager != null)
+ {
+ _userDataManager.UserDataSaved -= OnWatchedStatusChange;
+ }
+
+ // Dispose managed resources
+ HttpClient?.Dispose();
+ }
+ _disposed = true;
+ }
}
- private async void OnWatchedStatusChange(object? sender, UserDataSaveEventArgs eventArgs)
+ public void Dispose()
{
- if (eventArgs == null || eventArgs.Item.Id == Guid.Empty)
- {
- Logger.LogDebug("Skipping watched status synchronization: WatchedStatusChange event triggered with null or empty Guid.");
- return;
- }
-
- var user = _userManager.GetUserById(eventArgs.UserId);
- if (Configuration.JFTASync && user != null && Configuration.GetJFUsernamesToArray().Contains(user!.Username))
- {
- var isPlayed = eventArgs.Item.IsPlayed(user);
- Logger.LogDebug("User {UserId} changed watched status to {Status} for the item {ItemName}", eventArgs.UserId, isPlayed, eventArgs.Item.Name);
- string itemYTId;
- try
- {
- if (eventArgs.Item is Series)
- {
- itemYTId = Utils.GetChannelNameFromPath(eventArgs.Item.Path);
- }
- else if (eventArgs.Item is Episode)
- {
- itemYTId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
- }
- else
- {
- return;
- }
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error while processing item path: {ItemPath}", eventArgs.Item.Path ?? "null");
- return;
- }
-
- try
- {
- var statusCode = await TubeArchivistApi.GetInstance().SetWatchedStatus(itemYTId, isPlayed).ConfigureAwait(true);
- if (statusCode != System.Net.HttpStatusCode.OK)
- {
- Logger.LogCritical("POST /watched returned {StatusCode} for item {ItemName} ({VideoYTId}) with watched status {IsPlayed}", statusCode, eventArgs.Item.Name, itemYTId, isPlayed);
- }
-
- 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)
- {
- Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
- }
- }
- }
- catch (Exception ex)
- {
- Logger.LogCritical("An exception occurred while calling POST /watched for item {ItemName} ({VideoYTId}) with watched status {IsPlayed}: {ExceptionMessage}", eventArgs.Item.Name, itemYTId, isPlayed, ex.Message);
- }
- }
+ Dispose(true);
+ GC.SuppressFinalize(this);
}
}
}