mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-18 05:30:34 +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.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.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
|
||||||
dto.Studios = entity.Studios?.Split('|') ?? [];
|
dto.Studios = entity.Studios?.Split('|') ?? [];
|
||||||
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
||||||
@@ -1043,7 +1042,6 @@ public sealed class BaseItemRepository
|
|||||||
entity.ExtraType = (BaseItemExtraType)dto.ExtraType;
|
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.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.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
||||||
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : 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))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
@@ -2585,12 +2594,12 @@ public sealed class BaseItemRepository
|
|||||||
if (filter.HasSpecialFeature.Value)
|
if (filter.HasSpecialFeature.Value)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => e.ExtraIds != null);
|
.Where(e => e.Extras != null && e.Extras.Count > 0);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
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())
|
if (filter.HasTrailer.GetValueOrDefault() || filter.HasThemeSong.GetValueOrDefault() || filter.HasThemeVideo.GetValueOrDefault())
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => e.ExtraIds != null);
|
.Where(e => e.Extras != null && e.Extras.Count > 0);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
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;
|
entity.ProductionLocations = productionLocations;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var extraIds))
|
// Skip ExtraIds column (removed - extras are now tracked via OwnerId relationship)
|
||||||
{
|
index++;
|
||||||
entity.ExtraIds = extraIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(index++, out var totalBitrate))
|
if (reader.TryGetInt32(index++, out var totalBitrate))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
ImageInfos = Array.Empty<ItemImageInfo>();
|
ImageInfos = Array.Empty<ItemImageInfo>();
|
||||||
ProductionLocations = Array.Empty<string>();
|
ProductionLocations = Array.Empty<string>();
|
||||||
RemoteTrailers = Array.Empty<MediaUrl>();
|
RemoteTrailers = Array.Empty<MediaUrl>();
|
||||||
ExtraIds = Array.Empty<Guid>();
|
|
||||||
UserData = [];
|
UserData = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,8 +397,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
|
|
||||||
public Guid[] ExtraIds { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the primary image path.
|
/// Gets the primary image path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1396,7 +1393,13 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
|
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
|
||||||
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
|
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)
|
if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
|
||||||
{
|
{
|
||||||
@@ -1418,8 +1421,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
|
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup removed extras
|
var removedExtraIds = currentExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
|
||||||
var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
|
|
||||||
if (removedExtraIds.Length > 0)
|
if (removedExtraIds.Length > 0)
|
||||||
{
|
{
|
||||||
var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery()
|
var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery()
|
||||||
@@ -1437,8 +1439,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
|
|
||||||
item.ExtraIds = newExtraIds;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2668,10 +2668,11 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
/// <returns>An enumerable containing the items.</returns>
|
/// <returns>An enumerable containing the items.</returns>
|
||||||
public IEnumerable<BaseItem> GetExtras()
|
public IEnumerable<BaseItem> GetExtras()
|
||||||
{
|
{
|
||||||
return ExtraIds
|
return LibraryManager.GetItemList(new InternalItemsQuery()
|
||||||
.Select(LibraryManager.GetItemById)
|
{
|
||||||
.Where(i => i is not null)
|
OwnerIds = [Id],
|
||||||
.OrderBy(i => i.SortName);
|
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2681,11 +2682,12 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
/// <returns>An enumerable containing the extras.</returns>
|
/// <returns>An enumerable containing the extras.</returns>
|
||||||
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
|
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
|
||||||
{
|
{
|
||||||
return ExtraIds
|
return LibraryManager.GetItemList(new InternalItemsQuery()
|
||||||
.Select(LibraryManager.GetItemById)
|
{
|
||||||
.Where(i => i is not null)
|
OwnerIds = [Id],
|
||||||
.Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value))
|
ExtraTypes = extraTypes.ToArray(),
|
||||||
.OrderBy(i => i.SortName);
|
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual long GetRunTimeTicksForPlayState()
|
public virtual long GetRunTimeTicksForPlayState()
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
ImageTypes = Array.Empty<ImageType>();
|
ImageTypes = Array.Empty<ImageType>();
|
||||||
IncludeItemTypes = Array.Empty<BaseItemKind>();
|
IncludeItemTypes = Array.Empty<BaseItemKind>();
|
||||||
ItemIds = Array.Empty<Guid>();
|
ItemIds = Array.Empty<Guid>();
|
||||||
|
OwnerIds = Array.Empty<Guid>();
|
||||||
|
ExtraTypes = Array.Empty<ExtraType>();
|
||||||
MediaTypes = Array.Empty<MediaType>();
|
MediaTypes = Array.Empty<MediaType>();
|
||||||
OfficialRatings = Array.Empty<string>();
|
OfficialRatings = Array.Empty<string>();
|
||||||
OrderBy = Array.Empty<(ItemSortBy, SortOrder)>();
|
OrderBy = Array.Empty<(ItemSortBy, SortOrder)>();
|
||||||
@@ -133,6 +135,10 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public Guid[] ItemIds { get; set; }
|
public Guid[] ItemIds { get; set; }
|
||||||
|
|
||||||
|
public Guid[] OwnerIds { get; set; }
|
||||||
|
|
||||||
|
public ExtraType[] ExtraTypes { get; set; }
|
||||||
|
|
||||||
public Guid[] ExcludeItemIds { get; set; }
|
public Guid[] ExcludeItemIds { get; set; }
|
||||||
|
|
||||||
public Guid? AdjacentTo { get; set; }
|
public Guid? AdjacentTo { get; set; }
|
||||||
|
|||||||
@@ -118,8 +118,6 @@ public class BaseItemEntity
|
|||||||
|
|
||||||
public string? ProductionLocations { get; set; }
|
public string? ProductionLocations { get; set; }
|
||||||
|
|
||||||
public string? ExtraIds { get; set; }
|
|
||||||
|
|
||||||
public int? TotalBitrate { get; set; }
|
public int? TotalBitrate { get; set; }
|
||||||
|
|
||||||
public BaseItemExtraType? ExtraType { 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")
|
b.Property<string>("ExternalServiceId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ExtraIds")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int?>("ExtraType")
|
b.Property<int?>("ExtraType")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user