using System.Collections.Concurrent; using Flaro.Core.Delivery; using Flaro.Core.Library; using Flaro.Data; using Flaro.Media; using Microsoft.EntityFrameworkCore; namespace Flaro.Api; public sealed record PackageState( string Status, double Progress, DateTimeOffset StartedAt, DateTimeOffset? FinishedAt, string? Error); /// Packt Dateien im Hintergrund um und weiss, wie weit das ist. /// /// /// /// Erst auf die Platte, dann hinaus — nicht unmittelbar durchgereicht. /// Der bequeme Weg wäre, ffmpeg in die Antwort schreiben zu lassen. Dann /// kennt aber niemand die Länge im Voraus: kein Fortschrittsbalken, kein /// Fortsetzen nach einem Abbruch, kein Springen in der fertigen Datei. Genau /// daran ist das Herunterladen schon einmal gescheitert — bei drei Gigabyte /// über Mobilfunk ist „von vorn" keine Antwort. /// /// /// Der Preis ist Plattenplatz und Wartezeit vor dem ersten Byte. Dafür gibt /// es FLARO_TRANSCODE, und dafür meldet dieser Weg einen Fortschritt. /// /// public sealed class PackageCoordinator( IServiceScopeFactory scopes, FlaroPaths pfade, TimeProvider clock, ILogger log) { private readonly ConcurrentDictionary _zustand = new(); /// Wie viel freier Platz über die Dateigrösse hinaus bleiben muss. /// /// /// Eine volle Platte ist der unangenehmste Ausgang: ffmpeg bricht /// irgendwann ab, und nebenher kann der Server seine Datenbank nicht mehr /// schreiben. Lieber vorher ablehnen und sagen, woran es liegt. /// private const long Sicherheitsabstand = 2L * 1024 * 1024 * 1024; public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId); /// Wie viele Umwandlungen gerade laufen — für das Dashboard. public int LaufendeAnzahl => _zustand.Values.Count(z => z.Status == "running"); /// Wohin die umgepackte Datei kommt. /// /// /// Der Änderungszeitpunkt steckt im Namen. Wird die Quelle ersetzt, /// heisst das Ergebnis anders — und niemand bekommt die alte Fassung /// ausgeliefert, bloss weil sie zufällig noch herumliegt. /// /// Wie das Umpacken gebaut wurde — Teil des Namens. /// /// /// Sonst bleibt ein Fehler liegen, der behoben ist. Der Name trug /// bisher nur Kennung und Änderungszeitpunkt der Quelle. Ändert sich das /// Umpacken selbst, passt beides weiterhin — und eine fehlerhaft /// gebaute Datei wird weiter ausgeliefert, obwohl der Server es längst /// besser könnte. /// /// 2: genau eine Vorgabe je Spurart. Davor landeten zwei als /// Vorgabe markierte Tonspuren in getrennten Alternativgruppen und /// liefen gleichzeitig. /// public const int Bauart = 2; /// Der Ordner, in dem die umgepackten Dateien liegen. public string Ordner => Path.Combine(pfade.Transcode, "packages"); public string PathFor(Guid fileId, DateTimeOffset geändert) => Path.Combine(Ordner, Paketname.Bauen(fileId, geändert, Bauart)); /// Wer gerade an einer Datei hängt — die wird nicht weggeräumt. /// /// /// /// Die Schonfrist allein genügt nicht. Ein Download über /// Mobilfunk dauert Stunden, und zwischen zwei Bereichsanfragen kann /// lange nichts kommen. Eine Datei, die in dieser Pause verschwindet, /// kostet den ganzen Download: der laufende Strom überlebt ein /// Entfernen unter Linux zwar, die Fortsetzung aber nicht — der /// Client bekäme 202 und finge von vorn an. Genau das schliesst die /// Beschreibung dieser Klasse oben aus. /// /// private readonly ConcurrentDictionary _inBenutzung = new(); public bool IstInBenutzung(string pfad) => _inBenutzung.ContainsKey(pfad); /// Hält eine Datei fest, bis das Ergebnis entsorgt wird. public IDisposable Benutzen(string pfad) { _inBenutzung.AddOrUpdate(pfad, 1, (_, alt) => alt + 1); return new Griff(this, pfad); } private sealed class Griff(PackageCoordinator wem, string pfad) : IDisposable { private bool _weg; public void Dispose() { if (_weg) return; _weg = true; wem._inBenutzung.AddOrUpdate(pfad, 0, (_, alt) => alt - 1); wem._inBenutzung.TryRemove(new KeyValuePair(pfad, 0)); } } /// Hält fest, dass diese Datei gerade gebraucht wurde. /// /// /// Die Änderungszeit als Benutzungsstempel. Die Zugriffszeit wäre /// das Naheliegende, nur führt sie nicht jedes Dateisystem — mit /// noatime steht sie still, und der Aufräumer hielte alles für /// uralt. Die Änderungszeit führt jedes, sie übersteht den Neustart, und /// nach dem Bauen steht sie schon auf einem richtigen Wert. /// /// Gedrosselt. Eine Bereichsanfrage je Sekunde ist bei einem Film /// normal; jedes Mal den Inode anzufassen wäre Schreiblast für nichts. /// public void AlsBenutztStempeln(string pfad) { try { // Nur im eigenen Ordner. Der Zweig für durchgereichte Dateien // liefert eine Mediendatei aus — die fasst hier niemand an. if (!Path.GetFullPath(pfad).StartsWith(Path.GetFullPath(Ordner), StringComparison.Ordinal)) return; var jetzt = clock.GetUtcNow(); if (jetzt - File.GetLastWriteTimeUtc(pfad) < TimeSpan.FromMinutes(5)) return; File.SetLastWriteTimeUtc(pfad, jetzt.UtcDateTime); } catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) { log.LogDebug(fehler, "{Datei} liess sich nicht stempeln.", pfad); } } public bool IsReady(Guid fileId, DateTimeOffset geändert) => File.Exists(PathFor(fileId, geändert)); /// Startet einen Lauf — oder sagt, dass schon einer läuft. public bool TryStart(Guid fileId) { var start = new PackageState("running", 0, clock.GetUtcNow(), null, null); var vorher = _zustand.AddOrUpdate(fileId, start, (_, alt) => alt.Status == "running" ? alt : start); if (!ReferenceEquals(vorher, start)) return false; _ = Task.Run(() => Laufen(fileId)); return true; } private async Task Laufen(Guid fileId) { try { using var bereich = scopes.CreateScope(); var db = bereich.ServiceProvider.GetRequiredService(); var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == fileId) ?? throw new InvalidOperationException("Diese Datei steht nicht mehr in der Mediathek."); if (!File.Exists(datei.Path)) throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da."); // **Erst aufräumen, dann messen.** Der Augenblick vor dem // Umpacken ist der eine, in dem der Platz wirklich gebraucht // wird — und `PlatzPruefen` sagt danach die Wahrheit statt // „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen. await bereich.ServiceProvider.GetRequiredService() .LaufenAsync(Transcodepflege.Anlass.VorDemUmpacken); PlatzPruefen(datei.SizeBytes); var spuren = await db.MediaStreams.AsNoTracking() .Where(s => s.MediaFileId == fileId).OrderBy(s => s.Index).ToListAsync(); var gelesen = spuren.Select(s => new ProbedStream( s.Index, s.Kind, s.Codec, s.Language, s.Title, s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList(); var plan = DeliveryDecider.ForDownload(datei.Container, gelesen); var ziel = PathFor(fileId, datei.ModifiedAt); // **Erst unter `.teil`, dann umbenennen.** Vorher schrieb ffmpeg // direkt auf den Endnamen; nach einem harten Abbruch lag dort // eine abgeschnittene Datei, `IsReady` meldete „fertig", und sie // ging so hinaus. Ein Aufräumer konnte vollständig und // abgeschnitten nicht unterscheiden. Umbenennen ist auf // demselben Dateisystem ein Zug und kann nicht halb geschehen. var teil = ziel + ".teil"; Directory.CreateDirectory(Path.GetDirectoryName(ziel)!); AelteresWegraeumen(fileId, ziel); var fortschritt = new Progress(wert => _zustand.AddOrUpdate(fileId, _ => new PackageState("running", wert, clock.GetUtcNow(), null, null), (_, alt) => alt with { Progress = wert })); var umpacker = bereich.ServiceProvider.GetRequiredService(); var ergebnis = await umpacker.RunAsync( new RemuxRequest(datei.Path, teil, gelesen, plan), datei.Duration, fortschritt); if (ergebnis.Success) File.Move(teil, ziel, overwrite: true); else { try { File.Delete(teil); } catch (IOException) { /* Kür */ } } _zustand[fileId] = ergebnis.Success ? new PackageState("ready", 1, _zustand[fileId].StartedAt, clock.GetUtcNow(), null) : new PackageState("failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), ergebnis.Error); } catch (Exception fehler) { log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId); // Der rohe Ausnahmetext bleibt im Protokoll; angezeigt wird ein Satz. _zustand[fileId] = new PackageState( "failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), Fehlertext.FuerMenschen(fehler)); } } /// Bricht ab, bevor die Platte volläuft. private void PlatzPruefen(long quellgrösse) { try { var platte = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfade.Transcode)) ?? "/"); var gebraucht = quellgrösse + Sicherheitsabstand; if (platte.AvailableFreeSpace >= gebraucht) return; throw new InvalidOperationException( $"Auf dem Transcode-Cache ({pfade.Transcode}) sind noch " + $"{platte.AvailableFreeSpace / (1024 * 1024)} MB frei, gebraucht werden etwa " + $"{gebraucht / (1024 * 1024)} MB. Räume dort auf oder hänge ein grösseres Volume ein."); } catch (Exception fehler) when (fehler is ArgumentException or IOException or UnauthorizedAccessException) { // Lässt sich der freie Platz nicht ermitteln, wird es versucht. // Eine Vorsichtsprüfung darf nicht selbst zum Hindernis werden. log.LogDebug(fehler, "Der freie Platz auf {Pfad} liess sich nicht ermitteln.", pfade.Transcode); } } /// Löscht frühere Fassungen derselben Datei. private void AelteresWegraeumen(Guid fileId, string behalten) { var ordner = Path.GetDirectoryName(behalten)!; foreach (var alt in Directory.EnumerateFiles(ordner, $"{fileId:N}-*.mp4")) { if (string.Equals(alt, behalten, StringComparison.Ordinal)) continue; try { File.Delete(alt); } catch (IOException fehler) { log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", alt); } } } }