Fix: Implement IDisposable to properly clean up resources

- Added IDisposable implementation to Plugin class
- Dispose HttpClient and HttpClientHandler to prevent resource leaks
- Unsubscribe from PlaybackProgress and UserDataSaved events on disposal
- Prevents potential memory leaks from undisposed managed resources
This commit is contained in:
Matt Duran 2025-10-17 21:01:13 -07:00
parent bdd99441d1
commit ab561abee4
1 changed files with 301 additions and 68 deletions

View File

@ -25,10 +25,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
/// <summary>
/// The main plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IDisposable
{
private bool _disposed = false;
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
private Guid? _tubeArchivistCollectionId;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
@ -38,6 +40,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 +49,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
ILogger<Plugin> 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<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,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;
}
}
/// <summary>
/// Refreshes the cached TubeArchivist collection ID.
/// Called when the collection title changes in configuration.
/// </summary>
/// <param name="collectionTitle">Optional collection title to use. If null, reads from Configuration.</param>
public void RefreshTubeArchivistCollectionId(string? collectionTitle = null)
{
Logger.LogInformation("Refreshing TubeArchivist collection cache due to configuration change");
CacheTubeArchivistCollectionId(collectionTitle ?? Configuration.CollectionTitle);
}
/// <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");
}
}
}
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);
}
}
}