Flixy heißt ab hier Halo

Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,76 @@
import Testing
import Foundation
import MediaKit
@testable import JellyfinKit
@Suite("Leiter bestellen")
struct AdaptiveURLTests {
private func url(_ text: String) -> URL { URL(string: text)! }
private func werte(_ url: URL, _ name: String) -> [String] {
(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [])
.filter { $0.name.lowercased() == name.lowercased() }
.compactMap(\.value)
}
/// `main.m3u8` ist die Liste der Schnipsel **einer** Stufe. Auf ihr kann
/// der Parameter nichts bewirken — es gibt dort keine Varianten.
@Test("Aus der Schnipselliste wird die Stufenliste")
func mainBecomesMaster() {
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/main.m3u8?Tag=1"))
#expect(ergebnis.lastPathComponent == "master.m3u8")
// Die übrige Abfrage bleibt unangetastet.
#expect(werte(ergebnis, "Tag") == ["1"])
}
@Test("Eine Stufenliste bleibt, wie sie ist")
func masterStays() {
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8"))
#expect(ergebnis.lastPathComponent == "master.m3u8")
}
/// Die Falle, an der `maxHeight` schon einmal hängen geblieben ist: bei
/// zwei gleichnamigen Werten gilt beim Server der erste. Ein
/// danebengestellter zweiter bewirkt nichts.
@Test("Der Parameter steht genau einmal da")
func parameterAppearsOnce() {
let ergebnis = JellyfinBackend.adaptive(
url("http://h/videos/abc/master.m3u8?enableAdaptiveBitrateStreaming=false&Tag=1")
)
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
#expect(werte(ergebnis, "Tag") == ["1"])
}
@Test("Ohne vorhandenen Wert kommt er dazu")
func parameterIsAdded() {
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8?Tag=1"))
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
}
/// Groß- und Kleinschreibung ist beim Server gleichgültig — beim
/// Herausfiltern darf sie es nicht sein, sonst steht er doch zweimal da.
@Test("Auch anders geschrieben wird er ersetzt")
func parameterIsReplacedRegardlessOfCase() {
let ergebnis = JellyfinBackend.adaptive(
url("http://h/videos/abc/master.m3u8?EnableAdaptiveBitrateStreaming=false")
)
let alle = (URLComponents(url: ergebnis, resolvingAgainstBaseURL: false)?.queryItems ?? [])
.filter { $0.name.lowercased() == "enableadaptivebitratestreaming" }
#expect(alle.count == 1)
#expect(alle.first?.value == "true")
}
/// Die Höhe gehört weiterhin dran: die Leiter ist die Abstufung, die
/// Obergrenze bleibt unsere.
@Test("Höhenbegrenzung und Leiter vertragen sich")
func heightAndLadderCoexist() {
let begrenzt = JellyfinBackend.limiting(
height: "1280x720",
in: url("http://h/videos/abc/main.m3u8")
)
let ergebnis = JellyfinBackend.adaptive(begrenzt)
#expect(werte(ergebnis, "maxHeight") == ["720"])
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
#expect(ergebnis.lastPathComponent == "master.m3u8")
}
}
@@ -0,0 +1,67 @@
import Foundation
import Testing
@testable import JellyfinKit
/// Was aus dem wird, was jemand in das Adressfeld tippt.
///
/// Die Reihenfolge ist der Prüfgegenstand, nicht die bloße Menge: probiert
/// die App bei einer IP-Adresse zuerst HTTPS, wartet man auf jedem Server
/// ohne Zertifikat erst einmal auf eine Zeitüberschreitung.
@Suite("Adressen ergänzen")
struct AddressTests {
@Test("Ein getipptes Schema wird nicht angetastet")
func keepsExplicitScheme() {
let candidates = JellyfinAddress.candidates(for: "https://medien.example.com/jellyfin")
#expect(candidates.map(\.absoluteString) == ["https://medien.example.com/jellyfin"])
}
@Test("Nachlaufende Schrägstriche fallen weg")
func trimsTrailingSlash() {
let candidates = JellyfinAddress.candidates(for: " https://example.com/ ")
#expect(candidates.map(\.absoluteString) == ["https://example.com"])
}
@Test("Bei einer IP-Adresse zuerst der Standardport, unverschlüsselt")
func prefersPlainHTTPForAddresses() {
let candidates = JellyfinAddress.candidates(for: "192.168.1.50")
#expect(candidates.map(\.absoluteString) == [
"http://192.168.1.50:8096",
"http://192.168.1.50",
"https://192.168.1.50",
])
}
@Test("Bei einem Domänennamen zuerst HTTPS")
func prefersHTTPSForDomains() {
let candidates = JellyfinAddress.candidates(for: "medien.example.com")
#expect(candidates.first?.absoluteString == "https://medien.example.com")
#expect(candidates.map(\.absoluteString).contains("http://medien.example.com:8096"))
}
@Test("Ein genannter Port wird nicht überschrieben")
func keepsExplicitPort() {
let candidates = JellyfinAddress.candidates(for: "medien.example.com:8920")
#expect(candidates.map(\.absoluteString) == [
"http://medien.example.com:8920",
"https://medien.example.com:8920",
])
}
@Test("Der Standardport landet vor dem Pfad, nicht dahinter")
func insertsPortBeforePath() {
let candidates = JellyfinAddress.candidates(for: "server.local/jellyfin")
#expect(candidates.first?.absoluteString == "http://server.local:8096/jellyfin")
}
@Test("Leere Eingabe ergibt keine Adresse")
func rejectsEmptyInput() {
#expect(JellyfinAddress.candidates(for: " ").isEmpty)
}
}
@@ -0,0 +1,91 @@
{
"Items": [
{
"Name": "Der Anfang",
"Id": "a1b2c3d4e5f6",
"Type": "Episode",
"Overview": "Es f\u00e4ngt an.",
"ProductionYear": 2024,
"RunTimeTicks": 26400000000,
"IndexNumber": 3,
"ParentIndexNumber": 2,
"SeriesName": "Eine Serie",
"SeriesId": "series-1",
"SeasonId": "season-2",
"SeasonName": "Staffel 2",
"OfficialRating": "12",
"CommunityRating": 8.4,
"DateCreated": "2026-01-15T10:30:00.0000000Z",
"Genres": [
"Drama",
"Mystery"
],
"People": [
{
"Id": "p1",
"Name": "Eine Person",
"Role": "Die Hauptrolle",
"Type": "Actor",
"PrimaryImageTag": "t1"
},
{
"Id": "p2",
"Name": "Wer Regie f\u00fchrte",
"Type": "Director"
}
],
"UserData": {
"PlaybackPositionTicks": 6000000000,
"PlayCount": 1,
"Played": false,
"IsFavorite": true
},
"ImageTags": {
"Primary": "tag-primary",
"Logo": "tag-logo"
},
"BackdropImageTags": [
"tag-backdrop"
],
"SeriesPrimaryImageTag": "tag-series",
"MediaSources": [
{
"Id": "source-1",
"Container": "mkv",
"Size": 5368709120,
"Bitrate": 12000000,
"RunTimeTicks": 26400000000,
"SupportsDirectPlay": true,
"SupportsTranscoding": true,
"MediaStreams": [
{
"Index": 0,
"Type": "Video",
"Codec": "hevc",
"Height": 2160,
"Width": 3840
},
{
"Index": 1,
"Type": "Audio",
"Codec": "dts",
"Language": "ger",
"Channels": 6,
"DisplayTitle": "Deutsch (DTS 5.1)",
"IsDefault": true
},
{
"Index": 2,
"Type": "Subtitle",
"Codec": "subrip",
"Language": "eng",
"IsForced": false
}
]
}
]
}
],
"TotalRecordCount": 1,
"StartIndex": 0
}
@@ -0,0 +1,17 @@
{
"Items": [
{
"Name": "Eine Serie",
"Id": "series-1",
"Type": "Series",
"RecursiveItemCount": 24,
"ChildCount": 3,
"UserData": {
"UnplayedItemCount": 9,
"PlayCount": 0,
"IsFavorite": false
}
}
],
"TotalRecordCount": 1
}
@@ -0,0 +1,443 @@
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: "Halo", 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: "Halo", 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: "Halo", 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: "Halo", 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)
}
}
@@ -0,0 +1,223 @@
import Foundation
import Testing
import MediaKit
@testable import JellyfinKit
/// Die Übersetzung der Jellyfin-Antworten in die neutrale Form.
///
/// Gegen aufgezeichnete Antworten, weil genau dort die Fallen liegen: Zeiten
/// in Ticks, großgeschriebene Feldnamen, und Zählwerte, die das Gegenteil von
/// dem meinen, was man erwartet.
@Suite("Übersetzung aus Jellyfin")
struct TranslationTests {
private func item(_ fixture: String) throws -> JellyfinItem {
let url = try #require(Bundle.module.url(forResource: "Fixtures/\(fixture)", withExtension: "json"))
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let page = try decoder.decode(JellyfinItemPage.self, from: try Data(contentsOf: url))
return try #require(page.items.first)
}
private func media(_ fixture: String) throws -> MediaItem {
try item(fixture).asMediaItem(imageBase: JellyfinImagePaths())
}
@Test("Ticks werden zu Millisekunden")
func convertsTicks() throws {
let episode = try media("episode")
// 26.400.000.000 Ticks sind 44 Minuten. Zehntausend Ticks je
// Millisekunde — verrechnet man sich hier um eine Null, zeigt die App
// Laufzeiten von siebeneinhalb Stunden.
#expect(episode.duration == 2_640_000)
#expect(episode.resumeMilliseconds == 600_000)
}
@Test("Der Fortschritt ergibt sich richtig")
func computesProgress() throws {
let episode = try media("episode")
let progress = try #require(episode.watchProgress)
// Zehn Minuten von 44 — knapp 23 Prozent.
#expect(abs(progress - 10.0 / 44.0) < 0.001)
}
@Test("Serie, Staffel und Nummern kommen an")
func translatesHierarchy() throws {
let episode = try media("episode")
#expect(episode.kind == .episode)
#expect(episode.title == "Der Anfang")
#expect(episode.seriesTitle == "Eine Serie")
#expect(episode.seriesID == "series-1")
#expect(episode.parentTitle == "Staffel 2")
#expect(episode.index == 3)
#expect(episode.parentIndex == 2)
}
@Test("Favoriten gibt es hier wirklich")
func readsFavourite() throws {
// Das Feld, das bei Plex immer false ist.
#expect(try media("episode").isFavourite)
}
@Test("Aus ungesehenen Folgen werden gesehene")
func invertsUnplayedCount() throws {
let series = try media("series")
// Jellyfin meldet **ungesehene** Folgen. Die Zahl, die die Oberfläche
// zeigt, ist die andere: 24 minus 9 sind 15 gesehene. Ohne diese
// Umkehrung stünde bei einer fast fertigen Serie „9 von 24".
#expect(series.episodeCount == 24)
#expect(series.playedEpisodeCount == 15)
}
@Test("Bitrate kommt in Kilobit an, nicht in Bit")
func convertsBitrate() throws {
let version = try #require(try media("episode").versions.first)
// Jellyfin rechnet in Bit je Sekunde. Ungeprüft übernommen stünde in
// der App „12000000 Mbit/s".
#expect(version.bitrate == 12_000)
#expect(version.qualityLabel.contains("12 Mbit/s"))
}
@Test("Die Fassung trägt Codecs und Spuren")
func translatesVersion() throws {
let version = try #require(try media("episode").versions.first)
let file = try #require(version.files.first)
#expect(version.container == "mkv")
#expect(version.videoCodec == "hevc")
#expect(version.pixelHeight == 2_160)
#expect(version.resolutionLabel == "4K")
// Die gewählte Tonspur ist die als Standard markierte.
#expect(file.selectedAudioStream?.codec == "dts")
#expect(file.subtitleStreams.count == 1)
#expect(file.size == 5_368_709_120)
}
@Test("Das Wiedergabe-Profil beschreibt, was iOS nicht kann")
func buildsProfile() throws {
let version = try #require(try media("episode").versions.first)
let profile = version.profile(file: version.files.first)
// MKV mit DTS: genau der Fall, für den es den zweiten Player gibt.
#expect(profile.container == "mkv")
#expect(profile.audioCodec == "dts")
#expect(profile.hasKnownStreams)
}
@Test("Besetzung und Regie werden getrennt")
func separatesPeople() throws {
let episode = try media("episode")
#expect(episode.cast.count == 1)
#expect(episode.cast.first?.role == "Die Hauptrolle")
#expect(episode.directors == ["Wer Regie führte"])
#expect(episode.genres == ["Drama", "Mystery"])
}
@Test("Bildadressen tragen ihre Marke")
func buildsImagePaths() throws {
let episode = try media("episode")
// Ohne die Marke liefert der Server zwar ein Bild, aber der
// Zwischenspeicher merkt nicht, wenn es sich ändert.
#expect(episode.thumb == "/Items/a1b2c3d4e5f6/Images/Primary?tag=tag-primary")
#expect(episode.clearLogoPath == "/Items/a1b2c3d4e5f6/Images/Logo?tag=tag-logo")
#expect(episode.seriesThumb == "/Items/series-1/Images/Primary?tag=tag-series")
#expect(episode.art == "/Items/a1b2c3d4e5f6/Images/Backdrop?tag=tag-backdrop")
}
@Test("Ohne eigenes Hintergrundbild kommt es von der Serie")
func borrowsBackdropFromParent() throws {
let item = try decode("""
{"Id": "ohne-bild", "Name": "Folge ohne Bild", "Type": "Episode",
"ParentBackdropItemId": "series-1"}
""")
// Die Bildadressen entstehen aus der Kennung, nicht aus einer Antwort
// des Servers. Ohne diese Unterscheidung stünde hinter jeder Folge
// eine Adresse, die mit „nicht gefunden" antwortet — und das Feld, das
// Jellyfin dafür mitschickt, wäre totes Gewicht.
#expect(item.art == "/Items/series-1/Images/Backdrop")
}
@Test("Gibt es weder eigenes noch geliehenes Bild, bleibt das Feld leer")
func leavesBackdropEmpty() throws {
let item = try decode("""
{"Id": "ganz-ohne", "Name": "Nichts da", "Type": "Movie"}
""")
#expect(item.art == nil)
}
@Test("Die Neuzugänge kommen als nacktes Feld, nicht als Objekt")
func decodesBareArray() throws {
// `/Users/{id}/Items/Latest` antwortet anders als jede andere Liste:
// ohne `Items`-Hülle und ohne Gesamtzahl. Wer das übersieht, bekommt
// einen Entschlüsselungsfehler statt einer Reihe — und die Startseite
// bliebe leer, ohne dass klar wäre, warum.
let json = """
[{"Id": "neu-1", "Name": "Frisch dabei", "Type": "Movie"},
{"Id": "neu-2", "Name": "Auch neu", "Type": "Series"}]
"""
let items = try JSONDecoder().decode([JellyfinItem].self, from: Data(json.utf8))
#expect(items.map(\.id) == ["neu-1", "neu-2"])
#expect(items.map { $0.asMediaItem(imageBase: JellyfinImagePaths()).kind } == [.movie, .show])
}
/// Ein einzelner Eintrag, wie er über die Leitung käme.
///
/// Für die Fälle, die in den aufgezeichneten Antworten nicht vorkommen —
/// eine Folge ohne eigenes Bild etwa. Eine weitere Datei dafür anzulegen
/// verstellt den Blick darauf, worum es im Test geht.
private func decode(_ json: String) throws -> MediaItem {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let item = try decoder.decode(JellyfinItem.self, from: Data(json.utf8))
return item.asMediaItem(imageBase: JellyfinImagePaths())
}
}
@Suite("Die Art einer Mediathek")
struct LibraryKindTests {
@Test(
"Die bekannten Arten",
arguments: [
("movies", MediaLibrary.Kind.movie),
("tvshows", .show),
("music", .music),
("photos", .photo),
("livetv", .liveTV),
]
)
func mapsKnownTypes(type: String, expected: MediaLibrary.Kind) {
#expect(MediaLibrary.Kind(jellyfinCollectionType: type) == expected)
}
/// **Der Fall, der eine ganze Mediathek verschwinden liess.** Jellyfin
/// nennt „Heimvideos & Fotos" `homevideos`; weil Fotos im Namen stehen,
/// lag es bei den Fotos — und fiel damit unter `isSupported` heraus. Die
/// Mediathek war in der App nicht vorhanden, nicht einmal in der Auswahl
/// für den Aufmacher.
@Test("Heimvideos sind Videos, keine Fotos")
func homeVideosAreWatchable() {
let art = MediaLibrary.Kind(jellyfinCollectionType: "homevideos")
#expect(art == .other)
#expect(MediaLibrary(id: "1", title: "Videos", kind: art).isSupported)
}
/// Eine gemischte Mediathek meldet gar keine Art. Sie deshalb
/// wegzulassen hiesse, den häufigsten selbstgebauten Fall zu übersehen.
@Test("Ohne Angabe bleibt sie trotzdem da")
func unknownStays() {
let art = MediaLibrary.Kind(jellyfinCollectionType: nil)
#expect(art == .other)
#expect(MediaLibrary(id: "1", title: "Gemischtes", kind: art).isSupported)
}
}