< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.CleanupOrphanedExternalData
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 74
Coverable lines: 74
Total lines: 182
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 26
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 6/8/2026 - 12:16:15 AM Line coverage: 0% (0/74) Branch coverage: 0% (0/26) Total lines: 182 6/8/2026 - 12:16:15 AM Line coverage: 0% (0/74) Branch coverage: 0% (0/26) Total lines: 182

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
PerformAsync()100%210%
LoadKnownItemIdsAsync()0%620%
CleanupGuidIndexedRoot(...)0%600240%
TryDelete(...)100%210%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Jellyfin.Database.Implementations;
 9using Jellyfin.Server.ServerSetupApp;
 10using MediaBrowser.Common.Configuration;
 11using MediaBrowser.Controller;
 12using Microsoft.EntityFrameworkCore;
 13using Microsoft.Extensions.Logging;
 14
 15namespace Jellyfin.Server.Migrations.Routines;
 16
 17/// <summary>
 18/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that
 19/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by
 20/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem.
 21/// </summary>
 22[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))]
 23[JellyfinMigrationBackup(JellyfinDb = true)]
 24public class CleanupOrphanedExternalData : IAsyncMigrationRoutine
 25{
 26    private const int ProgressLogStep = 500;
 27
 28    private readonly IStartupLogger<CleanupOrphanedExternalData> _logger;
 29    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
 30    private readonly IApplicationPaths _appPaths;
 31    private readonly IServerApplicationPaths _serverPaths;
 32
 33    /// <summary>
 34    /// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class.
 35    /// </summary>
 36    /// <param name="logger">The startup logger.</param>
 37    /// <param name="dbContextFactory">The database context factory.</param>
 38    /// <param name="appPaths">The application paths.</param>
 39    /// <param name="serverPaths">The server application paths.</param>
 40    public CleanupOrphanedExternalData(
 41        IStartupLogger<CleanupOrphanedExternalData> logger,
 42        IDbContextFactory<JellyfinDbContext> dbContextFactory,
 43        IApplicationPaths appPaths,
 44        IServerApplicationPaths serverPaths)
 45    {
 046        _logger = logger;
 047        _dbContextFactory = dbContextFactory;
 048        _appPaths = appPaths;
 049        _serverPaths = serverPaths;
 050    }
 51
 52    /// <inheritdoc/>
 53    public async Task PerformAsync(CancellationToken cancellationToken)
 54    {
 055        var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false);
 56
 057        CleanupGuidIndexedRoot(
 058            "attachment",
 059            Path.Combine(_appPaths.DataPath, "attachments"),
 060            knownIds,
 061            deleteSubPath: null,
 062            cancellationToken);
 63
 064        CleanupGuidIndexedRoot(
 065            "subtitle",
 066            Path.Combine(_appPaths.DataPath, "subtitles"),
 067            knownIds,
 068            deleteSubPath: null,
 069            cancellationToken);
 70
 071        CleanupGuidIndexedRoot(
 072            "trickplay",
 073            _appPaths.TrickplayPath,
 074            knownIds,
 075            deleteSubPath: null,
 076            cancellationToken);
 77
 078        CleanupGuidIndexedRoot(
 079            "chapter image",
 080            Path.Combine(_serverPaths.InternalMetadataPath, "library"),
 081            knownIds,
 082            deleteSubPath: "chapters",
 083            cancellationToken);
 084    }
 85
 86    private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken)
 87    {
 088        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 089        await using (context.ConfigureAwait(false))
 90        {
 091            var ids = await context.BaseItems
 092                .AsNoTracking()
 093                .Select(b => b.Id)
 094                .ToListAsync(cancellationToken)
 095                .ConfigureAwait(false);
 096            return [.. ids];
 97        }
 098    }
 99
 100    private void CleanupGuidIndexedRoot(
 101        string label,
 102        string root,
 103        HashSet<Guid> knownIds,
 104        string? deleteSubPath,
 105        CancellationToken cancellationToken)
 106    {
 0107        if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
 108        {
 0109            _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root);
 0110            return;
 111        }
 112
 0113        _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root);
 114
 0115        var scanned = 0;
 0116        var removed = 0;
 0117        foreach (var prefixDir in Directory.EnumerateDirectories(root))
 118        {
 0119            cancellationToken.ThrowIfCancellationRequested();
 120
 0121            var prefixName = Path.GetFileName(prefixDir);
 0122            if (prefixName.Length != 2)
 123            {
 124                continue;
 125            }
 126
 0127            foreach (var guidDir in Directory.EnumerateDirectories(prefixDir))
 128            {
 0129                cancellationToken.ThrowIfCancellationRequested();
 130
 0131                scanned++;
 0132                if (scanned % ProgressLogStep == 0)
 133                {
 0134                    _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed 
 135                }
 136
 0137                var leafName = Path.GetFileName(guidDir);
 0138                if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id))
 139                {
 140                    continue;
 141                }
 142
 0143                if (knownIds.Contains(id))
 144                {
 145                    continue;
 146                }
 147
 0148                var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath);
 0149                if (deleteSubPath is not null && !Directory.Exists(target))
 150                {
 151                    continue;
 152                }
 153
 0154                if (TryDelete(target))
 155                {
 0156                    removed++;
 157                }
 158            }
 159        }
 160
 0161        _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", lab
 0162    }
 163
 164    private bool TryDelete(string dir)
 165    {
 166        try
 167        {
 0168            Directory.Delete(dir, recursive: true);
 0169            return true;
 170        }
 0171        catch (IOException ex)
 172        {
 0173            _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir);
 0174        }
 0175        catch (UnauthorizedAccessException ex)
 176        {
 0177            _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir);
 0178        }
 179
 0180        return false;
 0181    }
 182}