Der Vorrat steigt jetzt auch eine Ebene tiefer

Bei „Auto\" bestellen wir eine Liste von Stufen; die Schnipsel stehen eine
Ebene darunter. Der Speicher erkannte das zwar, stieg dann aber aus und
reichte die Liste mit relativen Adressen durch — die zeigten danach auf den
lokalen Server, wo es sie nicht gibt. Es wurde nichts vorgeholt, und
beinahe waere auch nichts mehr gelaufen.

Jetzt wird auch die Stufenliste umgeschrieben. Holt der Player eine Stufe,
kommt er wieder bei uns an, und dort beginnt das Vorauslaufen — bei der
Stufe, fuer die er sich entschieden hat.

Dazu eine Zeile im Qualitaetsblatt, die sagt, ob der Vorrat laeuft und wie
weit er voraus ist. Von aussen ist das nicht zu sehen: laeuft er nicht,
spielt es trotzdem, nur ohne Vorsprung — und genau so sieht auch ein
Vorrat aus, der laeuft und nichts bekommt. Ohne diese Zeile sind die beiden
Faelle nicht zu unterscheiden, und ich haette wieder geraten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 20:29:20 +02:00
co-authored by Claude Opus 5
parent 134dc82927
commit bbd1c3b572
6 changed files with 225 additions and 13 deletions
+68
View File
@@ -4127,6 +4127,23 @@
} }
} }
}, },
"quality.current.prefetch": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Eigener Vorrat"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Own prefetch"
}
}
}
},
"quality.current.resolution": { "quality.current.resolution": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -4383,6 +4400,57 @@
} }
} }
}, },
"quality.prefetch.ahead %lld": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld s voraus"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld s ahead"
}
}
}
},
"quality.prefetch.off": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "aus"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "off"
}
}
}
},
"quality.prefetch.starting": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "läuft an"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "starting"
}
}
}
},
"quality.seconds %@": { "quality.seconds %@": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
+18 -1
View File
@@ -109,6 +109,15 @@ final class PlayerModel {
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst. /// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
private(set) var bufferedUntil: Duration? private(set) var bufferedUntil: Duration?
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
///
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
/// dazwischensteht, ist von aussen nicht zu sehen: läuft er nicht, spielt
/// es trotzdem, nur eben ohne Vorsprung. Genau so sieht aber auch ein
/// Speicher aus, der läuft und nichts bekommt. Ohne diese Zeile sind die
/// beiden Fälle nicht zu unterscheiden.
private(set) var prefetchStatus: String?
/// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt /// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt
/// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein /// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein
/// Bild dazu. /// Bild dazu.
@@ -807,9 +816,12 @@ final class PlayerModel {
let lokal = try await server.start(upstream: url) let lokal = try await server.start(upstream: url)
streamServer = server streamServer = server
streamCache = speicher streamCache = speicher
prefetchStatus = String(localized: "quality.prefetch.starting")
diagnostics.note("Vorrat läuft voraus") diagnostics.note("Vorrat läuft voraus")
return lokal return lokal
} catch { } catch {
streamCache = nil
prefetchStatus = String(localized: "quality.prefetch.off")
diagnostics.note("Vorrat nicht gestartet: \(error.localizedDescription)") diagnostics.note("Vorrat nicht gestartet: \(error.localizedDescription)")
return nil return nil
} }
@@ -824,7 +836,11 @@ final class PlayerModel {
// der Platte. Ohne Speicher bleibt die Auskunft des Players. // der Platte. Ohne Speicher bleibt die Auskunft des Players.
let vorrat: Duration? let vorrat: Duration?
if let cache = self.streamCache { if let cache = self.streamCache {
vorrat = await cache.bufferedAhead let eigener = await cache.bufferedAhead
vorrat = eigener
self.prefetchStatus = String(
localized: "quality.prefetch.ahead \(Int(eigener.seconds))"
)
} else { } else {
vorrat = self.engine?.statistics?.bufferedAhead vorrat = self.engine?.statistics?.bufferedAhead
} }
@@ -1308,6 +1324,7 @@ final class PlayerModel {
if let streamServer { Task { await streamServer.stop() } } if let streamServer { Task { await streamServer.stop() } }
streamServer = nil streamServer = nil
streamCache = nil streamCache = nil
prefetchStatus = nil
pictureInPicture = nil pictureInPicture = nil
pictureInPictureDelegate = nil pictureInPictureDelegate = nil
surface.playerLayer.player = nil surface.playerLayer.player = nil
+8
View File
@@ -87,6 +87,14 @@ struct QualitySheet: View {
LabeledContent("quality.current.dropped", value: "\(dropped)") LabeledContent("quality.current.dropped", value: "\(dropped)")
} }
// **Ob der eigene Vorrat läuft, und wie weit.** Von aussen ist
// das nicht zu sehen: läuft er nicht, spielt es trotzdem, nur
// ohne Vorsprung — und genau so sieht auch ein Vorrat aus, der
// läuft und nichts bekommt.
if let vorrat = model.prefetchStatus {
LabeledContent("quality.current.prefetch", value: vorrat)
}
// **Die Zahl, die alles entscheidet.** Eine Playlist mit // **Die Zahl, die alles entscheidet.** Eine Playlist mit
// einer Stufe hat nichts zum Umschalten — dann greift jedes // einer Stufe hat nichts zum Umschalten — dann greift jedes
// Nachregeln ins Leere, ganz gleich was bestellt wurde. // Nachregeln ins Leere, ganz gleich was bestellt wurde.
@@ -24,11 +24,28 @@ public actor LocalStreamServer {
private static let segmentPrefix = "/s/" private static let segmentPrefix = "/s/"
/// Der Pfad der Abspielliste selbst. /// Der Pfad der Abspielliste selbst.
private static let playlistPath = "/playlist.m3u8" private static let playlistPath = "/playlist.m3u8"
/// Der Pfad einer Stufe, die eine Ebene tiefer liegt.
///
/// Die Endung muss stimmen: AVPlayer entscheidet am Namen, ob er eine
/// Abspielliste oder Bild erwartet.
private static let levelPrefix = "/level.m3u8"
/// Wie eine Adresse aus der Abspielliste beim Server heisst. /// Wie eine Adresse aus der Abspielliste beim Server heisst.
/// ///
/// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche /// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche
/// und Punkte darin nichts anrichten. /// und Punkte darin nichts anrichten.
/// Wie eine Stufe aus der Stufenliste beim Server heisst.
///
/// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem
/// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins
/// Leere.
public nonisolated static func localPlaylistPath(for absolute: String) -> String {
var teile = URLComponents()
teile.path = levelPrefix
teile.queryItems = [URLQueryItem(name: "u", value: absolute)]
return teile.string ?? levelPrefix
}
public nonisolated static func localPath(for original: String) -> String { public nonisolated static func localPath(for original: String) -> String {
var teile = URLComponents() var teile = URLComponents()
teile.path = segmentPrefix teile.path = segmentPrefix
@@ -134,6 +151,18 @@ public actor LocalStreamServer {
let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first
else { return finish(verbindung, status: 400, body: Data()) } else { return finish(verbindung, status: 400, body: Data()) }
if pfadTeil.hasPrefix(Self.levelPrefix) {
guard let teile = URLComponents(string: pfadTeil),
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value,
let inhalt = await cache.playlist(at: adresse)
else { return finish(verbindung, status: 404, body: Data()) }
return finish(
verbindung, status: 200, body: Data(inhalt.utf8),
type: "application/vnd.apple.mpegurl"
)
}
if pfadTeil.hasPrefix(Self.playlistPath) { if pfadTeil.hasPrefix(Self.playlistPath) {
let inhalt = Data((playlist ?? "").utf8) let inhalt = Data((playlist ?? "").utf8)
return finish( return finish(
@@ -42,6 +42,7 @@ public actor StreamCache {
/// Woher die Schnipsel wirklich kommen. /// Woher die Schnipsel wirklich kommen.
private var upstream: URL? private var upstream: URL?
private var headers: [String: String] = [:]
/// Die Liste, in der Reihenfolge des Servers. /// Die Liste, in der Reihenfolge des Servers.
private var segments: [String] = [] private var segments: [String] = []
private var targetDuration: Duration = .seconds(6) private var targetDuration: Duration = .seconds(6)
@@ -77,23 +78,55 @@ public actor StreamCache {
/// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen. /// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen.
/// ///
/// Gibt die umgeschriebene Liste zurück — die, die der Player bekommt. /// Gibt die umgeschriebene Liste zurück — die, die der Player bekommt.
/// Ist es eine Liste von Stufen, wird nichts vorgeholt: dort stehen keine ///
/// Schnipsel, sondern weitere Listen. /// **Zwei Ebenen, und beide müssen durch uns.** Bei „Auto" bestellen wir
/// eine Liste von **Stufen**; die Schnipsel stehen eine Ebene tiefer. Wer
/// hier aussteigt, reicht relative Adressen durch, die danach auf den
/// lokalen Server zeigen — und dort gibt es sie nicht. Genau daran ist
/// der erste Anlauf gescheitert: es wurde nichts vorgeholt, und beinahe
/// wäre auch nichts mehr gelaufen.
///
/// Also wird auch die Stufenliste umgeschrieben. Holt der Player dann
/// eine Stufe, kommt er wieder bei uns an, und **dort** beginnt das
/// Vorauslaufen — bei der Stufe, für die er sich entschieden hat.
public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String { public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String {
upstream = url self.headers = headers
let (liste, _) = try await load(url)
guard !liste.isMaster else {
return liste.rewritingReferences { eintrag in
let absolut = URL(string: eintrag, relativeTo: url)?.absoluteString ?? eintrag
return LocalStreamServer.localPlaylistPath(for: absolut)
}
}
return adopt(liste, from: url)
}
/// Eine Stufe, die der Player angefordert hat.
///
/// Ab hier stehen die Schnipsel fest, und der Vorrat beginnt zu laufen.
public func playlist(at address: String) async -> String? {
guard let url = URL(string: address), let (liste, _) = try? await load(url) else { return nil }
guard !liste.isMaster else { return liste.rewritingReferences { $0 } }
return adopt(liste, from: url)
}
/// Übernimmt eine Liste von Schnipseln als die, der wir vorauslaufen.
private func adopt(_ liste: MediaPlaylist, from url: URL) -> String {
upstream = url
segments = liste.segments
if let dauer = liste.targetDuration { targetDuration = dauer }
lastRequested = 0
fetched = 0
beginPrefetching()
return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) }
}
private func load(_ url: URL) async throws -> (MediaPlaylist, Data) {
var request = URLRequest(url: url) var request = URLRequest(url: url)
for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) } for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) }
let (daten, _) = try await session.data(for: request) let (daten, _) = try await session.data(for: request)
return (MediaPlaylist(String(decoding: daten, as: UTF8.self)), daten)
let liste = MediaPlaylist(String(decoding: daten, as: UTF8.self))
guard !liste.isMaster else { return liste.rewritingReferences { $0 } }
segments = liste.segments
if let dauer = liste.targetDuration { targetDuration = dauer }
beginPrefetching()
return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) }
} }
// MARK: - Ausliefern // MARK: - Ausliefern
@@ -86,3 +86,60 @@ struct MediaPlaylistTests {
#expect(MediaPlaylist("").rewritingReferences { $0 } == "") #expect(MediaPlaylist("").rewritingReferences { $0 } == "")
} }
} }
@Suite("Adressen für den lokalen Speicher")
struct LocalStreamServerPathTests {
/// Der Fehler, an dem der erste Anlauf gescheitert ist: eine Liste von
/// Stufen wurde durchgereicht, ihre **relativen** Adressen zeigten danach
/// auf den lokalen Server — und dort gibt es sie nicht.
@Test("Eine Stufe wird vollständig adressiert, nicht relativ")
func levelPathIsAbsolute() {
let pfad = LocalStreamServer.localPlaylistPath(
for: "http://server:8096/videos/x/main.m3u8?level=1&api_key=t"
)
let teile = URLComponents(string: pfad)
#expect(teile?.path == "/level.m3u8")
#expect(
teile?.queryItems?.first { $0.name == "u" }?.value
== "http://server:8096/videos/x/main.m3u8?level=1&api_key=t"
)
}
/// Die Endung muss stehen bleiben: AVPlayer entscheidet am Namen, ob er
/// eine Abspielliste oder Bild erwartet.
@Test("Die Endung bleibt eine Abspielliste")
func levelPathKeepsExtension() {
#expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8").hasPrefix("/level.m3u8"))
}
/// Schrägstriche und Punkte im ursprünglichen Pfad dürfen nichts
/// anrichten — deshalb wandert er in die Abfrage, nicht in den Pfad.
@Test("Der Schnipselpfad überlebt Schrägstriche")
func segmentPathSurvivesSlashes() {
let pfad = LocalStreamServer.localPath(for: "hls1/main/12.mp4?runtimeTicks=0")
let teile = URLComponents(string: pfad)
#expect(teile?.path == "/s/")
#expect(teile?.queryItems?.first { $0.name == "p" }?.value == "hls1/main/12.mp4?runtimeTicks=0")
}
/// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen
/// Server zeigen — und zwar jede Zeile.
@Test("Jede Stufe zeigt danach auf uns")
func everyLevelIsRewritten() {
let stufen = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=8000000
main.m3u8?level=0
#EXT-X-STREAM-INF:BANDWIDTH=3000000
main.m3u8?level=1
"""
let basis = URL(string: "http://server:8096/videos/x/master.m3u8?api_key=t")!
let neu = MediaPlaylist(stufen).rewritingReferences { eintrag in
let absolut = URL(string: eintrag, relativeTo: basis)?.absoluteString ?? eintrag
return LocalStreamServer.localPlaylistPath(for: absolut)
}
#expect(neu.components(separatedBy: "/level.m3u8").count == 3)
#expect(!neu.contains("\nmain.m3u8"))
}
}