aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server/Migrations/Routines
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server/Migrations/Routines')
-rw-r--r--Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs112
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixAudioData.cs19
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs341
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs105
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs52
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs589
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs7
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs11
9 files changed, 1209 insertions, 30 deletions
diff --git a/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs b/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs
new file mode 100644
index 0000000000..14abaa7317
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+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 MediaBrowser.Model.IO;
+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="itemCountService">The item count service.</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>
+ /// <param name="fileSystem">The file system.</param>
+ public CleanupOrphanedExtras(
+ IStartupLogger<CleanupOrphanedExtras> logger,
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ ILibraryManager libraryManager,
+ IItemRepository itemRepository,
+ IItemCountService itemCountService,
+ IChannelManager channelManager,
+ IRecordingsManager recordingsManager,
+ IMediaSourceManager mediaSourceManager,
+ IMediaSegmentManager mediaSegmentManager,
+ IServerConfigurationManager configurationManager,
+ IFileSystem fileSystem)
+ {
+ _logger = logger;
+ _dbContextFactory = dbContextFactory;
+ _libraryManager = libraryManager;
+ BaseItem.LibraryManager ??= libraryManager;
+ BaseItem.ItemRepository ??= itemRepository;
+ BaseItem.ItemCountService ??= itemCountService;
+ BaseItem.ChannelManager ??= channelManager;
+ BaseItem.MediaSourceManager ??= mediaSourceManager;
+ BaseItem.MediaSegmentManager ??= mediaSegmentManager;
+ BaseItem.ConfigurationManager ??= configurationManager;
+ BaseItem.FileSystem ??= fileSystem;
+ 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);
+
+ // Batch-resolve items for metadata path cleanup, then delete all at once
+ var itemsToDelete = new List<BaseItem>();
+ foreach (var itemId in orphanedItemIds)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (item is not null)
+ {
+ itemsToDelete.Add(item);
+ }
+ }
+
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
+
+ _logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
index 05ded06ba8..d102e24b91 100644
--- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
@@ -1,10 +1,6 @@
-using System;
-using System.Globalization;
-using System.IO;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Enums;
-using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
@@ -23,16 +19,19 @@ namespace Jellyfin.Server.Migrations.Routines
#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILogger<FixAudioData> _logger;
- private readonly IServerApplicationPaths _applicationPaths;
private readonly IItemRepository _itemRepository;
+ private readonly IItemCountService _countService;
+ private readonly IItemPersistenceService _persistenceService;
public FixAudioData(
- IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
- IItemRepository itemRepository)
+ IItemRepository itemRepository,
+ IItemCountService countService,
+ IItemPersistenceService persistenceService)
{
- _applicationPaths = applicationPaths;
_itemRepository = itemRepository;
+ _countService = countService;
+ _persistenceService = persistenceService;
_logger = loggerFactory.CreateLogger<FixAudioData>();
}
@@ -41,7 +40,7 @@ namespace Jellyfin.Server.Migrations.Routines
{
_logger.LogInformation("Backfilling audio lyrics data to database.");
var startIndex = 0;
- var records = _itemRepository.GetCount(new InternalItemsQuery
+ var records = _countService.GetCount(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Audio],
});
@@ -68,7 +67,7 @@ namespace Jellyfin.Server.Migrations.Routines
}
}
- _itemRepository.SaveItems(results, CancellationToken.None);
+ _persistenceService.SaveItems(results, CancellationToken.None);
startIndex += results.Count;
_logger.LogInformation("Backfilled data for {UpdatedRecords} of {TotalRecords} audio records", startIndex, records);
}
diff --git a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs
new file mode 100644
index 0000000000..0baf261a2e
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs
@@ -0,0 +1,341 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Fixes incorrect OwnerId relationships where video/movie items are children of other video/movie items.
+/// These are alternate versions (4K vs 1080p) that were incorrectly linked as parent-child relationships
+/// by the auto-merge logic. Only legitimate extras (trailers, behind-the-scenes) should have OwnerId set.
+/// Also removes duplicate database entries for the same file path.
+/// </summary>
+[JellyfinMigration("2026-01-15T12:00:00", nameof(FixIncorrectOwnerIdRelationships))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<FixIncorrectOwnerIdRelationships> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IItemPersistenceService _persistenceService;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FixIncorrectOwnerIdRelationships"/> 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="persistenceService">The item persistence service.</param>
+ public FixIncorrectOwnerIdRelationships(
+ IStartupLogger<FixIncorrectOwnerIdRelationships> logger,
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ ILibraryManager libraryManager,
+ IItemPersistenceService persistenceService)
+ {
+ _logger = logger;
+ _dbContextFactory = dbContextFactory;
+ _libraryManager = libraryManager;
+ _persistenceService = persistenceService;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ // Step 1: Find and remove duplicate database entries (same Path, different IDs)
+ await RemoveDuplicateItemsAsync(context, cancellationToken).ConfigureAwait(false);
+
+ // Step 2: Clear incorrect OwnerId for video/movie items that are children of other video/movie items
+ await ClearIncorrectOwnerIdsAsync(context, cancellationToken).ConfigureAwait(false);
+
+ // Step 3: Reassign orphaned extras to correct parents
+ await ReassignOrphanedExtrasAsync(context, cancellationToken).ConfigureAwait(false);
+
+ // Step 4: Populate PrimaryVersionId for alternate version children
+ await PopulatePrimaryVersionIdAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task RemoveDuplicateItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ // Find all paths that have duplicate entries
+ var duplicatePaths = await context.BaseItems
+ .Where(b => b.Path != null)
+ .GroupBy(b => b.Path)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (duplicatePaths.Count == 0)
+ {
+ _logger.LogInformation("No duplicate items found, skipping duplicate removal.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} paths with duplicate database entries", duplicatePaths.Count);
+
+ // Collect all duplicate IDs to delete in one batch
+ var allIdsToDelete = new List<Guid>();
+ const int progressLogStep = 500;
+ var processedPaths = 0;
+ foreach (var path in duplicatePaths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (processedPaths > 0 && processedPaths % progressLogStep == 0)
+ {
+ _logger.LogInformation("Resolving duplicates: {Processed}/{Total} paths", processedPaths, duplicatePaths.Count);
+ }
+
+ processedPaths++;
+
+ // Get all items with this path
+ var itemsWithPath = await context.BaseItems
+ .Where(b => b.Path == path)
+ .Select(b => new
+ {
+ b.Id,
+ b.Type,
+ b.DateCreated,
+ HasOwnedExtras = context.BaseItems.Any(c => c.OwnerId.HasValue && c.OwnerId.Value.Equals(b.Id)),
+ HasDirectChildren = context.BaseItems.Any(c => c.ParentId.HasValue && c.ParentId.Value.Equals(b.Id))
+ })
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (itemsWithPath.Count <= 1)
+ {
+ continue;
+ }
+
+ // Keep the item that has direct children, then owned extras, then prefer non-Folder types, then newest
+ var itemToKeep = itemsWithPath
+ .OrderByDescending(i => i.HasDirectChildren)
+ .ThenByDescending(i => i.HasOwnedExtras)
+ .ThenByDescending(i => i.Type != "MediaBrowser.Controller.Entities.Folder")
+ .ThenByDescending(i => i.DateCreated)
+ .First();
+ if (itemToKeep is null)
+ {
+ continue;
+ }
+
+ allIdsToDelete.AddRange(itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id));
+ }
+
+ if (allIdsToDelete.Count > 0)
+ {
+ // Batch-resolve items for metadata path cleanup, then delete all at once
+ var itemsToDelete = allIdsToDelete
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+
+ // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
+ var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
+ var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
+ if (unresolvedIds.Count > 0)
+ {
+ _persistenceService.DeleteItem(unresolvedIds);
+ }
+ }
+
+ _logger.LogInformation("Successfully removed {Count} duplicate database entries", allIdsToDelete.Count);
+ }
+
+ private async Task ClearIncorrectOwnerIdsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ // Find video/movie items with incorrect OwnerId (ExtraType is NULL or 0, pointing to another video/movie)
+ var incorrectChildrenWithParent = await context.BaseItems
+ .Where(b => b.OwnerId.HasValue
+ && (b.ExtraType == null || b.ExtraType == 0)
+ && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie"))
+ .Where(b => context.BaseItems.Any(parent =>
+ parent.Id.Equals(b.OwnerId!.Value)
+ && (parent.Type == "MediaBrowser.Controller.Entities.Video" || parent.Type == "MediaBrowser.Controller.Entities.Movies.Movie")))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ // Also find orphaned items (parent doesn't exist)
+ var orphanedChildren = await context.BaseItems
+ .Where(b => b.OwnerId.HasValue
+ && (b.ExtraType == null || b.ExtraType == 0)
+ && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie"))
+ .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var totalIncorrect = incorrectChildrenWithParent.Count + orphanedChildren.Count;
+ if (totalIncorrect == 0)
+ {
+ _logger.LogInformation("No items with incorrect OwnerId found, skipping OwnerId cleanup.");
+ return;
+ }
+
+ _logger.LogInformation(
+ "Found {Count} video/movie items with incorrect OwnerId relationships ({WithParent} with parent, {Orphaned} orphaned)",
+ totalIncorrect,
+ incorrectChildrenWithParent.Count,
+ orphanedChildren.Count);
+
+ // Clear OwnerId for all incorrect items
+ var allIncorrectItems = incorrectChildrenWithParent.Concat(orphanedChildren).ToList();
+ foreach (var item in allIncorrectItems)
+ {
+ item.OwnerId = null;
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("Successfully cleared OwnerId for {Count} items", totalIncorrect);
+ }
+
+ private async Task ReassignOrphanedExtrasAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ // Find extras whose parent was deleted during duplicate removal
+ var orphanedExtras = await context.BaseItems
+ .Where(b => b.ExtraType != null && b.ExtraType != 0 && b.OwnerId.HasValue)
+ .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (orphanedExtras.Count == 0)
+ {
+ _logger.LogInformation("No orphaned extras found, skipping reassignment.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} orphaned extras to reassign", orphanedExtras.Count);
+ const int extraProgressLogStep = 500;
+
+ // Build a lookup of directory -> first video/movie item for parent resolution
+ var extraDirectories = orphanedExtras
+ .Where(e => !string.IsNullOrEmpty(e.Path))
+ .Select(e => System.IO.Path.GetDirectoryName(e.Path))
+ .Where(d => !string.IsNullOrEmpty(d))
+ .Distinct()
+ .ToList();
+
+ // Load all potential parent video/movies with paths in one query
+ var videoTypes = new[]
+ {
+ "MediaBrowser.Controller.Entities.Video",
+ "MediaBrowser.Controller.Entities.Movies.Movie"
+ };
+ var potentialParents = await context.BaseItems
+ .Where(b => b.Path != null && videoTypes.Contains(b.Type))
+ .Select(b => new { b.Id, b.Path })
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ // Build directory -> parent ID mapping
+ var dirToParent = new Dictionary<string, Guid>();
+ foreach (var dir in extraDirectories)
+ {
+ var parent = potentialParents
+ .Where(p => p.Path!.StartsWith(dir!, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(p => p.Id)
+ .FirstOrDefault();
+ if (parent is not null)
+ {
+ dirToParent[dir!] = parent.Id;
+ }
+ }
+
+ var reassignedCount = 0;
+ var processedExtras = 0;
+ foreach (var extra in orphanedExtras)
+ {
+ if (processedExtras > 0 && processedExtras % extraProgressLogStep == 0)
+ {
+ _logger.LogInformation("Reassigning orphaned extras: {Processed}/{Total}", processedExtras, orphanedExtras.Count);
+ }
+
+ processedExtras++;
+
+ if (string.IsNullOrEmpty(extra.Path))
+ {
+ continue;
+ }
+
+ var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path);
+ if (!string.IsNullOrEmpty(extraDirectory) && dirToParent.TryGetValue(extraDirectory, out var parentId))
+ {
+ extra.OwnerId = parentId;
+ reassignedCount++;
+ }
+ else
+ {
+ extra.OwnerId = null;
+ }
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("Successfully reassigned {Count} orphaned extras", reassignedCount);
+ }
+
+ private async Task PopulatePrimaryVersionIdAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ // Find all alternate version relationships where child's PrimaryVersionId is not set
+ // ChildType 2 = LocalAlternateVersion, ChildType 3 = LinkedAlternateVersion
+ var alternateVersionLinks = await context.LinkedChildren
+ .Where(lc => (lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
+ || lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion))
+ .Join(
+ context.BaseItems,
+ lc => lc.ChildId,
+ item => item.Id,
+ (lc, item) => new { lc.ParentId, lc.ChildId, item.PrimaryVersionId })
+ .Where(x => !x.PrimaryVersionId.HasValue || !x.PrimaryVersionId.Value.Equals(x.ParentId))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (alternateVersionLinks.Count == 0)
+ {
+ _logger.LogInformation("No alternate version items need PrimaryVersionId population, skipping.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} alternate version items that need PrimaryVersionId populated", alternateVersionLinks.Count);
+
+ // Batch-load all child items in a single query
+ var childIds = alternateVersionLinks.Select(l => l.ChildId).Distinct().ToList();
+ var childItems = await context.BaseItems
+ .WhereOneOrMany(childIds, b => b.Id)
+ .ToDictionaryAsync(b => b.Id, cancellationToken)
+ .ConfigureAwait(false);
+
+ var updatedCount = 0;
+ const int linkProgressLogStep = 1000;
+ var processedLinks = 0;
+ foreach (var link in alternateVersionLinks)
+ {
+ if (processedLinks > 0 && processedLinks % linkProgressLogStep == 0)
+ {
+ _logger.LogInformation("Populating PrimaryVersionId: {Processed}/{Total} links", processedLinks, alternateVersionLinks.Count);
+ }
+
+ processedLinks++;
+
+ if (childItems.TryGetValue(link.ChildId, out var childItem))
+ {
+ childItem.PrimaryVersionId = link.ParentId;
+ updatedCount++;
+ }
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("Successfully populated PrimaryVersionId for {Count} alternate version items", updatedCount);
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs b/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs
new file mode 100644
index 0000000000..2b1f549940
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to fix broken library subtitle download languages.
+/// </summary>
+[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
+internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
+{
+ private readonly ILocalizationManager _localizationManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The Localization manager.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI integration.</param>
+ /// <param name="libraryManager">The Library manager.</param>
+ /// <param name="logger">The logger.</param>
+ public FixLibrarySubtitleDownloadLanguages(
+ ILocalizationManager localizationManager,
+ IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
+ ILibraryManager libraryManager,
+ ILogger<FixLibrarySubtitleDownloadLanguages> logger)
+ {
+ _localizationManager = localizationManager;
+ _libraryManager = libraryManager;
+ _logger = startupLogger.With(logger);
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Starting to fix library subtitle download languages.");
+
+ var virtualFolders = _libraryManager.GetVirtualFolders(false);
+
+ foreach (var virtualFolder in virtualFolders)
+ {
+ var options = virtualFolder.LibraryOptions;
+ if (options?.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
+ {
+ continue;
+ }
+
+ // Some virtual folders don't have a proper item id.
+ if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
+ {
+ continue;
+ }
+
+ var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
+ if (collectionFolder is null)
+ {
+ _logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
+ continue;
+ }
+
+ var fixedLanguages = new List<string>();
+
+ foreach (var language in options.SubtitleDownloadLanguages)
+ {
+ var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
+ if (foundLanguage is not null)
+ {
+ // Converted ISO 639-2/B to T (ger to deu)
+ if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
+ }
+
+ if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
+ continue;
+ }
+
+ fixedLanguages.Add(foundLanguage);
+ }
+ else
+ {
+ _logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
+ }
+ }
+
+ options.SubtitleDownloadLanguages = [.. fixedLanguages];
+ collectionFolder.UpdateLibraryOptions(options);
+ }
+
+ _logger.LogInformation("Library subtitle download languages fixed.");
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index 4b1e53a355..3e4205547a 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -464,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
SqliteConnection.ClearAllPools();
+ using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
+ {
+ checkpointConnection.Open();
+ using var cmd = checkpointConnection.CreateCommand();
+ cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
+ cmd.ExecuteNonQuery();
+ }
+
+ SqliteConnection.ClearAllPools();
+
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
File.Move(libraryDbPath, libraryDbPath + ".old", true);
}
@@ -505,7 +515,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
PlayCount = dto.GetInt32(4),
IsFavorite = dto.GetBoolean(5),
PlaybackPositionTicks = dto.GetInt64(6),
- LastPlayedDate = dto.IsDBNull(7) ? null : dto.GetDateTime(7),
+ LastPlayedDate = dto.IsDBNull(7) ? null : ReadDateTimeFromColumn(dto, 7),
AudioStreamIndex = dto.IsDBNull(8) ? null : dto.GetInt32(8),
SubtitleStreamIndex = dto.IsDBNull(9) ? null : dto.GetInt32(9),
Likes = null,
@@ -514,6 +524,28 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
};
}
+ private static DateTime? ReadDateTimeFromColumn(SqliteDataReader reader, int index)
+ {
+ // Try reading as a formatted date string first (handles ISO-8601 dates).
+ if (reader.TryReadDateTime(index, out var dateTimeResult))
+ {
+ return dateTimeResult;
+ }
+
+ // Some databases have Unix epoch timestamps stored as integers.
+ // SqliteDataReader.GetDateTime interprets integers as Julian dates, which crashes
+ // for Unix epoch values. Handle them explicitly.
+ var rawValue = reader.GetValue(index);
+ if (rawValue is long unixTimestamp
+ && unixTimestamp > 0
+ && unixTimestamp <= DateTimeOffset.MaxValue.ToUnixTimeSeconds())
+ {
+ return DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime;
+ }
+
+ return null;
+ }
+
private AncestorId GetAncestorId(SqliteDataReader reader)
{
return new AncestorId()
@@ -1074,9 +1106,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.OriginalTitle = originalTitle;
}
- if (reader.TryGetString(index++, out var primaryVersionId))
+ if (reader.TryGetString(index++, out var primaryVersionId) && Guid.TryParse(primaryVersionId, out var primaryVersionGuid))
{
- entity.PrimaryVersionId = primaryVersionId;
+ entity.PrimaryVersionId = primaryVersionGuid;
}
if (reader.TryReadDateTime(index++, out var dateLastMediaAdded))
@@ -1163,7 +1195,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
Item = null!,
ProviderId = e[0],
ProviderValue = string.Join('|', e.Skip(1))
- }).ToArray();
+ })
+ .DistinctBy(e => e.ProviderId)
+ .ToArray();
}
if (reader.TryGetString(index++, out var imageInfos))
@@ -1176,10 +1210,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))
{
@@ -1216,9 +1248,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.ShowId = showId;
}
- if (reader.TryGetString(index++, out var ownerId))
+ if (reader.TryGetString(index++, out var ownerId) && Guid.TryParse(ownerId, out var ownerIdGuid))
{
- entity.OwnerId = ownerId;
+ entity.OwnerId = ownerIdGuid;
}
if (reader.TryGetString(index++, out var mediaType))
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
new file mode 100644
index 0000000000..14ae535531
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
@@ -0,0 +1,589 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migrates LinkedChildren data from JSON Data column to the LinkedChildren table.
+/// </summary>
+[JellyfinMigration("2026-01-13T12:00:00", nameof(MigrateLinkedChildren))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
+{
+ private readonly ILogger<MigrateLinkedChildren> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IServerApplicationPaths _appPaths;
+
+ public MigrateLinkedChildren(
+ ILoggerFactory loggerFactory,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ ILibraryManager libraryManager,
+ IServerApplicationHost appHost,
+ IServerApplicationPaths appPaths)
+ {
+ _logger = loggerFactory.CreateLogger<MigrateLinkedChildren>();
+ _dbProvider = dbProvider;
+ _libraryManager = libraryManager;
+ _appHost = appHost;
+ _appPaths = appPaths;
+ }
+
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ using var context = _dbProvider.CreateDbContext();
+
+ var containerTypes = new[]
+ {
+ "MediaBrowser.Controller.Entities.Movies.BoxSet",
+ "MediaBrowser.Controller.Playlists.Playlist",
+ "MediaBrowser.Controller.Entities.CollectionFolder"
+ };
+
+ var videoTypes = new[]
+ {
+ "MediaBrowser.Controller.Entities.Video",
+ "MediaBrowser.Controller.Entities.Movies.Movie",
+ "MediaBrowser.Controller.Entities.TV.Episode"
+ };
+
+ var itemsWithData = context.BaseItems
+ .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
+ .Select(b => new { b.Id, b.Data, b.Type })
+ .ToList();
+
+ _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count);
+
+ var pathToIdMap = context.BaseItems
+ .Where(b => b.Path != null)
+ .Select(b => new { b.Id, b.Path })
+ .GroupBy(b => b.Path!)
+ .ToDictionary(g => g.Key, g => g.First().Id);
+
+ var linkedChildrenToAdd = new List<LinkedChildEntity>();
+ var processedCount = 0;
+ const int progressLogStep = 1000;
+ var totalItems = itemsWithData.Count;
+
+ foreach (var item in itemsWithData)
+ {
+ if (string.IsNullOrEmpty(item.Data))
+ {
+ continue;
+ }
+
+ if (processedCount > 0 && processedCount % progressLogStep == 0)
+ {
+ _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems);
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(item.Data);
+
+ var isVideo = videoTypes.Contains(item.Type);
+
+ // Handle Video alternate versions
+ if (isVideo)
+ {
+ ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd);
+ }
+
+ // Handle LinkedChildren (for containers and other items)
+ if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenElement.ValueKind != JsonValueKind.Array)
+ {
+ processedCount++;
+ continue;
+ }
+
+ var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
+ var sortOrder = 0;
+ foreach (var childElement in linkedChildrenElement.EnumerateArray())
+ {
+ Guid? childId = null;
+ if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
+ {
+ var itemIdStr = itemIdProp.GetString();
+ if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
+ {
+ childId = parsedId;
+ }
+ }
+
+ if (!childId.HasValue || childId.Value.IsEmpty())
+ {
+ if (childElement.TryGetProperty("Path", out var pathProp))
+ {
+ var path = pathProp.GetString();
+ if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
+ {
+ childId = resolvedId;
+ }
+ }
+ }
+
+ if (!childId.HasValue || childId.Value.IsEmpty())
+ {
+ if (childElement.TryGetProperty("LibraryItemId", out var libIdProp))
+ {
+ var libIdStr = libIdProp.GetString();
+ if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
+ {
+ childId = parsedLibId;
+ }
+ }
+ }
+
+ if (!childId.HasValue || childId.Value.IsEmpty())
+ {
+ continue;
+ }
+
+ var childType = LinkedChildType.Manual;
+ if (childElement.TryGetProperty("Type", out var typeProp))
+ {
+ if (typeProp.ValueKind == JsonValueKind.Number)
+ {
+ childType = (LinkedChildType)typeProp.GetInt32();
+ }
+ else if (typeProp.ValueKind == JsonValueKind.String)
+ {
+ var typeStr = typeProp.GetString();
+ if (Enum.TryParse<LinkedChildType>(typeStr, out var parsedType))
+ {
+ childType = parsedType;
+ }
+ }
+ }
+
+ linkedChildrenToAdd.Add(new LinkedChildEntity
+ {
+ ParentId = item.Id,
+ ChildId = childId.Value,
+ ChildType = childType,
+ SortOrder = isPlaylist ? sortOrder : null
+ });
+
+ sortOrder++;
+ }
+
+ processedCount++;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id);
+ }
+ }
+
+ if (linkedChildrenToAdd.Count > 0)
+ {
+ _logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count);
+
+ var existingKeys = context.LinkedChildren
+ .Select(lc => new { lc.ParentId, lc.ChildId })
+ .ToHashSet();
+
+ var toInsert = linkedChildrenToAdd
+ .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
+ .ToList();
+
+ if (toInsert.Count > 0)
+ {
+ // Deduplicate by composite key (ParentId, ChildId)
+ // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
+ toInsert = toInsert
+ .OrderBy(lc => lc.ChildType switch
+ {
+ LinkedChildType.LocalAlternateVersion => 0,
+ LinkedChildType.LinkedAlternateVersion => 1,
+ _ => 2
+ })
+ .DistinctBy(lc => new { lc.ParentId, lc.ChildId })
+ .ToList();
+
+ var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
+ var existingChildIds = context.BaseItems
+ .WhereOneOrMany(childIds, b => b.Id)
+ .Select(b => b.Id)
+ .ToHashSet();
+
+ toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
+
+ context.LinkedChildren.AddRange(toInsert);
+ context.SaveChanges();
+
+ _logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count);
+ }
+ else
+ {
+ _logger.LogInformation("All LinkedChildren records already exist, nothing to insert.");
+ }
+ }
+ else
+ {
+ _logger.LogInformation("No LinkedChildren data found to migrate.");
+ }
+
+ _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
+
+ CleanupWrongTypeAlternateVersions(context);
+ CleanupOrphanedAlternateVersionBaseItems(context);
+ CleanupItemsFromDeletedLibraries(context);
+ CleanupStaleFileEntries(context);
+ CleanupOrphanedLinkedChildren(context);
+ }
+
+ private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context)
+ {
+ _logger.LogInformation("Cleaning up alternate version items with wrong type...");
+
+ // Find all LocalAlternateVersion relationships where the child is a generic Video
+ // but the parent is a more specific type (like Movie).
+ // Since IDs are computed from type + path, just updating the Type column would break ID lookups.
+ // Instead, delete them and let the runtime recreate them with the correct type during the next library scan.
+ var wrongTypeChildIds = context.LinkedChildren
+ .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion)
+ .Join(
+ context.BaseItems,
+ lc => lc.ParentId,
+ parent => parent.Id,
+ (lc, parent) => new { lc.ChildId, ParentType = parent.Type })
+ .Join(
+ context.BaseItems,
+ x => x.ChildId,
+ child => child.Id,
+ (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type })
+ .Where(x => x.ChildType != x.ParentType)
+ .Select(x => x.ChildId)
+ .Distinct()
+ .ToList();
+
+ if (wrongTypeChildIds.Count == 0)
+ {
+ _logger.LogInformation("No wrong-type alternate version items found.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
+
+ var itemsToDelete = wrongTypeChildIds
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+
+ _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count);
+ }
+
+ private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
+ {
+ _logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems...");
+
+ // Find BaseItems that have OwnerId set (they belonged to another item) and are not extras,
+ // but no LinkedChild entry references them — meaning they're orphaned alternate versions.
+ // This happens when a version file is renamed: the old BaseItem remains in the DB
+ // with a stale OwnerId but nothing links to it anymore.
+ var orphanedVersionIds = context.BaseItems
+ .Where(b => b.OwnerId.HasValue && b.ExtraType == null)
+ .Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id)))
+ .Select(b => b.Id)
+ .ToList();
+
+ if (orphanedVersionIds.Count == 0)
+ {
+ _logger.LogInformation("No orphaned alternate version BaseItems found.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count);
+
+ var itemsToDelete = orphanedVersionIds
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+
+ _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count);
+ }
+
+ private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
+ {
+ _logger.LogInformation("Starting cleanup of items from deleted libraries...");
+
+ // Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists.
+ // This happens when a library is removed but the scan didn't fully clean up all items under it.
+ var orphanedIds = context.BaseItems
+ .Where(b => b.TopParentId.HasValue)
+ .Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value)))
+ .Select(b => b.Id)
+ .ToList();
+
+ if (orphanedIds.Count == 0)
+ {
+ _logger.LogInformation("No items from deleted libraries found.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
+
+ var itemsToDelete = orphanedIds
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+
+ _logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count);
+ }
+
+ private void CleanupStaleFileEntries(JellyfinDbContext context)
+ {
+ _logger.LogInformation("Starting cleanup of items with missing files...");
+
+ // Get all library media locations and partition into accessible vs inaccessible.
+ // This mirrors the scanner's safeguard: if a library root is inaccessible
+ // (e.g. NAS offline), we skip items under it to avoid false deletions.
+ var virtualFolders = _libraryManager.GetVirtualFolders();
+ var accessiblePaths = new List<string>();
+ var inaccessiblePaths = new List<string>();
+
+ foreach (var folder in virtualFolders)
+ {
+ foreach (var location in folder.Locations)
+ {
+ if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any())
+ {
+ accessiblePaths.Add(location);
+ }
+ else
+ {
+ inaccessiblePaths.Add(location);
+ _logger.LogWarning(
+ "Library location {Path} is inaccessible or empty, skipping file existence checks for items under this path.",
+ location);
+ }
+ }
+ }
+
+ var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList();
+
+ // Get all non-folder, non-virtual items with paths from the DB
+ var itemsWithPaths = context.BaseItems
+ .Where(b => b.Path != null && b.Path != string.Empty)
+ .Where(b => !b.IsFolder && !b.IsVirtualItem)
+ .Select(b => new { b.Id, b.Path })
+ .ToList();
+
+ var internalMetadataPath = _appPaths.InternalMetadataPath;
+
+ var staleIds = new List<Guid>();
+ foreach (var item in itemsWithPaths)
+ {
+ // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
+ var path = _appHost.ExpandVirtualPath(item.Path!);
+
+ // Skip items stored under internal metadata (images, subtitles, trickplay, etc.)
+ if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+ {
+ // Item is under an accessible library location — check if it still exists
+ // Directory check covers BDMV/DVD items whose Path points to a folder
+ if (!File.Exists(path) && !Directory.Exists(path))
+ {
+ staleIds.Add(item.Id);
+ }
+ }
+ else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+ {
+ // Item is not under ANY library location (accessible or not) —
+ // it's orphaned from all libraries (e.g. media path was removed from config)
+ staleIds.Add(item.Id);
+ }
+
+ // Otherwise: item is under an inaccessible location — skip (storage may be offline)
+ }
+
+ if (staleIds.Count == 0)
+ {
+ _logger.LogInformation("No stale items found.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
+
+ var itemsToDelete = staleIds
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+
+ _logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count);
+ }
+
+ private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
+ {
+ _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records...");
+
+ // Find all LinkedChildren where the ChildId doesn't exist in BaseItems
+ var orphanedLinkedChildren = context.LinkedChildren
+ .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId)))
+ .ToList();
+
+ if (orphanedLinkedChildren.Count == 0)
+ {
+ _logger.LogInformation("No orphaned LinkedChildren found.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count);
+
+ var orphanedByParent = context.LinkedChildren
+ .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId)))
+ .ToList();
+
+ if (orphanedByParent.Count > 0)
+ {
+ _logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count);
+ orphanedLinkedChildren.AddRange(orphanedByParent);
+ }
+
+ // Remove all orphaned records
+ var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList();
+ context.LinkedChildren.RemoveRange(distinctOrphaned);
+ context.SaveChanges();
+
+ _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
+ }
+
+ private void ProcessVideoAlternateVersions(
+ JsonElement root,
+ Guid parentId,
+ Dictionary<string, Guid> pathToIdMap,
+ List<LinkedChildEntity> linkedChildrenToAdd)
+ {
+ int sortOrder = 0;
+
+ if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement)
+ && localAlternateVersionsElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var pathElement in localAlternateVersionsElement.EnumerateArray())
+ {
+ if (pathElement.ValueKind != JsonValueKind.String)
+ {
+ continue;
+ }
+
+ var path = pathElement.GetString();
+ if (string.IsNullOrEmpty(path))
+ {
+ continue;
+ }
+
+ // Try to resolve the path to an ItemId
+ if (pathToIdMap.TryGetValue(path, out var childId))
+ {
+ linkedChildrenToAdd.Add(new LinkedChildEntity
+ {
+ ParentId = parentId,
+ ChildId = childId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = sortOrder++
+ });
+
+ _logger.LogDebug(
+ "Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}",
+ parentId,
+ childId,
+ path);
+ }
+ else
+ {
+ _logger.LogWarning(
+ "Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}",
+ path,
+ parentId);
+ }
+ }
+ }
+
+ if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement)
+ && linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
+ {
+ Guid? childId = null;
+
+ // Try to get ItemId
+ if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
+ {
+ var itemIdStr = itemIdProp.GetString();
+ if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
+ {
+ childId = parsedId;
+ }
+ }
+
+ // Try to get from Path if ItemId not available
+ if (!childId.HasValue || childId.Value.IsEmpty())
+ {
+ if (linkedChildElement.TryGetProperty("Path", out var pathProp))
+ {
+ var path = pathProp.GetString();
+ if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
+ {
+ childId = resolvedId;
+ }
+ }
+ }
+
+ // Try LibraryItemId as fallback
+ if (!childId.HasValue || childId.Value.IsEmpty())
+ {
+ if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp))
+ {
+ var libIdStr = libIdProp.GetString();
+ if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
+ {
+ childId = parsedLibId;
+ }
+ }
+ }
+
+ if (!childId.HasValue || childId.Value.IsEmpty())
+ {
+ _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId);
+ continue;
+ }
+
+ linkedChildrenToAdd.Add(new LinkedChildEntity
+ {
+ ParentId = parentId,
+ ChildId = childId.Value,
+ ChildType = LinkedChildType.LinkedAlternateVersion,
+ SortOrder = sortOrder++
+ });
+
+ _logger.LogDebug(
+ "Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}",
+ parentId,
+ childId.Value);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
index 0f55465e86..79a8f9577c 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
@@ -57,7 +57,8 @@ public class MoveTrickplayFiles : IMigrationRoutine
MediaTypes = [MediaType.Video],
SourceTypes = [SourceType.Library],
IsVirtualItem = false,
- IsFolder = false
+ IsFolder = false,
+ IncludeOwnedItems = true
};
do
diff --git a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs b/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs
index eadabf6776..eca50ac100 100644
--- a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs
+++ b/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs
@@ -1,13 +1,10 @@
using System;
using System.Diagnostics;
using System.Linq;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
-using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
-using Jellyfin.Server.Implementations.Item;
using Jellyfin.Server.ServerSetupApp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -40,7 +37,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
- const int Limit = 1000;
+ const int Limit = 10000;
int itemCount = 0;
var sw = Stopwatch.StartNew();
@@ -61,7 +58,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
{
try
{
- var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : BaseItemRepository.GetCleanValue(item.Name);
+ var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue();
if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal))
{
_logger.LogDebug(
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
index 23f212424b..1545ebdc8e 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
@@ -45,10 +45,13 @@ internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
var linkedChildren = playlist.LinkedChildren;
if (linkedChildren.Length > 0)
{
- var nullItemChildren = linkedChildren.Where(c => c.ItemId is null);
- var deduplicatedChildren = linkedChildren.DistinctBy(c => c.ItemId);
- var newLinkedChildren = nullItemChildren.Concat(deduplicatedChildren);
- playlist.LinkedChildren = linkedChildren;
+ 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);
}