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. /// public string PathFor(Guid fileId, DateTimeOffset geändert) => Path.Combine(pfade.Transcode, "packages", $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}.mp4"); 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."); 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); 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, ziel, gelesen, plan), datei.Duration, fortschritt); _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 Kratzplatz ({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); } } } }