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",