aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-07-29 14:40:49 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-07-29 14:40:49 +0200
commit8293eb26b995a21f88bff60dcf93da8d98080cc0 (patch)
tree6bd36928ddf44b9b729d36e47ce4d618cb10a465
parentdc300fae53b6b42090341cb517238bbc76479e02 (diff)
Fix playlist entries being lost on migration and library scans
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs14
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemMapper.cs2
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs56
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs239
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs191
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs28
6 files changed, 426 insertions, 104 deletions
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<string> 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<Folder>()
+ .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<LinkedChildEntity>();
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<Guid>();
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);
}
+ /// <summary>
+ /// Resolves the item a legacy LinkedChild entry points at.
+ /// </summary>
+ private static Guid? ResolveChildId(
+ JsonElement childElement,
+ string? containingFolderPath,
+ Dictionary<string, Guid> pathToIdMap,
+ HashSet<Guid> 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<string, Guid> pathToIdMap,
+ HashSet<Guid> allItemIds,
List<LinkedChildEntity> 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;
+
+/// <summary>
+/// Restores playlist entries from playlist.xml for playlists that lost all of their children.
+/// </summary>
+[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<RestorePlaylistChildrenFromMetadata> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IServerApplicationHost _appHost;
+
+ public RestorePlaylistChildrenFromMetadata(
+ ILoggerFactory loggerFactory,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IServerApplicationHost appHost)
+ {
+ _logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>();
+ _dbProvider = dbProvider;
+ _appHost = appHost;
+ }
+
+ /// <inheritdoc/>
+ 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<string> ReadEntryPaths(string metadataPath, Guid playlistId)
+ {
+ var paths = new List<string>();
+ 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<BaseItem> _children;
-
- public Folder()
- {
- LinkedChildren = Array.Empty<LinkedChild>();
- }
+ private LinkedChild[] _linkedChildren = [];
public static IUserViewManager UserViewManager { get; set; }
@@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities
/// Gets or sets the linked children.
/// </summary>
[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;
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links.
+ /// </summary>
+ /// <remarks>
+ /// An unloaded instance carries an empty array that means "unknown", not "no children" —
+ /// persisting it would delete every link the item has.
+ /// </remarks>
+ [JsonIgnore]
+ public bool LinkedChildrenLoaded { get; private set; }
[JsonIgnore]
public DateTime? DateLastMediaAdded { get; set; }