aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs47
-rw-r--r--Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs (renamed from Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs)4
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj1
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs10
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixDates.cs168
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs83
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs123
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs74
-rw-r--r--Jellyfin.Server/Migrations/Stages/CodeMigration.cs18
-rw-r--r--Jellyfin.Server/Program.cs11
-rw-r--r--Jellyfin.Server/ServerSetupApp/IStartupLogger.cs43
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs32
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs31
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogger.cs78
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs18
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs56
-rw-r--r--Jellyfin.Server/ServerSetupApp/index.mstemplate.html5
22 files changed, 691 insertions, 121 deletions
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 09a4e2ed3..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>
@@ -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/RetryOnTemporarlyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
index 74470eda0..fef5577a1 100644
--- a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs
+++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
@@ -6,13 +6,13 @@ using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters;
-internal class RetryOnTemporarlyUnavailableFilter : IOperationFilter
+internal class RetryOnTemporarilyUnavailableFilter : 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.",
+ Description = "The server is currently starting or is temporarily not available.",
Headers = new Dictionary<string, OpenApiHeader>()
{
{
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index 14c4285fe..df630922a 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -53,6 +53,7 @@
<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" />
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index 5331b43e3..fe191916c 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -17,6 +17,7 @@ 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;
@@ -47,7 +48,7 @@ internal class JellyfinMigrationService
public JellyfinMigrationService(
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILoggerFactory loggerFactory,
- IStartupLogger startupLogger,
+ IStartupLogger<JellyfinMigrationService> startupLogger,
IApplicationPaths applicationPaths,
IBackupService? backupService = null,
IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
@@ -105,6 +106,13 @@ internal class JellyfinMigrationService
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);
diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/FixDates.cs
new file mode 100644
index 000000000..f112502b9
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixDates.cs
@@ -0,0 +1,168 @@
+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))
+ {
+ using var context = await _dbProvider.CreateDbContextAsync(cancellationToken).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/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
index 033045e63..c199ee4d6 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
@@ -35,7 +35,7 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="dbProvider">The EFCore db factory.</param>
public MigrateKeyframeData(
- IStartupLogger startupLogger,
+ IStartupLogger<MigrateKeyframeData> startupLogger,
IApplicationPaths appPaths,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index 521655a4f..e04a2737a 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -48,7 +48,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
/// <param name="paths">The server application paths.</param>
/// <param name="jellyfinDatabaseProvider">The database provider for special access.</param>
public MigrateLibraryDb(
- IStartupLogger startupLogger,
+ IStartupLogger<MigrateLibraryDb> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
@@ -90,11 +90,14 @@ 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"))
{
const string typedBaseItemsQuery =
"""
@@ -105,7 +108,7 @@ 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))
{
@@ -121,13 +124,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
- 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();
}
}
- 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 =
@@ -138,7 +141,7 @@ 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))
{
@@ -166,13 +169,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(
"""
@@ -181,14 +184,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);
@@ -212,19 +215,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
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 =
"""
@@ -237,7 +238,7 @@ 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))
{
@@ -245,13 +246,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
- 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 =
"""
@@ -260,7 +261,7 @@ 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))
{
@@ -268,13 +269,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
- 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 =
"""
@@ -284,14 +285,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
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;
}
@@ -330,13 +331,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 =
"""
@@ -344,7 +345,7 @@ 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))
{
@@ -353,13 +354,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
- 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 =
"""
@@ -370,7 +371,7 @@ 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))
{
@@ -379,7 +380,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
- 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();
}
@@ -404,19 +405,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;
}
@@ -1168,7 +1170,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);
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs
index 2d5fc2a0d..d4cc9bbee 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs
@@ -26,7 +26,7 @@ public class MigrateLibraryDbCompatibilityCheck : IAsyncMigrationRoutine
/// </summary>
/// <param name="startupLogger">The startup logger.</param>
/// <param name="paths">The Path service.</param>
- public MigrateLibraryDbCompatibilityCheck(IStartupLogger startupLogger, IServerApplicationPaths paths)
+ public MigrateLibraryDbCompatibilityCheck(IStartupLogger<MigrateLibraryDbCompatibilityCheck> startupLogger, IServerApplicationPaths paths)
{
_logger = startupLogger;
_paths = paths;
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 ae93557de..2a6db01cf 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
@@ -23,7 +23,7 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
public MigrateRatingLevels(
IDbContextFactory<JellyfinDbContext> provider,
- IStartupLogger logger,
+ IStartupLogger<MigrateRatingLevels> logger,
ILocalizationManager localizationManager)
{
_provider = provider;
diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
index 6f650f731..8b394dd7a 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
@@ -47,7 +47,7 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
public MoveExtractedFiles(
IApplicationPaths appPaths,
ILogger<MoveExtractedFiles> logger,
- IStartupLogger startupLogger,
+ IStartupLogger<MoveExtractedFiles> startupLogger,
IPathManager pathManager,
IFileSystem fileSystem,
IDbContextFactory<JellyfinDbContext> dbProvider)
diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
index a674aa928..0f55465e8 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
@@ -37,7 +37,7 @@ public class MoveTrickplayFiles : IMigrationRoutine
ITrickplayManager trickplayManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
- IStartupLogger logger)
+ IStartupLogger<MoveTrickplayFiles> logger)
{
_trickplayManager = trickplayManager;
_fileSystem = fileSystem;
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/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
index 47ed26965..264710bce 100644
--- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
+++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
@@ -5,6 +5,7 @@ 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;
@@ -21,11 +22,13 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
}
- private ServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
+ private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
{
- var childServiceCollection = new ServiceCollection();
- childServiceCollection.AddSingleton(serviceProvider);
- childServiceCollection.AddSingleton(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>())
{
@@ -78,4 +81,11 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
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/Program.cs b/Jellyfin.Server/Program.cs
index 0b77d63ac..dc7fa5eb3 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -60,7 +60,7 @@ namespace Jellyfin.Server
private static long _startTimestamp;
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
- private static IStartupLogger? _migrationLogger;
+ private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
private static string? _restoreFromBackup;
/// <summary>
@@ -103,6 +103,7 @@ namespace Jellyfin.Server
_setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost, _loggerFactory, startupConfig);
await _setupServer.RunAsync().ConfigureAwait(false);
_logger = _loggerFactory.CreateLogger("Main");
+ StartupLogger.Logger = new StartupLogger(_logger);
// Use the logging framework for uncaught exceptions instead of std error
AppDomain.CurrentDomain.UnhandledException += (_, e)
@@ -178,7 +179,9 @@ namespace Jellyfin.Server
})
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
.UseSerilog()
- .ConfigureServices(e => e.AddTransient<IStartupLogger, StartupLogger>().AddSingleton<IServiceCollection>(e))
+ .ConfigureServices(e => e
+ .RegisterStartupLogger()
+ .AddSingleton<IServiceCollection>(e))
.Build();
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
@@ -268,7 +271,7 @@ namespace Jellyfin.Server
/// <returns>A task.</returns>
public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
{
- _migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service");
+ _migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
var migrationStartupServiceProvider = new ServiceCollection()
@@ -276,7 +279,7 @@ namespace Jellyfin.Server
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
.AddSingleton<IApplicationPaths>(appPaths)
.AddSingleton<ServerApplicationPaths>(appPaths)
- .AddSingleton<IStartupLogger>(_migrationLogger);
+ .RegisterStartupLogger();
migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
index 2c2ef05f8..e7c193936 100644
--- a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
+++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
@@ -1,5 +1,4 @@
using System;
-using Morestachio.Helper.Logging;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Jellyfin.Server.ServerSetupApp;
@@ -10,6 +9,11 @@ namespace Jellyfin.Server.ServerSetupApp;
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>
@@ -22,4 +26,41 @@ public interface IStartupLogger : ILogger
/// <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 d88dbee57..92e012940 100644
--- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs
+++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
@@ -10,6 +10,7 @@ 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;
@@ -27,6 +28,8 @@ 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;
@@ -71,7 +74,7 @@ public sealed class SetupServer : IDisposable
_configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
}
- internal static ConcurrentQueue<StartupLogEntry>? LogQueue { get; set; } = new();
+ internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
/// <summary>
/// Gets a value indicating whether Startup server is currently running.
@@ -88,12 +91,12 @@ public sealed class SetupServer : IDisposable
_startupUiRenderer = (await ParserOptionsBuilder.New()
.WithTemplate(fileTemplate)
.WithFormatter(
- (StartupLogEntry logEntry, IEnumerable<StartupLogEntry> children) =>
+ (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
{
if (children.Any())
{
var maxLevel = logEntry.LogLevel;
- var stack = new Stack<StartupLogEntry>(children);
+ var stack = new Stack<StartupLogTopic>(children);
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != null) // error is the highest inherted error level.
{
@@ -138,19 +141,25 @@ public sealed class SetupServer : IDisposable
ThrowIfDisposed();
var retryAfterValue = TimeSpan.FromSeconds(5);
- _startupServer = Host.CreateDefaultBuilder()
+ 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);
@@ -168,7 +177,7 @@ public sealed class SetupServer : IDisposable
.Configure(app =>
{
app.UseHealthChecks("/health");
-
+ app.UseForwardedHeaders();
app.Map("/startup/logger", loggerRoute =>
{
loggerRoute.Run(async context =>
@@ -362,15 +371,4 @@ public sealed class SetupServer : IDisposable
});
}
}
-
- internal class StartupLogEntry
- {
- public LogLevel LogLevel { get; set; }
-
- public string? Content { get; set; }
-
- public DateTimeOffset DateOfCreation { get; set; }
-
- public List<StartupLogEntry> Children { get; set; } = [];
- }
}
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
index 2b86dc0c1..0121854ce 100644
--- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
@@ -1,56 +1,86 @@
using System;
-using System.Collections.Generic;
using System.Globalization;
-using System.Linq;
-using Jellyfin.Server.Migrations.Routines;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Jellyfin.Server.ServerSetupApp;
/// <inheritdoc/>
public class StartupLogger : IStartupLogger
{
- private readonly SetupServer.StartupLogEntry? _groupEntry;
+ private readonly StartupLogTopic? _topic;
/// <summary>
/// Initializes a new instance of the <see cref="StartupLogger"/> class.
/// </summary>
- public StartupLogger()
+ /// <param name="logger">The underlying base logger.</param>
+ public StartupLogger(ILogger logger)
{
- Loggers = [];
+ BaseLogger = logger;
}
/// <summary>
/// Initializes a new instance of the <see cref="StartupLogger"/> class.
/// </summary>
- private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this()
+ /// <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)
{
- _groupEntry = groupEntry;
+ _topic = topic;
}
- internal static IStartupLogger Logger { get; } = new StartupLogger();
+ internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance);
- private List<ILogger> Loggers { get; set; }
+ /// <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)
{
- var startupEntry = new SetupServer.StartupLogEntry()
+ 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 (_groupEntry is null)
+ if (Topic is null)
{
SetupServer.LogQueue?.Enqueue(startupEntry);
}
else
{
- _groupEntry.Children.Add(startupEntry);
+ Topic.Children.Add(startupEntry);
}
- return new StartupLogger(startupEntry);
+ return startupEntry;
}
/// <inheritdoc/>
@@ -69,34 +99,26 @@ public class StartupLogger : IStartupLogger
/// <inheritdoc/>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
- foreach (var item in Loggers.Where(e => e.IsEnabled(logLevel)))
+ if (BaseLogger.IsEnabled(logLevel))
{
- item.Log(logLevel, eventId, state, exception, formatter);
+ // if enabled allow the base logger also to receive the message
+ BaseLogger.Log(logLevel, eventId, state, exception, formatter);
}
- var startupEntry = new SetupServer.StartupLogEntry()
+ var startupEntry = new StartupLogTopic()
{
LogLevel = logLevel,
Content = formatter(state, exception),
DateOfCreation = DateTimeOffset.Now
};
- if (_groupEntry is null)
+ if (Topic is null)
{
SetupServer.LogQueue?.Enqueue(startupEntry);
}
else
{
- _groupEntry.Children.Add(startupEntry);
+ Topic.Children.Add(startupEntry);
}
}
-
- /// <inheritdoc/>
- public IStartupLogger With(ILogger logger)
- {
- return new StartupLogger(_groupEntry)
- {
- Loggers = [.. Loggers, logger]
- };
- }
}
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
index 747835b2a..523f38d74 100644
--- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
+++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
@@ -204,6 +204,7 @@
</li>
{{--| /DECLARE}}
+ {{#IF localNetworkRequest}}
<div class="flex-col">
<ol class="action-list">
{{#FOREACH log IN logs.Reverse()}}
@@ -211,6 +212,10 @@
{{/FOREACH}}
</ol>
</div>
+ {{#ELSE}}
+ <p>Please visit this page from your local network to view detailed startup logs.</p>
+ {{/ELSE}}
+ {{/IF}}
</div>
</body>