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)