Allow duplicate LinkedChildren for Playlists
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -428,106 +428,100 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
if (item.Item is Folder folder)
|
||||
if (item.Item is Folder or Video
|
||||
&& allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
|
||||
&& existingLinks.Count > 0)
|
||||
{
|
||||
context.LinkedChildren.RemoveRange(existingLinks);
|
||||
}
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
if (item.Item is Folder folder && folder.LinkedChildren.Length > 0)
|
||||
{
|
||||
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()
|
||||
.ToList();
|
||||
var pathsToResolve = folder.LinkedChildren
|
||||
.Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path))
|
||||
.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 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 resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
|
||||
foreach (var linkedChild in folder.LinkedChildren)
|
||||
{
|
||||
var childItemId = linkedChild.ItemId;
|
||||
if (!childItemId.HasValue || childItemId.Value.IsEmpty())
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
|
||||
{
|
||||
resolvedChildren.Add((linkedChild, childItemId.Value));
|
||||
childItemId = resolvedId;
|
||||
}
|
||||
}
|
||||
#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))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
|
||||
item.Item.Name,
|
||||
item.Item.Id,
|
||||
childId);
|
||||
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 +571,7 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
int sortOrder = 0;
|
||||
var sortOrder = 0;
|
||||
foreach (var (childId, childType) in newLinkedChildren)
|
||||
{
|
||||
if (!existingChildIds.Contains(childId))
|
||||
@@ -590,36 +584,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,6 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
continue;
|
||||
}
|
||||
|
||||
var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
|
||||
var sortOrder = 0;
|
||||
foreach (var childElement in linkedChildrenElement.EnumerateArray())
|
||||
{
|
||||
@@ -175,7 +174,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
ParentId = item.Id,
|
||||
ChildId = childId.Value,
|
||||
ChildType = childType,
|
||||
SortOrder = isPlaylist ? sortOrder : null
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
|
||||
sortOrder++;
|
||||
|
||||
+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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
||||
{
|
||||
@@ -812,23 +812,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