Files
Flaro/server/src/Flaro.Api/Transcodepflege.cs
T
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00

263 lines
9.6 KiB
C#

using Flaro.Core.Delivery;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Was ein Aufräumlauf freigemacht hat.</summary>
public sealed record Aufraeumbericht(
long Frei, int Dateien, IReadOnlyList<Teilbericht> Teile);
/// <param name="Name">Der Unterordner — <c>stroeme</c>, <c>packages</c>, <c>untertitel</c>.</param>
public sealed record Teilbericht(string Name, long Frei, int Dateien);
/// <summary>Räumt den Transcode-Cache auf.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass.</b> 39 Gigabyte in <c>FLARO_TRANSCODE</c>. Für jede
/// Mitnahme baut der Server eine MP4 in Quellgrösse und löschte sie nie; die
/// einzige Löschung räumte ältere Fassungen derselben Datei weg. Stromordner
/// verschwanden nur beim geordneten Herunterfahren, Untertitel gar nicht.
/// </para>
/// <para>
/// <b>Kein Dienst, kein Takt.</b> Dieser Server hat nirgends einen
/// Hintergrunddienst, und das ist Absicht — alles Wiederkehrende hängt
/// beiläufig an einem Aufruf. Der Aufräumer hält sich daran: er läuft beim
/// Start, vor jedem Umpacken, nach einem Scan und auf Knopfdruck.
/// </para>
/// <para>
/// <b>Einer zur Zeit.</b> Wer nicht hineinkommt, geht weiter; zwei Läufe
/// nebeneinander löschten einander die Dateien unter den Händen weg.
/// </para>
/// </remarks>
public sealed class Transcodepflege(
IServiceScopeFactory scopes,
FlaroPaths pfade,
PackageCoordinator pakete,
Streamkoordinator stroeme,
TimeProvider uhr,
ILogger<Transcodepflege> log)
{
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
/// <summary>Woher der Lauf kam — fürs Protokoll.</summary>
public enum Anlass { Start, VorDemUmpacken, NachDemScan, VonHand }
/// <summary>Räumt auf und sagt, was freigeworden ist.</summary>
public async Task<Aufraeumbericht> LaufenAsync(Anlass anlass, CancellationToken ct = default)
{
if (!await _einerZurZeit.WaitAsync(0, ct))
{
return new Aufraeumbericht(0, 0, []);
}
try
{
var jetzt = uhr.GetUtcNow();
// Beim Start ist jeder Stromordner verwaist — es gibt noch keine
// einzige Sitzung. Im Betrieb nur die, zu denen keine passt.
var stromfrei = anlass == Anlass.Start
? stroeme.WurzelLeeren()
: stroeme.VerwaisteOrdnerWegraeumen(jetzt);
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
var teile = new List<Teilbericht>
{
new("stroeme", stromfrei, 0),
new("packages", paketfrei, paketzahl),
new("untertitel", textfrei, textzahl),
};
var bericht = new Aufraeumbericht(
teile.Sum(t => t.Frei), teile.Sum(t => t.Dateien), teile);
if (bericht.Frei > 0)
{
log.LogInformation(
"Transcode-Cache aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).",
anlass, bericht.Frei / (1024 * 1024), bericht.Dateien);
}
return bericht;
}
finally
{
_einerZurZeit.Release();
}
}
/// <summary>Verwaiste Pakete, zu alte Pakete und alles über der Obergrenze.</summary>
private async Task<(long Frei, int Dateien)> PaketeAufraeumenAsync(
DateTimeOffset jetzt, CancellationToken ct)
{
var ordner = pakete.Ordner;
if (!Directory.Exists(ordner)) return (0, 0);
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Transcodeeinstellungen>().RegelnAsync(ct);
// **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen,
// und wer gerade baut, hat die Datei noch offen — eine liegengebliebene
// ist das Überbleibsel eines Abbruchs.
long frei = 0;
var zahl = 0;
foreach (var teil in Directory.EnumerateFiles(ordner, "*" + Paketname.Teilendung))
{
if (pakete.IstInBenutzung(teil)) continue;
frei += Loeschen(teil);
zahl++;
}
var akten = new List<Paketakte>();
foreach (var pfad in Directory.EnumerateFiles(ordner, "*" + Paketname.Endung))
{
try
{
var info = new FileInfo(pfad);
akten.Add(new Paketakte(pfad, info.Length,
new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
Paketname.Lesen(pfad)));
}
catch (IOException) { /* Verschwunden, während gelesen wurde. */ }
}
if (akten.Count == 0) return (frei, zahl);
// Nur die Dateien nachschlagen, die wirklich dort liegen — nicht die
// ganze Mediathek.
var kennungen = akten.Select(a => a.Name?.DateiId).OfType<Guid>().Distinct().ToList();
var bekannt = await db.MediaFiles.AsNoTracking()
.Where(f => kennungen.Contains(f.Id))
.Select(f => new { f.Id, f.ModifiedAt })
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
foreach (var a in Transcodeauswahl.Wegzuwerfen(
akten, bekannt, PackageCoordinator.Bauart, regeln, jetzt, pakete.IstInBenutzung))
{
frei += Loeschen(a.Pfad);
zahl++;
}
return (frei, zahl);
}
/// <summary>Untertitel zu Dateien, die es nicht mehr gibt oder die sich geändert haben.</summary>
///
/// <remarks>
/// <b>Keine Obergrenze, kein Alter.</b> Eine <c>.vtt</c> ist Kilobytes,
/// und eine gültige wird wieder gebraucht — sie nach Platz zu löschen
/// hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen.
///
/// <b>Eine leere bleibt ausdrücklich liegen.</b> Sie ist das Ergebnis
/// „diese Spur hat keinen Text", kein Fehlschlag. Wer sie wegräumt,
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
/// neu durchlesen, um wieder nichts zu finden.
/// </remarks>
private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct)
{
var ordner = Path.Combine(pfade.Transcode, "untertitel");
if (!Directory.Exists(ordner)) return (0, 0);
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var gefunden = new List<(string Pfad, Guid Id, long Ms, int Spur)>();
foreach (var pfad in Directory.EnumerateFiles(ordner, "*.vtt"))
{
var teile = Path.GetFileNameWithoutExtension(pfad).Split('-');
if (teile.Length != 3
|| !Guid.TryParseExact(teile[0], "N", out var id)
|| !long.TryParse(teile[1], out var ms)
|| !int.TryParse(teile[2], out var spur))
{
gefunden.Add((pfad, Guid.Empty, 0, -1));
continue;
}
gefunden.Add((pfad, id, ms, spur));
}
if (gefunden.Count == 0) return (0, 0);
var kennungen = gefunden.Where(g => g.Spur >= 0).Select(g => g.Id).Distinct().ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => kennungen.Contains(f.Id))
.Select(f => new { f.Id, f.ModifiedAt })
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
// Eine Abfrage für alle Spuren, nicht eine je Datei.
var spuren = (await db.MediaStreams.AsNoTracking()
.Where(s => kennungen.Contains(s.MediaFileId) && s.Kind == Core.Library.MediaStreamKind.Subtitle)
.Select(s => new { s.MediaFileId, s.Index, s.Codec })
.ToListAsync(ct))
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
.Select(s => (s.MediaFileId, s.Index))
.ToHashSet();
long frei = 0;
var zahl = 0;
foreach (var g in gefunden)
{
var verwaist = g.Spur < 0
|| !dateien.TryGetValue(g.Id, out var ms)
|| ms != g.Ms
|| !spuren.Contains((g.Id, g.Spur));
if (!verwaist) continue;
frei += Loeschen(g.Pfad);
zahl++;
}
return (frei, zahl);
}
private long Loeschen(string pfad)
{
try
{
var gross = new FileInfo(pfad).Length;
File.Delete(pfad);
return gross;
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", pfad);
return 0;
}
}
/// <summary>Die Belegung je Unterordner — für die Übersicht.</summary>
public IReadOnlyList<Teilbericht> Belegung()
{
var ordner = new[]
{
("stroeme", stroeme.Wurzel),
("packages", pakete.Ordner),
("untertitel", Path.Combine(pfade.Transcode, "untertitel")),
};
return ordner.Select(o => new Teilbericht(o.Item1, Streamkoordinator.Belegung(o.Item2), Zaehlen(o.Item2)))
.ToList();
}
private static int Zaehlen(string ordner)
{
try
{
return Directory.Exists(ordner)
? Directory.EnumerateFiles(ordner, "*", SearchOption.AllDirectories).Count()
: 0;
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
return 0;
}
}
}