Im Spieler stand „der Server hat den Download mit Fehler 500 abgelehnt", und im Tagesprotokoll war davon nichts zu finden. Der Haken, der Ausnahmen aufschreibt, fängt nur Ausnahmen; ein Endpunkt, der von sich aus `Results.Problem(… 500)` zurückgibt, läuft daran vorbei. Jetzt wird jede Antwort ab 500 vermerkt, mit Verfahren, Weg und Abfrage — egal woher sie kommt. Dieselbe Lehre wie beim ffmpeg-Fehler: eine Fehlermeldung, die nur der Benutzer sieht, ist eine Sackgasse. Dazu, weil es beim Suchen im Weg stand: „Hole Metadaten zu Phineas und Ferb 4×23 — 1334 von 1347" steht nicht mehr im Protokoll. Ein Auffrischen schrieb vierzehnhundert solche Zeilen und schob alles andere aus der Ansicht. Das ist Fortschritt, keine Auskunft; er gehört in die Fortschrittszeile, wo er auch steht. Was bleibt, sind die Vermerke — sie erklären, warum etwas so zugeordnet wurde — und die Zusammenfassung am Ende. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
470 lines
21 KiB
C#
470 lines
21 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.AddScoped<Saisoneinstellungen>();
|
|
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>());
|
|
|
|
// **Und eine für Bewertungen.** IMDbs eigene Schnittstelle kostet Geld,
|
|
// Rotten Tomatoes vergibt Zugänge nur an Partner, Letterboxd hat gar keine
|
|
// offene — OMDb liefert alle drei über die IMDb-Kennung, die ohnehin schon
|
|
// an jedem Film steht.
|
|
builder.Services.AddScoped<IOmdbSettingsSource, DatabaseOmdbSettings>();
|
|
builder.Services.AddHttpClient<OmdbClient>(c =>
|
|
{
|
|
c.BaseAddress = new Uri(OmdbClient.BaseAddress);
|
|
c.Timeout = TimeSpan.FromSeconds(20);
|
|
});
|
|
|
|
// **Rotten Tomatoes dazu, aber nur auf Wunsch.** Dort gibt es keine
|
|
// Schnittstelle — die beiden Werte stehen auf der Seite selbst, und OMDb
|
|
// gibt davon nur den der Kritik weiter. Das Auslesen einer fremden Webseite
|
|
// bricht, wenn dort umgebaut wird; deshalb ein Schalter, und der steht aus.
|
|
builder.Services.AddScoped<IRottenTomatoesSettingsSource, DatabaseRottenTomatoesSettings>();
|
|
|
|
// **Eine Anfrage je Sekunde, prozessweit.** Achthundert Titel in einem
|
|
// Schwall von einer Adresse wären eine Zumutung und würden gesperrt. Der
|
|
// Scan dauert dadurch länger; das ist der Preis fürs Mitbenutzen.
|
|
builder.Services.AddSingleton(new Anklopfbremse(TimeSpan.FromSeconds(1)));
|
|
builder.Services.AddHttpClient<RottenTomatoesClient>(c =>
|
|
{
|
|
c.BaseAddress = new Uri(RottenTomatoesClient.BaseAddress);
|
|
c.Timeout = TimeSpan.FromSeconds(20);
|
|
// Ohne Browserkennung antwortet die Seite mit einer Sperrseite statt
|
|
// mit dem Film.
|
|
c.DefaultRequestHeaders.UserAgent.ParseAdd(
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
+ "Chrome/131.0.0.0 Safari/537.36");
|
|
});
|
|
|
|
// **Die Reihenfolge ist die Rangfolge.** Beide führen den Wert der Kritik;
|
|
// OMDb aus zweiter Hand, Rotten Tomatoes als Quelle selbst. Wer hinten
|
|
// steht, überschreibt.
|
|
builder.Services.AddScoped<IBewertungsquelle>(sp => new Bewertungsquellen(
|
|
[
|
|
sp.GetRequiredService<OmdbClient>(),
|
|
sp.GetRequiredService<RottenTomatoesClient>(),
|
|
]));
|
|
|
|
// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine
|
|
// Auskunft darüber, ob der Dienst antwortet.
|
|
builder.Services.AddScoped<Quellenprobe>();
|
|
|
|
// **Damit ein festgehaltenes Ergebnis nicht ewig falsch bleibt.** Ob ein
|
|
// Titel in „Fortsetzen" steht, ist eine gespeicherte Spalte — berechnet
|
|
// wird sie nur, wenn eine Fortschrittsmeldung eintrifft. Nach einem Update
|
|
// einmal durchrechnen fängt genau das ab.
|
|
builder.Services.AddSingleton<Sehstandpflege>();
|
|
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>();
|
|
// **Von Hand gebaut, nicht aus dem Behälter geholt.** Die Protokollsenke
|
|
// braucht ihn, bevor der Behälter steht; ein `BuildServiceProvider()` dafür
|
|
// legte eine zweite Ausgabe an — zwei Tagebücher mit zwei Schlössern, die
|
|
// sich beim Schreiben ins Gehege kommen.
|
|
var tagebuch = new Tagebuch(pfade, TimeProvider.System);
|
|
builder.Services.AddSingleton(tagebuch);
|
|
|
|
// **Was der Server ohnehin meldet, gehört auch ins Tagesprotokoll.**
|
|
//
|
|
// „Scan gestartet", „Strom angelegt", „Untertitel gezogen" standen längst
|
|
// im Quelltext — sie gingen nur an die Container-Ausgabe, wo sie zwischen
|
|
// den EF-Abfragen untergehen. Die Senke nimmt nur Flaros eigene Kategorien;
|
|
// `Microsoft.*` bliebe sonst mit einer Zeile je Datenbankabfrage darin.
|
|
builder.Logging.AddProvider(new Tagebuchsenke(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.
|
|
// **Jede Fünfhunderter-Antwort wird vermerkt, egal woher sie kommt.**
|
|
//
|
|
// Der Haken darunter fängt nur Ausnahmen. Ein Endpunkt, der von sich aus
|
|
// `Results.Problem(... 500)` zurückgibt, läuft daran vorbei — und genau so
|
|
// eine Antwort stand in der App („der Server hat den Download mit Fehler
|
|
// 500 abgelehnt"), während im Protokoll nichts davon zu finden war. Eine
|
|
// Fehlermeldung, die der Benutzer sieht und der Betreiber nicht, ist eine
|
|
// Sackgasse; dieselbe Lehre wie beim ffmpeg-Fehler vor ein paar Tagen.
|
|
app.Use(async (zusammenhang, weiter) =>
|
|
{
|
|
await weiter(zusammenhang);
|
|
|
|
if (zusammenhang.Response.StatusCode >= 500)
|
|
{
|
|
zusammenhang.RequestServices.GetRequiredService<Tagebuch>().Schreiben(
|
|
$"{zusammenhang.Response.StatusCode} auf "
|
|
+ $"{zusammenhang.Request.Method} {zusammenhang.Request.Path}"
|
|
+ zusammenhang.Request.QueryString,
|
|
Tagebuch.Art.Fehler);
|
|
}
|
|
});
|
|
|
|
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 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);
|
|
|
|
_ = app.Services.GetRequiredService<Sehstandpflege>()
|
|
.NachUpdateAsync(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;
|