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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
32f592b83e
commit
ed13d5fa43
@@ -0,0 +1,262 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user