Remove ExtraIds column and use OwnerId relationship for extras

- Remove ExtraIds property from BaseItemEntity and BaseItem
- Update RefreshExtras to query via OwnerId instead of cached ExtraIds
- Update GetExtras methods to query database via OwnerIds filter
- Add OwnerIds and ExtraTypes filter support to InternalItemsQuery
- Add filter handling in BaseItemRepository for new query options
- Update HasSpecialFeature/HasTrailer filters to use Extras relationship
- Add CleanupOrphanedExtras migration routine
- Add database migration to drop ExtraIds column
This commit is contained in:
Shadowghost
2026-01-17 15:11:45 +01:00
parent 139d23ddc2
commit c350fd0f40
9 changed files with 1990 additions and 32 deletions

View File

@@ -881,7 +881,6 @@ public sealed class BaseItemRepository
dto.Audio = (ProgramAudio)entity.Audio;
}
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
dto.Studios = entity.Studios?.Split('|') ?? [];
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
@@ -1043,7 +1042,6 @@ public sealed class BaseItemRepository
entity.ExtraType = (BaseItemExtraType)dto.ExtraType;
}
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
@@ -2310,6 +2308,17 @@ public sealed class BaseItemRepository
}
}
if (filter.OwnerIds.Length > 0)
{
baseQuery = baseQuery.Where(e => e.OwnerId != null && filter.OwnerIds.Contains(e.OwnerId.Value));
}
if (filter.ExtraTypes.Length > 0)
{
var extraTypeValues = filter.ExtraTypes.Cast<BaseItemExtraType?>().ToArray();
baseQuery = baseQuery.Where(e => e.ExtraType != null && extraTypeValues.Contains(e.ExtraType));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
baseQuery = baseQuery
@@ -2585,12 +2594,12 @@ public sealed class BaseItemRepository
if (filter.HasSpecialFeature.Value)
{
baseQuery = baseQuery
.Where(e => e.ExtraIds != null);
.Where(e => e.Extras != null && e.Extras.Count > 0);
}
else
{
baseQuery = baseQuery
.Where(e => e.ExtraIds == null);
.Where(e => e.Extras == null || e.Extras.Count == 0);
}
}
@@ -2599,12 +2608,12 @@ public sealed class BaseItemRepository
if (filter.HasTrailer.GetValueOrDefault() || filter.HasThemeSong.GetValueOrDefault() || filter.HasThemeVideo.GetValueOrDefault())
{
baseQuery = baseQuery
.Where(e => e.ExtraIds != null);
.Where(e => e.Extras != null && e.Extras.Count > 0);
}
else
{
baseQuery = baseQuery
.Where(e => e.ExtraIds == null);
.Where(e => e.Extras == null || e.Extras.Count == 0);
}
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Migrations.Stages;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Removes orphaned extras (items with OwnerId pointing to non-existent items).
/// Must run before EF migrations that add FK constraints on OwnerId.
/// </summary>
[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.CoreInitialisation)]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class CleanupOrphanedExtras : IAsyncMigrationRoutine
{
private readonly IStartupLogger<CleanupOrphanedExtras> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="CleanupOrphanedExtras"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="itemRepository">The item repository.</param>
/// <param name="channelManager">The channel manager.</param>
/// <param name="recordingsManager">The recordings manager.</param>
/// <param name="mediaSourceManager">The media source manager.</param>
/// <param name="mediaSegmentManager">The media segments manager.</param>
/// <param name="configurationManager">The configuration manager.</param>
public CleanupOrphanedExtras(
IStartupLogger<CleanupOrphanedExtras> logger,
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILibraryManager libraryManager,
IItemRepository itemRepository,
IChannelManager channelManager,
IRecordingsManager recordingsManager,
IMediaSourceManager mediaSourceManager,
IMediaSegmentManager mediaSegmentManager,
IServerConfigurationManager configurationManager)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_libraryManager = libraryManager;
BaseItem.LibraryManager ??= libraryManager;
BaseItem.ItemRepository ??= itemRepository;
BaseItem.ChannelManager ??= channelManager;
BaseItem.MediaSourceManager ??= mediaSourceManager;
BaseItem.MediaSegmentManager ??= mediaSegmentManager;
BaseItem.ConfigurationManager ??= configurationManager;
Video.RecordingsManager ??= recordingsManager;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var orphanedItemIds = await context.BaseItems
.Where(b => b.OwnerId.HasValue && !b.OwnerId.Value.Equals(Guid.Empty))
.Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
.Select(b => b.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (orphanedItemIds.Count == 0)
{
_logger.LogInformation("No orphaned extras found, skipping migration.");
return;
}
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
var deleteOptions = new DeleteOptions
{
DeleteFileLocation = false // Extras don't have their own media files
};
var deletedCount = 0;
foreach (var itemId in orphanedItemIds)
{
cancellationToken.ThrowIfCancellationRequested();
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
_logger.LogDebug("Item {ItemId} not found in library, may have been already deleted", itemId);
continue;
}
try
{
_libraryManager.DeleteItem(item, deleteOptions, notifyParentItem: false);
deletedCount++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete orphaned item {ItemId} ({ItemName})", item.Id, item.Name);
}
}
_logger.LogInformation("Successfully removed {Count} orphaned extras", deletedCount);
}
}
}

View File

@@ -1176,10 +1176,8 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.ProductionLocations = productionLocations;
}
if (reader.TryGetString(index++, out var extraIds))
{
entity.ExtraIds = extraIds;
}
// Skip ExtraIds column (removed - extras are now tracked via OwnerId relationship)
index++;
if (reader.TryGetInt32(index++, out var totalBitrate))
{

View File

@@ -107,7 +107,6 @@ namespace MediaBrowser.Controller.Entities
ImageInfos = Array.Empty<ItemImageInfo>();
ProductionLocations = Array.Empty<string>();
RemoteTrailers = Array.Empty<MediaUrl>();
ExtraIds = Array.Empty<Guid>();
UserData = [];
}
@@ -398,8 +397,6 @@ namespace MediaBrowser.Controller.Entities
public int Height { get; set; }
public Guid[] ExtraIds { get; set; }
/// <summary>
/// Gets the primary image path.
/// </summary>
@@ -1396,7 +1393,13 @@ namespace MediaBrowser.Controller.Entities
{
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds);
var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = [item.Id]
}).Select(e => e.Id).ToArray();
var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x));
if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
{
@@ -1418,8 +1421,7 @@ namespace MediaBrowser.Controller.Entities
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
});
// Cleanup removed extras
var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
var removedExtraIds = currentExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
if (removedExtraIds.Length > 0)
{
var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery()
@@ -1437,8 +1439,6 @@ namespace MediaBrowser.Controller.Entities
await Task.WhenAll(tasks).ConfigureAwait(false);
item.ExtraIds = newExtraIds;
return true;
}
@@ -2668,10 +2668,11 @@ namespace MediaBrowser.Controller.Entities
/// <returns>An enumerable containing the items.</returns>
public IEnumerable<BaseItem> GetExtras()
{
return ExtraIds
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
.OrderBy(i => i.SortName);
return LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = [Id],
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
/// <summary>
@@ -2681,11 +2682,12 @@ namespace MediaBrowser.Controller.Entities
/// <returns>An enumerable containing the extras.</returns>
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
{
return ExtraIds
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
.Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value))
.OrderBy(i => i.SortName);
return LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = [Id],
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
public virtual long GetRunTimeTicksForPlayState()

View File

@@ -39,6 +39,8 @@ namespace MediaBrowser.Controller.Entities
ImageTypes = Array.Empty<ImageType>();
IncludeItemTypes = Array.Empty<BaseItemKind>();
ItemIds = Array.Empty<Guid>();
OwnerIds = Array.Empty<Guid>();
ExtraTypes = Array.Empty<ExtraType>();
MediaTypes = Array.Empty<MediaType>();
OfficialRatings = Array.Empty<string>();
OrderBy = Array.Empty<(ItemSortBy, SortOrder)>();
@@ -133,6 +135,10 @@ namespace MediaBrowser.Controller.Entities
public Guid[] ItemIds { get; set; }
public Guid[] OwnerIds { get; set; }
public ExtraType[] ExtraTypes { get; set; }
public Guid[] ExcludeItemIds { get; set; }
public Guid? AdjacentTo { get; set; }

View File

@@ -118,8 +118,6 @@ public class BaseItemEntity
public string? ProductionLocations { get; set; }
public string? ExtraIds { get; set; }
public int? TotalBitrate { get; set; }
public BaseItemExtraType? ExtraType { get; set; }

View File

@@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class DropExtraIdsColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExtraIds",
table: "BaseItems");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExtraIds",
table: "BaseItems",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "BaseItems",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "ExtraIds",
value: null);
}
}
}

View File

@@ -207,9 +207,6 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("ExternalServiceId")
.HasColumnType("TEXT");
b.Property<string>("ExtraIds")
.HasColumnType("TEXT");
b.Property<int?>("ExtraType")
.HasColumnType("INTEGER");