Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar kannte. Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten, sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder angesprochen und konnte deshalb mit nichts anderem reden. `MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil es dafür keinen Fall gab. Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst (`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen muss. Drei Dinge, die beim Hinsehen auffielen: - **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig Minuten Fortschritt landet man bei zweiundvierzig. - **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus, weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem Öffnen fest. Jetzt tut es für die erste Meldung die des Servers. - **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf; bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört. Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht. Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag. Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst ein richtiger Server. 257 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.5 KiB
Swift
209 lines
8.5 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("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)!
|
|
)
|
|
}
|
|
}
|