mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs
Normal file
119
Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user