delete guest devices on teardown (#18)

Jellyfin does not cascade a user delete to the Device rows redemption
creates, and DeviceManager throws for the whole listing when one device's
user is missing, so a single leftover guest 404s the dashboard devices page.

Deletes the guest's devices before the user, and sweeps devices this plugin
created whose user is already gone at startup for links torn down by older
builds.
Cette révision appartient à :
Francois CB
2026-07-27 20:56:04 +02:00
révisé par GitHub
Parent d1ee74677b
révision 911ab41b0c
5 fichiers modifiés avec 112 ajouts et 6 suppressions
+3 -3
Voir le fichier
@@ -6,9 +6,9 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace> <RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName> <AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
<Version>1.0.4.0</Version> <Version>1.0.5.0</Version>
<AssemblyVersion>1.0.4.0</AssemblyVersion> <AssemblyVersion>1.0.5.0</AssemblyVersion>
<FileVersion>1.0.4.0</FileVersion> <FileVersion>1.0.5.0</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo> <GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
+22
Voir le fichier
@@ -13,14 +13,17 @@ namespace Jellyfin.Plugin.ShareLinks.Lifecycle;
public sealed class StartupCleanupHostedService : BackgroundService public sealed class StartupCleanupHostedService : BackgroundService
{ {
private readonly IShareLinkCleanupService _cleanupService; private readonly IShareLinkCleanupService _cleanupService;
private readonly JellyfinGuestUserService _guestUserService;
private readonly ILogger<StartupCleanupHostedService> _logger; private readonly ILogger<StartupCleanupHostedService> _logger;
/// <summary>Initializes a new instance of the <see cref="StartupCleanupHostedService"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="StartupCleanupHostedService"/> class.</summary>
public StartupCleanupHostedService( public StartupCleanupHostedService(
IShareLinkCleanupService cleanupService, IShareLinkCleanupService cleanupService,
JellyfinGuestUserService guestUserService,
ILogger<StartupCleanupHostedService> logger) ILogger<StartupCleanupHostedService> logger)
{ {
_cleanupService = cleanupService; _cleanupService = cleanupService;
_guestUserService = guestUserService;
_logger = logger; _logger = logger;
} }
@@ -38,5 +41,24 @@ public sealed class StartupCleanupHostedService : BackgroundService
{ {
_logger.LogWarning(ex, "ShareLinks: startup cleanup failed."); _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.");
}
} }
} }
+85 -1
Voir le fichier
@@ -3,8 +3,10 @@ using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.ShareLinks.Models; using Jellyfin.Plugin.ShareLinks.Models;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Users; using MediaBrowser.Model.Users;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -14,13 +16,24 @@ namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Creates and tears down temporary Jellyfin guest users.</summary> /// <summary>Creates and tears down temporary Jellyfin guest users.</summary>
public sealed class JellyfinGuestUserService public sealed class JellyfinGuestUserService
{ {
/// <summary>
/// The app name redemption stamps on a guest session. Used to tell this plugin's
/// device rows apart from everything else on the server.
/// </summary>
public const string GuestAppName = "ShareLinks";
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IDeviceManager _deviceManager;
private readonly ILogger<JellyfinGuestUserService> _logger; private readonly ILogger<JellyfinGuestUserService> _logger;
/// <summary>Initializes a new instance of the <see cref="JellyfinGuestUserService"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="JellyfinGuestUserService"/> class.</summary>
public JellyfinGuestUserService(IUserManager userManager, ILogger<JellyfinGuestUserService> logger) public JellyfinGuestUserService(
IUserManager userManager,
IDeviceManager deviceManager,
ILogger<JellyfinGuestUserService> logger)
{ {
_userManager = userManager; _userManager = userManager;
_deviceManager = deviceManager;
_logger = logger; _logger = logger;
} }
@@ -117,6 +130,11 @@ public sealed class JellyfinGuestUserService
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
try 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); await _userManager.DeleteUserAsync(user.Id).ConfigureAwait(false);
_logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", user.Username, record.Id); _logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", user.Username, record.Id);
} }
@@ -127,6 +145,72 @@ public sealed class JellyfinGuestUserService
} }
} }
/// <summary>Removes every device row belonging to a guest account.</summary>
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);
}
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<int> 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) private User? FindRecordUser(ShareLinkRecord record)
{ {
if (record.GuestUserId.HasValue) if (record.GuestUserId.HasValue)
+1 -1
Voir le fichier
@@ -177,7 +177,7 @@ public sealed class ShareLinkRedemptionService
{ {
Username = record.GuestUserName, Username = record.GuestUserName,
UserId = record.GuestUserId.Value, UserId = record.GuestUserId.Value,
App = "ShareLinks", App = JellyfinGuestUserService.GuestAppName,
AppVersion = "1.0.0", AppVersion = "1.0.0",
DeviceId = record.DeviceId, DeviceId = record.DeviceId,
DeviceName = "ShareLinks", DeviceName = "ShareLinks",
+1 -1
Voir le fichier
@@ -1,7 +1,7 @@
{ {
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6", "guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
"name": "ShareLinks", "name": "ShareLinks",
"version": "1.0.4.0", "version": "1.0.5.0",
"targetAbi": "10.11.0.0", "targetAbi": "10.11.0.0",
"framework": "net9.0", "framework": "net9.0",
"owner": "Franciskid", "owner": "Franciskid",