Merge pull request #17416 from Shadowghost/enable-duplicate-playlist-children

Allow duplicate LinkedChildren for Playlists
This commit is contained in:
Cody Robibero
2026-08-01 08:06:02 -04:00
committed by GitHub
14 changed files with 2414 additions and 284 deletions
@@ -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
@@ -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)
{
@@ -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)
@@ -428,106 +428,144 @@ public class ItemPersistenceService : IItemPersistenceService
foreach (var item in tuples)
{
if (item.Item is Folder folder)
// 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 })
{
var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
if (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))
.Select(lc => lc.Path)
.Distinct()
continue;
}
if (item.Item is Folder or Video
&& allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
&& existingLinks.Count > 0)
{
// 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();
var pathToIdMap = pathsToResolve.Count > 0
? context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id)
: [];
if (staleLinks.Count > 0)
{
context.LinkedChildren.RemoveRange(staleLinks);
}
}
}
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
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 { 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 => !string.IsNullOrEmpty(lc.Path)
&& (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)))
.Select(lc => lc.Path)
.Distinct()
.ToList();
var pathToIdMap = pathsToResolve.Count > 0
? context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id)
: [];
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value))
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty())
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
childItemId = resolvedId;
}
childItemId = resolvedId;
}
#pragma warning restore CS0618
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty())
{
resolvedChildren.Add((linkedChild, childItemId.Value));
childItemId = libraryItemId;
}
}
#pragma warning restore CS0618
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
{
resolvedChildren.Add((linkedChild, childItemId.Value));
}
}
// Playlists may legitimately contain the same item multiple times (e.g. a song repeated
// in an .m3u file). Every other container type keeps a single entry per child.
var isPlaylist = folder is Playlist;
if (!isPlaylist)
{
resolvedChildren = resolvedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.ToList();
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
var isPlaylist = folder is Playlist;
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
if (!existingChildIds.Contains(childId))
{
_logger.LogWarning(
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
item.Item.Name,
item.Item.Id,
childId);
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
{
context.LinkedChildren.Add(new LinkedChildEntity()
{
ParentId = item.Item.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)linkedChild.Type,
SortOrder = isPlaylist ? sortOrder : null
});
}
else
{
existingLink.SortOrder = isPlaylist ? sortOrder : null;
existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
existingLinkedChildren.Remove(existingLink);
}
sortOrder++;
}
}
if (existingLinkedChildren.Count > 0)
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
context.LinkedChildren.RemoveRange(existingLinkedChildren);
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} (path {ChildPath}) does not exist in database",
item.Item.Name,
item.Item.Id,
childId,
linkedChild.Path ?? "unknown");
#pragma warning restore CS0618
continue;
}
context.LinkedChildren.Add(new LinkedChildEntity()
{
ParentId = item.Item.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)linkedChild.Type,
SortOrder = sortOrder
});
sortOrder++;
}
}
if (item.Item is Video video)
{
var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>())
.Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
.ToList();
var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
if (video.LocalAlternateVersions.Length > 0)
@@ -577,7 +615,7 @@ public class ItemPersistenceService : IItemPersistenceService
.ToHashSet()
: [];
int sortOrder = 0;
var sortOrder = 0;
foreach (var (childId, childType) in newLinkedChildren)
{
if (!existingChildIds.Contains(childId))
@@ -590,36 +628,27 @@ public class ItemPersistenceService : IItemPersistenceService
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
context.LinkedChildren.Add(new LinkedChildEntity
{
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = video.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)childType,
SortOrder = sortOrder
});
}
else
{
existingLink.ChildType = (DbLinkedChildType)childType;
existingLink.SortOrder = sortOrder;
existingLinkedChildren.Remove(existingLink);
}
ParentId = video.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)childType,
SortOrder = sortOrder
});
sortOrder++;
}
if (existingLinkedChildren.Count > 0)
// A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
if (previousLinkedChildren is { Count: > 0 })
{
var orphanedLocalVersionIds = existingLinkedChildren
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
var orphanedLocalVersionIds = previousLinkedChildren
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId))
.Select(e => e.ChildId)
.ToList();
context.LinkedChildren.RemoveRange(existingLinkedChildren);
if (orphanedLocalVersionIds.Count > 0)
{
var orphanedItems = context.BaseItems
@@ -159,12 +159,16 @@ public class LinkedChildrenService : ILinkedChildrenService
if (existingLink is null)
{
var nextSortOrder = (context.LinkedChildren
.Where(lc => lc.ParentId == parentId)
.Max(lc => (int?)lc.SortOrder) ?? -1) + 1;
context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = dbChildType,
SortOrder = null
SortOrder = nextSortOrder
});
}
else
@@ -1,61 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Remove duplicate playlist entries.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")]
internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILibraryManager _libraryManager;
private readonly IPlaylistManager _playlistManager;
public RemoveDuplicatePlaylistChildren(
ILibraryManager libraryManager,
IPlaylistManager playlistManager)
{
_libraryManager = libraryManager;
_playlistManager = playlistManager;
}
/// <inheritdoc/>
public void Perform()
{
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Playlist]
})
.Cast<Playlist>()
.Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty))
.ToArray();
if (playlists.Length > 0)
{
foreach (var playlist in playlists)
{
var linkedChildren = playlist.LinkedChildren;
if (linkedChildren.Length > 0)
{
var newLinkedChildren = linkedChildren
.Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty))
.Concat(linkedChildren
.Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty))
.DistinctBy(c => c.ItemId))
.ToArray();
playlist.LinkedChildren = newLinkedChildren;
playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
_playlistManager.SavePlaylistFile(playlist);
}
}
}
}
}
@@ -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,46 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
continue;
}
var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
// 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;
}
@@ -175,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
ParentId = item.Id,
ChildId = childId.Value,
ChildType = childType,
SortOrder = isPlaylist ? sortOrder : null
SortOrder = sortOrder
});
sortOrder++;
@@ -197,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
@@ -267,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);
@@ -418,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)
{
@@ -436,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);
}
}
@@ -443,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.");
@@ -518,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;
@@ -582,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;
@@ -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;
}
}
+22 -6
View File
@@ -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; }
@@ -25,7 +25,7 @@ public class LinkedChildEntity
/// <summary>
/// Gets or sets the sort order.
/// </summary>
public int? SortOrder { get; set; }
public int SortOrder { get; set; }
/// <summary>
/// Gets or sets the parent item navigation property.
@@ -13,8 +13,7 @@ public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEnti
public void Configure(EntityTypeBuilder<LinkedChildEntity> builder)
{
builder.ToTable("LinkedChildren");
builder.HasKey(e => new { e.ParentId, e.ChildId });
builder.HasIndex(e => new { e.ParentId, e.SortOrder });
builder.HasKey(e => new { e.ParentId, e.SortOrder });
builder.HasIndex(e => new { e.ParentId, e.ChildType });
builder.HasIndex(e => new { e.ChildId, e.ChildType });
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AllowDuplicatePlaylistChildren : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Rows that predate the composite (ParentId, SortOrder) primary key stored a null SortOrder
// (e.g. BoxSet and Collection children). Assign each such row a stable 0-based position within
// its parent so the rows stay unique once SortOrder becomes part of the primary key; otherwise
// they would all collapse to the column default (0) and collide during the table rebuild.
migrationBuilder.Sql(
@"UPDATE ""LinkedChildren""
SET ""SortOrder"" = (
SELECT COUNT(*)
FROM ""LinkedChildren"" AS lc2
WHERE lc2.""ParentId"" = ""LinkedChildren"".""ParentId""
AND lc2.""rowid"" < ""LinkedChildren"".""rowid""
)
WHERE ""SortOrder"" IS NULL;");
migrationBuilder.DropPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren");
migrationBuilder.DropIndex(
name: "IX_LinkedChildren_ParentId_SortOrder",
table: "LinkedChildren");
migrationBuilder.AlterColumn<int>(
name: "SortOrder",
table: "LinkedChildren",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AddPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren",
columns: new[] { "ParentId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// The (ParentId, ChildId) primary key cannot represent the same child more than once per
// parent. Drop any duplicate entries (keeping the first by SortOrder) that may have been
// created while duplicates were allowed, so the old key can be restored. This is lossy by
// nature — duplicate playlist entries cannot survive a downgrade.
migrationBuilder.Sql(
@"DELETE FROM ""LinkedChildren""
WHERE ""rowid"" NOT IN (
SELECT MIN(""rowid"")
FROM ""LinkedChildren""
GROUP BY ""ParentId"", ""ChildId""
);");
migrationBuilder.DropPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren");
migrationBuilder.AlterColumn<int>(
name: "SortOrder",
table: "LinkedChildren",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AddPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren",
columns: new[] { "ParentId", "ChildId" });
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ParentId_SortOrder",
table: "LinkedChildren",
columns: new[] { "ParentId", "SortOrder" });
}
}
}
@@ -815,23 +815,21 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<Guid>("ParentId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<Guid>("ChildId")
.HasColumnType("TEXT");
b.Property<int>("ChildType")
.HasColumnType("INTEGER");
b.Property<int?>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("ParentId", "ChildId");
b.HasKey("ParentId", "SortOrder");
b.HasIndex("ChildId", "ChildType");
b.HasIndex("ParentId", "ChildType");
b.HasIndex("ParentId", "SortOrder");
b.ToTable("LinkedChildren", (string)null);
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);