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") )) #expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/main.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)! ) } }