diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index cfa64d70..420e37ad 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1223,29 +1223,26 @@ namespace Emby.Server.Implementations.Library private async Task RunPostScanTasks(IProgress progress, CancellationToken cancellationToken) { var tasks = PostScanTasks.ToList(); - - var numComplete = 0; var numTasks = tasks.Count; - foreach (var task in tasks) + if (numTasks == 0) { - // Prevent access to modified closure - var currentNumComplete = numComplete; + _itemRepository.UpdateInheritedValues(); + progress.Report(100); + return; + } + var progressValues = new double[numTasks]; + + async Task RunOneTask(ILibraryPostScanTask task, int taskIndex) + { + _logger.LogDebug("Running post-scan task {0}", task.GetType().Name); var innerProgress = new Progress(pct => { - double innerPercent = pct; - innerPercent /= 100; - innerPercent += currentNumComplete; - - innerPercent /= numTasks; - innerPercent *= 100; - - progress.Report(innerPercent); + progressValues[taskIndex] = pct; + progress.Report(progressValues.Average()); }); - _logger.LogDebug("Running post-scan task {0}", task.GetType().Name); - try { await task.Run(innerProgress, cancellationToken).ConfigureAwait(false); @@ -1260,12 +1257,12 @@ namespace Emby.Server.Implementations.Library _logger.LogError(ex, "Error running post-scan task"); } - numComplete++; - double percent = numComplete; - percent /= numTasks; - progress.Report(percent * 100); + progressValues[taskIndex] = 100; + progress.Report(progressValues.Average()); } + await Task.WhenAll(tasks.Select((task, i) => RunOneTask(task, i))).ConfigureAwait(false); + _itemRepository.UpdateInheritedValues(); progress.Report(100); @@ -1997,7 +1994,7 @@ namespace Emby.Server.Implementations.Library /// public void CreateItems(IReadOnlyList items, BaseItem? parent, CancellationToken cancellationToken) { - _itemRepository.SaveItems(items, cancellationToken); + _itemRepository.SaveItemsAsync(items, cancellationToken).GetAwaiter().GetResult(); foreach (var item in items) { @@ -2165,7 +2162,7 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } - _itemRepository.SaveItems(items, cancellationToken); + await _itemRepository.SaveItemsAsync(items, cancellationToken).ConfigureAwait(false); if (parent is Folder folder) { diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index c77bc3c1..d56b5569 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -15,13 +15,13 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Migrations; using Jellyfin.Server.Implementations.StorageHelpers; using Jellyfin.Server.Implementations.SystemBackupService; using MediaBrowser.Controller; using MediaBrowser.Controller.SystemBackupService; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -156,34 +156,31 @@ public class BackupService : IBackupService await using (dbContext.ConfigureAwait(false)) { // restore migration history manually - var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json"))); + var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{MigrationTracker.BackupEntryName}.json"))); if (historyEntry is null) { _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation"); throw new InvalidOperationException("Cannot restore backup that has no History data."); } - HistoryRow[] historyEntries; + MigrationRecord[] historyEntries; var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false); await using (historyArchive.ConfigureAwait(false)) { - historyEntries = await JsonSerializer.DeserializeAsync(historyArchive).ConfigureAwait(false) ?? + historyEntries = await JsonSerializer.DeserializeAsync(historyArchive).ConfigureAwait(false) ?? throw new InvalidOperationException("Cannot restore backup that has no History data."); } - var historyRepository = dbContext.GetService(); - await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false); + await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false); - foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false)) + foreach (var item in await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext, CancellationToken.None).ConfigureAwait(false)) { - var insertScript = historyRepository.GetDeleteScript(item.MigrationId); - await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false); + await MigrationTracker.DeleteAsync(dbContext, item).ConfigureAwait(false); } foreach (var item in historyEntries) { - var insertScript = historyRepository.GetInsertScript(item); - await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false); + await MigrationTracker.InsertAsync(dbContext, item.MigrationId, item.ProductVersion).ConfigureAwait(false); } dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; @@ -310,8 +307,7 @@ public class BackupService : IBackupService } // include the migration history as well - var historyRepository = dbContext.GetService(); - var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + var migrations = await MigrationTracker.GetAppliedMigrationsAsync(dbContext).ConfigureAwait(false); ICollection<(Type Type, string SourceName, Func> ValueFactory)> entityTypes = [ @@ -319,7 +315,7 @@ public class BackupService : IBackupService .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), - (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) + (Type: typeof(MigrationRecord), SourceName: MigrationTracker.BackupEntryName, ValueFactory: () => migrations.ToAsyncEnumerable()) ]; manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index e59d91b2..36ef7815 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -1157,18 +1157,17 @@ public sealed class BaseItemRepository .ConfigureAwait(false); } - // UPSERT all providers using raw SQL with ON CONFLICT - // This is atomic and handles concurrent operations correctly - foreach (var provider in providersToUpsert) - { - await context.Database.ExecuteSqlAsync( - $@"INSERT INTO library.base_item_providers (item_id, provider_id, provider_value) - VALUES ({entity.Id}, {provider.ProviderId}, {provider.ProviderValue}) - ON CONFLICT (item_id, provider_id) - DO UPDATE SET provider_value = EXCLUDED.provider_value", - cancellationToken) - .ConfigureAwait(false); - } + // UPSERT all providers in a single UNNEST-based INSERT instead of one round-trip per provider + var providerItemIds = providersToUpsert.Select(_ => entity.Id).ToArray(); + var providerIds = providersToUpsert.Select(p => p.ProviderId).ToArray(); + var providerValues = providersToUpsert.Select(p => p.ProviderValue).ToArray(); + await context.Database.ExecuteSqlAsync( + $@"INSERT INTO library.base_item_providers (item_id, provider_id, provider_value) + SELECT * FROM UNNEST({providerItemIds}, {providerIds}, {providerValues}) + ON CONFLICT (item_id, provider_id) + DO UPDATE SET provider_value = EXCLUDED.provider_value", + cancellationToken) + .ConfigureAwait(false); // CRITICAL: Clear the navigation property so EF Core doesn't try to track/save these entity.Provider = null; @@ -1291,7 +1290,6 @@ public sealed class BaseItemRepository Value = f.Value }).ToArray(); context.ItemValues.AddRange(missingItemValues); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); var valueMap = itemValueMaps @@ -1332,19 +1330,36 @@ public sealed class BaseItemRepository await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Hoist ancestor queries outside the per-item loop to avoid NĂ—2 DB round-trips + var ancestorItemIds = tuples + .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null) + .Select(t => t.Item.Id) + .ToArray(); + var allNeededAncestorIds = tuples + .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null) + .SelectMany(t => t.AncestorIds!) + .Distinct() + .ToArray(); + var allExistingAncestorIds = ancestorItemIds.Length > 0 + ? await context.AncestorIds + .Where(e => ancestorItemIds.Contains(e.ItemId)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false) + : []; + var validAncestorIdSet = allNeededAncestorIds.Length > 0 + ? (await context.BaseItems + .Where(e => allNeededAncestorIds.Contains(e.Id)) + .Select(f => f.Id) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false)).ToHashSet() + : []; + foreach (var item in tuples) { if (item.Item.SupportsAncestors && item.AncestorIds != null) { - var existingAncestorIds = await context.AncestorIds - .Where(e => e.ItemId == item.Item.Id) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - var validAncestorIds = await context.BaseItems - .Where(e => item.AncestorIds.Contains(e.Id)) - .Select(f => f.Id) - .ToArrayAsync(cancellationToken) - .ConfigureAwait(false); + var existingAncestorIds = allExistingAncestorIds.Where(e => e.ItemId == item.Item.Id).ToList(); + var validAncestorIds = item.AncestorIds.Where(validAncestorIdSet.Contains).ToArray(); foreach (var ancestorId in validAncestorIds) { var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId); diff --git a/Jellyfin.Server.Implementations/Migrations/MigrationRecord.cs b/Jellyfin.Server.Implementations/Migrations/MigrationRecord.cs new file mode 100644 index 00000000..e171461b --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/MigrationRecord.cs @@ -0,0 +1,15 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Jellyfin.Server.Implementations.Migrations; + +/// Represents one row of the EF migrations history table. +public sealed class MigrationRecord +{ + /// Gets or sets the migration identifier. + public string MigrationId { get; set; } = string.Empty; + + /// Gets or sets the product version that applied this migration. + public string ProductVersion { get; set; } = string.Empty; +} diff --git a/Jellyfin.Server.Implementations/Migrations/MigrationTracker.cs b/Jellyfin.Server.Implementations/Migrations/MigrationTracker.cs new file mode 100644 index 00000000..b2939238 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/MigrationTracker.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Migrations; + +/// +/// Plain-SQL tracker for the EF migrations history table. +/// Replaces the EF-internal IHistoryRepository / SnakeCaseHistoryRepository. +/// +public static class MigrationTracker +{ + /// + /// Entry name used when writing/reading migration history from backup archives. + /// Kept as "HistoryRow" for backward compatibility with existing backup files. + /// + public const string BackupEntryName = "HistoryRow"; + + private const string CreateTableSql = """ + CREATE TABLE IF NOT EXISTS public."__EFMigrationsHistory" ( + migration_id character varying(150) NOT NULL, + product_version character varying(32) NOT NULL, + CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY (migration_id) + ) + """; + + /// Ensures the migrations history table exists. + /// The database context. + /// Cancellation token. + /// A representing the operation. + public static async Task EnsureTableExistsAsync(JellyfinDbContext dbContext, CancellationToken ct = default) + => await dbContext.Database.ExecuteSqlRawAsync(CreateTableSql, ct).ConfigureAwait(false); + + /// Returns the migration_id of every applied migration. + /// The database context. + /// Cancellation token. + /// A read-only list of applied migration identifiers. + public static async Task> GetAppliedMigrationIdsAsync(JellyfinDbContext dbContext, CancellationToken ct = default) + => await dbContext.Database + .SqlQueryRaw("""SELECT migration_id AS "Value" FROM public."__EFMigrationsHistory" """) + .ToListAsync(ct).ConfigureAwait(false); + + /// Returns all applied migration rows. + /// The database context. + /// Cancellation token. + /// A read-only list of instances. + public static async Task> GetAppliedMigrationsAsync(JellyfinDbContext dbContext, CancellationToken ct = default) + => await dbContext.Database + .SqlQueryRaw(""" + SELECT migration_id AS "MigrationId", product_version AS "ProductVersion" + FROM public."__EFMigrationsHistory" + """) + .ToListAsync(ct).ConfigureAwait(false); + + /// Inserts a migration row, ignoring conflicts. + /// The database context. + /// The migration identifier. + /// The product version. + /// Cancellation token. + /// A representing the operation. + public static async Task InsertAsync(JellyfinDbContext dbContext, string migrationId, string productVersion, CancellationToken ct = default) + => await dbContext.Database.ExecuteSqlAsync( + $""" + INSERT INTO public."__EFMigrationsHistory" (migration_id, product_version) + VALUES ({migrationId}, {productVersion}) ON CONFLICT DO NOTHING + """, + ct).ConfigureAwait(false); + + /// Deletes a migration row by id. + /// The database context. + /// The migration identifier to delete. + /// Cancellation token. + /// A representing the operation. + public static async Task DeleteAsync(JellyfinDbContext dbContext, string migrationId, CancellationToken ct = default) + => await dbContext.Database.ExecuteSqlAsync( + $"""DELETE FROM public."__EFMigrationsHistory" WHERE migration_id = {migrationId}""", + ct).ConfigureAwait(false); +} diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 9ad44bb0..10aa5efe 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -16,6 +16,7 @@ using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Serialization; using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Migrations; using Jellyfin.Server.Implementations.SystemBackupService; using Jellyfin.Server.Migrations.Stages; using Jellyfin.Server.ServerSetupApp; @@ -24,7 +25,6 @@ using MediaBrowser.Controller.SystemBackupService; using MediaBrowser.Model.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -123,18 +123,15 @@ internal class JellyfinMigrationService } */ - var historyRepository = dbContext.GetService(); - - await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false); - var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false); - var startupScripts = flatApplyMigrations + await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false); + var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false); + var migrationsToSeed = flatApplyMigrations .Where(e => !appliedMigrations.Any(f => f != e.BuildCodeMigrationId())) - .Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion())))) .ToArray(); - foreach (var item in startupScripts) + foreach (var item in migrationsToSeed) { - logger.LogInformation("Seed migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name); - await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false); + logger.LogInformation("Seed migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name); + await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false); } } @@ -155,8 +152,8 @@ internal class JellyfinMigrationService var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var historyRepository = dbContext.GetService(); - var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false); + await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false); + var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false); var lastOldAppliedMigration = Migrations .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations. .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value))) @@ -173,11 +170,10 @@ internal class JellyfinMigrationService ]; // those are all migrations that had to run in the old migration system, even if not noted in the migration.xml file. - var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion())))); - foreach (var item in startupScripts) + foreach (var item in oldMigrations) { - logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name); - await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false); + logger.LogInformation("Migrate migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name); + await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false); } logger.LogInformation("Rename old migration.xml to migration.xml.backup"); @@ -250,51 +246,14 @@ internal class JellyfinMigrationService logger.LogInformation("Empty database created successfully"); } - var historyRepository = dbContext.GetService(); - - // Explicitly ensure the __EFMigrationsHistory table exists - await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false); - - // Fallback: Ensure the migrations history table exists with explicit SQL creation - // This handles cases where CreateIfNotExistsAsync doesn't create the table - try - { - var historyTableSql = @" - CREATE TABLE IF NOT EXISTS public.__EFMigrationsHistory ( - MigrationId character varying(150) NOT NULL, - ProductVersion character varying(32) NOT NULL, - CONSTRAINT PK___EFMigrationsHistory PRIMARY KEY (MigrationId) - ); - "; - await dbContext.Database.ExecuteSqlRawAsync(historyTableSql).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error creating __EFMigrationsHistory table with SQL (may already exist): {Message}", ex.Message); - // Continue - table may already exist or will be created by EF Core's CreateIfNotExistsAsync - } - - var migrationsAssembly = dbContext.GetService(); - var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false); + var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false); var pendingCodeMigrations = migrationStage - .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) + .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId())) .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) .ToArray(); - (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; - - // DISABLED for .NET 11 preview: EF Core database migrations don't work with preview SDK - // Database schema is initialized from SQL scripts in PostgresDatabaseProvider instead - /* - if (stage is JellyfinMigrationStageTypes.CoreInitialisation) - { - pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) - .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) - .ToArray(); - } - */ - - (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; + (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations]; logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage); var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); @@ -415,16 +374,15 @@ internal class JellyfinMigrationService { logger.LogInformation("Prepare system for possible migrations"); JellyfinMigrationBackupAttribute backupInstruction; - IReadOnlyList appliedMigrations; + IReadOnlyList appliedMigrations; var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var historyRepository = dbContext.GetService(); - var migrationsAssembly = dbContext.GetService(); - appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false); + appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false); backupInstruction = new JellyfinMigrationBackupAttribute() { - JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key)) + JellyfinDb = false // Schema migrations are disabled; no EF schema migrations are ever pending }; } @@ -433,7 +391,7 @@ internal class JellyfinMigrationService bool isSqliteProvider = false; backupInstruction = Migrations.SelectMany(e => e) - .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) + .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId())) .Where(e => isSqliteProvider || !e.Metadata.RequiresSqlite) // Skip SQLite migrations for non-SQLite providers .Select(e => e.BackupRequirements) .Where(e => e is not null) @@ -525,27 +483,8 @@ internal class JellyfinMigrationService { await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false); - var historyRepository = _dbContext.GetService(); - var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion())); - await _dbContext.Database.ExecuteSqlRawAsync(createScript).ConfigureAwait(false); - } - } - - private class InternalDatabaseMigration : IInternalMigration - { - private readonly JellyfinDbContext _jellyfinDbContext; - private KeyValuePair _databaseMigrationInfo; - - public InternalDatabaseMigration(KeyValuePair databaseMigrationInfo, JellyfinDbContext jellyfinDbContext) - { - _databaseMigrationInfo = databaseMigrationInfo; - _jellyfinDbContext = jellyfinDbContext; - } - - public async Task PerformAsync(IStartupLogger logger) - { - var migrator = _jellyfinDbContext.GetService(); - await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false); + await MigrationTracker.InsertAsync(_dbContext, _codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false); } } } + diff --git a/Jellyfin.sln b/Jellyfin.sln index 05c0680c..d5a9f51e 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -553,18 +553,6 @@ Global {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x64.Build.0 = Release|Any CPU {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.ActiveCfg = Release|Any CPU {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.Build.0 = Release|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x64.ActiveCfg = Debug|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x64.Build.0 = Debug|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x86.ActiveCfg = Debug|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x86.Build.0 = Debug|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x64.ActiveCfg = Release|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x64.Build.0 = Release|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x86.ActiveCfg = Release|Any CPU - {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x86.Build.0 = Release|Any CPU {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -630,7 +618,6 @@ Global {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} - {A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {E1A314DC-837D-4472-AC15-EC08947C55B7} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 487b3821..227698f7 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -420,6 +420,7 @@ namespace MediaBrowser.Controller.Entities // Create a list for our validated children var newItems = new List(); + var changedItems = new List(); cancellationToken.ThrowIfCancellationRequested(); @@ -436,7 +437,7 @@ namespace MediaBrowser.Controller.Entities if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None) { - await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + changedItems.Add(currentChild); } else { @@ -484,6 +485,11 @@ namespace MediaBrowser.Controller.Entities LibraryManager.CreateItems(newItems, this, cancellationToken); } + if (changedItems.Count > 0) + { + await LibraryManager.UpdateItemsAsync(changedItems, this, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + // After removing items, reattach any detached user data to remaining children // that share the same user data keys (eg. same episode replaced with a new file). if (actuallyRemoved.Count > 0) diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs index 4261beb4..44d07e32 100644 --- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs +++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs @@ -115,7 +115,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr if (fanoutConcurrency <= 0) { // in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation. - return Environment.ProcessorCount - 3; + return Math.Max(2, Environment.ProcessorCount / 2); } return fanoutConcurrency; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/BulkOperationTransaction.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/BulkOperationTransaction.cs index 6e9f2193..1e059eb5 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/BulkOperationTransaction.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/BulkOperationTransaction.cs @@ -113,7 +113,6 @@ public sealed class BulkOperationTransaction : IAsyncDisposable, IDisposable _transaction?.Dispose(); _transaction = null; _disposed = true; - GC.SuppressFinalize(this); } /// @@ -134,7 +133,6 @@ public sealed class BulkOperationTransaction : IAsyncDisposable, IDisposable _transaction = null; _disposed = true; - GC.SuppressFinalize(this); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs index e54c01ce..027798e7 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs @@ -289,7 +289,6 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: new[] { "57P01" }); // Add "terminating connection" to retryable errors }) - .ReplaceService() .ConfigureWarnings(warnings => warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/SnakeCaseHistoryRepository.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/SnakeCaseHistoryRepository.cs deleted file mode 100644 index a498e28e..00000000 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/SnakeCaseHistoryRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -// -// Copyright (c) PlaceholderCompany. All rights reserved. -// - -#pragma warning disable EF1001 // NpgsqlHistoryRepository is an internal EF Core infrastructure type; intentional use in this PostgreSQL fork. - -namespace Jellyfin.Database.Providers.Postgres; - -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Internal; - -/// -/// A custom history repository that uses snake_case column names for the -/// __EFMigrationsHistory table, matching the PostgreSQL naming convention -/// used throughout this fork. -/// -public sealed class SnakeCaseHistoryRepository : NpgsqlHistoryRepository -{ - /// - /// Initializes a new instance of the class. - /// - /// Repository dependencies injected by EF Core. - public SnakeCaseHistoryRepository(HistoryRepositoryDependencies dependencies) - : base(dependencies) - { - } - - /// - protected override string MigrationIdColumnName => "migration_id"; - - /// - protected override string ProductVersionColumnName => "product_version"; -}