JellyfinKit: anmelden, stöbern, übersetzen
Der zweite Server. Eigenes Target neben PlexKit, nicht darin — die beiden teilen die neutrale Schicht und sonst nichts. Die Anmeldung geht zuerst über **Quick Connect**: die App zeigt einen sechsstelligen Code, den man in einem bereits angemeldeten Client bestätigt. Damit gilt hier dieselbe Zusage wie bei Plex — das Passwort erreicht Flixy nie. Benutzername und Passwort bleiben als Rückfall, falls der Server Quick Connect abgeschaltet hat; dann hält die App das Passwort für die Dauer einer Anfrage und speichert es nirgends. Zwei Stellen, an denen sich eine Übersetzung verrechnet, und die deshalb Tests haben: Jellyfin zählt Zeiten in Ticks zu hundert Nanosekunden. Eine Null zu viel, und aus 44 Minuten werden siebeneinhalb Stunden. Und es meldet die **ungesehenen** Folgen, nicht die gesehenen. Ungeprüft übernommen stünde bei einer fast fertigen Serie „9 von 24" statt „15 von 24". Dass die Adressen den Token tragen, steht hier von Anfang an — bei Plex hat genau das tagelang gekostet, weil AVPlayer keine Kopfzeilen mitschickt. Zwei Fähigkeiten sind ausdrücklich nicht da: Sterne-Bewertungen kennt Jellyfin nicht, nur Daumen hoch und runter. Und die Adresse einer verkleinerten Fassung gibt es erst nach einer Anfrage — sie zu erfinden hieße, eine Adresse anzubieten, die nicht antwortet. Die Startseite ist nachsichtig gebaut: fällt „Als Nächstes" aus, bleibt deshalb nicht die ganze Seite leer. Die App bietet Jellyfin noch nicht an — das ist der nächste Schritt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe2d730c00
commit
9540f26dd8
@@ -0,0 +1,132 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user