Die App wartete auf eine fertige Datei, statt zu streamen

Gleichzeitig im Web und in der App auf Abspielen getippt: das Web lief,
die App lud dreissig Sekunden. Das war keine Ladezeit, sondern die Zeit,
die der Server braucht, um den ganzen Film einmal durchzuschreiben.

`preparePlayback` kannte nur `/files/{id}/download`. Muss die Datei
umgepackt werden, fragte es im Sekundentakt nach, bis die vollständige
MP4 auf der Platte lag — Grenze zehn Minuten. Der Endpunkt, den das Web
benutzt, ist am Server längst da; die App wusste nur nichts davon.

Jetzt zuerst `/files/{id}/stream`. Das erste Stück ist beim Umpacken in
deutlich unter einer Sekunde da. Der alte Weg bleibt als Rückfall: er ist
langsam, kommt aber ohne Sitzung aus, und eine späte Wiedergabe ist
besser als keine.

**Der Strom beginnt dort, wo man weiterschauen wollte**, und zählt
trotzdem ab null. Ohne das mitzurechnen stünde bei „Fortsetzen ab 1:32"
eine Null auf der Uhr — und genau die ginge als Fortschritt an den
Server. Also: die Zeit der Engine plus Vorlauf ist die Zeit des Films,
und der Spieler springt nicht noch einmal dorthin, wo der Server schon
steht.

**Ein Sprung aus dem Strom heraus baut ihn neu auf.** Die Abspielliste
wächst von der Einstiegsstelle vorwärts; davor und dahinter gibt es
nichts, und ein Sprung ins Leere liesse den Spieler mit einem Kreisel
stehen. Innerhalb des Erzeugten springt er weiterhin selbst — dafür eine
neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
zurückzugehen.

**Und die Sitzung wird geschlossen.** `endPlayback` war eine leere
Methode; das passte, solange es keine Sitzung gab. Mit dem Strom läuft
sonst ein ffmpeg weiter und schreibt den Film zu Ende, während längst
niemand mehr zusieht.

Die Laufzeit kommt weiter aus den Metadaten und nicht aus der Engine:
eine wachsende Liste meldet anfangs ein paar Sekunden, und als Filmlänge
genommen stünde der Regler sofort am Ende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-10 14:22:18 +02:00
co-authored by Claude Opus 5
parent 4a5576f939
commit 1bcf6f12e5
4 changed files with 200 additions and 12 deletions
@@ -331,6 +331,50 @@ public actor FlaroBackend: MediaBackend {
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
// **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.**
//
// Hier stand ausschliesslich der zweite Weg: `/download`, und dann
// im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt.
// Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz,
// während die Weboberfläche nach einer Sekunde lief — die holt sich
// eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in
// deutlich unter einer Sekunde da.
if let strom = try? await client.send(
.json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest(
height: intent.transcode?.resolution.flatMap(Self.hoehe),
// Ohne diese Angabe wählt der Server, und bei einem
// umgerechneten Strom kommt danach nur noch eine Spur an.
audioStream: intent.audioStreamIndex,
startSeconds: max(0, intent.startTime.seconds),
// Nur wenn der Spieler es ausdrücklich verlangt: das Profil
// entscheidet der Server, und er entscheidet es besser.
transcode: intent.transcode?.prefersRemux == false
&& intent.transcode?.maximumBitrate != nil)),
as: Wire.Stromauskunft.self),
// **Die Hauptliste, wo es sie gibt.** Nur dort stehen die
// Textspuren; die Medienliste trägt bloss Bild und Ton.
let adresse = client.url(path: strom.masterUrl ?? strom.url)
{
return PlaybackTarget(
url: adresse,
// Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre.
sessionID: strom.method == "direct" ? nil : strom.sessionId,
versionID: intent.file.id,
startOffset: .seconds(strom.startSeconds))
}
return try await mitgenommeneDatei(intent)
}
/// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt.
///
/// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne
/// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte
/// Wiedergabe besser als keine.
private func mitgenommeneDatei(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
throw .transport(description: "Adresse nicht baubar")
@@ -347,8 +391,6 @@ public actor FlaroBackend: MediaBackend {
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
// Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird
// wiederhergestellt", 0:00, für immer.
//
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
// wird nur nachgefragt, bis es so weit ist.
@@ -376,14 +418,25 @@ public actor FlaroBackend: MediaBackend {
}
}
// **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das
// hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder
// liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was
// weiterrechnet, wenn niemand mehr zusieht.
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
}
public func endPlayback(sessionID: String) async {}
/// „1080p" wird zu 1080 — der Server kennt nur Zahlen.
private static func hoehe(_ aufloesung: String) -> Int? {
Int(aufloesung.filter(\.isNumber))
}
/// Schliesst die Sitzung, damit nichts weiterrechnet.
///
/// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der
/// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet
/// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne
/// diesen Aufruf den ganzen Film zu Ende schreibt, während längst
/// niemand mehr zusieht.
public func endPlayback(sessionID: String) async {
_ = try? await client.send(FlaroRequest(
method: .delete, path: "/api/v1/stream/\(sessionID)"))
}
public func reportProgress(_ report: PlaybackReport) async {
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden