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