using Flaro.Core.Delivery;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// Was ein Aufräumlauf freigemacht hat.
public sealed record Aufraeumbericht(
long Frei, int Dateien, IReadOnlyList Teile);
/// Der Unterordner — stroeme, packages, untertitel.
public sealed record Teilbericht(string Name, long Frei, int Dateien);
/// Räumt den Transcode-Cache auf.
///
///
///
/// Der Anlass. 39 Gigabyte in FLARO_TRANSCODE. 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.
///
///
/// Kein Dienst, kein Takt. 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.
///
///
/// Einer zur Zeit. Wer nicht hineinkommt, geht weiter; zwei Läufe
/// nebeneinander löschten einander die Dateien unter den Händen weg.
///
///
public sealed class Transcodepflege(
IServiceScopeFactory scopes,
FlaroPaths pfade,
PackageCoordinator pakete,
Streamkoordinator stroeme,
TimeProvider uhr,
ILogger log)
{
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
/// Woher der Lauf kam — fürs Protokoll.
public enum Anlass { Start, VorDemUmpacken, NachDemScan, VonHand }
/// Räumt auf und sagt, was freigeworden ist.
public async Task 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
{
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();
}
}
/// Verwaiste Pakete, zu alte Pakete und alles über der Obergrenze.
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();
var regeln = await raum.ServiceProvider
.GetRequiredService().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();
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().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);
}
/// Untertitel zu Dateien, die es nicht mehr gibt oder die sich geändert haben.
///
///
/// Keine Obergrenze, kein Alter. Eine .vtt ist Kilobytes,
/// und eine gültige wird wieder gebraucht — sie nach Platz zu löschen
/// hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen.
///
/// Eine leere bleibt ausdrücklich liegen. 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.
///
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();
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;
}
}
/// Die Belegung je Unterordner — für die Übersicht.
public IReadOnlyList 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;
}
}
}