Files
Flaro/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift
T
ScarriffleandClaude Opus 5 67c679006f Untertitel kommen aus der Datei, und Fortsetzen geht wieder
Im Spurenblatt stand „CC" und sonst nichts — derselbe Fehler wie beim Ton:
das ist AVFoundations Name für den einen Untertitel, den ein umgerechneter
Stream mitbringt. Zur Auswahl steht jetzt, was in der Datei liegt, und die
Nummer geht als `subtitleStreamIndex` mit in die Anfrage.

`-1` heisst dabei ausdrücklich „keine". Das ist nicht dasselbe wie nichts zu
schicken: dann sucht der Server sich einen aus, und es steht plötzlich ein
Untertitel im Bild, den niemand bestellt hat.

Und der Klick ins Bild: Anhalten ging, Fortsetzen nicht. Der Abdunkler
hinter der Bedienung deckt das ganze Fenster ab und lag über dem Film —
solange die Bedienung zu sehen war, landete jeder Klick auf ihm. Auffallen
konnte das nur an einer Stelle: nach dem Pausieren bleibt die Bedienung
stehen, also traf der Klick, der wieder starten sollte, nie das Bild. Der
Abdunkler ist jetzt unantastbar; er dunkelt ab, mehr nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 19:14:07 +02:00

347 lines
14 KiB
Swift

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)!
)
}
}
@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" })
}
}