„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>
263 lines
9.6 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|