< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.RestorePlaylistChildrenFromMetadata
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 93
Coverable lines: 93
Total lines: 191
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 34
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 8/2/2026 - 12:17:28 AM Line coverage: 0% (0/93) Branch coverage: 0% (0/34) Total lines: 191 8/2/2026 - 12:17:28 AM Line coverage: 0% (0/93) Branch coverage: 0% (0/34) Total lines: 191

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
Perform()0%506220%
ReadEntryPaths(...)0%156120%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.IO;
 4using System.Linq;
 5using System.Xml;
 6using Jellyfin.Database.Implementations;
 7using Jellyfin.Database.Implementations.Entities;
 8using MediaBrowser.Controller;
 9using Microsoft.EntityFrameworkCore;
 10using Microsoft.Extensions.Logging;
 11
 12namespace Jellyfin.Server.Migrations.Routines;
 13
 14/// <summary>
 15/// Restores playlist entries from playlist.xml for playlists that lost all of their children.
 16/// </summary>
 17[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))]
 18internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine
 19{
 20    private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist";
 21    private const string PlaylistFileName = "playlist.xml";
 22
 23    private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger;
 24    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 25    private readonly IServerApplicationHost _appHost;
 26
 27    public RestorePlaylistChildrenFromMetadata(
 28        ILoggerFactory loggerFactory,
 29        IDbContextFactory<JellyfinDbContext> dbProvider,
 30        IServerApplicationHost appHost)
 31    {
 032        _logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>();
 033        _dbProvider = dbProvider;
 034        _appHost = appHost;
 035    }
 36
 37    /// <inheritdoc/>
 38    public void Perform()
 39    {
 040        using var context = _dbProvider.CreateDbContext();
 41
 042        var playlists = context.BaseItems
 043            .Where(b => b.Type == PlaylistTypeName && b.Path != null)
 044            .Select(b => new { b.Id, b.Name, b.Path })
 045            .ToList();
 46
 047        if (playlists.Count == 0)
 48        {
 049            return;
 50        }
 51
 052        var childCountByPlaylist = context.LinkedChildren
 053            .Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName))
 054            .GroupBy(lc => lc.ParentId)
 055            .Select(g => new { ParentId = g.Key, Count = g.Count() })
 056            .ToDictionary(g => g.ParentId, g => g.Count);
 57
 058        var pathToIdMap = context.BaseItems
 059            .Where(b => b.Path != null)
 060            .Select(b => new { b.Id, b.Path })
 061            .GroupBy(b => b.Path!)
 062            .ToDictionary(g => g.Key, g => g.First().Id);
 63
 064        var restoredPlaylists = 0;
 065        var restoredEntries = 0;
 66
 067        foreach (var playlist in playlists)
 68        {
 69            // Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml.
 70            // A playlist that is itself a file (.m3u and friends) is re-read by the library scan.
 071            var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!);
 072            var metadataPath = Path.Combine(playlistPath, PlaylistFileName);
 073            if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath))
 74            {
 75                continue;
 76            }
 77
 078            var storedPaths = ReadEntryPaths(metadataPath, playlist.Id);
 079            if (storedPaths.Count == 0)
 80            {
 81                continue;
 82            }
 83
 084            var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id);
 085            if (childCount > 0)
 86            {
 87                // Merging into a playlist that still has entries would resurrect anything the user
 88                // removed while the metadata file was not rewritten, and there is no way to tell the
 89                // two apart. Report the mismatch instead so it can be checked by hand.
 090                if (storedPaths.Count > childCount)
 91                {
 092                    _logger.LogWarning(
 093                        "Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {Sto
 094                        playlist.Name,
 095                        playlist.Id,
 096                        childCount,
 097                        metadataPath,
 098                        storedPaths.Count);
 99                }
 100
 0101                continue;
 102            }
 103
 0104            var sortOrder = 0;
 0105            foreach (var storedPath in storedPaths)
 106            {
 0107                if (!pathToIdMap.TryGetValue(storedPath, out var childId))
 108                {
 0109                    _logger.LogWarning(
 0110                        "Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.",
 0111                        storedPath,
 0112                        playlist.Name);
 0113                    continue;
 114                }
 115
 0116                context.LinkedChildren.Add(new LinkedChildEntity
 0117                {
 0118                    ParentId = playlist.Id,
 0119                    ChildId = childId,
 0120                    ChildType = LinkedChildType.Manual,
 0121                    SortOrder = sortOrder
 0122                });
 123
 0124                sortOrder++;
 125            }
 126
 0127            if (sortOrder > 0)
 128            {
 0129                restoredPlaylists++;
 0130                restoredEntries += sortOrder;
 0131                _logger.LogInformation(
 0132                    "Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.",
 0133                    sortOrder,
 0134                    playlist.Name,
 0135                    playlist.Id,
 0136                    metadataPath);
 137            }
 138        }
 139
 0140        if (restoredEntries > 0)
 141        {
 0142            context.SaveChanges();
 0143            _logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, r
 144        }
 0145    }
 146
 147    private List<string> ReadEntryPaths(string metadataPath, Guid playlistId)
 148    {
 0149        var paths = new List<string>();
 0150        var settings = new XmlReaderSettings
 0151        {
 0152            IgnoreComments = true,
 0153            IgnoreWhitespace = true,
 0154            IgnoreProcessingInstructions = true,
 0155            DtdProcessing = DtdProcessing.Prohibit
 0156        };
 157
 158        try
 159        {
 0160            using var reader = XmlReader.Create(metadataPath, settings);
 0161            var inEntry = false;
 0162            while (reader.Read())
 163            {
 0164                if (reader.NodeType != XmlNodeType.Element)
 165                {
 166                    continue;
 167                }
 168
 0169                if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal))
 170                {
 0171                    inEntry = true;
 172                }
 0173                else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal))
 174                {
 0175                    inEntry = false;
 0176                    var value = reader.ReadElementContentAsString();
 0177                    if (!string.IsNullOrWhiteSpace(value))
 178                    {
 0179                        paths.Add(value.Trim());
 180                    }
 181                }
 182            }
 0183        }
 0184        catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
 185        {
 0186            _logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadata
 0187        }
 188
 0189        return paths;
 190    }
 191}