From bdb82503007abe1a036385c5839bce1e6f20142e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 31 May 2026 20:02:00 +0200 Subject: Fix filename --- .../20260524220000_CleanupOrphanedExternalData.cs | 182 --------------------- .../20260525010000_CleanupOrphanedExternalData.cs | 182 +++++++++++++++++++++ 2 files changed, 182 insertions(+), 182 deletions(-) delete mode 100644 Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs diff --git a/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs deleted file mode 100644 index d8dfe181ca..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Database.Implementations; -using Jellyfin.Server.ServerSetupApp; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.Migrations.Routines; - -/// -/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that -/// no longer exist in the BaseItems table. The database side is cleaned up synchronously by -/// IItemPersistenceService.DeleteItem, so the leftover orphans live on the filesystem. -/// -[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))] -[JellyfinMigrationBackup(JellyfinDb = true)] -public class CleanupOrphanedExternalData : IAsyncMigrationRoutine -{ - private const int ProgressLogStep = 500; - - private readonly IStartupLogger _logger; - private readonly IDbContextFactory _dbContextFactory; - private readonly IApplicationPaths _appPaths; - private readonly IServerApplicationPaths _serverPaths; - - /// - /// Initializes a new instance of the class. - /// - /// The startup logger. - /// The database context factory. - /// The application paths. - /// The server application paths. - public CleanupOrphanedExternalData( - IStartupLogger logger, - IDbContextFactory dbContextFactory, - IApplicationPaths appPaths, - IServerApplicationPaths serverPaths) - { - _logger = logger; - _dbContextFactory = dbContextFactory; - _appPaths = appPaths; - _serverPaths = serverPaths; - } - - /// - public async Task PerformAsync(CancellationToken cancellationToken) - { - var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false); - - CleanupGuidIndexedRoot( - "attachment", - Path.Combine(_appPaths.DataPath, "attachments"), - knownIds, - deleteSubPath: null, - cancellationToken); - - CleanupGuidIndexedRoot( - "subtitle", - Path.Combine(_appPaths.DataPath, "subtitles"), - knownIds, - deleteSubPath: null, - cancellationToken); - - CleanupGuidIndexedRoot( - "trickplay", - _appPaths.TrickplayPath, - knownIds, - deleteSubPath: null, - cancellationToken); - - CleanupGuidIndexedRoot( - "chapter image", - Path.Combine(_serverPaths.InternalMetadataPath, "library"), - knownIds, - deleteSubPath: "chapters", - cancellationToken); - } - - private async Task> LoadKnownItemIdsAsync(CancellationToken cancellationToken) - { - var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await using (context.ConfigureAwait(false)) - { - var ids = await context.BaseItems - .AsNoTracking() - .Select(b => b.Id) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return [.. ids]; - } - } - - private void CleanupGuidIndexedRoot( - string label, - string root, - HashSet knownIds, - string? deleteSubPath, - CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) - { - _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root); - return; - } - - _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root); - - var scanned = 0; - var removed = 0; - foreach (var prefixDir in Directory.EnumerateDirectories(root)) - { - cancellationToken.ThrowIfCancellationRequested(); - - var prefixName = Path.GetFileName(prefixDir); - if (prefixName.Length != 2) - { - continue; - } - - foreach (var guidDir in Directory.EnumerateDirectories(prefixDir)) - { - cancellationToken.ThrowIfCancellationRequested(); - - scanned++; - if (scanned % ProgressLogStep == 0) - { - _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed); - } - - var leafName = Path.GetFileName(guidDir); - if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id)) - { - continue; - } - - if (knownIds.Contains(id)) - { - continue; - } - - var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath); - if (deleteSubPath is not null && !Directory.Exists(target)) - { - continue; - } - - if (TryDelete(target)) - { - removed++; - } - } - } - - _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed); - } - - private bool TryDelete(string dir) - { - try - { - Directory.Delete(dir, recursive: true); - return true; - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir); - } - - return false; - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs new file mode 100644 index 0000000000..d8dfe181ca --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that +/// no longer exist in the BaseItems table. The database side is cleaned up synchronously by +/// IItemPersistenceService.DeleteItem, so the leftover orphans live on the filesystem. +/// +[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanupOrphanedExternalData : IAsyncMigrationRoutine +{ + private const int ProgressLogStep = 500; + + private readonly IStartupLogger _logger; + private readonly IDbContextFactory _dbContextFactory; + private readonly IApplicationPaths _appPaths; + private readonly IServerApplicationPaths _serverPaths; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The database context factory. + /// The application paths. + /// The server application paths. + public CleanupOrphanedExternalData( + IStartupLogger logger, + IDbContextFactory dbContextFactory, + IApplicationPaths appPaths, + IServerApplicationPaths serverPaths) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _appPaths = appPaths; + _serverPaths = serverPaths; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false); + + CleanupGuidIndexedRoot( + "attachment", + Path.Combine(_appPaths.DataPath, "attachments"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "subtitle", + Path.Combine(_appPaths.DataPath, "subtitles"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "trickplay", + _appPaths.TrickplayPath, + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "chapter image", + Path.Combine(_serverPaths.InternalMetadataPath, "library"), + knownIds, + deleteSubPath: "chapters", + cancellationToken); + } + + private async Task> LoadKnownItemIdsAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var ids = await context.BaseItems + .AsNoTracking() + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return [.. ids]; + } + } + + private void CleanupGuidIndexedRoot( + string label, + string root, + HashSet knownIds, + string? deleteSubPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root); + return; + } + + _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root); + + var scanned = 0; + var removed = 0; + foreach (var prefixDir in Directory.EnumerateDirectories(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var prefixName = Path.GetFileName(prefixDir); + if (prefixName.Length != 2) + { + continue; + } + + foreach (var guidDir in Directory.EnumerateDirectories(prefixDir)) + { + cancellationToken.ThrowIfCancellationRequested(); + + scanned++; + if (scanned % ProgressLogStep == 0) + { + _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed); + } + + var leafName = Path.GetFileName(guidDir); + if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id)) + { + continue; + } + + if (knownIds.Contains(id)) + { + continue; + } + + var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath); + if (deleteSubPath is not null && !Directory.Exists(target)) + { + continue; + } + + if (TryDelete(target)) + { + removed++; + } + } + } + + _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed); + } + + private bool TryDelete(string dir) + { + try + { + Directory.Delete(dir, recursive: true); + return true; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir); + } + + return false; + } +} -- cgit v1.2.3