Add LinkedChildren database table for normalized relationships

Introduces a new database table to store linked child relationships for
boxsets, playlists, and video alternate versions. This replaces the
JSON-serialized Data column approach with a proper relational structure.

- Add LinkedChildEntity and LinkedChildType enum
- Add entity configuration with proper foreign keys
- Add EF Core migration for SQLite
This commit is contained in:
Shadowghost
2026-01-17 15:02:26 +01:00
parent 1491494bcb
commit cc2ccd1bf3
8 changed files with 2070 additions and 1 deletions

View File

@@ -178,6 +178,16 @@ public class BaseItemEntity
public ICollection<BaseItemImageInfo>? Images { get; set; }
/// <summary>
/// Gets or sets the linked children (for BoxSets, Playlists, etc.).
/// </summary>
public ICollection<LinkedChildEntity>? LinkedChildEntities { get; set; }
/// <summary>
/// Gets or sets the items this entity is linked to as a child.
/// </summary>
public ICollection<LinkedChildEntity>? LinkedChildOfEntities { get; set; }
// those are references to __LOCAL__ ids not DB ids ... TODO: Bring the whole folder structure into the DB
// public ICollection<BaseItemEntity>? SeriesEpisodes { get; set; }
// public BaseItemEntity? Series { get; set; }

View File

@@ -0,0 +1,39 @@
using System;
namespace Jellyfin.Database.Implementations.Entities;
/// <summary>
/// Represents a linked child relationship between items (e.g., BoxSet to Movies, Playlist to tracks).
/// </summary>
public class LinkedChildEntity
{
/// <summary>
/// Gets or sets the parent item ID (BoxSet, Playlist, etc.).
/// </summary>
public required Guid ParentId { get; set; }
/// <summary>
/// Gets or sets the child item ID.
/// </summary>
public required Guid ChildId { get; set; }
/// <summary>
/// Gets or sets the type of linked child (Manual or Shortcut).
/// </summary>
public required LinkedChildType ChildType { get; set; }
/// <summary>
/// Gets or sets the sort order.
/// </summary>
public int? SortOrder { get; set; }
/// <summary>
/// Gets or sets the parent item navigation property.
/// </summary>
public BaseItemEntity? Parent { get; set; }
/// <summary>
/// Gets or sets the child item navigation property.
/// </summary>
public BaseItemEntity? Child { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace Jellyfin.Database.Implementations.Entities;
/// <summary>
/// The linked child type.
/// </summary>
public enum LinkedChildType
{
/// <summary>
/// Manually linked child.
/// </summary>
Manual = 0,
/// <summary>
/// Shortcut linked child.
/// </summary>
Shortcut = 1,
/// <summary>
/// Local alternate version (same item, different file path).
/// </summary>
LocalAlternateVersion = 2,
/// <summary>
/// Linked alternate version (different item ID).
/// </summary>
LinkedAlternateVersion = 3
}

View File

@@ -143,6 +143,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
/// </summary>
public DbSet<PeopleBaseItemMap> PeopleBaseItemMap => Set<PeopleBaseItemMap>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing linked children relationships.
/// </summary>
public DbSet<LinkedChildEntity> LinkedChildren => Set<LinkedChildEntity>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing the referenced Providers with ids.
/// </summary>

View File

@@ -0,0 +1,33 @@
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Jellyfin.Database.Implementations.ModelConfiguration;
/// <summary>
/// LinkedChildEntity configuration.
/// </summary>
public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEntity>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<LinkedChildEntity> builder)
{
builder.ToTable("LinkedChildren");
builder.HasKey(e => new { e.ParentId, e.ChildId });
builder.HasIndex(e => e.ParentId);
builder.HasIndex(e => e.ChildId);
builder.HasIndex(e => new { e.ParentId, e.SortOrder });
builder.HasIndex(e => new { e.ParentId, e.ChildType });
builder.HasIndex(e => new { e.ChildId, e.ChildType });
builder.HasOne(e => e.Parent)
.WithMany(e => e.LinkedChildEntities)
.HasForeignKey(e => e.ParentId)
.OnDelete(DeleteBehavior.NoAction);
builder.HasOne(e => e.Child)
.WithMany(e => e.LinkedChildOfEntities)
.HasForeignKey(e => e.ChildId)
.OnDelete(DeleteBehavior.NoAction);
}
}

View File

@@ -0,0 +1,126 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddLinkedChildrenTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LinkedChildren",
columns: table => new
{
ParentId = table.Column<Guid>(type: "TEXT", nullable: false),
ChildId = table.Column<Guid>(type: "TEXT", nullable: false),
ChildType = table.Column<int>(type: "INTEGER", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkedChildren", x => new { x.ParentId, x.ChildId });
table.ForeignKey(
name: "FK_LinkedChildren_BaseItems_ChildId",
column: x => x.ChildId,
principalTable: "BaseItems",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LinkedChildren_BaseItems_ParentId",
column: x => x.ParentId,
principalTable: "BaseItems",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ChildId",
table: "LinkedChildren",
column: "ChildId");
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ChildId_ChildType",
table: "LinkedChildren",
columns: new[] { "ChildId", "ChildType" });
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ParentId",
table: "LinkedChildren",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ParentId_ChildType",
table: "LinkedChildren",
columns: new[] { "ParentId", "ChildType" });
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ParentId_SortOrder",
table: "LinkedChildren",
columns: new[] { "ParentId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Re-populate LinkedChildren data back into the JSON Data column before dropping the table
migrationBuilder.Sql(
@"UPDATE BaseItems
SET Data = CASE
WHEN Data IS NULL OR Data = '' THEN
json_object('LinkedChildren', (
SELECT json_group_array(
json_object(
'Path', Child.Path,
'Type', CASE LC.ChildType
WHEN 0 THEN 'Manual'
WHEN 1 THEN 'Shortcut'
ELSE 'Manual'
END,
'ItemId', LOWER(REPLACE(LC.ChildId, '-', ''))
)
)
FROM LinkedChildren LC
INNER JOIN BaseItems Child ON LC.ChildId = Child.Id
WHERE LC.ParentId = BaseItems.Id
ORDER BY LC.SortOrder
))
ELSE
json_set(
Data,
'$.LinkedChildren',
(
SELECT json_group_array(
json_object(
'Path', Child.Path,
'Type', CASE LC.ChildType
WHEN 0 THEN 'Manual'
WHEN 1 THEN 'Shortcut'
ELSE 'Manual'
END,
'ItemId', LOWER(REPLACE(LC.ChildId, '-', ''))
)
)
FROM LinkedChildren LC
INNER JOIN BaseItems Child ON LC.ChildId = Child.Id
WHERE LC.ParentId = BaseItems.Id
ORDER BY LC.SortOrder
)
)
END
WHERE EXISTS (
SELECT 1
FROM LinkedChildren LC
WHERE LC.ParentId = BaseItems.Id
)");
migrationBuilder.DropTable(
name: "LinkedChildren");
migrationBuilder.Sql(
@"DELETE FROM __EFMigrationsHistory
WHERE MigrationId = '20260113120000_MigrateLinkedChildren'");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -782,6 +782,37 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
{
b.Property<Guid>("ParentId")
.HasColumnType("TEXT");
b.Property<Guid>("ChildId")
.HasColumnType("TEXT");
b.Property<int>("ChildType")
.HasColumnType("INTEGER");
b.Property<int?>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("ParentId", "ChildId");
b.HasIndex("ChildId");
b.HasIndex("ParentId");
b.HasIndex("ChildId", "ChildType");
b.HasIndex("ParentId", "ChildType");
b.HasIndex("ParentId", "SortOrder");
b.ToTable("LinkedChildren", (string)null);
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
{
b.Property<Guid>("Id")
@@ -1580,6 +1611,25 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Navigation("Item");
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
{
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child")
.WithMany("LinkedChildOfEntities")
.HasForeignKey("ChildId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent")
.WithMany("LinkedChildEntities")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("Child");
b.Navigation("Parent");
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
{
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
@@ -1668,6 +1718,10 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Navigation("ItemValues");
b.Navigation("LinkedChildEntities");
b.Navigation("LinkedChildOfEntities");
b.Navigation("LockedFields");
b.Navigation("MediaStreams");