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