Merge pull request #17416 from Shadowghost/enable-duplicate-playlist-children
Allow duplicate LinkedChildren for Playlists
This commit is contained in:
@@ -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;
|
||||
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-2
@@ -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 });
|
||||
|
||||
|
||||
+1808
File diff suppressed because it is too large
Load Diff
+89
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user