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);
}
}