import Foundation import Testing import MediaKit @testable import JellyfinKit /// Was die App dem Server zum Abspielen schickt — und was sie daraus macht. /// /// Adressen und Anfragekörper entstehen ohne Netz und lassen sich deshalb /// genau prüfen. Genau hier lagen bei Plex die Fehler, die am teuersten waren: /// ein Wert, der nicht kodiert wurde, ein Feld, das der Server anders nennt. @Suite("Wiedergabe bei Jellyfin") struct PlaybackTests { private func backend(_ transport: RecordingTransport) -> JellyfinBackend { let 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 ) return JellyfinBackend( client: client, account: JellyfinAccount( userID: "u1", userName: "Test", token: "tok", isAdministrator: false ), serverName: "Attrappe" ) } private var file: MediaFile { MediaFile(id: "src-1", key: "src-1", container: "mp4") } @Test("Unverändert: die Datei selbst, mit Token in der Adresse") func buildsDirectPlayURL() async throws { let target = try await backend(RecordingTransport()) .preparePlayback(PlaybackIntent(itemID: "film-1", versionIndex: 0, file: file)) let url = try #require(URLComponents(url: target.url, resolvingAgainstBaseURL: false)) #expect(url.path == "/Videos/film-1/stream") // Der Systemplayer schickt keine Kopfzeilen mit. Steht der Token nicht // in der Adresse, antwortet der Server mit „nicht angemeldet". #expect(url.queryItems?.contains(URLQueryItem(name: "ApiKey", value: "tok")) == true) #expect(url.queryItems?.contains(URLQueryItem(name: "static", value: "true")) == true) #expect(url.queryItems?.contains(URLQueryItem(name: "mediaSourceId", value: "src-1")) == true) // Auch ohne Umrechnung eine Sitzung: sonst ordnet Jellyfin den // Fortschritt keiner Wiedergabe zu. #expect(target.sessionID != nil) #expect(target.versionID == "src-1") } @Test("Umgerechnet: die Adresse kommt vom Server") func usesServerTranscodingURL() async throws { let transport = RecordingTransport(response: """ {"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}], "PlaySessionId": "ps-7"} """) let target = try await backend(transport).preparePlayback(PlaybackIntent( itemID: "film-1", versionIndex: 0, file: file, transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720") )) // **`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 // wenig, und aus vier Megabit werden vier Kilobit. let asked = try #require(transport.lastRequest) let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems) #expect(query.contains(URLQueryItem(name: "maxStreamingBitrate", value: "4000000"))) #expect(query.contains(URLQueryItem(name: "enableDirectPlay", value: "false"))) #expect(asked.httpMethod == "POST") } @Test("Lehnt der Server ab, steht der Grund im Fehler") func refusesWithoutTranscodingURL() async throws { let transport = RecordingTransport(response: """ {"MediaSources": [{"Id": "src-1"}], "PlaySessionId": "ps-7"} """) await #expect(throws: MediaBackendError.self) { try await backend(transport).preparePlayback(PlaybackIntent( itemID: "film-1", versionIndex: 0, file: file, transcode: TranscodeSettings(maximumBitrate: nil, resolution: nil) )) } } @Test("Die Bildhöhe ersetzt eine vorhandene Angabe, statt daneben zu stehen") func replacesExistingHeight() { let url = URL(string: "http://s/main.m3u8?maxHeight=1080&videoCodec=h264")! let limited = JellyfinBackend.limiting(height: "1280x720", in: url) let query = URLComponents(url: limited, resolvingAgainstBaseURL: false)?.queryItems ?? [] // Bei zwei gleichnamigen Werten gilt beim Server der erste — und das // wäre dann gerade nicht der gewählte. #expect(query.filter { $0.name.lowercased() == "maxheight" }.count == 1) #expect(query.contains(URLQueryItem(name: "maxHeight", value: "720"))) #expect(query.contains(URLQueryItem(name: "videoCodec", value: "h264"))) } @Test("Ohne gewählte Auflösung bleibt die Adresse, wie sie ist") func leavesURLAloneWithoutResolution() { let url = URL(string: "http://s/main.m3u8?videoCodec=h264")! #expect(JellyfinBackend.limiting(height: nil, in: url) == url) } @Test("Der Fortschritt geht in Ticks und mit allen Kennungen hinaus") func reportsProgressInTicks() async throws { let transport = RecordingTransport() await backend(transport).reportProgress(PlaybackReport( itemID: "film-1", sessionID: "ps-7", versionID: "src-1", state: .playing, time: .seconds(90), duration: .seconds(600) )) let asked = try #require(transport.lastRequest) #expect(asked.url?.path == "/Sessions/Playing/Progress") let body = try #require(asked.httpBody) let fields = try #require( try JSONSerialization.jsonObject(with: body) as? [String: Any] ) // 90 Sekunden sind 900.000.000 Ticks. Zehntausend je Millisekunde. #expect(fields["PositionTicks"] as? Int == 900_000_000) #expect(fields["PlaySessionId"] as? String == "ps-7") #expect(fields["MediaSourceId"] as? String == "src-1") #expect(fields["IsPaused"] as? Bool == false) } @Test("Verkleinert herunterladen ergibt eine einzelne Datei") func buildsCompressedDownloadURL() throws { let url = try #require(backend(RecordingTransport()).compressedDownloadURL( itemID: "film-1", versionIndex: 0, maximumBitrate: 4_000, resolution: "1280x720" )) // `.mp4`, nicht `.m3u8`: eine Abspielliste mit hunderten Schnipseln // lässt sich nicht als Datei ablegen. #expect(url.path == "/Videos/film-1/stream.mp4") let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) // In Bit, nicht in Kilobit — eine Null zu wenig, und aus vier Megabit // werden vier Kilobit. #expect(query.contains(URLQueryItem(name: "videoBitRate", value: "4000000"))) #expect(query.contains(URLQueryItem(name: "maxHeight", value: "720"))) #expect(query.contains(URLQueryItem(name: "ApiKey", value: "tok"))) } @Test("Der erste Bericht legt die Sitzung an") func reportsStartSeparately() async throws { let transport = RecordingTransport() await backend(transport).reportProgress(PlaybackReport( itemID: "film-1", state: .started, time: .zero, duration: .seconds(600) )) // Ohne diese Meldung erscheint der Titel nirgends als „läuft gerade", // und der Server kennt die Sitzung nicht, unter der alles Weitere // gemeldet wird. #expect(transport.lastRequest?.url?.path == "/Sessions/Playing") } @Test("Der letzte Bericht geht an einen eigenen Endpunkt") func reportsStopSeparately() async throws { let transport = RecordingTransport() await backend(transport).reportProgress(PlaybackReport( itemID: "film-1", state: .stopped, time: .seconds(5), duration: .seconds(600) )) // „Gestoppt" als Fortschritt zu melden nähme den Titel nicht aus // „wird gerade abgespielt" heraus. #expect(transport.lastRequest?.url?.path == "/Sessions/Playing/Stopped") } @Test("Abräumen nennt Gerät und Sitzung") func endsEncodingWithDeviceAndSession() async throws { let transport = RecordingTransport() await backend(transport).endPlayback(sessionID: "ps-7") let asked = try #require(transport.lastRequest) #expect(asked.httpMethod == "DELETE") #expect(asked.url?.path == "/Videos/ActiveEncodings") let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems) // Über die Sitzung allein hält Jellyfin nichts an. #expect(query.contains(URLQueryItem(name: "deviceId", value: "gerät-1"))) #expect(query.contains(URLQueryItem(name: "playSessionId", value: "ps-7"))) } } /// Ein Transport, der nichts verschickt und alles behält. private final class RecordingTransport: JellyfinTransport, @unchecked Sendable { private let response: String private let lock = NSLock() private var recorded: URLRequest? init(response: String = "{}") { self.response = response } var lastRequest: URLRequest? { lock.withLock { recorded } } func data(for request: URLRequest) async throws -> (Data, URLResponse) { lock.withLock { recorded = request } return ( Data(response.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) } } @Suite("Tonspur ausdrücklich bestellen") struct AudioStreamIndexTests { private func backend(_ transport: RecordingTransport) -> JellyfinBackend { let 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 ) return JellyfinBackend( client: client, account: JellyfinAccount( userID: "u1", userName: "Test", token: "tok", isAdministrator: false ), serverName: "Attrappe" ) } private func intent(audio: Int?) -> PlaybackIntent { PlaybackIntent( itemID: "abc", versionIndex: 0, file: MediaFile(id: "src", key: "src", itemID: "abc"), transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil), audioStreamIndex: audio ) } private func query(_ transport: RecordingTransport) throws -> [URLQueryItem] { let asked = try #require(transport.lastRequest) return URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] } /// Ohne diese Angabe wählt der Server — und bei einem umgerechneten /// Stream kommt danach nur noch **eine** Tonspur an. Dann ist im Player /// nichts mehr umzustellen: die Wahl muss in der Aushandlung stattfinden /// oder gar nicht. @Test("Die Spurnummer steht in der Anfrage") func indexIsSent() async throws { let transport = RecordingTransport() _ = try? await backend(transport).preparePlayback(intent(audio: 3)) let werte = try query(transport) #expect(werte.contains(URLQueryItem(name: "audioStreamIndex", value: "3"))) } @Test("Ohne Wunsch steht sie nicht da") func indexIsOmitted() async throws { let transport = RecordingTransport() _ = try? await backend(transport).preparePlayback(intent(audio: nil)) let werte = try query(transport) #expect(!werte.contains { $0.name == "audioStreamIndex" }) } } @Suite("Untertitel ausdrücklich bestellen") struct SubtitleStreamIndexTests { 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 intent(subtitle: Int?) -> PlaybackIntent { PlaybackIntent( itemID: "abc", versionIndex: 0, file: MediaFile(id: "src", key: "src", itemID: "abc"), transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil), subtitleStreamIndex: subtitle ) } private func query(_ transport: RecordingTransport) throws -> [URLQueryItem] { let asked = try #require(transport.lastRequest) return URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] } @Test("Die Nummer steht in der Anfrage") func indexIsSent() async throws { let transport = RecordingTransport() _ = try? await backend(transport).preparePlayback(intent(subtitle: 4)) #expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "4"))) } /// **Der Unterschied, der zählt.** Ohne Angabe sucht der Server selbst /// einen aus; „keine" muss man ihm ausdrücklich sagen, sonst steht /// plötzlich ein Untertitel im Bild, den niemand bestellt hat. @Test("Minus eins heisst ausdrücklich keine") func minusOneMeansNone() async throws { let transport = RecordingTransport() _ = try? await backend(transport).preparePlayback(intent(subtitle: -1)) #expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "-1"))) } @Test("Ohne Angabe steht sie nicht da") func indexIsOmitted() async throws { let transport = RecordingTransport() _ = try? await backend(transport).preparePlayback(intent(subtitle: nil)) #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) } }