Files
Flaro/server/src/Flaro.Api/Program.cs
T
ScarriffleandClaude Opus 5 97edb49564 Eine zweite Bildquelle, weil TMDB nicht alles hat
„365 Tage" stand mit polnischem Plakat da. Nachgesehen: TMDB hat zu diesem
Film 26 Plakate und kein einziges deutsches. Die Auswahl von gestern nimmt
seither das sprachlose statt des polnischen — erfinden kann sie keines.

fanart.tv ist die Quelle, bei der jedes Plakat seine Sprache mitführt, und
die Gemeinschaft dort pflegt landessprachliche Fassungen. OMDb wäre keine
Hilfe gewesen: es liefert das Plakat von IMDb, und das ist in aller Regel
das amerikanische.

**Ausdrücklich keine zweite Metadatenquelle.** `IBildquelle` liefert
Bilder, sonst nichts. Titel, Beschreibung und Besetzung kommen weiterhin
von einer Stelle — zwei Anbieter für dieselbe Angabe wären zwei Wahrheiten.

**Gefragt wird nur, wenn es lohnt.** `MovieMetadata` trägt jetzt die
Sprache des gewählten Plakats mit. Hat TMDB schon eines in der
eingestellten Sprache, geht keine Anfrage hinaus — bei achthundert Titeln
ist das der Unterschied zwischen „läuft nebenher" und „hängt am fremden
Dienst". Und nur ein landessprachliches sticht: ein sprachloses von
fanart.tv ist nicht besser als ein sprachloses von TMDB.

**Die Rangfolge liegt jetzt im Kern**, nicht im TMDB-Client. Sie gilt für
alle Quellen, sonst kämen die Bilder zweier Anbieter in zwei Reihenfolgen
zusammen. Im Bilderdialog stehen beide Quellen gemeinsam geordnet: ein
deutsches Plakat von fanart.tv vor einem sprachlosen von TMDB.

**Nur Filme.** Der Serienweg von fanart.tv verlangt eine TVDB-Kennung, und
die führt Flaro nicht. Eine halbe Lösung, die bei Serien stillschweigend
nichts tut, wäre schlechter als eine, die sagt, worauf sie sich beschränkt.

**Kein mitgelieferter Schlüssel.** Bei TMDB gibt es einen als Rückfall;
fanart.tv verlangt einen persönlichen, und einen fremden mitzuliefern
hiesse, ihn fremd zu benutzen. Ohne eigenen Schlüssel geht keine Anfrage
hinaus — geprüft. Einzutragen unter Einstellungen → Übersicht → Metadaten.

Dass „00" dort „ohne Sprache" heisst, wird beim Lesen übersetzt: sonst
führte die gemeinsame Rangfolge ein sprachloses Plakat als Sprache „00"
und setzte es hinter Englisch statt davor.

732 Tests grün, davon sieben neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:18:27 +02:00

381 lines
17 KiB
C#

using Microsoft.AspNetCore.DataProtection;
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;
using Microsoft.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
var pfade = FlaroPaths.FromEnvironment(builder.Environment.ContentRootPath);
pfade.EnsureExists();
builder.Services.AddSingleton(pfade);
builder.Services.AddSingleton(TimeProvider.System);
// **Mit Wartezeit statt sofortigem Abbruch.**
//
// SQLite lässt genau einen Schreiber zu. Ohne `Default Timeout` scheitert
// jeder, der dazwischenkommt, auf der Stelle mit „database is locked" —
// während eines Scans über tausende Dateien ist das jede zweite Anfrage aus
// der Oberfläche. Dreissig Sekunden sind grosszügig; gebraucht werden
// Millisekunden.
builder.Services.AddDbContext<FlaroContext>(o =>
o.UseSqlite($"Data Source={pfade.DatabaseFile};Default Timeout=30"));
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<Schluesselbilder>();
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>();
builder.Services.AddScoped<Zusammenlegung>();
builder.Services.AddScoped<Uebersetzer>();
// 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.AddScoped<Wiedergabeeinstellungen>();
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>());
// **Eine zweite Bildquelle, und ausdrücklich keine zweite Metadatenquelle.**
// fanart.tv liefert Plakate mit Sprachangabe — genau das, was TMDB für
// manche Filme nicht hat. Titel, Beschreibung und Besetzung kommen
// weiterhin von einer Stelle; zwei Anbieter für dieselbe Angabe wären zwei
// Wahrheiten.
builder.Services.AddScoped<IFanartSettingsSource, DatabaseFanartSettings>();
builder.Services.AddHttpClient<FanartClient>(c =>
{
c.BaseAddress = new Uri(FanartClient.BaseAddress);
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IBildquelle>(sp => sp.GetRequiredService<FanartClient>());
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>();
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
// zum Mitnehmen; dieser lässt sie laufen, während sie entsteht.
builder.Services.AddSingleton<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>();
builder.Services.AddSingleton<Untertitelvorrat>();
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Kratzplatzeinstellungen>();
builder.Services.AddSingleton<Kratzplatzpflege>();
// **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt
// an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand
// etwas anklickt. Alles andere Wiederkehrende bleibt beiläufig.
builder.Services.AddScoped<Wartungsplaneinstellungen>();
builder.Services.AddSingleton<Wartungsplan>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<Wartungsplan>());
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
builder.Services.AddSingleton<PlaybackSessions>();
builder.Services.AddSingleton<Tagebuch>();
// **Die Schlüssel der Datenverschlüsselung gehören ins Volume.**
//
// Ohne diese Zeile legt ASP.NET sie unter `~/.aspnet/DataProtection-Keys` ab —
// also im Container, der beim nächsten Update weg ist. Heute fällt das nicht
// auf, weil Flaros Sitzungs-Cookie das Gerätetoken selbst trägt und nichts
// davon verschlüsselt wird. Es fällt erst auf, wenn irgendwann etwas doch
// darauf baut, und dann sieht es aus wie ein Fehler anderswo.
//
// Nebenbei verschwindet damit die Warnung, die bei jedem Start im Protokoll
// stand und nach einem Problem aussah, das keines war.
var schluesselOrdner = Path.Combine(pfade.Data, "keys");
Directory.CreateDirectory(schluesselOrdner);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(schluesselOrdner))
.SetApplicationName("Flaro");
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())
{
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
// **WAL, und zwar vor der Migration.**
//
// In der Vorgabe („delete") sperrt ein Schreiber die ganze Datei: solange
// der Scan schreibt, kommt *keine* Leseanfrage durch. Genau so sah es in
// der Oberfläche aus — Bilddialog und Protokoll liefen während eines
// Laufs sporadisch ins Leere. Mit WAL lesen beliebig viele weiter,
// während einer schreibt.
//
// Die Einstellung hält sich in der Datei; einmal gesetzt bleibt sie. Sie
// trotzdem bei jedem Start zu setzen kostet nichts und deckt den Fall ab,
// dass jemand mit einer frischen Datei kommt.
await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
// Ohne das wartet SQLite nach jeder Schreibung auf die Platte. Mit WAL
// ist „normal" die übliche Wahl: ein Absturz des Prozesses kostet nichts,
// nur ein Stromausfall mitten im Schreiben könnte die letzte Transaktion
// kosten — und die ist bei einem Medienserver ein Scanfortschritt.
await db.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;");
await db.Database.MigrateAsync();
}
// **Auch das Letzte, was durchrutscht, spricht Deutsch.**
//
// Die Vorgabe von ASP.NET antwortet mit „An error occurred while processing
// your request." — englisch, ohne Grund, ohne nächsten Schritt. Genau das
// stand im Zuordnungsdialog, als eine Kennung doppelt vergeben werden
// sollte. Hier bekommt jede Ausnahme, die es bis nach oben schafft, einen
// deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie
// sucht.
app.UseExceptionHandler(zweig => zweig.Run(async zusammenhang =>
{
var fehler = zusammenhang.Features
.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>()?.Error;
if (fehler is not null)
{
zusammenhang.RequestServices.GetRequiredService<Tagebuch>().Schreiben(
$"{zusammenhang.Request.Method} {zusammenhang.Request.Path}: "
+ Fehlertext.FuersProtokoll(fehler),
Tagebuch.Art.Fehler);
}
zusammenhang.Response.StatusCode = StatusCodes.Status500InternalServerError;
await Results.Problem(
fehler is null ? "Etwas ist schiefgegangen." : Fehlertext.FuerMenschen(fehler),
statusCode: StatusCodes.Status500InternalServerError)
.ExecuteAsync(zusammenhang);
}));
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();
// **Zwei Lebensdauern, und die Unterscheidung ist nötig.**
//
// Die Dateien unter `assets/` tragen eine Prüfsumme im Namen: ändert
// sich ihr Inhalt, ändert sich der Name. Sie dürfen deshalb für immer im
// Zwischenspeicher liegen — und sollen es auch, sonst lädt jeder Besuch
// dasselbe Skript erneut.
//
// `index.html` trägt keine. Sie nennt die Namen der Skripte, und wer sie
// aus dem Zwischenspeicher nimmt, bekommt nach einem Server-Update
// weiter die alte App — nicht kaputt, sondern **veraltet**, und das
// fällt niemandem auf. Genau das ist hier passiert: der Browser lud
// `index-C0K3V8eg.js`, während der Server längst `index-DomHN4_b.js`
// auslieferte.
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = zusammenhang =>
{
var name = zusammenhang.File.Name;
var kopf = zusammenhang.Context.Response.GetTypedHeaders();
if (name.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
{
kopf.CacheControl = new CacheControlHeaderValue
{
NoCache = true,
MustRevalidate = true,
};
}
else
{
kopf.CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromDays(365),
Extensions = { new NameValueHeaderValue("immutable") },
};
}
},
});
}
app.UseAuthentication();
app.UseAuthorization();
// **Nach der Anmeldung, vor den Endpunkten.** Davor stünde im Principal noch
// nichts, und ein Administrator sähe seine eigene Prüfliste nicht.
app.UseFreigabefilter();
app.MapSystem();
app.MapAuth();
app.MapManagement();
app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
app.MapStream();
app.MapPlayback();
app.MapMatching();
app.MapArtwork();
app.MapProfil();
app.MapFreigabe();
app.MapMeldungen();
app.MapEinladungen();
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
// Webseite mit Status 200 — ein vertippter Aufruf sieht dann aus wie ein
// Erfolg, und der Fehler fällt erst dort auf, wo HTML verarbeitet wird.
// Buchstaben schlagen Platzhalter, also gewinnt jeder echte Endpunkt hier.
app.Map("/api/{*rest}", () => Results.NotFound()).AllowAnonymous();
// Alles Übrige bekommt die Seite — der Router läuft im Browser. Ohne das
// endet ein Neuladen auf /verwaltung in einem 404.
if (Directory.Exists(oberfläche))
{
// Auch hier `no-cache`: der Rückfall liefert dieselbe `index.html`, und
// sie über diesen Weg zwischenspeichern zu lassen hätte genau denselben
// Effekt wie oben.
app.MapFallback((HttpContext zusammenhang) =>
{
zusammenhang.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
NoCache = true,
MustRevalidate = true,
};
return Results.File(Path.Combine(oberfläche, "index.html"), "text/html");
}).AllowAnonymous();
}
// **Start und Ende gehören ins Protokoll.**
//
// Ohne das war nicht zu unterscheiden, ob der Server während eines langen
// Scans nur beschäftigt oder zwischendurch neu gestartet war — und genau das
// macht den Unterschied zwischen „dauert eben" und „stürzt ab". Steht mitten
// im Scan ein Start, weiss man es beim nächsten Blick ins Protokoll.
{
var tagebuch = app.Services.GetRequiredService<Tagebuch>();
var leben = app.Services.GetRequiredService<IHostApplicationLifetime>();
leben.ApplicationStarted.Register(() =>
{
tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}");
// **Die Untertitel holen, bevor jemand sie einschaltet.**
//
// Eine Spur aus einer Datei zu ziehen heisst, die Datei ganz zu
// lesen — bei einem Dreistünder auf einem NAS sind das Minuten.
// Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten
// „wird geholt", und noch immer kein Text. Hier läuft es einmal je
// Datei durch, eine nach der anderen, während niemand wartet.
//
// **Und nicht sofort.** Der Lauf liest die ganze Mediathek von
// derselben Platte, von der die Startseite ihre Bilder holt. Wer in
// dem Augenblick aufmacht, in dem der Server hochkommt — und das ist
// der häufige Fall, denn man startet ihn ja, um etwas zu schauen —,
// wartete auf beides zugleich. Eine Minute reicht, damit die erste
// Seite steht.
// **Zuerst aufräumen.** Nach einem harten Ende liegt jeder
// Stromordner noch da und jedes Teilstück eines abgebrochenen
// Umpackens — und beim Start ist sicher, dass niemand davon noch
// gebraucht wird.
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromMinutes(1), leben.ApplicationStopping);
await app.Services.GetRequiredService<Untertitelvorrat>()
.NachtragenAsync(leben.ApplicationStopping);
}
catch (OperationCanceledException) { /* Der Server fährt herunter. */ }
});
});
leben.ApplicationStopping.Register(() =>
tagebuch.Schreiben("Server fährt herunter."));
}
app.Run();
/// <summary>Damit die Tests den Server hochfahren können.</summary>
public partial class Program;