39,1 GB in FLARO_TRANSCODE, und das war kein Normalzustand, sondern ein
Leck: für jede Mitnahme baut der Server eine MP4 in Quellgrösse und löschte
sie nie. Die einzige Löschung, die es gab, räumte ältere Fassungen
derselben Datei weg. Stromordner verschwanden nur beim geordneten
Herunterfahren, Untertitel gar nicht.
**Zuerst: fertig heisst fertig.** ffmpeg schrieb direkt auf den Endnamen.
Nach einem harten Abbruch lag dort eine abgeschnittene Datei, `IsReady`
meldete „fertig", und sie ging so hinaus — und ein Aufräumer konnte
vollständig und abgeschnitten nicht unterscheiden. Gebaut wird jetzt unter
`.teil`, umbenannt erst nach Erfolg. Dabei muss das Format ausdrücklich
gesetzt werden: ffmpeg wählt den Muxer an der Endung und antwortet auf
`.teil` mit „Unable to choose an output format". Das hätte jedes Umpacken
zerlegt; die Prüfung hat es gefunden, bevor es jemand zu spüren bekam.
**Zwei Stufen für die Pakete.** Verwaiste gehen bedingungslos: unleserlicher
Name, fremde Bauart, kein Eintrag in der Mediathek, oder der Zeitstempel im
Namen passt nicht mehr. Der Rest fällt unter Obergrenze (20 GB) und Alter
(7 Tage), Ältestes zuerst.
Beides, und nicht eines: ein Alter allein löst den Fall nicht, denn die 39
GB waren gültige, aktuelle Dateien, jede für sich berechtigt. Eine
Obergrenze allein ist zu grob — ein Topf knapp darunter, in dem seit Wochen
nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.
„Zuletzt gebraucht" ist die Änderungszeit der Paketdatei selbst, bei jeder
Auslieferung neu gestempelt. 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.
Ein laufender Download wird nie gelöscht: ein Benutzungszähler hält die
Datei fest, solange die Antwort läuft, dazu eine Schonfrist von 30 Minuten.
Der Zähler ist auch unter Linux nötig — der laufende Strom überlebt ein
Entfernen, die Fortsetzung einer Bereichsanfrage nicht, und der Client finge
von vorn an.
**Stromordner** werden beim Start bedingungslos geleert — nach einem Start
gibt es keine einzige Sitzung, also ist jeder Ordner verwaist. Im Betrieb
nur die ohne passende Sitzung, deren Änderungszeit älter ist als die
Geduld; das fängt auch den Fall, dass `Abraeumen` an einem IOException
scheitert und der Ordner für immer liegenbleibt.
**Untertitel** nur, wenn sie verwaist sind. Keine Obergrenze: 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", und wer sie wegräumt, lässt bei jedem Start eine vierzig Gigabyte
grosse Datei neu durchlesen.
**Vier Anlässe, kein Dienst.** Start, vor jedem Umpacken, nach einem Scan,
auf Knopfdruck — wie alles Wiederkehrende in diesem Server. Einer zur Zeit;
wer nicht hineinkommt, geht weiter.
Gemessen am laufenden Server mit Attrappen:
vorher packages 17408 KB (4), untertitel 5 KB (3), 1 Stromordner
nachher 14 MB freigegeben, 4 Dateien
gültiges Paket blieb, verwaistes/veraltetes/Teilstück weg,
verwaiste .vtt weg, gültige blieben, Stromordner weg
Obergrenze 1 MB, Datei in der Schonfrist -> bleibt
Obergrenze 1 MB, Schonfrist vorbei -> 3072 KB freigegeben
In der Übersicht steht die Belegung je Unterordner mit Dateizahl und
daneben „Jetzt aufräumen"; die drei Zahlen stehen unter Wiedergabe, wo
Transcoder-Stufe und Schwellen schon stehen — der Kratzplatz ist, was die
Wiedergabe hinterlässt.
714 Tests grün, davon 36 neue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
12 KiB
C#
264 lines
12 KiB
C#
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);
|
|
|
|
/// <summary>Packt Dateien im Hintergrund um und weiss, wie weit das ist.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Erst auf die Platte, dann hinaus — nicht unmittelbar durchgereicht.</b>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Der Preis ist Plattenplatz und Wartezeit vor dem ersten Byte. Dafür gibt
|
|
/// es <c>FLARO_TRANSCODE</c>, und dafür meldet dieser Weg einen Fortschritt.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class PackageCoordinator(
|
|
IServiceScopeFactory scopes,
|
|
FlaroPaths pfade,
|
|
TimeProvider clock,
|
|
ILogger<PackageCoordinator> log)
|
|
{
|
|
private readonly ConcurrentDictionary<Guid, PackageState> _zustand = new();
|
|
|
|
/// <summary>Wie viel freier Platz über die Dateigrösse hinaus bleiben muss.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private const long Sicherheitsabstand = 2L * 1024 * 1024 * 1024;
|
|
|
|
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
|
|
|
|
/// <summary>Wie viele Umwandlungen gerade laufen — für das Dashboard.</summary>
|
|
public int LaufendeAnzahl => _zustand.Values.Count(z => z.Status == "running");
|
|
|
|
/// <summary>Wohin die umgepackte Datei kommt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Änderungszeitpunkt steckt im Namen.</b> Wird die Quelle ersetzt,
|
|
/// heisst das Ergebnis anders — und niemand bekommt die alte Fassung
|
|
/// ausgeliefert, bloss weil sie zufällig noch herumliegt.
|
|
/// </remarks>
|
|
/// <summary>Wie das Umpacken gebaut wurde — Teil des Namens.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Sonst bleibt ein Fehler liegen, der behoben ist.</b> 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.
|
|
///
|
|
/// <b>2:</b> genau eine Vorgabe je Spurart. Davor landeten zwei als
|
|
/// Vorgabe markierte Tonspuren in getrennten Alternativgruppen und
|
|
/// liefen gleichzeitig.
|
|
/// </remarks>
|
|
public const int Bauart = 2;
|
|
|
|
/// <summary>Der Ordner, in dem die umgepackten Dateien liegen.</summary>
|
|
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));
|
|
|
|
/// <summary>Wer gerade an einer Datei hängt — die wird nicht weggeräumt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Die Schonfrist allein genügt nicht.</b> 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 <b>Fortsetzung</b> aber nicht — der
|
|
/// Client bekäme 202 und finge von vorn an. Genau das schliesst die
|
|
/// Beschreibung dieser Klasse oben aus.
|
|
/// </para>
|
|
/// </remarks>
|
|
private readonly ConcurrentDictionary<string, int> _inBenutzung = new();
|
|
|
|
public bool IstInBenutzung(string pfad) => _inBenutzung.ContainsKey(pfad);
|
|
|
|
/// <summary>Hält eine Datei fest, bis das Ergebnis entsorgt wird.</summary>
|
|
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<string, int>(pfad, 0));
|
|
}
|
|
}
|
|
|
|
/// <summary>Hält fest, dass diese Datei gerade gebraucht wurde.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Die Änderungszeit als Benutzungsstempel.</b> Die Zugriffszeit wäre
|
|
/// das Naheliegende, nur führt sie nicht jedes Dateisystem — mit
|
|
/// <c>noatime</c> 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.
|
|
///
|
|
/// <b>Gedrosselt.</b> Eine Bereichsanfrage je Sekunde ist bei einem Film
|
|
/// normal; jedes Mal den Inode anzufassen wäre Schreiblast für nichts.
|
|
/// </remarks>
|
|
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));
|
|
|
|
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
|
|
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<FlaroContext>();
|
|
|
|
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<Kratzplatzpflege>()
|
|
.LaufenAsync(Kratzplatzpflege.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<double>(wert =>
|
|
_zustand.AddOrUpdate(fileId,
|
|
_ => new PackageState("running", wert, clock.GetUtcNow(), null, null),
|
|
(_, alt) => alt with { Progress = wert }));
|
|
|
|
var umpacker = bereich.ServiceProvider.GetRequiredService<FfmpegRemuxer>();
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>Bricht ab, bevor die Platte volläuft.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Löscht frühere Fassungen derselben Datei.</summary>
|
|
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); }
|
|
}
|
|
}
|
|
}
|