feat: Add collection caching for improved performance

- Added _tubeArchivistCollectionId field to cache collection GUID on startup
- Added CacheTubeArchivistCollectionId() to find and cache collection once
- Added RefreshTubeArchivistCollectionId() to update cache on config changes
- Added IsItemInTubeArchivistCollection() helper for efficient validation
- Updated PluginConfiguration.CollectionTitle to trigger cache refresh on change
- Updated OnPlaybackProgress to use cached ID for O(1) validation

This eliminates repeated database lookups (3 per playback event) by caching
the collection ID once at startup. For libraries with thousands of movies,
this significantly reduces overhead when non-TubeArchivist media is played.

Performance improvement:
- Before: 3 calls per playback event for all media types
- After: 0 calls for rejected media (type check), 1-3 for Episodes only
This commit is contained in:
Matt Duran 2025-10-17 15:23:57 -07:00
parent bdd99441d1
commit c0bafbb1da
2 changed files with 303 additions and 70 deletions

View File

@ -16,6 +16,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
private ILogger _logger;
private string _tubeArchivistUrl;
private string _tubeArchivistApiKey;
private string _collectionTitle;
private HashSet<string> _jfUsernamesTo;
/// <summary>
@ -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
/// <summary>
/// Gets or sets TubeArchivist collection display name.
/// </summary>
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;
}
}
}
/// <summary>
/// Gets or sets TubeArchivist URL.

View File

@ -29,6 +29,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
{
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
private Guid? _tubeArchivistCollectionId;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
@ -38,6 +39,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
public Plugin(
@ -46,6 +48,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
ILogger<Plugin> 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<TAToJellyfinProgressSyncTask>();
}
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<JFToTubearchivistProgressSyncTask>();
}
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);
}
/// <summary>
/// Caches the TubeArchivist collection ID for efficient lookups.
/// This is called once during initialization to avoid repeated searches.
/// </summary>
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;
}
}
/// <summary>
/// Refreshes the cached TubeArchivist collection ID.
/// Called when the collection title changes in configuration.
/// </summary>
public void RefreshTubeArchivistCollectionId()
{
Logger.LogInformation("Refreshing TubeArchivist collection cache due to configuration change");
CacheTubeArchivistCollectionId();
}
/// <summary>
/// Validates if an item belongs to the TubeArchivist collection.
/// Uses cached collection ID for efficient lookups without traversing hierarchy.
/// </summary>
/// <param name="item">The item to validate.</param>
/// <returns>True if the item is a valid Episode in the TubeArchivist collection; otherwise, false.</returns>
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;
}
/// <summary>
/// Fallback method for validating collection membership when collection ID is not cached.
/// </summary>
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);
}
}
}