diff options
Diffstat (limited to 'Jellyfin.Server')
21 files changed, 1657 insertions, 77 deletions
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index d5b6e93b8..f3bf6b805 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -4,13 +4,14 @@ using System.Reflection; using Emby.Server.Implementations; using Emby.Server.Implementations.Session; using Jellyfin.Api.WebSocketListeners; +using Jellyfin.Database.Implementations; using Jellyfin.Drawing; using Jellyfin.Drawing.Skia; using Jellyfin.LiveTv; -using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Devices; using Jellyfin.Server.Implementations.Events; +using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Implementations.Security; using Jellyfin.Server.Implementations.Trickplay; using Jellyfin.Server.Implementations.Users; @@ -116,9 +117,12 @@ namespace Jellyfin.Server // Jellyfin.Server yield return typeof(CoreAppHost).Assembly; - // Jellyfin.Server.Implementations + // Jellyfin.Database.Implementations yield return typeof(JellyfinDbContext).Assembly; + // Jellyfin.Server.Implementations + yield return typeof(ServiceCollectionExtensions).Assembly; + // Jellyfin.LiveTv yield return typeof(LiveTvManager).Assembly; } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 597643ed1..c6c3f21fe 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using Jellyfin.Api.Controllers; using Jellyfin.Api.Formatters; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; @@ -247,6 +248,7 @@ namespace Jellyfin.Server.Extensions c.AddSwaggerTypeMappings(); c.SchemaFilter<IgnoreEnumSchemaFilter>(); + c.OperationFilter<RetryOnTemporarlyUnavailableFilter>(); c.OperationFilter<SecurityRequirementsOperationFilter>(); c.OperationFilter<FileResponseFilter>(); c.OperationFilter<FileRequestFilter>(); diff --git a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs index 6b95770ed..7695c0d9e 100644 --- a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs @@ -85,6 +85,6 @@ public static class WebHostBuilderExtensions logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); } }) - .UseStartup(_ => new Startup(appHost)); + .UseStartup(context => new Startup(appHost, context.Configuration)); } } diff --git a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs new file mode 100644 index 000000000..74470eda0 --- /dev/null +++ b/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Net.Http.Headers; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters; + +internal class RetryOnTemporarlyUnavailableFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + operation.Responses.Add("503", new OpenApiResponse() + { + Description = "The server is currently starting or is temporarly not available.", + Headers = new Dictionary<string, OpenApiHeader>() + { + { + "Retry-After", + new() { AllowEmptyValue = true, Required = false, Description = "A hint for when to retry the operation in full seconds." } + }, + { + "Message", + new() { AllowEmptyValue = true, Required = false, Description = "A short plain-text reason why the server is not available." } + } + }, + Content = new Dictionary<string, OpenApiMediaType>() + { + { + "text/html", + new OpenApiMediaType() + } + } + }); + } +} diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs index 901ed55be..910b5c467 100644 --- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs +++ b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs @@ -67,38 +67,40 @@ namespace Jellyfin.Server.Infrastructure } /// <inheritdoc /> - protected override Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) + protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(result); if (range is not null && rangeLength == 0) { - return Task.CompletedTask; + return; } // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code if (!IsSymLink(result.FileName)) { - return base.WriteFileAsync(context, result, range, rangeLength); + await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false); + return; } var response = context.HttpContext.Response; if (range is not null) { - return SendFileAsync( + await SendFileAsync( result.FileName, response, offset: range.From ?? 0L, - count: rangeLength); + count: rangeLength).ConfigureAwait(false); + return; } - return SendFileAsync( + await SendFileAsync( result.FileName, response, offset: 0, - count: null); + count: null).ConfigureAwait(false); } private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default) diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index ebb12ba4e..452b03efb 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -66,6 +66,7 @@ <ProjectReference Include="..\src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj" /> <ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" /> <ProjectReference Include="..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" /> + <ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs new file mode 100644 index 000000000..78ff1e3fd --- /dev/null +++ b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs @@ -0,0 +1,12 @@ +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Migrations; + +/// <summary> +/// Defines a migration that operates on the Database. +/// </summary> +internal interface IDatabaseMigrationRoutine : IMigrationRoutine +{ +} diff --git a/Jellyfin.Server/Migrations/IMigrationRoutine.cs b/Jellyfin.Server/Migrations/IMigrationRoutine.cs index c1000eede..29f681df5 100644 --- a/Jellyfin.Server/Migrations/IMigrationRoutine.cs +++ b/Jellyfin.Server/Migrations/IMigrationRoutine.cs @@ -1,4 +1,6 @@ using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore.Internal; namespace Jellyfin.Server.Migrations { diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 2ab130eef..9865199f3 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -2,10 +2,15 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Emby.Server.Implementations; using Emby.Server.Implementations.Serialization; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -24,7 +29,8 @@ namespace Jellyfin.Server.Migrations typeof(PreStartupRoutines.CreateNetworkConfiguration), typeof(PreStartupRoutines.MigrateMusicBrainzTimeout), typeof(PreStartupRoutines.MigrateNetworkConfiguration), - typeof(PreStartupRoutines.MigrateEncodingOptions) + typeof(PreStartupRoutines.MigrateEncodingOptions), + typeof(PreStartupRoutines.RenameEnableGroupingIntoCollections) }; /// <summary> @@ -48,7 +54,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.UpdateDefaultPluginRepository), typeof(Routines.FixAudioData), typeof(Routines.MoveTrickplayFiles), - typeof(Routines.RemoveDuplicatePlaylistChildren) + typeof(Routines.RemoveDuplicatePlaylistChildren), + typeof(Routines.MigrateLibraryDb), }; /// <summary> @@ -56,7 +63,8 @@ namespace Jellyfin.Server.Migrations /// </summary> /// <param name="host">CoreAppHost that hosts current version.</param> /// <param name="loggerFactory">Factory for making the logger.</param> - public static void Run(CoreAppHost host, ILoggerFactory loggerFactory) + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + public static async Task Run(CoreAppHost host, ILoggerFactory loggerFactory) { var logger = loggerFactory.CreateLogger<MigrationRunner>(); var migrations = _migrationTypes @@ -66,7 +74,8 @@ namespace Jellyfin.Server.Migrations var migrationOptions = host.ConfigurationManager.GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey); HandleStartupWizardCondition(migrations, migrationOptions, host.ConfigurationManager.Configuration.IsStartupWizardCompleted, logger); - PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger); + await PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger, host.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>()) + .ConfigureAwait(false); } /// <summary> @@ -74,7 +83,8 @@ namespace Jellyfin.Server.Migrations /// </summary> /// <param name="appPaths">Application paths.</param> /// <param name="loggerFactory">Factory for making the logger.</param> - public static void RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory) + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + public static async Task RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory) { var logger = loggerFactory.CreateLogger<MigrationRunner>(); var migrations = _preStartupMigrationTypes @@ -94,7 +104,7 @@ namespace Jellyfin.Server.Migrations : new ServerConfiguration(); HandleStartupWizardCondition(migrations, migrationOptions, serverConfig.IsStartupWizardCompleted, logger); - PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger); + await PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger, null).ConfigureAwait(false); } private static void HandleStartupWizardCondition(IEnumerable<IMigrationRoutine> migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger) @@ -110,38 +120,61 @@ namespace Jellyfin.Server.Migrations migrationOptions.Applied.AddRange(onlyOldInstalls.Select(m => (m.Id, m.Name))); } - private static void PerformMigrations(IMigrationRoutine[] migrations, MigrationOptions migrationOptions, Action<MigrationOptions> saveConfiguration, ILogger logger) + private static async Task PerformMigrations( + IMigrationRoutine[] migrations, + MigrationOptions migrationOptions, + Action<MigrationOptions> saveConfiguration, + ILogger logger, + IJellyfinDatabaseProvider? jellyfinDatabaseProvider) { // save already applied migrations, and skip them thereafter saveConfiguration(migrationOptions); var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet(); + var migrationsToBeApplied = migrations.Where(e => !appliedMigrationIds.Contains(e.Id)).ToArray(); - for (var i = 0; i < migrations.Length; i++) + string? migrationKey = null; + if (jellyfinDatabaseProvider is not null && migrationsToBeApplied.Any(f => f is IDatabaseMigrationRoutine)) { - var migrationRoutine = migrations[i]; - if (appliedMigrationIds.Contains(migrationRoutine.Id)) - { - logger.LogDebug("Skipping migration '{Name}' since it is already applied", migrationRoutine.Name); - continue; - } - - logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name); - + logger.LogInformation("Performing database backup"); try { - migrationRoutine.Perform(); + migrationKey = await jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false); + logger.LogInformation("Database backup with key '{BackupKey}' has been successfully created.", migrationKey); } - catch (Exception ex) + catch (NotImplementedException) { - logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name); - throw; + logger.LogWarning("Could not perform backup of database before migration because provider does not support it"); } + } + + try + { + foreach (var migrationRoutine in migrationsToBeApplied) + { + logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name); + + try + { + migrationRoutine.Perform(); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name); + throw; + } - // Mark the migration as completed - logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name); - migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name)); - saveConfiguration(migrationOptions); - logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name); + // Mark the migration as completed + logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name); + migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name)); + saveConfiguration(migrationOptions); + logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name); + } + } + catch (System.Exception) when (migrationKey is not null && jellyfinDatabaseProvider is not null) + { + logger.LogInformation("Rollback on database as migration reported failure."); + await jellyfinDatabaseProvider.RestoreBackupFast(migrationKey, CancellationToken.None).ConfigureAwait(false); + throw; } } } diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs index 49960f430..09b292171 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS0618 // obsolete + using System; using System.IO; using System.Xml; diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs new file mode 100644 index 000000000..0a37b35a6 --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using Emby.Server.Implementations; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.PreStartupRoutines; + +/// <inheritdoc /> +public class RenameEnableGroupingIntoCollections : IMigrationRoutine +{ + private readonly ServerApplicationPaths _applicationPaths; + private readonly ILogger<RenameEnableGroupingIntoCollections> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="RenameEnableGroupingIntoCollections"/> class. + /// </summary> + /// <param name="applicationPaths">An instance of <see cref="ServerApplicationPaths"/>.</param> + /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param> + public RenameEnableGroupingIntoCollections(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory) + { + _applicationPaths = applicationPaths; + _logger = loggerFactory.CreateLogger<RenameEnableGroupingIntoCollections>(); + } + + /// <inheritdoc /> + public Guid Id => Guid.Parse("E73B777D-CD5C-4E71-957A-B86B3660B7CF"); + + /// <inheritdoc /> + public string Name => nameof(RenameEnableGroupingIntoCollections); + + /// <inheritdoc /> + public bool PerformOnNewInstall => false; + + /// <inheritdoc /> + public void Perform() + { + string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "system.xml"); + if (!File.Exists(path)) + { + _logger.LogWarning("Configuration file not found: {Path}", path); + return; + } + + try + { + XDocument xmlDocument = XDocument.Load(path); + var element = xmlDocument.Descendants("EnableGroupingIntoCollections").FirstOrDefault(); + if (element is not null) + { + element.Name = "EnableGroupingMoviesIntoCollections"; + _logger.LogInformation("The tag <EnableGroupingIntoCollections> was successfully renamed to <EnableGroupingMoviesIntoCollections>."); + xmlDocument.Save(path); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while updating the XML file: {Message}", ex.Message); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs index ee4f8b0ba..5a8ef2e1c 100644 --- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs +++ b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs @@ -46,7 +46,7 @@ namespace Jellyfin.Server.Migrations.Routines public Guid Id => Guid.Parse("{EF103419-8451-40D8-9F34-D1A8E93A1679}"); /// <inheritdoc/> - public string Name => "CreateLoggingConfigHeirarchy"; + public string Name => "CreateLoggingConfigHierarchy"; /// <inheritdoc/> public bool PerformOnNewInstall => false; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index 2f23cb1f8..e9fe9abce 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.IO; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index c845beef2..feaf46c84 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.IO; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities.Security; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities.Security; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using Microsoft.Data.Sqlite; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 502a37cde..a8fa2e52a 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -5,9 +5,9 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs new file mode 100644 index 000000000..cc90a53e8 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -0,0 +1,1217 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Data; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; +using Chapter = Jellyfin.Database.Implementations.Entities.Chapter; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// The migration routine for migrating the userdata database to EF Core. +/// </summary> +internal class MigrateLibraryDb : IDatabaseMigrationRoutine +{ + private const string DbFilename = "library.db"; + + private readonly ILogger<MigrateLibraryDb> _logger; + private readonly IServerApplicationPaths _paths; + private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; + + /// <summary> + /// Initializes a new instance of the <see cref="MigrateLibraryDb"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="provider">The database provider.</param> + /// <param name="paths">The server application paths.</param> + /// <param name="jellyfinDatabaseProvider">The database provider for special access.</param> + public MigrateLibraryDb( + ILogger<MigrateLibraryDb> logger, + IDbContextFactory<JellyfinDbContext> provider, + IServerApplicationPaths paths, + IJellyfinDatabaseProvider jellyfinDatabaseProvider) + { + _logger = logger; + _provider = provider; + _paths = paths; + _jellyfinDatabaseProvider = jellyfinDatabaseProvider; + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("36445464-849f-429f-9ad0-bb130efa0664"); + + /// <inheritdoc/> + public string Name => "MigrateLibraryDbData"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; // TODO Change back after testing + + /// <inheritdoc/> + public void Perform() + { + _logger.LogInformation("Migrating the userdata from library.db may take a while, do not stop Jellyfin."); + + var dataPath = _paths.DataPath; + var libraryDbPath = Path.Combine(dataPath, DbFilename); + using var connection = new SqliteConnection($"Filename={libraryDbPath}"); + var migrationTotalTime = TimeSpan.Zero; + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + connection.Open(); + using var dbContext = _provider.CreateDbContext(); + + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving UserData entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving TypedBaseItem."); + const string typedBaseItemsQuery = """ + SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie, + IsSeries, EpisodeTitle, IsRepeat, CommunityRating, CustomRating, IndexNumber, IsLocked, PreferredMetadataLanguage, + PreferredMetadataCountryCode, Width, Height, DateLastRefreshed, Name, Path, PremiereDate, Overview, ParentIndexNumber, + ProductionYear, OfficialRating, ForcedSortName, RunTimeTicks, Size, DateCreated, DateModified, Genres, ParentId, TopParentId, + Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId, + DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId, + PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate, + ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType FROM TypedBaseItems + """; + dbContext.BaseItems.ExecuteDelete(); + + var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>(); + foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery)) + { + var baseItem = GetItem(dto); + dbContext.BaseItems.Add(baseItem.BaseItem); + foreach (var dataKey in baseItem.LegacyUserDataKey) + { + legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem; + } + } + + _logger.LogInformation("Try saving {0} BaseItem entries.", dbContext.BaseItems.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving BaseItems entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving ItemValues."); + // do not migrate inherited types as they are now properly mapped in search and lookup. + const string itemValueQuery = + """ + SELECT ItemId, Type, Value, CleanValue FROM ItemValues + WHERE Type <> 6 AND EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = ItemValues.ItemId) + """; + dbContext.ItemValues.ExecuteDelete(); + + // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow. + var localItems = new Dictionary<(int Type, string CleanValue), (Database.Implementations.Entities.ItemValue ItemValue, List<Guid> ItemIds)>(); + + foreach (SqliteDataReader dto in connection.Query(itemValueQuery)) + { + var itemId = dto.GetGuid(0); + var entity = GetItemValue(dto); + var key = ((int)entity.Type, entity.CleanValue); + if (!localItems.TryGetValue(key, out var existing)) + { + localItems[key] = existing = (entity, []); + } + + existing.ItemIds.Add(itemId); + } + + foreach (var item in localItems) + { + dbContext.ItemValues.Add(item.Value.ItemValue); + dbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new ItemValueMap() + { + Item = null!, + ItemValue = null!, + ItemId = f, + ItemValueId = item.Value.ItemValue.ItemValueId + })); + } + + _logger.LogInformation("Try saving {0} ItemValues entries.", dbContext.ItemValues.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving People ItemValues took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving UserData."); + var queryResult = connection.Query(""" + SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas + + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key) + """); + + dbContext.UserData.ExecuteDelete(); + + var users = dbContext.Users.AsNoTracking().ToImmutableArray(); + + foreach (var entity in queryResult) + { + var userData = GetUserData(users, entity); + if (userData is null) + { + _logger.LogError("Was not able to migrate user data with key {0}", entity.GetString(0)); + continue; + } + + if (!legacyBaseItemWithUserKeys.TryGetValue(userData.CustomDataKey!, out var refItem)) + { + _logger.LogError("Was not able to migrate user data with key {0} because it does not reference a valid BaseItem.", entity.GetString(0)); + continue; + } + + userData.ItemId = refItem.Id; + dbContext.UserData.Add(userData); + } + + users.Clear(); + legacyBaseItemWithUserKeys.Clear(); + _logger.LogInformation("Try saving {0} UserData entries.", dbContext.UserData.Local.Count); + dbContext.SaveChanges(); + + _logger.LogInformation("Start moving MediaStreamInfos."); + const string mediaStreamQuery = """ + SELECT ItemId, StreamIndex, StreamType, Codec, Language, ChannelLayout, Profile, AspectRatio, Path, + IsInterlaced, BitRate, Channels, SampleRate, IsDefault, IsForced, IsExternal, Height, Width, + AverageFrameRate, RealFrameRate, Level, PixelFormat, BitDepth, IsAnamorphic, RefFrames, CodecTag, + Comment, NalLengthSize, IsAvc, Title, TimeBase, CodecTimeBase, ColorPrimaries, ColorSpace, ColorTransfer, + DvVersionMajor, DvVersionMinor, DvProfile, DvLevel, RpuPresentFlag, ElPresentFlag, BlPresentFlag, DvBlSignalCompatibilityId, IsHearingImpaired + FROM MediaStreams + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId) + """; + dbContext.MediaStreamInfos.ExecuteDelete(); + + foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery)) + { + dbContext.MediaStreamInfos.Add(GetMediaStream(dto)); + } + + _logger.LogInformation("Try saving {0} MediaStreamInfos entries.", dbContext.MediaStreamInfos.Local.Count); + dbContext.SaveChanges(); + + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving MediaStreamInfos entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving People."); + const string personsQuery = """ + SELECT ItemId, Name, Role, PersonType, SortOrder FROM People + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId) + """; + dbContext.Peoples.ExecuteDelete(); + dbContext.PeopleBaseItemMap.ExecuteDelete(); + + var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>(); + var baseItemIds = dbContext.BaseItems.Select(b => b.Id).ToHashSet(); + + foreach (SqliteDataReader reader in connection.Query(personsQuery)) + { + var itemId = reader.GetGuid(0); + if (!baseItemIds.Contains(itemId)) + { + _logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1)); + continue; + } + + var entity = GetPerson(reader); + if (!peopleCache.TryGetValue(entity.Name, out var personCache)) + { + peopleCache[entity.Name] = personCache = (entity, []); + } + + if (reader.TryGetString(2, out var role)) + { + } + + int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4); + + personCache.Items.Add(new PeopleBaseItemMap() + { + Item = null!, + ItemId = itemId, + People = null!, + PeopleId = personCache.Person.Id, + ListOrder = sortOrder, + SortOrder = sortOrder, + Role = role + }); + } + + baseItemIds.Clear(); + + foreach (var item in peopleCache) + { + dbContext.Peoples.Add(item.Value.Person); + dbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e.PeopleId))); + } + + peopleCache.Clear(); + + _logger.LogInformation("Try saving {0} People entries.", dbContext.Peoples.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving People entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving Chapters."); + const string chapterQuery = """ + SELECT ItemId,StartPositionTicks,Name,ImagePath,ImageDateModified,ChapterIndex from Chapters2 + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId) + """; + dbContext.Chapters.ExecuteDelete(); + + foreach (SqliteDataReader dto in connection.Query(chapterQuery)) + { + var chapter = GetChapter(dto); + dbContext.Chapters.Add(chapter); + } + + _logger.LogInformation("Try saving {0} Chapters entries.", dbContext.Chapters.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving Chapters took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving AncestorIds."); + const string ancestorIdsQuery = """ + SELECT ItemId, AncestorId, AncestorIdText FROM AncestorIds + WHERE + EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.ItemId) + AND + EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId) + """; + dbContext.AncestorIds.ExecuteDelete(); + + foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery)) + { + var ancestorId = GetAncestorId(dto); + dbContext.AncestorIds.Add(ancestorId); + } + + _logger.LogInformation("Try saving {0} AncestorIds entries.", dbContext.AncestorIds.Local.Count); + + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving AncestorIds took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + connection.Close(); + _logger.LogInformation("Migration of the Library.db done."); + _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old"); + + SqliteConnection.ClearAllPools(); + + File.Move(libraryDbPath, libraryDbPath + ".old", true); + + _logger.LogInformation("Migrating Library db took {0}.", migrationTotalTime); + + _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult(); + } + + private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto) + { + var internalUserId = dto.GetInt32(1); + var user = users.FirstOrDefault(e => e.InternalId == internalUserId); + + if (user is null) + { + _logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length); + return null; + } + + var oldKey = dto.GetString(0); + + return new UserData() + { + ItemId = Guid.NewGuid(), + CustomDataKey = oldKey, + UserId = user.Id, + Rating = dto.IsDBNull(2) ? null : dto.GetDouble(2), + Played = dto.GetBoolean(3), + PlayCount = dto.GetInt32(4), + IsFavorite = dto.GetBoolean(5), + PlaybackPositionTicks = dto.GetInt64(6), + LastPlayedDate = dto.IsDBNull(7) ? null : dto.GetDateTime(7), + AudioStreamIndex = dto.IsDBNull(8) ? null : dto.GetInt32(8), + SubtitleStreamIndex = dto.IsDBNull(9) ? null : dto.GetInt32(9), + Likes = null, + User = null!, + Item = null! + }; + } + + private AncestorId GetAncestorId(SqliteDataReader reader) + { + return new AncestorId() + { + ItemId = reader.GetGuid(0), + ParentItemId = reader.GetGuid(1), + Item = null!, + ParentItem = null! + }; + } + + /// <summary> + /// Gets the chapter. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>ChapterInfo.</returns> + private Chapter GetChapter(SqliteDataReader reader) + { + var chapter = new Chapter + { + StartPositionTicks = reader.GetInt64(1), + ChapterIndex = reader.GetInt32(5), + Item = null!, + ItemId = reader.GetGuid(0), + }; + + if (reader.TryGetString(2, out var chapterName)) + { + chapter.Name = chapterName; + } + + if (reader.TryGetString(3, out var imagePath)) + { + chapter.ImagePath = imagePath; + } + + if (reader.TryReadDateTime(4, out var imageDateModified)) + { + chapter.ImageDateModified = imageDateModified; + } + + return chapter; + } + + private ItemValue GetItemValue(SqliteDataReader reader) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = (ItemValueType)reader.GetInt32(1), + Value = reader.GetString(2), + CleanValue = reader.GetString(3), + }; + } + + private People GetPerson(SqliteDataReader reader) + { + var item = new People + { + Id = Guid.NewGuid(), + Name = reader.GetString(1), + }; + + if (reader.TryGetString(3, out var type)) + { + item.PersonType = type; + } + + return item; + } + + /// <summary> + /// Gets the media stream. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>MediaStream.</returns> + private MediaStreamInfo GetMediaStream(SqliteDataReader reader) + { + var item = new MediaStreamInfo + { + StreamIndex = reader.GetInt32(1), + StreamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2)), + Item = null!, + ItemId = reader.GetGuid(0), + AspectRatio = null!, + ChannelLayout = null!, + Codec = null!, + IsInterlaced = false, + Language = null!, + Path = null!, + Profile = null!, + }; + + if (reader.TryGetString(3, out var codec)) + { + item.Codec = codec; + } + + if (reader.TryGetString(4, out var language)) + { + item.Language = language; + } + + if (reader.TryGetString(5, out var channelLayout)) + { + item.ChannelLayout = channelLayout; + } + + if (reader.TryGetString(6, out var profile)) + { + item.Profile = profile; + } + + if (reader.TryGetString(7, out var aspectRatio)) + { + item.AspectRatio = aspectRatio; + } + + if (reader.TryGetString(8, out var path)) + { + item.Path = path; + } + + item.IsInterlaced = reader.GetBoolean(9); + + if (reader.TryGetInt32(10, out var bitrate)) + { + item.BitRate = bitrate; + } + + if (reader.TryGetInt32(11, out var channels)) + { + item.Channels = channels; + } + + if (reader.TryGetInt32(12, out var sampleRate)) + { + item.SampleRate = sampleRate; + } + + item.IsDefault = reader.GetBoolean(13); + item.IsForced = reader.GetBoolean(14); + item.IsExternal = reader.GetBoolean(15); + + if (reader.TryGetInt32(16, out var width)) + { + item.Width = width; + } + + if (reader.TryGetInt32(17, out var height)) + { + item.Height = height; + } + + if (reader.TryGetSingle(18, out var averageFrameRate)) + { + item.AverageFrameRate = averageFrameRate; + } + + if (reader.TryGetSingle(19, out var realFrameRate)) + { + item.RealFrameRate = realFrameRate; + } + + if (reader.TryGetSingle(20, out var level)) + { + item.Level = level; + } + + if (reader.TryGetString(21, out var pixelFormat)) + { + item.PixelFormat = pixelFormat; + } + + if (reader.TryGetInt32(22, out var bitDepth)) + { + item.BitDepth = bitDepth; + } + + if (reader.TryGetBoolean(23, out var isAnamorphic)) + { + item.IsAnamorphic = isAnamorphic; + } + + if (reader.TryGetInt32(24, out var refFrames)) + { + item.RefFrames = refFrames; + } + + if (reader.TryGetString(25, out var codecTag)) + { + item.CodecTag = codecTag; + } + + if (reader.TryGetString(26, out var comment)) + { + item.Comment = comment; + } + + if (reader.TryGetString(27, out var nalLengthSize)) + { + item.NalLengthSize = nalLengthSize; + } + + if (reader.TryGetBoolean(28, out var isAVC)) + { + item.IsAvc = isAVC; + } + + if (reader.TryGetString(29, out var title)) + { + item.Title = title; + } + + if (reader.TryGetString(30, out var timeBase)) + { + item.TimeBase = timeBase; + } + + if (reader.TryGetString(31, out var codecTimeBase)) + { + item.CodecTimeBase = codecTimeBase; + } + + if (reader.TryGetString(32, out var colorPrimaries)) + { + item.ColorPrimaries = colorPrimaries; + } + + if (reader.TryGetString(33, out var colorSpace)) + { + item.ColorSpace = colorSpace; + } + + if (reader.TryGetString(34, out var colorTransfer)) + { + item.ColorTransfer = colorTransfer; + } + + if (reader.TryGetInt32(35, out var dvVersionMajor)) + { + item.DvVersionMajor = dvVersionMajor; + } + + if (reader.TryGetInt32(36, out var dvVersionMinor)) + { + item.DvVersionMinor = dvVersionMinor; + } + + if (reader.TryGetInt32(37, out var dvProfile)) + { + item.DvProfile = dvProfile; + } + + if (reader.TryGetInt32(38, out var dvLevel)) + { + item.DvLevel = dvLevel; + } + + if (reader.TryGetInt32(39, out var rpuPresentFlag)) + { + item.RpuPresentFlag = rpuPresentFlag; + } + + if (reader.TryGetInt32(40, out var elPresentFlag)) + { + item.ElPresentFlag = elPresentFlag; + } + + if (reader.TryGetInt32(41, out var blPresentFlag)) + { + item.BlPresentFlag = blPresentFlag; + } + + if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId)) + { + item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId; + } + + item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result; + + // if (reader.TryGetInt32(44, out var rotation)) + // { + // item.Rotation = rotation; + // } + + return item; + } + + private (BaseItemEntity BaseItem, string[] LegacyUserDataKey) GetItem(SqliteDataReader reader) + { + var entity = new BaseItemEntity() + { + Id = reader.GetGuid(0), + Type = reader.GetString(1), + }; + + var index = 2; + + if (reader.TryGetString(index++, out var data)) + { + entity.Data = data; + } + + if (reader.TryReadDateTime(index++, out var startDate)) + { + entity.StartDate = startDate; + } + + if (reader.TryReadDateTime(index++, out var endDate)) + { + entity.EndDate = endDate; + } + + if (reader.TryGetGuid(index++, out var guid)) + { + entity.ChannelId = guid; + } + + if (reader.TryGetBoolean(index++, out var isMovie)) + { + entity.IsMovie = isMovie; + } + + if (reader.TryGetBoolean(index++, out var isSeries)) + { + entity.IsSeries = isSeries; + } + + if (reader.TryGetString(index++, out var episodeTitle)) + { + entity.EpisodeTitle = episodeTitle; + } + + if (reader.TryGetBoolean(index++, out var isRepeat)) + { + entity.IsRepeat = isRepeat; + } + + if (reader.TryGetSingle(index++, out var communityRating)) + { + entity.CommunityRating = communityRating; + } + + if (reader.TryGetString(index++, out var customRating)) + { + entity.CustomRating = customRating; + } + + if (reader.TryGetInt32(index++, out var indexNumber)) + { + entity.IndexNumber = indexNumber; + } + + if (reader.TryGetBoolean(index++, out var isLocked)) + { + entity.IsLocked = isLocked; + } + + if (reader.TryGetString(index++, out var preferredMetadataLanguage)) + { + entity.PreferredMetadataLanguage = preferredMetadataLanguage; + } + + if (reader.TryGetString(index++, out var preferredMetadataCountryCode)) + { + entity.PreferredMetadataCountryCode = preferredMetadataCountryCode; + } + + if (reader.TryGetInt32(index++, out var width)) + { + entity.Width = width; + } + + if (reader.TryGetInt32(index++, out var height)) + { + entity.Height = height; + } + + if (reader.TryReadDateTime(index++, out var dateLastRefreshed)) + { + entity.DateLastRefreshed = dateLastRefreshed; + } + + if (reader.TryGetString(index++, out var name)) + { + entity.Name = name; + } + + if (reader.TryGetString(index++, out var restorePath)) + { + entity.Path = restorePath; + } + + if (reader.TryReadDateTime(index++, out var premiereDate)) + { + entity.PremiereDate = premiereDate; + } + + if (reader.TryGetString(index++, out var overview)) + { + entity.Overview = overview; + } + + if (reader.TryGetInt32(index++, out var parentIndexNumber)) + { + entity.ParentIndexNumber = parentIndexNumber; + } + + if (reader.TryGetInt32(index++, out var productionYear)) + { + entity.ProductionYear = productionYear; + } + + if (reader.TryGetString(index++, out var officialRating)) + { + entity.OfficialRating = officialRating; + } + + if (reader.TryGetString(index++, out var forcedSortName)) + { + entity.ForcedSortName = forcedSortName; + } + + if (reader.TryGetInt64(index++, out var runTimeTicks)) + { + entity.RunTimeTicks = runTimeTicks; + } + + if (reader.TryGetInt64(index++, out var size)) + { + entity.Size = size; + } + + if (reader.TryReadDateTime(index++, out var dateCreated)) + { + entity.DateCreated = dateCreated; + } + + if (reader.TryReadDateTime(index++, out var dateModified)) + { + entity.DateModified = dateModified; + } + + if (reader.TryGetString(index++, out var genres)) + { + entity.Genres = genres; + } + + if (reader.TryGetGuid(index++, out var parentId)) + { + entity.ParentId = parentId; + } + + if (reader.TryGetGuid(index++, out var topParentId)) + { + entity.TopParentId = topParentId; + } + + if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var audioType)) + { + entity.Audio = audioType; + } + + if (reader.TryGetString(index++, out var serviceName)) + { + entity.ExternalServiceId = serviceName; + } + + if (reader.TryGetBoolean(index++, out var isInMixedFolder)) + { + entity.IsInMixedFolder = isInMixedFolder; + } + + if (reader.TryReadDateTime(index++, out var dateLastSaved)) + { + entity.DateLastSaved = dateLastSaved; + } + + if (reader.TryGetString(index++, out var lockedFields)) + { + entity.LockedFields = lockedFields.Split('|').Select(Enum.Parse<MetadataField>) + .Select(e => new BaseItemMetadataField() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }) + .ToArray(); + } + + if (reader.TryGetString(index++, out var studios)) + { + entity.Studios = studios; + } + + if (reader.TryGetString(index++, out var tags)) + { + entity.Tags = tags; + } + + if (reader.TryGetString(index++, out var trailerTypes)) + { + entity.TrailerTypes = trailerTypes.Split('|').Select(Enum.Parse<TrailerType>) + .Select(e => new BaseItemTrailerType() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }) + .ToArray(); + } + + if (reader.TryGetString(index++, out var originalTitle)) + { + entity.OriginalTitle = originalTitle; + } + + if (reader.TryGetString(index++, out var primaryVersionId)) + { + entity.PrimaryVersionId = primaryVersionId; + } + + if (reader.TryReadDateTime(index++, out var dateLastMediaAdded)) + { + entity.DateLastMediaAdded = dateLastMediaAdded; + } + + if (reader.TryGetString(index++, out var album)) + { + entity.Album = album; + } + + if (reader.TryGetSingle(index++, out var lUFS)) + { + entity.LUFS = lUFS; + } + + if (reader.TryGetSingle(index++, out var normalizationGain)) + { + entity.NormalizationGain = normalizationGain; + } + + if (reader.TryGetSingle(index++, out var criticRating)) + { + entity.CriticRating = criticRating; + } + + if (reader.TryGetBoolean(index++, out var isVirtualItem)) + { + entity.IsVirtualItem = isVirtualItem; + } + + if (reader.TryGetString(index++, out var seriesName)) + { + entity.SeriesName = seriesName; + } + + var userDataKeys = new List<string>(); + if (reader.TryGetString(index++, out var directUserDataKey)) + { + userDataKeys.Add(directUserDataKey); + } + + if (reader.TryGetString(index++, out var seasonName)) + { + entity.SeasonName = seasonName; + } + + if (reader.TryGetGuid(index++, out var seasonId)) + { + entity.SeasonId = seasonId; + } + + if (reader.TryGetGuid(index++, out var seriesId)) + { + entity.SeriesId = seriesId; + } + + if (reader.TryGetString(index++, out var presentationUniqueKey)) + { + entity.PresentationUniqueKey = presentationUniqueKey; + } + + if (reader.TryGetInt32(index++, out var parentalRating)) + { + entity.InheritedParentalRatingValue = parentalRating; + } + + if (reader.TryGetString(index++, out var externalSeriesId)) + { + entity.ExternalSeriesId = externalSeriesId; + } + + if (reader.TryGetString(index++, out var tagLine)) + { + entity.Tagline = tagLine; + } + + if (reader.TryGetString(index++, out var providerIds)) + { + entity.Provider = providerIds.Split('|').Select(e => e.Split("=")) + .Select(e => new BaseItemProvider() + { + Item = null!, + ProviderId = e[0], + ProviderValue = e[1] + }).ToArray(); + } + + if (reader.TryGetString(index++, out var imageInfos)) + { + entity.Images = DeserializeImages(imageInfos).Select(f => Map(entity.Id, f)).ToArray(); + } + + if (reader.TryGetString(index++, out var productionLocations)) + { + entity.ProductionLocations = productionLocations; + } + + if (reader.TryGetString(index++, out var extraIds)) + { + entity.ExtraIds = extraIds; + } + + if (reader.TryGetInt32(index++, out var totalBitrate)) + { + entity.TotalBitrate = totalBitrate; + } + + if (reader.TryGetString(index++, out var extraTypeString) && Enum.TryParse<BaseItemExtraType>(extraTypeString, out var extraType)) + { + entity.ExtraType = extraType; + } + + if (reader.TryGetString(index++, out var artists)) + { + entity.Artists = artists; + } + + if (reader.TryGetString(index++, out var albumArtists)) + { + entity.AlbumArtists = albumArtists; + } + + if (reader.TryGetString(index++, out var externalId)) + { + entity.ExternalId = externalId; + } + + if (reader.TryGetString(index++, out var seriesPresentationUniqueKey)) + { + entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; + } + + if (reader.TryGetString(index++, out var showId)) + { + entity.ShowId = showId; + } + + if (reader.TryGetString(index++, out var ownerId)) + { + entity.OwnerId = ownerId; + } + + if (reader.TryGetString(index++, out var mediaType)) + { + entity.MediaType = mediaType; + } + + if (reader.TryGetString(index++, out var sortName)) + { + entity.SortName = sortName; + } + + if (reader.TryGetString(index++, out var cleanName)) + { + entity.CleanName = cleanName; + } + + if (reader.TryGetString(index++, out var unratedType)) + { + entity.UnratedType = unratedType; + } + + var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false); + var dataKeys = baseItem.GetUserDataKeys(); + userDataKeys.AddRange(dataKeys); + + return (entity, userDataKeys.ToArray()); + } + + private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e) + { + return new BaseItemImageInfo() + { + ItemId = baseItemId, + Id = Guid.NewGuid(), + Path = e.Path, + Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null, + DateModified = e.DateModified, + Height = e.Height, + Width = e.Width, + ImageType = (ImageInfoImageType)e.Type, + Item = null! + }; + } + + internal ItemImageInfo[] DeserializeImages(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty<ItemImageInfo>(); + } + + // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed + var valueSpan = value.AsSpan(); + var count = valueSpan.Count('|') + 1; + + var position = 0; + var result = new ItemImageInfo[count]; + foreach (var part in valueSpan.Split('|')) + { + var image = ItemImageInfoFromValueString(part); + + if (image is not null) + { + result[position++] = image; + } + } + + if (position == count) + { + return result; + } + + if (position == 0) + { + return Array.Empty<ItemImageInfo>(); + } + + // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array. + return result[..position]; + } + + internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan<char> value) + { + const char Delimiter = '*'; + + var nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan<char> path = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan<char> dateModified = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan<char> imageType = value[..nextSegment]; + + var image = new ItemImageInfo + { + Path = path.ToString() + }; + + if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks) + && ticks >= DateTime.MinValue.Ticks + && ticks <= DateTime.MaxValue.Ticks) + { + image.DateModified = new DateTime(ticks, DateTimeKind.Utc); + } + else + { + return null; + } + + if (Enum.TryParse(imageType, true, out ImageType type)) + { + image.Type = type; + } + else + { + return null; + } + + // Optional parameters: width*height*blurhash + if (nextSegment + 1 < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1 || nextSegment == value.Length) + { + return image; + } + + ReadOnlySpan<char> widthSpan = value[..nextSegment]; + + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan<char> heightSpan = value[..nextSegment]; + + if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) + && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) + { + image.Width = width; + image.Height = height; + } + + if (nextSegment < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + var length = value.Length; + + Span<char> blurHashSpan = stackalloc char[length]; + for (int i = 0; i < length; i++) + { + var c = value[i]; + blurHashSpan[i] = c switch + { + '/' => Delimiter, + '\\' => '|', + _ => c + }; + } + + image.BlurHash = new string(blurHashSpan); + } + } + + return image; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 7dcae5bd9..c40560660 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -1,10 +1,11 @@ using System; using System.IO; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; -using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs index c1a9e8894..f4ebac377 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs @@ -39,7 +39,7 @@ public class MoveTrickplayFiles : IMigrationRoutine } /// <inheritdoc /> - public Guid Id => new("4EF123D5-8EFF-4B0B-869D-3AED07A60E1B"); + public Guid Id => new("9540D44A-D8DC-11EF-9CBB-B77274F77C52"); /// <inheritdoc /> public string Name => "MoveTrickplayFiles"; @@ -89,6 +89,12 @@ public class MoveTrickplayFiles : IMigrationRoutine { _fileSystem.MoveDirectory(oldPath, newPath); } + + oldPath = GetNewOldTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false); + if (_fileSystem.DirectoryExists(oldPath)) + { + _fileSystem.MoveDirectory(oldPath, newPath); + } } } while (previousCount == Limit); @@ -101,4 +107,20 @@ public class MoveTrickplayFiles : IMigrationRoutine return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path; } + + private string GetNewOldTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false) + { + var path = saveWithMedia + ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay")) + : Path.Combine(item.GetInternalMetadataPath(), "trickplay"); + + var subdirectory = string.Format( + CultureInfo.InvariantCulture, + "{0} - {1}x{2}", + width.ToString(CultureInfo.InvariantCulture), + tileWidth.ToString(CultureInfo.InvariantCulture), + tileHeight.ToString(CultureInfo.InvariantCulture)); + + return Path.Combine(path, subdirectory); + } } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 295fb8112..e661d0d4a 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -4,15 +4,19 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; +using Jellyfin.Database.Implementations; using Jellyfin.Server.Extensions; using Jellyfin.Server.Helpers; -using Jellyfin.Server.Implementations; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; using MediaBrowser.Controller; using Microsoft.AspNetCore.Hosting; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -42,6 +46,9 @@ namespace Jellyfin.Server public const string LoggingConfigFileSystem = "logging.json"; private static readonly SerilogLoggerFactory _loggerFactory = new SerilogLoggerFactory(); + private static SetupServer _setupServer = new(); + private static CoreAppHost? _appHost; + private static IHost? _jellyfinHost = null; private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; @@ -68,6 +75,7 @@ namespace Jellyfin.Server { _startTimestamp = Stopwatch.GetTimestamp(); ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options); + await _setupServer.RunAsync(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost).ConfigureAwait(false); // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath); @@ -113,7 +121,7 @@ namespace Jellyfin.Server } StartupHelpers.PerformStaticInitialization(); - Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory); + await Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory).ConfigureAwait(false); do { @@ -122,22 +130,23 @@ namespace Jellyfin.Server if (_restartOnShutdown) { _startTimestamp = Stopwatch.GetTimestamp(); + _setupServer = new SetupServer(); + await _setupServer.RunAsync(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost).ConfigureAwait(false); } } while (_restartOnShutdown); } private static async Task StartServer(IServerApplicationPaths appPaths, StartupOptions options, IConfiguration startupConfig) { - using var appHost = new CoreAppHost( - appPaths, - _loggerFactory, - options, - startupConfig); - - IHost? host = null; + using CoreAppHost appHost = new CoreAppHost( + appPaths, + _loggerFactory, + options, + startupConfig); + _appHost = appHost; try { - host = Host.CreateDefaultBuilder() + _jellyfinHost = Host.CreateDefaultBuilder() .UseConsoleLifetime() .ConfigureServices(services => appHost.Init(services)) .ConfigureWebHostDefaults(webHostBuilder => @@ -154,14 +163,17 @@ namespace Jellyfin.Server .Build(); // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. - appHost.ServiceProvider = host.Services; + appHost.ServiceProvider = _jellyfinHost.Services; - await appHost.InitializeServices().ConfigureAwait(false); - Migrations.MigrationRunner.Run(appHost, _loggerFactory); + await appHost.InitializeServices(startupConfig).ConfigureAwait(false); + await Migrations.MigrationRunner.Run(appHost, _loggerFactory).ConfigureAwait(false); try { - await host.StartAsync().ConfigureAwait(false); + await _setupServer.StopAsync().ConfigureAwait(false); + _setupServer.Dispose(); + _setupServer = null!; + await _jellyfinHost.StartAsync().ConfigureAwait(false); if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) { @@ -180,7 +192,7 @@ namespace Jellyfin.Server _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); - await host.WaitForShutdownAsync().ConfigureAwait(false); + await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); _restartOnShutdown = appHost.ShouldRestart; } catch (Exception ex) @@ -194,18 +206,15 @@ namespace Jellyfin.Server if (appHost.ServiceProvider is not null) { _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); - // Run before disposing the application - var context = await appHost.ServiceProvider.GetRequiredService<IDbContextFactory<JellyfinDbContext>>().CreateDbContextAsync().ConfigureAwait(false); - await using (context.ConfigureAwait(false)) - { - if (context.Database.IsSqlite()) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize").ConfigureAwait(false); - } - } + + var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); + using var shutdownSource = new CancellationTokenSource(); + shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds); + await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false); } - host?.Dispose(); + _appHost = null; + _jellyfinHost?.Dispose(); } } diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs new file mode 100644 index 000000000..9e2cf5bc8 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -0,0 +1,172 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Model.System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Creates a fake application pipeline that will only exist for as long as the main app is not started. +/// </summary> +public sealed class SetupServer : IDisposable +{ + private IHost? _startupServer; + private bool _disposed; + + /// <summary> + /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup. + /// </summary> + /// <param name="networkManagerFactory">The networkmanager.</param> + /// <param name="applicationPaths">The application paths.</param> + /// <param name="serverApplicationHost">The servers application host.</param> + /// <returns>A Task.</returns> + public async Task RunAsync( + Func<INetworkManager?> networkManagerFactory, + IApplicationPaths applicationPaths, + Func<IServerApplicationHost?> serverApplicationHost) + { + ThrowIfDisposed(); + _startupServer = Host.CreateDefaultBuilder() + .UseConsoleLifetime() + .ConfigureServices(serv => + { + serv.AddHealthChecks() + .AddCheck<SetupHealthcheck>("StartupCheck"); + }) + .ConfigureWebHostDefaults(webHostBuilder => + { + webHostBuilder + .UseKestrel() + .Configure(app => + { + app.UseHealthChecks("/health"); + + app.Map("/startup/logger", loggerRoute => + { + loggerRoute.Run(async context => + { + var networkManager = networkManagerFactory(); + if (context.Connection.RemoteIpAddress is null || networkManager is null || !networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress)) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + + var logFilePath = new DirectoryInfo(applicationPaths.LogDirectoryPath) + .EnumerateFiles() + .OrderBy(f => f.CreationTimeUtc) + .FirstOrDefault() + ?.FullName; + if (logFilePath is not null) + { + await context.Response.SendFileAsync(logFilePath, CancellationToken.None).ConfigureAwait(false); + } + }); + }); + + app.Map("/System/Info/Public", systemRoute => + { + systemRoute.Run(async context => + { + var jfApplicationHost = serverApplicationHost(); + + var retryCounter = 0; + while (jfApplicationHost is null && retryCounter < 5) + { + await Task.Delay(500).ConfigureAwait(false); + jfApplicationHost = serverApplicationHost(); + retryCounter++; + } + + if (jfApplicationHost is null) + { + context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + context.Response.Headers.RetryAfter = new Microsoft.Extensions.Primitives.StringValues("60"); + return; + } + + var sysInfo = new PublicSystemInfo + { + Version = jfApplicationHost.ApplicationVersionString, + ProductName = jfApplicationHost.Name, + Id = jfApplicationHost.SystemId, + ServerName = jfApplicationHost.FriendlyName, + LocalAddress = jfApplicationHost.GetSmartApiUrl(context.Request), + StartupWizardCompleted = false + }; + + await context.Response.WriteAsJsonAsync(sysInfo).ConfigureAwait(false); + }); + }); + + app.Run((context) => + { + context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + context.Response.Headers.RetryAfter = new Microsoft.Extensions.Primitives.StringValues("60"); + context.Response.WriteAsync("<p>Jellyfin Server still starting. Please wait.</p>"); + var networkManager = networkManagerFactory(); + if (networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress)) + { + context.Response.WriteAsync("<p>You can download the current logfiles <a href='/startup/logger'>here</a>.</p>"); + } + + return Task.CompletedTask; + }); + }); + }) + .Build(); + await _startupServer.StartAsync().ConfigureAwait(false); + } + + /// <summary> + /// Stops the Setup server. + /// </summary> + /// <returns>A task. Duh.</returns> + public async Task StopAsync() + { + ThrowIfDisposed(); + if (_startupServer is null) + { + throw new InvalidOperationException("Tried to stop a non existing startup server"); + } + + await _startupServer.StopAsync().ConfigureAwait(false); + } + + /// <inheritdoc/> + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _startupServer?.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private class SetupHealthcheck : IHealthCheck + { + public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up.")); + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index e9fb3e4c2..688b16935 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -6,6 +6,7 @@ using System.Net.Mime; using System.Text; using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; +using Jellyfin.Database.Implementations; using Jellyfin.LiveTv.Extensions; using Jellyfin.LiveTv.Recordings; using Jellyfin.MediaEncoding.Hls.Extensions; @@ -13,7 +14,6 @@ using Jellyfin.Networking; using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; -using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; @@ -39,15 +39,18 @@ namespace Jellyfin.Server public class Startup { private readonly CoreAppHost _serverApplicationHost; + private readonly IConfiguration _configuration; private readonly IServerConfigurationManager _serverConfigurationManager; /// <summary> /// Initializes a new instance of the <see cref="Startup" /> class. /// </summary> /// <param name="appHost">The server application host.</param> - public Startup(CoreAppHost appHost) + /// <param name="configuration">The used Configuration.</param> + public Startup(CoreAppHost appHost, IConfiguration configuration) { _serverApplicationHost = appHost; + _configuration = configuration; _serverConfigurationManager = appHost.ConfigurationManager; } @@ -67,7 +70,7 @@ namespace Jellyfin.Server // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371 services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>(); services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); - services.AddJellyfinDbContext(); + services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration); services.AddJellyfinApiSwagger(); // configure custom legacy authentication @@ -129,7 +132,6 @@ namespace Jellyfin.Server services.AddHostedService<RecordingsHost>(); services.AddHostedService<AutoDiscoveryHost>(); - services.AddHostedService<PortForwardingHost>(); services.AddHostedService<NfoUserDataSaver>(); services.AddHostedService<LibraryChangedNotifier>(); services.AddHostedService<UserDataChangeNotifier>(); |
