Files
Flaro/Packages/FlaroKit/Tests/JellyfinKitTests/PlaybackTests.swift
T
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +02:00

444 lines
18 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: "Flaro", 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: "Flaro", 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: "Flaro", 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: "Flaro", 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)
}
}