aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server/ServerSetupApp
diff options
context:
space:
mode:
authorJPVenson <github@jpb.email>2025-06-05 17:59:11 +0300
committerGitHub <noreply@github.com>2025-06-05 08:59:11 -0600
commit88332e89c458266bc073d3304eafcb23603f15fa (patch)
treef067cdc997219a1a15e11356ef7aa31b466d7bba /Jellyfin.Server/ServerSetupApp
parenta3578caa8c71c84b278e18a07ebc157bcf04c687 (diff)
Feature/version check in library migration (#14105)
Diffstat (limited to 'Jellyfin.Server/ServerSetupApp')
-rw-r--r--Jellyfin.Server/ServerSetupApp/IStartupLogger.cs25
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs174
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogger.cs102
-rw-r--r--Jellyfin.Server/ServerSetupApp/index.mstemplate.html225
4 files changed, 517 insertions, 9 deletions
diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
new file mode 100644
index 000000000..2c2ef05f8
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
@@ -0,0 +1,25 @@
+using System;
+using Morestachio.Helper.Logging;
+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>
+ /// 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);
+}
diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
index 7ab5defc8..751cf7f42 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;
@@ -10,6 +13,7 @@ using Jellyfin.Networking.Manager;
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 +24,9 @@ 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;
namespace Jellyfin.Server.ServerSetupApp;
@@ -34,8 +41,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,13 +71,73 @@ public sealed class SetupServer : IDisposable
_configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
}
+ internal static ConcurrentQueue<StartupLogEntry>? 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("ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
+ _startupUiRenderer = (await ParserOptionsBuilder.New()
+ .WithTemplate(fileTemplate)
+ .WithFormatter(
+ (StartupLogEntry logEntry, IEnumerable<StartupLogEntry> children) =>
+ {
+ if (children.Any())
+ {
+ var maxLevel = logEntry.LogLevel;
+ var stack = new Stack<StartupLogEntry>(children);
+
+ while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != 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();
+ var retryAfterValue = TimeSpan.FromSeconds(5);
_startupServer = Host.CreateDefaultBuilder()
.UseConsoleLifetime()
.ConfigureServices(serv =>
@@ -140,7 +209,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 +227,30 @@ 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 },
+ { "logs", startupLogEntries },
+ { "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 +266,7 @@ public sealed class SetupServer : IDisposable
}
await _startupServer.StopAsync().ConfigureAwait(false);
+ IsAlive = false;
}
/// <inheritdoc/>
@@ -203,6 +279,9 @@ public sealed class SetupServer : IDisposable
_disposed = true;
_startupServer?.Dispose();
+ IsAlive = false;
+ LogQueue?.Clear();
+ LogQueue = null;
}
private void ThrowIfDisposed()
@@ -210,11 +289,88 @@ 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
+ });
+ }
+ }
+
+ 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/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
new file mode 100644
index 000000000..2b86dc0c1
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Server.Migrations.Routines;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <inheritdoc/>
+public class StartupLogger : IStartupLogger
+{
+ private readonly SetupServer.StartupLogEntry? _groupEntry;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+ /// </summary>
+ public StartupLogger()
+ {
+ Loggers = [];
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+ /// </summary>
+ private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this()
+ {
+ _groupEntry = groupEntry;
+ }
+
+ internal static IStartupLogger Logger { get; } = new StartupLogger();
+
+ private List<ILogger> Loggers { get; set; }
+
+ /// <inheritdoc/>
+ public IStartupLogger BeginGroup(FormattableString logEntry)
+ {
+ var startupEntry = new SetupServer.StartupLogEntry()
+ {
+ Content = logEntry.ToString(CultureInfo.InvariantCulture),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (_groupEntry is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ _groupEntry.Children.Add(startupEntry);
+ }
+
+ return new StartupLogger(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)
+ {
+ foreach (var item in Loggers.Where(e => e.IsEnabled(logLevel)))
+ {
+ item.Log(logLevel, eventId, state, exception, formatter);
+ }
+
+ var startupEntry = new SetupServer.StartupLogEntry()
+ {
+ LogLevel = logLevel,
+ Content = formatter(state, exception),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (_groupEntry is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ _groupEntry.Children.Add(startupEntry);
+ }
+ }
+
+ /// <inheritdoc/>
+ public IStartupLogger With(ILogger logger)
+ {
+ return new StartupLogger(_groupEntry)
+ {
+ Loggers = [.. Loggers, logger]
+ };
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
new file mode 100644
index 000000000..747835b2a
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
@@ -0,0 +1,225 @@
+<!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 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}}
+
+ <div class="flex-col">
+ <ol class="action-list">
+ {{#FOREACH log IN logs.Reverse()}}
+ {{#IMPORT 'LogEntry' #WITH log}}
+ {{/FOREACH}}
+ </ol>
+ </div>
+ </div>
+</body>
+
+{{^IF isInReportingMode}}
+<script>
+ setTimeout(() => {
+ window.location.reload();
+ }, {{ retryValue.TotalMilliseconds }});
+</script>
+{{/IF}}
+
+</html>