„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>
381 lines
17 KiB
C#
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;
|