aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs
blob: 14abaa7317d9c2f3960936f9c3c4630c96cb1bf2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Migrations.Stages;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Jellyfin.Server.Migrations.Routines;

/// <summary>
/// Removes orphaned extras (items with OwnerId pointing to non-existent items).
/// Must run before EF migrations that add FK constraints on OwnerId.
/// </summary>
[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.CoreInitialisation)]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class CleanupOrphanedExtras : IAsyncMigrationRoutine
{
    private readonly IStartupLogger<CleanupOrphanedExtras> _logger;
    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
    private readonly ILibraryManager _libraryManager;

    /// <summary>
    /// Initializes a new instance of the <see cref="CleanupOrphanedExtras"/> class.
    /// </summary>
    /// <param name="logger">The startup logger.</param>
    /// <param name="dbContextFactory">The database context factory.</param>
    /// <param name="libraryManager">The library manager.</param>
    /// <param name="itemRepository">The item repository.</param>
    /// <param name="itemCountService">The item count service.</param>
    /// <param name="channelManager">The channel manager.</param>
    /// <param name="recordingsManager">The recordings manager.</param>
    /// <param name="mediaSourceManager">The media source manager.</param>
    /// <param name="mediaSegmentManager">The media segments manager.</param>
    /// <param name="configurationManager">The configuration manager.</param>
    /// <param name="fileSystem">The file system.</param>
    public CleanupOrphanedExtras(
        IStartupLogger<CleanupOrphanedExtras> logger,
        IDbContextFactory<JellyfinDbContext> dbContextFactory,
        ILibraryManager libraryManager,
        IItemRepository itemRepository,
        IItemCountService itemCountService,
        IChannelManager channelManager,
        IRecordingsManager recordingsManager,
        IMediaSourceManager mediaSourceManager,
        IMediaSegmentManager mediaSegmentManager,
        IServerConfigurationManager configurationManager,
        IFileSystem fileSystem)
    {
        _logger = logger;
        _dbContextFactory = dbContextFactory;
        _libraryManager = libraryManager;
        BaseItem.LibraryManager ??= libraryManager;
        BaseItem.ItemRepository ??= itemRepository;
        BaseItem.ItemCountService ??= itemCountService;
        BaseItem.ChannelManager ??= channelManager;
        BaseItem.MediaSourceManager ??= mediaSourceManager;
        BaseItem.MediaSegmentManager ??= mediaSegmentManager;
        BaseItem.ConfigurationManager ??= configurationManager;
        BaseItem.FileSystem ??= fileSystem;
        Video.RecordingsManager ??= recordingsManager;
    }

    /// <inheritdoc/>
    public async Task PerformAsync(CancellationToken cancellationToken)
    {
        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
        await using (context.ConfigureAwait(false))
        {
            var orphanedItemIds = await context.BaseItems
                .Where(b => b.OwnerId.HasValue && !b.OwnerId.Value.Equals(Guid.Empty))
                .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
                .Select(b => b.Id)
                .ToListAsync(cancellationToken)
                .ConfigureAwait(false);

            if (orphanedItemIds.Count == 0)
            {
                _logger.LogInformation("No orphaned extras found, skipping migration.");
                return;
            }

            _logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);

            // Batch-resolve items for metadata path cleanup, then delete all at once
            var itemsToDelete = new List<BaseItem>();
            foreach (var itemId in orphanedItemIds)
            {
                var item = _libraryManager.GetItemById(itemId);
                if (item is not null)
                {
                    itemsToDelete.Add(item);
                }
            }

            _libraryManager.DeleteItemsUnsafeFast(itemsToDelete);

            _logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
        }
    }
}