aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/CoreAppHost.cs2
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs10
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs49
-rw-r--r--Jellyfin.Server/Filters/AdditionalModelFilter.cs2
-rw-r--r--Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs51
-rw-r--r--Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs36
-rw-r--r--Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs11
-rw-r--r--Jellyfin.Server/Helpers/StartupHelpers.cs6
-rw-r--r--Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs151
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj7
-rw-r--r--Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs31
-rw-r--r--Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs2
-rw-r--r--Jellyfin.Server/Migrations/IMigrationRoutine.cs32
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs68
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs35
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs459
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs204
-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.cs10
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs12
-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/CleanMusicArtist.cs47
-rw-r--r--Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs32
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixAudioData.cs37
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixDates.cs171
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs30
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs33
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs30
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs103
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs211
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs73
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs123
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs111
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs346
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs218
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs46
-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.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs74
-rw-r--r--Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs12
-rw-r--r--Jellyfin.Server/Migrations/Stages/CodeMigration.cs87
-rw-r--r--Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs26
-rw-r--r--Jellyfin.Server/Migrations/Stages/MigrationStage.cs16
-rw-r--r--Jellyfin.Server/Program.cs107
-rw-r--r--Jellyfin.Server/ServerSetupApp/IStartupLogger.cs66
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs182
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs31
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogger.cs124
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs18
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs56
-rw-r--r--Jellyfin.Server/ServerSetupApp/index.mstemplate.html235
-rw-r--r--Jellyfin.Server/Startup.cs17
-rw-r--r--Jellyfin.Server/StartupOptions.cs6
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/banner-dark.svg34
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/jellyfin.svg26
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/swagger/custom.css12
67 files changed, 3098 insertions, 1348 deletions
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index f3bf6b805..2548ddea7 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -84,7 +84,7 @@ namespace Jellyfin.Server
serviceCollection.AddSingleton<IAuthenticationProvider, DefaultAuthenticationProvider>();
serviceCollection.AddSingleton<IAuthenticationProvider, InvalidAuthProvider>();
serviceCollection.AddSingleton<IPasswordResetProvider, DefaultPasswordResetProvider>();
- serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
+ serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>();
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index 6066893de..a56baba33 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -69,16 +69,6 @@ namespace Jellyfin.Server.Extensions
}
/// <summary>
- /// Adds LAN based access filtering to the application pipeline.
- /// </summary>
- /// <param name="appBuilder">The application builder.</param>
- /// <returns>The updated application builder.</returns>
- public static IApplicationBuilder UseLanFiltering(this IApplicationBuilder appBuilder)
- {
- return appBuilder.UseMiddleware<LanFilteringMiddleware>();
- }
-
- /// <summary>
/// Enables url decoding before binding to the application pipeline.
/// </summary>
/// <param name="appBuilder">The <see cref="IApplicationBuilder"/>.</param>
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index b04e55baa..08c1a5065 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -116,26 +116,7 @@ namespace Jellyfin.Server.Extensions
.AddTransient<ICorsPolicyProvider, CorsPolicyProvider>()
.Configure<ForwardedHeadersOptions>(options =>
{
- // 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.
-
- 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);
- }
-
- // Only set forward limit if we have some known proxies or some known networks.
- if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0)
- {
- options.ForwardLimit = null;
- }
+ ConfigureForwardHeaders(config, options);
})
.AddMvc(opts =>
{
@@ -183,6 +164,30 @@ namespace Jellyfin.Server.Extensions
return mvcBuilder.AddControllersAsServices();
}
+ internal static void ConfigureForwardHeaders(NetworkConfiguration config, ForwardedHeadersOptions options)
+ {
+ // 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.
+
+ 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);
+ }
+
+ // Only set forward limit if we have some known proxies or some known networks.
+ if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0)
+ {
+ options.ForwardLimit = null;
+ }
+ }
+
/// <summary>
/// Adds Swagger to the service collection.
/// </summary>
@@ -215,7 +220,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);
@@ -248,7 +253,7 @@ namespace Jellyfin.Server.Extensions
c.AddSwaggerTypeMappings();
c.SchemaFilter<IgnoreEnumSchemaFilter>();
- c.OperationFilter<RetryOnTemporarlyUnavailableFilter>();
+ c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
c.OperationFilter<SecurityRequirementsOperationFilter>();
c.OperationFilter<FileResponseFilter>();
c.OperationFilter<FileRequestFilter>();
diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
index 421eeecda..58d37db5a 100644
--- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs
+++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
@@ -175,7 +175,7 @@ namespace Jellyfin.Server.Filters
// Manually generate sync play GroupUpdate messages.
var groupUpdateTypes = typeof(GroupUpdate<>).Assembly.GetTypes()
- .Where(t => t.BaseType != null
+ .Where(t => t.BaseType is not null
&& t.BaseType.IsGenericType
&& t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>))
.ToList();
diff --git a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
new file mode 100644
index 000000000..8b7268513
--- /dev/null
+++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Jellyfin.Server.Filters;
+
+internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
+{
+ public void Apply(OpenApiOperation operation, OperationFilterContext context)
+ {
+ operation.Responses.TryAdd(
+ "503",
+ new OpenApiResponse
+ {
+ Description = "The server is currently starting or is temporarily not available.",
+ Headers = new Dictionary<string, OpenApiHeader>
+ {
+ {
+ "Retry-After", new OpenApiHeader
+ {
+ AllowEmptyValue = true,
+ Required = false,
+ Description = "A hint for when to retry the operation in full seconds.",
+ Schema = new OpenApiSchema
+ {
+ Type = "integer",
+ Format = "int32"
+ }
+ }
+ },
+ {
+ "Message", new OpenApiHeader
+ {
+ AllowEmptyValue = true,
+ Required = false,
+ Description = "A short plain-text reason why the server is not available.",
+ Schema = new OpenApiSchema
+ {
+ Type = "string",
+ Format = "text"
+ }
+ }
+ }
+ },
+ Content = new Dictionary<string, OpenApiMediaType>()
+ {
+ { "text/html", new OpenApiMediaType() }
+ }
+ });
+ }
+}
diff --git a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs
deleted file mode 100644
index 74470eda0..000000000
--- a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-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/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
index 401392a63..8f5757269 100644
--- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
+++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
@@ -66,15 +66,8 @@ public class SecurityRequirementsOperationFilter : IOperationFilter
return;
}
- if (!operation.Responses.ContainsKey("401"))
- {
- operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
- }
-
- if (!operation.Responses.ContainsKey("403"))
- {
- operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
- }
+ operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
+ operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });
var scheme = new OpenApiSecurityScheme
{
diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs
index bbf6d31f1..93c996166 100644
--- a/Jellyfin.Server/Helpers/StartupHelpers.cs
+++ b/Jellyfin.Server/Helpers/StartupHelpers.cs
@@ -3,18 +3,19 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Net;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Threading.Tasks;
using Emby.Server.Implementations;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Extensions;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Serilog;
+using Serilog.Extensions.Logging;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Jellyfin.Server.Helpers;
@@ -257,11 +258,14 @@ public static class StartupHelpers
{
try
{
+ var startupLogger = new LoggerProviderCollection();
+ startupLogger.AddProvider(new SetupServer.SetupLoggerFactory());
// Serilog.Log is used by SerilogLoggerFactory when no logger is specified
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.FromLogContext()
.Enrich.WithThreadId()
+ .WriteTo.Async(e => e.Providers(startupLogger))
.CreateLogger();
}
catch (Exception ex)
diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs
deleted file mode 100644
index 910b5c467..000000000
--- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs
+++ /dev/null
@@ -1,151 +0,0 @@
-// The MIT License (MIT)
-//
-// Copyright (c) .NET Foundation and Contributors
-//
-// All rights reserved.
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Extensions;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
-using Microsoft.Extensions.Logging;
-using Microsoft.Net.Http.Headers;
-
-namespace Jellyfin.Server.Infrastructure
-{
- /// <inheritdoc />
- public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor
- {
- /// <summary>
- /// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class.
- /// </summary>
- /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
- public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory)
- {
- }
-
- /// <inheritdoc />
- protected override FileMetadata GetFileInfo(string path)
- {
- var fileInfo = new FileInfo(path);
- var length = fileInfo.Length;
- // This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371
- if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
- {
- using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
- length = RandomAccess.GetLength(fileHandle);
- }
-
- return new FileMetadata
- {
- Exists = fileInfo.Exists,
- Length = length,
- LastModified = fileInfo.LastWriteTimeUtc
- };
- }
-
- /// <inheritdoc />
- 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;
- }
-
- // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code
- if (!IsSymLink(result.FileName))
- {
- await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false);
- return;
- }
-
- var response = context.HttpContext.Response;
-
- if (range is not null)
- {
- await SendFileAsync(
- result.FileName,
- response,
- offset: range.From ?? 0L,
- count: rangeLength).ConfigureAwait(false);
- return;
- }
-
- await SendFileAsync(
- result.FileName,
- response,
- offset: 0,
- count: null).ConfigureAwait(false);
- }
-
- private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default)
- {
- var fileInfo = GetFileInfo(filePath);
- if (offset < 0 || offset > fileInfo.Length)
- {
- throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty);
- }
-
- if (count.HasValue
- && (count.Value < 0 || count.Value > fileInfo.Length - offset))
- {
- throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty);
- }
-
- // Copied from SendFileFallback.SendFileAsync
- const int BufferSize = 1024 * 16;
-
- var useRequestAborted = !cancellationToken.CanBeCanceled;
- var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken;
-
- var fileStream = new FileStream(
- filePath,
- FileMode.Open,
- FileAccess.Read,
- FileShare.ReadWrite,
- bufferSize: BufferSize,
- options: FileOptions.Asynchronous | FileOptions.SequentialScan);
- await using (fileStream.ConfigureAwait(false))
- {
- try
- {
- localCancel.ThrowIfCancellationRequested();
- fileStream.Seek(offset, SeekOrigin.Begin);
- await StreamCopyOperation
- .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel)
- .ConfigureAwait(true);
- }
- catch (OperationCanceledException) when (useRequestAborted)
- {
- }
- }
- }
-
- private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
- }
-}
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index 452b03efb..14ab114fb 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -48,10 +48,12 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
+ <PackageReference Include="Morestachio" />
<PackageReference Include="prometheus-net" />
<PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Thread" />
+ <PackageReference Include="Serilog.Expressions" />
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Async" />
<PackageReference Include="Serilog.Sinks.Console" />
@@ -76,7 +78,10 @@
<None Update="wwwroot\api-docs\swagger\custom.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
- <None Update="wwwroot\api-docs\banner-dark.svg">
+ <None Update="wwwroot\api-docs\jellyfin.svg">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="ServerSetupApp/index.mstemplate.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
diff --git a/Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs b/Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs
new file mode 100644
index 000000000..5b6a5fe94
--- /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
index 78ff1e3fd..d2d80a81e 100644
--- a/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs
+++ b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs
@@ -7,6 +7,8 @@ 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 29f681df5..000000000
--- a/Jellyfin.Server/Migrations/IMigrationRoutine.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using System;
-using Jellyfin.Server.Implementations;
-using Microsoft.EntityFrameworkCore.Internal;
-
-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 000000000..70e54125b
--- /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.CoreInitialisation"/>.
+ /// </summary>
+ public JellyfinMigrationStageTypes Stage { get; set; } = JellyfinMigrationStageTypes.CoreInitialisation;
+
+ /// <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/JellyfinMigrationBackupAttribute.cs b/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs
new file mode 100644
index 000000000..6c8da7e82
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Marks an <see cref="JellyfinMigrationAttribute"/> migration and instructs the <see cref="JellyfinMigrationService"/> to perform a backup.
+/// </summary>
+[AttributeUsage(System.AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
+public sealed class JellyfinMigrationBackupAttribute : System.Attribute
+{
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the old library.db should be performed.
+ /// </summary>
+ public bool LegacyLibraryDb { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the Database should be performed.
+ /// </summary>
+ public bool JellyfinDb { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the metadata folder should be performed.
+ /// </summary>
+ public bool Metadata { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the Trickplay folder should be performed.
+ /// </summary>
+ public bool Trickplay { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the Subtitles folder should be performed.
+ /// </summary>
+ public bool Subtitles { get; set; }
+}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
new file mode 100644
index 000000000..188d3c4a9
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -0,0 +1,459 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+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 Jellyfin.Server.ServerSetupApp;
+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.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Handles Migration of the Jellyfin data structure.
+/// </summary>
+internal class JellyfinMigrationService
+{
+ private const string DbFilename = "library.db";
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly IStartupLogger _startupLogger;
+ private readonly IBackupService? _backupService;
+ private readonly IJellyfinDatabaseProvider? _jellyfinDatabaseProvider;
+ private readonly IApplicationPaths _applicationPaths;
+ private (string? LibraryDb, string? JellyfinDb, BackupManifestDto? FullBackup) _backupKey;
+
+ /// <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>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
+ /// <param name="applicationPaths">Application paths for library.db backup.</param>
+ /// <param name="backupService">The jellyfin backup service.</param>
+ /// <param name="jellyfinDatabaseProvider">The jellyfin database provider.</param>
+ public JellyfinMigrationService(
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ ILoggerFactory loggerFactory,
+ IStartupLogger<JellyfinMigrationService> startupLogger,
+ IApplicationPaths applicationPaths,
+ IBackupService? backupService = null,
+ IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
+ {
+ _dbContextFactory = dbContextFactory;
+ _loggerFactory = loggerFactory;
+ _startupLogger = startupLogger;
+ _backupService = backupService;
+ _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ _applicationPaths = applicationPaths;
+#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>(), Backup: e.GetCustomAttributes<JellyfinMigrationBackupAttribute>()))
+ .Where(e => e.Metadata is not null)
+ .GroupBy(e => e.Metadata!.Stage)
+ .Select(f =>
+ {
+ var stage = new MigrationStage(f.Key);
+ foreach (var item in f)
+ {
+ JellyfinMigrationBackupAttribute? backupMetadata = null;
+ if (item.Backup?.Any() == true)
+ {
+ backupMetadata = item.Backup.Aggregate(MergeBackupAttributes);
+ }
+
+ stage.Add(new(item.Type, item.Metadata!, backupMetadata));
+ }
+
+ return stage;
+ })];
+#pragma warning restore CS0618 // Type or member is obsolete
+ }
+
+ private interface IInternalMigration
+ {
+ Task PerformAsync(IStartupLogger logger);
+ }
+
+ private HashSet<MigrationStage> Migrations { get; set; }
+
+ public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
+ {
+ var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
+ 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 databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator
+ ?? throw new InvalidOperationException("Jellyfin does only support relational databases.");
+ if (!await databaseCreator.ExistsAsync().ConfigureAwait(false))
+ {
+ await databaseCreator.CreateAsync().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 is not 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 lastOldAppliedMigration = Migrations
+ .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
+ .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
+ .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
+ .OrderBy(e => e.BuildCodeMigrationId())
+ .Last(); // this is the latest migration applied in the old migration.xml
+
+ IReadOnlyList<CodeMigration> oldMigrations = [
+ .. Migrations
+ .SelectMany(e => e)
+ .OrderBy(e => e.BuildCodeMigrationId())
+ .TakeWhile(e => e.BuildCodeMigrationId() != lastOldAppliedMigration.BuildCodeMigrationId()),
+ lastOldAppliedMigration
+ ];
+ // those are all migrations that had to run in the old migration system, even if not noted in the migration.xml file.
+
+ var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
+ foreach (var item in startupScripts)
+ {
+ 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 = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate 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.CoreInitialisation)
+ {
+ pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
+ .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
+ .ToArray();
+ }
+
+ (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
+ 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)
+ {
+ var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
+ try
+ {
+ migrationLogger.LogInformation("Perform migration {Name}", item.Key);
+ await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
+ migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
+ }
+ catch (Exception ex)
+ {
+ migrationLogger.LogCritical("Error: {Error}", ex.Message);
+ migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
+
+ if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
+ {
+ if (_backupKey.LibraryDb is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback librarydb.");
+ try
+ {
+ var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+ File.Move(_backupKey.LibraryDb, libraryDbPath, true);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
+ }
+ }
+
+ if (_backupKey.JellyfinDb is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
+ try
+ {
+ await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
+ }
+ }
+
+ if (_backupKey.FullBackup is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback from backup.");
+ try
+ {
+ await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
+ }
+ }
+ }
+
+ throw;
+ }
+ }
+ }
+ }
+
+ private static string GetJellyfinVersion()
+ {
+ return Assembly.GetEntryAssembly()!.GetName().Version!.ToString();
+ }
+
+ public async Task CleanupSystemAfterMigration(ILogger logger)
+ {
+ if (_backupKey != default)
+ {
+ if (_backupKey.LibraryDb is not null)
+ {
+ logger.LogInformation("Attempt to cleanup librarydb backup.");
+ try
+ {
+ File.Delete(_backupKey.LibraryDb);
+ }
+ catch (Exception inner)
+ {
+ logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.LibraryDb);
+ }
+ }
+
+ if (_backupKey.JellyfinDb is not null && _jellyfinDatabaseProvider is not null)
+ {
+ logger.LogInformation("Attempt to cleanup JellyfinDb backup.");
+ try
+ {
+ await _jellyfinDatabaseProvider.DeleteBackup(_backupKey.JellyfinDb).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.JellyfinDb);
+ }
+ }
+
+ if (_backupKey.FullBackup is not null)
+ {
+ logger.LogInformation("Attempt to cleanup from migration backup.");
+ try
+ {
+ File.Delete(_backupKey.FullBackup.Path);
+ }
+ catch (Exception inner)
+ {
+ logger.LogCritical(inner, "Could not cleanup backup {Backup}.", _backupKey.FullBackup.Path);
+ }
+ }
+ }
+ }
+
+ public async Task PrepareSystemForMigration(ILogger logger)
+ {
+ logger.LogInformation("Prepare system for possible migrations");
+ JellyfinMigrationBackupAttribute backupInstruction;
+ IReadOnlyList<HistoryRow> appliedMigrations;
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
+ appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ backupInstruction = new JellyfinMigrationBackupAttribute()
+ {
+ JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key))
+ };
+ }
+
+ backupInstruction = Migrations.SelectMany(e => e)
+ .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
+ .Select(e => e.BackupRequirements)
+ .Where(e => e is not null)
+ .Aggregate(backupInstruction, MergeBackupAttributes!);
+
+ if (backupInstruction.LegacyLibraryDb)
+ {
+ logger.LogInformation("A migration will attempt to modify the library.db, will attempt to backup the file now.");
+ // for legacy migrations that still operates on the library.db
+ var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+ if (File.Exists(libraryDbPath))
+ {
+ for (int i = 1; ; i++)
+ {
+ var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", libraryDbPath, i);
+ if (!File.Exists(bakPath))
+ {
+ try
+ {
+ logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
+ File.Copy(libraryDbPath, bakPath);
+ _backupKey = (bakPath, _backupKey.JellyfinDb, _backupKey.FullBackup);
+ logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
+ break;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+ throw;
+ }
+ }
+ }
+
+ logger.LogInformation("{Library} has been backed up as {BackupPath}", DbFilename, _backupKey.LibraryDb);
+ }
+ else
+ {
+ logger.LogError("Cannot make a backup of {Library} at path {BackupPath} because file could not be found at {LibraryPath}", DbFilename, libraryDbPath, _applicationPaths.DataPath);
+ }
+ }
+
+ if (backupInstruction.JellyfinDb && _jellyfinDatabaseProvider is not null)
+ {
+ logger.LogInformation("A migration will attempt to modify the jellyfin.db, will attempt to backup the file now.");
+ _backupKey = (_backupKey.LibraryDb, await _jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false), _backupKey.FullBackup);
+ logger.LogInformation("Jellyfin database has been backed up as {BackupPath}", _backupKey.JellyfinDb);
+ }
+
+ if (_backupService is not null && (backupInstruction.Metadata || backupInstruction.Subtitles || backupInstruction.Trickplay))
+ {
+ logger.LogInformation("A migration will attempt to modify system resources. Will attempt to create backup now.");
+ _backupKey = (_backupKey.LibraryDb, _backupKey.JellyfinDb, await _backupService.CreateBackupAsync(new BackupOptionsDto()
+ {
+ Metadata = backupInstruction.Metadata,
+ Subtitles = backupInstruction.Subtitles,
+ Trickplay = backupInstruction.Trickplay,
+ Database = false // database backups are explicitly handled by the provider itself as the backup service requires parity with the current model
+ }).ConfigureAwait(false));
+ logger.LogInformation("Pre-Migration backup successfully created as {BackupKey}", _backupKey.FullBackup.Path);
+ }
+ }
+
+ private static JellyfinMigrationBackupAttribute MergeBackupAttributes(JellyfinMigrationBackupAttribute left, JellyfinMigrationBackupAttribute right)
+ {
+ return new JellyfinMigrationBackupAttribute()
+ {
+ JellyfinDb = left!.JellyfinDb || right!.JellyfinDb,
+ LegacyLibraryDb = left.LegacyLibraryDb || right!.LegacyLibraryDb,
+ Metadata = left.Metadata || right!.Metadata,
+ Subtitles = left.Subtitles || right!.Subtitles,
+ Trickplay = left.Trickplay || right!.Trickplay
+ };
+ }
+
+ 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(IStartupLogger logger)
+ {
+ await _codeMigration.Perform(_serviceProvider, logger, 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(IStartupLogger 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 c223576da..000000000
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ /dev/null
@@ -1,204 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Emby.Server.Implementations;
-using Emby.Server.Implementations.Serialization;
-using Jellyfin.Database.Implementations;
-using Jellyfin.Server.Implementations;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Configuration;
-using Microsoft.EntityFrameworkCore.Storage;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-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),
- typeof(PreStartupRoutines.RenameEnableGroupingIntoCollections)
- };
-
- /// <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.AddDefaultCastReceivers),
- typeof(Routines.UpdateDefaultPluginRepository),
- typeof(Routines.FixAudioData),
- typeof(Routines.RemoveDuplicatePlaylistChildren),
- typeof(Routines.MigrateLibraryDb),
- typeof(Routines.MoveExtractedFiles),
- typeof(Routines.MigrateRatingLevels),
- typeof(Routines.MoveTrickplayFiles),
- typeof(Routines.MigrateKeyframeData),
- };
-
- /// <summary>
- /// Run all needed migrations.
- /// </summary>
- /// <param name="host">CoreAppHost that hosts current version.</param>
- /// <param name="loggerFactory">Factory for making the logger.</param>
- /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
- public static async Task Run(CoreAppHost host, ILoggerFactory loggerFactory)
- {
- var logger = loggerFactory.CreateLogger<MigrationRunner>();
- var migrations = _migrationTypes
- .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);
- await PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger, host.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>())
- .ConfigureAwait(false);
- }
-
- /// <summary>
- /// Run all needed pre-startup migrations.
- /// </summary>
- /// <param name="appPaths">Application paths.</param>
- /// <param name="loggerFactory">Factory for making the logger.</param>
- /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
- public static async Task RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory)
- {
- var logger = loggerFactory.CreateLogger<MigrationRunner>();
- var migrations = _preStartupMigrationTypes
- .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);
- await PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger, null).ConfigureAwait(false);
- }
-
- private static void HandleStartupWizardCondition(IEnumerable<IMigrationRoutine> migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger)
- {
- 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 async Task PerformMigrations(
- IMigrationRoutine[] migrations,
- MigrationOptions migrationOptions,
- Action<MigrationOptions> saveConfiguration,
- ILogger logger,
- IJellyfinDatabaseProvider? jellyfinDatabaseProvider)
- {
- // save already applied migrations, and skip them thereafter
- saveConfiguration(migrationOptions);
- var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet();
- var migrationsToBeApplied = migrations.Where(e => !appliedMigrationIds.Contains(e.Id)).ToArray();
-
- string? migrationKey = null;
- if (jellyfinDatabaseProvider is not null && migrationsToBeApplied.Any(f => f is IDatabaseMigrationRoutine))
- {
- logger.LogInformation("Performing database backup");
- try
- {
- migrationKey = await jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false);
- logger.LogInformation("Database backup with key '{BackupKey}' has been successfully created.", migrationKey);
- }
- catch (NotImplementedException)
- {
- logger.LogWarning("Could not perform backup of database before migration because provider does not support it");
- }
- }
-
- List<IMigrationRoutine> databaseMigrations = [];
- try
- {
- foreach (var migrationRoutine in migrationsToBeApplied)
- {
- logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name);
- var isDbMigration = migrationRoutine is IDatabaseMigrationRoutine;
-
- if (isDbMigration)
- {
- databaseMigrations.Add(migrationRoutine);
- }
-
- 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);
- if (!isDbMigration)
- {
- migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name));
- saveConfiguration(migrationOptions);
- logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name);
- }
- }
- }
- catch (Exception) when (migrationKey is not null && jellyfinDatabaseProvider is not null)
- {
- if (databaseMigrations.Count != 0)
- {
- logger.LogInformation("Rolling back database as migrations reported failure.");
- await jellyfinDatabaseProvider.RestoreBackupFast(migrationKey, CancellationToken.None).ConfigureAwait(false);
- }
-
- throw;
- }
-
- foreach (var migrationRoutine in databaseMigrations)
- {
- 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 23c1b1ee6..000000000
--- 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 7a1ca6671..000000000
--- 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 8462d0a8c..fd472cff7 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 61f5620dc..0141b43c9 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 580282a5f..e8da9f515 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 09b292171..f2790c1a1 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs
@@ -11,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;
@@ -28,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
index 0a37b35a6..995b2bbf9 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.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-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;
@@ -26,15 +29,6 @@ public class RenameEnableGroupingIntoCollections : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("E73B777D-CD5C-4E71-957A-B86B3660B7CF");
-
- /// <inheritdoc />
- public string Name => nameof(RenameEnableGroupingIntoCollections);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "system.xml");
diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
index 2047ec743..00d152b4b 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 fc6b5d597..8c8398a16 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/CleanMusicArtist.cs b/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs
new file mode 100644
index 000000000..d5c5f3d92
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs
@@ -0,0 +1,47 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Cleans up all Music artists that have been migrated in the 10.11 RC migrations.
+/// </summary>
+[JellyfinMigration("2025-10-09T20:00:00", nameof(CleanMusicArtist))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class CleanMusicArtist : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<CleanMusicArtist> _startupLogger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CleanMusicArtist"/> class.
+ /// </summary>
+ /// <param name="startupLogger">The startup logger.</param>
+ /// <param name="dbContextFactory">The Db context factory.</param>
+ public CleanMusicArtist(IStartupLogger<CleanMusicArtist> startupLogger, IDbContextFactory<JellyfinDbContext> dbContextFactory)
+ {
+ _startupLogger = startupLogger;
+ _dbContextFactory = dbContextFactory;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ var peoples = context.Peoples.Where(e => e.PersonType == nameof(PersonKind.Artist) || e.PersonType == nameof(PersonKind.AlbumArtist));
+ _startupLogger.LogInformation("Delete {Number} Artist and Album Artist person types from db", await peoples.CountAsync(cancellationToken).ConfigureAwait(false));
+
+ await peoples
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
index 5a8ef2e1c..1326a6dc8 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 => "CreateLoggingConfigHierarchy";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var logDirectory = _appPaths.ConfigurationDirectoryPath;
diff --git a/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
new file mode 100644
index 000000000..6edfcbcfd
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to disable legacy authorization in the system config.
+/// </summary>
+[JellyfinMigration("2025-11-18T16:00:00", nameof(DisableLegacyAuthorization))]
+public class DisableLegacyAuthorization : IAsyncMigrationRoutine
+{
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
+ _serverConfigurationManager.SaveConfiguration();
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
index 378e88e25..acf2835fe 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 a20253369..05ded06ba 100644
--- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
@@ -16,9 +16,12 @@ 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")]
+ [JellyfinMigrationBackup(LegacyLibraryDb = true)]
internal class FixAudioData : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
- private const string DbFilename = "library.db";
private readonly ILogger<FixAudioData> _logger;
private readonly IServerApplicationPaths _applicationPaths;
private readonly IItemRepository _itemRepository;
@@ -34,40 +37,8 @@ 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);
-
- // Back up the database before modifying any entries
- for (int i = 1; ; i++)
- {
- var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
- if (!File.Exists(bakPath))
- {
- try
- {
- _logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
- File.Copy(dbPath, bakPath);
- _logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
- break;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
- throw;
- }
- }
- }
-
_logger.LogInformation("Backfilling audio lyrics data to database.");
var startIndex = 0;
var records = _itemRepository.GetCount(new InternalItemsQuery
diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/FixDates.cs
new file mode 100644
index 000000000..a5b11b11d
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixDates.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to fix dates saved in the database to always be UTC.
+/// </summary>
+[JellyfinMigration("2025-06-20T18:00:00", nameof(FixDates))]
+public class FixDates : IAsyncMigrationRoutine
+{
+ private const int PageSize = 5000;
+
+ private readonly ILogger _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FixDates"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI integration.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
+ public FixDates(
+ ILogger<FixDates> logger,
+ IStartupLogger<FixDates> startupLogger,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _logger = startupLogger.With(logger);
+ _dbProvider = dbProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ if (!TimeZoneInfo.Local.Equals(TimeZoneInfo.Utc))
+ {
+ var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ var sw = Stopwatch.StartNew();
+
+ await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false);
+ sw.Reset();
+ await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false);
+ sw.Reset();
+ await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task FixBaseItemsAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
+ {
+ int itemCount = 0;
+
+ var baseQuery = context.BaseItems.OrderBy(e => e.Id);
+ var records = baseQuery.Count();
+ _logger.LogInformation("Fixing dates for {Count} BaseItems.", records);
+
+ sw.Start();
+ await foreach (var result in context.BaseItems.OrderBy(e => e.Id)
+ .WithPartitionProgress(
+ (partition) =>
+ _logger.LogInformation(
+ "Processing BaseItems batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
+ partition + 1,
+ Math.Min((partition + 1) * PageSize, records),
+ records,
+ sw.Elapsed))
+ .PartitionEagerAsync(PageSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ result.DateCreated = ToUniversalTime(result.DateCreated);
+ result.DateLastMediaAdded = ToUniversalTime(result.DateLastMediaAdded);
+ result.DateLastRefreshed = ToUniversalTime(result.DateLastRefreshed);
+ result.DateLastSaved = ToUniversalTime(result.DateLastSaved);
+ result.DateModified = ToUniversalTime(result.DateModified);
+ itemCount++;
+ }
+
+ var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("BaseItems: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
+ }
+
+ private async Task FixChaptersAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
+ {
+ int itemCount = 0;
+
+ var baseQuery = context.Chapters;
+ var records = baseQuery.Count();
+ _logger.LogInformation("Fixing dates for {Count} Chapters.", records);
+
+ sw.Start();
+ await foreach (var result in context.Chapters.OrderBy(e => e.ItemId)
+ .WithPartitionProgress(
+ (partition) =>
+ _logger.LogInformation(
+ "Processing Chapter batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
+ partition + 1,
+ Math.Min((partition + 1) * PageSize, records),
+ records,
+ sw.Elapsed))
+ .PartitionEagerAsync(PageSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ result.ImageDateModified = ToUniversalTime(result.ImageDateModified, true);
+ itemCount++;
+ }
+
+ var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("Chapters: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
+ }
+
+ private async Task FixBaseItemImageInfos(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
+ {
+ int itemCount = 0;
+
+ var baseQuery = context.BaseItemImageInfos;
+ var records = baseQuery.Count();
+ _logger.LogInformation("Fixing dates for {Count} BaseItemImageInfos.", records);
+
+ sw.Start();
+ await foreach (var result in context.BaseItemImageInfos.OrderBy(e => e.Id)
+ .WithPartitionProgress(
+ (partition) =>
+ _logger.LogInformation(
+ "Processing BaseItemImageInfos batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
+ partition + 1,
+ Math.Min((partition + 1) * PageSize, records),
+ records,
+ sw.Elapsed))
+ .PartitionEagerAsync(PageSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ result.DateModified = ToUniversalTime(result.DateModified);
+ itemCount++;
+ }
+
+ var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("BaseItemImageInfos: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
+ }
+
+ private DateTime? ToUniversalTime(DateTime? dateTime, bool isUTC = false)
+ {
+ if (dateTime is null)
+ {
+ return null;
+ }
+
+ if (dateTime.Value.Year == 1 && dateTime.Value.Month == 1 && dateTime.Value.Day == 1)
+ {
+ return null;
+ }
+
+ if (dateTime.Value.Kind == DateTimeKind.Utc || isUTC)
+ {
+ return dateTime.Value;
+ }
+
+ return dateTime.Value.ToUniversalTime();
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
index 192c170b2..56614ece3 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 e9fe9abce..8c8563190 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
@@ -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)
@@ -61,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines
};
var dataPath = _paths.DataPath;
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ var activityLogPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(activityLogPath))
+ {
+ _logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath);
+ return;
+ }
+
+ using (var connection = new SqliteConnection($"Filename={activityLogPath}"))
{
connection.Open();
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath);
+ return;
+ }
+ }
using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}");
userDbConnection.Open();
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
index feaf46c84..0de775e03 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
@@ -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,21 +47,31 @@ 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;
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ var dbFilePath = Path.Combine(dataPath, DbFilename);
+
+ if (!File.Exists(dbFilePath))
+ {
+ _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
+ return;
+ }
+
+ using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
+
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath);
+ return;
+ }
+ }
+
using var dbContext = _dbProvider.CreateDbContext();
var authenticatedDevices = connection.Query("SELECT * FROM Tokens");
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
index a8fa2e52a..ffd06fea0 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
@@ -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 =
@@ -84,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines
var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
+
+ if (!File.Exists(dbFilePath))
+ {
+ _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
+ return;
+ }
+
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
+
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath);
+ return;
+ }
+ }
+
using var dbContext = _provider.CreateDbContext();
var results = connection.Query("SELECT * FROM userdisplaypreferences");
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
index b8e69db8e..aa5530926 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -10,10 +9,9 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions.Json;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -22,10 +20,10 @@ 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 ILibraryManager _libraryManager;
- private readonly ILogger<MoveTrickplayFiles> _logger;
+ private readonly IStartupLogger _logger;
private readonly IApplicationPaths _appPaths;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
@@ -33,18 +31,15 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
/// <summary>
/// Initializes a new instance of the <see cref="MigrateKeyframeData"/> class.
/// </summary>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- /// <param name="logger">The logger.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="dbProvider">The EFCore db factory.</param>
public MigrateKeyframeData(
- ILibraryManager libraryManager,
- ILogger<MoveTrickplayFiles> logger,
+ IStartupLogger<MigrateKeyframeData> startupLogger,
IApplicationPaths appPaths,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
- _libraryManager = libraryManager;
- _logger = logger;
+ _logger = startupLogger;
_appPaths = appPaths;
_dbProvider = dbProvider;
}
@@ -52,59 +47,41 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
private string KeyframeCachePath => Path.Combine(_appPaths.DataPath, "keyframes");
/// <inheritdoc />
- public Guid Id => new("EA4bCAE1-09A4-428E-9B90-4B4FD2EA1B24");
-
- /// <inheritdoc />
- public string Name => "MigrateKeyframeData";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
- const int Limit = 100;
- int itemCount = 0, offset = 0, previousCount;
+ const int Limit = 5000;
+ int itemCount = 0, offset = 0;
var sw = Stopwatch.StartNew();
- var itemsQuery = new InternalItemsQuery
- {
- MediaTypes = [MediaType.Video],
- SourceTypes = [SourceType.Library],
- IsVirtualItem = false,
- IsFolder = false
- };
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();
- List<KeyframeData> keyframes = [];
-
do
{
- var result = _libraryManager.GetItemsResult(itemsQuery);
- _logger.LogInformation("Importing keyframes for {Count} items", result.TotalRecordCount);
-
- var items = result.Items;
- previousCount = items.Count;
- offset += Limit;
- foreach (var item in items)
+ 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(item, out var data))
+ if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
{
- keyframes.Add(data);
+ itemCount++;
+ context.KeyframeData.Add(data);
}
+ }
- if (++itemCount % 10_000 == 0)
- {
- context.KeyframeData.AddRange(keyframes);
- keyframes.Clear();
- _logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed);
- }
+ offset += Limit;
+ if (offset > records)
+ {
+ offset = records;
}
- } while (previousCount == Limit);
- context.KeyframeData.AddRange(keyframes);
+ _logger.LogInformation("Checked: {Count} - Imported: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
+ } while (offset < records);
+
context.SaveChanges();
transaction.Commit();
@@ -116,10 +93,9 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
}
}
- private bool TryGetKeyframeData(BaseItem item, [NotNullWhen(true)] out KeyframeData? data)
+ private bool TryGetKeyframeData(Guid id, string? path, [NotNullWhen(true)] out KeyframeData? data)
{
data = null;
- var path = item.Path;
if (!string.IsNullOrEmpty(path))
{
var cachePath = GetCachePath(KeyframeCachePath, path);
@@ -127,7 +103,7 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
{
data = new()
{
- ItemId = item.Id,
+ ItemId = id,
KeyframeTicks = keyframeData.KeyframeTicks.ToList(),
TotalDuration = keyframeData.TotalDuration
};
@@ -146,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
{
lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
}
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
+ return null;
+ }
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
+ return null;
+ }
catch (IOException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
@@ -159,14 +145,21 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
return Path.Join(keyframeCachePath, prefix, filename);
}
- private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
+ private 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);
+ try
+ {
+ var bytes = File.ReadAllBytes(cachePath);
+ cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
- return cachedResult is not null;
+ return cachedResult is not null;
+ }
+ catch (JsonException jsonException)
+ {
+ _logger.LogWarning(jsonException, "Failed to read {Path}", cachePath);
+ }
}
cachedResult = null;
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index 105fd555f..d221d1853 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -9,12 +9,12 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
-using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
@@ -29,11 +29,13 @@ 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))]
+[JellyfinMigrationBackup(JellyfinDb = true, LegacyLibraryDb = true)]
internal class MigrateLibraryDb : IDatabaseMigrationRoutine
{
private const string DbFilename = "library.db";
- private readonly ILogger<MigrateLibraryDb> _logger;
+ private readonly IStartupLogger _logger;
private readonly IServerApplicationPaths _paths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly IDbContextFactory<JellyfinDbContext> _provider;
@@ -41,38 +43,35 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
/// <summary>
/// Initializes a new instance of the <see cref="MigrateLibraryDb"/> class.
/// </summary>
- /// <param name="logger">The logger.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
/// <param name="provider">The database provider.</param>
/// <param name="paths">The server application paths.</param>
/// <param name="jellyfinDatabaseProvider">The database provider for special access.</param>
public MigrateLibraryDb(
- ILogger<MigrateLibraryDb> logger,
+ IStartupLogger<MigrateLibraryDb> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
{
- _logger = logger;
+ _logger = startupLogger;
_provider = provider;
_paths = paths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("36445464-849f-429f-9ad0-bb130efa0664");
-
- /// <inheritdoc/>
- public string Name => "MigrateLibraryDbData";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false; // TODO Change back after testing
-
- /// <inheritdoc/>
public void Perform()
{
_logger.LogInformation("Migrating the userdata from library.db may take a while, do not stop Jellyfin.");
var dataPath = _paths.DataPath;
var libraryDbPath = Path.Combine(dataPath, DbFilename);
+ 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();
@@ -91,12 +90,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
operation.JellyfinDbContext.AncestorIds.ExecuteDelete();
}
+ // notify the other migration to just silently abort because the fix has been applied here already.
+ ReseedFolderFlag.RerunGuardFlag = true;
+
var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
connection.Open();
var baseItemIds = new HashSet<Guid>();
- using (var operation = GetPreparedDbContext("moving TypedBaseItem"))
+ using (var operation = GetPreparedDbContext("Moving TypedBaseItem"))
{
+ IDictionary<Guid, (BaseItemEntity BaseItem, string[] Keys)> allItemsLookup = new Dictionary<Guid, (BaseItemEntity BaseItem, string[] Keys)>();
const string typedBaseItemsQuery =
"""
SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie,
@@ -106,29 +109,68 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
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
+ ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType, IsFolder 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)
+ allItemsLookup.Add(baseItem.BaseItem.Id, baseItem);
+ }
+ }
+
+ bool DoesResolve(Guid? parentId, HashSet<(BaseItemEntity BaseItem, string[] Keys)> checkStack)
+ {
+ if (parentId is null)
+ {
+ return true;
+ }
+
+ if (!allItemsLookup.TryGetValue(parentId.Value, out var parent))
+ {
+ return false; // item is detached and has no root anymore.
+ }
+
+ if (!checkStack.Add(parent))
+ {
+ return false; // recursive structure. Abort.
+ }
+
+ return DoesResolve(parent.BaseItem.ParentId, checkStack);
+ }
+
+ using (new TrackedMigrationStep("Clean TypedBaseItems hierarchy", _logger))
+ {
+ var checkStack = new HashSet<(BaseItemEntity BaseItem, string[] Keys)>();
+
+ foreach (var item in allItemsLookup)
+ {
+ var cachedItem = item.Value;
+ if (DoesResolve(cachedItem.BaseItem.ParentId, checkStack))
{
- legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem;
+ checkStack.Add(cachedItem);
+ operation.JellyfinDbContext.BaseItems.Add(cachedItem.BaseItem);
+ baseItemIds.Add(cachedItem.BaseItem.Id);
+ foreach (var dataKey in cachedItem.Keys)
+ {
+ legacyBaseItemWithUserKeys[dataKey] = cachedItem.BaseItem;
+ }
}
+
+ checkStack.Clear();
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
+
+ allItemsLookup.Clear();
}
- using (var operation = GetPreparedDbContext("moving ItemValues"))
+ using (var operation = GetPreparedDbContext("Moving ItemValues"))
{
// do not migrate inherited types as they are now properly mapped in search and lookup.
const string itemValueQuery =
@@ -139,11 +181,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
// 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))
+ using (new TrackedMigrationStep("Loading ItemValues", _logger))
{
foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
{
var itemId = dto.GetGuid(0);
+ if (!baseItemIds.Contains(itemId))
+ {
+ continue;
+ }
+
var entity = GetItemValue(dto);
var key = ((int)entity.Type, entity.Value);
if (!localItems.TryGetValue(key, out var existing))
@@ -167,13 +214,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving UserData"))
+ using (var operation = GetPreparedDbContext("Moving UserData"))
{
var queryResult = connection.Query(
"""
@@ -182,14 +229,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
""");
- using (new TrackedMigrationStep("loading UserData", _logger))
+ using (new TrackedMigrationStep("Loading UserData", _logger))
{
- var users = operation.JellyfinDbContext.Users.AsNoTracking().ToImmutableArray();
+ var users = operation.JellyfinDbContext.Users.AsNoTracking().ToArray();
var userIdBlacklist = new HashSet<int>();
foreach (var entity in queryResult)
{
- var userData = GetUserData(users, entity, userIdBlacklist);
+ var userData = GetUserData(users, entity, userIdBlacklist, _logger);
if (userData is null)
{
var userDataId = entity.GetString(0);
@@ -210,22 +257,25 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
continue;
}
+ if (!baseItemIds.Contains(refItem.Id))
+ {
+ 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))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving MediaStreamInfos"))
+ using (var operation = GetPreparedDbContext("Moving MediaStreamInfos"))
{
const string mediaStreamQuery =
"""
@@ -238,21 +288,27 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId)
""";
- using (new TrackedMigrationStep("loading MediaStreamInfos", _logger))
+ using (new TrackedMigrationStep("Loading MediaStreamInfos", _logger))
{
foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
{
- operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto));
+ var entity = GetMediaStream(dto);
+ if (!baseItemIds.Contains(entity.ItemId))
+ {
+ continue;
+ }
+
+ operation.JellyfinDbContext.MediaStreamInfos.Add(entity);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos"))
+ using (var operation = GetPreparedDbContext("Moving AttachmentStreamInfos"))
{
const string mediaAttachmentQuery =
"""
@@ -261,45 +317,51 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
""";
- using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger))
+ using (new TrackedMigrationStep("Loading AttachmentStreamInfos", _logger))
{
foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
{
- operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto));
+ var entity = GetMediaAttachment(dto);
+ if (!baseItemIds.Contains(entity.ItemId))
+ {
+ continue;
+ }
+
+ operation.JellyfinDbContext.AttachmentStreamInfos.Add(entity);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving People"))
+ using (var operation = GetPreparedDbContext("Moving People"))
{
const string personsQuery =
"""
- SELECT ItemId, Name, Role, PersonType, SortOrder FROM People
+ SELECT ItemId, Name, Role, PersonType, SortOrder, ListOrder 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))
+ 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));
+ _logger.LogError("Not saving person {0} because it's not in use by any BaseItem", reader.GetString(1));
continue;
}
var entity = GetPerson(reader);
- if (!peopleCache.TryGetValue(entity.Name, out var personCache))
+ if (!peopleCache.TryGetValue(entity.Name + "|" + entity.PersonType, out var personCache))
{
- peopleCache[entity.Name] = personCache = (entity, []);
+ peopleCache[entity.Name + "|" + entity.PersonType] = personCache = (entity, []);
}
if (reader.TryGetString(2, out var role))
@@ -307,6 +369,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4);
+ int? listOrder = reader.IsDBNull(5) ? null : reader.GetInt32(5);
personCache.Items.Add(new PeopleBaseItemMap()
{
@@ -314,14 +377,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
ItemId = itemId,
People = null!,
PeopleId = personCache.Person.Id,
- ListOrder = sortOrder,
+ ListOrder = listOrder,
SortOrder = sortOrder,
Role = role
});
}
- baseItemIds.Clear();
-
foreach (var item in peopleCache)
{
operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
@@ -331,13 +392,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
peopleCache.Clear();
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger))
+ 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"))
+ using (var operation = GetPreparedDbContext("Moving Chapters"))
{
const string chapterQuery =
"""
@@ -345,22 +406,27 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId)
""";
- using (new TrackedMigrationStep("loading Chapters", _logger))
+ using (new TrackedMigrationStep("Loading Chapters", _logger))
{
foreach (SqliteDataReader dto in connection.Query(chapterQuery))
{
var chapter = GetChapter(dto);
+ if (!baseItemIds.Contains(chapter.ItemId))
+ {
+ continue;
+ }
+
operation.JellyfinDbContext.Chapters.Add(chapter);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving AncestorIds"))
+ using (var operation = GetPreparedDbContext("Moving AncestorIds"))
{
const string ancestorIdsQuery =
"""
@@ -371,16 +437,21 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId)
""";
- using (new TrackedMigrationStep("loading AncestorIds", _logger))
+ using (new TrackedMigrationStep("Loading AncestorIds", _logger))
{
foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
{
var ancestorId = GetAncestorId(dto);
+ if (!baseItemIds.Contains(ancestorId.ItemId) || !baseItemIds.Contains(ancestorId.ParentItemId))
+ {
+ continue;
+ }
+
operation.JellyfinDbContext.AncestorIds.Add(ancestorId);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
@@ -395,8 +466,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
File.Move(libraryDbPath, libraryDbPath + ".old", true);
-
- _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
}
private DatabaseMigrationStep GetPreparedDbContext(string operationName)
@@ -407,19 +476,20 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
return new DatabaseMigrationStep(dbContext, operationName, _logger);
}
- private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto, HashSet<int> userIdBlacklist)
+ internal static UserData? GetUserData(User[] users, SqliteDataReader dto, HashSet<int> userIdBlacklist, ILogger logger)
{
var internalUserId = dto.GetInt32(1);
- var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
+ if (userIdBlacklist.Contains(internalUserId))
+ {
+ return null;
+ }
+ var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
if (user is null)
{
- if (userIdBlacklist.Contains(internalUserId))
- {
- return null;
- }
+ userIdBlacklist.Add(internalUserId);
- _logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length);
+ logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length);
return null;
}
@@ -1087,12 +1157,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var providerIds))
{
- entity.Provider = providerIds.Split('|').Select(e => e.Split("="))
+ entity.Provider = providerIds.Split('|').Select(e => e.Split("=")).Where(e => e.Length >= 2)
.Select(e => new BaseItemProvider()
{
Item = null!,
ProviderId = e[0],
- ProviderValue = e[1]
+ ProviderValue = string.Join('|', e.Skip(1))
}).ToArray();
}
@@ -1171,7 +1241,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.UnratedType = unratedType;
}
- var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false);
+ if (reader.TryGetBoolean(index++, out var isFolder))
+ {
+ entity.IsFolder = isFolder;
+ }
+
+ var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
var dataKeys = baseItem.GetUserDataKeys();
userDataKeys.AddRange(dataKeys);
@@ -1185,7 +1260,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
ItemId = baseItemId,
Id = Guid.NewGuid(),
Path = e.Path,
- Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
+ Blurhash = e.BlurHash is not null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
DateModified = e.DateModified,
Height = e.Height,
Width = e.Width,
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs
new file mode 100644
index 000000000..d4cc9bbee
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs
@@ -0,0 +1,73 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// The migration routine for checking if the current instance of Jellyfin is compatiable to be upgraded.
+/// </summary>
+[JellyfinMigration("2025-04-20T19:30:00", nameof(MigrateLibraryDbCompatibilityCheck))]
+public class MigrateLibraryDbCompatibilityCheck : IAsyncMigrationRoutine
+{
+ private const string DbFilename = "library.db";
+ private readonly IStartupLogger _logger;
+ private readonly IServerApplicationPaths _paths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MigrateLibraryDbCompatibilityCheck"/> class.
+ /// </summary>
+ /// <param name="startupLogger">The startup logger.</param>
+ /// <param name="paths">The Path service.</param>
+ public MigrateLibraryDbCompatibilityCheck(IStartupLogger<MigrateLibraryDbCompatibilityCheck> startupLogger, IServerApplicationPaths paths)
+ {
+ _logger = startupLogger;
+ _paths = paths;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ 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");
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ CheckMigratableVersion(connection);
+ await connection.CloseAsync().ConfigureAwait(false);
+ }
+
+ private static void CheckMigratableVersion(SqliteConnection connection)
+ {
+ CheckColumnExistance(connection, "TypedBaseItems", "lufs");
+ CheckColumnExistance(connection, "TypedBaseItems", "normalizationgain");
+ CheckColumnExistance(connection, "mediastreams", "dvversionmajor");
+
+ static void CheckColumnExistance(SqliteConnection connection, string table, string column)
+ {
+ using (var cmd = connection.CreateCommand())
+ {
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+ cmd.CommandText = $"Select COUNT(1) FROM pragma_table_xinfo('{table}') WHERE lower(name) = '{column}';";
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+ var result = cmd.ExecuteScalar()!;
+ if (!result.Equals(1L))
+ {
+ throw new InvalidOperationException("Your database does not meet the required standard. Only upgrades from server version 10.9.11 or above are supported. Please upgrade first to server version 10.10.7 before attempting to upgrade afterwards to 10.11");
+ }
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs
new file mode 100644
index 000000000..8a0a1741f
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs
@@ -0,0 +1,123 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+[JellyfinMigration("2025-06-18T01:00:00", nameof(MigrateLibraryUserData))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class MigrateLibraryUserData : IAsyncMigrationRoutine
+{
+ private const string DbFilename = "library.db.old";
+
+ private readonly IStartupLogger _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+
+ public MigrateLibraryUserData(
+ IStartupLogger<MigrateLibraryDb> startupLogger,
+ IDbContextFactory<JellyfinDbContext> provider,
+ IServerApplicationPaths paths)
+ {
+ _logger = startupLogger;
+ _provider = provider;
+ _paths = paths;
+ }
+
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Migrating the userdata from library.db.old 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 userdata from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
+ return;
+ }
+
+ var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ if (!await dbContext.BaseItems.AnyAsync(e => e.Id == BaseItemRepository.PlaceholderId, cancellationToken).ConfigureAwait(false))
+ {
+ // the placeholder baseitem has been deleted by the librarydb migration so we need to readd it.
+ await dbContext.BaseItems.AddAsync(
+ new Database.Implementations.Entities.BaseItemEntity()
+ {
+ Id = BaseItemRepository.PlaceholderId,
+ Type = "PLACEHOLDER",
+ Name = "This is a placeholder item for UserData that has been detacted from its original item"
+ },
+ cancellationToken)
+ .ConfigureAwait(false);
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var users = dbContext.Users.AsNoTracking().ToArray();
+ var userIdBlacklist = new HashSet<int>();
+ using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
+ var retentionDate = DateTime.UtcNow;
+
+ var queryResult = connection.Query(
+"""
+ SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas
+
+ WHERE NOT EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
+""");
+
+ var importedUserData = new Dictionary<Guid, List<UserData>>();
+ foreach (var entity in queryResult)
+ {
+ var userData = MigrateLibraryDb.GetUserData(users, entity, userIdBlacklist, _logger);
+ 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;
+ }
+
+ var ogId = userData.ItemId;
+ userData.ItemId = BaseItemRepository.PlaceholderId;
+ userData.RetentionDate = retentionDate;
+ if (!importedUserData.TryGetValue(ogId, out var importUserData))
+ {
+ importUserData = [];
+ importedUserData[ogId] = importUserData;
+ }
+
+ importUserData.Add(userData);
+ }
+
+ foreach (var item in importedUserData)
+ {
+ await dbContext.UserData.Where(e => e.ItemId == item.Key).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ dbContext.UserData.AddRange(item.Value.DistinctBy(e => e.CustomDataKey)); // old userdata can have fucked up duplicates
+ }
+
+ _logger.LogInformation("Try saving {NewSaved} UserData entries.", dbContext.UserData.Local.Count);
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
index c38beb723..2a6db01cf 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
@@ -1,74 +1,69 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.Server.Migrations.Routines
-{
- /// <summary>
- /// Migrate rating levels.
- /// </summary>
- internal class MigrateRatingLevels : IDatabaseMigrationRoutine
- {
- private readonly ILogger<MigrateRatingLevels> _logger;
- private readonly IDbContextFactory<JellyfinDbContext> _provider;
- private readonly ILocalizationManager _localizationManager;
+namespace Jellyfin.Server.Migrations.Routines;
- public MigrateRatingLevels(
- IDbContextFactory<JellyfinDbContext> provider,
- ILoggerFactory loggerFactory,
- ILocalizationManager localizationManager)
- {
- _provider = provider;
- _localizationManager = localizationManager;
- _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
- }
-
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("{98724538-EB11-40E3-931A-252C55BDDE7A}");
-
- /// <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))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+#pragma warning restore CS0618 // Type or member is obsolete
+internal class MigrateRatingLevels : IDatabaseMigrationRoutine
+{
+ private readonly IStartupLogger _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+ private readonly ILocalizationManager _localizationManager;
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ public MigrateRatingLevels(
+ IDbContextFactory<JellyfinDbContext> provider,
+ IStartupLogger<MigrateRatingLevels> logger,
+ ILocalizationManager localizationManager)
+ {
+ _provider = provider;
+ _localizationManager = localizationManager;
+ _logger = logger;
+ }
- /// <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)
{
- _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)
+ if (string.IsNullOrEmpty(rating))
{
- if (string.IsNullOrEmpty(rating))
- {
- 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));
- }
- else
- {
- 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));
- }
+ 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));
+ }
+ else
+ {
+ 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();
}
+
+ transaction.Commit();
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
index 1b5fab7a8..8c3361ee1 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
@@ -17,207 +17,217 @@ 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)
+ {
+ _logger = logger;
+ _paths = paths;
+ _provider = provider;
+ _xmlSerializer = xmlSerializer;
+ }
+
+ /// <inheritdoc/>
+ public void Perform()
{
- 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)
+ var dataPath = _paths.DataPath;
+ var userDbPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(userDbPath))
{
- _logger = logger;
- _paths = paths;
- _provider = provider;
- _xmlSerializer = xmlSerializer;
+ _logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
+ return;
}
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("5C4B82A2-F053-4009-BD05-B6FCAD82F14C");
+ _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={userDbPath}"))
+ {
+ connection.Open();
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
+ return;
+ }
+ }
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ using var dbContext = _provider.CreateDbContext();
- /// <inheritdoc/>
- public void Perform()
- {
- var dataPath = _paths.DataPath;
- _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
+ var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
- {
- connection.Open();
- using var dbContext = _provider.CreateDbContext();
+ dbContext.RemoveRange(dbContext.Users);
+ dbContext.SaveChanges();
- var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
+ foreach (var entry in queryResult)
+ {
+ 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),
- 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
+ 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
index f63c5fd40..fbf9c1637 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
@@ -1,20 +1,24 @@
#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 System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
@@ -22,34 +26,37 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to move extracted files to the new directories.
/// </summary>
-public class MoveExtractedFiles : IDatabaseMigrationRoutine
+[JellyfinMigration("2025-04-20T21:00:00", nameof(MoveExtractedFiles))]
+public class MoveExtractedFiles : IAsyncMigrationRoutine
{
private readonly IApplicationPaths _appPaths;
- private readonly ILibraryManager _libraryManager;
- private readonly ILogger<MoveExtractedFiles> _logger;
- private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly ILogger _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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="logger">The logger.</param>
- /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</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,
- ILibraryManager libraryManager,
ILogger<MoveExtractedFiles> logger,
- IMediaSourceManager mediaSourceManager,
- IPathManager pathManager)
+ IStartupLogger<MoveExtractedFiles> startupLogger,
+ IPathManager pathManager,
+ IFileSystem fileSystem,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
{
_appPaths = appPaths;
- _libraryManager = libraryManager;
- _logger = logger;
- _mediaSourceManager = mediaSourceManager;
+ _logger = startupLogger.With(logger);
_pathManager = pathManager;
+ _fileSystem = fileSystem;
+ _dbProvider = dbProvider;
}
private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
@@ -57,62 +64,43 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
/// <inheritdoc />
- public Guid Id => new("9063b0Ef-CFF1-4EDC-9A13-74093681A89B");
-
- /// <inheritdoc />
- public string Name => "MoveExtractedFiles";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
- public void Perform()
+ public async Task PerformAsync(CancellationToken cancellationToken)
{
- const int Limit = 500;
- int itemCount = 0, offset = 0;
+ const int Limit = 5000;
+ int itemCount = 0;
var sw = Stopwatch.StartNew();
- var itemsQuery = new InternalItemsQuery
- {
- MediaTypes = [MediaType.Video],
- SourceTypes = [SourceType.Library],
- IsVirtualItem = false,
- IsFolder = false,
- Limit = Limit,
- StartIndex = offset,
- EnableTotalRecordCount = true,
- };
-
- var records = _libraryManager.GetItemsResult(itemsQuery).TotalRecordCount;
+
+ 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);
- itemsQuery.EnableTotalRecordCount = false;
- do
+ await foreach (var result in 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)
+ .Select(b => new
+ {
+ b.Id,
+ b.Path,
+ b.MediaStreams
+ })
+ .OrderBy(e => e.Id)
+ .WithPartitionProgress((partition) => _logger.LogInformation("Checked: {Count} - Moved: {Items} - Time: {Time}", partition * Limit, itemCount, sw.Elapsed))
+ .PartitionEagerAsync(Limit, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
{
- itemsQuery.StartIndex = offset;
- var result = _libraryManager.GetItemsResult(itemsQuery);
-
- var items = result.Items;
- foreach (var item in items)
+ if (MoveSubtitleAndAttachmentFiles(result.Id, result.Path, result.MediaStreams, context))
{
- if (MoveSubtitleAndAttachmentFiles(item))
- {
- itemCount++;
- }
+ itemCount++;
}
+ }
- offset += Limit;
- if (offset % 5_000 == 0)
- {
- _logger.LogInformation("Checked extracted files for {Count} items in {Time}.", offset, sw.Elapsed);
- }
- } while (offset < records);
-
- _logger.LogInformation("Checked {Checked} items - Moved files for {Items} items in {Time}.", records, itemCount, sw.Elapsed);
+ _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();
@@ -134,52 +122,56 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
_logger.LogInformation("Cleaned up left over subtitles and attachments.");
}
- private bool MoveSubtitleAndAttachmentFiles(BaseItem item)
+ private bool MoveSubtitleAndAttachmentFiles(Guid id, string? path, ICollection<MediaStreamInfo>? mediaStreams, JellyfinDbContext context)
{
- var mediaStreams = item.GetMediaStreams().Where(s => s.Type == MediaStreamType.Subtitle && !s.IsExternal);
- var itemIdString = item.Id.ToString("N", CultureInfo.InvariantCulture);
+ var itemIdString = id.ToString("N", CultureInfo.InvariantCulture);
var modified = false;
- foreach (var mediaStream in mediaStreams)
+ if (mediaStreams is not null)
{
- if (mediaStream.Codec is null)
+ foreach (var mediaStream in mediaStreams)
{
- continue;
- }
-
- var mediaStreamIndex = mediaStream.Index;
- var extension = GetSubtitleExtension(mediaStream.Codec);
- var oldSubtitleCachePath = GetOldSubtitleCachePath(item.Path, mediaStream.Index, extension);
- if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath))
- {
- continue;
- }
+ if (mediaStream.Codec is null)
+ {
+ 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)
+ var mediaStreamIndex = mediaStream.StreamIndex;
+ var extension = GetSubtitleExtension(mediaStream.Codec);
+ var oldSubtitleCachePath = GetOldSubtitleCachePath(path, mediaStreamIndex, extension);
+ if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath))
{
- Directory.CreateDirectory(newDirectory);
- File.Move(oldSubtitleCachePath, newSubtitleCachePath, false);
- _logger.LogDebug("Moved subtitle {Index} for {Item} from {Source} to {Destination}", mediaStreamIndex, item.Id, oldSubtitleCachePath, newSubtitleCachePath);
+ continue;
+ }
- modified = true;
+ 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;
+ }
}
}
}
- var attachments = _mediaSourceManager.GetMediaAttachments(item.Id).Where(a => !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)).ToList();
- var shouldExtractOneByOne = attachments.Any(a => !string.IsNullOrEmpty(a.FileName)
- && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
+#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(item.Path, attachmentIndex);
+ var oldAttachmentPath = GetOldAttachmentDataPath(path, attachmentIndex);
if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
{
oldAttachmentPath = GetOldAttachmentCachePath(itemIdString, attachment, shouldExtractOneByOne);
@@ -189,7 +181,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
}
}
- var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.FileName ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
+ var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
if (File.Exists(newAttachmentPath))
{
File.Delete(oldAttachmentPath);
@@ -201,7 +193,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
{
Directory.CreateDirectory(newDirectory);
File.Move(oldAttachmentPath, newAttachmentPath, false);
- _logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, item.Id, oldAttachmentPath, newAttachmentPath);
+ _logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, id, oldAttachmentPath, newAttachmentPath);
modified = true;
}
@@ -219,8 +211,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
}
string filename;
- var protocol = _mediaSourceManager.GetPathProtocol(mediaPath);
- if (protocol == MediaProtocol.File)
+ if (_fileSystem.IsPathFile(mediaPath))
{
DateTime? date;
try
@@ -233,6 +224,18 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
return null;
}
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
+
+ return null;
+ }
+ catch (ArgumentOutOfRangeException 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);
}
@@ -244,7 +247,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
return Path.Join(_appPaths.DataPath, "attachments", filename[..1], filename);
}
- private string? GetOldAttachmentCachePath(string mediaSourceId, MediaAttachment attachment, bool shouldExtractOneByOne)
+ private string? GetOldAttachmentCachePath(string mediaSourceId, AttachmentStreamInfo attachment, bool shouldExtractOneByOne)
{
var attachmentFolderPath = Path.Join(_appPaths.CachePath, "attachments", mediaSourceId);
if (shouldExtractOneByOne)
@@ -252,21 +255,38 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
return Path.Join(attachmentFolderPath, attachment.Index.ToString(CultureInfo.InvariantCulture));
}
- if (string.IsNullOrEmpty(attachment.FileName))
+ if (string.IsNullOrEmpty(attachment.Filename))
{
return null;
}
- return Path.Join(attachmentFolderPath, attachment.FileName);
+ return Path.Join(attachmentFolderPath, attachment.Filename);
}
- private string? GetOldSubtitleCachePath(string path, int streamIndex, string outputSubtitleExtension)
+ private string? GetOldSubtitleCachePath(string? path, int streamIndex, string outputSubtitleExtension)
{
+ if (path is null)
+ {
+ return null;
+ }
+
DateTime? date;
try
{
date = File.GetLastWriteTimeUtc(path);
}
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
catch (IOException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
index eeb11e14c..0f55465e8 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Trickplay;
@@ -15,12 +16,15 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to move trickplay files to the new directory.
/// </summary>
-public class MoveTrickplayFiles : IDatabaseMigrationRoutine
+#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;
private readonly ILibraryManager _libraryManager;
- private readonly ILogger<MoveTrickplayFiles> _logger;
+ private readonly IStartupLogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MoveTrickplayFiles"/> class.
@@ -29,7 +33,11 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
/// <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,
+ IStartupLogger<MoveTrickplayFiles> logger)
{
_trickplayManager = trickplayManager;
_fileSystem = fileSystem;
@@ -38,18 +46,9 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => new("9540D44A-D8DC-11EF-9CBB-B77274F77C52");
-
- /// <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();
@@ -64,9 +63,6 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
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)
@@ -77,24 +73,32 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
continue;
}
- if (++itemCount % 1_000 == 0)
- {
- _logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed);
- }
-
+ 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))
{
_fileSystem.MoveDirectory(oldPath, newPath);
+ moved = true;
}
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);
diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
index 9cfaec46f..ebf4a2780 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 000000000..b23a7dbc4
--- /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 52fb93d59..b626c473e 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 7b0d9456d..c9e66d0cf 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 e183a1d63..23f212424 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
@@ -11,7 +11,10 @@ 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 ILibraryManager _libraryManager;
private readonly IPlaylistManager _playlistManager;
@@ -25,15 +28,6 @@ internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
}
/// <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/ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs
new file mode 100644
index 000000000..502763ac0
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs
@@ -0,0 +1,74 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+[JellyfinMigration("2025-07-30T21:50:00", nameof(ReseedFolderFlag))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class ReseedFolderFlag : IAsyncMigrationRoutine
+{
+ private const string DbFilename = "library.db.old";
+
+ private readonly IStartupLogger _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+
+ public ReseedFolderFlag(
+ IStartupLogger<MigrateLibraryDb> startupLogger,
+ IDbContextFactory<JellyfinDbContext> provider,
+ IServerApplicationPaths paths)
+ {
+ _logger = startupLogger;
+ _provider = provider;
+ _paths = paths;
+ }
+
+ internal static bool RerunGuardFlag { get; set; } = false;
+
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ if (RerunGuardFlag)
+ {
+ _logger.LogInformation("Migration is skipped because it does not apply.");
+ return;
+ }
+
+ _logger.LogInformation("Migrating the IsFolder flag from library.db.old 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 IsFolder flag from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
+ return;
+ }
+
+ var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
+ var queryResult = connection.Query(
+ """
+ SELECT guid FROM TypedBaseItems
+ WHERE IsFolder = true
+ """)
+ .Select(entity => entity.GetGuid(0))
+ .ToList();
+ _logger.LogInformation("Migrating the IsFolder flag for {Count} items.", queryResult.Count);
+ foreach (var id in queryResult)
+ {
+ await dbContext.BaseItems.Where(e => e.Id == id).ExecuteUpdateAsync(e => e.SetProperty(f => f.IsFolder, true), cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
index 7e8c8ac87..f58cf2741 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 000000000..971b47608
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Stages;
+
+internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute metadata, JellyfinMigrationBackupAttribute? migrationBackupAttribute)
+{
+ public Type MigrationType { get; } = migrationType;
+
+ public JellyfinMigrationAttribute Metadata { get; } = metadata;
+
+ public JellyfinMigrationBackupAttribute? BackupRequirements { get; set; } = migrationBackupAttribute;
+
+ public string BuildCodeMigrationId()
+ {
+ return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
+ }
+
+ private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
+ {
+ var childServiceCollection = new ServiceCollection()
+ .AddSingleton(serviceProvider)
+ .AddSingleton(logger)
+ .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>))
+ .AddSingleton<StartupLogTopic>(logger.Topic!);
+
+ foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>())
+ {
+ if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition)
+ {
+ childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!);
+ continue;
+ }
+
+ childServiceCollection.Add(service);
+ }
+
+ return childServiceCollection;
+ }
+
+ public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, 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
+ {
+ using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
+ ((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, 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
+ {
+ using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
+ await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, 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");
+ }
+ }
+
+ private class NestedStartupLogger<TCategory> : StartupLogger<TCategory>
+ {
+ public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic)
+ {
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs b/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs
new file mode 100644
index 000000000..3d5ec233b
--- /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>
+ CoreInitialisation = 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 000000000..efcadbf00
--- /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 8d0bf73f6..93f71fdc6 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -9,16 +9,22 @@ 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.DatabaseConfiguration;
+using Jellyfin.Server.Implementations.Extensions;
using Jellyfin.Server.Implementations.StorageHelpers;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using Jellyfin.Server.Migrations;
+using Jellyfin.Server.Migrations.Stages;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -54,6 +60,8 @@ namespace Jellyfin.Server
private static long _startTimestamp;
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
+ private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
+ private static string? _restoreFromBackup;
/// <summary>
/// The entry point of the application.
@@ -75,8 +83,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);
@@ -89,10 +99,11 @@ namespace Jellyfin.Server
// Create an instance of the application configuration to use for application startup
IConfiguration startupConfig = CreateAppConfiguration(options, appPaths);
+ StartupHelpers.InitializeLoggingFramework(startupConfig, 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");
+ StartupLogger.Logger = new StartupLogger(_logger);
// Use the logging framework for uncaught exceptions instead of std error
AppDomain.CurrentDomain.UnhandledException += (_, e)
@@ -122,10 +133,11 @@ namespace Jellyfin.Server
}
}
- StorageHelper.TestCommonPathsForStorageCapacity(appPaths, _loggerFactory.CreateLogger<Startup>());
+ StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
StartupHelpers.PerformStaticInitialization();
- await Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory).ConfigureAwait(false);
+
+ await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false);
do
{
@@ -150,6 +162,7 @@ namespace Jellyfin.Server
options,
startupConfig);
_appHost = appHost;
+ var configurationCompleted = false;
try
{
_jellyfinHost = Host.CreateDefaultBuilder()
@@ -166,16 +179,40 @@ namespace Jellyfin.Server
})
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
.UseSerilog()
+ .ConfigureServices(e => e
+ .RegisterStartupLogger()
+ .AddSingleton<IServiceCollection>(e))
.Build();
+ /*
+ * Initialize the transcode path marker so we avoid starting Jellyfin in a broken state.
+ * This should really be a part of IApplicationPaths but this path is configured differently.
+ */
+ _ = appHost.ConfigurationManager.GetTranscodePath();
+
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
appHost.ServiceProvider = _jellyfinHost.Services;
+ PrepareDatabaseProvider(appHost.ServiceProvider);
+
+ if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
+ {
+ await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
+ _restoreFromBackup = null;
+ _restartOnShutdown = true;
+ return;
+ }
+
+ var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
+ await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
+ await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
- await Migrations.MigrationRunner.Run(appHost, _loggerFactory).ConfigureAwait(false);
+ await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
+ await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
try
{
+ configurationCompleted = true;
await _setupServer!.StopAsync().ConfigureAwait(false);
await _jellyfinHost.StartAsync().ConfigureAwait(false);
@@ -198,11 +235,18 @@ namespace Jellyfin.Server
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
_restartOnShutdown = appHost.ShouldRestart;
+ _restoreFromBackup = appHost.RestoreBackupPath;
}
catch (Exception ex)
{
_restartOnShutdown = false;
_logger.LogCritical(ex, "Error while starting server");
+ if (_setupServer!.IsAlive && !configurationCompleted)
+ {
+ _setupServer!.SoftStop();
+ await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
+ await _setupServer!.StopAsync().ConfigureAwait(false);
+ }
}
finally
{
@@ -223,6 +267,52 @@ namespace Jellyfin.Server
}
/// <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)
+ {
+ _migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
+ 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)
+ .RegisterStartupLogger();
+
+ migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
+ 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, _migrationLogger!);
+ 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>
@@ -256,5 +346,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/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
new file mode 100644
index 000000000..e7c193936
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
@@ -0,0 +1,66 @@
+using System;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Defines the Startup Logger. This logger acts an an aggregate logger that will push though all log messages to both the attached logger as well as the startup UI.
+/// </summary>
+public interface IStartupLogger : ILogger
+{
+ /// <summary>
+ /// Gets the topic this logger is assigned to.
+ /// </summary>
+ StartupLogTopic? Topic { get; }
+
+ /// <summary>
+ /// Adds another logger instance to this logger for combined logging.
+ /// </summary>
+ /// <param name="logger">Other logger to rely messages to.</param>
+ /// <returns>A combined logger.</returns>
+ IStartupLogger With(ILogger logger);
+
+ /// <summary>
+ /// Opens a new Group logger within the parent logger.
+ /// </summary>
+ /// <param name="logEntry">Defines the log message that introduces the new group.</param>
+ /// <returns>A new logger that can write to the group.</returns>
+ IStartupLogger BeginGroup(FormattableString logEntry);
+
+ /// <summary>
+ /// Adds another logger instance to this logger for combined logging.
+ /// </summary>
+ /// <param name="logger">Other logger to rely messages to.</param>
+ /// <returns>A combined logger.</returns>
+ /// <typeparam name="TCategory">The logger cateogry.</typeparam>
+ IStartupLogger<TCategory> With<TCategory>(ILogger logger);
+
+ /// <summary>
+ /// Opens a new Group logger within the parent logger.
+ /// </summary>
+ /// <param name="logEntry">Defines the log message that introduces the new group.</param>
+ /// <returns>A new logger that can write to the group.</returns>
+ /// <typeparam name="TCategory">The logger cateogry.</typeparam>
+ IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry);
+}
+
+/// <summary>
+/// Defines a logger that can be injected via DI to get a startup logger initialised with an logger framework connected <see cref="ILogger"/>.
+/// </summary>
+/// <typeparam name="TCategory">The logger cateogry.</typeparam>
+public interface IStartupLogger<TCategory> : IStartupLogger
+{
+ /// <summary>
+ /// Adds another logger instance to this logger for combined logging.
+ /// </summary>
+ /// <param name="logger">Other logger to rely messages to.</param>
+ /// <returns>A combined logger.</returns>
+ new IStartupLogger<TCategory> With(ILogger logger);
+
+ /// <summary>
+ /// Opens a new Group logger within the parent logger.
+ /// </summary>
+ /// <param name="logEntry">Defines the log message that introduces the new group.</param>
+ /// <returns>A new logger that can write to the group.</returns>
+ new IStartupLogger<TCategory> BeginGroup(FormattableString logEntry);
+}
diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
index 3d4810bd7..4340969a3 100644
--- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs
+++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
@@ -1,4 +1,7 @@
using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
@@ -7,9 +10,11 @@ using System.Threading.Tasks;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Serialization;
using Jellyfin.Networking.Manager;
+using Jellyfin.Server.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
+using MediaBrowser.Model.IO;
using MediaBrowser.Model.System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -20,6 +25,11 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
+using Morestachio;
+using Morestachio.Framework.IO.SingleStream;
+using Morestachio.Rendering;
+using Serilog;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Jellyfin.Server.ServerSetupApp;
@@ -34,8 +44,10 @@ public sealed class SetupServer : IDisposable
private readonly ILoggerFactory _loggerFactory;
private readonly IConfiguration _startupConfiguration;
private readonly ServerConfigurationManager _configurationManager;
+ private IRenderer? _startupUiRenderer;
private IHost? _startupServer;
private bool _disposed;
+ private bool _isUnhealthy;
/// <summary>
/// Initializes a new instance of the <see cref="SetupServer"/> class.
@@ -62,26 +74,92 @@ public sealed class SetupServer : IDisposable
_configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
}
+ internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
+
+ /// <summary>
+ /// Gets a value indicating whether Startup server is currently running.
+ /// </summary>
+ public bool IsAlive { get; internal set; }
+
/// <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()
{
+ var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
+ _startupUiRenderer = (await ParserOptionsBuilder.New()
+ .WithTemplate(fileTemplate)
+ .WithFormatter(
+ (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
+ {
+ if (children.Any())
+ {
+ var maxLevel = logEntry.LogLevel;
+ var stack = new Stack<StartupLogTopic>(children);
+
+ while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
+ {
+ maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
+ foreach (var child in logEntry.Children)
+ {
+ stack.Push(child);
+ }
+ }
+
+ return maxLevel;
+ }
+
+ return logEntry.LogLevel;
+ },
+ "FormatLogLevel")
+ .WithFormatter(
+ (LogLevel logLevel) =>
+ {
+ switch (logLevel)
+ {
+ case LogLevel.Trace:
+ case LogLevel.Debug:
+ case LogLevel.None:
+ return "success";
+ case LogLevel.Information:
+ return "info";
+ case LogLevel.Warning:
+ return "warn";
+ case LogLevel.Error:
+ return "danger";
+ case LogLevel.Critical:
+ return "danger-strong";
+ }
+
+ return string.Empty;
+ },
+ "ToString")
+ .BuildAndParseAsync()
+ .ConfigureAwait(false))
+ .CreateCompiledRenderer();
+
ThrowIfDisposed();
- _startupServer = Host.CreateDefaultBuilder()
+ var retryAfterValue = TimeSpan.FromSeconds(5);
+ var config = _configurationManager.GetNetworkConfiguration()!;
+ _startupServer = Host.CreateDefaultBuilder(["hostBuilder:reloadConfigOnChange=false"])
.UseConsoleLifetime()
+ .UseSerilog()
.ConfigureServices(serv =>
{
+ serv.AddSingleton(this);
serv.AddHealthChecks()
.AddCheck<SetupHealthcheck>("StartupCheck");
+ serv.Configure<ForwardedHeadersOptions>(options =>
+ {
+ ApiServiceCollectionExtensions.ConfigureForwardHeaders(config, options);
+ });
})
.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);
@@ -99,7 +177,7 @@ public sealed class SetupServer : IDisposable
.Configure(app =>
{
app.UseHealthChecks("/health");
-
+ app.UseForwardedHeaders();
app.Map("/startup/logger", loggerRoute =>
{
loggerRoute.Run(async context =>
@@ -113,7 +191,7 @@ public sealed class SetupServer : IDisposable
var logFilePath = new DirectoryInfo(_applicationPaths.LogDirectoryPath)
.EnumerateFiles()
- .OrderBy(f => f.CreationTimeUtc)
+ .OrderByDescending(f => f.CreationTimeUtc)
.FirstOrDefault()
?.FullName;
if (logFilePath is not null)
@@ -140,7 +218,7 @@ public sealed class SetupServer : IDisposable
if (jfApplicationHost is null)
{
context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
- context.Response.Headers.RetryAfter = new StringValues("5");
+ context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture));
return;
}
@@ -158,24 +236,32 @@ public sealed class SetupServer : IDisposable
});
});
- app.Run((context) =>
+ app.Run(async (context) =>
{
context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
- context.Response.Headers.RetryAfter = new StringValues("5");
+ context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture));
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;
+ var startupLogEntries = LogQueue?.ToArray() ?? [];
+ await _startupUiRenderer.RenderAsync(
+ new Dictionary<string, object>()
+ {
+ { "isInReportingMode", _isUnhealthy },
+ { "retryValue", retryAfterValue },
+ { "version", typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version! },
+ { "logs", startupLogEntries },
+ { "networkManagerReady", networkManager is not null },
+ { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
+ },
+ new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
+ .ConfigureAwait(false);
});
});
})
.Build();
await _startupServer.StartAsync().ConfigureAwait(false);
+ IsAlive = true;
}
/// <summary>
@@ -191,6 +277,7 @@ public sealed class SetupServer : IDisposable
}
await _startupServer.StopAsync().ConfigureAwait(false);
+ IsAlive = false;
}
/// <inheritdoc/>
@@ -203,6 +290,9 @@ public sealed class SetupServer : IDisposable
_disposed = true;
_startupServer?.Dispose();
+ IsAlive = false;
+ LogQueue?.Clear();
+ LogQueue = null;
}
private void ThrowIfDisposed()
@@ -210,11 +300,77 @@ public sealed class SetupServer : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
}
+ internal void SoftStop()
+ {
+ _isUnhealthy = true;
+ }
+
private class SetupHealthcheck : IHealthCheck
{
+ private readonly SetupServer _startupServer;
+
+ public SetupHealthcheck(SetupServer startupServer)
+ {
+ _startupServer = startupServer;
+ }
+
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
+ if (_startupServer._isUnhealthy)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy("Server is could not complete startup. Check logs."));
+ }
+
return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up."));
}
}
+
+ internal sealed class SetupLoggerFactory : ILoggerProvider, IDisposable
+ {
+ private bool _disposed;
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new CatchingSetupServerLogger();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ }
+ }
+
+ internal sealed class CatchingSetupServerLogger : ILogger
+ {
+ public IDisposable? BeginScope<TState>(TState state)
+ where TState : notnull
+ {
+ return null;
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return logLevel is LogLevel.Error or LogLevel.Critical;
+ }
+
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ LogQueue?.Enqueue(new()
+ {
+ LogLevel = logLevel,
+ Content = formatter(state, exception),
+ DateOfCreation = DateTimeOffset.Now
+ });
+ }
+ }
}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs
new file mode 100644
index 000000000..cd440a9b5
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.ObjectModel;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Defines a topic for the Startup UI.
+/// </summary>
+public class StartupLogTopic
+{
+ /// <summary>
+ /// Gets or Sets the LogLevel.
+ /// </summary>
+ public LogLevel LogLevel { get; set; }
+
+ /// <summary>
+ /// Gets or Sets the descriptor for the topic.
+ /// </summary>
+ public string? Content { get; set; }
+
+ /// <summary>
+ /// Gets or sets the time the topic was created.
+ /// </summary>
+ public DateTimeOffset DateOfCreation { get; set; }
+
+ /// <summary>
+ /// Gets the child items of this topic.
+ /// </summary>
+ public Collection<StartupLogTopic> Children { get; } = [];
+}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
new file mode 100644
index 000000000..0121854ce
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <inheritdoc/>
+public class StartupLogger : IStartupLogger
+{
+ private readonly StartupLogTopic? _topic;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+ /// </summary>
+ /// <param name="logger">The underlying base logger.</param>
+ public StartupLogger(ILogger logger)
+ {
+ BaseLogger = logger;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+ /// </summary>
+ /// <param name="logger">The underlying base logger.</param>
+ /// <param name="topic">The group for this logger.</param>
+ internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger)
+ {
+ _topic = topic;
+ }
+
+ internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance);
+
+ /// <inheritdoc/>
+ public StartupLogTopic? Topic => _topic;
+
+ /// <summary>
+ /// Gets or Sets the underlying base logger.
+ /// </summary>
+ protected ILogger BaseLogger { get; set; }
+
+ /// <inheritdoc/>
+ public IStartupLogger BeginGroup(FormattableString logEntry)
+ {
+ return new StartupLogger(BaseLogger, AddToTopic(logEntry));
+ }
+
+ /// <inheritdoc/>
+ public IStartupLogger With(ILogger logger)
+ {
+ return new StartupLogger(logger, Topic);
+ }
+
+ /// <inheritdoc/>
+ public IStartupLogger<TCategory> With<TCategory>(ILogger logger)
+ {
+ return new StartupLogger<TCategory>(logger, Topic);
+ }
+
+ /// <inheritdoc/>
+ public IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry)
+ {
+ return new StartupLogger<TCategory>(BaseLogger, AddToTopic(logEntry));
+ }
+
+ private StartupLogTopic AddToTopic(FormattableString logEntry)
+ {
+ var startupEntry = new StartupLogTopic()
+ {
+ Content = logEntry.ToString(CultureInfo.InvariantCulture),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (Topic is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ Topic.Children.Add(startupEntry);
+ }
+
+ return startupEntry;
+ }
+
+ /// <inheritdoc/>
+ public IDisposable? BeginScope<TState>(TState state)
+ where TState : notnull
+ {
+ return null;
+ }
+
+ /// <inheritdoc/>
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return true;
+ }
+
+ /// <inheritdoc/>
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+ {
+ if (BaseLogger.IsEnabled(logLevel))
+ {
+ // if enabled allow the base logger also to receive the message
+ BaseLogger.Log(logLevel, eventId, state, exception, formatter);
+ }
+
+ var startupEntry = new StartupLogTopic()
+ {
+ LogLevel = logLevel,
+ Content = formatter(state, exception),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (Topic is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ Topic.Children.Add(startupEntry);
+ }
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs
new file mode 100644
index 000000000..ada4b56a7
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+internal static class StartupLoggerExtensions
+{
+ public static IServiceCollection RegisterStartupLogger(this IServiceCollection services)
+ {
+ return services
+ .AddTransient<IStartupLogger, StartupLogger<Startup>>()
+ .AddTransient(typeof(IStartupLogger<>), typeof(StartupLogger<>));
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs
new file mode 100644
index 000000000..64da0ce88
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Startup logger for usage with DI that utilises an underlying logger from the DI.
+/// </summary>
+/// <typeparam name="TCategory">The category of the underlying logger.</typeparam>
+#pragma warning disable SA1649 // File name should match first type name
+public class StartupLogger<TCategory> : StartupLogger, IStartupLogger<TCategory>
+#pragma warning restore SA1649 // File name should match first type name
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class.
+ /// </summary>
+ /// <param name="logger">The injected base logger.</param>
+ public StartupLogger(ILogger<TCategory> logger) : base(logger)
+ {
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class.
+ /// </summary>
+ /// <param name="logger">The underlying base logger.</param>
+ /// <param name="groupEntry">The group for this logger.</param>
+ internal StartupLogger(ILogger logger, StartupLogTopic? groupEntry) : base(logger, groupEntry)
+ {
+ }
+
+ IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry)
+ {
+ var startupEntry = new StartupLogTopic()
+ {
+ Content = logEntry.ToString(CultureInfo.InvariantCulture),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (Topic is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ Topic.Children.Add(startupEntry);
+ }
+
+ return new StartupLogger<TCategory>(BaseLogger, startupEntry);
+ }
+
+ IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger)
+ {
+ return new StartupLogger<TCategory>(logger, Topic);
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
new file mode 100644
index 000000000..890a77619
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
@@ -0,0 +1,235 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8" />
+ <title>
+ {{#IF isInReportingMode}}
+ ❌
+ {{/IF}}
+ Jellyfin Startup
+ </title>
+ <style>
+ * {
+ font-family: sans-serif;
+ }
+
+ .flex-row {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: center;
+ align-content: normal;
+ }
+
+ .flex-col {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: center;
+ align-content: normal;
+ }
+
+ header {
+ height: 5rem;
+ width: 100%;
+ }
+
+ header svg {
+ height: 3rem;
+ width: 9rem;
+ margin-right: 1rem;
+ }
+
+ /* ol.action-list {
+ list-style-type: none;
+ position: relative;
+ } */
+
+ ol.action-list * {
+ font-family: monospace;
+ font-weight: 300;
+ font-size: clamp(18px, 100vw / var(--width), 20px);
+ font-feature-settings: 'onum', 'pnum';
+ line-height: 1.8;
+ -webkit-text-size-adjust: none;
+ }
+
+ /*
+ ol.action-list li {
+ padding-top: .5rem;
+ }
+
+ ol.action-list li::before {
+ position: absolute;
+ left: -0.8em;
+ font-size: 1.1em;
+ } */
+
+ /* Attribution as heavily inspired by: https://iamkate.com/code/tree-views/ */
+ .action-list {
+ --spacing: 1.4rem;
+ --radius: 14px;
+ }
+
+ .action-list li {
+ display: block;
+ position: relative;
+ padding-left: calc(2 * var(--spacing) - var(--radius) - 1px);
+ }
+
+ .action-list ul {
+ margin-left: calc(var(--radius) - var(--spacing));
+ padding-left: 0;
+ }
+
+ .action-list ul li {
+ border-left: 2px solid #ddd;
+ }
+
+ .action-list ul li:last-child {
+ border-color: transparent;
+ }
+
+ .action-list ul li::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: calc(var(--spacing) / -2);
+ left: -2px;
+ width: calc(var(--spacing) + 2px);
+ height: calc(var(--spacing) + 1px);
+ border: solid #ddd;
+ border-width: 0 0 2px 2px;
+ }
+
+ .action-list summary {
+ display: block;
+ cursor: pointer;
+ }
+
+ .action-list summary::marker,
+ .action-list summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .action-list summary:focus {
+ outline: none;
+ }
+
+ .action-list summary:focus-visible {
+ outline: 1px dotted #000;
+ }
+
+ .action-list li::after,
+ .action-list summary::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: calc(var(--spacing) / 2 - var(--radius) + 4px);
+ left: calc(var(--spacing) - var(--radius) - -5px);
+ }
+
+ .action-list summary::before {
+ z-index: 1;
+ /* background: #696 url('expand-collapse.svg') 0 0; */
+ }
+
+ .action-list details[open]>summary::before {
+ background-position: calc(-2 * var(--radius)) 0;
+ }
+
+ .action-list li.danger-item::after,
+ .action-list li.danger-strong-item::after {
+ content: '❌';
+ }
+
+ ol.action-list li span.danger-strong-item {
+ text-decoration-style: solid;
+ text-decoration-color: red;
+ text-decoration-line: underline;
+ }
+
+ ol.action-list li.warn-item::after {
+ content: '⚠️';
+ }
+
+ ol.action-list li.success-item::after {
+ content: '✅';
+ }
+
+ ol.action-list li.info-item::after {
+ content: '🔹';
+ }
+
+ /* End Attribution */
+ </style>
+</head>
+
+<body>
+ <div>
+ <header class="flex-row">
+
+ {{^IF isInReportingMode}}
+ <p>Jellyfin Server {{version}} still starting. Please wait.</p>
+ {{#ELSE}}
+ <p>Jellyfin Server has encountered an error and was not able to start.</p>
+ {{/ELSE}}
+ {{/IF}}
+
+ {{#IF localNetworkRequest}}
+ <p style="margin-left: 1rem;">You can download the current log file <a href='/startup/logger'
+ target="_blank">here</a>.</p>
+ {{/IF}}
+ </header>
+
+ {{#DECLARE LogEntry |--}}
+ {{#LET children = Children}}
+ <li class="{{FormatLogLevel(children).ToString()}}-item">
+ {{--| #IF children.Count > 0}}
+ <details open>
+ <summary>{{DateOfCreation}} - {{Content}}</summary>
+ <ul class="action-list">
+ {{--| #EACH children.Reverse() |-}}
+ {{#IMPORT 'LogEntry'}}
+ {{--| /EACH |-}}
+ </ul>
+ </details>
+ {{--| #ELSE |-}}
+ <span class="{{FormatLogLevel(children).ToString()}}-item">{{DateOfCreation}} - {{Content}}</span>
+ {{--| /ELSE |--}}
+ {{--| /IF |-}}
+ </li>
+ {{--| /DECLARE}}
+
+ {{#IF localNetworkRequest}}
+ <div class="flex-col">
+ <ol class="action-list">
+ {{#FOREACH log IN logs.Reverse()}}
+ {{#IMPORT 'LogEntry' #WITH log}}
+ {{/FOREACH}}
+ </ol>
+ </div>
+ {{#ELSE}}
+ {{#IF networkManagerReady}}
+ <p>Please visit this page from your local network to view detailed startup logs.</p>
+ {{#ELSE}}
+ <p>Initializing network settings. Please wait.</p>
+ {{/ELSE}}
+ {{/IF}}
+ {{/ELSE}}
+ {{/IF}}
+ </div>
+</body>
+
+{{^IF isInReportingMode}}
+<script>
+ setTimeout(() => {
+ window.location.reload();
+ }, {{ retryValue.TotalMilliseconds }});
+</script>
+{{/IF}}
+
+</html>
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 688b16935..5032b2aec 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,4 +1,5 @@
using System;
+using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -15,20 +16,18 @@ using Jellyfin.Networking.HappyEyeballs;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.HealthChecks;
using Jellyfin.Server.Implementations.Extensions;
-using Jellyfin.Server.Infrastructure;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Extensions;
using MediaBrowser.XbmcMetadata;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Primitives;
using Prometheus;
namespace Jellyfin.Server
@@ -67,8 +66,6 @@ namespace Jellyfin.Server
options.HttpsPort = _serverApplicationHost.HttpsPort;
});
- // 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(_serverApplicationHost.ConfigurationManager, _configuration);
services.AddJellyfinApiSwagger();
@@ -195,7 +192,14 @@ namespace Jellyfin.Server
{
FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath),
RequestPath = "/web",
- ContentTypeProvider = extensionProvider
+ ContentTypeProvider = extensionProvider,
+ OnPrepareResponse = (context) =>
+ {
+ if (Path.GetFileName(context.File.Name).Equals("index.html", StringComparison.Ordinal))
+ {
+ context.Context.Response.Headers.CacheControl = new StringValues("no-cache");
+ }
+ }
});
mainApp.UseRobotsRedirection();
@@ -208,7 +212,6 @@ namespace Jellyfin.Server
mainApp.UseRouting();
mainApp.UseAuthorization();
- mainApp.UseLanFiltering();
mainApp.UseIPBasedAccessValidation();
mainApp.UseWebSocketHandler();
mainApp.UseServerStartupMessage();
diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs
index 91ac827ca..4890ccbb2 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>
diff --git a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg b/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
deleted file mode 100644
index b62b7545c..000000000
--- a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
+++ /dev/null
@@ -1,34 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!-- ***** BEGIN LICENSE BLOCK *****
- - Part of the Jellyfin project (https://jellyfin.media)
- -
- - All copyright belongs to the Jellyfin contributors; a full list can
- - be found in the file CONTRIBUTORS.md
- -
- - This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- - To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
-- ***** END LICENSE BLOCK ***** -->
-<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
- <defs>
- <linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
- <stop offset="0" stop-color="#aa5cc3"/>
- <stop offset="1" stop-color="#00a4dc"/>
- </linearGradient>
- </defs>
- <title>banner-dark</title>
- <g id="banner-dark">
- <g id="banner-dark-icon">
- <path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
- <path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
- </g>
- <g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
- <path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
- <path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
- <path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
- <path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
- <path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
- <path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
- <path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
- </g>
- </g>
-</svg> \ No newline at end of file
diff --git a/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
new file mode 100644
index 000000000..692530319
--- /dev/null
+++ b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="251" height="72" fill="none" viewBox="0 0 251 72">
+ <g clip-path="url(#a)">
+ <path fill="url(#b)"
+ d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" />
+ <path fill="url(#c)" fill-rule="evenodd"
+ d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847"
+ clip-rule="evenodd" />
+ <path fill="#fff"
+ d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" />
+ </g>
+ <defs>
+ <linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002"
+ gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001"
+ gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <clipPath id="a">
+ <path fill="#fff" d="M0 0h251v72H0z" />
+ </clipPath>
+ </defs>
+</svg> \ No newline at end of file
diff --git a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
index acb59888e..c14ad6021 100644
--- a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
+++ b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
@@ -4,12 +4,14 @@
}
.topbar-wrapper .link:after {
- content: url(../banner-dark.svg);
+ content: '';
display: block;
- -moz-box-sizing: border-box;
+ background-image: url(../jellyfin.svg);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
box-sizing: border-box;
- max-width: 100%;
- max-height: 100%;
- width: 150px;
+ width: 220px;
+ height: 40px;
}
/* end logo */