Files
Flaro/server/src/Flaro.Api/Program.cs
T
ScarriffleandClaude Opus 5 ce5dc66047 Eine 500, die der Benutzer sieht und das Protokoll nicht
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>
2026-10-11 20:41:47 +02:00

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;