From cff0c067454f4458d61554e7a750df1cc1696d09 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 28 Sep 2026 21:36:35 +0200 Subject: [PATCH] Untertitel brachten den Vorratsspeicher auf 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untertitel stehen in der Abspielliste als eigene Spur unter EXT-X-MEDIA — eine Anweisung mit einer Adresse darin. Umgeschrieben habe ich bisher nur EXT-X-MAP, also blieb diese relativ stehen und zeigte danach auf den lokalen Server, wo es sie nicht gibt. Zu sehen war der Fehler erst, als jemand Untertitel einschaltete: HTTP 404, mitten im Film. Jetzt sagt der Umschreiber, **was** fuer eine Adresse er gerade in der Hand hat: eine Qualitaetsstufe, eine Nebenspur, ein Kopfstueck oder ein Schnipsel. Das ist noetig, weil sie Verschiedenes wollen — hinter einer Stufe liegt Bild und die soll vorgeladen werden, hinter einer Untertitel- spur nicht. Sonst lueden wir Untertitel statt Bild: der Film bliebe stehen, waehrend die Anzeige reichlich Vorrat meldet. Dazu wandern alle Adressen im lokalen Server auf die **vollstaendige** Form. Eine relative gilt je nach Liste gegen eine andere Grundadresse — die Untertitelspur liegt woanders als das Bild —, und wer das erst beim Abholen aufloest, loest es irgendwann gegen die falsche auf. Das waere der naechste Fehler derselben Art gewesen. Drei Tests halten die Unterscheidung fest, darunter die Liste mit Untertitelspur, die den 404 ausgeloest hat. Co-Authored-By: Claude Opus 5 --- .../Prefetch/LocalStreamServer.swift | 33 +++++-- .../Prefetch/MediaPlaylist.swift | 61 +++++++----- .../FlixyPlayback/Prefetch/StreamCache.swift | 92 ++++++++++--------- .../MediaPlaylistTests.swift | 77 ++++++++++++++-- 4 files changed, 180 insertions(+), 83 deletions(-) diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift index fa61ffb..7e5f0db 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift @@ -39,17 +39,33 @@ public actor LocalStreamServer { /// 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 { + /// - Parameter prefetch: ob dahinter Bild liegt. Bei einer Nebenspur — + /// Untertitel oder zusätzlicher Ton — steht es auf `false`, sonst lüde + /// der Vorrat Untertitel statt Bild, und der Film bliebe stehen, + /// während die Anzeige reichlich Vorrat meldet. + public nonisolated static func localPlaylistPath( + for absolute: String, + prefetch: Bool + ) -> String { var teile = URLComponents() teile.path = levelPrefix - teile.queryItems = [URLQueryItem(name: "u", value: absolute)] + teile.queryItems = [ + URLQueryItem(name: "u", value: absolute), + URLQueryItem(name: "v", value: prefetch ? "1" : "0"), + ] return teile.string ?? levelPrefix } - public nonisolated static func localPath(for original: String) -> String { + /// Wie ein Schnipsel beim Server heisst. + /// + /// Die **vollständige** Adresse wandert in die Abfrage. Eine relative + /// gälte je nach Liste gegen eine andere Grundadresse — die + /// Untertitelspur liegt woanders als das Bild —, und wer das erst beim + /// Abholen auflöst, löst es irgendwann gegen die falsche auf. + public nonisolated static func localPath(for absolute: String) -> String { var teile = URLComponents() teile.path = segmentPrefix - teile.queryItems = [URLQueryItem(name: "p", value: original)] + teile.queryItems = [URLQueryItem(name: "u", value: absolute)] return teile.string ?? segmentPrefix } @@ -154,7 +170,10 @@ public actor LocalStreamServer { 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) + let inhalt = await cache.playlist( + at: adresse, + prefetch: teile.queryItems?.first { $0.name == "v" }?.value != "0" + ) else { return finish(verbindung, status: 404, body: Data()) } return finish( @@ -173,10 +192,10 @@ public actor LocalStreamServer { guard pfadTeil.hasPrefix(Self.segmentPrefix), let teile = URLComponents(string: pfadTeil), - let original = teile.queryItems?.first(where: { $0.name == "p" })?.value + let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value else { return finish(verbindung, status: 404, body: Data()) } - guard let daten = await cache.data(for: original) else { + guard let daten = await cache.data(for: adresse) else { return finish(verbindung, status: 504, body: Data()) } finish(verbindung, status: 200, body: daten, type: "video/mp4") diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift index 0facb37..a8c2d9c 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift @@ -62,52 +62,71 @@ public struct MediaPlaylist: Sendable, Equatable { self.isMaster = master } + /// Was für eine Adresse an einer Stelle steht. + /// + /// **Warum das jemanden interessiert.** Eine Abspielliste enthält + /// viererlei Adressen, und sie wollen Verschiedenes: eine Qualitätsstufe + /// führt zu weiteren Schnipseln und soll vorgeladen werden, eine + /// Untertitelspur führt zu einer eigenen Liste und soll es **nicht** — + /// sonst lädt der Vorrat Untertitel statt Bild. + public enum Reference: Sendable, Equatable { + /// Eine Qualitätsstufe — dahinter liegt das Bild. + case level + /// Eine Nebenspur: Untertitel oder zusätzlicher Ton. + case rendition + /// Das Kopfstück einer fragmentierten MP4. + case map + /// Ein Schnipsel. + case segment + } + /// Schreibt jede Adresse um, ohne sonst etwas anzufassen. /// /// Die Reihenfolge bleibt, die Anweisungen bleiben, die Leerzeilen /// bleiben. Nur wo eine Adresse stand, steht danach eine andere. - public func rewritingReferences(_ transform: (String) -> String) -> String { + /// + /// **Auch die Adressen in Anweisungen.** Zwei Anweisungen tragen eine: + /// `EXT-X-MAP` das Kopfstück und `EXT-X-MEDIA` die Nebenspuren. Beide + /// stehen zu lassen ergibt eine Liste, die halb auf uns und halb ins + /// Leere zeigt — und das endet mit „404", sobald jemand Untertitel + /// einschaltet. + public func rewritingReferences(_ transform: (String, Reference) -> String) -> String { var ergebnis: [String] = [] ergebnis.reserveCapacity(lines.count) + var nächsteIstStufe = false for zeile in lines { let getrimmt = zeile.trimmingCharacters(in: .whitespaces) - if getrimmt.isEmpty { + + if getrimmt.hasPrefix("#EXT-X-STREAM-INF") { + nächsteIstStufe = true ergebnis.append(zeile) + } else if getrimmt.hasPrefix("#EXT-X-MEDIA:") { + ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .rendition) }) } else if getrimmt.hasPrefix("#EXT-X-MAP:") { - // **Die eine Anweisung, in der eine Adresse steckt.** - // - // Sie zeigt auf das Kopfstück einer fragmentierten MP4 — ohne - // das ist kein einziger Schnipsel zu dekodieren. Bliebe sie - // stehen, zeigte sie relativ auf den lokalen Server, und dort - // gibt es sie nicht: schwarzes Bild, ohne dass an den - // Schnipseln irgendetwas falsch wäre. - ergebnis.append(Self.rewritingMapURI(zeile, transform)) - } else if getrimmt.hasPrefix("#") { + ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .map) }) + } else if getrimmt.isEmpty || getrimmt.hasPrefix("#") { ergebnis.append(zeile) } else { - ergebnis.append(transform(getrimmt)) + ergebnis.append(transform(getrimmt, nächsteIstStufe ? .level : .segment)) + nächsteIstStufe = false } } return ergebnis.joined(separator: "\n") } - /// Tauscht die Adresse in `#EXT-X-MAP:URI="…"` aus, sonst nichts. - private static func rewritingMapURI( - _ zeile: String, + /// Tauscht die Adresse in `URI="…"` aus, sonst nichts. + private static func rewritingURI( + in zeile: String, _ transform: (String) -> String ) -> String { let marke = "URI=\"" guard let start = zeile.range(of: marke), let ende = zeile[start.upperBound...].firstIndex(of: "\"") else { return zeile } - let ende2 = ende.. String { 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) + let liste = try await load(url) + return rewrite(liste, base: url, adopting: !liste.isMaster) } - /// Eine Stufe, die der Player angefordert hat. + /// Eine Liste, die der Player eine Ebene tiefer 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) + /// `prefetch` sagt, ob dahinter Bild liegt. Bei einer Nebenspur — also + /// Untertiteln — steht es auf `false`: sonst lüde der Vorrat Untertitel + /// statt Bild, und der Film bliebe stehen, während die Kachel meldet, es + /// sei reichlich geladen. + public func playlist(at address: String, prefetch: Bool) async -> String? { + guard let url = URL(string: address), let liste = try? await load(url) else { return nil } + return rewrite(liste, base: url, adopting: prefetch && !liste.isMaster) + } + + private func rewrite(_ liste: MediaPlaylist, base: URL, adopting: Bool) -> String { + if adopting { adopt(liste, base: base) } + + return liste.rewritingReferences { adresse, art in + let absolut = URL(string: adresse, relativeTo: base)?.absoluteString ?? adresse + switch art { + case .level: + return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: true) + case .rendition: + return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: false) + case .map, .segment: + return LocalStreamServer.localPath(for: absolut) + } + } } /// Übernimmt eine Liste von Schnipseln als die, der wir vorauslaufen. - private func adopt(_ liste: MediaPlaylist, from url: URL) -> String { + private func adopt(_ liste: MediaPlaylist, base: URL) { // **Nur bei einem echten Wechsel von vorn.** Der Player holt dieselbe // Liste immer wieder — nach einem Sprung, beim Stufenwechsel, von // sich aus. Jedes Mal zurückzusetzen hiesse, den Vorrat jedes Mal zu // vergessen und von Schnipsel null neu zu zählen. - let neueQuelle = upstream != url - upstream = url - segments = liste.segments + let neueQuelle = upstream != base + upstream = base + segments = liste.segments.map { + URL(string: $0, relativeTo: base)?.absoluteString ?? $0 + } if let dauer = liste.targetDuration { targetDuration = dauer } if neueQuelle { @@ -127,29 +133,26 @@ public actor StreamCache { fetched = 0 } beginPrefetching() - return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) } } - private func load(_ url: URL) async throws -> (MediaPlaylist, Data) { + private func load(_ url: URL) async throws -> MediaPlaylist { var request = URLRequest(url: url) for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) } let (daten, _) = try await session.data(for: request) - return (MediaPlaylist(String(decoding: daten, as: UTF8.self)), daten) + return MediaPlaylist(String(decoding: daten, as: UTF8.self)) } // MARK: - Ausliefern - /// Die Daten zu einem Pfad — aus dem Vorrat, sonst frisch geholt. - public func data(for path: String) async -> Data? { - if let index = segments.firstIndex(of: path) { - // Woran der Player gerade ist. Daran hängt, wie weit wir - // vorauslaufen — und was wir wegwerfen dürfen. + /// Die Daten zu einer Adresse — aus dem Vorrat, sonst frisch geholt. + public func data(for address: String) async -> Data? { + if let index = segments.firstIndex(of: address) { lastRequested = max(lastRequested, index) trimBehind() } - if let ort = stored[path], let daten = try? Data(contentsOf: ort) { return daten } - return await fetch(path) + if let ort = stored[address], let daten = try? Data(contentsOf: ort) { return daten } + return await fetch(address) } // MARK: - Vorauslaufen @@ -201,14 +204,13 @@ public actor StreamCache { } @discardableResult - private func fetch(_ path: String) async -> Data? { - guard let upstream, let ziel = URL(string: path, relativeTo: upstream)?.absoluteURL - else { return nil } + private func fetch(_ address: String) async -> Data? { + guard let ziel = URL(string: address) else { return nil } do { let (daten, antwort) = try await session.data(from: ziel) guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil } - store(daten, at: path) + store(daten, at: address) return daten } catch { return nil diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift index e85cd70..845b1b9 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift @@ -49,7 +49,7 @@ struct MediaPlaylistTests { /// findet man im laufenden Bild nicht. @Test("Umgeschrieben werden Adressen, sonst nichts") func rewritesOnlyReferences() { - let neu = MediaPlaylist(medien).rewritingReferences { "http://127.0.0.1:1234/s/\($0)" } + let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "http://127.0.0.1:1234/s/\(adresse)" } #expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/0.mp4")) #expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/2.mp4")) @@ -68,20 +68,20 @@ struct MediaPlaylistTests { /// irgendetwas falsch wäre. @Test("Das Kopfstück wandert mit") func rewritesInitSegment() { - let neu = MediaPlaylist(medien).rewritingReferences { "/s/\($0)" } + let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" } #expect(neu.contains("#EXT-X-MAP:URI=\"/s/hls1/main/-1.mp4\"")) } @Test("Eine Anweisung ohne Adresse bleibt unberührt") func leavesOtherTagsAlone() { - let neu = MediaPlaylist(medien).rewritingReferences { "/s/\($0)" } + let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" } #expect(neu.contains("#EXT-X-VERSION:7")) #expect(neu.contains("#EXTINF:6.000000,")) } @Test("Die Zeilenzahl bleibt gleich") func keepsStructure() { - let neu = MediaPlaylist(medien).rewritingReferences { $0 } + let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in adresse } #expect(neu.components(separatedBy: "\n").count == medien.components(separatedBy: "\n").count) } @@ -101,7 +101,63 @@ struct MediaPlaylistTests { @Test("Eine leere Eingabe ergibt eine leere Liste, keinen Absturz") func emptyIsEmpty() { #expect(MediaPlaylist("").segments.isEmpty) - #expect(MediaPlaylist("").rewritingReferences { $0 } == "") + #expect(MediaPlaylist("").rewritingReferences { adresse, _ in adresse } == "") + } +} + +@Suite("Nebenspuren in der Abspielliste") +struct PlaylistRenditionTests { + /// **Der Fehler, der mit „404" endete.** Untertitel stehen in einer + /// eigenen Anweisung mit einer Adresse darin. Blieb sie stehen, zeigte + /// sie relativ auf den lokalen Server — und dort gibt es sie nicht. Zu + /// sehen war das erst, als jemand Untertitel einschaltete. + private let mitUntertiteln = """ + #EXTM3U + #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",URI="subtitles/0.m3u8" + #EXT-X-STREAM-INF:BANDWIDTH=8000000,SUBTITLES="subs" + main.m3u8?level=0 + """ + + @Test("Die Untertitelspur wird mit umgeschrieben") + func rewritesSubtitleRendition() { + let neu = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, _ in + "/um/\(adresse)" + } + #expect(neu.contains("URI=\"/um/subtitles/0.m3u8\"")) + #expect(!neu.contains("URI=\"subtitles/0.m3u8\"")) + } + + /// **Und sie darf nicht vorgeladen werden.** Sonst lüde der Vorrat + /// Untertitel statt Bild: der Film bliebe stehen, während die Anzeige + /// reichlich Vorrat meldet. + @Test("Stufe und Nebenspur werden unterschieden") + func tellsLevelFromRendition() { + var arten: [String: MediaPlaylist.Reference] = [:] + _ = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, art in + arten[adresse] = art + return adresse + } + + #expect(arten["main.m3u8?level=0"] == .level) + #expect(arten["subtitles/0.m3u8"] == .rendition) + } + + @Test("Das Kopfstück gilt als Schnipsel, nicht als Liste") + func mapIsASegment() { + let liste = """ + #EXTM3U + #EXT-X-MAP:URI="hls1/main/-1.mp4" + #EXTINF:6.0, + hls1/main/0.mp4 + """ + var arten: [String: MediaPlaylist.Reference] = [:] + _ = MediaPlaylist(liste).rewritingReferences { adresse, art in + arten[adresse] = art + return adresse + } + + #expect(arten["hls1/main/-1.mp4"] == .map) + #expect(arten["hls1/main/0.mp4"] == .segment) } } @@ -113,7 +169,8 @@ struct LocalStreamServerPathTests { @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" + for: "http://server:8096/videos/x/main.m3u8?level=1&api_key=t", + prefetch: true ) let teile = URLComponents(string: pfad) #expect(teile?.path == "/level.m3u8") @@ -127,7 +184,7 @@ struct LocalStreamServerPathTests { /// eine Abspielliste oder Bild erwartet. @Test("Die Endung bleibt eine Abspielliste") func levelPathKeepsExtension() { - #expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8").hasPrefix("/level.m3u8")) + #expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8", prefetch: true).hasPrefix("/level.m3u8")) } /// Schrägstriche und Punkte im ursprünglichen Pfad dürfen nichts @@ -137,7 +194,7 @@ struct LocalStreamServerPathTests { 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") + #expect(teile?.queryItems?.first { $0.name == "u" }?.value == "hls1/main/12.mp4?runtimeTicks=0") } /// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen @@ -152,9 +209,9 @@ struct LocalStreamServerPathTests { 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 neu = MediaPlaylist(stufen).rewritingReferences { eintrag, _ in let absolut = URL(string: eintrag, relativeTo: basis)?.absoluteString ?? eintrag - return LocalStreamServer.localPlaylistPath(for: absolut) + return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: true) } #expect(neu.components(separatedBy: "/level.m3u8").count == 3)