Der Anlass für diesen Server, jetzt als Code. Fortschritt, Gesehen-Status, Weiterschauen, Nächste Folge, Favoriten und Bewertungen — alles je Benutzer. **Die Jellyfin-Werte sind jetzt belegt statt behauptet.** Das Entwurfsdokument hatte das ausdrücklich offengelassen („aus dem Gedächtnis"); hier stehen sie aus `MediaBrowser.Model/Configuration/ServerConfiguration.cs` und `Emby.Server.Implementations/Library/UserDataManager.cs`: | | Vorgabe | Was passiert | |---|---|---| | `MinResumePct` | 5 | darunter `positionTicks = 0` — Kommentar im Quelltext: „ignore progress during the beginning" | | `MaxResumePct` | 90 | darüber gesehen, Position **auf 0** | | `MinResumeDurationSeconds` | 300 | alles unter fünf Minuten gilt nach dem ersten Lebenszeichen als gesehen | Bei einem Zweistünder sind die ersten sechs Minuten damit weg. Eine vierminütige Folge gilt nach dreissig Sekunden als vollständig gesehen. Und fehlt die Laufzeit, nimmt Jellyfin an, der Titel sei durch — wörtlich: „we'll just have to assume it was fully played". **Flaro legt jede Meldung ab und wirft nie eine Position weg.** Eine Grenze fürs Häkchen gibt es trotzdem — 90 %, sonst stünde jeder zu Ende geschaute Film für immer bei 98 % in Weiterschauen. Der Unterschied ist nicht, *dass* es die Grenze gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort zusätzlich die Stelle vernichtet. Wer einen gesehenen Film noch einmal anfängt, hat beides — das Häkchen und den Punkt, an dem er steht. Ohne bekannte Laufzeit wird nichts vermutet. Das ist der Fehler, den man nicht zurücknehmen kann, ohne ihn zu bemerken. --- **Eine Spalte statt einer zweiten Regel.** „Gehört in Weiterschauen" vergleicht die Stelle mit einem Anteil der Laufzeit, und diese Rechnung lässt sich nicht nach SQL übersetzen. Die Regel daneben noch einmal als Abfrage zu schreiben wäre die Variante, bei der beide irgendwann auseinanderlaufen — unbemerkt, weil keine von beiden dabei falsch aussieht. Stattdessen hält `InProgress` das Ergebnis fest, geschrieben an genau einer Stelle. Eine Prüfung hält Reihe und Regel über vier Stände gegeneinander. **„Weiterschauen" und „Nächste Folge" überschneiden sich nicht.** Was angefangen ist, steht links; was ansteht, rechts. Jellyfin zeigt beides doppelt, und auf einer Startseite mit zwei Reihen nebeneinander sieht das nach einem Fehler aus. Dazu, weil die App sie braucht: Einzelabruf für Film und Serie, die nächste Folge nach einer gegebenen, und der Zustand hängt an jeder Liste mit dran — in einer Abfrage je Seite, nicht in einer je Titel. Ein Titel wird über seine Kennung allein angesprochen, ohne dass der Aufrufer sagen muss, ob es ein Film oder eine Folge ist. Die App hat diese Auskunft an der Stelle nicht. 348 Tests grün. Im Container nachgewiesen: fünf Minuten „Der Untergang" von 2:36 bleiben stehen, bei Minute 150 kommt das Häkchen und die Stelle bleibt, beim zweiten Anlauf steht der Film mit beidem wieder in der Reihe — und nach Folge 1 von Dark zeigt „Nächste Folge" S01E02. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
6.6 KiB
C#
155 lines
6.6 KiB
C#
using System.Threading.RateLimiting;
|
|
using Flaro.Api;
|
|
using Flaro.Api.Auth;
|
|
using Flaro.Api.Endpoints;
|
|
using Flaro.Core.Metadata;
|
|
using Flaro.Data;
|
|
using Flaro.Core.Library;
|
|
using Flaro.Media;
|
|
using Flaro.Metadata;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
var pfade = FlaroPaths.FromEnvironment(builder.Environment.ContentRootPath);
|
|
pfade.EnsureExists();
|
|
builder.Services.AddSingleton(pfade);
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
|
|
builder.Services.AddDbContext<FlaroContext>(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}"));
|
|
|
|
builder.Services
|
|
.AddAuthentication(TokenAuthenticationHandler.SchemeName)
|
|
.AddScheme<AuthenticationSchemeOptions, TokenAuthenticationHandler>(
|
|
TokenAuthenticationHandler.SchemeName, _ => { });
|
|
|
|
builder.Services.AddAuthorizationBuilder()
|
|
// Jeder Endpunkt verlangt Anmeldung, sofern er sie nicht ausdrücklich
|
|
// abbestellt. Andersherum — jeden einzeln absichern — ist der Entwurf, bei
|
|
// dem irgendwann einer vergessen wird.
|
|
.SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
|
.RequireAuthenticatedUser().Build())
|
|
.AddPolicy(FlaroPolicies.Administrator, p => p.RequireRole(FlaroRoles.Administrator));
|
|
|
|
// Einstellbar, weil die richtige Zahl vom Aufbau abhängt: steht Flaro hinter
|
|
// einem Reverse Proxy, erscheinen **alle** Benutzer unter derselben Adresse
|
|
// und teilen sich einen Eimer. Wer das nicht weiss, hält die eigene App für
|
|
// kaputt, während in Wahrheit der Nachbar im selben Haus gerade angemeldet hat.
|
|
//
|
|
// NOCH OFFEN: `X-Forwarded-For` auswerten, damit die Aufteilung auch hinter
|
|
// einem Proxy je Benutzer greift. Blind zu vertrauen wäre falsch — es braucht
|
|
// eine Liste vertrauenswürdiger Gegenstellen.
|
|
int grenze(string name, int voreinstellung) =>
|
|
int.TryParse(Environment.GetEnvironmentVariable(name), out var wert) && wert > 0 ? wert : voreinstellung;
|
|
|
|
builder.Services.AddRateLimiter(o =>
|
|
{
|
|
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
var beimAnmelden = grenze("FLARO_RATELIMIT_SIGNIN", 10);
|
|
var beimNachfragen = grenze("FLARO_RATELIMIT_POLLING", 120);
|
|
|
|
// Je Absender, nicht global: sonst sperrt ein einzelner Angreifer den
|
|
// ganzen Haushalt aus, und die Abwehr wäre selbst der Ausfall.
|
|
o.AddPolicy(FlaroRateLimits.SignIn, anfrage => RateLimitPartition.GetFixedWindowLimiter(
|
|
anfrage.Connection.RemoteIpAddress?.ToString() ?? "unbekannt",
|
|
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimAnmelden, Window = TimeSpan.FromMinutes(1) }));
|
|
|
|
o.AddPolicy(FlaroRateLimits.Polling, anfrage => RateLimitPartition.GetFixedWindowLimiter(
|
|
anfrage.Connection.RemoteIpAddress?.ToString() ?? "unbekannt",
|
|
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimNachfragen, Window = TimeSpan.FromMinutes(1) }));
|
|
});
|
|
|
|
// ffmpeg als eigener Prozess, nicht eingebunden: hält die Lizenzen getrennt
|
|
// und lässt einen Absturz dort den Server nicht mitnehmen.
|
|
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
|
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
|
builder.Services.AddSingleton<FfmpegRemuxer>();
|
|
|
|
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
|
|
// schreiben ist nichts, was ein Server ungefragt tun sollte.
|
|
builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
|
|
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
|
|
|
|
builder.Services.AddScoped<LibraryScanner>();
|
|
|
|
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
|
|
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
|
|
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
|
builder.Services.AddHttpClient<TmdbClient>(c =>
|
|
{
|
|
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
|
// Ein hängender fremder Dienst darf einen Scan nicht festsetzen.
|
|
c.Timeout = TimeSpan.FromSeconds(20);
|
|
});
|
|
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
|
|
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
|
|
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
|
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
|
builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>());
|
|
builder.Services.AddScoped<MetadataEnricher>();
|
|
builder.Services.AddScoped<MatchApplier>();
|
|
builder.Services.AddSingleton<ScanCoordinator>();
|
|
builder.Services.AddSingleton<PackageCoordinator>();
|
|
builder.Services.AddProblemDetails();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Beim Start wandern, nicht über ein eigenes Kommando: wer den Container
|
|
// hochfährt, soll nicht erst eine Migration von Hand anstossen müssen.
|
|
using (var bereich = app.Services.CreateScope())
|
|
{
|
|
await bereich.ServiceProvider.GetRequiredService<FlaroContext>().Database.MigrateAsync();
|
|
}
|
|
|
|
app.UseExceptionHandler();
|
|
app.UseStatusCodePages();
|
|
app.UseRateLimiter();
|
|
|
|
// --- Die Oberfläche -------------------------------------------------------
|
|
//
|
|
// **Vom Server selbst ausgeliefert, nicht von einem Proxy davor.** Damit
|
|
// liegen Seite und Schnittstelle unter derselben Adresse: kein CORS, kein
|
|
// Mischmasch aus http und https, und der Zugang bleibt beim Server statt im
|
|
// Browser. Der Node-Proxy aus der Jellyfin-Zeit wird dadurch überflüssig.
|
|
//
|
|
// **Vor der Anmeldung, und das ist kein Versehen.** Die Rückfallregel weiter
|
|
// oben verlangt für jede Anfrage ohne eigenen Endpunkt einen angemeldeten
|
|
// Benutzer — und `/assets/index.js` hat keinen. Stünde das hier unter der
|
|
// Anmeldung, bekäme ein neuer Besucher 401 auf genau das Skript, das ihm die
|
|
// Anmeldemaske zeichnen soll. Die Seite selbst ist kein Geheimnis; sie zeigt
|
|
// ohne gültigen Zugang nichts als ein Anmeldefeld.
|
|
var oberfläche = Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
|
if (Directory.Exists(oberfläche))
|
|
{
|
|
app.UseDefaultFiles();
|
|
app.UseStaticFiles();
|
|
}
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapSystem();
|
|
app.MapAuth();
|
|
app.MapManagement();
|
|
app.MapLibraryContent();
|
|
app.MapSettings();
|
|
app.MapImages();
|
|
app.MapDelivery();
|
|
app.MapPlayback();
|
|
app.MapMatching();
|
|
|
|
// Alles, was keine Schnittstelle ist, bekommt die Seite — der Router läuft
|
|
// im Browser. Ohne das endet ein Neuladen auf /admin in einem 404.
|
|
if (Directory.Exists(oberfläche))
|
|
{
|
|
app.MapFallback(() => Results.File(Path.Combine(oberfläche, "index.html"), "text/html"))
|
|
.AllowAnonymous();
|
|
}
|
|
|
|
app.Run();
|
|
|
|
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
|
public partial class Program;
|