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.cs8
-rw-r--r--Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs116
-rw-r--r--Jellyfin.Server/Filters/AdditionalModelFilter.cs107
-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/IAsyncMigrationRoutine.cs31
-rw-r--r--Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs14
-rw-r--r--Jellyfin.Server/Migrations/IMigrationRoutine.cs30
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs68
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs231
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs148
-rw-r--r--Jellyfin.Server/Migrations/MigrationsFactory.cs20
-rw-r--r--Jellyfin.Server/Migrations/MigrationsListStore.cs24
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs12
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs12
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs12
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs12
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs57
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixAudioData.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs16
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs16
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs18
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs151
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs1389
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs131
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs339
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs298
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs62
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs65
-rw-r--r--Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs131
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs69
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs111
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs17
-rw-r--r--Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs12
-rw-r--r--Jellyfin.Server/Migrations/Stages/CodeMigration.cs51
-rw-r--r--Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs26
-rw-r--r--Jellyfin.Server/Migrations/Stages/MigrationStage.cs16
-rw-r--r--Jellyfin.Server/Program.cs137
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs220
-rw-r--r--Jellyfin.Server/Startup.cs10
-rw-r--r--Jellyfin.Server/StartupOptions.cs6
48 files changed, 3443 insertions, 883 deletions
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index d5b6e93b8e..f3bf6b805a 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 597643ed19..09a4e2ed31 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;
@@ -118,15 +119,15 @@ namespace Jellyfin.Server.Extensions
// https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs
// Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues.
- options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
-
if (config.KnownProxies.Length == 0)
{
+ options.ForwardedHeaders = ForwardedHeaders.None;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
}
else
{
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
AddProxyAddresses(config, config.KnownProxies, options);
}
@@ -214,7 +215,7 @@ namespace Jellyfin.Server.Extensions
});
// Add all xml doc files to swagger generator.
- var xmlFiles = Directory.GetFiles(
+ var xmlFiles = Directory.EnumerateFiles(
AppContext.BaseDirectory,
"*.xml",
SearchOption.TopDirectoryOnly);
@@ -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 6b95770ed5..be9cf0f154 100644
--- a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
@@ -1,10 +1,14 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Net;
+using System.Security.Cryptography.X509Certificates;
using Jellyfin.Server.Helpers;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Extensions;
+using MediaBrowser.Model.Net;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -35,56 +39,98 @@ public static class WebHostBuilderExtensions
return builder
.UseKestrel((builderContext, options) =>
{
- var addresses = appHost.NetManager.GetAllBindInterfaces(false);
+ SetupJellyfinWebServer(
+ appHost.NetManager.GetAllBindInterfaces(false),
+ appHost.HttpPort,
+ appHost.ListenWithHttps ? appHost.HttpsPort : null,
+ appHost.Certificate,
+ startupConfig,
+ appPaths,
+ logger,
+ builderContext,
+ options);
+ })
+ .UseStartup(context => new Startup(appHost, context.Configuration));
+ }
- bool flagged = false;
- foreach (var netAdd in addresses)
+ /// <summary>
+ /// Configures a Kestrel type webServer to bind to the specific arguments.
+ /// </summary>
+ /// <param name="addresses">The IP addresses that should be listend to.</param>
+ /// <param name="httpPort">The http port.</param>
+ /// <param name="httpsPort">If set the https port. If set you must also set the certificate.</param>
+ /// <param name="certificate">The certificate used for https port.</param>
+ /// <param name="startupConfig">The startup config.</param>
+ /// <param name="appPaths">The app paths.</param>
+ /// <param name="logger">A logger.</param>
+ /// <param name="builderContext">The kestrel build pipeline context.</param>
+ /// <param name="options">The kestrel server options.</param>
+ /// <exception cref="InvalidOperationException">Will be thrown when a https port is set but no or an invalid certificate is provided.</exception>
+ public static void SetupJellyfinWebServer(
+ IReadOnlyList<IPData> addresses,
+ int httpPort,
+ int? httpsPort,
+ X509Certificate2? certificate,
+ IConfiguration startupConfig,
+ IApplicationPaths appPaths,
+ ILogger logger,
+ WebHostBuilderContext builderContext,
+ KestrelServerOptions options)
+ {
+ bool flagged = false;
+ foreach (var netAdd in addresses)
+ {
+ var address = netAdd.Address;
+ logger.LogInformation("Kestrel is listening on {Address}", address.Equals(IPAddress.IPv6Any) ? "all interfaces" : address);
+ options.Listen(netAdd.Address, httpPort);
+ if (httpsPort.HasValue)
+ {
+ if (builderContext.HostingEnvironment.IsDevelopment())
{
- var address = netAdd.Address;
- logger.LogInformation("Kestrel is listening on {Address}", address.Equals(IPAddress.IPv6Any) ? "all interfaces" : address);
- options.Listen(netAdd.Address, appHost.HttpPort);
- if (appHost.ListenWithHttps)
+ try
{
options.Listen(
address,
- appHost.HttpsPort,
- listenOptions => listenOptions.UseHttps(appHost.Certificate));
+ httpsPort.Value,
+ listenOptions => listenOptions.UseHttps());
}
- else if (builderContext.HostingEnvironment.IsDevelopment())
+ catch (InvalidOperationException)
{
- try
+ if (!flagged)
{
- options.Listen(
- address,
- appHost.HttpsPort,
- listenOptions => listenOptions.UseHttps());
- }
- catch (InvalidOperationException)
- {
- if (!flagged)
- {
- logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted");
- flagged = true;
- }
+ logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted");
+ flagged = true;
}
}
}
-
- // Bind to unix socket (only on unix systems)
- if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix)
+ else
{
- var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths);
-
- // Workaround for https://github.com/aspnet/AspNetCore/issues/14134
- if (File.Exists(socketPath))
+ if (certificate is null)
{
- File.Delete(socketPath);
+ throw new InvalidOperationException("Cannot run jellyfin with https without setting a valid certificate.");
}
- options.ListenUnixSocket(socketPath);
- logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath);
+ options.Listen(
+ address,
+ httpsPort.Value,
+ listenOptions => listenOptions.UseHttps(certificate));
}
- })
- .UseStartup(_ => new Startup(appHost));
+ }
+ }
+
+ // Bind to unix socket (only on unix systems)
+ if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix)
+ {
+ var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths);
+
+ // Workaround for https://github.com/aspnet/AspNetCore/issues/14134
+ if (File.Exists(socketPath))
+ {
+ File.Delete(socketPath);
+ }
+
+ options.ListenUnixSocket(socketPath);
+ logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath);
+ }
}
}
diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
index bf38f741cd..421eeecda0 100644
--- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs
+++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
@@ -25,7 +25,7 @@ namespace Jellyfin.Server.Filters
public class AdditionalModelFilter : IDocumentFilter
{
// Array of options that should not be visible in the api spec.
- private static readonly Type[] _ignoredConfigurations = { typeof(MigrationOptions) };
+ private static readonly Type[] _ignoredConfigurations = { typeof(MigrationOptions), typeof(MediaBrowser.Model.Branding.BrandingOptions) };
private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
@@ -92,17 +92,51 @@ namespace Jellyfin.Server.Filters
continue;
}
- // Additional discriminator needed for GroupUpdate models...
- if (messageType == SessionMessageType.SyncPlayGroupUpdate && type != typeof(SyncPlayGroupUpdateCommandMessage))
- {
- continue;
- }
-
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
outboundWebSocketSchemas.Add(schema);
outboundWebSocketDiscriminators.Add(messageType.ToString()!, schema.Reference.ReferenceV3);
}
+ // Add custom "SyncPlayGroupUpdateMessage" schema because Swashbuckle cannot generate it for us
+ var syncPlayGroupUpdateMessageSchema = new OpenApiSchema
+ {
+ Type = "object",
+ Description = "Untyped sync play command.",
+ Properties = new Dictionary<string, OpenApiSchema>
+ {
+ {
+ "Data", new OpenApiSchema
+ {
+ AllOf =
+ [
+ new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(GroupUpdate<object>) } }
+ ],
+ Description = "Group update data",
+ Nullable = false,
+ }
+ },
+ { "MessageId", new OpenApiSchema { Type = "string", Format = "uuid", Description = "Gets or sets the message id." } },
+ {
+ "MessageType", new OpenApiSchema
+ {
+ Enum = Enum.GetValues<SessionMessageType>().Select(type => new OpenApiString(type.ToString())).ToList<IOpenApiAny>(),
+ AllOf =
+ [
+ new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(SessionMessageType) } }
+ ],
+ Description = "The different kinds of messages that are used in the WebSocket api.",
+ Default = new OpenApiString(nameof(SessionMessageType.SyncPlayGroupUpdate)),
+ ReadOnly = true
+ }
+ },
+ },
+ AdditionalPropertiesAllowed = false,
+ Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "SyncPlayGroupUpdateMessage" }
+ };
+ context.SchemaRepository.AddDefinition("SyncPlayGroupUpdateMessage", syncPlayGroupUpdateMessageSchema);
+ outboundWebSocketSchemas.Add(syncPlayGroupUpdateMessageSchema);
+ outboundWebSocketDiscriminators[nameof(SessionMessageType.SyncPlayGroupUpdate)] = syncPlayGroupUpdateMessageSchema.Reference.ReferenceV3;
+
var outboundWebSocketMessageSchema = new OpenApiSchema
{
Type = "object",
@@ -140,41 +174,46 @@ namespace Jellyfin.Server.Filters
});
// Manually generate sync play GroupUpdate messages.
- if (!context.SchemaRepository.Schemas.TryGetValue(nameof(GroupUpdate), out var groupUpdateSchema))
- {
- groupUpdateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository);
- }
-
- var groupUpdateOfGroupInfoSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<GroupInfoDto>), context.SchemaRepository);
- var groupUpdateOfGroupStateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<GroupStateUpdate>), context.SchemaRepository);
- var groupUpdateOfStringSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<string>), context.SchemaRepository);
- var groupUpdateOfPlayQueueSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<PlayQueueUpdate>), context.SchemaRepository);
+ var groupUpdateTypes = typeof(GroupUpdate<>).Assembly.GetTypes()
+ .Where(t => t.BaseType != null
+ && t.BaseType.IsGenericType
+ && t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>))
+ .ToList();
- groupUpdateSchema.OneOf = new List<OpenApiSchema>
+ var groupUpdateSchemas = new List<OpenApiSchema>();
+ var groupUpdateDiscriminators = new Dictionary<string, string>();
+ foreach (var type in groupUpdateTypes)
{
- groupUpdateOfGroupInfoSchema,
- groupUpdateOfGroupStateSchema,
- groupUpdateOfStringSchema,
- groupUpdateOfPlayQueueSchema
- };
+ var groupUpdateType = (GroupUpdateType?)type.GetProperty(nameof(GroupUpdate<object>.Type))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
+ if (groupUpdateType is null)
+ {
+ continue;
+ }
+
+ var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
+ groupUpdateSchemas.Add(schema);
+ groupUpdateDiscriminators[groupUpdateType.ToString()!] = schema.Reference.ReferenceV3;
+ }
- groupUpdateSchema.Discriminator = new OpenApiDiscriminator
+ var groupUpdateSchema = new OpenApiSchema
{
- PropertyName = nameof(GroupUpdate.Type),
- Mapping = new Dictionary<string, string>
+ Type = "object",
+ Description = "Represents the list of possible group update types",
+ Reference = new OpenApiReference
+ {
+ Id = nameof(GroupUpdate<object>),
+ Type = ReferenceType.Schema
+ },
+ OneOf = groupUpdateSchemas,
+ Discriminator = new OpenApiDiscriminator
{
- { GroupUpdateType.UserJoined.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
- { GroupUpdateType.UserLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
- { GroupUpdateType.GroupJoined.ToString(), groupUpdateOfGroupInfoSchema.Reference.ReferenceV3 },
- { GroupUpdateType.GroupLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
- { GroupUpdateType.StateUpdate.ToString(), groupUpdateOfGroupStateSchema.Reference.ReferenceV3 },
- { GroupUpdateType.PlayQueue.ToString(), groupUpdateOfPlayQueueSchema.Reference.ReferenceV3 },
- { GroupUpdateType.NotInGroup.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
- { GroupUpdateType.GroupDoesNotExist.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
- { GroupUpdateType.LibraryAccessDenied.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }
+ PropertyName = nameof(GroupUpdate<object>.Type),
+ Mapping = groupUpdateDiscriminators
}
};
+ context.SchemaRepository.Schemas[nameof(GroupUpdate<object>)] = groupUpdateSchema;
+
context.SchemaGenerator.GenerateSchema(typeof(ServerDiscoveryInfo), context.SchemaRepository);
foreach (var configuration in _serverConfigurationManager.GetConfigurationStores())
diff --git a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs
new file mode 100644
index 0000000000..74470eda0d
--- /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 901ed55be6..910b5c4672 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 ebb12ba4e7..452b03efbe 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/IAsyncMigrationRoutine.cs b/Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs
new file mode 100644
index 0000000000..5b6a5fe942
--- /dev/null
+++ b/Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Interface that describes a migration routine.
+/// </summary>
+internal interface IAsyncMigrationRoutine
+{
+ /// <summary>
+ /// Execute the migration routine.
+ /// </summary>
+ /// <param name="cancellationToken">A cancellation token triggered if the migration should be aborted.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ public Task PerformAsync(CancellationToken cancellationToken);
+}
+
+/// <summary>
+/// Interface that describes a migration routine.
+/// </summary>
+[Obsolete("Use IAsyncMigrationRoutine instead")]
+internal interface IMigrationRoutine
+{
+ /// <summary>
+ /// Execute the migration routine.
+ /// </summary>
+ [Obsolete("Use IAsyncMigrationRoutine.PerformAsync instead")]
+ public void Perform();
+}
diff --git a/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs
new file mode 100644
index 0000000000..d2d80a81eb
--- /dev/null
+++ b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs
@@ -0,0 +1,14 @@
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Defines a migration that operates on the Database.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+internal interface IDatabaseMigrationRoutine : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
+{
+}
diff --git a/Jellyfin.Server/Migrations/IMigrationRoutine.cs b/Jellyfin.Server/Migrations/IMigrationRoutine.cs
deleted file mode 100644
index c1000eeded..0000000000
--- a/Jellyfin.Server/Migrations/IMigrationRoutine.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// Interface that describes a migration routine.
- /// </summary>
- internal interface IMigrationRoutine
- {
- /// <summary>
- /// Gets the unique id for this migration. This should never be modified after the migration has been created.
- /// </summary>
- public Guid Id { get; }
-
- /// <summary>
- /// Gets the display name of the migration.
- /// </summary>
- public string Name { get; }
-
- /// <summary>
- /// Gets a value indicating whether to perform migration on a new install.
- /// </summary>
- public bool PerformOnNewInstall { get; }
-
- /// <summary>
- /// Execute the migration routine.
- /// </summary>
- public void Perform();
- }
-}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs b/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs
new file mode 100644
index 0000000000..5c8322ef78
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs
@@ -0,0 +1,68 @@
+#pragma warning disable CA1019 // Define accessors for attribute arguments
+
+using System;
+using System.Globalization;
+using Jellyfin.Server.Migrations.Stages;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Declares an class as an migration with its set metadata.
+/// </summary>
+[AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
+public sealed class JellyfinMigrationAttribute : Attribute
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinMigrationAttribute"/> class.
+ /// </summary>
+ /// <param name="order">The ordering this migration should be applied to. Must be a valid DateTime ISO8601 formatted string.</param>
+ /// <param name="name">The name of this Migration.</param>
+#pragma warning disable CS0618 // Type or member is obsolete
+ public JellyfinMigrationAttribute(string order, string name) : this(order, name, null)
+#pragma warning restore CS0618 // Type or member is obsolete
+ {
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinMigrationAttribute"/> class for legacy migrations.
+ /// </summary>
+ /// <param name="order">The ordering this migration should be applied to. Must be a valid DateTime ISO8601 formatted string.</param>
+ /// <param name="name">The name of this Migration.</param>
+ /// <param name="key">[ONLY FOR LEGACY MIGRATIONS]The unique key of this migration. Must be a valid Guid formatted string.</param>
+ [Obsolete("This Constructor should only be used for Legacy migrations. Use the (Order,Name) one for all new ones instead.")]
+ public JellyfinMigrationAttribute(string order, string name, string? key)
+ {
+ Order = DateTime.Parse(order, CultureInfo.InvariantCulture);
+ Name = name;
+ Stage = JellyfinMigrationStageTypes.AppInitialisation;
+ if (key is not null)
+ {
+ Key = Guid.Parse(key);
+ }
+ }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether the annoated migration should be executed on a fresh install.
+ /// </summary>
+ public bool RunMigrationOnSetup { get; set; }
+
+ /// <summary>
+ /// Gets or Sets the stage the annoated migration should be executed at. Defaults to <see cref="JellyfinMigrationStageTypes.CoreInitialisaition"/>.
+ /// </summary>
+ public JellyfinMigrationStageTypes Stage { get; set; } = JellyfinMigrationStageTypes.CoreInitialisaition;
+
+ /// <summary>
+ /// Gets the ordering of the migration.
+ /// </summary>
+ public DateTime Order { get; }
+
+ /// <summary>
+ /// Gets the name of the migration.
+ /// </summary>
+ public string Name { get; }
+
+ /// <summary>
+ /// Gets the Legacy Key of the migration. Not required for new Migrations.
+ /// </summary>
+ public Guid? Key { get; }
+}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
new file mode 100644
index 0000000000..3d6ed73bc6
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -0,0 +1,231 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Serialization;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using Jellyfin.Server.Migrations.Stages;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.SystemBackupService;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Handles Migration of the Jellyfin data structure.
+/// </summary>
+internal class JellyfinMigrationService
+{
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinMigrationService"/> class.
+ /// </summary>
+ /// <param name="dbContextFactory">Provides access to the jellyfin database.</param>
+ /// <param name="loggerFactory">The logger factory.</param>
+ public JellyfinMigrationService(IDbContextFactory<JellyfinDbContext> dbContextFactory, ILoggerFactory loggerFactory)
+ {
+ _dbContextFactory = dbContextFactory;
+ _loggerFactory = loggerFactory;
+#pragma warning disable CS0618 // Type or member is obsolete
+ Migrations = [.. typeof(IMigrationRoutine).Assembly.GetTypes().Where(e => typeof(IMigrationRoutine).IsAssignableFrom(e) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(e))
+ .Select(e => (Type: e, Metadata: e.GetCustomAttribute<JellyfinMigrationAttribute>()))
+ .Where(e => e.Metadata != null)
+ .GroupBy(e => e.Metadata!.Stage)
+ .Select(f =>
+ {
+ var stage = new MigrationStage(f.Key);
+ foreach (var item in f)
+ {
+ stage.Add(new(item.Type, item.Metadata!));
+ }
+
+ return stage;
+ })];
+#pragma warning restore CS0618 // Type or member is obsolete
+ }
+
+ private interface IInternalMigration
+ {
+ Task PerformAsync(ILogger logger);
+ }
+
+ private HashSet<MigrationStage> Migrations { get; set; }
+
+ public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
+ {
+ var logger = _loggerFactory.CreateLogger<JellyfinMigrationService>();
+ logger.LogInformation("Initialise Migration service.");
+ var xmlSerializer = new MyXmlSerializer();
+ var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
+ ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)!
+ : new ServerConfiguration();
+ if (!serverConfig.IsStartupWizardCompleted)
+ {
+ logger.LogInformation("System initialisation detected. Seed data.");
+ var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray();
+
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+
+ await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
+ var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ var startupScripts = flatApplyMigrations
+ .Where(e => !appliedMigrations.Any(f => f != e.BuildCodeMigrationId()))
+ .Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))))
+ .ToArray();
+ foreach (var item in startupScripts)
+ {
+ logger.LogInformation("Seed migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
+ await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
+ }
+ }
+
+ logger.LogInformation("Migration system initialisation completed.");
+ }
+ else
+ {
+ // migrate any existing migration.xml files
+ var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, "migrations.xml");
+ var migrationOptions = File.Exists(migrationConfigPath)
+ ? (MigrationOptions)xmlSerializer.DeserializeFromFile(typeof(MigrationOptions), migrationConfigPath)!
+ : null;
+ if (migrationOptions != null && migrationOptions.Applied.Count > 0)
+ {
+ logger.LogInformation("Old migration style migration.xml detected. Migrate now.");
+ try
+ {
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ var oldMigrations = Migrations
+ .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
+ .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
+ .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
+ .ToArray();
+ var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
+ foreach (var item in startupScripts)
+ {
+ logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
+ await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
+ }
+
+ logger.LogInformation("Rename old migration.xml to migration.xml.backup");
+ File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogCritical(ex, "Failed to apply migrations");
+ throw;
+ }
+ }
+ }
+ }
+
+ public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
+ {
+ var logger = _loggerFactory.CreateLogger<JellyfinMigrationService>();
+ logger.LogInformation("Migrate stage {Stage}.", stage);
+ ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
+
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
+ var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ var pendingCodeMigrations = migrationStage
+ .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
+ .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
+ .ToArray();
+
+ (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
+ if (stage is JellyfinMigrationStageTypes.CoreInitialisaition)
+ {
+ pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
+ .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
+ .ToArray();
+ }
+
+ (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
+ logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
+ var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
+
+ foreach (var item in migrations)
+ {
+ try
+ {
+ logger.LogInformation("Perform migration {Name}", item.Key);
+ await item.Migration.PerformAsync(_loggerFactory.CreateLogger(item.GetType().Name)).ConfigureAwait(false);
+ logger.LogInformation("Migration {Name} was successfully applied", item.Key);
+ }
+ catch (Exception ex)
+ {
+ logger.LogCritical(ex, "Migration {Name} failed", item.Key);
+ throw;
+ }
+ }
+ }
+ }
+
+ private static string GetJellyfinVersion()
+ {
+ return Assembly.GetEntryAssembly()!.GetName().Version!.ToString();
+ }
+
+ private class InternalCodeMigration : IInternalMigration
+ {
+ private readonly CodeMigration _codeMigration;
+ private readonly IServiceProvider? _serviceProvider;
+ private JellyfinDbContext _dbContext;
+
+ public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext)
+ {
+ _codeMigration = codeMigration;
+ _serviceProvider = serviceProvider;
+ _dbContext = dbContext;
+ }
+
+ public async Task PerformAsync(ILogger logger)
+ {
+ await _codeMigration.Perform(_serviceProvider, CancellationToken.None).ConfigureAwait(false);
+
+ var historyRepository = _dbContext.GetService<IHistoryRepository>();
+ var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()));
+ await _dbContext.Database.ExecuteSqlRawAsync(createScript).ConfigureAwait(false);
+ }
+ }
+
+ private class InternalDatabaseMigration : IInternalMigration
+ {
+ private readonly JellyfinDbContext _jellyfinDbContext;
+ private KeyValuePair<string, TypeInfo> _databaseMigrationInfo;
+
+ public InternalDatabaseMigration(KeyValuePair<string, TypeInfo> databaseMigrationInfo, JellyfinDbContext jellyfinDbContext)
+ {
+ _databaseMigrationInfo = databaseMigrationInfo;
+ _jellyfinDbContext = jellyfinDbContext;
+ }
+
+ public async Task PerformAsync(ILogger logger)
+ {
+ var migrator = _jellyfinDbContext.GetService<IMigrator>();
+ await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
deleted file mode 100644
index 2ab130eefb..0000000000
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using Emby.Server.Implementations;
-using Emby.Server.Implementations.Serialization;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// The class that knows which migrations to apply and how to apply them.
- /// </summary>
- public sealed class MigrationRunner
- {
- /// <summary>
- /// The list of known pre-startup migrations, in order of applicability.
- /// </summary>
- private static readonly Type[] _preStartupMigrationTypes =
- {
- typeof(PreStartupRoutines.CreateNetworkConfiguration),
- typeof(PreStartupRoutines.MigrateMusicBrainzTimeout),
- typeof(PreStartupRoutines.MigrateNetworkConfiguration),
- typeof(PreStartupRoutines.MigrateEncodingOptions)
- };
-
- /// <summary>
- /// The list of known migrations, in order of applicability.
- /// </summary>
- private static readonly Type[] _migrationTypes =
- {
- typeof(Routines.DisableTranscodingThrottling),
- typeof(Routines.CreateUserLoggingConfigFile),
- typeof(Routines.MigrateActivityLogDb),
- typeof(Routines.RemoveDuplicateExtras),
- typeof(Routines.AddDefaultPluginRepository),
- typeof(Routines.MigrateUserDb),
- typeof(Routines.ReaddDefaultPluginRepository),
- typeof(Routines.MigrateDisplayPreferencesDb),
- typeof(Routines.RemoveDownloadImagesInAdvance),
- typeof(Routines.MigrateAuthenticationDb),
- typeof(Routines.FixPlaylistOwner),
- typeof(Routines.MigrateRatingLevels),
- typeof(Routines.AddDefaultCastReceivers),
- typeof(Routines.UpdateDefaultPluginRepository),
- typeof(Routines.FixAudioData),
- typeof(Routines.MoveTrickplayFiles),
- typeof(Routines.RemoveDuplicatePlaylistChildren)
- };
-
- /// <summary>
- /// Run all needed 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)
- {
- var logger = loggerFactory.CreateLogger<MigrationRunner>();
- var migrations = _migrationTypes
- .Select(m => ActivatorUtilities.CreateInstance(host.ServiceProvider, m))
- .OfType<IMigrationRoutine>()
- .ToArray();
-
- 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);
- }
-
- /// <summary>
- /// Run all needed pre-startup 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)
- {
- var logger = loggerFactory.CreateLogger<MigrationRunner>();
- var migrations = _preStartupMigrationTypes
- .Select(m => Activator.CreateInstance(m, appPaths, loggerFactory))
- .OfType<IMigrationRoutine>()
- .ToArray();
-
- var xmlSerializer = new MyXmlSerializer();
- var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, MigrationsListStore.StoreKey.ToLowerInvariant() + ".xml");
- var migrationOptions = File.Exists(migrationConfigPath)
- ? (MigrationOptions)xmlSerializer.DeserializeFromFile(typeof(MigrationOptions), migrationConfigPath)!
- : new MigrationOptions();
-
- // We have to deserialize it manually since the configuration manager may overwrite it
- var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
- ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)!
- : new ServerConfiguration();
-
- HandleStartupWizardCondition(migrations, migrationOptions, serverConfig.IsStartupWizardCompleted, logger);
- PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger);
- }
-
- private static void HandleStartupWizardCondition(IEnumerable<IMigrationRoutine> migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger)
- {
- if (isStartWizardCompleted)
- {
- return;
- }
-
- // If startup wizard is not finished, this is a fresh install.
- var onlyOldInstalls = migrations.Where(m => !m.PerformOnNewInstall).ToArray();
- logger.LogInformation("Marking following migrations as applied because this is a fresh install: {@OnlyOldInstalls}", onlyOldInstalls.Select(m => m.Name));
- migrationOptions.Applied.AddRange(onlyOldInstalls.Select(m => (m.Id, m.Name)));
- }
-
- private static void PerformMigrations(IMigrationRoutine[] migrations, MigrationOptions migrationOptions, Action<MigrationOptions> saveConfiguration, ILogger logger)
- {
- // save already applied migrations, and skip them thereafter
- saveConfiguration(migrationOptions);
- var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet();
-
- for (var i = 0; i < migrations.Length; i++)
- {
- 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);
-
- 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);
- }
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/MigrationsFactory.cs b/Jellyfin.Server/Migrations/MigrationsFactory.cs
deleted file mode 100644
index 23c1b1ee6f..0000000000
--- a/Jellyfin.Server/Migrations/MigrationsFactory.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System.Collections.Generic;
-using MediaBrowser.Common.Configuration;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// A factory that can find a persistent file of the migration configuration, which lists all applied migrations.
- /// </summary>
- public class MigrationsFactory : IConfigurationFactory
- {
- /// <inheritdoc/>
- public IEnumerable<ConfigurationStore> GetConfigurations()
- {
- return new[]
- {
- new MigrationsListStore()
- };
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/MigrationsListStore.cs b/Jellyfin.Server/Migrations/MigrationsListStore.cs
deleted file mode 100644
index 7a1ca66714..0000000000
--- a/Jellyfin.Server/Migrations/MigrationsListStore.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using MediaBrowser.Common.Configuration;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// A configuration that lists all the migration routines that were applied.
- /// </summary>
- public class MigrationsListStore : ConfigurationStore
- {
- /// <summary>
- /// The name of the configuration in the storage.
- /// </summary>
- public static readonly string StoreKey = "migrations";
-
- /// <summary>
- /// Initializes a new instance of the <see cref="MigrationsListStore"/> class.
- /// </summary>
- public MigrationsListStore()
- {
- ConfigurationType = typeof(MigrationOptions);
- Key = StoreKey;
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs
index 8462d0a8c9..fd472cff7c 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs
@@ -8,7 +8,10 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T00:00:00", nameof(CreateNetworkConfiguration), "9B354818-94D5-4B68-AC49-E35CB85F9D84", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class CreateNetworkConfiguration : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ServerApplicationPaths _applicationPaths;
private readonly ILogger<CreateNetworkConfiguration> _logger;
@@ -25,15 +28,6 @@ public class CreateNetworkConfiguration : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("9B354818-94D5-4B68-AC49-E35CB85F9D84");
-
- /// <inheritdoc />
- public string Name => nameof(CreateNetworkConfiguration);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "network.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
index 61f5620dc0..0141b43c96 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
@@ -10,7 +10,10 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T03:00:00", nameof(MigrateEncodingOptions), "A8E61960-7726-4450-8F3D-82C12DAABBCB", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class MigrateEncodingOptions : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ServerApplicationPaths _applicationPaths;
private readonly ILogger<MigrateEncodingOptions> _logger;
@@ -27,15 +30,6 @@ public class MigrateEncodingOptions : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("A8E61960-7726-4450-8F3D-82C12DAABBCB");
-
- /// <inheritdoc />
- public string Name => nameof(MigrateEncodingOptions);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "encoding.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
index 580282a5f5..e8da9f515d 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
@@ -9,7 +9,10 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T02:00:00", nameof(MigrateMusicBrainzTimeout), "A6DCACF4-C057-4Ef9-80D3-61CEF9DDB4F0", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class MigrateMusicBrainzTimeout : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ServerApplicationPaths _applicationPaths;
private readonly ILogger<MigrateMusicBrainzTimeout> _logger;
@@ -26,15 +29,6 @@ public class MigrateMusicBrainzTimeout : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("A6DCACF4-C057-4Ef9-80D3-61CEF9DDB4F0");
-
- /// <inheritdoc />
- public string Name => nameof(MigrateMusicBrainzTimeout);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.MusicBrainz.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs
index 49960f4305..f2790c1a1f 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;
@@ -9,6 +11,7 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+[JellyfinMigration("2025-04-20T01:00:00", nameof(MigrateNetworkConfiguration), "4FB5C950-1991-11EE-9B4B-0800200C9A66", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class MigrateNetworkConfiguration : IMigrationRoutine
{
private readonly ServerApplicationPaths _applicationPaths;
@@ -26,15 +29,6 @@ public class MigrateNetworkConfiguration : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("4FB5C950-1991-11EE-9B4B-0800200C9A66");
-
- /// <inheritdoc />
- public string Name => nameof(MigrateNetworkConfiguration);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "network.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs
new file mode 100644
index 0000000000..995b2bbf9b
--- /dev/null
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs
@@ -0,0 +1,57 @@
+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 />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T04:00:00", nameof(RenameEnableGroupingIntoCollections), "E73B777D-CD5C-4E71-957A-B86B3660B7CF", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
+public class RenameEnableGroupingIntoCollections : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
+{
+ 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 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/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
index 2047ec743e..00d152b4b8 100644
--- a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
+++ b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
@@ -7,7 +7,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to add the default cast receivers to the system config.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T16:00:00", nameof(AddDefaultCastReceivers), "34A1A1C4-5572-418E-A2F8-32CDFE2668E8", RunMigrationOnSetup = true)]
public class AddDefaultCastReceivers : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly IServerConfigurationManager _serverConfigurationManager;
@@ -21,15 +24,6 @@ public class AddDefaultCastReceivers : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => new("34A1A1C4-5572-418E-A2F8-32CDFE2668E8");
-
- /// <inheritdoc />
- public string Name => "AddDefaultCastReceivers";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc />
public void Perform()
{
_serverConfigurationManager.Configuration.CastReceiverApplications =
diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
index fc6b5d5979..8c8398a161 100644
--- a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
@@ -7,7 +7,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// Migration to initialize system configuration with the default plugin repository.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T09:00:00", nameof(AddDefaultPluginRepository), "EB58EBEE-9514-4B9B-8225-12E1A40020DF", RunMigrationOnSetup = true)]
public class AddDefaultPluginRepository : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly IServerConfigurationManager _serverConfigurationManager;
@@ -27,15 +30,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("EB58EBEE-9514-4B9B-8225-12E1A40020DF");
-
- /// <inheritdoc/>
- public string Name => "AddDefaultPluginRepository";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc/>
public void Perform()
{
_serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
index ee4f8b0bab..1326a6dc8d 100644
--- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
+++ b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
@@ -12,7 +12,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// If the deprecated logging.json file exists and has a custom config, it will be used as logging.user.json,
/// otherwise a blank file will be created.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T06:00:00", nameof(CreateUserLoggingConfigFile), "EF103419-8451-40D8-9F34-D1A8E93A1679")]
internal class CreateUserLoggingConfigFile : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
/// <summary>
/// File history for logging.json as existed during this migration creation. The contents for each has been minified.
@@ -43,15 +46,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{EF103419-8451-40D8-9F34-D1A8E93A1679}");
-
- /// <inheritdoc/>
- public string Name => "CreateLoggingConfigHeirarchy";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var logDirectory = _appPaths.ConfigurationDirectoryPath;
diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
index 378e88e25b..acf2835fe0 100644
--- a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
+++ b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
@@ -7,7 +7,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// Disable transcode throttling for all installations since it is currently broken for certain video formats.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T05:00:00", nameof(DisableTranscodingThrottling), "4124C2CD-E939-4FFB-9BE9-9B311C413638")]
internal class DisableTranscodingThrottling : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILogger<DisableTranscodingThrottling> _logger;
private readonly IConfigurationManager _configManager;
@@ -19,15 +22,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{4124C2CD-E939-4FFB-9BE9-9B311C413638}");
-
- /// <inheritdoc/>
- public string Name => "DisableTranscodingThrottling";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
// Set EnableThrottling to false since it wasn't used before and may introduce issues
diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
index a202533692..af8787b955 100644
--- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
@@ -16,7 +16,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// Fixes the data column of audio types to be deserializable.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T18:00:00", nameof(FixAudioData), "CF6FABC2-9FBE-4933-84A5-FFE52EF22A58")]
internal class FixAudioData : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "library.db";
private readonly ILogger<FixAudioData> _logger;
@@ -34,15 +37,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{CF6FABC2-9FBE-4933-84A5-FFE52EF22A58}");
-
- /// <inheritdoc/>
- public string Name => "FixAudioData";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
index 192c170b26..56614ece3c 100644
--- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
@@ -13,7 +13,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Properly set playlist owner.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T15:00:00", nameof(FixPlaylistOwner), "615DFA9E-2497-4DBB-A472-61938B752C5B")]
internal class FixPlaylistOwner : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILogger<FixPlaylistOwner> _logger;
private readonly ILibraryManager _libraryManager;
@@ -30,15 +33,6 @@ internal class FixPlaylistOwner : IMigrationRoutine
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{615DFA9E-2497-4DBB-A472-61938B752C5B}");
-
- /// <inheritdoc/>
- public string Name => "FixPlaylistOwner";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
index 2f23cb1f8f..a954d307e1 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;
@@ -14,7 +14,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// The migration routine for migrating the activity log database to EF Core.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T07:00:00", nameof(MigrateActivityLogDb), "3793eb59-bc8c-456c-8b9f-bd5a62a42978")]
public class MigrateActivityLogDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "activitylog.db";
@@ -36,15 +39,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("3793eb59-bc8c-456c-8b9f-bd5a62a42978");
-
- /// <inheritdoc/>
- public string Name => "MigrateActivityLogDatabase";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var logLevelDictionary = new Dictionary<string, LogLevel>(StringComparer.OrdinalIgnoreCase)
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
index c845beef2f..c6699c21df 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;
@@ -15,7 +15,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// A migration that moves data from the authentication database into the new schema.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T14:00:00", nameof(MigrateAuthenticationDb), "5BD72F41-E6F3-4F60-90AA-09869ABE0E22")]
public class MigrateAuthenticationDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "authentication.db";
@@ -44,15 +47,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("5BD72F41-E6F3-4F60-90AA-09869ABE0E22");
-
- /// <inheritdoc />
- public string Name => "MigrateAuthenticationDatabase";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
var dataPath = _appPaths.DataPath;
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
index 502a37cde1..0d9952ce97 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;
@@ -20,7 +20,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// The migration routine for migrating the display preferences database to EF Core.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T12:00:00", nameof(MigrateDisplayPreferencesDb), "06387815-C3CC-421F-A888-FB5F9992BEA8")]
public class MigrateDisplayPreferencesDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "displaypreferences.db";
@@ -52,15 +55,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("06387815-C3CC-421F-A888-FB5F9992BEA8");
-
- /// <inheritdoc />
- public string Name => "MigrateDisplayPreferencesDatabase";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
HomeSectionType[] defaults =
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
new file mode 100644
index 0000000000..03a5212585
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to move extracted files to the new directories.
+/// </summary>
+[JellyfinMigration("2025-04-21T00:00:00", nameof(MigrateKeyframeData))]
+public class MigrateKeyframeData : IDatabaseMigrationRoutine
+{
+ private readonly ILogger<MigrateKeyframeData> _logger;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MigrateKeyframeData"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="dbProvider">The EFCore db factory.</param>
+ public MigrateKeyframeData(
+ ILogger<MigrateKeyframeData> logger,
+ IApplicationPaths appPaths,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _logger = logger;
+ _appPaths = appPaths;
+ _dbProvider = dbProvider;
+ }
+
+ private string KeyframeCachePath => Path.Combine(_appPaths.DataPath, "keyframes");
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ const int Limit = 5000;
+ int itemCount = 0, offset = 0;
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var baseQuery = context.BaseItems.Where(b => b.MediaType == MediaType.Video.ToString() && !b.IsVirtualItem && !b.IsFolder).OrderBy(e => e.Id);
+ var records = baseQuery.Count();
+ _logger.LogInformation("Checking {Count} items for importable keyframe data.", records);
+
+ context.KeyframeData.ExecuteDelete();
+ using var transaction = context.Database.BeginTransaction();
+ do
+ {
+ var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
+ foreach (var result in results)
+ {
+ if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
+ {
+ itemCount++;
+ context.KeyframeData.Add(data);
+ }
+ }
+
+ offset += Limit;
+ if (offset > records)
+ {
+ offset = records;
+ }
+
+ _logger.LogInformation("Checked: {Count} - Imported: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
+ } while (offset < records);
+
+ context.SaveChanges();
+ transaction.Commit();
+
+ _logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed);
+
+ if (Directory.Exists(KeyframeCachePath))
+ {
+ Directory.Delete(KeyframeCachePath, true);
+ }
+ }
+
+ private bool TryGetKeyframeData(Guid id, string? path, [NotNullWhen(true)] out KeyframeData? data)
+ {
+ data = null;
+ if (!string.IsNullOrEmpty(path))
+ {
+ var cachePath = GetCachePath(KeyframeCachePath, path);
+ if (TryReadFromCache(cachePath, out var keyframeData))
+ {
+ data = new()
+ {
+ ItemId = id,
+ KeyframeTicks = keyframeData.KeyframeTicks.ToList(),
+ TotalDuration = keyframeData.TotalDuration
+ };
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private string? GetCachePath(string keyframeCachePath, string filePath)
+ {
+ DateTime? lastWriteTimeUtc;
+ try
+ {
+ lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
+ }
+ catch (IOException e)
+ {
+ _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
+
+ return null;
+ }
+
+ ReadOnlySpan<char> filename = (filePath + "_" + lastWriteTimeUtc.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5() + ".json";
+ var prefix = filename[..1];
+
+ return Path.Join(keyframeCachePath, prefix, filename);
+ }
+
+ private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
+ {
+ if (File.Exists(cachePath))
+ {
+ var bytes = File.ReadAllBytes(cachePath);
+ cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
+
+ return cachedResult is not null;
+ }
+
+ cachedResult = null;
+
+ return false;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
new file mode 100644
index 0000000000..c9d2899407
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -0,0 +1,1389 @@
+#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 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>
+[JellyfinMigration("2025-04-20T20:00:00", nameof(MigrateLibraryDb))]
+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>
+ /// <param name="serviceProvider">The Service provider.</param>
+ public MigrateLibraryDb(
+ ILogger<MigrateLibraryDb> logger,
+ IDbContextFactory<JellyfinDbContext> provider,
+ IServerApplicationPaths paths,
+ IJellyfinDatabaseProvider jellyfinDatabaseProvider,
+ IServiceProvider serviceProvider)
+ {
+ _logger = logger;
+ _provider = provider;
+ _paths = paths;
+ _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ }
+
+ /// <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);
+ if (!File.Exists(libraryDbPath))
+ {
+ _logger.LogError("Cannot migrate {LibraryDb} as it does not exist..", libraryDbPath);
+ return;
+ }
+
+ using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
+
+ var fullOperationTimer = new Stopwatch();
+ fullOperationTimer.Start();
+
+ using (var operation = GetPreparedDbContext("Cleanup database"))
+ {
+ operation.JellyfinDbContext.AttachmentStreamInfos.ExecuteDelete();
+ operation.JellyfinDbContext.BaseItems.ExecuteDelete();
+ operation.JellyfinDbContext.ItemValues.ExecuteDelete();
+ operation.JellyfinDbContext.UserData.ExecuteDelete();
+ operation.JellyfinDbContext.MediaStreamInfos.ExecuteDelete();
+ operation.JellyfinDbContext.Peoples.ExecuteDelete();
+ operation.JellyfinDbContext.PeopleBaseItemMap.ExecuteDelete();
+ operation.JellyfinDbContext.Chapters.ExecuteDelete();
+ operation.JellyfinDbContext.AncestorIds.ExecuteDelete();
+ }
+
+ var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
+ connection.Open();
+
+ var baseItemIds = new HashSet<Guid>();
+ using (var operation = GetPreparedDbContext("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
+ """;
+ using (new TrackedMigrationStep("Loading TypedBaseItems", _logger))
+ {
+ foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery))
+ {
+ var baseItem = GetItem(dto);
+ operation.JellyfinDbContext.BaseItems.Add(baseItem.BaseItem);
+ baseItemIds.Add(baseItem.BaseItem.Id);
+ foreach (var dataKey in baseItem.LegacyUserDataKey)
+ {
+ legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem;
+ }
+ }
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ using (var operation = GetPreparedDbContext("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)
+ """;
+
+ // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow.
+ var localItems = new Dictionary<(int Type, string Value), (Database.Implementations.Entities.ItemValue ItemValue, List<Guid> ItemIds)>();
+ using (new TrackedMigrationStep("loading ItemValues", _logger))
+ {
+ foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
+ {
+ var itemId = dto.GetGuid(0);
+ var entity = GetItemValue(dto);
+ var key = ((int)entity.Type, entity.Value);
+ if (!localItems.TryGetValue(key, out var existing))
+ {
+ localItems[key] = existing = (entity, []);
+ }
+
+ existing.ItemIds.Add(itemId);
+ }
+
+ foreach (var item in localItems)
+ {
+ operation.JellyfinDbContext.ItemValues.Add(item.Value.ItemValue);
+ operation.JellyfinDbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new ItemValueMap()
+ {
+ Item = null!,
+ ItemValue = null!,
+ ItemId = f,
+ ItemValueId = item.Value.ItemValue.ItemValueId
+ }));
+ }
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ using (var operation = GetPreparedDbContext("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)
+ """);
+
+ using (new TrackedMigrationStep("loading UserData", _logger))
+ {
+ var users = operation.JellyfinDbContext.Users.AsNoTracking().ToImmutableArray();
+ var userIdBlacklist = new HashSet<int>();
+
+ foreach (var entity in queryResult)
+ {
+ var userData = GetUserData(users, entity, userIdBlacklist);
+ if (userData is null)
+ {
+ var userDataId = entity.GetString(0);
+ var internalUserId = entity.GetInt32(1);
+
+ if (!userIdBlacklist.Contains(internalUserId))
+ {
+ _logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId} does not match any existing user.", userDataId, internalUserId);
+ userIdBlacklist.Add(internalUserId);
+ }
+
+ 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;
+ operation.JellyfinDbContext.UserData.Add(userData);
+ }
+
+ users.Clear();
+ }
+
+ legacyBaseItemWithUserKeys.Clear();
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ using (var operation = GetPreparedDbContext("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)
+ """;
+
+ using (new TrackedMigrationStep("loading MediaStreamInfos", _logger))
+ {
+ foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
+ {
+ operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto));
+ }
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos"))
+ {
+ const string mediaAttachmentQuery =
+ """
+ SELECT ItemId, AttachmentIndex, Codec, CodecTag, Comment, filename, MIMEType
+ FROM mediaattachments
+ WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
+ """;
+
+ using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger))
+ {
+ foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
+ {
+ operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto));
+ }
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ using (var operation = GetPreparedDbContext("moving People"))
+ {
+ const string personsQuery =
+ """
+ SELECT ItemId, Name, Role, PersonType, SortOrder FROM People
+ WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId)
+ """;
+
+ var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
+
+ using (new TrackedMigrationStep("loading People", _logger))
+ {
+ 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)
+ {
+ operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
+ operation.JellyfinDbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e.PeopleId)));
+ }
+
+ peopleCache.Clear();
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ using (var operation = GetPreparedDbContext("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)
+ """;
+
+ using (new TrackedMigrationStep("loading Chapters", _logger))
+ {
+ foreach (SqliteDataReader dto in connection.Query(chapterQuery))
+ {
+ var chapter = GetChapter(dto);
+ operation.JellyfinDbContext.Chapters.Add(chapter);
+ }
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ using (var operation = GetPreparedDbContext("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)
+ """;
+
+ using (new TrackedMigrationStep("loading AncestorIds", _logger))
+ {
+ foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
+ {
+ var ancestorId = GetAncestorId(dto);
+ operation.JellyfinDbContext.AncestorIds.Add(ancestorId);
+ }
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
+ connection.Close();
+
+ _logger.LogInformation("Migration of the Library.db done.");
+ _logger.LogInformation("Migrating Library db took {0}.", fullOperationTimer.Elapsed);
+
+ SqliteConnection.ClearAllPools();
+
+ _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
+ File.Move(libraryDbPath, libraryDbPath + ".old", true);
+ }
+
+ private DatabaseMigrationStep GetPreparedDbContext(string operationName)
+ {
+ var dbContext = _provider.CreateDbContext();
+ dbContext.ChangeTracker.AutoDetectChangesEnabled = false;
+ dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
+ return new DatabaseMigrationStep(dbContext, operationName, _logger);
+ }
+
+ private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto, HashSet<int> userIdBlacklist)
+ {
+ var internalUserId = dto.GetInt32(1);
+ var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
+
+ if (user is null)
+ {
+ if (userIdBlacklist.Contains(internalUserId))
+ {
+ return 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;
+ }
+
+ /// <summary>
+ /// Gets the attachment.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>MediaAttachment.</returns>
+ private AttachmentStreamInfo GetMediaAttachment(SqliteDataReader reader)
+ {
+ var item = new AttachmentStreamInfo
+ {
+ Index = reader.GetInt32(1),
+ Item = null!,
+ ItemId = reader.GetGuid(0),
+ };
+
+ if (reader.TryGetString(2, out var codec))
+ {
+ item.Codec = codec;
+ }
+
+ if (reader.TryGetString(3, out var codecTag))
+ {
+ item.CodecTag = codecTag;
+ }
+
+ if (reader.TryGetString(4, out var comment))
+ {
+ item.Comment = comment;
+ }
+
+ if (reader.TryGetString(5, out var fileName))
+ {
+ item.Filename = fileName;
+ }
+
+ if (reader.TryGetString(6, out var mimeType))
+ {
+ item.MimeType = mimeType;
+ }
+
+ 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;
+ }
+
+ private class TrackedMigrationStep : IDisposable
+ {
+ private readonly string _operationName;
+ private readonly ILogger _logger;
+ private readonly Stopwatch _operationTimer;
+ private bool _disposed;
+
+ public TrackedMigrationStep(string operationName, ILogger logger)
+ {
+ _operationName = operationName;
+ _logger = logger;
+ _operationTimer = Stopwatch.StartNew();
+ logger.LogInformation("Start {OperationName}", operationName);
+ }
+
+ public bool Disposed
+ {
+ get => _disposed;
+ set => _disposed = value;
+ }
+
+ public virtual void Dispose()
+ {
+ if (Disposed)
+ {
+ return;
+ }
+
+ Disposed = true;
+ _logger.LogInformation("{OperationName} took '{Time}'", _operationName, _operationTimer.Elapsed);
+ }
+ }
+
+ private sealed class DatabaseMigrationStep : TrackedMigrationStep
+ {
+ public DatabaseMigrationStep(JellyfinDbContext jellyfinDbContext, string operationName, ILogger logger) : base(operationName, logger)
+ {
+ JellyfinDbContext = jellyfinDbContext;
+ }
+
+ public JellyfinDbContext JellyfinDbContext { get; }
+
+ public override void Dispose()
+ {
+ if (Disposed)
+ {
+ return;
+ }
+
+ JellyfinDbContext.Dispose();
+ base.Dispose();
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
index 9c2184029c..234965c0a5 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
@@ -1,94 +1,67 @@
using System;
-using System.Globalization;
-using System.IO;
-using Emby.Server.Implementations.Data;
-using MediaBrowser.Controller;
+using System.Linq;
+using Jellyfin.Database.Implementations;
using MediaBrowser.Model.Globalization;
-using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.Server.Migrations.Routines
-{
- /// <summary>
- /// Migrate rating levels to new rating level system.
- /// </summary>
- internal class MigrateRatingLevels : IMigrationRoutine
- {
- private const string DbFilename = "library.db";
- private readonly ILogger<MigrateRatingLevels> _logger;
- private readonly IServerApplicationPaths _applicationPaths;
- private readonly ILocalizationManager _localizationManager;
-
- public MigrateRatingLevels(
- IServerApplicationPaths applicationPaths,
- ILoggerFactory loggerFactory,
- ILocalizationManager localizationManager)
- {
- _applicationPaths = applicationPaths;
- _localizationManager = localizationManager;
- _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
- }
-
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("{73DAB92A-178B-48CD-B05B-FE18733ACDC8}");
+namespace Jellyfin.Server.Migrations.Routines;
- /// <inheritdoc/>
- public string Name => "MigrateRatingLevels";
+/// <summary>
+/// Migrate rating levels.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))]
+#pragma warning restore CS0618 // Type or member is obsolete
+internal class MigrateRatingLevels : IDatabaseMigrationRoutine
+{
+ private readonly ILogger<MigrateRatingLevels> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+ private readonly ILocalizationManager _localizationManager;
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ public MigrateRatingLevels(
+ IDbContextFactory<JellyfinDbContext> provider,
+ ILoggerFactory loggerFactory,
+ ILocalizationManager localizationManager)
+ {
+ _provider = provider;
+ _localizationManager = localizationManager;
+ _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
+ }
- /// <inheritdoc/>
- public void Perform()
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ _logger.LogInformation("Recalculating parental rating levels based on rating string.");
+ using var context = _provider.CreateDbContext();
+ using var transaction = context.Database.BeginTransaction();
+ var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct();
+ foreach (var rating in ratings)
{
- var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
-
- // Back up the database before modifying any entries
- for (int i = 1; ; i++)
+ if (string.IsNullOrEmpty(rating))
{
- var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
- if (!File.Exists(bakPath))
- {
- try
- {
- File.Copy(dbPath, bakPath);
- _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
- break;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
- throw;
- }
- }
+ int? value = null;
+ context.BaseItems
+ .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
+ context.BaseItems
+ .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
}
-
- // Migrate parental rating strings to new levels
- _logger.LogInformation("Recalculating parental rating levels based on rating string.");
- using var connection = new SqliteConnection($"Filename={dbPath}");
- connection.Open();
- using (var transaction = connection.BeginTransaction())
+ else
{
- var queryResult = connection.Query("SELECT DISTINCT OfficialRating FROM TypedBaseItems");
- foreach (var entry in queryResult)
- {
- if (!entry.TryGetString(0, out var ratingString) || string.IsNullOrEmpty(ratingString))
- {
- connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating IS NULL OR OfficialRating='';");
- }
- else
- {
- var ratingValue = _localizationManager.GetRatingLevel(ratingString)?.ToString(CultureInfo.InvariantCulture) ?? "NULL";
-
- using var statement = connection.PrepareStatement("UPDATE TypedBaseItems SET InheritedParentalRatingValue = @Value WHERE OfficialRating = @Rating;");
- statement.TryBind("@Value", ratingValue);
- statement.TryBind("@Rating", ratingString);
- statement.ExecuteNonQuery();
- }
- }
-
- transaction.Commit();
+ var ratingValue = _localizationManager.GetRatingScore(rating);
+ var score = ratingValue?.Score;
+ var subScore = ratingValue?.SubScore;
+ context.BaseItems
+ .Where(e => e.OfficialRating == rating)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score));
+ context.BaseItems
+ .Where(e => e.OfficialRating == rating)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore));
}
}
+
+ transaction.Commit();
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
index 7dcae5bd9d..e5584fb947 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;
@@ -16,206 +17,200 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using JsonSerializer = System.Text.Json.JsonSerializer;
-namespace Jellyfin.Server.Migrations.Routines
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// The migration routine for migrating the user database to EF Core.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T10:00:00", nameof(MigrateUserDb), "5C4B82A2-F053-4009-BD05-B6FCAD82F14C")]
+public class MigrateUserDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
+ private const string DbFilename = "users.db";
+
+ private readonly ILogger<MigrateUserDb> _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+ private readonly IXmlSerializer _xmlSerializer;
+
/// <summary>
- /// The migration routine for migrating the user database to EF Core.
+ /// Initializes a new instance of the <see cref="MigrateUserDb"/> class.
/// </summary>
- public class MigrateUserDb : IMigrationRoutine
+ /// <param name="logger">The logger.</param>
+ /// <param name="paths">The server application paths.</param>
+ /// <param name="provider">The database provider.</param>
+ /// <param name="xmlSerializer">The xml serializer.</param>
+ public MigrateUserDb(
+ ILogger<MigrateUserDb> logger,
+ IServerApplicationPaths paths,
+ IDbContextFactory<JellyfinDbContext> provider,
+ IXmlSerializer xmlSerializer)
{
- private const string DbFilename = "users.db";
-
- private readonly ILogger<MigrateUserDb> _logger;
- private readonly IServerApplicationPaths _paths;
- private readonly IDbContextFactory<JellyfinDbContext> _provider;
- private readonly IXmlSerializer _xmlSerializer;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="MigrateUserDb"/> class.
- /// </summary>
- /// <param name="logger">The logger.</param>
- /// <param name="paths">The server application paths.</param>
- /// <param name="provider">The database provider.</param>
- /// <param name="xmlSerializer">The xml serializer.</param>
- public MigrateUserDb(
- ILogger<MigrateUserDb> logger,
- IServerApplicationPaths paths,
- IDbContextFactory<JellyfinDbContext> provider,
- IXmlSerializer xmlSerializer)
- {
- _logger = logger;
- _paths = paths;
- _provider = provider;
- _xmlSerializer = xmlSerializer;
- }
+ _logger = logger;
+ _paths = paths;
+ _provider = provider;
+ _xmlSerializer = xmlSerializer;
+ }
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("5C4B82A2-F053-4009-BD05-B6FCAD82F14C");
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ var dataPath = _paths.DataPath;
+ _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
- /// <inheritdoc/>
- public string Name => "MigrateUserDatabase";
+ using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ {
+ connection.Open();
+ using var dbContext = _provider.CreateDbContext();
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
- /// <inheritdoc/>
- public void Perform()
- {
- var dataPath = _paths.DataPath;
- _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
+ dbContext.RemoveRange(dbContext.Users);
+ dbContext.SaveChanges();
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ foreach (var entry in queryResult)
{
- connection.Open();
- using var dbContext = _provider.CreateDbContext();
-
- var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
+ UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry.GetStream(2), JsonDefaults.Options);
+ if (mockup is null)
+ {
+ continue;
+ }
- dbContext.RemoveRange(dbContext.Users);
- dbContext.SaveChanges();
+ var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name);
+
+ var configPath = Path.Combine(userDataDir, "config.xml");
+ var config = File.Exists(configPath)
+ ? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration()
+ : new UserConfiguration();
+
+ var policyPath = Path.Combine(userDataDir, "policy.xml");
+ var policy = File.Exists(policyPath)
+ ? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy()
+ : new UserPolicy();
+ policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace(
+ "Emby.Server.Implementations.Library",
+ "Jellyfin.Server.Implementations.Users",
+ StringComparison.Ordinal)
+ ?? typeof(DefaultAuthenticationProvider).FullName;
+
+ policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName;
+ int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
+ {
+ -1 => null,
+ 0 => 3,
+ _ => policy.LoginAttemptsBeforeLockout
+ };
- foreach (var entry in queryResult)
+ var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!)
{
- UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry.GetStream(2), JsonDefaults.Options);
- if (mockup is null)
- {
- continue;
- }
-
- var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name);
-
- var configPath = Path.Combine(userDataDir, "config.xml");
- var config = File.Exists(configPath)
- ? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration()
- : new UserConfiguration();
-
- var policyPath = Path.Combine(userDataDir, "policy.xml");
- var policy = File.Exists(policyPath)
- ? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy()
- : new UserPolicy();
- policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace(
- "Emby.Server.Implementations.Library",
- "Jellyfin.Server.Implementations.Users",
- StringComparison.Ordinal)
- ?? typeof(DefaultAuthenticationProvider).FullName;
-
- policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName;
- int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
- {
- -1 => null,
- 0 => 3,
- _ => policy.LoginAttemptsBeforeLockout
- };
+ Id = entry.GetGuid(1),
+ InternalId = entry.GetInt64(0),
+ MaxParentalRatingScore = policy.MaxParentalRating,
+ MaxParentalRatingSubScore = null,
+ EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess,
+ RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit,
+ InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount,
+ LoginAttemptsBeforeLockout = maxLoginAttempts,
+ SubtitleMode = config.SubtitleMode,
+ HidePlayedInLatest = config.HidePlayedInLatest,
+ EnableLocalPassword = config.EnableLocalPassword,
+ PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
+ DisplayCollectionsView = config.DisplayCollectionsView,
+ DisplayMissingEpisodes = config.DisplayMissingEpisodes,
+ AudioLanguagePreference = config.AudioLanguagePreference,
+ RememberAudioSelections = config.RememberAudioSelections,
+ EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay,
+ RememberSubtitleSelections = config.RememberSubtitleSelections,
+ SubtitleLanguagePreference = config.SubtitleLanguagePreference,
+ Password = mockup.Password,
+ LastLoginDate = mockup.LastLoginDate,
+ LastActivityDate = mockup.LastActivityDate
+ };
+
+ if (mockup.ImageInfos.Length > 0)
+ {
+ ItemImageInfo info = mockup.ImageInfos[0];
- var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!)
+ user.ProfileImage = new ImageInfo(info.Path)
{
- Id = entry.GetGuid(1),
- InternalId = entry.GetInt64(0),
- MaxParentalAgeRating = policy.MaxParentalRating,
- EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess,
- RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit,
- InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount,
- LoginAttemptsBeforeLockout = maxLoginAttempts,
- SubtitleMode = config.SubtitleMode,
- HidePlayedInLatest = config.HidePlayedInLatest,
- EnableLocalPassword = config.EnableLocalPassword,
- PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
- DisplayCollectionsView = config.DisplayCollectionsView,
- DisplayMissingEpisodes = config.DisplayMissingEpisodes,
- AudioLanguagePreference = config.AudioLanguagePreference,
- RememberAudioSelections = config.RememberAudioSelections,
- EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay,
- RememberSubtitleSelections = config.RememberSubtitleSelections,
- SubtitleLanguagePreference = config.SubtitleLanguagePreference,
- Password = mockup.Password,
- LastLoginDate = mockup.LastLoginDate,
- LastActivityDate = mockup.LastActivityDate
+ LastModified = info.DateModified
};
+ }
- if (mockup.ImageInfos.Length > 0)
- {
- ItemImageInfo info = mockup.ImageInfos[0];
-
- user.ProfileImage = new ImageInfo(info.Path)
- {
- LastModified = info.DateModified
- };
- }
-
- user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
- user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
- user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
- user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
- user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
- user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
- user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
- user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
- user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
- user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
- user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
- user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
- user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
- user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
- user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
- user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
- user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
- user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
- user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
- user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
- user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
- user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
-
- foreach (var policyAccessSchedule in policy.AccessSchedules)
- {
- user.AccessSchedules.Add(policyAccessSchedule);
- }
-
- user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
- user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
- user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
- user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
- user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
- user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
- user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
- user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
- user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
-
- dbContext.Users.Add(user);
+ user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
+ user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
+ user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
+ user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
+ user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
+ user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
+ user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
+ user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
+ user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
+ user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
+ user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
+ user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
+ user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
+ user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
+ user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
+ user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
+ user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
+ user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
+ user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
+ user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
+
+ foreach (var policyAccessSchedule in policy.AccessSchedules)
+ {
+ user.AccessSchedules.Add(policyAccessSchedule);
}
- dbContext.SaveChanges();
+ user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
+ user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
+ user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
+ user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
+ user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
+ user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
+ user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
+ user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
+ user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
+
+ dbContext.Users.Add(user);
}
- try
- {
- File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old"));
+ dbContext.SaveChanges();
+ }
- var journalPath = Path.Combine(dataPath, DbFilename + "-journal");
- if (File.Exists(journalPath))
- {
- File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal"));
- }
- }
- catch (IOException e)
+ try
+ {
+ File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old"));
+
+ var journalPath = Path.Combine(dataPath, DbFilename + "-journal");
+ if (File.Exists(journalPath))
{
- _logger.LogError(e, "Error renaming legacy user database to 'users.db.old'");
+ File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal"));
}
}
+ catch (IOException e)
+ {
+ _logger.LogError(e, "Error renaming legacy user database to 'users.db.old'");
+ }
+ }
#nullable disable
- internal class UserMockup
- {
- public string Password { get; set; }
+ internal class UserMockup
+ {
+ public string Password { get; set; }
- public string EasyPassword { get; set; }
+ public string EasyPassword { get; set; }
- public DateTime? LastLoginDate { get; set; }
+ public DateTime? LastLoginDate { get; set; }
- public DateTime? LastActivityDate { get; set; }
+ public DateTime? LastActivityDate { get; set; }
- public string Name { get; set; }
+ public string Name { get; set; }
- public ItemImageInfo[] ImageInfos { get; set; }
- }
+ public ItemImageInfo[] ImageInfos { get; set; }
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
new file mode 100644
index 0000000000..8b4abdfe59
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
@@ -0,0 +1,298 @@
+#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to move extracted files to the new directories.
+/// </summary>
+[JellyfinMigration("2025-04-20T21:00:00", nameof(MoveExtractedFiles))]
+#pragma warning disable CS0618 // Type or member is obsolete
+public class MoveExtractedFiles : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
+{
+ private readonly IApplicationPaths _appPaths;
+ private readonly ILogger<MoveExtractedFiles> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IPathManager _pathManager;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MoveExtractedFiles"/> class.
+ /// </summary>
+ /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="pathManager">Instance of the <see cref="IPathManager"/> interface.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
+ public MoveExtractedFiles(
+ IApplicationPaths appPaths,
+ ILogger<MoveExtractedFiles> logger,
+ IPathManager pathManager,
+ IFileSystem fileSystem,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _appPaths = appPaths;
+ _logger = logger;
+ _pathManager = pathManager;
+ _fileSystem = fileSystem;
+ _dbProvider = dbProvider;
+ }
+
+ private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
+
+ private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ const int Limit = 5000;
+ int itemCount = 0, offset = 0;
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.BaseItems.Count(b => b.MediaType == MediaType.Video.ToString() && !b.IsVirtualItem && !b.IsFolder);
+ _logger.LogInformation("Checking {Count} items for movable extracted files.", records);
+
+ // Make sure directories exist
+ Directory.CreateDirectory(SubtitleCachePath);
+ Directory.CreateDirectory(AttachmentCachePath);
+ do
+ {
+ var results = context.BaseItems
+ .Include(e => e.MediaStreams!.Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle && !s.IsExternal))
+ .Where(b => b.MediaType == MediaType.Video.ToString() && !b.IsVirtualItem && !b.IsFolder)
+ .OrderBy(e => e.Id)
+ .Skip(offset)
+ .Take(Limit)
+ .Select(b => new Tuple<Guid, string?, ICollection<MediaStreamInfo>?>(b.Id, b.Path, b.MediaStreams)).ToList();
+
+ foreach (var result in results)
+ {
+ if (MoveSubtitleAndAttachmentFiles(result.Item1, result.Item2, result.Item3, context))
+ {
+ itemCount++;
+ }
+ }
+
+ offset += Limit;
+ if (offset > records)
+ {
+ offset = records;
+ }
+
+ _logger.LogInformation("Checked: {Count} - Moved: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
+ } while (offset < records);
+
+ _logger.LogInformation("Moved files for {Count} items in {Time}", itemCount, sw.Elapsed);
+
+ // Get all subdirectories with 1 character names (those are the legacy directories)
+ var subdirectories = Directory.GetDirectories(SubtitleCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == SubtitleCachePath.Length + 2).ToList();
+ subdirectories.AddRange(Directory.GetDirectories(AttachmentCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == AttachmentCachePath.Length + 2));
+
+ // Remove all legacy subdirectories
+ foreach (var subdir in subdirectories)
+ {
+ Directory.Delete(subdir, true);
+ }
+
+ // Remove old cache path
+ var attachmentCachePath = Path.Join(_appPaths.CachePath, "attachments");
+ if (Directory.Exists(attachmentCachePath))
+ {
+ Directory.Delete(attachmentCachePath, true);
+ }
+
+ _logger.LogInformation("Cleaned up left over subtitles and attachments.");
+ }
+
+ private bool MoveSubtitleAndAttachmentFiles(Guid id, string? path, ICollection<MediaStreamInfo>? mediaStreams, JellyfinDbContext context)
+ {
+ var itemIdString = id.ToString("N", CultureInfo.InvariantCulture);
+ var modified = false;
+ if (mediaStreams is not null)
+ {
+ foreach (var mediaStream in mediaStreams)
+ {
+ if (mediaStream.Codec is null)
+ {
+ continue;
+ }
+
+ var mediaStreamIndex = mediaStream.StreamIndex;
+ var extension = GetSubtitleExtension(mediaStream.Codec);
+ var oldSubtitleCachePath = GetOldSubtitleCachePath(path, mediaStreamIndex, extension);
+ if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath))
+ {
+ continue;
+ }
+
+ var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension);
+ if (File.Exists(newSubtitleCachePath))
+ {
+ File.Delete(oldSubtitleCachePath);
+ }
+ else
+ {
+ var newDirectory = Path.GetDirectoryName(newSubtitleCachePath);
+ if (newDirectory is not null)
+ {
+ Directory.CreateDirectory(newDirectory);
+ File.Move(oldSubtitleCachePath, newSubtitleCachePath, false);
+ _logger.LogDebug("Moved subtitle {Index} for {Item} from {Source} to {Destination}", mediaStreamIndex, id, oldSubtitleCachePath, newSubtitleCachePath);
+
+ modified = true;
+ }
+ }
+ }
+ }
+
+#pragma warning disable CA1309 // Use ordinal string comparison
+ var attachments = context.AttachmentStreamInfos.Where(a => a.ItemId.Equals(id) && !string.Equals(a.Codec, "mjpeg")).ToList();
+#pragma warning restore CA1309 // Use ordinal string comparison
+ var shouldExtractOneByOne = attachments.Any(a => !string.IsNullOrEmpty(a.Filename)
+ && (a.Filename.Contains('/', StringComparison.OrdinalIgnoreCase) || a.Filename.Contains('\\', StringComparison.OrdinalIgnoreCase)));
+ foreach (var attachment in attachments)
+ {
+ var attachmentIndex = attachment.Index;
+ var oldAttachmentPath = GetOldAttachmentDataPath(path, attachmentIndex);
+ if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
+ {
+ oldAttachmentPath = GetOldAttachmentCachePath(itemIdString, attachment, shouldExtractOneByOne);
+ if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
+ {
+ continue;
+ }
+ }
+
+ var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
+ if (File.Exists(newAttachmentPath))
+ {
+ File.Delete(oldAttachmentPath);
+ }
+ else
+ {
+ var newDirectory = Path.GetDirectoryName(newAttachmentPath);
+ if (newDirectory is not null)
+ {
+ Directory.CreateDirectory(newDirectory);
+ File.Move(oldAttachmentPath, newAttachmentPath, false);
+ _logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, id, oldAttachmentPath, newAttachmentPath);
+
+ modified = true;
+ }
+ }
+ }
+
+ return modified;
+ }
+
+ private string? GetOldAttachmentDataPath(string? mediaPath, int attachmentStreamIndex)
+ {
+ if (mediaPath is null)
+ {
+ return null;
+ }
+
+ string filename;
+ if (_fileSystem.IsPathFile(mediaPath))
+ {
+ DateTime? date;
+ try
+ {
+ date = File.GetLastWriteTimeUtc(mediaPath);
+ }
+ catch (IOException e)
+ {
+ _logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
+
+ return null;
+ }
+
+ filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
+ }
+
+ return Path.Join(_appPaths.DataPath, "attachments", filename[..1], filename);
+ }
+
+ private string? GetOldAttachmentCachePath(string mediaSourceId, AttachmentStreamInfo attachment, bool shouldExtractOneByOne)
+ {
+ var attachmentFolderPath = Path.Join(_appPaths.CachePath, "attachments", mediaSourceId);
+ if (shouldExtractOneByOne)
+ {
+ return Path.Join(attachmentFolderPath, attachment.Index.ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (string.IsNullOrEmpty(attachment.Filename))
+ {
+ return null;
+ }
+
+ return Path.Join(attachmentFolderPath, attachment.Filename);
+ }
+
+ private string? GetOldSubtitleCachePath(string? path, int streamIndex, string outputSubtitleExtension)
+ {
+ if (path is null)
+ {
+ return null;
+ }
+
+ DateTime? date;
+ try
+ {
+ date = File.GetLastWriteTimeUtc(path);
+ }
+ catch (IOException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
+
+ var ticksParam = string.Empty;
+ ReadOnlySpan<char> filename = new Guid(MD5.HashData(Encoding.Unicode.GetBytes(path + "_" + streamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam))) + outputSubtitleExtension;
+
+ return Path.Join(SubtitleCachePath, filename[..1], filename);
+ }
+
+ private static string GetSubtitleExtension(string codec)
+ {
+ if (codec.ToLower(CultureInfo.InvariantCulture).Equals("ass", StringComparison.OrdinalIgnoreCase)
+ || codec.ToLower(CultureInfo.InvariantCulture).Equals("ssa", StringComparison.OrdinalIgnoreCase))
+ {
+ return "." + codec;
+ }
+ else if (codec.Contains("pgs", StringComparison.OrdinalIgnoreCase))
+ {
+ return ".sup";
+ }
+ else
+ {
+ return ".srt";
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
index c1a9e88949..63b0614fd4 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
@@ -4,7 +4,6 @@ using System.Globalization;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
-using MediaBrowser.Common;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Trickplay;
@@ -16,7 +15,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to move trickplay files to the new directory.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T23:00:00", nameof(MoveTrickplayFiles), RunMigrationOnSetup = true)]
public class MoveTrickplayFiles : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ITrickplayManager _trickplayManager;
private readonly IFileSystem _fileSystem;
@@ -30,7 +32,11 @@ public class MoveTrickplayFiles : IMigrationRoutine
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="logger">The logger.</param>
- public MoveTrickplayFiles(ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILogger<MoveTrickplayFiles> logger)
+ public MoveTrickplayFiles(
+ ITrickplayManager trickplayManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ ILogger<MoveTrickplayFiles> logger)
{
_trickplayManager = trickplayManager;
_fileSystem = fileSystem;
@@ -39,18 +45,9 @@ public class MoveTrickplayFiles : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => new("4EF123D5-8EFF-4B0B-869D-3AED07A60E1B");
-
- /// <inheritdoc />
- public string Name => "MoveTrickplayFiles";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc />
public void Perform()
{
- const int Limit = 100;
+ const int Limit = 5000;
int itemCount = 0, offset = 0, previousCount;
var sw = Stopwatch.StartNew();
@@ -65,9 +62,6 @@ public class MoveTrickplayFiles : IMigrationRoutine
do
{
var trickplayInfos = _trickplayManager.GetTrickplayItemsAsync(Limit, offset).GetAwaiter().GetResult();
- previousCount = trickplayInfos.Count;
- offset += Limit;
-
trickplayQuery.ItemIds = trickplayInfos.Select(i => i.ItemId).Distinct().ToArray();
var items = _libraryManager.GetItemList(trickplayQuery);
foreach (var trickplayInfo in trickplayInfos)
@@ -78,18 +72,32 @@ public class MoveTrickplayFiles : IMigrationRoutine
continue;
}
- if (++itemCount % 1_000 == 0)
+ var moved = false;
+ var oldPath = GetOldTrickplayDirectory(item, trickplayInfo.Width);
+ var newPath = _trickplayManager.GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false);
+ if (_fileSystem.DirectoryExists(oldPath))
{
- _logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed);
+ _fileSystem.MoveDirectory(oldPath, newPath);
+ moved = true;
}
- var oldPath = GetOldTrickplayDirectory(item, trickplayInfo.Width);
- var newPath = _trickplayManager.GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false);
+ oldPath = GetNewOldTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false);
if (_fileSystem.DirectoryExists(oldPath))
{
_fileSystem.MoveDirectory(oldPath, newPath);
+ moved = true;
+ }
+
+ if (moved)
+ {
+ itemCount++;
}
}
+
+ offset += Limit;
+ previousCount = trickplayInfos.Count;
+
+ _logger.LogInformation("Checked: {Checked} - Moved: {Count} - Time: {Time}", offset, itemCount, sw.Elapsed);
} while (previousCount == Limit);
_logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed);
@@ -101,4 +109,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/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
index 9cfaec46f8..ebf4a2780e 100644
--- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
@@ -2,48 +2,41 @@ using System;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
-namespace Jellyfin.Server.Migrations.Routines
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to initialize system configuration with the default plugin repository.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T11:00:00", nameof(ReaddDefaultPluginRepository), "5F86E7F6-D966-4C77-849D-7A7B40B68C4E", RunMigrationOnSetup = true)]
+public class ReaddDefaultPluginRepository : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo
+ {
+ Name = "Jellyfin Stable",
+ Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"
+ };
+
/// <summary>
- /// Migration to initialize system configuration with the default plugin repository.
+ /// Initializes a new instance of the <see cref="ReaddDefaultPluginRepository"/> class.
/// </summary>
- public class ReaddDefaultPluginRepository : IMigrationRoutine
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public ReaddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
{
- private readonly IServerConfigurationManager _serverConfigurationManager;
-
- private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo
- {
- Name = "Jellyfin Stable",
- Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"
- };
-
- /// <summary>
- /// Initializes a new instance of the <see cref="ReaddDefaultPluginRepository"/> class.
- /// </summary>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
- public ReaddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
- {
- _serverConfigurationManager = serverConfigurationManager;
- }
-
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("5F86E7F6-D966-4C77-849D-7A7B40B68C4E");
-
- /// <inheritdoc/>
- public string Name => "ReaddDefaultPluginRepository";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => true;
+ _serverConfigurationManager = serverConfigurationManager;
+ }
- /// <inheritdoc/>
- public void Perform()
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ // Only add if repository list is empty
+ if (_serverConfigurationManager.Configuration.PluginRepositories.Length == 0)
{
- // Only add if repository list is empty
- if (_serverConfigurationManager.Configuration.PluginRepositories.Length == 0)
- {
- _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
- _serverConfigurationManager.SaveConfiguration();
- }
+ _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
+ _serverConfigurationManager.SaveConfiguration();
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs b/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs
new file mode 100644
index 0000000000..b23a7dbc42
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to re-read creation dates for library items with internal metadata paths.
+/// </summary>
+[JellyfinMigration("2025-04-20T23:00:00", nameof(RefreshInternalDateModified))]
+public class RefreshInternalDateModified : IDatabaseMigrationRoutine
+{
+ private readonly ILogger<RefreshInternalDateModified> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerApplicationHost _applicationHost;
+ private readonly bool _useFileCreationTimeForDateAdded;
+
+ private IReadOnlyList<string> _internalTypes = [
+ typeof(Genre).FullName!,
+ typeof(MusicGenre).FullName!,
+ typeof(MusicArtist).FullName!,
+ typeof(People).FullName!,
+ typeof(Studio).FullName!
+ ];
+
+ private IReadOnlyList<string> _internalPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshInternalDateModified"/> class.
+ /// </summary>
+ /// <param name="applicationHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param>
+ /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
+ /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ public RefreshInternalDateModified(
+ IServerApplicationHost applicationHost,
+ IServerApplicationPaths applicationPaths,
+ IServerConfigurationManager configurationManager,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ ILogger<RefreshInternalDateModified> logger,
+ IFileSystem fileSystem)
+ {
+ _dbProvider = dbProvider;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _applicationHost = applicationHost;
+ _internalPaths = [
+ applicationPaths.ArtistsPath,
+ applicationPaths.GenrePath,
+ applicationPaths.MusicGenrePath,
+ applicationPaths.StudioPath,
+ applicationPaths.PeoplePath
+ ];
+ _useFileCreationTimeForDateAdded = configurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded;
+ }
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ const int Limit = 5000;
+ int itemCount = 0, offset = 0;
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.BaseItems.Count(b => _internalTypes.Contains(b.Type));
+ _logger.LogInformation("Checking if {Count} potentially internal items require refreshed DateModified", records);
+
+ do
+ {
+ var results = context.BaseItems
+ .Where(b => _internalTypes.Contains(b.Type))
+ .OrderBy(e => e.Id)
+ .Skip(offset)
+ .Take(Limit)
+ .ToList();
+
+ foreach (var item in results)
+ {
+ var itemPath = item.Path;
+ if (itemPath is not null)
+ {
+ var realPath = _applicationHost.ExpandVirtualPath(item.Path);
+ if (_internalPaths.Any(path => realPath.StartsWith(path, StringComparison.Ordinal)))
+ {
+ var writeTime = _fileSystem.GetLastWriteTimeUtc(realPath);
+ var itemModificationTime = item.DateModified;
+ if (writeTime != itemModificationTime)
+ {
+ _logger.LogDebug("Reset file modification date: Old: {Old} - New: {New} - Path: {Path}", itemModificationTime, writeTime, realPath);
+ item.DateModified = writeTime;
+ if (_useFileCreationTimeForDateAdded)
+ {
+ item.DateCreated = _fileSystem.GetCreationTimeUtc(realPath);
+ }
+
+ itemCount++;
+ }
+ }
+ }
+ }
+
+ offset += Limit;
+ if (offset > records)
+ {
+ offset = records;
+ }
+
+ _logger.LogInformation("Checked: {Count} - Refreshed: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
+ } while (offset < records);
+
+ context.SaveChanges();
+
+ _logger.LogInformation("Refreshed DateModified for {Count} items in {Time}", itemCount, sw.Elapsed);
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
index 52fb93d594..b626c473e3 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
@@ -3,50 +3,43 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.Server.Migrations.Routines
-{
- /// <summary>
- /// Removes the old 'RemoveDownloadImagesInAdvance' from library options.
- /// </summary>
- internal class RemoveDownloadImagesInAdvance : IMigrationRoutine
- {
- private readonly ILogger<RemoveDownloadImagesInAdvance> _logger;
- private readonly ILibraryManager _libraryManager;
-
- public RemoveDownloadImagesInAdvance(ILogger<RemoveDownloadImagesInAdvance> logger, ILibraryManager libraryManager)
- {
- _logger = logger;
- _libraryManager = libraryManager;
- }
-
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("{A81F75E0-8F43-416F-A5E8-516CCAB4D8CC}");
+namespace Jellyfin.Server.Migrations.Routines;
- /// <inheritdoc/>
- public string Name => "RemoveDownloadImagesInAdvance";
+/// <summary>
+/// Removes the old 'RemoveDownloadImagesInAdvance' from library options.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T13:00:00", nameof(RemoveDownloadImagesInAdvance), "A81F75E0-8F43-416F-A5E8-516CCAB4D8CC")]
+internal class RemoveDownloadImagesInAdvance : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
+{
+ private readonly ILogger<RemoveDownloadImagesInAdvance> _logger;
+ private readonly ILibraryManager _libraryManager;
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ public RemoveDownloadImagesInAdvance(ILogger<RemoveDownloadImagesInAdvance> logger, ILibraryManager libraryManager)
+ {
+ _logger = logger;
+ _libraryManager = libraryManager;
+ }
- /// <inheritdoc/>
- public void Perform()
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ var virtualFolders = _libraryManager.GetVirtualFolders(false);
+ _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries");
+ foreach (var virtualFolder in virtualFolders)
{
- var virtualFolders = _libraryManager.GetVirtualFolders(false);
- _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries");
- foreach (var virtualFolder in virtualFolders)
+ // Some virtual folders don't have a proper item id.
+ if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
{
- // Some virtual folders don't have a proper item id.
- if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
- {
- continue;
- }
-
- var libraryOptions = virtualFolder.LibraryOptions;
- var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId) ?? throw new InvalidOperationException("Failed to find CollectionFolder");
- // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed.
- collectionFolder.UpdateLibraryOptions(libraryOptions);
- _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name);
+ continue;
}
+
+ var libraryOptions = virtualFolder.LibraryOptions;
+ var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId) ?? throw new InvalidOperationException("Failed to find CollectionFolder");
+ // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed.
+ collectionFolder.UpdateLibraryOptions(libraryOptions);
+ _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name);
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
index 7b0d9456dc..c9e66d0cfe 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
@@ -7,77 +7,70 @@ using MediaBrowser.Controller;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.Server.Migrations.Routines
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T08:00:00", nameof(RemoveDuplicateExtras), "ACBE17B7-8435-4A83-8B64-6FCF162CB9BD")]
+internal class RemoveDuplicateExtras : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
- /// <summary>
- /// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself.
- /// </summary>
- internal class RemoveDuplicateExtras : IMigrationRoutine
+ private const string DbFilename = "library.db";
+ private readonly ILogger<RemoveDuplicateExtras> _logger;
+ private readonly IServerApplicationPaths _paths;
+
+ public RemoveDuplicateExtras(ILogger<RemoveDuplicateExtras> logger, IServerApplicationPaths paths)
{
- private const string DbFilename = "library.db";
- private readonly ILogger<RemoveDuplicateExtras> _logger;
- private readonly IServerApplicationPaths _paths;
+ _logger = logger;
+ _paths = paths;
+ }
- public RemoveDuplicateExtras(ILogger<RemoveDuplicateExtras> logger, IServerApplicationPaths paths)
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ var dataPath = _paths.DataPath;
+ var dbPath = Path.Combine(dataPath, DbFilename);
+ using var connection = new SqliteConnection($"Filename={dbPath}");
+ connection.Open();
+ using (var transaction = connection.BeginTransaction())
{
- _logger = logger;
- _paths = paths;
- }
+ // Query the database for the ids of duplicate extras
+ var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'");
+ var bads = string.Join(", ", queryResult.Select(x => x.GetString(0)));
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("{ACBE17B7-8435-4A83-8B64-6FCF162CB9BD}");
-
- /// <inheritdoc/>
- public string Name => "RemoveDuplicateExtras";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
- public void Perform()
- {
- var dataPath = _paths.DataPath;
- var dbPath = Path.Combine(dataPath, DbFilename);
- using var connection = new SqliteConnection($"Filename={dbPath}");
- connection.Open();
- using (var transaction = connection.BeginTransaction())
+ // Do nothing if no duplicate extras were detected
+ if (bads.Length == 0)
{
- // Query the database for the ids of duplicate extras
- var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'");
- var bads = string.Join(", ", queryResult.Select(x => x.GetString(0)));
-
- // Do nothing if no duplicate extras were detected
- if (bads.Length == 0)
- {
- _logger.LogInformation("No duplicate extras detected, skipping migration.");
- return;
- }
+ _logger.LogInformation("No duplicate extras detected, skipping migration.");
+ return;
+ }
- // Back up the database before deleting any entries
- for (int i = 1; ; i++)
+ // Back up the database before deleting any entries
+ for (int i = 1; ; i++)
+ {
+ var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
+ if (!File.Exists(bakPath))
{
- var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
- if (!File.Exists(bakPath))
+ try
{
- try
- {
- File.Copy(dbPath, bakPath);
- _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
- break;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
- throw;
- }
+ File.Copy(dbPath, bakPath);
+ _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+ throw;
}
}
-
- // Delete all duplicate extras
- _logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads);
- connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')");
- transaction.Commit();
}
+
+ // Delete all duplicate extras
+ _logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads);
+ connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')");
+ transaction.Commit();
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
index f84bccc258..23f212424b 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
@@ -1,44 +1,33 @@
using System;
using System.Linq;
using System.Threading;
-
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
-using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Remove duplicate playlist entries.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")]
internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
- private readonly ILogger<RemoveDuplicatePlaylistChildren> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IPlaylistManager _playlistManager;
public RemoveDuplicatePlaylistChildren(
- ILogger<RemoveDuplicatePlaylistChildren> logger,
ILibraryManager libraryManager,
IPlaylistManager playlistManager)
{
- _logger = logger;
_libraryManager = libraryManager;
_playlistManager = playlistManager;
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{96C156A2-7A13-4B3B-A8B8-FB80C94D20C0}");
-
- /// <inheritdoc/>
- public string Name => "RemoveDuplicatePlaylistChildren";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
diff --git a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
index 7e8c8ac871..f58cf27413 100644
--- a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
@@ -6,7 +6,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to update the default Jellyfin plugin repository.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T17:00:00", nameof(UpdateDefaultPluginRepository), "852816E0-2712-49A9-9240-C6FC5FCAD1A8", RunMigrationOnSetup = true)]
public class UpdateDefaultPluginRepository : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string NewRepositoryUrl = "https://repo.jellyfin.org/files/plugin/manifest.json";
private const string OldRepositoryUrl = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json";
@@ -23,15 +26,6 @@ public class UpdateDefaultPluginRepository : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => new("852816E0-2712-49A9-9240-C6FC5FCAD1A8");
-
- /// <inheritdoc />
- public string Name => "UpdateDefaultPluginRepository10.9";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc />
public void Perform()
{
var updated = false;
diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
new file mode 100644
index 0000000000..1e4dfb237c
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Jellyfin.Server.Migrations.Stages;
+
+internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute metadata)
+{
+ public Type MigrationType { get; } = migrationType;
+
+ public JellyfinMigrationAttribute Metadata { get; } = metadata;
+
+ public string BuildCodeMigrationId()
+ {
+ return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + MigrationType.Name!;
+ }
+
+ public async Task Perform(IServiceProvider? serviceProvider, CancellationToken cancellationToken)
+ {
+#pragma warning disable CS0618 // Type or member is obsolete
+ if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType))
+ {
+ if (serviceProvider is null)
+ {
+ ((IMigrationRoutine)Activator.CreateInstance(MigrationType)!).Perform();
+ }
+ else
+ {
+ ((IMigrationRoutine)ActivatorUtilities.CreateInstance(serviceProvider, MigrationType)).Perform();
+#pragma warning restore CS0618 // Type or member is obsolete
+ }
+ }
+ else if (typeof(IAsyncMigrationRoutine).IsAssignableFrom(MigrationType))
+ {
+ if (serviceProvider is null)
+ {
+ await ((IAsyncMigrationRoutine)Activator.CreateInstance(MigrationType)!).PerformAsync(cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(serviceProvider, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type");
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs b/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs
new file mode 100644
index 0000000000..d90ad3d9be
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs
@@ -0,0 +1,26 @@
+namespace Jellyfin.Server.Migrations.Stages;
+
+/// <summary>
+/// Defines the stages the <see cref="JellyfinMigrationService"/> supports.
+/// </summary>
+#pragma warning disable CA1008 // Enums should have zero value
+public enum JellyfinMigrationStageTypes
+#pragma warning restore CA1008 // Enums should have zero value
+{
+ /// <summary>
+ /// Runs before services are initialised.
+ /// Reserved for migrations that are modifying the application server itself. Should be avoided if possible.
+ /// </summary>
+ PreInitialisation = 1,
+
+ /// <summary>
+ /// Runs after the host has been configured and includes the database migrations.
+ /// Allows the mix order of migrations that contain application code and database changes.
+ /// </summary>
+ CoreInitialisaition = 2,
+
+ /// <summary>
+ /// Runs after services has been registered and initialised. Last step before running the server.
+ /// </summary>
+ AppInitialisation = 3
+}
diff --git a/Jellyfin.Server/Migrations/Stages/MigrationStage.cs b/Jellyfin.Server/Migrations/Stages/MigrationStage.cs
new file mode 100644
index 0000000000..efcadbf006
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Stages/MigrationStage.cs
@@ -0,0 +1,16 @@
+using System.Collections.ObjectModel;
+
+namespace Jellyfin.Server.Migrations.Stages;
+
+/// <summary>
+/// Defines a Stage that can be Invoked and Handled at different times from the code.
+/// </summary>
+internal class MigrationStage : Collection<CodeMigration>
+{
+ public MigrationStage(JellyfinMigrationStageTypes stage)
+ {
+ Stage = stage;
+ }
+
+ public JellyfinMigrationStageTypes Stage { get; }
+}
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index 295fb8112f..4584b25bdf 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -1,16 +1,28 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
+using System.Threading;
using System.Threading.Tasks;
using CommandLine;
using Emby.Server.Implementations;
+using Emby.Server.Implementations.Configuration;
+using Emby.Server.Implementations.Serialization;
+using Jellyfin.Database.Implementations;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Helpers;
-using Jellyfin.Server.Implementations;
+using Jellyfin.Server.Implementations.DatabaseConfiguration;
+using Jellyfin.Server.Implementations.Extensions;
+using Jellyfin.Server.Implementations.FullSystemBackup;
+using Jellyfin.Server.Implementations.StorageHelpers;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using Jellyfin.Server.Migrations;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
@@ -42,9 +54,13 @@ namespace Jellyfin.Server
public const string LoggingConfigFileSystem = "logging.json";
private static readonly SerilogLoggerFactory _loggerFactory = new SerilogLoggerFactory();
+ private static SetupServer? _setupServer;
+ private static CoreAppHost? _appHost;
+ private static IHost? _jellyfinHost = null;
private static long _startTimestamp;
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
+ private static string? _restoreFromBackup;
/// <summary>
/// The entry point of the application.
@@ -66,8 +82,10 @@ namespace Jellyfin.Server
private static async Task StartApp(StartupOptions options)
{
+ _restoreFromBackup = options.RestoreArchive;
_startTimestamp = Stopwatch.GetTimestamp();
ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options);
+ appPaths.MakeSanityCheckOrThrow();
// $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager
Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath);
@@ -80,7 +98,8 @@ namespace Jellyfin.Server
// Create an instance of the application configuration to use for application startup
IConfiguration startupConfig = CreateAppConfiguration(options, appPaths);
-
+ _setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost, _loggerFactory, startupConfig);
+ await _setupServer.RunAsync().ConfigureAwait(false);
StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths);
_logger = _loggerFactory.CreateLogger("Main");
@@ -112,8 +131,11 @@ namespace Jellyfin.Server
}
}
+ StorageHelper.TestCommonPathsForStorageCapacity(appPaths, _loggerFactory.CreateLogger<Startup>());
+
StartupHelpers.PerformStaticInitialization();
- Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory);
+
+ await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false);
do
{
@@ -122,22 +144,25 @@ namespace Jellyfin.Server
if (_restartOnShutdown)
{
_startTimestamp = Stopwatch.GetTimestamp();
+ await _setupServer.StopAsync().ConfigureAwait(false);
+ await _setupServer.RunAsync().ConfigureAwait(false);
}
} while (_restartOnShutdown);
+
+ _setupServer.Dispose();
}
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 +179,27 @@ 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;
+ PrepareDatabaseProvider(appHost.ServiceProvider);
- await appHost.InitializeServices().ConfigureAwait(false);
- Migrations.MigrationRunner.Run(appHost, _loggerFactory);
+ if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
+ {
+ await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
+ _restoreFromBackup = null;
+ _restartOnShutdown = true;
+ return;
+ }
+
+ await ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisaition).ConfigureAwait(false);
+
+ await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
+
+ await ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).ConfigureAwait(false);
try
{
- await host.StartAsync().ConfigureAwait(false);
+ await _setupServer!.StopAsync().ConfigureAwait(false);
+ await _jellyfinHost.StartAsync().ConfigureAwait(false);
if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket())
{
@@ -180,8 +218,9 @@ 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;
+ _restoreFromBackup = appHost.RestoreBackupPath;
}
catch (Exception ex)
{
@@ -194,22 +233,61 @@ 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();
}
}
/// <summary>
+ /// [Internal]Runs the startup Migrations.
+ /// </summary>
+ /// <remarks>
+ /// Not intended to be used other then by jellyfin and its tests.
+ /// </remarks>
+ /// <param name="appPaths">Application Paths.</param>
+ /// <param name="startupConfig">Startup Config.</param>
+ /// <returns>A task.</returns>
+ public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
+ {
+ var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
+ startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
+ var migrationStartupServiceProvider = new ServiceCollection()
+ .AddLogging(d => d.AddSerilog())
+ .AddJellyfinDbContext(startupConfigurationManager, startupConfig)
+ .AddSingleton<IApplicationPaths>(appPaths)
+ .AddSingleton<ServerApplicationPaths>(appPaths);
+ var startupService = migrationStartupServiceProvider.BuildServiceProvider();
+
+ PrepareDatabaseProvider(startupService);
+
+ var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
+ await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths).ConfigureAwait(false);
+ await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// [Internal]Runs the Jellyfin migrator service with the Core stage.
+ /// </summary>
+ /// <remarks>
+ /// Not intended to be used other then by jellyfin and its tests.
+ /// </remarks>
+ /// <param name="serviceProvider">The service provider.</param>
+ /// <param name="jellyfinMigrationStage">The stage to run.</param>
+ /// <returns>A task.</returns>
+ public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage)
+ {
+ var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider);
+ await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
+ }
+
+ /// <summary>
/// Create the application configuration.
/// </summary>
/// <param name="commandLineOpts">The command line options passed to the program.</param>
@@ -243,5 +321,12 @@ namespace Jellyfin.Server
.AddEnvironmentVariables("JELLYFIN_")
.AddInMemoryCollection(commandLineOpts.ConvertToConfig());
}
+
+ private static void PrepareDatabaseProvider(IServiceProvider services)
+ {
+ var factory = services.GetRequiredService<IDbContextFactory<JellyfinDbContext>>();
+ var provider = services.GetRequiredService<IJellyfinDatabaseProvider>();
+ provider.DbContextFactory = factory;
+ }
}
}
diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
new file mode 100644
index 0000000000..7ab5defc8a
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
@@ -0,0 +1,220 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Configuration;
+using Emby.Server.Implementations.Serialization;
+using Jellyfin.Networking.Manager;
+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.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Primitives;
+
+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 readonly Func<INetworkManager?> _networkManagerFactory;
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly Func<IServerApplicationHost?> _serverFactory;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly IConfiguration _startupConfiguration;
+ private readonly ServerConfigurationManager _configurationManager;
+ private IHost? _startupServer;
+ private bool _disposed;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetupServer"/> class.
+ /// </summary>
+ /// <param name="networkManagerFactory">The networkmanager.</param>
+ /// <param name="applicationPaths">The application paths.</param>
+ /// <param name="serverApplicationHostFactory">The servers application host.</param>
+ /// <param name="loggerFactory">The logger factory.</param>
+ /// <param name="startupConfiguration">The startup configuration.</param>
+ public SetupServer(
+ Func<INetworkManager?> networkManagerFactory,
+ IApplicationPaths applicationPaths,
+ Func<IServerApplicationHost?> serverApplicationHostFactory,
+ ILoggerFactory loggerFactory,
+ IConfiguration startupConfiguration)
+ {
+ _networkManagerFactory = networkManagerFactory;
+ _applicationPaths = applicationPaths;
+ _serverFactory = serverApplicationHostFactory;
+ _loggerFactory = loggerFactory;
+ _startupConfiguration = startupConfiguration;
+ var xmlSerializer = new MyXmlSerializer();
+ _configurationManager = new ServerConfigurationManager(_applicationPaths, loggerFactory, xmlSerializer);
+ _configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
+ }
+
+ /// <summary>
+ /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup.
+ /// </summary>
+ /// <returns>A Task.</returns>
+ public async Task RunAsync()
+ {
+ ThrowIfDisposed();
+ _startupServer = Host.CreateDefaultBuilder()
+ .UseConsoleLifetime()
+ .ConfigureServices(serv =>
+ {
+ serv.AddHealthChecks()
+ .AddCheck<SetupHealthcheck>("StartupCheck");
+ })
+ .ConfigureWebHostDefaults(webHostBuilder =>
+ {
+ webHostBuilder
+ .UseKestrel((builderContext, options) =>
+ {
+ var config = _configurationManager.GetNetworkConfiguration()!;
+ var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger<SetupServer>(), config.EnableIPv4, config.EnableIPv6);
+ knownBindInterfaces = NetworkManager.FilterBindSettings(config, knownBindInterfaces.ToList(), config.EnableIPv4, config.EnableIPv6);
+ var bindInterfaces = NetworkManager.GetAllBindInterfaces(false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6);
+ Extensions.WebHostBuilderExtensions.SetupJellyfinWebServer(
+ bindInterfaces,
+ config.InternalHttpPort,
+ null,
+ null,
+ _startupConfiguration,
+ _applicationPaths,
+ _loggerFactory.CreateLogger<SetupServer>(),
+ builderContext,
+ options);
+ })
+ .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()
+ .OrderByDescending(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 = _serverFactory();
+
+ var retryCounter = 0;
+ while (jfApplicationHost is null && retryCounter < 5)
+ {
+ await Task.Delay(500).ConfigureAwait(false);
+ jfApplicationHost = _serverFactory();
+ retryCounter++;
+ }
+
+ if (jfApplicationHost is null)
+ {
+ context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
+ context.Response.Headers.RetryAfter = new StringValues("5");
+ 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 StringValues("5");
+ context.Response.Headers.ContentType = new StringValues("text/html");
+ 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 e9fb3e4c27..688b169359 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>();
diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs
index 91ac827ca6..4890ccbb2e 100644
--- a/Jellyfin.Server/StartupOptions.cs
+++ b/Jellyfin.Server/StartupOptions.cs
@@ -74,6 +74,12 @@ namespace Jellyfin.Server
public bool NoDetectNetworkChange { get; set; }
/// <summary>
+ /// Gets or sets the path to an jellyfin backup archive to restore the application to.
+ /// </summary>
+ [Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")]
+ public string? RestoreArchive { get; set; }
+
+ /// <summary>
/// Gets the command line options as a dictionary that can be used in the .NET configuration system.
/// </summary>
/// <returns>The configuration dictionary.</returns>