From dc300fae53b6b42090341cb517238bbc76479e02 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 23 Jul 2026 13:35:43 +0200 Subject: Allow duplicate LinkedChildren for Playlists --- Emby.Server.Implementations/Playlists/PlaylistManager.cs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 409414139c..308faed8cc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists var playlist = _libraryManager.GetItemById(playlistId) as Playlist ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); - // Retrieve all the items to be added to the playlist + // Retrieve all the items to be added to the playlist. var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); - // Filter out duplicate items - var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); - newItems = newItems - .Where(i => !existingIds.Contains(i.Id)) - .Distinct(); - // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems .Select(LinkedChild.Create) .ToList(); - // Log duplicates that have been ignored, if any - int numDuplicates = newItemIds.Count - childrenToAdd.Count; - if (numDuplicates > 0) - { - _logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name); - } - // Do nothing else if there are no items to add to the playlist if (childrenToAdd.Count == 0) { -- cgit v1.2.3 From 8293eb26b995a21f88bff60dcf93da8d98080cc0 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 29 Jul 2026 14:40:49 +0200 Subject: Fix playlist entries being lost on migration and library scans --- .../Library/Resolvers/PlaylistResolver.cs | 14 ++ .../Item/BaseItemMapper.cs | 2 +- .../Item/ItemPersistenceService.cs | 56 ++++- .../20260113120000_MigrateLinkedChildren.cs | 239 +++++++++++++-------- ...29120000_RestorePlaylistChildrenFromMetadata.cs | 191 ++++++++++++++++ MediaBrowser.Controller/Entities/Folder.cs | 28 ++- 6 files changed, 426 insertions(+), 104 deletions(-) create mode 100644 Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 74c1f69616..d6513fc79c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; @@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers }; } + // Anything directly inside the internal playlists folder is a playlist, even when its + // playlist.xml is missing: failing to resolve here makes the library scan treat the + // playlist as deleted from disk and remove it, taking its items with it. + if (args.Parent is PlaylistsFolder) + { + return new Playlist + { + Path = args.Path, + Name = filename, + OpenAccess = true + }; + } + // It's a directory-based playlist if the directory contains a playlist file IEnumerable filePaths; try diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index c64e6ac068..958d11e21e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -183,7 +183,7 @@ public static class BaseItemMapper if (dto is Folder folder) { folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0) + if (entity.LinkedChildEntities is not null) { folder.LinkedChildren = entity.LinkedChildEntities .OrderBy(e => e.SortOrder) diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index 9201a031d5..827c766449 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -428,23 +428,60 @@ public class ItemPersistenceService : IItemPersistenceService foreach (var item in tuples) { + // A container that was never hydrated cannot be used to rewrite its links: its empty + // array means "unknown", so clearing the stored rows would silently empty the item. + if (item.Item is Folder { LinkedChildrenLoaded: false }) + { + continue; + } + if (item.Item is Folder or Video && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks) && existingLinks.Count > 0) { - context.LinkedChildren.RemoveRange(existingLinks); + // A video only owns its alternate version links; any other link on that parent is + // written by the folder branch below and must survive. + var staleLinks = item.Item is Folder + ? existingLinks + : existingLinks + .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion) + .ToList(); + + if (staleLinks.Count > 0) + { + context.LinkedChildren.RemoveRange(staleLinks); + } } } context.SaveChanges(); + // A LinkedChild's ItemId is only a cache. + var cachedChildIds = tuples + .Select(t => t.Item) + .OfType() + .Where(f => f.LinkedChildrenLoaded) + .SelectMany(f => f.LinkedChildren) + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .Distinct() + .ToList(); + + var knownChildIds = cachedChildIds.Count > 0 + ? context.BaseItems + .WhereOneOrMany(cachedChildIds, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; + foreach (var item in tuples) { - if (item.Item is Folder folder && folder.LinkedChildren.Length > 0) + if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0) { #pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data var pathsToResolve = folder.LinkedChildren - .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path)) + .Where(lc => !string.IsNullOrEmpty(lc.Path) + && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value))) .Select(lc => lc.Path) .Distinct() .ToList(); @@ -461,12 +498,16 @@ public class ItemPersistenceService : IItemPersistenceService foreach (var linkedChild in folder.LinkedChildren) { var childItemId = linkedChild.ItemId; - if (!childItemId.HasValue || childItemId.Value.IsEmpty()) + if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value)) { if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) { childItemId = resolvedId; } + else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty()) + { + childItemId = libraryItemId; + } } #pragma warning restore CS0618 @@ -500,11 +541,14 @@ public class ItemPersistenceService : IItemPersistenceService { if (!existingChildIds.Contains(childId)) { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics _logger.LogWarning( - "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database", + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database", item.Item.Name, item.Item.Id, - childId); + childId, + linkedChild.Path ?? "unknown"); +#pragma warning restore CS0618 continue; } diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index d13c6cf700..4a6e74c229 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -63,7 +63,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var itemsWithData = context.BaseItems .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type))) - .Select(b => new { b.Id, b.Data, b.Type }) + .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder }) .ToList(); _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count); @@ -74,6 +74,15 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .GroupBy(b => b.Path!) .ToDictionary(g => g.Key, g => g.First().Id); + // Needed to tell a stale cached ItemId apart from one that still points at a real item. + var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet(); + + var playlistParentIds = itemsWithData + .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist") + .Select(b => b.Id) + .ToHashSet(); + + var droppedChildren = 0; var linkedChildrenToAdd = new List(); var processedCount = 0; const int progressLogStep = 1000; @@ -100,7 +109,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Handle Video alternate versions if (isVideo) { - ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd); + ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd); } // Handle LinkedChildren (for containers and other items) @@ -110,45 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine continue; } + // Legacy entries may hold a path relative to the container that holds them, so the + // container's own location has to be a real path, not a virtual one. + var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path); + var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath); var sortOrder = 0; foreach (var childElement in linkedChildrenElement.EnumerateArray()) { - Guid? childId = null; - if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds); + if (!childId.HasValue) { + droppedChildren++; + _logger.LogWarning( + "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}", + item.Id, + GetStringProperty(childElement, "ItemId") ?? "none", + GetStringProperty(childElement, "Path") ?? "none"); continue; } @@ -196,23 +182,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(lc => new { lc.ParentId, lc.ChildId }) .ToHashSet(); + // A playlist may list the same child more than once, so it cannot be keyed by + // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which + // keeps the routine re-runnable without collapsing repeated entries. + var populatedParentIds = context.LinkedChildren + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + var toInsert = linkedChildrenToAdd - .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) + .Where(lc => playlistParentIds.Contains(lc.ParentId) + ? !populatedParentIds.Contains(lc.ParentId) + : !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) .ToList(); if (toInsert.Count > 0) { - // Deduplicate by composite key (ParentId, ChildId) + // Every container type other than a playlist keeps a single entry per child. // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other - toInsert = toInsert - .OrderBy(lc => lc.ChildType switch - { - LinkedChildType.LocalAlternateVersion => 0, - LinkedChildType.LinkedAlternateVersion => 1, - _ => 2 - }) - .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) - .ToList(); + toInsert = + [ + .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)), + .. toInsert + .Where(lc => !playlistParentIds.Contains(lc.ParentId)) + .OrderBy(lc => lc.ChildType switch + { + LinkedChildType.LocalAlternateVersion => 0, + LinkedChildType.LinkedAlternateVersion => 1, + _ => 2 + }) + .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) + ]; var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); var existingChildIds = context.BaseItems @@ -266,7 +266,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("No LinkedChildren data found to migrate."); } - _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); + _logger.LogInformation( + "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.", + processedCount, + droppedChildren); CleanupWrongTypeAlternateVersions(context); CleanupOrphanedAlternateVersionBaseItems(context); @@ -417,6 +420,12 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var internalMetadataPath = _appPaths.InternalMetadataPath; + // An item outside every library location is normally left over from a removed media path, but + // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the + // first container start, for example). Only act on it while every location is readable. + var canRemoveUnrootedItems = inaccessiblePaths.Count == 0; + var skippedUnrootedItems = 0; + var staleIds = new List(); foreach (var item in itemsWithPaths) { @@ -435,6 +444,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Directory check covers BDMV/DVD items whose Path points to a folder if (!File.Exists(path) && !Directory.Exists(path)) { + _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path); staleIds.Add(item.Id); } } @@ -442,12 +452,28 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { // Item is not under ANY library location (accessible or not) — // it's orphaned from all libraries (e.g. media path was removed from config) - staleIds.Add(item.Id); + if (canRemoveUnrootedItems) + { + _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path); + staleIds.Add(item.Id); + } + else + { + skippedUnrootedItems++; + } } // Otherwise: item is under an inaccessible location — skip (storage may be offline) } + if (skippedUnrootedItems > 0) + { + _logger.LogWarning( + "Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.", + skippedUnrootedItems, + inaccessiblePaths.Count); + } + if (staleIds.Count == 0) { _logger.LogInformation("No stale items found."); @@ -517,18 +543,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine orphanedLinkedChildren.AddRange(orphanedByParent); } - // Remove all orphaned records - var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList(); + // Remove all orphaned records. Both queries can return the same row, and a playlist may hold + // several rows for one child, so the position is what identifies an entry here. + var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList(); context.LinkedChildren.RemoveRange(distinctOrphaned); context.SaveChanges(); _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count); } + /// + /// Resolves the item a legacy LinkedChild entry points at. + /// + private static Guid? ResolveChildId( + JsonElement childElement, + string? containingFolderPath, + Dictionary pathToIdMap, + HashSet allItemIds) + { + // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value + // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore + // fall through to the path, or the entry is lost even though its file is still in the library. + if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId)) + { + return itemId; + } + + var path = GetStringProperty(childElement, "Path"); + if (!string.IsNullOrEmpty(path)) + { + if (pathToIdMap.TryGetValue(path, out var idByPath)) + { + return idByPath; + } + + // 10.x resolved entries relative to the container that holds them. + if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath)) + { + string? absolutePath = null; + try + { + absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path)); + } + catch (ArgumentException) + { + // Malformed path, nothing to resolve. + } + + if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath)) + { + return idByAbsolutePath; + } + } + } + + if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId)) + { + return libraryItemId; + } + + return null; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value) + { + value = Guid.Empty; + var raw = GetStringProperty(element, propertyName); + + return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty(); + } + private void ProcessVideoAlternateVersions( JsonElement root, Guid parentId, Dictionary pathToIdMap, + HashSet allItemIds, List linkedChildrenToAdd) { int sortOrder = 0; @@ -581,45 +675,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray()) { - Guid? childId = null; - - // Try to get ItemId - if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - // Try to get from Path if ItemId not available - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - // Try LibraryItemId as fallback - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds); + if (!childId.HasValue) { _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId); continue; diff --git a/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs new file mode 100644 index 0000000000..16ac6cb5e5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Restores playlist entries from playlist.xml for playlists that lost all of their children. +/// +[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))] +internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine +{ + private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist"; + private const string PlaylistFileName = "playlist.xml"; + + private readonly ILogger _logger; + private readonly IDbContextFactory _dbProvider; + private readonly IServerApplicationHost _appHost; + + public RestorePlaylistChildrenFromMetadata( + ILoggerFactory loggerFactory, + IDbContextFactory dbProvider, + IServerApplicationHost appHost) + { + _logger = loggerFactory.CreateLogger(); + _dbProvider = dbProvider; + _appHost = appHost; + } + + /// + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + var playlists = context.BaseItems + .Where(b => b.Type == PlaylistTypeName && b.Path != null) + .Select(b => new { b.Id, b.Name, b.Path }) + .ToList(); + + if (playlists.Count == 0) + { + return; + } + + var childCountByPlaylist = context.LinkedChildren + .Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(g => g.ParentId, g => g.Count); + + var pathToIdMap = context.BaseItems + .Where(b => b.Path != null) + .Select(b => new { b.Id, b.Path }) + .GroupBy(b => b.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + var restoredPlaylists = 0; + var restoredEntries = 0; + + foreach (var playlist in playlists) + { + // Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml. + // A playlist that is itself a file (.m3u and friends) is re-read by the library scan. + var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!); + var metadataPath = Path.Combine(playlistPath, PlaylistFileName); + if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath)) + { + continue; + } + + var storedPaths = ReadEntryPaths(metadataPath, playlist.Id); + if (storedPaths.Count == 0) + { + continue; + } + + var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id); + if (childCount > 0) + { + // Merging into a playlist that still has entries would resurrect anything the user + // removed while the metadata file was not rewritten, and there is no way to tell the + // two apart. Report the mismatch instead so it can be checked by hand. + if (storedPaths.Count > childCount) + { + _logger.LogWarning( + "Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.", + playlist.Name, + playlist.Id, + childCount, + metadataPath, + storedPaths.Count); + } + + continue; + } + + var sortOrder = 0; + foreach (var storedPath in storedPaths) + { + if (!pathToIdMap.TryGetValue(storedPath, out var childId)) + { + _logger.LogWarning( + "Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.", + storedPath, + playlist.Name); + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = playlist.Id, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + + sortOrder++; + } + + if (sortOrder > 0) + { + restoredPlaylists++; + restoredEntries += sortOrder; + _logger.LogInformation( + "Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.", + sortOrder, + playlist.Name, + playlist.Id, + metadataPath); + } + } + + if (restoredEntries > 0) + { + context.SaveChanges(); + _logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists); + } + } + + private List ReadEntryPaths(string metadataPath, Guid playlistId) + { + var paths = new List(); + var settings = new XmlReaderSettings + { + IgnoreComments = true, + IgnoreWhitespace = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit + }; + + try + { + using var reader = XmlReader.Create(metadataPath, settings); + var inEntry = false; + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + { + continue; + } + + if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal)) + { + inEntry = true; + } + else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal)) + { + inEntry = false; + var value = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(value)) + { + paths.Add(value.Trim()); + } + } + } + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId); + } + + return paths; + } +} diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..f475379cc3 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities public class Folder : BaseItem { private IEnumerable _children; - - public Folder() - { - LinkedChildren = Array.Empty(); - } + private LinkedChild[] _linkedChildren = []; public static IUserViewManager UserViewManager { get; set; } @@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the linked children. /// [JsonIgnore] - public LinkedChild[] LinkedChildren { get; set; } + public LinkedChild[] LinkedChildren + { + get => _linkedChildren; + set + { + _linkedChildren = value; + + // Assigning the collection means the caller knows the complete set of links. + LinkedChildrenLoaded = true; + } + } + + /// + /// Gets a value indicating whether holds the stored set of links. + /// + /// + /// An unloaded instance carries an empty array that means "unknown", not "no children" — + /// persisting it would delete every link the item has. + /// + [JsonIgnore] + public bool LinkedChildrenLoaded { get; private set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } -- cgit v1.2.3