diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs index ce10eef..cd460b0 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Configuration/PluginConfiguration.cs @@ -16,6 +16,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration private ILogger _logger; private string _tubeArchivistUrl; private string _tubeArchivistApiKey; + private string _collectionTitle; private HashSet _jfUsernamesTo; /// @@ -32,7 +33,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration _logger = Plugin.Instance.Logger; } - CollectionTitle = string.Empty; + _collectionTitle = string.Empty; _tubeArchivistUrl = string.Empty; _tubeArchivistApiKey = string.Empty; MaxDescriptionLength = 500; @@ -46,7 +47,34 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration /// /// Gets or sets TubeArchivist collection display name. /// - public string CollectionTitle { get; set; } + public string CollectionTitle + { + get + { + return _collectionTitle; + } + + set + { + // Only refresh if value actually changed AND plugin is initialized + if (_collectionTitle != value && !string.IsNullOrEmpty(_collectionTitle)) + { + _collectionTitle = value; + _logger?.LogInformation("Collection title changed to: {CollectionTitle}", value); + + // Only refresh if plugin is fully initialized + if (Plugin.Instance?.LibraryManager != null) + { + Plugin.Instance?.RefreshTubeArchivistCollectionId(); + } + } + else + { + // Initial set, no refresh needed + _collectionTitle = value; + } + } + } /// /// Gets or sets TubeArchivist URL. diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs index 256d3d6..b175959 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs @@ -29,6 +29,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata { private readonly IUserManager _userManager; private readonly IUserDataManager _userDataManager; + private Guid? _tubeArchivistCollectionId; /// /// Initializes a new instance of the class. @@ -38,6 +39,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 +48,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, + ITaskManager taskManager, IUserManager userManager, IUserDataManager userDataManager) : base(applicationPaths, xmlSerializer) @@ -65,6 +68,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,90 +175,271 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata TaskScheduler.Default); } + /// + /// Caches the TubeArchivist collection ID for efficient lookups. + /// This is called once during initialization to avoid repeated searches. + /// + private void CacheTubeArchivistCollectionId() + { + try + { + if (string.IsNullOrEmpty(Configuration.CollectionTitle)) + { + 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(Configuration.CollectionTitle, 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}", Configuration.CollectionTitle); + _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. + /// + public void RefreshTubeArchivistCollectionId() + { + Logger.LogInformation("Refreshing TubeArchivist collection cache due to configuration change"); + CacheTubeArchivistCollectionId(); + } + + /// + /// 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"); } } } - } - - private async void OnWatchedStatusChange(object? sender, UserDataSaveEventArgs eventArgs) - { - if (eventArgs == null || eventArgs.Item.Id == Guid.Empty) + catch (Exception ex) { - 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); - } + 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); } } }