Fünf Fehler auf einem Weg — warum nie ein Untertitel kam
Sechs Reparaturversuche haben nichts bewirkt, weil jeder an einer Stelle
ansetzte, die gar nicht die Ursache war. Eine systematische Untersuchung —
45 Hypothesen, jede von drei Skeptikern gegengeprüft — hat fünf voneinander
unabhängige Defekte auf demselben Weg gefunden. Jeder einzelne genügt.
**1. Es wurde eine Playlist bestellt, die keine Untertitelspur tragen kann.**
Jellyfins TranscodingUrl lautet `main.m3u8` — eine Media Playlist, also eine
Liste von Schnipseln. `#EXT-X-MEDIA`, die Zeile, mit der ein Server eine
Untertitelspur ankündigt, darf nach RFC 8216 nur in einer Master Playlist
stehen. Der Wechsel main→master hing aber an der Leiter, und die wird beim
Umpacken und bei jeder von Hand gewählten Stufe nicht bestellt. Ein Matroska
geht immer über das Umpacken — die halbe Sammlung konnte strukturell keine
Untertitel haben.
Der Kommentar daneben war überholt und hat den Fehler verdeckt: er stammte
aus der Zeit, als Umpacken eine fortlaufende MP4 war. Seit es über HLS läuft,
ist die Master-Liste genau richtig. Pfadwechsel und Leiter sind jetzt zwei
Fragen, nicht eine.
**2. Der lokale Server etikettierte jeden Schnipsel als video/mp4.** Auch die
WebVTT-Schnipsel der Untertitelspur. So angekündigt gehen sie bei
AVFoundation an den falschen Parser, finden keine Box, und es entsteht
lautlos keine Einblendung — die Spur steht im Blatt, ist ausgewählt, bleibt
leer. Der Vorrat behält jetzt die Sorte der Gegenstelle; die Endung ist nur
noch der Notnagel.
**3. Nach dem ersten Untertitel wählte niemand mehr etwas aus.**
`selectedSubtitleTrack` wurde beim Neuaufbau nie zurückgesetzt, und beide
Wähler prüfen genau darauf. Eine frische Engine hat aber nichts ausgewählt —
also blieb sie für den Rest des Films stumm, während das Symbol „ein" zeigte.
Auch das Nachfassen im Sekundentakt war damit stillgelegt. Die Bestellung
(`selectedSubtitleStream`) bleibt, die Merker fallen.
**4. Die Schutzmarke war nach 250 ms tot.** `restart()` setzte sie als
Allererstes — und wartete danach auf einen echten Netzaufruf mit fünf
Sekunden Zeitgrenze, bevor es den alten Ereignisstrom abbestellte. In diesem
Fenster meldete der alte Player weiter seine Stelle, und die Prüfung „sind
wir angekommen" traf sofort zu. Die Marke war gelöscht, bevor der neue Stream
bestellt war; danach setzte jede Meldung des anlaufenden Streams die Stelle
auf null, und der nächste Neuaufbau merkte sich den Filmanfang. Das war der
Sprung — und der Grund, warum mein Fix von 88ba1a5 wirkungslos blieb.
**5. Der Sprung wurde einmal versucht und nie geprüft.** Der Rückgabewert von
`seek` wurde verworfen, die gemerkte Stelle verbraucht, bevor gesprungen
wurde, und framegenaue Toleranz verlangt — in einen Stream, den der Server
gerade erst erzeugt. Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt
ist, die Toleranz liegt bei einer halben Sekunde, und jedes Wachsen des
springbaren Bereichs ist ein neuer Anlass.
Belegt statt vermutet: ein neuer Test startet den lokalen Server gegen einen
eigenen Herkunftsserver und liest die Antworten Byte für Byte — ohne Gerät,
ohne Simulator, in 0,03 Sekunden. Er hat Fehler 2 vor der Reparatur
reproduziert und danach bestätigt. Fünf weitere Tests halten Fehler 1 fest.
Zwei alte Tests hielten das fehlerhafte Verhalten fest und waren damit
mitschuldig; beide sind mitgeändert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8e0e8ec246
commit
c681d69fcc
@@ -0,0 +1,367 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import FlixyPlayback
|
||||
|
||||
/// Was der lokale Server dem Player wirklich schickt.
|
||||
///
|
||||
/// **Warum es diesen Test gibt.** Der Speicher steht zwischen Jellyfin und
|
||||
/// AVPlayer, und was dort schiefgeht, sieht von aussen aus wie „der Server
|
||||
/// liefert nichts" oder „der Player zeigt nichts". Sechs Reparaturversuche an
|
||||
/// den falschen Stellen später steht fest: ohne eine Stelle, an der man die
|
||||
/// Antwort **liest**, ist das nicht zu finden.
|
||||
///
|
||||
/// Hier steht ein eigener Herkunftsserver, der aufschreibt, was der Speicher
|
||||
/// ihn gefragt hat, und der Test liest die Antworten Byte für Byte. Kein
|
||||
/// Gerät, kein Simulator, kein echtes Jellyfin.
|
||||
@Suite("Der lokale Server", .serialized)
|
||||
struct LocalStreamServerTests {
|
||||
|
||||
// MARK: - Vorlagen
|
||||
|
||||
private static let master = """
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",LANGUAGE="ger",DEFAULT=YES,AUTOSELECT=YES,URI="subtitles/2/subtitles.m3u8?api_key=abc&SubtitleStreamIndex=2"
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,SUBTITLES="subs"
|
||||
main.m3u8?api_key=abc
|
||||
"""
|
||||
|
||||
private static let bildListe = """
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:6.000000,
|
||||
hls1/main/0.ts?api_key=abc
|
||||
#EXTINF:6.000000,
|
||||
hls1/main/1.ts?api_key=abc
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
|
||||
private static let untertitelListe = """
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:6.000000,
|
||||
0.vtt?api_key=abc&SubtitleStreamIndex=2
|
||||
#EXTINF:6.000000,
|
||||
1.vtt?api_key=abc&SubtitleStreamIndex=2
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
|
||||
private static let vtt = """
|
||||
WEBVTT
|
||||
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
|
||||
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
Hallo Welt.
|
||||
"""
|
||||
|
||||
/// Der Herkunftsserver mit den Antworten, die Jellyfin geben würde.
|
||||
private static func jellyfin() -> FakeOrigin {
|
||||
let server = FakeOrigin()
|
||||
server.add("/videos/x/master.m3u8", type: "application/vnd.apple.mpegurl", body: master)
|
||||
server.add("/videos/x/main.m3u8", type: "application/vnd.apple.mpegurl", body: bildListe)
|
||||
server.add("/videos/x/subtitles/2/subtitles.m3u8", type: "application/vnd.apple.mpegurl", body: untertitelListe)
|
||||
server.add("/videos/x/hls1/main/0.ts", type: "video/MP2T", body: "TS-DATEN-0")
|
||||
server.add("/videos/x/hls1/main/1.ts", type: "video/MP2T", body: "TS-DATEN-1")
|
||||
server.add("/videos/x/subtitles/2/0.vtt", type: "text/vtt", body: vtt)
|
||||
server.add("/videos/x/subtitles/2/1.vtt", type: "text/vtt", body: vtt)
|
||||
return server
|
||||
}
|
||||
|
||||
// MARK: - Der Aufbau
|
||||
|
||||
/// Startet Herkunftsserver und Speicher und gibt die Adresse zurück, die
|
||||
/// der Player bekäme.
|
||||
private func aufbauen() async throws -> (FakeOrigin, LocalStreamServer, URL) {
|
||||
let herkunft = Self.jellyfin()
|
||||
let port = try await herkunft.start()
|
||||
|
||||
let speicher = StreamCache()
|
||||
let server = LocalStreamServer(cache: speicher)
|
||||
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
|
||||
let lokal = try await server.start(upstream: oben)
|
||||
return (herkunft, server, lokal)
|
||||
}
|
||||
|
||||
/// Holt eine Adresse und gibt Status, Inhaltstyp und Körper zurück.
|
||||
private func hole(_ url: URL) async throws -> (status: Int, type: String?, body: String) {
|
||||
let (daten, antwort) = try await URLSession.shared.data(from: url)
|
||||
let http = antwort as? HTTPURLResponse
|
||||
return (
|
||||
http?.statusCode ?? 0,
|
||||
http?.value(forHTTPHeaderField: "Content-Type"),
|
||||
String(decoding: daten, as: UTF8.self)
|
||||
)
|
||||
}
|
||||
|
||||
/// Zieht die erste Adresse aus einem `URI="…"` der Liste.
|
||||
private func renditionAdresse(in liste: String, basis: URL) -> URL? {
|
||||
guard let start = liste.range(of: "URI=\""),
|
||||
let ende = liste[start.upperBound...].firstIndex(of: "\"")
|
||||
else { return nil }
|
||||
return URL(string: String(liste[start.upperBound..<ende]), relativeTo: basis)
|
||||
}
|
||||
|
||||
/// Zieht die erste Segment-Zeile aus einer Medienliste.
|
||||
private func ersteSegmentAdresse(in liste: String, basis: URL) -> URL? {
|
||||
for zeile in liste.components(separatedBy: .newlines) {
|
||||
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
|
||||
guard !getrimmt.isEmpty, !getrimmt.hasPrefix("#") else { continue }
|
||||
return URL(string: getrimmt, relativeTo: basis)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Die Tests
|
||||
|
||||
/// **Der entscheidende.** Ein WebVTT-Körper, angekündigt als `video/mp4`,
|
||||
/// geht bei AVFoundation an den falschen Parser, findet keine Box, und es
|
||||
/// entstehen keine Einblendungen — lautlos. Die Spur steht im Blatt, ist
|
||||
/// ausgewählt, und bleibt leer.
|
||||
@Test("Ein Untertitel-Schnipsel kommt als text/vtt zurück")
|
||||
func subtitleSegmentKeepsItsType() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
|
||||
let rendition = try await hole(renditionURL)
|
||||
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
|
||||
|
||||
let segment = try await hole(segmentURL)
|
||||
#expect(segment.status == 200)
|
||||
#expect(segment.body.hasPrefix("WEBVTT"))
|
||||
#expect(segment.type == "text/vtt", "geliefert wurde: \(segment.type ?? "nichts")")
|
||||
}
|
||||
|
||||
/// Gegentest, damit die Reparatur nicht zu breit wird.
|
||||
@Test("Ein Bild-Schnipsel behält seinen eigenen Typ")
|
||||
func videoSegmentKeepsItsType() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
// Die Bildstufe ist die Zeile ohne `URI=` — die erste gewöhnliche
|
||||
// Adresse in der Liste.
|
||||
let stufeURL = try #require(ersteSegmentAdresse(in: masterAntwort.body, basis: lokal))
|
||||
let stufe = try await hole(stufeURL)
|
||||
let segmentURL = try #require(ersteSegmentAdresse(in: stufe.body, basis: lokal))
|
||||
|
||||
let segment = try await hole(segmentURL)
|
||||
#expect(segment.status == 200)
|
||||
#expect(segment.type == "video/MP2T", "geliefert wurde: \(segment.type ?? "nichts")")
|
||||
}
|
||||
|
||||
/// Die Abfrage des Servers wandert durch unsere eigene Abfrage hindurch.
|
||||
/// Verliert `URLComponents` dabei etwas, holt der Speicher die falsche
|
||||
/// Datei — und niemand sähe es der Antwort an.
|
||||
@Test("Die Adresse übersteht die Rundreise zeichengenau")
|
||||
func addressSurvivesTheRoundTrip() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
_ = try await hole(renditionURL)
|
||||
|
||||
let gesehen = herkunft.requests
|
||||
#expect(
|
||||
gesehen.contains { $0.contains("/videos/x/subtitles/2/subtitles.m3u8")
|
||||
&& $0.contains("api_key=abc")
|
||||
&& $0.contains("SubtitleStreamIndex=2") },
|
||||
"der Herkunftsserver sah: \(gesehen)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ohne diese Zeilen gibt es für AVPlayer keine Untertitelspur — egal was
|
||||
/// danach passiert.
|
||||
@Test("Die Untertitelspur bleibt in der Master-Liste stehen")
|
||||
func masterKeepsTheRendition() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let antwort = try await hole(lokal)
|
||||
#expect(antwort.status == 200)
|
||||
#expect(antwort.type == "application/vnd.apple.mpegurl")
|
||||
#expect(antwort.body.contains("#EXT-X-MEDIA:TYPE=SUBTITLES"))
|
||||
#expect(antwort.body.contains("GROUP-ID=\"subs\""))
|
||||
#expect(antwort.body.contains("LANGUAGE=\"ger\""))
|
||||
#expect(antwort.body.contains("DEFAULT=YES"))
|
||||
#expect(antwort.body.contains("SUBTITLES=\"subs\""))
|
||||
}
|
||||
|
||||
/// Sonst lüde der Vorrat Untertitel statt Bild: der Film bliebe stehen,
|
||||
/// während die Anzeige reichlich Vorrat meldet.
|
||||
@Test("Die Untertitelspur wird nicht vorgeladen")
|
||||
func renditionIsNotPrefetched() async throws {
|
||||
let herkunft = Self.jellyfin()
|
||||
let port = try await herkunft.start()
|
||||
|
||||
let speicher = StreamCache()
|
||||
let server = LocalStreamServer(cache: speicher)
|
||||
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
|
||||
let lokal = try await server.start(upstream: oben)
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
_ = try await hole(renditionURL)
|
||||
|
||||
#expect(await speicher.bufferedAhead == .zero)
|
||||
}
|
||||
|
||||
/// Ein leeres 200 ist genauso unsichtbar wie der falsche Typ. Die beiden
|
||||
/// müssen auseinanderzuhalten sein, sonst sucht man wieder an der falschen
|
||||
/// Stelle.
|
||||
@Test("Ein Fehler von oben bleibt ein Fehler")
|
||||
func upstreamFailureStaysAFailure() async throws {
|
||||
let herkunft = Self.jellyfin()
|
||||
herkunft.add("/videos/x/subtitles/2/0.vtt", status: 401, type: "text/plain", body: "nope")
|
||||
let port = try await herkunft.start()
|
||||
|
||||
let speicher = StreamCache()
|
||||
let server = LocalStreamServer(cache: speicher)
|
||||
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
|
||||
let lokal = try await server.start(upstream: oben)
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
let rendition = try await hole(renditionURL)
|
||||
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
|
||||
|
||||
let segment = try await hole(segmentURL)
|
||||
#expect(segment.status == 504)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Der Herkunftsserver
|
||||
|
||||
/// Ein winziger HTTP-Server, der die Antworten gibt, die Jellyfin geben würde
|
||||
/// — und aufschreibt, wonach gefragt wurde.
|
||||
///
|
||||
/// Das Protokoll ist der eigentliche Gewinn: ohne es sieht man nur, was
|
||||
/// **heraus**kommt, nicht wonach der Speicher gefragt hat. Genau diese Lücke
|
||||
/// hat die Fehlersuche so lange gekostet.
|
||||
final class FakeOrigin: @unchecked Sendable {
|
||||
private struct Antwort {
|
||||
let status: Int
|
||||
let type: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var antworten: [String: Antwort] = [:]
|
||||
private var gesehen: [String] = []
|
||||
private var listener: NWListener?
|
||||
|
||||
func add(_ path: String, status: Int = 200, type: String, body: String) {
|
||||
lock.withLock { antworten[path] = Antwort(status: status, type: type, body: body) }
|
||||
}
|
||||
|
||||
/// Alles, wonach gefragt wurde — mit vollständiger Abfrage.
|
||||
var requests: [String] {
|
||||
lock.withLock { gesehen }
|
||||
}
|
||||
|
||||
func start() async throws -> UInt16 {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
|
||||
let lauscher = try NWListener(using: parameters)
|
||||
|
||||
lauscher.newConnectionHandler = { [weak self] verbindung in
|
||||
verbindung.start(queue: .global(qos: .userInitiated))
|
||||
self?.receive(on: verbindung, buffer: Data())
|
||||
}
|
||||
|
||||
let port: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
|
||||
let riegel = TestOneShot()
|
||||
lauscher.stateUpdateHandler = { zustand in
|
||||
switch zustand {
|
||||
case .ready:
|
||||
guard riegel.claim() else { return }
|
||||
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
|
||||
case .failed(let fehler):
|
||||
guard riegel.claim() else { return }
|
||||
fortsetzen.resume(throwing: fehler)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
lauscher.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
listener = lauscher
|
||||
return port
|
||||
}
|
||||
|
||||
func stop() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
}
|
||||
|
||||
private func receive(on verbindung: NWConnection, buffer: Data) {
|
||||
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
|
||||
[weak self] daten, _, abgeschlossen, fehler in
|
||||
guard let self else { return }
|
||||
var gesammelt = buffer
|
||||
if let daten { gesammelt.append(daten) }
|
||||
|
||||
guard let ende = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
if fehler == nil, !abgeschlossen {
|
||||
self.receive(on: verbindung, buffer: gesammelt)
|
||||
} else {
|
||||
verbindung.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let kopf = String(decoding: gesammelt[..<ende.lowerBound], as: UTF8.self)
|
||||
self.antworte(auf: kopf, verbindung: verbindung)
|
||||
}
|
||||
}
|
||||
|
||||
private func antworte(auf kopf: String, verbindung: NWConnection) {
|
||||
let zeile = kopf.components(separatedBy: "\r\n").first ?? ""
|
||||
let pfadMitAbfrage = zeile.components(separatedBy: " ").dropFirst().first ?? ""
|
||||
|
||||
let pfad = pfadMitAbfrage.components(separatedBy: "?").first ?? pfadMitAbfrage
|
||||
let antwort = lock.withLock { () -> Antwort? in
|
||||
gesehen.append(pfadMitAbfrage)
|
||||
return antworten[pfad]
|
||||
}
|
||||
|
||||
let a = antwort ?? Antwort(status: 404, type: "text/plain", body: "nicht da")
|
||||
let koerper = Data(a.body.utf8)
|
||||
let ausgabe = """
|
||||
HTTP/1.1 \(a.status) OK\r
|
||||
Content-Type: \(a.type)\r
|
||||
Content-Length: \(koerper.count)\r
|
||||
Connection: close\r
|
||||
\r
|
||||
|
||||
"""
|
||||
var alles = Data(ausgabe.utf8)
|
||||
alles.append(koerper)
|
||||
verbindung.send(content: alles, completion: .contentProcessed { _ in verbindung.cancel() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie im Original: ein Riegel, der genau einmal aufgeht.
|
||||
private final class TestOneShot: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var used = false
|
||||
|
||||
func claim() -> Bool {
|
||||
lock.withLock {
|
||||
guard !used else { return false }
|
||||
used = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,13 @@ struct PlaybackTests {
|
||||
transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720")
|
||||
))
|
||||
|
||||
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/main.m3u8"))
|
||||
// **`master.m3u8`, nicht `main.m3u8`.** Der Pfad des Servers bleibt,
|
||||
// nur die Liste wechselt: `main` führt die Schnipsel einer Stufe,
|
||||
// `master` die Spuren. Nur in der zweiten darf `#EXT-X-MEDIA` stehen
|
||||
// — die Zeile, mit der eine Untertitelspur angekündigt wird. Dieser
|
||||
// Test hielt vorher das Gegenteil fest und war damit mitschuldig,
|
||||
// dass nie ein Untertitel erschien.
|
||||
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/master.m3u8"))
|
||||
#expect(target.sessionID == "ps-7")
|
||||
|
||||
// Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu
|
||||
@@ -344,3 +350,94 @@ struct SubtitleStreamIndexTests {
|
||||
#expect(!(try query(transport)).contains { $0.name == "subtitleStreamIndex" })
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Die Master-Liste, auch ohne Leiter")
|
||||
struct MasterPlaylistTests {
|
||||
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
|
||||
JellyfinBackend(
|
||||
client: JellyfinClient(
|
||||
baseURL: URL(string: "http://server:8096")!,
|
||||
identity: JellyfinIdentity(
|
||||
client: "Flixy", device: "Testgerät", deviceID: "gerät-1", version: "1"
|
||||
),
|
||||
token: "tok",
|
||||
transport: transport
|
||||
),
|
||||
account: JellyfinAccount(
|
||||
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
|
||||
),
|
||||
serverName: "Attrappe"
|
||||
)
|
||||
}
|
||||
|
||||
private func transport() -> RecordingTransport {
|
||||
RecordingTransport(response: """
|
||||
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
|
||||
"PlaySessionId": "ps-7"}
|
||||
""")
|
||||
}
|
||||
|
||||
private func target(remux: Bool, adaptive: Bool) async throws -> URL {
|
||||
try await backend(transport()).preparePlayback(PlaybackIntent(
|
||||
itemID: "film-1",
|
||||
versionIndex: 0,
|
||||
file: MediaFile(id: "src-1", key: "src-1", container: "mkv"),
|
||||
transcode: TranscodeSettings(
|
||||
maximumBitrate: nil,
|
||||
resolution: nil,
|
||||
prefersAdaptive: adaptive,
|
||||
prefersRemux: remux
|
||||
)
|
||||
)).url
|
||||
}
|
||||
|
||||
/// **Der Fehler, wegen dem nie ein Untertitel erschien.** `main.m3u8` ist
|
||||
/// eine Liste von Schnipseln; `#EXT-X-MEDIA` — die Zeile, mit der ein
|
||||
/// Server eine Untertitelspur ankündigt — darf nach RFC 8216 nur in einer
|
||||
/// Master-Liste stehen. Auf `main.m3u8` kann es strukturell keine
|
||||
/// Untertitel geben. Und ein Matroska geht immer über das Umpacken.
|
||||
@Test("Umgepackt kommt die Master-Liste")
|
||||
func remuxGetsMaster() async throws {
|
||||
let adresse = try await target(remux: true, adaptive: false)
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
}
|
||||
|
||||
/// Die Leiter ist eine **andere** Frage als die Master-Liste. Beim
|
||||
/// Umpacken gibt es nur eine Fassung — sie zu bestellen wäre sinnlos.
|
||||
@Test("Umgepackt aber keine Leiter")
|
||||
func remuxHasNoLadder() async throws {
|
||||
let adresse = try await target(remux: true, adaptive: true)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
|
||||
}
|
||||
|
||||
/// Auch eine von Hand gewählte Stufe braucht die Master-Liste — sonst
|
||||
/// verliert man die Untertitel, sobald man die Qualität einstellt.
|
||||
@Test("Eine feste Stufe bekommt sie auch")
|
||||
func fixedRungGetsMaster() async throws {
|
||||
let adresse = try await target(remux: false, adaptive: false)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
|
||||
}
|
||||
|
||||
@Test("Bei automatisch kommt zusätzlich die Leiter")
|
||||
func automaticGetsLadder() async throws {
|
||||
let adresse = try await target(remux: false, adaptive: true)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(werte.contains(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true")))
|
||||
}
|
||||
|
||||
/// Heisst die Adresse schon so, bleibt sie — und wird nicht doppelt
|
||||
/// umgeschrieben.
|
||||
@Test("Was schon Master heisst, bleibt es")
|
||||
func masterStaysMaster() {
|
||||
let schon = URL(string: "http://s/Videos/x/master.m3u8?api_key=t")!
|
||||
#expect(JellyfinBackend.masterPlaylist(schon) == schon)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user