diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 0381e53..b8fea03 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -32,10 +32,14 @@ public sealed record SystemInfo( /// Ab wann Unbenutztes auch unterhalb der Grenze geht; 0 heisst nie. /// Wie lange frisch Gebrauchtes in jedem Fall bleibt. public sealed record MaintenanceSettingsRequest( - double? CapGb, int? UnusedDays, int? GraceMinutes); + double? CapGb, int? UnusedDays, int? GraceMinutes, + int? BackupDays = null, int? BackupKeep = null, string? BackupFolder = null, + int? OptimizeDays = null, int? ImageDays = null); public sealed record MaintenanceSettingsResponse( - double CapGb, int UnusedDays, int GraceMinutes); + double CapGb, int UnusedDays, int GraceMinutes, + int BackupDays, int BackupKeep, string BackupFolder, + int OptimizeDays, int ImageDays); /// /// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz. diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index ea4f6c9..a11a030 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -154,17 +154,22 @@ public static class SettingsEndpoints /* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */ - app.MapGet("/api/v1/settings/maintenance", async (Kratzplatzeinstellungen einstellungen) => + app.MapGet("/api/v1/settings/maintenance", async ( + Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) => { - var regeln = await einstellungen.RegelnAsync(); + var regeln = await kratz.RegelnAsync(); + var w = await plan.RegelnAsync(); return Results.Ok(new MaintenanceSettingsResponse( regeln.ObergrenzeBytes / 1024d / 1024 / 1024, (int)regeln.Unbenutzt.TotalDays, - (int)regeln.Schonfrist.TotalMinutes)); + (int)regeln.Schonfrist.TotalMinutes, + w.SicherungTage, w.SicherungBehalten, w.SicherungOrdner, + w.OptimierenTage, w.BilderTage)); }).RequireAuthorization(FlaroPolicies.Administrator); app.MapPut("/api/v1/settings/maintenance", async ( - MaintenanceSettingsRequest anfrage, FlaroContext db, Kratzplatzeinstellungen einstellungen) => + MaintenanceSettingsRequest anfrage, FlaroContext db, + Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) => { var vorher = await einstellungen.RegelnAsync(); @@ -182,12 +187,33 @@ public static class SettingsEndpoints ((int)nachher.Unbenutzt.TotalDays).ToString(zahl)); await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel, ((int)nachher.Schonfrist.TotalMinutes).ToString(zahl)); + var wVorher = await plan.RegelnAsync(); + var wNachher = Wartungsregeln.Geprueft( + anfrage.BackupDays ?? wVorher.SicherungTage, + anfrage.BackupKeep ?? wVorher.SicherungBehalten, + anfrage.BackupFolder ?? wVorher.SicherungOrdner, + anfrage.OptimizeDays ?? wVorher.OptimierenTage, + anfrage.ImageDays ?? wVorher.BilderTage); + + await Setzen(db, Wartungsplaneinstellungen.SicherungTageSchluessel, + wNachher.SicherungTage.ToString(zahl)); + await Setzen(db, Wartungsplaneinstellungen.SicherungBehaltenSchluessel, + wNachher.SicherungBehalten.ToString(zahl)); + await Setzen(db, Wartungsplaneinstellungen.SicherungOrdnerSchluessel, + wNachher.SicherungOrdner); + await Setzen(db, Wartungsplaneinstellungen.OptimierenTageSchluessel, + wNachher.OptimierenTage.ToString(zahl)); + await Setzen(db, Wartungsplaneinstellungen.BilderTageSchluessel, + wNachher.BilderTage.ToString(zahl)); + await db.SaveChangesAsync(); return Results.Ok(new MaintenanceSettingsResponse( nachher.ObergrenzeBytes / 1024d / 1024 / 1024, (int)nachher.Unbenutzt.TotalDays, - (int)nachher.Schonfrist.TotalMinutes)); + (int)nachher.Schonfrist.TotalMinutes, + wNachher.SicherungTage, wNachher.SicherungBehalten, wNachher.SicherungOrdner, + wNachher.OptimierenTage, wNachher.BilderTage)); }).RequireAuthorization(FlaroPolicies.Administrator); } diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 9d064e2..c95d6b7 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -275,6 +275,31 @@ public static class SystemEndpoints // Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes // von Gebrauchtem unterscheidet und einen laufenden Download in Ruhe // lässt, ist keine Löschung eines Dings. + // **Der Wartungsplan: was wann zuletzt lief und was dabei herauskam.** + app.MapGet("/api/v1/system/maintenance", async ( + Wartungsplan plan, CancellationToken ct) => + Results.Ok(await plan.StandAsync(ct))) + .RequireAuthorization(FlaroPolicies.Administrator); + + // **Jetzt ausführen, ohne auf den Plan zu warten.** + // + // Vor allem für die Sicherung: wer gleich etwas Grosses vorhat — + // eine Mediathek umbauen, eine Migration ziehen —, will vorher eine + // Kopie und nicht in drei Tagen. + app.MapPost("/api/v1/system/maintenance/{aufgabe}", async ( + string aufgabe, Wartungsplan plan, CancellationToken ct) => + { + if (!Enum.TryParse(aufgabe, ignoreCase: true, out var welche)) + return Einwand.Bitte("Diese Wartungsaufgabe gibt es nicht."); + + try { return Results.Ok(new { ergebnis = await plan.LaufenAsync(welche, ct) }); } + catch (Exception fehler) + { + // Der rohe Text bleibt im Protokoll; hier steht ein Satz. + return Results.Problem(Fehlertext.FuerMenschen(fehler)); + } + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapPost("/api/v1/system/storage/transcode/cleanup", async ( Kratzplatzpflege pflege, CancellationToken ct) => Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct))) diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index c00a284..e95f655 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -115,6 +115,13 @@ builder.Services.AddSingleton(); // Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe. builder.Services.AddScoped(); builder.Services.AddSingleton(); + +// **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(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); // 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(); diff --git a/server/src/Flaro.Api/Wartungsplan.cs b/server/src/Flaro.Api/Wartungsplan.cs new file mode 100644 index 0000000..bb631a5 --- /dev/null +++ b/server/src/Flaro.Api/Wartungsplan.cs @@ -0,0 +1,343 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Flaro.Core; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Was in Abständen geschieht: sichern, aufräumen, Bilder wegwerfen. +/// +/// +/// +/// Der erste Hintergrunddienst in diesem Server, und das mit Bedacht. +/// An drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne +/// Gegenwert — und für das Wegräumen abgelaufener Gerätecodes stimmt das: +/// solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung +/// hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand +/// etwas anklickt, und genau dafür gibt es eine Uhr. +/// +/// +/// Die Uhr tickt, der Dienst rechnet nicht. Alle fünfzehn Minuten +/// eine Frage an die Einstellungen, ob etwas fällig ist. Ist nichts fällig +/// — der Normalfall —, kostet das eine Abfrage auf eine Tabelle mit einer +/// Handvoll Zeilen. +/// +/// +/// Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. +/// Ein Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — +/// oder, bei einem Zähler, der bei null anfängt, nie. +/// +/// +public sealed class Wartungsplan( + IServiceScopeFactory scopes, + FlaroPaths pfade, + TimeProvider uhr, + Tagebuch tagebuch, + ILogger log) : BackgroundService +{ + /// Wie oft nachgesehen wird, ob etwas fällig ist. + /// + /// + /// Eine Viertelstunde ist fein genug für Abstände in Tagen und grob + /// genug, dass das Nachsehen selbst nicht auffällt. + /// + private static readonly TimeSpan Takt = TimeSpan.FromMinutes(15); + + /// Was zuletzt herauskam — fürs Dashboard, nicht für die Entscheidung. + private readonly ConcurrentDictionary _zuletzt = new(); + + private readonly SemaphoreSlim _einerZurZeit = new(1, 1); + + protected override async Task ExecuteAsync(CancellationToken ct) + { + // **Nicht sofort.** Beim Hochfahren hat der Server Wichtigeres zu + // tun — Migration, erster Seitenaufbau, Untertitelvorrat. Eine + // Sicherung, die zwei Minuten später anfängt, ist keine schlechtere. + try { await Task.Delay(TimeSpan.FromMinutes(2), ct); } + catch (OperationCanceledException) { return; } + + using var takt = new PeriodicTimer(Takt); + do + { + try { await FaelligesLaufenAsync(ct); } + catch (OperationCanceledException) { return; } + catch (Exception fehler) + { + // Ein Fehler in der Wartung darf den Dienst nicht beenden — + // sonst fällt mit der ersten vollen Platte auch die + // Sicherung für immer aus. + log.LogError(fehler, "Der Wartungslauf ist gescheitert."); + } + } + while (await takt.WaitForNextTickAsync(ct)); + } + + /// Führt aus, was fällig ist. + public async Task FaelligesLaufenAsync(CancellationToken ct) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + var regeln = await raum.ServiceProvider + .GetRequiredService().RegelnAsync(ct); + + var jetzt = uhr.GetUtcNow(); + var stand = await Wartungsplaneinstellungen.Werte(db, ct); + + foreach (var (aufgabe, tage) in new[] + { + (Wartungsaufgabe.Sicherung, regeln.SicherungTage), + (Wartungsaufgabe.Optimieren, regeln.OptimierenTage), + (Wartungsaufgabe.Bilder, regeln.BilderTage), + }) + { + if (tage <= 0) continue; + + var zuletzt = Zeit(stand, Wartungsplaneinstellungen.ZuletztSchluessel(aufgabe)); + if (zuletzt is { } wann && jetzt - wann < TimeSpan.FromDays(tage)) continue; + + await LaufenAsync(aufgabe, ct); + } + } + + /// Führt eine Aufgabe aus, egal ob sie fällig ist. + public async Task LaufenAsync(Wartungsaufgabe aufgabe, CancellationToken ct) + { + await _einerZurZeit.WaitAsync(ct); + try + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + var regeln = await raum.ServiceProvider + .GetRequiredService().RegelnAsync(ct); + + string ergebnis; + try + { + ergebnis = aufgabe switch + { + Wartungsaufgabe.Sicherung => await SichernAsync(db, regeln, ct), + Wartungsaufgabe.Optimieren => await OptimierenAsync(db, ct), + Wartungsaufgabe.Bilder => await BilderAufraeumenAsync(db, ct), + _ => "Unbekannte Aufgabe.", + }; + _zuletzt[aufgabe] = (ergebnis, null); + tagebuch.Schreiben($"Wartung — {aufgabe}: {ergebnis}"); + } + catch (Exception fehler) + { + _zuletzt[aufgabe] = (null, Fehlertext.FuerMenschen(fehler)); + log.LogError(fehler, "Die Wartungsaufgabe {Aufgabe} ist gescheitert.", aufgabe); + tagebuch.Schreiben($"Wartung — {aufgabe} gescheitert: {Fehlertext.FuerMenschen(fehler)}", + Tagebuch.Art.Fehler); + throw; + } + finally + { + // **Auch nach einem Fehlschlag merken.** Sonst versucht der + // Plan es alle fünfzehn Minuten wieder, und eine volle + // Platte erzeugt ein volles Protokoll. + await Merken(db, Wartungsplaneinstellungen.ZuletztSchluessel(aufgabe), + uhr.GetUtcNow().ToString("O", CultureInfo.InvariantCulture), ct); + } + + return ergebnis; + } + finally + { + _einerZurZeit.Release(); + } + } + + /// Der Stand aller Aufgaben — für die Oberfläche. + public async Task> StandAsync(CancellationToken ct) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + var regeln = await raum.ServiceProvider + .GetRequiredService().RegelnAsync(ct); + var werte = await Wartungsplaneinstellungen.Werte(db, ct); + + return new[] + { + (Wartungsaufgabe.Sicherung, regeln.SicherungTage), + (Wartungsaufgabe.Optimieren, regeln.OptimierenTage), + (Wartungsaufgabe.Bilder, regeln.BilderTage), + }.Select(x => + { + var zuletzt = Zeit(werte, Wartungsplaneinstellungen.ZuletztSchluessel(x.Item1)); + var (ergebnis, fehler) = _zuletzt.GetValueOrDefault(x.Item1); + return new Wartungsstand( + x.Item1.ToString(), x.Item2, zuletzt, + x.Item2 > 0 ? (zuletzt ?? uhr.GetUtcNow()).AddDays(x.Item2) : null, + ergebnis, fehler); + }).ToList(); + } + + /* ---------- Die Aufgaben ---------- */ + + /// Eine Kopie der Datenbank, in sich stimmig. + /// + /// + /// VACUUM INTO statt Datei kopieren. Die Datenbank läuft im + /// WAL-Modus: neben flaro.db stehen -wal und -shm, + /// und wer nur die erste Datei kopiert, bekommt einen Stand von vor + /// Stunden — oder einen halben. VACUUM INTO schreibt eine + /// vollständige, aufgeräumte Kopie, während der Betrieb weiterläuft. + /// + private async Task SichernAsync(FlaroContext db, Wartungsregeln regeln, CancellationToken ct) + { + var ordner = regeln.SicherungOrdner.Length > 0 + ? regeln.SicherungOrdner + : Path.Combine(pfade.Data, "sicherungen"); + + Directory.CreateDirectory(ordner); + + var name = $"flaro-{uhr.GetUtcNow():yyyy-MM-dd-HHmm}.db"; + var ziel = Path.Combine(ordner, name); + + // VACUUM INTO legt die Datei selbst an und bricht ab, wenn sie steht. + if (File.Exists(ziel)) File.Delete(ziel); + + // **Als Parameter, nicht in die Anweisung geschrieben.** Der Ordner + // ist eine Einstellung; ihn in den Text zu kleben hiesse, eine + // Einstellung zu einer SQL-Anweisung zu machen. SQLite nimmt an + // dieser Stelle einen gebundenen Wert. + await db.Database.ExecuteSqlRawAsync("VACUUM INTO {0}", [ziel], ct); + + var weg = Aufbewahren(ordner, regeln.SicherungBehalten); + var gross = new FileInfo(ziel).Length; + + return $"{name}, {gross / (1024 * 1024)} MB" + + (weg > 0 ? $", {weg} ältere entfernt" : ""); + } + + /// Behält die neuesten Sicherungen und wirft den Rest weg. + private int Aufbewahren(string ordner, int behalten) + { + var weg = 0; + foreach (var alt in Directory.EnumerateFiles(ordner, "flaro-*.db") + .OrderByDescending(d => d) + .Skip(behalten)) + { + try { File.Delete(alt); weg++; } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + log.LogDebug(fehler, "Die alte Sicherung {Datei} liess sich nicht löschen.", alt); + } + } + + return weg; + } + + /// Räumt die Datenbank auf. + /// + /// + /// PRAGMA optimize frischt die Statistiken auf, nach denen SQLite + /// seine Abfragepläne wählt; VACUUM packt die Datei zusammen. + /// Beides sperrt kurz — deshalb nicht bei jedem Start, sondern nach Plan. + /// + private async Task OptimierenAsync(FlaroContext db, CancellationToken ct) + { + var pfad = pfade.DatabaseFile; + var vorher = File.Exists(pfad) ? new FileInfo(pfad).Length : 0; + + await db.Database.ExecuteSqlRawAsync("PRAGMA optimize;", ct); + await db.Database.ExecuteSqlRawAsync("VACUUM;", ct); + + var nachher = File.Exists(pfad) ? new FileInfo(pfad).Length : 0; + var frei = vorher - nachher; + + return frei > 0 + ? $"{frei / 1024} KB kleiner" + : "nichts zusammenzupacken"; + } + + /// Wirft Bilder weg, auf die kein Titel mehr zeigt. + /// + /// + /// Warum nicht einfach alles. Den Bildspeicher zu leeren gibt es + /// als Knopf, und es ist gefahrlos — nur holt der Server danach jedes + /// Plakat neu, und das sind bei achthundert Titeln ein paar tausend + /// Abrufe bei TMDB. Hier fliegt nur, worauf nichts mehr zeigt. + /// + /// Eine Stunde Schonfrist. Ein Bild wird abgelegt, bevor der + /// Verweis darauf gespeichert ist. Ohne diese Frist räumte ein Lauf + /// mitten im Scan genau das weg, was gerade geholt wurde. + /// + private async Task BilderAufraeumenAsync(FlaroContext db, CancellationToken ct) + { + var ordner = Path.Combine(pfade.Cache, "images"); + if (!Directory.Exists(ordner)) return "kein Bildspeicher da"; + + var benutzt = new HashSet(StringComparer.Ordinal); + + void Sammeln(IEnumerable schluessel) + { + foreach (var s in schluessel) if (!string.IsNullOrEmpty(s)) benutzt.Add(s); + } + + // **Drei Spalten je Zeile, nicht drei Abfragen.** `SelectMany` über + // ein frisch gebautes Feld lässt sich nicht nach SQL übersetzen — + // EF wirft dafür. Also je Zeile ein Tripel holen und hier auffalten. + foreach (var m in await db.Movies.AsNoTracking() + .Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage }).ToListAsync(ct)) + { + Sammeln([m.PosterImage, m.BackdropImage, m.LogoImage]); + } + + foreach (var r in await db.Series.AsNoTracking() + .Select(r => new { r.PosterImage, r.BackdropImage, r.LogoImage }).ToListAsync(ct)) + { + Sammeln([r.PosterImage, r.BackdropImage, r.LogoImage]); + } + Sammeln(await db.Episodes.AsNoTracking().Select(e => e.StillImage).ToListAsync(ct)); + Sammeln(await db.People.AsNoTracking().Select(p => p.Image).ToListAsync(ct)); + Sammeln(await db.Users.AsNoTracking().Select(u => u.AvatarImage).ToListAsync(ct)); + + var jetzt = uhr.GetUtcNow(); + long frei = 0; + var zahl = 0; + + foreach (var datei in Directory.EnumerateFiles(ordner, "*.img", SearchOption.AllDirectories)) + { + var schluessel = Path.GetFileNameWithoutExtension(datei); + if (benutzt.Contains(schluessel)) continue; + + try + { + var info = new FileInfo(datei); + if (jetzt - new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero) < TimeSpan.FromHours(1)) continue; + + var gross = info.Length; + File.Delete(datei); + frei += gross; + zahl++; + } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", datei); + } + } + + return zahl == 0 ? "nichts verwaist" : $"{zahl} Bild(er), {frei / 1024} KB frei"; + } + + /* ---------- Kleinkram ---------- */ + + private static DateTimeOffset? Zeit(IReadOnlyDictionary werte, string schluessel) => + werte.TryGetValue(schluessel, out var roh) + && DateTimeOffset.TryParse(roh, CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out var wann) + ? wann + : null; + + private static async Task Merken(FlaroContext db, string schluessel, string wert, CancellationToken ct) + { + var zeile = await db.Settings.FindAsync([schluessel], ct); + if (zeile is null) db.Settings.Add(new ServerSetting { Key = schluessel, Value = wert }); + else zeile.Value = wert; + + await db.SaveChangesAsync(ct); + } +} diff --git a/server/src/Flaro.Api/Wartungsplaneinstellungen.cs b/server/src/Flaro.Api/Wartungsplaneinstellungen.cs new file mode 100644 index 0000000..5080195 --- /dev/null +++ b/server/src/Flaro.Api/Wartungsplaneinstellungen.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using Flaro.Core; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Was regelmässig geschieht und wie oft. +/// +/// Abstand zwischen zwei Sicherungen; 0 heisst: keine. +/// Wie viele Sicherungen liegen bleiben. +/// Wohin; leer heisst: neben die Datenbank. +/// Abstand fürs Aufräumen der Datenbank; 0 heisst: nie. +/// Abstand fürs Wegräumen verwaister Bilder; 0 heisst: nie. +public sealed record Wartungsregeln( + int SicherungTage, + int SicherungBehalten, + string SicherungOrdner, + int OptimierenTage, + int BilderTage) +{ + /// + /// Alle drei Tage sichern, fünf behalten. Was man verliert, ist + /// höchstens das, was seit der letzten Sicherung dazukam — bei drei Tagen + /// also ein paar Sehstände und vielleicht ein Scan. Fünf Stände decken + /// zwei Wochen ab, und das reicht, um einen Fehler zu bemerken, bevor + /// die letzte heile Kopie verfallen ist. + /// + public static Wartungsregeln Vorgabe { get; } = new(3, 5, "", 7, 7); + + public static Wartungsregeln Geprueft( + int sicherungTage, int behalten, string? ordner, int optimierenTage, int bilderTage) => + new(Math.Clamp(sicherungTage, 0, 365), + Math.Clamp(behalten, 1, 100), + (ordner ?? "").Trim(), + Math.Clamp(optimierenTage, 0, 365), + Math.Clamp(bilderTage, 0, 365)); +} + +/// Liest die Wartungsregeln aus den Einstellungen. +/// +/// +/// Nach dem Vorbild von . Die Tabelle +/// ist generisch; es braucht keine Migration. +/// +public sealed class Wartungsplaneinstellungen(FlaroContext db) +{ + public const string SicherungTageSchluessel = "wartung.sicherung.tage"; + public const string SicherungBehaltenSchluessel = "wartung.sicherung.behalten"; + public const string SicherungOrdnerSchluessel = "wartung.sicherung.ordner"; + public const string OptimierenTageSchluessel = "wartung.optimieren.tage"; + public const string BilderTageSchluessel = "wartung.bilder.tage"; + + /// Wann eine Aufgabe zuletzt gelaufen ist — ebenfalls in den Einstellungen. + /// + /// + /// Und nicht im Arbeitsspeicher. Ein Server, der jede Nacht neu + /// startet, führte sonst jeden Morgen eine Sicherung aus — oder, wenn + /// der Zähler bei null anfinge, nie eine. + /// + public static string ZuletztSchluessel(Wartungsaufgabe aufgabe) => $"wartung.{aufgabe}.zuletzt".ToLowerInvariant(); + + private Wartungsregeln? _gemerkt; + + public async Task RegelnAsync(CancellationToken ct = default) + { + if (_gemerkt is { } fertig) return fertig; + + var werte = await Werte(db, ct); + var vorgabe = Wartungsregeln.Vorgabe; + + _gemerkt = Wartungsregeln.Geprueft( + (int)Zahl(werte, SicherungTageSchluessel, vorgabe.SicherungTage), + (int)Zahl(werte, SicherungBehaltenSchluessel, vorgabe.SicherungBehalten), + werte.GetValueOrDefault(SicherungOrdnerSchluessel, vorgabe.SicherungOrdner), + (int)Zahl(werte, OptimierenTageSchluessel, vorgabe.OptimierenTage), + (int)Zahl(werte, BilderTageSchluessel, vorgabe.BilderTage)); + + return _gemerkt; + } + + internal static async Task> Werte(FlaroContext db, CancellationToken ct) => + await db.Settings.AsNoTracking() + .Where(e => e.Key.StartsWith("wartung.")) + .ToDictionaryAsync(e => e.Key, e => e.Value, ct); + + private static double Zahl(IReadOnlyDictionary werte, string schluessel, double vorgabe) => + werte.TryGetValue(schluessel, out var roh) + && double.TryParse(roh, NumberStyles.Float, CultureInfo.InvariantCulture, out var wert) + ? wert + : vorgabe; +} + +/// Die Aufgaben, die der Plan kennt. +public enum Wartungsaufgabe +{ + /// Eine Kopie der Datenbank. + Sicherung, + + /// Die Datenbank aufräumen. + Optimieren, + + /// Bilder wegräumen, zu denen es keinen Titel mehr gibt. + Bilder, +} + +/// Was eine Aufgabe zuletzt getan hat. +public sealed record Wartungsstand( + string Aufgabe, + int Tage, + DateTimeOffset? Zuletzt, + DateTimeOffset? Faellig, + string? Ergebnis, + string? Fehler); diff --git a/server/tests/Flaro.Api.Tests/WartungsplanTests.cs b/server/tests/Flaro.Api.Tests/WartungsplanTests.cs new file mode 100644 index 0000000..b73e6d6 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/WartungsplanTests.cs @@ -0,0 +1,267 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api; +using Flaro.Api.Endpoints; +using Microsoft.Data.Sqlite; + +namespace Flaro.Api.Tests; + +/// Sichern, aufräumen, Bilder wegwerfen — und was dabei herauskommt. +/// +/// +/// +/// Die Sicherung ist das Stück, das im Ernstfall zählt. Eine Kopie, +/// die sich nicht öffnen lässt, ist schlimmer als keine: man verlässt sich +/// darauf und merkt es erst, wenn es zu spät ist. Deshalb prüft der Test +/// nicht, dass eine Datei entstanden ist, sondern dass sie sich als +/// Datenbank öffnen lässt und die Zeilen enthält. +/// +/// +/// Warum nicht einfach die Datei kopieren. Die Datenbank läuft im +/// WAL-Modus: neben flaro.db stehen -wal und -shm, und +/// wer nur die erste kopiert, bekommt einen Stand von vor Stunden oder +/// einen halben. VACUUM INTO schreibt eine vollständige Kopie im +/// laufenden Betrieb — genau das hält dieser Test fest. +/// +/// +[TestFixture] +public sealed class WartungsplanTests +{ + private static async Task AlsAdmin(FlaroServer server) + { + var admin = await server.SetUpAndSignIn(); + return server.AlsAngemeldeter(admin.Token); + } + + private static Task Ausfuehren(HttpClient client, string aufgabe) => + client.PostAsync($"/api/v1/system/maintenance/{aufgabe}", null); + + private static async Task Datenordner(HttpClient client) + { + var orte = await client.GetFromJsonAsync>("/api/v1/system/storage"); + return orte!.First(o => o.Variable == "FLARO_DATA").Pfad; + } + + /// Die Sicherung ergibt eine Datenbank, die sich öffnen lässt. + [Test] + public async Task Die_Sicherung_ist_eine_lesbare_Datenbank() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + var antwort = await Ausfuehren(client, "sicherung"); + antwort.EnsureSuccessStatusCode(); + + var ordner = Path.Combine(await Datenordner(client), "sicherungen"); + var kopien = Directory.GetFiles(ordner, "flaro-*.db"); + + Assert.That(kopien, Has.Length.EqualTo(1)); + + // Die Probe aufs Exempel: öffnen und zählen. + await using var verbindung = new SqliteConnection($"Data Source={kopien[0]};Mode=ReadOnly"); + await verbindung.OpenAsync(); + await using var befehl = verbindung.CreateCommand(); + befehl.CommandText = "SELECT COUNT(*) FROM Users"; + + Assert.That(Convert.ToInt32(await befehl.ExecuteScalarAsync()), Is.EqualTo(1), + "der eingerichtete Administrator muss in der Kopie stehen"); + } + + /// Es bleiben so viele Sicherungen liegen, wie eingestellt ist. + /// + /// + /// Ohne Grenze füllt eine Sicherung alle drei Tage über ein Jahr + /// hundertzwanzig Kopien derselben Datenbank an — auf demselben Volume, + /// das sie schützen soll. + /// + [Test] + public async Task Alte_Sicherungen_werden_weggeraeumt() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + await client.PutAsJsonAsync("/api/v1/settings/maintenance", + new MaintenanceSettingsRequest(null, null, null, BackupKeep: 2)); + + var ordner = Path.Combine(await Datenordner(client), "sicherungen"); + Directory.CreateDirectory(ordner); + // Drei ältere Stände, die der Lauf überzählig macht. + foreach (var tag in new[] { "2020-01-01-0000", "2020-01-02-0000", "2020-01-03-0000" }) + await File.WriteAllTextAsync(Path.Combine(ordner, $"flaro-{tag}.db"), "alt"); + + (await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode(); + + Assert.That(Directory.GetFiles(ordner, "flaro-*.db"), Has.Length.EqualTo(2), + "die neue und die neueste alte"); + } + + /// Die Sicherung landet im eingestellten Ordner. + [Test] + public async Task Ein_eigener_Ordner_wird_benutzt() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + var eigen = Path.Combine(Path.GetTempPath(), "flaro-sicherung-" + Guid.CreateVersion7().ToString("N")); + try + { + await client.PutAsJsonAsync("/api/v1/settings/maintenance", + new MaintenanceSettingsRequest(null, null, null, BackupFolder: eigen)); + + (await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode(); + + Assert.That(Directory.GetFiles(eigen, "flaro-*.db"), Has.Length.EqualTo(1)); + } + finally + { + try { Directory.Delete(eigen, recursive: true); } catch (IOException) { /* Kür */ } + } + } + + /// Das Aufräumen der Datenbank läuft durch und meldet etwas. + [Test] + public async Task Die_Datenbank_laesst_sich_optimieren() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + var antwort = await Ausfuehren(client, "optimieren"); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + /// Ein Bild, auf das nichts zeigt, fliegt — ein benutztes nicht. + /// + /// + /// Die Schonfrist ist der Grund für die Zeitstempel. Ein Bild + /// wird abgelegt, bevor der Verweis darauf gespeichert ist; ohne Frist + /// räumte ein Lauf mitten im Scan genau das weg, was gerade geholt wurde. + /// + [Test] + public async Task Verwaiste_Bilder_fliegen_frische_bleiben() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + var orte = await client.GetFromJsonAsync>("/api/v1/system/storage"); + var bilder = Path.Combine(orte!.First(o => o.Variable == "FLARO_CACHE").Pfad, "images", "ab"); + Directory.CreateDirectory(bilder); + + var alt = Path.Combine(bilder, "abverwaistesbild.img"); + var frisch = Path.Combine(bilder, "abfrischesbild.img"); + await File.WriteAllBytesAsync(alt, new byte[256]); + await File.WriteAllBytesAsync(frisch, new byte[256]); + File.SetLastWriteTimeUtc(alt, DateTime.UtcNow.AddDays(-1)); + + (await Ausfuehren(client, "bilder")).EnsureSuccessStatusCode(); + + Assert.Multiple(() => + { + Assert.That(File.Exists(alt), Is.False, "auf dieses Bild zeigt nichts mehr"); + Assert.That(File.Exists(frisch), Is.True, + "das könnte gerade erst abgelegt und noch nicht verknüpft sein"); + }); + } + + /// Der Stand nennt jede Aufgabe mit ihrem Abstand. + [Test] + public async Task Der_Stand_nennt_alle_drei_Aufgaben() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + var stand = await client.GetFromJsonAsync>("/api/v1/system/maintenance"); + + Assert.Multiple(() => + { + Assert.That(stand!.Select(s => s.Aufgabe), + Is.EquivalentTo(new[] { "Sicherung", "Optimieren", "Bilder" })); + Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Tage, Is.EqualTo(3)); + Assert.That(stand.All(s => s.Zuletzt is null), Is.True, "noch lief keine"); + }); + } + + /// Nach einem Lauf steht da, wann er war und was herauskam. + [Test] + public async Task Nach_einem_Lauf_steht_der_Zeitpunkt_fest() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + await Ausfuehren(client, "sicherung"); + var stand = await client.GetFromJsonAsync>("/api/v1/system/maintenance"); + var sicherung = stand!.First(s => s.Aufgabe == "Sicherung"); + + Assert.Multiple(() => + { + Assert.That(sicherung.Zuletzt, Is.Not.Null); + Assert.That(sicherung.Faellig, Is.Not.Null, "und wann die nächste ansteht"); + Assert.That(sicherung.Ergebnis, Does.Contain("MB")); + }); + } + + [Test] + public async Task Eine_unbekannte_Aufgabe_wird_abgewiesen() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + var antwort = await Ausfuehren(client, "kaffeekochen"); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Wartung_verlangt_Administratorrechte() + { + using var server = new FlaroServer(); + var admin = await AlsAdmin(server); + var gast = await server.NeuerBenutzer(admin, "gast", "gastpasswortlang"); + + Assert.Multiple(async () => + { + Assert.That((await Ausfuehren(gast, "sicherung")).StatusCode, + Is.EqualTo(HttpStatusCode.Forbidden)); + Assert.That((await gast.GetAsync("/api/v1/system/maintenance")).StatusCode, + Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + /// Ein Abstand von null heisst: diese Aufgabe läuft nicht von selbst. + [Test] + public async Task Abgeschaltet_bedeutet_kein_Faelligkeitstermin() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + await client.PutAsJsonAsync("/api/v1/settings/maintenance", + new MaintenanceSettingsRequest(null, null, null, BackupDays: 0)); + var stand = await client.GetFromJsonAsync>("/api/v1/system/maintenance"); + + Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Faellig, Is.Null); + } + + /// Die Einstellungen des Plans kommen zurück, wie sie gesetzt wurden. + [Test] + public async Task Die_Vorgaben_des_Plans_stehen_und_lassen_sich_aendern() + { + using var server = new FlaroServer(); + var client = await AlsAdmin(server); + + var vorher = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); + + await client.PutAsJsonAsync("/api/v1/settings/maintenance", + new MaintenanceSettingsRequest(null, null, null, BackupDays: 1, BackupKeep: 10)); + var nachher = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); + + Assert.Multiple(() => + { + Assert.That(vorher!.BackupDays, Is.EqualTo(3)); + Assert.That(vorher.OptimizeDays, Is.EqualTo(7)); + Assert.That(nachher!.BackupDays, Is.EqualTo(1)); + Assert.That(nachher.BackupKeep, Is.EqualTo(10)); + Assert.That(nachher.OptimizeDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt"); + Assert.That(nachher.CapGb, Is.EqualTo(20).Within(0.01), "und der Kratzplatz auch"); + }); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index dcf7eff..2cc6e1d 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1035,7 +1035,27 @@ export type Aufraeumbericht = { export const kratzplatzAufraeumen = () => anfrage('/system/storage/transcode/cleanup', { method: 'POST' }); -export type Wartungseinstellung = { capGb: number; unusedDays: number; graceMinutes: number }; +export type Wartungseinstellung = { + capGb: number; unusedDays: number; graceMinutes: number; + backupDays: number; backupKeep: number; backupFolder: string; + optimizeDays: number; imageDays: number; +}; + +/** Was eine Wartungsaufgabe zuletzt getan hat. */ +export type Wartungsstand = { + aufgabe: 'Sicherung' | 'Optimieren' | 'Bilder'; + tage: number; + zuletzt: string | null; + faellig: string | null; + ergebnis: string | null; + fehler: string | null; +}; + +export const wartungsstand = () => anfrage('/system/maintenance'); + +/** Führt eine Wartungsaufgabe sofort aus, ohne auf den Plan zu warten. */ +export const wartungAusfuehren = (aufgabe: string) => + anfrage<{ ergebnis: string }>(`/system/maintenance/${aufgabe}`, { method: 'POST' }); export const wartungseinstellung = () => anfrage('/settings/maintenance'); diff --git a/web/app/src/views/einstellungen/index.ts b/web/app/src/views/einstellungen/index.ts index 9ef360b..1216089 100644 --- a/web/app/src/views/einstellungen/index.ts +++ b/web/app/src/views/einstellungen/index.ts @@ -10,12 +10,13 @@ import { wiedergabebereich } from './wiedergabe'; import { sprachbereich } from './metadaten'; import { benutzerbereich } from './benutzer'; import { protokollbereich } from './protokolle'; +import { wartungsbereich } from './wartung'; /** Welcher Teil der Einstellungen offen ist. */ export type Bereich = | 'konto' | 'geschaut' | 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung' - | 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle'; + | 'wiedergabe' | 'metadaten' | 'benutzer' | 'wartung' | 'protokolle'; type Eintrag = { wert: Bereich; text: string; bild: IconName }; @@ -34,6 +35,7 @@ const SERVER: Eintrag[] = [ { wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' }, { wert: 'metadaten', text: 'Metadaten', bild: 'stift' }, { wert: 'benutzer', text: 'Benutzer', bild: 'serien' }, + { wert: 'wartung', text: 'Wartung', bild: 'stufen' }, { wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' }, ]; @@ -162,6 +164,8 @@ export function einstellungenseite( return [sprachbereich()]; case 'benutzer': return [benutzerbereich()]; + case 'wartung': + return [wartungsbereich()]; case 'protokolle': return [protokollbereich()]; case 'uebersicht': diff --git a/web/app/src/views/einstellungen/wartung.ts b/web/app/src/views/einstellungen/wartung.ts new file mode 100644 index 0000000..a72d409 --- /dev/null +++ b/web/app/src/views/einstellungen/wartung.ts @@ -0,0 +1,196 @@ +import { + wartungsstand, wartungAusfuehren, wartungseinstellung, wartungSetzen, + type Wartungsstand, type Wartungseinstellung, +} from '../../api'; +import { fehlerzeile, knopf, meldung, hinweisZeigen } from '../../util'; +import { dialog, type Dialogfeld } from '../../dialog'; +import { abschnitt, zeile } from './teile'; + +/** Was der Server in Abständen von selbst tut. + * + * **Warum das eine eigene Seite ist.** Die drei Zahlen zum Kratzplatz + * standen zuerst unter „Wiedergabe" — dort ist ihr Anlass, aber nicht ihr + * Thema. Sobald es eine Sicherung und ein Aufräumen der Datenbank gibt, + * gehören sie zusammen: alles hier ist etwas, das der Server ohne Zutun + * erledigt, und man sieht es an einem Ort nach. + */ + +/** Wie die Aufgaben heissen, wenn man sie liest statt sie aufzurufen. */ +const NAMEN: Record = { + Sicherung: { + titel: 'Sicherung der Datenbank', + was: 'Eine vollständige Kopie, im laufenden Betrieb geschrieben. ' + + 'Das ist das Stück, das im Ernstfall zählt.', + }, + Optimieren: { + titel: 'Datenbank aufräumen', + was: 'Packt die Datei zusammen und frischt die Statistiken auf, nach ' + + 'denen SQLite seine Abfragen plant. Sperrt dabei für ein paar Sekunden.', + }, + Bilder: { + titel: 'Verwaiste Bilder entfernen', + was: 'Nur Bilder, auf die kein Titel mehr zeigt. Den ganzen Bildspeicher ' + + 'zu leeren gibt es als Knopf in der Übersicht — das kostet danach ' + + 'aber tausende Abrufe bei TMDB.', + }, +}; + +export function wartungsbereich(): HTMLElement { + const bereich = document.createElement('section'); + bereich.appendChild(abschnitt('Wartung')); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + liste.innerHTML = '
'; + bereich.appendChild(liste); + + void laden(); + return bereich; + + async function laden(): Promise { + let stand: Wartungsstand[]; + let werte: Wartungseinstellung; + try { + [stand, werte] = await Promise.all([wartungsstand(), wartungseinstellung()]); + } catch (fehler) { + liste.innerHTML = ''; + liste.appendChild(fehlerzeile(meldung(fehler))); + return; + } + + liste.innerHTML = ''; + stand.forEach(s => liste.appendChild(aufgabe(s, werte))); + + /* ---------- Der Kratzplatz ---------- */ + + liste.appendChild(abschnitt('Kratzplatz')); + + liste.appendChild(zeile( + 'Obergrenze', + werte.capGb > 0 ? `${werte.capGb} GB` : 'Unbegrenzt', + 'Wird sie überschritten, geht das am längsten Ungenutzte zuerst. ' + + 'Was gerade heruntergeladen wird, bleibt in jedem Fall. 0 heisst unbegrenzt.', + () => zahlFragen('Obergrenze', 'Gigabyte', werte.capGb, v => wartungSetzen({ capGb: v })))); + + liste.appendChild(zeile( + 'Unbenutzt löschen nach', + werte.unusedDays > 0 ? `${werte.unusedDays} Tagen` : 'Nie', + 'Gilt auch unterhalb der Obergrenze: ein Topf, in dem seit Wochen ' + + 'nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.', + () => zahlFragen('Unbenutzt löschen nach', 'Tagen', werte.unusedDays, + v => wartungSetzen({ unusedDays: v })))); + + liste.appendChild(zeile( + 'Schonfrist', + `${werte.graceMinutes} Minuten`, + 'So lange bleibt eine frisch gebrauchte Datei in jedem Fall liegen. ' + + 'Weniger als fünf Minuten lässt der Server nicht zu — das löschte ' + + 'eine Datei mitten im Herunterladen weg.', + () => zahlFragen('Schonfrist', 'Minuten', werte.graceMinutes, + v => wartungSetzen({ graceMinutes: v })))); + } + + /** Eine Aufgabe mit Abstand, letztem Lauf und einem Knopf zum Sofortstart. */ + function aufgabe(s: Wartungsstand, werte: Wartungseinstellung): HTMLElement { + const { titel, was } = NAMEN[s.aufgabe]; + + const takt = s.tage > 0 + ? (s.tage === 1 ? 'Täglich' : `Alle ${s.tage} Tage`) + : 'Abgeschaltet'; + + // **Der letzte Lauf gehört in dieselbe Zeile wie der Takt.** „Alle 3 + // Tage" allein sagt nicht, ob es je geschehen ist — und genau das will + // man bei einer Sicherung wissen. + const wann = s.fehler + ? `zuletzt gescheitert: ${s.fehler}` + : s.zuletzt + ? `zuletzt ${datum(s.zuletzt)}${s.ergebnis ? ` — ${s.ergebnis}` : ''}` + : 'noch nie gelaufen'; + + const d = zeile(titel, `${takt} · ${wann}`, was, + () => takteFragen(s, werte)); + + const jetzt = knopf('Jetzt ausführen', 'btn'); + jetzt.onclick = async () => { + jetzt.disabled = true; + const vorher = jetzt.textContent; + jetzt.textContent = 'Läuft …'; + try { + const { ergebnis } = await wartungAusfuehren(s.aufgabe.toLowerCase()); + hinweisZeigen(`${titel}: ${ergebnis}`); + await laden(); + } catch (fehler) { + hinweisZeigen(meldung(fehler), 'fehler'); + jetzt.disabled = false; + jetzt.textContent = vorher; + } + }; + + d.querySelector('.knopfreihe')?.appendChild(jetzt); + return d; + } + + /** Der Dialog je Aufgabe — Takt, und bei der Sicherung zwei Felder mehr. */ + function takteFragen(s: Wartungsstand, werte: Wartungseinstellung): void { + const felder: Dialogfeld[] = [{ + name: 'tage', beschriftung: 'Alle wie viele Tage (0 = aus)', + wert: String(s.tage), art: 'number', + }]; + + if (s.aufgabe === 'Sicherung') { + felder.push( + { + name: 'behalten', beschriftung: 'Wie viele Kopien behalten', + wert: String(werte.backupKeep), art: 'number', + }, + { + name: 'ordner', beschriftung: 'Ordner (leer = neben der Datenbank)', + wert: werte.backupFolder, art: 'ordner', + hinweis: 'Am besten ein anderes Volume als die Datenbank — eine ' + + 'Kopie neben dem Original hilft gegen Versehen, nicht gegen ' + + 'eine kaputte Platte.', + }); + } + + dialog({ + titel: NAMEN[s.aufgabe].titel, + felder, + bestaetigen: 'Übernehmen', + tun: async lesen => { + const tage = Number(lesen('tage')); + await wartungSetzen( + s.aufgabe === 'Sicherung' + ? { + backupDays: tage, + backupKeep: Number(lesen('behalten')), + backupFolder: lesen('ordner'), + } + : s.aufgabe === 'Optimieren' + ? { optimizeDays: tage } + : { imageDays: tage }); + hinweisZeigen('Übernommen.'); + await laden(); + }, + }); + } + + function zahlFragen( + titel: string, beschriftung: string, wert: number, + setzen: (wert: number) => Promise, + ): void { + dialog({ + titel, + felder: [{ name: 'wert', beschriftung, wert: String(wert), art: 'number' }], + bestaetigen: 'Übernehmen', + tun: async lesen => { + await setzen(Number(lesen('wert'))); + hinweisZeigen('Übernommen.'); + await laden(); + }, + }); + } +} + +function datum(wert: string): string { + return new Date(wert).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' }); +} diff --git a/web/app/src/views/einstellungen/wiedergabe.ts b/web/app/src/views/einstellungen/wiedergabe.ts index 548c5a9..8b37ed3 100644 --- a/web/app/src/views/einstellungen/wiedergabe.ts +++ b/web/app/src/views/einstellungen/wiedergabe.ts @@ -1,6 +1,4 @@ -import { - wiedergabeEinstellung, wiedergabeSetzen, wartungseinstellung, wartungSetzen, -} from '../../api'; +import { wiedergabeEinstellung, wiedergabeSetzen } from '../../api'; import { fehlerzeile, meldung, hinweisZeigen } from '../../util'; import { dialog } from '../../dialog'; @@ -49,9 +47,8 @@ export function wiedergabebereich(): HTMLElement { async function laden(): Promise { let stand; - let wartung; try { - [stand, wartung] = await Promise.all([wiedergabeEinstellung(), wartungseinstellung()]); + stand = await wiedergabeEinstellung(); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); @@ -109,55 +106,6 @@ export function wiedergabebereich(): HTMLElement { wert: stand.encoder, setzen: v => wiedergabeSetzen({ encoder: v }), }))); - - /* ---------- Was die Wiedergabe auf der Platte hinterlässt ---------- */ - - // **Hier und nicht unter einem eigenen Punkt.** Der Kratzplatz ist, was - // die Wiedergabe hinterlässt: umgepackte Dateien, Segmente, Untertitel. - // Drei Felder rechtfertigen keine eigene Seite in der Leiste. - liste.appendChild(abschnitt('Kratzplatz')); - - liste.appendChild(zeile( - 'Obergrenze', - wartung.capGb > 0 ? `${wartung.capGb} GB` : 'Unbegrenzt', - 'Wird sie überschritten, geht das am längsten Ungenutzte zuerst. ' - + 'Was gerade heruntergeladen wird, bleibt in jedem Fall. 0 heisst unbegrenzt.', - () => zahlFragen('Obergrenze', 'Gigabyte', String(wartung!.capGb), - v => wartungSetzen({ capGb: Number(v) })))); - - liste.appendChild(zeile( - 'Unbenutzt löschen nach', - wartung.unusedDays > 0 ? `${wartung.unusedDays} Tagen` : 'Nie', - 'Gilt auch unterhalb der Obergrenze: ein Topf, in dem seit Wochen ' - + 'nichts gebraucht wurde, besetzt ein Volume, das anderes braucht. ' - + '0 heisst: nur die Obergrenze zählt.', - () => zahlFragen('Unbenutzt löschen nach', 'Tagen', String(wartung!.unusedDays), - v => wartungSetzen({ unusedDays: Number(v) })))); - - liste.appendChild(zeile( - 'Schonfrist', - `${wartung.graceMinutes} Minuten`, - 'So lange bleibt eine frisch gebrauchte Datei in jedem Fall liegen. ' - + 'Weniger als fünf Minuten lässt der Server nicht zu — das löschte ' - + 'eine Datei mitten im Herunterladen weg.', - () => zahlFragen('Schonfrist', 'Minuten', String(wartung!.graceMinutes), - v => wartungSetzen({ graceMinutes: Number(v) })))); - } - - /** Ein Dialog für eine einzelne Zahl. */ - function zahlFragen( - titel: string, beschriftung: string, wert: string, setzen: (wert: string) => Promise, - ): void { - dialog({ - titel, - felder: [{ name: 'wert', beschriftung, wert, art: 'number' }], - bestaetigen: 'Übernehmen', - tun: async lesen => { - await setzen(lesen('wert')); - hinweisZeigen('Übernommen.'); - await laden(); - }, - }); } /** Ein Auswahldialog, der danach meldet, was er umgeworfen hat. */