Untertitel brachten den Vorratsspeicher auf 404
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
88ba1a540c
commit
cff0c06745
@@ -39,17 +39,33 @@ public actor LocalStreamServer {
|
|||||||
/// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem
|
/// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem
|
||||||
/// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins
|
/// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins
|
||||||
/// Leere.
|
/// 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()
|
var teile = URLComponents()
|
||||||
teile.path = levelPrefix
|
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
|
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()
|
var teile = URLComponents()
|
||||||
teile.path = segmentPrefix
|
teile.path = segmentPrefix
|
||||||
teile.queryItems = [URLQueryItem(name: "p", value: original)]
|
teile.queryItems = [URLQueryItem(name: "u", value: absolute)]
|
||||||
return teile.string ?? segmentPrefix
|
return teile.string ?? segmentPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +170,10 @@ public actor LocalStreamServer {
|
|||||||
if pfadTeil.hasPrefix(Self.levelPrefix) {
|
if pfadTeil.hasPrefix(Self.levelPrefix) {
|
||||||
guard let teile = URLComponents(string: pfadTeil),
|
guard let teile = URLComponents(string: pfadTeil),
|
||||||
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value,
|
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()) }
|
else { return finish(verbindung, status: 404, body: Data()) }
|
||||||
|
|
||||||
return finish(
|
return finish(
|
||||||
@@ -173,10 +192,10 @@ public actor LocalStreamServer {
|
|||||||
|
|
||||||
guard pfadTeil.hasPrefix(Self.segmentPrefix),
|
guard pfadTeil.hasPrefix(Self.segmentPrefix),
|
||||||
let teile = URLComponents(string: pfadTeil),
|
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()) }
|
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())
|
return finish(verbindung, status: 504, body: Data())
|
||||||
}
|
}
|
||||||
finish(verbindung, status: 200, body: daten, type: "video/mp4")
|
finish(verbindung, status: 200, body: daten, type: "video/mp4")
|
||||||
|
|||||||
@@ -62,52 +62,71 @@ public struct MediaPlaylist: Sendable, Equatable {
|
|||||||
self.isMaster = master
|
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.
|
/// Schreibt jede Adresse um, ohne sonst etwas anzufassen.
|
||||||
///
|
///
|
||||||
/// Die Reihenfolge bleibt, die Anweisungen bleiben, die Leerzeilen
|
/// Die Reihenfolge bleibt, die Anweisungen bleiben, die Leerzeilen
|
||||||
/// bleiben. Nur wo eine Adresse stand, steht danach eine andere.
|
/// 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] = []
|
var ergebnis: [String] = []
|
||||||
ergebnis.reserveCapacity(lines.count)
|
ergebnis.reserveCapacity(lines.count)
|
||||||
|
var nächsteIstStufe = false
|
||||||
|
|
||||||
for zeile in lines {
|
for zeile in lines {
|
||||||
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
|
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
|
||||||
if getrimmt.isEmpty {
|
|
||||||
|
if getrimmt.hasPrefix("#EXT-X-STREAM-INF") {
|
||||||
|
nächsteIstStufe = true
|
||||||
ergebnis.append(zeile)
|
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:") {
|
} else if getrimmt.hasPrefix("#EXT-X-MAP:") {
|
||||||
// **Die eine Anweisung, in der eine Adresse steckt.**
|
ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .map) })
|
||||||
//
|
} else if getrimmt.isEmpty || getrimmt.hasPrefix("#") {
|
||||||
// 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(zeile)
|
ergebnis.append(zeile)
|
||||||
} else {
|
} else {
|
||||||
ergebnis.append(transform(getrimmt))
|
ergebnis.append(transform(getrimmt, nächsteIstStufe ? .level : .segment))
|
||||||
|
nächsteIstStufe = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ergebnis.joined(separator: "\n")
|
return ergebnis.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tauscht die Adresse in `#EXT-X-MAP:URI="…"` aus, sonst nichts.
|
/// Tauscht die Adresse in `URI="…"` aus, sonst nichts.
|
||||||
private static func rewritingMapURI(
|
private static func rewritingURI(
|
||||||
_ zeile: String,
|
in zeile: String,
|
||||||
_ transform: (String) -> String
|
_ transform: (String) -> String
|
||||||
) -> String {
|
) -> String {
|
||||||
let marke = "URI=\""
|
let marke = "URI=\""
|
||||||
guard let start = zeile.range(of: marke),
|
guard let start = zeile.range(of: marke),
|
||||||
let ende = zeile[start.upperBound...].firstIndex(of: "\"")
|
let ende = zeile[start.upperBound...].firstIndex(of: "\"")
|
||||||
else { return zeile }
|
else { return zeile }
|
||||||
let ende2 = ende..<zeile.index(after: ende)
|
|
||||||
|
|
||||||
let adresse = String(zeile[start.upperBound..<ende2.lowerBound])
|
let adresse = String(zeile[start.upperBound..<ende])
|
||||||
return zeile.replacingCharacters(
|
return zeile.replacingCharacters(in: start.upperBound..<ende, with: transform(adresse))
|
||||||
in: start.upperBound..<ende2.lowerBound,
|
|
||||||
with: transform(adresse)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ob die Liste vollständig ist — dann kommen keine Schnipsel mehr dazu.
|
/// Ob die Liste vollständig ist — dann kommen keine Schnipsel mehr dazu.
|
||||||
|
|||||||
@@ -77,49 +77,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.
|
/// **Alles wird auf eine vollständige Adresse umgeschrieben.** Relative
|
||||||
///
|
/// Angaben gelten je nach Liste gegen eine andere Grundadresse — die
|
||||||
/// **Zwei Ebenen, und beide müssen durch uns.** Bei „Auto" bestellen wir
|
/// Untertitelspur liegt woanders als das Bild. Wer das erst beim Abholen
|
||||||
/// eine Liste von **Stufen**; die Schnipsel stehen eine Ebene tiefer. Wer
|
/// auflöst, löst es irgendwann gegen die falsche auf. Hier steht die
|
||||||
/// hier aussteigt, reicht relative Adressen durch, die danach auf den
|
/// Grundadresse noch fest.
|
||||||
/// 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 {
|
||||||
self.headers = headers
|
self.headers = headers
|
||||||
let (liste, _) = try await load(url)
|
let liste = try await load(url)
|
||||||
|
return rewrite(liste, base: url, adopting: !liste.isMaster)
|
||||||
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.
|
/// Eine Liste, die der Player eine Ebene tiefer angefordert hat.
|
||||||
///
|
///
|
||||||
/// Ab hier stehen die Schnipsel fest, und der Vorrat beginnt zu laufen.
|
/// `prefetch` sagt, ob dahinter Bild liegt. Bei einer Nebenspur — also
|
||||||
public func playlist(at address: String) async -> String? {
|
/// Untertiteln — steht es auf `false`: sonst lüde der Vorrat Untertitel
|
||||||
guard let url = URL(string: address), let (liste, _) = try? await load(url) else { return nil }
|
/// statt Bild, und der Film bliebe stehen, während die Kachel meldet, es
|
||||||
guard !liste.isMaster else { return liste.rewritingReferences { $0 } }
|
/// sei reichlich geladen.
|
||||||
return adopt(liste, from: url)
|
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.
|
/// Ü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
|
// **Nur bei einem echten Wechsel von vorn.** Der Player holt dieselbe
|
||||||
// Liste immer wieder — nach einem Sprung, beim Stufenwechsel, von
|
// Liste immer wieder — nach einem Sprung, beim Stufenwechsel, von
|
||||||
// sich aus. Jedes Mal zurückzusetzen hiesse, den Vorrat jedes Mal zu
|
// sich aus. Jedes Mal zurückzusetzen hiesse, den Vorrat jedes Mal zu
|
||||||
// vergessen und von Schnipsel null neu zu zählen.
|
// vergessen und von Schnipsel null neu zu zählen.
|
||||||
let neueQuelle = upstream != url
|
let neueQuelle = upstream != base
|
||||||
upstream = url
|
upstream = base
|
||||||
segments = liste.segments
|
segments = liste.segments.map {
|
||||||
|
URL(string: $0, relativeTo: base)?.absoluteString ?? $0
|
||||||
|
}
|
||||||
if let dauer = liste.targetDuration { targetDuration = dauer }
|
if let dauer = liste.targetDuration { targetDuration = dauer }
|
||||||
|
|
||||||
if neueQuelle {
|
if neueQuelle {
|
||||||
@@ -127,29 +133,26 @@ public actor StreamCache {
|
|||||||
fetched = 0
|
fetched = 0
|
||||||
}
|
}
|
||||||
beginPrefetching()
|
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)
|
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)
|
return MediaPlaylist(String(decoding: daten, as: UTF8.self))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ausliefern
|
// MARK: - Ausliefern
|
||||||
|
|
||||||
/// Die Daten zu einem Pfad — aus dem Vorrat, sonst frisch geholt.
|
/// Die Daten zu einer Adresse — aus dem Vorrat, sonst frisch geholt.
|
||||||
public func data(for path: String) async -> Data? {
|
public func data(for address: String) async -> Data? {
|
||||||
if let index = segments.firstIndex(of: path) {
|
if let index = segments.firstIndex(of: address) {
|
||||||
// Woran der Player gerade ist. Daran hängt, wie weit wir
|
|
||||||
// vorauslaufen — und was wir wegwerfen dürfen.
|
|
||||||
lastRequested = max(lastRequested, index)
|
lastRequested = max(lastRequested, index)
|
||||||
trimBehind()
|
trimBehind()
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ort = stored[path], let daten = try? Data(contentsOf: ort) { return daten }
|
if let ort = stored[address], let daten = try? Data(contentsOf: ort) { return daten }
|
||||||
return await fetch(path)
|
return await fetch(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Vorauslaufen
|
// MARK: - Vorauslaufen
|
||||||
@@ -201,14 +204,13 @@ public actor StreamCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func fetch(_ path: String) async -> Data? {
|
private func fetch(_ address: String) async -> Data? {
|
||||||
guard let upstream, let ziel = URL(string: path, relativeTo: upstream)?.absoluteURL
|
guard let ziel = URL(string: address) else { return nil }
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (daten, antwort) = try await session.data(from: ziel)
|
let (daten, antwort) = try await session.data(from: ziel)
|
||||||
guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil }
|
guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil }
|
||||||
store(daten, at: path)
|
store(daten, at: address)
|
||||||
return daten
|
return daten
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ struct MediaPlaylistTests {
|
|||||||
/// findet man im laufenden Bild nicht.
|
/// findet man im laufenden Bild nicht.
|
||||||
@Test("Umgeschrieben werden Adressen, sonst nichts")
|
@Test("Umgeschrieben werden Adressen, sonst nichts")
|
||||||
func rewritesOnlyReferences() {
|
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/0.mp4"))
|
||||||
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/2.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.
|
/// irgendetwas falsch wäre.
|
||||||
@Test("Das Kopfstück wandert mit")
|
@Test("Das Kopfstück wandert mit")
|
||||||
func rewritesInitSegment() {
|
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\""))
|
#expect(neu.contains("#EXT-X-MAP:URI=\"/s/hls1/main/-1.mp4\""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Eine Anweisung ohne Adresse bleibt unberührt")
|
@Test("Eine Anweisung ohne Adresse bleibt unberührt")
|
||||||
func leavesOtherTagsAlone() {
|
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("#EXT-X-VERSION:7"))
|
||||||
#expect(neu.contains("#EXTINF:6.000000,"))
|
#expect(neu.contains("#EXTINF:6.000000,"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Die Zeilenzahl bleibt gleich")
|
@Test("Die Zeilenzahl bleibt gleich")
|
||||||
func keepsStructure() {
|
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)
|
#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")
|
@Test("Eine leere Eingabe ergibt eine leere Liste, keinen Absturz")
|
||||||
func emptyIsEmpty() {
|
func emptyIsEmpty() {
|
||||||
#expect(MediaPlaylist("").segments.isEmpty)
|
#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")
|
@Test("Eine Stufe wird vollständig adressiert, nicht relativ")
|
||||||
func levelPathIsAbsolute() {
|
func levelPathIsAbsolute() {
|
||||||
let pfad = LocalStreamServer.localPlaylistPath(
|
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)
|
let teile = URLComponents(string: pfad)
|
||||||
#expect(teile?.path == "/level.m3u8")
|
#expect(teile?.path == "/level.m3u8")
|
||||||
@@ -127,7 +184,7 @@ struct LocalStreamServerPathTests {
|
|||||||
/// eine Abspielliste oder Bild erwartet.
|
/// eine Abspielliste oder Bild erwartet.
|
||||||
@Test("Die Endung bleibt eine Abspielliste")
|
@Test("Die Endung bleibt eine Abspielliste")
|
||||||
func levelPathKeepsExtension() {
|
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
|
/// 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 pfad = LocalStreamServer.localPath(for: "hls1/main/12.mp4?runtimeTicks=0")
|
||||||
let teile = URLComponents(string: pfad)
|
let teile = URLComponents(string: pfad)
|
||||||
#expect(teile?.path == "/s/")
|
#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
|
/// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen
|
||||||
@@ -152,9 +209,9 @@ struct LocalStreamServerPathTests {
|
|||||||
main.m3u8?level=1
|
main.m3u8?level=1
|
||||||
"""
|
"""
|
||||||
let basis = URL(string: "http://server:8096/videos/x/master.m3u8?api_key=t")!
|
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
|
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)
|
#expect(neu.components(separatedBy: "/level.m3u8").count == 3)
|
||||||
|
|||||||
Reference in New Issue
Block a user