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()) } }