aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/CoreAppHost.cs8
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs2
-rw-r--r--Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs2
-rw-r--r--Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs36
-rw-r--r--Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs16
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj1
-rw-r--r--Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs12
-rw-r--r--Jellyfin.Server/Migrations/IMigrationRoutine.cs2
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs85
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs2
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs63
-rw-r--r--Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs4
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs4
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs6
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs1217
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs7
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs24
-rw-r--r--Jellyfin.Server/Program.cs59
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs172
-rw-r--r--Jellyfin.Server/Startup.cs10
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>();