„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.
Kratzplatz* → Transcode* (was es ist, steht in FLARO_TRANSCODE)
Marke, Marken → Segment, Segmente
Markenerkennung → Segmenterkennung
Markenart/-suche → Segmentart/-suche
Tonabdruck → Fingerprint (Chromaprint nennt es so)
Tonabgleich → Fingerprintabgleich
Schlüsselbild → Keyframe
Bildabtaster → Frameabtaster
Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.
**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.
**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.
Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.
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<Transcodepflege>()
|
|
.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<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 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);
|
|
}
|
|
}
|
|
|
|
/// <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); }
|
|
}
|
|
}
|
|
}
|