Deine Einstellungs-Bilder sind eine lange Liste. Ich habe sie durchgegangen
und getrennt: was etwas steuert, ist gebaut; was eine Einstellung für eine
Sache wäre, die es nicht gibt, ist nicht gebaut. Ein Schalter, der nichts
bewirkt, ist schlimmer als kein Schalter.
**Die Einstellungen sind jetzt eine Übersicht.** Vorher stand alles
untereinander auf einem Bildschirm — Serverangaben, Qualität, Wiedergabe, drei
Verweise, Downloads, Version, Abmelden. Das war beim Bauen bequem und beim
Suchen anstrengend. Jetzt führt jede Zeile in einen Bildschirm, der zu einer
Frage gehört: Server, Erscheinungsbild, Startseite, Wiedergabe, Ton &
Untertitel, Downloads, Diagnose.
**Neu, und jedes davon greift irgendwo:**
- Zurück- und Vorspulen getrennt einstellbar. Die Knöpfe im Player und die
Doppeltipp-Flächen tragen danach auch das passende Symbol.
- Startgeschwindigkeit — die Engine kann es längst, gefragt wurde nie.
- „Im Querformat sperren" abschaltbar. Für die allermeisten Filme richtig, für
den, der im Bett auf der Seite liegt, das Gegenteil.
- „Gewählte Qualität merken": die Stufe gilt dann auch für den nächsten Titel.
Aufbewahrt wird eine **Zahl**, keine Sprosse — welche Sprossen es gibt, hängt
von der Quelle ab, und eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe.
Dann kommt die nächstgelegene, nicht „automatisch".
- „Bitrate im Qualitätsmenü zeigen".
- Gleichzeitige Downloads. Der Wert gab es schon in der Engine, nur ohne
Bedienung und ohne dass er einen Neustart überlebte.
- Aufmacher: wie viele Titel, und ob sie aus Angefangenem oder nur aus
Neuzugängen kommen.
- „Weiterschauen und Als Nächstes zusammenlegen" und „Gesehenes aus
Neuzugängen ausblenden" — beides in der Ansicht, nicht im Server: das sind
Fragen der Darstellung, und so gelten sie für Plex und Jellyfin gleich.
**Verkleinert herunterladen geht jetzt auch bei Jellyfin.** Da hatte ich zu
früh aufgegeben: ich hatte nur nach der Adresse zum *Abspielen* gesucht, und
die ist eine Abspielliste mit hunderten Schnipseln — als Datei unbrauchbar.
`/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als eine Datei heraus. Die
Größe bleibt eine Schätzung; eine Umrechnung, die erst beim Abruf beginnt,
kennt ihre eigene Länge nicht.
**Die Kontozeile ist jetzt eine Karte, die nach oben aufklappt**, kein
Systemmenü mehr. Ein Menü legt sich als eigene Ebene über alles und springt
dorthin, wo Platz ist; die Karte bleibt, wo sie ist, und lässt die Zeile mit
dem Namen stehen — man sieht also weiter, um wessen Konto es geht.
**Nicht gebaut, mit Grund:** Trailer im Karussell, Poster- und Sammlungsstil,
Untertitel-Aussehen, sekundäre Untertitel, lokale Intros, Modular Home,
App-Symbol. Für all das fehlt das, was die Einstellung steuern würde — es gibt
in Flixy weder Trailer noch Sammlungen, und der Player zeichnet Untertitel
nicht selbst.
263 Tests grün. **Nicht angesehen**: der Simulator-Tunnel ist weiterhin
abgestürzt und ließ sich nicht wieder aufbauen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
9.5 KiB
Swift
230 lines
9.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("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)!
|
|
)
|
|
}
|
|
}
|