diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj index c9d0aa5..73e954d 100644 --- a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj +++ b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj @@ -6,9 +6,9 @@ latest Jellyfin.Plugin.ShareLinks Jellyfin.Plugin.ShareLinks - 1.0.4.0 - 1.0.4.0 - 1.0.4.0 + 1.0.5.0 + 1.0.5.0 + 1.0.5.0 true false disable diff --git a/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs b/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs index 55ee21b..dc990b8 100644 --- a/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs +++ b/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs @@ -13,14 +13,17 @@ namespace Jellyfin.Plugin.ShareLinks.Lifecycle; public sealed class StartupCleanupHostedService : BackgroundService { private readonly IShareLinkCleanupService _cleanupService; + private readonly JellyfinGuestUserService _guestUserService; private readonly ILogger _logger; /// Initializes a new instance of the class. public StartupCleanupHostedService( IShareLinkCleanupService cleanupService, + JellyfinGuestUserService guestUserService, ILogger logger) { _cleanupService = cleanupService; + _guestUserService = guestUserService; _logger = logger; } @@ -38,5 +41,24 @@ public sealed class StartupCleanupHostedService : BackgroundService { _logger.LogWarning(ex, "ShareLinks: startup cleanup failed."); } + + // Separate try: a device row stranded by an older build breaks the admin + // devices page until it goes, so this should still run when the record + // cleanup above fails for its own reasons. + try + { + var removed = await _guestUserService.PurgeOrphanedGuestDevicesAsync(stoppingToken).ConfigureAwait(false); + if (removed > 0) + { + _logger.LogInformation("ShareLinks: removed {Count} orphaned guest device(s) at startup.", removed); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: orphaned guest device sweep failed."); + } } } diff --git a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs index bf7b774..2537c05 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs @@ -3,8 +3,10 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Data.Queries; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.ShareLinks.Models; +using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Users; using Microsoft.Extensions.Logging; @@ -14,13 +16,24 @@ namespace Jellyfin.Plugin.ShareLinks.Services; /// Creates and tears down temporary Jellyfin guest users. public sealed class JellyfinGuestUserService { + /// + /// The app name redemption stamps on a guest session. Used to tell this plugin's + /// device rows apart from everything else on the server. + /// + public const string GuestAppName = "ShareLinks"; + private readonly IUserManager _userManager; + private readonly IDeviceManager _deviceManager; private readonly ILogger _logger; /// Initializes a new instance of the class. - public JellyfinGuestUserService(IUserManager userManager, ILogger logger) + public JellyfinGuestUserService( + IUserManager userManager, + IDeviceManager deviceManager, + ILogger logger) { _userManager = userManager; + _deviceManager = deviceManager; _logger = logger; } @@ -117,6 +130,11 @@ public sealed class JellyfinGuestUserService cancellationToken.ThrowIfCancellationRequested(); try { + // Devices first. Jellyfin does not cascade a user delete to the Device + // rows that redemption created, and a Device whose user is gone makes + // DeviceManager.ToDeviceInfo throw for the WHOLE listing, so one leftover + // guest 404s the admin dashboard's devices page entirely. + await DeleteDevicesForUserAsync(user.Id).ConfigureAwait(false); await _userManager.DeleteUserAsync(user.Id).ConfigureAwait(false); _logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", user.Username, record.Id); } @@ -127,6 +145,72 @@ public sealed class JellyfinGuestUserService } } + /// Removes every device row belonging to a guest account. + private async Task DeleteDevicesForUserAsync(Guid userId) + { + // GetDevices returns the raw entities. GetDevicesForUser/GetDevice project to + // DTOs and resolve the owning user on the way, which is exactly what throws + // once the user is gone, so neither of those can be used to clean up after it. + var devices = _deviceManager.GetDevices(new DeviceQuery { UserId = userId }); + foreach (var device in devices.Items) + { + try + { + await _deviceManager.DeleteDevice(device).ConfigureAwait(false); + _logger.LogInformation( + "ShareLinks: deleted device {DeviceId} ({AppName}) for guest {UserId}.", + device.DeviceId, + device.AppName, + userId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: failed to delete device {DeviceId}.", device.DeviceId); + } + } + } + + /// + /// Deletes device rows left behind by guests that were removed before this + /// cleanup existed. Scoped to devices this plugin created, so a stale row from + /// anything else on the server is left for its owner to deal with. + /// + public async Task PurgeOrphanedGuestDevicesAsync(CancellationToken cancellationToken) + { + var removed = 0; + var devices = _deviceManager.GetDevices(new DeviceQuery()); + foreach (var device in devices.Items) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.Equals(device.AppName, GuestAppName, StringComparison.Ordinal)) + { + continue; + } + + if (_userManager.GetUserById(device.UserId) is not null) + { + continue; + } + + try + { + await _deviceManager.DeleteDevice(device).ConfigureAwait(false); + removed++; + _logger.LogInformation( + "ShareLinks: removed orphaned guest device {DeviceId} whose user {UserId} no longer exists.", + device.DeviceId, + device.UserId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: failed to remove orphaned device {DeviceId}.", device.DeviceId); + } + } + + return removed; + } + private User? FindRecordUser(ShareLinkRecord record) { if (record.GuestUserId.HasValue) diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs index 8d1a44f..2ee8f3d 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs @@ -177,7 +177,7 @@ public sealed class ShareLinkRedemptionService { Username = record.GuestUserName, UserId = record.GuestUserId.Value, - App = "ShareLinks", + App = JellyfinGuestUserService.GuestAppName, AppVersion = "1.0.0", DeviceId = record.DeviceId, DeviceName = "ShareLinks", diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json index ccecb46..e6f043d 100644 --- a/Jellyfin.Plugin.ShareLinks/meta.json +++ b/Jellyfin.Plugin.ShareLinks/meta.json @@ -1,7 +1,7 @@ { "guid": "68540b76-ee74-436d-85ff-2abc884bbea6", "name": "ShareLinks", - "version": "1.0.4.0", + "version": "1.0.5.0", "targetAbi": "10.11.0.0", "framework": "net9.0", "owner": "Franciskid",