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. */