From bbd1c3b5722c4de051604aa56085dab7784afd64 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 28 Sep 2026 20:29:20 +0200 Subject: [PATCH] Der Vorrat steigt jetzt auch eine Ebene tiefer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Resources/Localizable.xcstrings | 68 +++++++++++++++++++ Flixy/Views/Player/PlayerModel.swift | 19 +++++- Flixy/Views/Player/QualitySheet.swift | 8 +++ .../Prefetch/LocalStreamServer.swift | 29 ++++++++ .../FlixyPlayback/Prefetch/StreamCache.swift | 57 ++++++++++++---- .../MediaPlaylistTests.swift | 57 ++++++++++++++++ 6 files changed, 225 insertions(+), 13 deletions(-) diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index b1c0f5b..7ac225a 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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": { "extractionState": "stale", "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 %@": { "extractionState": "manual", "localizations": { diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 43af2be..612e18e 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -109,6 +109,15 @@ final class PlayerModel { /// und ausgerechnet da will man zusehen, wie der Vorrat wächst. 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 /// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein /// Bild dazu. @@ -807,9 +816,12 @@ final class PlayerModel { let lokal = try await server.start(upstream: url) streamServer = server streamCache = speicher + prefetchStatus = String(localized: "quality.prefetch.starting") diagnostics.note("Vorrat läuft voraus") return lokal } catch { + streamCache = nil + prefetchStatus = String(localized: "quality.prefetch.off") diagnostics.note("Vorrat nicht gestartet: \(error.localizedDescription)") return nil } @@ -824,7 +836,11 @@ final class PlayerModel { // der Platte. Ohne Speicher bleibt die Auskunft des Players. let vorrat: Duration? 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 { vorrat = self.engine?.statistics?.bufferedAhead } @@ -1308,6 +1324,7 @@ final class PlayerModel { if let streamServer { Task { await streamServer.stop() } } streamServer = nil streamCache = nil + prefetchStatus = nil pictureInPicture = nil pictureInPictureDelegate = nil surface.playerLayer.player = nil diff --git a/Flixy/Views/Player/QualitySheet.swift b/Flixy/Views/Player/QualitySheet.swift index 5e8b5c8..ca1680d 100644 --- a/Flixy/Views/Player/QualitySheet.swift +++ b/Flixy/Views/Player/QualitySheet.swift @@ -87,6 +87,14 @@ struct QualitySheet: View { 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 // einer Stufe hat nichts zum Umschalten — dann greift jedes // Nachregeln ins Leere, ganz gleich was bestellt wurde. diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift index cd1d4ab..fa61ffb 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift @@ -24,11 +24,28 @@ public actor LocalStreamServer { private static let segmentPrefix = "/s/" /// Der Pfad der Abspielliste selbst. 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. /// /// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche /// 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 { var teile = URLComponents() teile.path = segmentPrefix @@ -134,6 +151,18 @@ public actor LocalStreamServer { let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first 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) { let inhalt = Data((playlist ?? "").utf8) return finish( diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift index d1b9901..9092cc3 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift @@ -42,6 +42,7 @@ public actor StreamCache { /// Woher die Schnipsel wirklich kommen. private var upstream: URL? + private var headers: [String: String] = [:] /// Die Liste, in der Reihenfolge des Servers. private var segments: [String] = [] private var targetDuration: Duration = .seconds(6) @@ -77,23 +78,55 @@ public actor StreamCache { /// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen. /// /// 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 { - 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) for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) } let (daten, _) = try await session.data(for: request) - - 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) } + return (MediaPlaylist(String(decoding: daten, as: UTF8.self)), daten) } // MARK: - Ausliefern diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift index 23c9412..d5b1cbe 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift @@ -86,3 +86,60 @@ struct MediaPlaylistTests { #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")) + } +}