| | | 1 | | using System; |
| | | 2 | | using System.Collections.Generic; |
| | | 3 | | using System.Linq; |
| | | 4 | | using System.Threading; |
| | | 5 | | using System.Threading.Tasks; |
| | | 6 | | using Jellyfin.Database.Implementations; |
| | | 7 | | using Jellyfin.Server.ServerSetupApp; |
| | | 8 | | using MediaBrowser.Controller.Library; |
| | | 9 | | using MediaBrowser.Controller.Persistence; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | using Microsoft.Extensions.Logging; |
| | | 12 | | |
| | | 13 | | namespace Jellyfin.Server.Migrations.Routines; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Fixes incorrect OwnerId relationships where video/movie items are children of other video/movie items. |
| | | 17 | | /// These are alternate versions (4K vs 1080p) that were incorrectly linked as parent-child relationships |
| | | 18 | | /// by the auto-merge logic. Only legitimate extras (trailers, behind-the-scenes) should have OwnerId set. |
| | | 19 | | /// Also removes duplicate database entries for the same file path. |
| | | 20 | | /// </summary> |
| | | 21 | | [JellyfinMigration("2026-01-15T12:00:00", nameof(FixIncorrectOwnerIdRelationships))] |
| | | 22 | | [JellyfinMigrationBackup(JellyfinDb = true)] |
| | | 23 | | public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine |
| | | 24 | | { |
| | | 25 | | private readonly IStartupLogger<FixIncorrectOwnerIdRelationships> _logger; |
| | | 26 | | private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; |
| | | 27 | | private readonly ILibraryManager _libraryManager; |
| | | 28 | | private readonly IItemPersistenceService _persistenceService; |
| | | 29 | | |
| | | 30 | | /// <summary> |
| | | 31 | | /// Initializes a new instance of the <see cref="FixIncorrectOwnerIdRelationships"/> class. |
| | | 32 | | /// </summary> |
| | | 33 | | /// <param name="logger">The startup logger.</param> |
| | | 34 | | /// <param name="dbContextFactory">The database context factory.</param> |
| | | 35 | | /// <param name="libraryManager">The library manager.</param> |
| | | 36 | | /// <param name="persistenceService">The item persistence service.</param> |
| | | 37 | | public FixIncorrectOwnerIdRelationships( |
| | | 38 | | IStartupLogger<FixIncorrectOwnerIdRelationships> logger, |
| | | 39 | | IDbContextFactory<JellyfinDbContext> dbContextFactory, |
| | | 40 | | ILibraryManager libraryManager, |
| | | 41 | | IItemPersistenceService persistenceService) |
| | | 42 | | { |
| | 0 | 43 | | _logger = logger; |
| | 0 | 44 | | _dbContextFactory = dbContextFactory; |
| | 0 | 45 | | _libraryManager = libraryManager; |
| | 0 | 46 | | _persistenceService = persistenceService; |
| | 0 | 47 | | } |
| | | 48 | | |
| | | 49 | | /// <inheritdoc/> |
| | | 50 | | public async Task PerformAsync(CancellationToken cancellationToken) |
| | | 51 | | { |
| | 0 | 52 | | var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); |
| | 0 | 53 | | await using (context.ConfigureAwait(false)) |
| | | 54 | | { |
| | | 55 | | // Step 1: Find and remove duplicate database entries (same Path, different IDs) |
| | 0 | 56 | | await RemoveDuplicateItemsAsync(context, cancellationToken).ConfigureAwait(false); |
| | | 57 | | |
| | | 58 | | // Step 2: Clear incorrect OwnerId for video/movie items that are children of other video/movie items |
| | 0 | 59 | | await ClearIncorrectOwnerIdsAsync(context, cancellationToken).ConfigureAwait(false); |
| | | 60 | | |
| | | 61 | | // Step 3: Reassign orphaned extras to correct parents |
| | 0 | 62 | | await ReassignOrphanedExtrasAsync(context, cancellationToken).ConfigureAwait(false); |
| | | 63 | | |
| | | 64 | | // Step 4: Populate PrimaryVersionId for alternate version children |
| | 0 | 65 | | await PopulatePrimaryVersionIdAsync(context, cancellationToken).ConfigureAwait(false); |
| | | 66 | | } |
| | 0 | 67 | | } |
| | | 68 | | |
| | | 69 | | private async Task RemoveDuplicateItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) |
| | | 70 | | { |
| | | 71 | | // Find all paths that have duplicate entries |
| | 0 | 72 | | var duplicatePaths = await context.BaseItems |
| | 0 | 73 | | .Where(b => b.Path != null) |
| | 0 | 74 | | .GroupBy(b => b.Path) |
| | 0 | 75 | | .Where(g => g.Count() > 1) |
| | 0 | 76 | | .Select(g => g.Key) |
| | 0 | 77 | | .ToListAsync(cancellationToken) |
| | 0 | 78 | | .ConfigureAwait(false); |
| | | 79 | | |
| | 0 | 80 | | if (duplicatePaths.Count == 0) |
| | | 81 | | { |
| | 0 | 82 | | _logger.LogInformation("No duplicate items found, skipping duplicate removal."); |
| | 0 | 83 | | return; |
| | | 84 | | } |
| | | 85 | | |
| | 0 | 86 | | _logger.LogInformation("Found {Count} paths with duplicate database entries", duplicatePaths.Count); |
| | | 87 | | |
| | | 88 | | // Collect all duplicate IDs to delete in one batch |
| | 0 | 89 | | var allIdsToDelete = new List<Guid>(); |
| | | 90 | | const int progressLogStep = 500; |
| | 0 | 91 | | var processedPaths = 0; |
| | 0 | 92 | | foreach (var path in duplicatePaths) |
| | | 93 | | { |
| | 0 | 94 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 95 | | |
| | 0 | 96 | | if (processedPaths > 0 && processedPaths % progressLogStep == 0) |
| | | 97 | | { |
| | 0 | 98 | | _logger.LogInformation("Resolving duplicates: {Processed}/{Total} paths", processedPaths, duplicatePaths |
| | | 99 | | } |
| | | 100 | | |
| | 0 | 101 | | processedPaths++; |
| | | 102 | | |
| | | 103 | | // Get all items with this path |
| | 0 | 104 | | var itemsWithPath = await context.BaseItems |
| | 0 | 105 | | .Where(b => b.Path == path) |
| | 0 | 106 | | .Select(b => new |
| | 0 | 107 | | { |
| | 0 | 108 | | b.Id, |
| | 0 | 109 | | b.Type, |
| | 0 | 110 | | b.DateCreated, |
| | 0 | 111 | | HasOwnedExtras = context.BaseItems.Any(c => c.OwnerId.HasValue && c.OwnerId.Value.Equals(b.Id)), |
| | 0 | 112 | | HasDirectChildren = context.BaseItems.Any(c => c.ParentId.HasValue && c.ParentId.Value.Equals(b.Id)) |
| | 0 | 113 | | }) |
| | 0 | 114 | | .ToListAsync(cancellationToken) |
| | 0 | 115 | | .ConfigureAwait(false); |
| | | 116 | | |
| | 0 | 117 | | if (itemsWithPath.Count <= 1) |
| | | 118 | | { |
| | | 119 | | continue; |
| | | 120 | | } |
| | | 121 | | |
| | | 122 | | // Keep the item that has direct children, then owned extras, then prefer non-Folder types, then newest |
| | 0 | 123 | | var itemToKeep = itemsWithPath |
| | 0 | 124 | | .OrderByDescending(i => i.HasDirectChildren) |
| | 0 | 125 | | .ThenByDescending(i => i.HasOwnedExtras) |
| | 0 | 126 | | .ThenByDescending(i => i.Type != "MediaBrowser.Controller.Entities.Folder") |
| | 0 | 127 | | .ThenByDescending(i => i.DateCreated) |
| | 0 | 128 | | .First(); |
| | 0 | 129 | | if (itemToKeep is null) |
| | | 130 | | { |
| | | 131 | | continue; |
| | | 132 | | } |
| | | 133 | | |
| | 0 | 134 | | allIdsToDelete.AddRange(itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id)); |
| | 0 | 135 | | } |
| | | 136 | | |
| | 0 | 137 | | if (allIdsToDelete.Count > 0) |
| | | 138 | | { |
| | | 139 | | // Batch-resolve items for metadata path cleanup, then delete all at once |
| | 0 | 140 | | var itemsToDelete = allIdsToDelete |
| | 0 | 141 | | .Select(id => _libraryManager.GetItemById(id)) |
| | 0 | 142 | | .Where(item => item is not null) |
| | 0 | 143 | | .ToList(); |
| | 0 | 144 | | _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); |
| | | 145 | | |
| | | 146 | | // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager |
| | 0 | 147 | | var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); |
| | 0 | 148 | | var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); |
| | 0 | 149 | | if (unresolvedIds.Count > 0) |
| | | 150 | | { |
| | 0 | 151 | | _persistenceService.DeleteItem(unresolvedIds); |
| | | 152 | | } |
| | | 153 | | } |
| | | 154 | | |
| | 0 | 155 | | _logger.LogInformation("Successfully removed {Count} duplicate database entries", allIdsToDelete.Count); |
| | 0 | 156 | | } |
| | | 157 | | |
| | | 158 | | private async Task ClearIncorrectOwnerIdsAsync(JellyfinDbContext context, CancellationToken cancellationToken) |
| | | 159 | | { |
| | | 160 | | // Find video/movie items with incorrect OwnerId (ExtraType is NULL or 0, pointing to another video/movie) |
| | 0 | 161 | | var incorrectChildrenWithParent = await context.BaseItems |
| | 0 | 162 | | .Where(b => b.OwnerId.HasValue |
| | 0 | 163 | | && (b.ExtraType == null || b.ExtraType == 0) |
| | 0 | 164 | | && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Mo |
| | 0 | 165 | | .Where(b => context.BaseItems.Any(parent => |
| | 0 | 166 | | parent.Id.Equals(b.OwnerId!.Value) |
| | 0 | 167 | | && (parent.Type == "MediaBrowser.Controller.Entities.Video" || parent.Type == "MediaBrowser.Controller.E |
| | 0 | 168 | | .ToListAsync(cancellationToken) |
| | 0 | 169 | | .ConfigureAwait(false); |
| | | 170 | | |
| | | 171 | | // Also find orphaned items (parent doesn't exist) |
| | 0 | 172 | | var orphanedChildren = await context.BaseItems |
| | 0 | 173 | | .Where(b => b.OwnerId.HasValue |
| | 0 | 174 | | && (b.ExtraType == null || b.ExtraType == 0) |
| | 0 | 175 | | && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Mo |
| | 0 | 176 | | .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) |
| | 0 | 177 | | .ToListAsync(cancellationToken) |
| | 0 | 178 | | .ConfigureAwait(false); |
| | | 179 | | |
| | 0 | 180 | | var totalIncorrect = incorrectChildrenWithParent.Count + orphanedChildren.Count; |
| | 0 | 181 | | if (totalIncorrect == 0) |
| | | 182 | | { |
| | 0 | 183 | | _logger.LogInformation("No items with incorrect OwnerId found, skipping OwnerId cleanup."); |
| | 0 | 184 | | return; |
| | | 185 | | } |
| | | 186 | | |
| | 0 | 187 | | _logger.LogInformation( |
| | 0 | 188 | | "Found {Count} video/movie items with incorrect OwnerId relationships ({WithParent} with parent, {Orphaned} |
| | 0 | 189 | | totalIncorrect, |
| | 0 | 190 | | incorrectChildrenWithParent.Count, |
| | 0 | 191 | | orphanedChildren.Count); |
| | | 192 | | |
| | | 193 | | // Clear OwnerId for all incorrect items |
| | 0 | 194 | | var allIncorrectItems = incorrectChildrenWithParent.Concat(orphanedChildren).ToList(); |
| | 0 | 195 | | foreach (var item in allIncorrectItems) |
| | | 196 | | { |
| | 0 | 197 | | item.OwnerId = null; |
| | | 198 | | } |
| | | 199 | | |
| | 0 | 200 | | await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); |
| | 0 | 201 | | _logger.LogInformation("Successfully cleared OwnerId for {Count} items", totalIncorrect); |
| | 0 | 202 | | } |
| | | 203 | | |
| | | 204 | | private async Task ReassignOrphanedExtrasAsync(JellyfinDbContext context, CancellationToken cancellationToken) |
| | | 205 | | { |
| | | 206 | | // Find extras whose parent was deleted during duplicate removal |
| | 0 | 207 | | var orphanedExtras = await context.BaseItems |
| | 0 | 208 | | .Where(b => b.ExtraType != null && b.ExtraType != 0 && b.OwnerId.HasValue) |
| | 0 | 209 | | .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) |
| | 0 | 210 | | .ToListAsync(cancellationToken) |
| | 0 | 211 | | .ConfigureAwait(false); |
| | | 212 | | |
| | 0 | 213 | | if (orphanedExtras.Count == 0) |
| | | 214 | | { |
| | 0 | 215 | | _logger.LogInformation("No orphaned extras found, skipping reassignment."); |
| | 0 | 216 | | return; |
| | | 217 | | } |
| | | 218 | | |
| | 0 | 219 | | _logger.LogInformation("Found {Count} orphaned extras to reassign", orphanedExtras.Count); |
| | | 220 | | const int extraProgressLogStep = 500; |
| | | 221 | | |
| | | 222 | | // Build a lookup of directory -> first video/movie item for parent resolution |
| | 0 | 223 | | var extraDirectories = orphanedExtras |
| | 0 | 224 | | .Where(e => !string.IsNullOrEmpty(e.Path)) |
| | 0 | 225 | | .Select(e => System.IO.Path.GetDirectoryName(e.Path)) |
| | 0 | 226 | | .Where(d => !string.IsNullOrEmpty(d)) |
| | 0 | 227 | | .Distinct() |
| | 0 | 228 | | .ToList(); |
| | | 229 | | |
| | | 230 | | // Load all potential parent video/movies with paths in one query |
| | 0 | 231 | | var videoTypes = new[] |
| | 0 | 232 | | { |
| | 0 | 233 | | "MediaBrowser.Controller.Entities.Video", |
| | 0 | 234 | | "MediaBrowser.Controller.Entities.Movies.Movie" |
| | 0 | 235 | | }; |
| | 0 | 236 | | var potentialParents = await context.BaseItems |
| | 0 | 237 | | .Where(b => b.Path != null && videoTypes.Contains(b.Type)) |
| | 0 | 238 | | .Select(b => new { b.Id, b.Path }) |
| | 0 | 239 | | .ToListAsync(cancellationToken) |
| | 0 | 240 | | .ConfigureAwait(false); |
| | | 241 | | |
| | | 242 | | // Build directory -> parent ID mapping |
| | 0 | 243 | | var dirToParent = new Dictionary<string, Guid>(); |
| | 0 | 244 | | foreach (var dir in extraDirectories) |
| | | 245 | | { |
| | 0 | 246 | | var parent = potentialParents |
| | 0 | 247 | | .Where(p => p.Path!.StartsWith(dir!, StringComparison.OrdinalIgnoreCase)) |
| | 0 | 248 | | .OrderBy(p => p.Id) |
| | 0 | 249 | | .FirstOrDefault(); |
| | 0 | 250 | | if (parent is not null) |
| | | 251 | | { |
| | 0 | 252 | | dirToParent[dir!] = parent.Id; |
| | | 253 | | } |
| | | 254 | | } |
| | | 255 | | |
| | 0 | 256 | | var reassignedCount = 0; |
| | 0 | 257 | | var processedExtras = 0; |
| | 0 | 258 | | foreach (var extra in orphanedExtras) |
| | | 259 | | { |
| | 0 | 260 | | if (processedExtras > 0 && processedExtras % extraProgressLogStep == 0) |
| | | 261 | | { |
| | 0 | 262 | | _logger.LogInformation("Reassigning orphaned extras: {Processed}/{Total}", processedExtras, orphanedExtr |
| | | 263 | | } |
| | | 264 | | |
| | 0 | 265 | | processedExtras++; |
| | | 266 | | |
| | 0 | 267 | | if (string.IsNullOrEmpty(extra.Path)) |
| | | 268 | | { |
| | | 269 | | continue; |
| | | 270 | | } |
| | | 271 | | |
| | 0 | 272 | | var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path); |
| | 0 | 273 | | if (!string.IsNullOrEmpty(extraDirectory) && dirToParent.TryGetValue(extraDirectory, out var parentId)) |
| | | 274 | | { |
| | 0 | 275 | | extra.OwnerId = parentId; |
| | 0 | 276 | | reassignedCount++; |
| | | 277 | | } |
| | | 278 | | else |
| | | 279 | | { |
| | 0 | 280 | | extra.OwnerId = null; |
| | | 281 | | } |
| | | 282 | | } |
| | | 283 | | |
| | 0 | 284 | | await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); |
| | 0 | 285 | | _logger.LogInformation("Successfully reassigned {Count} orphaned extras", reassignedCount); |
| | 0 | 286 | | } |
| | | 287 | | |
| | | 288 | | private async Task PopulatePrimaryVersionIdAsync(JellyfinDbContext context, CancellationToken cancellationToken) |
| | | 289 | | { |
| | | 290 | | // Find all alternate version relationships where child's PrimaryVersionId is not set |
| | | 291 | | // ChildType 2 = LocalAlternateVersion, ChildType 3 = LinkedAlternateVersion |
| | 0 | 292 | | var alternateVersionLinks = await context.LinkedChildren |
| | 0 | 293 | | .Where(lc => (lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersi |
| | 0 | 294 | | || lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LinkedAlternateVers |
| | 0 | 295 | | .Join( |
| | 0 | 296 | | context.BaseItems, |
| | 0 | 297 | | lc => lc.ChildId, |
| | 0 | 298 | | item => item.Id, |
| | 0 | 299 | | (lc, item) => new { lc.ParentId, lc.ChildId, item.PrimaryVersionId }) |
| | 0 | 300 | | .Where(x => !x.PrimaryVersionId.HasValue || !x.PrimaryVersionId.Value.Equals(x.ParentId)) |
| | 0 | 301 | | .ToListAsync(cancellationToken) |
| | 0 | 302 | | .ConfigureAwait(false); |
| | | 303 | | |
| | 0 | 304 | | if (alternateVersionLinks.Count == 0) |
| | | 305 | | { |
| | 0 | 306 | | _logger.LogInformation("No alternate version items need PrimaryVersionId population, skipping."); |
| | 0 | 307 | | return; |
| | | 308 | | } |
| | | 309 | | |
| | 0 | 310 | | _logger.LogInformation("Found {Count} alternate version items that need PrimaryVersionId populated", alternateVe |
| | | 311 | | |
| | | 312 | | // Batch-load all child items in a single query |
| | 0 | 313 | | var childIds = alternateVersionLinks.Select(l => l.ChildId).Distinct().ToList(); |
| | 0 | 314 | | var childItems = await context.BaseItems |
| | 0 | 315 | | .WhereOneOrMany(childIds, b => b.Id) |
| | 0 | 316 | | .ToDictionaryAsync(b => b.Id, cancellationToken) |
| | 0 | 317 | | .ConfigureAwait(false); |
| | | 318 | | |
| | 0 | 319 | | var updatedCount = 0; |
| | | 320 | | const int linkProgressLogStep = 1000; |
| | 0 | 321 | | var processedLinks = 0; |
| | 0 | 322 | | foreach (var link in alternateVersionLinks) |
| | | 323 | | { |
| | 0 | 324 | | if (processedLinks > 0 && processedLinks % linkProgressLogStep == 0) |
| | | 325 | | { |
| | 0 | 326 | | _logger.LogInformation("Populating PrimaryVersionId: {Processed}/{Total} links", processedLinks, alterna |
| | | 327 | | } |
| | | 328 | | |
| | 0 | 329 | | processedLinks++; |
| | | 330 | | |
| | 0 | 331 | | if (childItems.TryGetValue(link.ChildId, out var childItem)) |
| | | 332 | | { |
| | 0 | 333 | | childItem.PrimaryVersionId = link.ParentId; |
| | 0 | 334 | | updatedCount++; |
| | | 335 | | } |
| | | 336 | | } |
| | | 337 | | |
| | 0 | 338 | | await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); |
| | 0 | 339 | | _logger.LogInformation("Successfully populated PrimaryVersionId for {Count} alternate version items", updatedCou |
| | 0 | 340 | | } |
| | | 341 | | } |