From 9540f26dd8b650d532fca71f512e96686ff1b4e4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 12:59:02 +0200 Subject: [PATCH] =?UTF-8?q?JellyfinKit:=20anmelden,=20st=C3=B6bern,=20?= =?UTF-8?q?=C3=BCbersetzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Packages/FlixyKit/Package.swift | 11 + .../JellyfinKit/JellyfinAuthenticator.swift | 189 +++++++++ .../Sources/JellyfinKit/JellyfinBackend.swift | 360 ++++++++++++++++++ .../Sources/JellyfinKit/JellyfinClient.swift | 192 ++++++++++ .../JellyfinKit/JellyfinItem+Media.swift | 176 +++++++++ .../Sources/JellyfinKit/JellyfinItem.swift | 152 ++++++++ .../Sources/JellyfinKit/Localizable.xcstrings | 102 +++++ .../JellyfinKitTests/Fixtures/episode.json | 91 +++++ .../JellyfinKitTests/Fixtures/series.json | 17 + .../JellyfinKitTests/TranslationTests.swift | 132 +++++++ 10 files changed, 1422 insertions(+) create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinAuthenticator.swift create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings create mode 100644 Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/episode.json create mode 100644 Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/series.json create mode 100644 Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift index 824e876..c86b444 100644 --- a/Packages/FlixyKit/Package.swift +++ b/Packages/FlixyKit/Package.swift @@ -13,6 +13,7 @@ let package = Package( products: [ .library(name: "MediaKit", targets: ["MediaKit"]), .library(name: "PlexKit", targets: ["PlexKit"]), + .library(name: "JellyfinKit", targets: ["JellyfinKit"]), .library(name: "FlixyDesign", targets: ["FlixyDesign"]), .library(name: "FlixyPlayback", targets: ["FlixyPlayback"]), .library(name: "FlixyDownloads", targets: ["FlixyDownloads"]), @@ -29,6 +30,16 @@ let package = Package( .target(name: "PlexKit", dependencies: ["MediaKit"]), + // Jellyfin. Eigenes Target neben PlexKit, nicht darin: die beiden + // teilen die neutrale Schicht und sonst nichts. Wer später Plex + // herausnimmt, löscht ein Verzeichnis. + .target(name: "JellyfinKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]), + .testTarget( + name: "JellyfinKitTests", + dependencies: ["JellyfinKit"], + resources: [.copy("Fixtures")] + ), + // Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche, // ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt // eines halben Tages, und das Upload-Kontingent bleibt für Builds. diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinAuthenticator.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinAuthenticator.swift new file mode 100644 index 0000000..0f98da0 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinAuthenticator.swift @@ -0,0 +1,189 @@ +import Foundation +import MediaKit + +/// Wer angemeldet ist. +public struct JellyfinAccount: Sendable, Equatable { + public let userID: String + public let userName: String + public let token: String + /// Ob dieses Konto den Server verwalten darf. + /// + /// Entscheidet, ob der Verwaltungsbereich überhaupt erscheint. Ihn zu + /// zeigen und bei jedem Tipp „keine Berechtigung" zu melden, wäre die + /// schlechtere Antwort. + public let isAdministrator: Bool + + public init(userID: String, userName: String, token: String, isAdministrator: Bool) { + self.userID = userID + self.userName = userName + self.token = token + self.isAdministrator = isAdministrator + } +} + +/// Was ein Server über sich verrät, bevor man angemeldet ist. +public struct JellyfinServerInfo: Sendable, Equatable, Decodable { + public let serverName: String + public let version: String? + public let id: String? + /// Ob die Einrichtung abgeschlossen ist. + public let startupWizardCompleted: Bool? + + private enum CodingKeys: String, CodingKey { + case serverName = "ServerName" + case version = "Version" + case id = "Id" + case startupWizardCompleted = "StartupWizardCompleted" + } +} + +/// Der Code, mit dem sich die App ohne Passwort anmeldet. +public struct QuickConnectRequest: Sendable, Equatable, Decodable { + public let secret: String + public let code: String + + private enum CodingKeys: String, CodingKey { + case secret = "Secret" + case code = "Code" + } +} + +/// Meldet an einem Jellyfin-Server an. +/// +/// Zwei Wege, und die Reihenfolge ist Absicht. +/// +/// **Quick Connect** ist der erste: die App zeigt einen sechsstelligen Code, +/// den man in einem bereits angemeldeten Client bestätigt. Das Passwort +/// erreicht Flixy dabei nie — dieselbe Zusage, die bei Plex gilt, wo die +/// Anmeldemaske von Plex selbst kommt. +/// +/// **Benutzername und Passwort** sind der Rückfall, falls der Server Quick +/// Connect abgeschaltet hat. Dann hält die App das Passwort für die Dauer +/// einer Anfrage — sie speichert es nicht, protokolliert es nicht und legt es +/// nirgends ab. Gespeichert wird nur der Token, und der im Schlüsselbund. +public actor JellyfinAuthenticator { + private let client: JellyfinClient + + public init(client: JellyfinClient) { + self.client = client + } + + /// Fragt den Server, ob er überhaupt einer ist. + public func serverInfo() async throws(MediaBackendError) -> JellyfinServerInfo { + try await client.send( + JellyfinRequest(path: "/System/Info/Public", timeout: .seconds(10)), + as: JellyfinServerInfo.self + ) + } + + /// Ob der Server die Anmeldung per Code anbietet. + public func supportsQuickConnect() async -> Bool { + // Schweigt er oder antwortet mit einem Fehler, gilt es als + // abgeschaltet. Ein Code, den niemand bestätigen kann, wäre eine + // Sackgasse mit Wartezeit. + guard let data = try? await client.send(JellyfinRequest(path: "/QuickConnect/Enabled")) else { + return false + } + let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + return text.lowercased() == "true" + } + + public func beginQuickConnect() async throws(MediaBackendError) -> QuickConnectRequest { + try await client.send( + JellyfinRequest(method: .post, path: "/QuickConnect/Initiate"), + as: QuickConnectRequest.self + ) + } + + /// Sieht nach, ob der Code inzwischen bestätigt wurde. + /// + /// Gibt `nil` zurück, solange er es nicht ist — der Aufrufer führt seine + /// eigene Schleife, weil er nebenher auf Abbruch achten muss. + public func completeQuickConnect( + secret: String + ) async throws(MediaBackendError) -> JellyfinAccount? { + let state = try await client.send( + JellyfinRequest( + path: "/QuickConnect/Connect", + query: [URLQueryItem(name: "secret", value: secret)] + ), + as: QuickConnectState.self + ) + guard state.authenticated else { return nil } + + let body = try encode(["Secret": secret]) + return try await authenticate( + JellyfinRequest(method: .post, path: "/Users/AuthenticateWithQuickConnect", body: body) + ) + } + + /// Der Rückfall, wenn Quick Connect abgeschaltet ist. + public func signIn( + userName: String, + password: String + ) async throws(MediaBackendError) -> JellyfinAccount { + let body = try encode(["Username": userName, "Pw": password]) + return try await authenticate( + JellyfinRequest(method: .post, path: "/Users/AuthenticateByName", body: body) + ) + } + + private func authenticate( + _ request: JellyfinRequest + ) async throws(MediaBackendError) -> JellyfinAccount { + let result = try await client.send(request, as: AuthenticationResult.self) + await client.setToken(result.accessToken) + return JellyfinAccount( + userID: result.user.id, + userName: result.user.name, + token: result.accessToken, + isAdministrator: result.user.policy?.isAdministrator ?? false + ) + } + + private func encode(_ fields: [String: String]) throws(MediaBackendError) -> Data { + do { + return try JSONSerialization.data(withJSONObject: fields) + } catch { + throw .decoding(description: String(describing: error)) + } + } +} + +private struct QuickConnectState: Decodable, Sendable { + let authenticated: Bool + + private enum CodingKeys: String, CodingKey { + case authenticated = "Authenticated" + } +} + +private struct AuthenticationResult: Decodable, Sendable { + let accessToken: String + let user: User + + struct User: Decodable, Sendable { + let id: String + let name: String + let policy: Policy? + + private enum CodingKeys: String, CodingKey { + case id = "Id" + case name = "Name" + case policy = "Policy" + } + } + + struct Policy: Decodable, Sendable { + let isAdministrator: Bool? + + private enum CodingKeys: String, CodingKey { + case isAdministrator = "IsAdministrator" + } + } + + private enum CodingKeys: String, CodingKey { + case accessToken = "AccessToken" + case user = "User" + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift new file mode 100644 index 0000000..57c1ab3 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -0,0 +1,360 @@ +import Foundation +import MediaKit + +/// Jellyfin als `MediaBackend`. +public struct JellyfinBackend: MediaBackend { + private let client: JellyfinClient + private let account: JellyfinAccount + private let images = JellyfinImagePaths() + public let serverName: String + + public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) { + self.client = client + self.account = account + self.serverName = serverName + } + + public var isAdministrator: Bool { account.isAdministrator } + + public nonisolated var capabilities: Set { + var capabilities: Set = [.favourites, .liveTV, .libraryScan, .transcoding] + // Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen + // und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort. + if account.isAdministrator { capabilities.insert(.administration) } + return capabilities + } + + // MARK: - Blättern + + public func libraries() async throws(MediaBackendError) -> [MediaLibrary] { + let page = try await client.send( + JellyfinRequest(path: "/Users/\(account.userID)/Views"), + as: JellyfinItemPage.self + ) + return page.items.map { view in + let kind: MediaLibrary.Kind = switch view.collectionType { + case "movies": .movie + case "tvshows": .show + case "music": .music + case "homevideos", "photos": .photo + case "livetv": .liveTV + default: .other + } + return MediaLibrary( + id: view.id, + title: view.name ?? "—", + kind: kind, + thumb: images.primary(itemID: view.id, tag: nil) + ) + } + } + + public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] { + // Jellyfin hat keine fertigen Reihen wie Plex' Hubs — die Startseite + // wird aus einzelnen Abfragen zusammengesetzt. Das ist kein Mangel: + // so steht hier, was die Reihen bedeuten, statt dass es der Server + // entscheidet. + // „Zuletzt hinzugefügt" wird streng geholt: scheitert es, ist der + // Server nicht erreichbar, und dann gehört eine Fehlermeldung hin. + let latest = try await rowItems(path: "/Users/\(account.userID)/Items", query: [ + URLQueryItem(name: "SortBy", value: "DateCreated"), + URLQueryItem(name: "SortOrder", value: "Descending"), + URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"), + URLQueryItem(name: "Recursive", value: "true"), + URLQueryItem(name: "Limit", value: "20"), + URLQueryItem(name: "Fields", value: Self.fields), + ]) + + // Die beiden anderen nachsichtig: fällt „Als Nächstes" aus, soll + // deshalb nicht die ganze Startseite leer bleiben. + let resuming = try? await rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [ + URLQueryItem(name: "Limit", value: "20"), + URLQueryItem(name: "MediaTypes", value: "Video"), + URLQueryItem(name: "Fields", value: Self.fields), + ]) + let nextUp = try? await rowItems(path: "/Shows/NextUp", query: [ + URLQueryItem(name: "userId", value: account.userID), + URLQueryItem(name: "Limit", value: "20"), + URLQueryItem(name: "Fields", value: Self.fields), + ]) + + let rows = [ + MediaRowGroup( + id: "resume", + title: String(localized: "row.continueWatching", bundle: .module), + items: resuming ?? [] + ), + MediaRowGroup( + id: "nextup", + title: String(localized: "row.nextUp", bundle: .module), + items: nextUp ?? [] + ), + MediaRowGroup( + id: "latest", + title: String(localized: "row.recentlyAdded", bundle: .module), + items: latest + ), + ] + // Leere Reihen wären auf der Startseite nur Überschriften ohne Inhalt. + return rows.filter { !$0.items.isEmpty } + } + + public func items( + inLibrary library: MediaLibrary, + query: MediaQuery, + offset: Int, + limit: Int + ) async throws(MediaBackendError) -> MediaPage { + var items: [URLQueryItem] = [ + URLQueryItem(name: "ParentId", value: library.id), + URLQueryItem(name: "Recursive", value: "true"), + URLQueryItem(name: "StartIndex", value: String(offset)), + URLQueryItem(name: "Limit", value: String(limit)), + URLQueryItem(name: "Fields", value: Self.fields), + URLQueryItem(name: "SortBy", value: query.sort.jellyfinField), + URLQueryItem(name: "SortOrder", value: query.sort.isDescending ? "Descending" : "Ascending"), + ] + + // Ohne diese Angabe liefert eine Serienmediathek alle Folgen einzeln + // statt der Serien — dieselbe Falle wie bei Plex. + switch library.kind { + case .movie: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie")) + case .show: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Series")) + default: break + } + + if query.unwatchedOnly { items.append(URLQueryItem(name: "Filters", value: "IsUnplayed")) } + if query.favouritesOnly { items.append(URLQueryItem(name: "Filters", value: "IsFavorite")) } + if let genre = query.genre { items.append(URLQueryItem(name: "Genres", value: genre)) } + if let year = query.year { items.append(URLQueryItem(name: "Years", value: String(year))) } + if let rating = query.contentRating { + items.append(URLQueryItem(name: "OfficialRatings", value: rating)) + } + if let text = query.searchText { items.append(URLQueryItem(name: "searchTerm", value: text)) } + // Auflösung filtert Jellyfin nicht selbst; „4K" wird unten aussortiert. + + let page = try await client.send( + JellyfinRequest(path: "/Users/\(account.userID)/Items", query: items), + as: JellyfinItemPage.self + ) + + var mapped = page.items.map { $0.asMediaItem(imageBase: images) } + var total = page.totalRecordCount ?? mapped.count + + if query.resolution?.lowercased() == "4k" { + mapped = mapped.filter { ($0.preferredVersion?.pixelHeight ?? 0) >= 1_600 } + // Die Gesamtzahl stimmt dann nicht mehr — lieber die gefilterte + // Menge als eine Zahl, die zum Nachladen auffordert, obwohl nichts + // mehr kommt. + total = mapped.count + offset + } + + return MediaPage(items: mapped, offset: offset, totalCount: total) + } + + public func item(id: String) async throws(MediaBackendError) -> MediaItem { + let item = try await client.send( + JellyfinRequest( + path: "/Users/\(account.userID)/Items/\(id)", + query: [URLQueryItem(name: "Fields", value: Self.fields)] + ), + as: JellyfinItem.self + ) + return item.asMediaItem(imageBase: images) + } + + public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] { + try await rowItems(path: "/Users/\(account.userID)/Items", query: [ + URLQueryItem(name: "ParentId", value: id), + URLQueryItem(name: "SortBy", value: "SortName"), + URLQueryItem(name: "Fields", value: Self.fields), + ]) + } + + public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] { + let page = try await client.send( + JellyfinRequest(path: "/Users/\(account.userID)/Items", query: [ + URLQueryItem(name: "searchTerm", value: text), + URLQueryItem(name: "Recursive", value: "true"), + URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series,Episode"), + URLQueryItem(name: "Limit", value: String(limit)), + URLQueryItem(name: "Fields", value: Self.fields), + ]), + as: JellyfinItemPage.self + ) + + // Nach Art gruppiert, weil Plex' Suche gruppiert zurückkommt und die + // Ergebnisansicht darauf ausgelegt ist. + let items = page.items.map { $0.asMediaItem(imageBase: images) } + let groups: [(MediaItem.Kind, String)] = [ + (.movie, "search.movies"), (.show, "search.shows"), (.episode, "search.episodes"), + ] + return groups.compactMap { kind, key in + let matching = items.filter { $0.kind == kind } + guard !matching.isEmpty else { return nil } + return MediaRowGroup( + id: kind.rawValue, + title: String(localized: String.LocalizationValue(key), bundle: .module), + items: matching + ) + } + } + + public func filterOptions( + inLibrary library: MediaLibrary, + field: MediaQuery.Field + ) async throws(MediaBackendError) -> [MediaFilterOption] { + guard field == .genre else { return [] } + let page = try await client.send( + JellyfinRequest(path: "/Genres", query: [ + URLQueryItem(name: "ParentId", value: library.id), + URLQueryItem(name: "userId", value: account.userID), + ]), + as: JellyfinItemPage.self + ) + return page.items.map { MediaFilterOption(id: $0.name ?? $0.id, title: $0.name ?? "—") } + } + + public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? { + guard let seriesID = item.seriesID else { return nil } + let next = try await rowItems(path: "/Shows/NextUp", query: [ + URLQueryItem(name: "userId", value: account.userID), + URLQueryItem(name: "SeriesId", value: seriesID), + URLQueryItem(name: "Limit", value: "1"), + URLQueryItem(name: "Fields", value: Self.fields), + ]) + // Die laufende Folge selbst ist nicht die nächste. + return next.first { $0.id != item.id } + } + + // MARK: - Bilder + + public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? { + guard let path else { return nil } + return imageURL(path: path, extra: [ + URLQueryItem(name: "fillWidth", value: String(width)), + URLQueryItem(name: "fillHeight", value: String(height)), + URLQueryItem(name: "quality", value: "90"), + ]) + } + + public nonisolated func rawImageURL(path: String?) -> URL? { + guard let path else { return nil } + return imageURL(path: path, extra: []) + } + + /// Setzt Pfad, mitgelieferte Marke und Größenangaben zusammen. + private nonisolated func imageURL(path: String, extra: [URLQueryItem]) -> URL? { + let pieces = path.split(separator: "?", maxSplits: 1) + var query = extra + if pieces.count > 1 { + query += URLComponents(string: "?\(pieces[1])")?.queryItems ?? [] + } + // Bilder holt kein angemeldeter Client, sondern eine Bildansicht — sie + // schickt keine Kopfzeilen mit, also muss der Schlüssel in die Adresse. + return client.url(path: String(pieces[0]), query: query, includingToken: account.token) + } + + // MARK: - Herunterladen + + public nonisolated func downloadURL(for file: MediaFile) -> URL? { + guard let sourceID = file.key else { return nil } + return client.url( + path: "/Videos/\(sourceID)/stream", + query: [ + URLQueryItem(name: "static", value: "true"), + URLQueryItem(name: "mediaSourceId", value: sourceID), + ], + includingToken: account.token + ) + } + + public nonisolated func compressedDownloadURL( + itemID: String, + versionIndex: Int, + maximumBitrate: Int, + resolution: String? + ) -> URL? { + // Jellyfin liefert die Adresse für eine umgerechnete Fassung erst nach + // einer Anfrage an `/PlaybackInfo` — das geht hier nicht, weil diese + // Methode keine Anfrage stellen darf. `nil` heißt: nur Original + // anbieten, statt eine Adresse zu erfinden, die nicht antwortet. + nil + } + + // MARK: - Zustand melden + + public func markWatched(id: String) async throws(MediaBackendError) { + try await client.send(JellyfinRequest( + method: .post, + path: "/Users/\(account.userID)/PlayedItems/\(id)" + )) + } + + public func markUnwatched(id: String) async throws(MediaBackendError) { + try await client.send(JellyfinRequest( + method: .delete, + path: "/Users/\(account.userID)/PlayedItems/\(id)" + )) + } + + public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) { + try await client.send(JellyfinRequest( + method: isFavourite ? .post : .delete, + path: "/Users/\(account.userID)/FavoriteItems/\(id)" + )) + } + + public func rate(id: String, rating: Int) async throws(MediaBackendError) { + // Jellyfin kennt nur Daumen hoch und runter, keine Sterne. Eine Zahl + // von eins bis zehn hier hineinzupressen hieße, etwas zu behaupten, + // was der Server nicht speichert. + throw .notFound + } + + public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) { + try await client.send(JellyfinRequest( + method: .post, + path: "/Items/\(library.id)/Refresh", + query: [URLQueryItem(name: "Recursive", value: "true")] + )) + } + + // MARK: - Hilfen + + private func rowItems( + path: String, + query: [URLQueryItem] + ) async throws(MediaBackendError) -> [MediaItem] { + let page = try await client.send( + JellyfinRequest(path: path, query: query), + as: JellyfinItemPage.self + ) + return page.items.map { $0.asMediaItem(imageBase: images) } + } + + /// Was Jellyfin zusätzlich mitschicken soll. + /// + /// Ohne diese Liste fehlen Beschreibung, Besetzung und Fassungen — und + /// jede Detailseite bräuchte eine zweite Anfrage. + private static let fields = "Overview,Genres,People,MediaSources,DateCreated,ParentId" +} + +extension MediaQuery.Sort { + var jellyfinField: String { + switch self { + case .titleAscending, .titleDescending: "SortName" + case .recentlyAdded: "DateCreated" + case .releaseDate: "PremiereDate" + case .rating: "CommunityRating" + case .lastPlayed: "DatePlayed" + } + } + + var isDescending: Bool { + switch self { + case .titleAscending: false + case .titleDescending, .recentlyAdded, .releaseDate, .rating, .lastPlayed: true + } + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift new file mode 100644 index 0000000..41f8e6e --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift @@ -0,0 +1,192 @@ +import Foundation +import MediaKit + +/// Wer wir für einen Jellyfin-Server sind. +/// +/// Jellyfin erwartet all das in **einer** Kopfzeile, in einer eigenen +/// Schreibweise. Die Gerätekennung muss über Starts hinweg gleich bleiben: +/// wechselt sie, gilt die App als neues Gerät, und in der Geräteliste des +/// Servers sammelt sich Müll an. +public struct JellyfinIdentity: Sendable, Equatable { + public let client: String + public let device: String + public let deviceID: String + public let version: String + + public init(client: String, device: String, deviceID: String, version: String) { + self.client = client + self.device = device + self.deviceID = deviceID + self.version = version + } + + /// Die Kopfzeile, mit oder ohne Token. + /// + /// Anführungszeichen sind Pflicht, auch um Werte ohne Leerzeichen — + /// Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht + /// angemeldet, obwohl der Token stimmt. + func authorizationHeader(token: String?) -> String { + var fields = [ + "Client=\"\(client)\"", + "Device=\"\(device)\"", + "DeviceId=\"\(deviceID)\"", + "Version=\"\(version)\"", + ] + if let token { fields.append("Token=\"\(token)\"") } + return "MediaBrowser " + fields.joined(separator: ", ") + } +} + +/// Spricht mit einem Jellyfin-Server. +public actor JellyfinClient { + public nonisolated let baseURL: URL + private let identity: JellyfinIdentity + private let transport: any JellyfinTransport + private var token: String? + + public init( + baseURL: URL, + identity: JellyfinIdentity, + token: String? = nil, + transport: any JellyfinTransport = URLSession.shared + ) { + self.baseURL = baseURL + self.identity = identity + self.token = token + self.transport = transport + } + + public func setToken(_ token: String?) { + self.token = token + } + + public nonisolated func authorization(token: String?) -> String { + identity.authorizationHeader(token: token) + } + + /// Die Adresse eines Endpunkts, mit Token in der Abfrage. + /// + /// Für alles, was nicht die App abruft, sondern der Systemplayer oder eine + /// Bildansicht: die schicken keine Kopfzeilen mit. Bei Plex hat genau das + /// tagelang gekostet — hier steht es von Anfang an. + public nonisolated func url( + path: String, + query: [URLQueryItem] = [], + includingToken token: String? = nil + ) -> URL? { + guard var components = URLComponents( + url: baseURL.appending(path: path), + resolvingAgainstBaseURL: false + ) else { return nil } + + var items = query + if let token { items.append(URLQueryItem(name: "ApiKey", value: token)) } + components.queryItems = items.isEmpty ? nil : items + return components.url + } + + public nonisolated var currentTokenForURLs: String? { nil } + + // MARK: - Anfragen + + public func send( + _ request: JellyfinRequest, + as type: Value.Type + ) async throws(MediaBackendError) -> Value { + let data = try await send(request) + do { + return try Self.decoder.decode(Value.self, from: data) + } catch { + throw .decoding(description: String(describing: error)) + } + } + + @discardableResult + public func send(_ request: JellyfinRequest) async throws(MediaBackendError) -> Data { + guard var components = URLComponents( + url: baseURL.appending(path: request.path), + resolvingAgainstBaseURL: false + ) else { + throw .transport(description: "Adresse nicht baubar") + } + components.queryItems = request.query.isEmpty ? nil : request.query + + guard let url = components.url else { + throw .transport(description: "Adresse nicht baubar") + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.timeoutInterval = request.timeout.seconds + urlRequest.setValue(identity.authorizationHeader(token: token), forHTTPHeaderField: "Authorization") + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + if let body = request.body { + urlRequest.httpBody = body + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await transport.data(for: urlRequest) + } catch { + throw .transport(description: error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw .transport(description: "Keine HTTP-Antwort") + } + + switch http.statusCode { + case 200..<300: return data + case 401, 403: throw .unauthorized + case 404: throw .notFound + default: throw .server(status: http.statusCode) + } + } + + /// Jellyfin schreibt seine Felder groß und rechnet Zeiten in Ticks. + /// + /// Kein `convertFromPascalCase` — das gibt es nicht, und die Felder + /// tragen ihre Schreibweise in den `CodingKeys` der Modelle. + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() +} + +/// Eine Anfrage an Jellyfin. +public struct JellyfinRequest: Sendable { + public enum Method: String, Sendable { case get = "GET", post = "POST", delete = "DELETE" } + + public let method: Method + public let path: String + public let query: [URLQueryItem] + public let body: Data? + public let timeout: Duration + + public init( + method: Method = .get, + path: String, + query: [URLQueryItem] = [], + body: Data? = nil, + timeout: Duration = .seconds(30) + ) { + self.method = method + self.path = path + self.query = query + self.body = body + self.timeout = timeout + } +} + +/// Wie Anfragen tatsächlich verschickt werden. +/// +/// Dieselbe Trennung wie bei Plex: Tests brauchen keinen Server, sondern +/// vorbereitete Antworten. +public protocol JellyfinTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: JellyfinTransport {} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift new file mode 100644 index 0000000..e810ed8 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift @@ -0,0 +1,176 @@ +import Foundation +import MediaKit + +/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form. +/// +/// Das Gegenstück zu `PlexItem+Media`. Beide Dateien machen dasselbe, und dass +/// es sie zweimal gibt, ist der Preis dafür, dass es die Ansichten nur einmal +/// gibt. +extension JellyfinItem { + public func asMediaItem(imageBase: JellyfinImagePaths) -> MediaItem { + MediaItem( + id: id, + path: nil, + kind: Self.kind(type), + title: name ?? "—", + summary: overview, + year: productionYear, + duration: runTimeTicks?.millisecondsFromTicks, + contentRating: officialRating, + // Jellyfin führt beide auf einer Zehnerskala, wie Plex auch. + rating: criticRating.map { $0 / 10 } ?? communityRating, + audienceRating: communityRating, + thumb: imageBase.primary(itemID: id, tag: imageTags?["Primary"]), + art: imageBase.backdrop(itemID: id, tag: backdropImageTags?.first) + ?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) } ?? nil, + parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) } ?? nil, + seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) } ?? nil, + seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) } ?? nil, + index: indexNumber, + parentIndex: parentIndexNumber, + parentTitle: seasonName, + parentID: seasonID, + seriesTitle: seriesName, + seriesID: seriesID, + playCount: userData?.playCount, + resumeMilliseconds: userData?.playbackPositionTicks?.millisecondsFromTicks, + lastPlayedAt: nil, + addedAt: dateCreated, + // Das Feld, das es bei Plex nicht gibt — hier kommt es frei mit. + isFavourite: userData?.isFavorite ?? false, + episodeCount: recursiveItemCount, + playedEpisodeCount: playedCount, + childCount: childCount, + versions: (mediaSources ?? []).map { $0.asMediaVersion(itemID: id) }, + genres: genres ?? [], + directors: (people ?? []).filter { $0.type == "Director" }.compactMap(\.name), + writers: (people ?? []).filter { $0.type == "Writer" }.compactMap(\.name), + cast: (people ?? []).filter { $0.type == "Actor" }.map { person in + MediaPerson( + id: person.id ?? person.name ?? UUID().uuidString, + name: person.name ?? "—", + role: person.role, + thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil + ) + }, + markers: [], + artwork: logoArtwork(imageBase: imageBase) + ) + } + + /// Wie viele Folgen gesehen sind. + /// + /// Jellyfin meldet die **ungesehenen**. Die Zahl, die die Oberfläche + /// zeigt, ist die andere — und sie auszurechnen ist ehrlicher, als das + /// Gegenteil anzuzeigen und es „Fortschritt" zu nennen. + private var playedCount: Int? { + guard let total = recursiveItemCount, let unplayed = userData?.unplayedItemCount else { + return nil + } + return max(0, total - unplayed) + } + + private func logoArtwork(imageBase: JellyfinImagePaths) -> [MediaArtwork] { + guard let tag = imageTags?["Logo"] else { return [] } + return [MediaArtwork(kind: .clearLogo, path: imageBase.logo(itemID: id, tag: tag))] + } + + private static func kind(_ type: String?) -> MediaItem.Kind? { + switch type { + case "Movie": .movie + case "Series": .show + case "Season": .season + case "Episode": .episode + case "BoxSet": .collection + case "Trailer", "Video": .clip + default: nil + } + } +} + +extension JellyfinMediaSource { + public func asMediaVersion(itemID: String) -> MediaVersion { + let streams = mediaStreams ?? [] + let video = streams.first { $0.type == "Video" } + let audio = streams.first { $0.type == "Audio" && $0.isDefault == true } + ?? streams.first { $0.type == "Audio" } + + return MediaVersion( + id: id ?? itemID, + container: container, + videoCodec: video?.codec, + audioCodec: audio?.codec, + videoResolution: video?.height.map(String.init), + // Jellyfin rechnet in Bit je Sekunde, die App in Kilobit. + bitrate: bitrate.map { $0 / 1_000 }, + width: video?.width, + height: video?.height, + audioChannels: audio?.channels, + duration: runTimeTicks?.millisecondsFromTicks, + files: [ + // Jellyfin trennt Fassung und Datei nicht: eine Quelle ist + // beides. Die neutrale Form kennt beide Ebenen, weil Plex sie + // trennt — hier ist die zweite eben einelementig. + MediaFile( + id: id ?? itemID, + key: id, + container: container, + size: size, + duration: runTimeTicks?.millisecondsFromTicks, + streams: streams.compactMap(\.asMediaStream) + ) + ] + ) + } +} + +extension JellyfinMediaStream { + public var asMediaStream: MediaStream? { + let mapped: MediaStream.Kind + switch type { + case "Video": mapped = .video + case "Audio": mapped = .audio + case "Subtitle": mapped = .subtitle + default: return nil + } + + return MediaStream( + id: String(index ?? 0), + kind: mapped, + codec: codec, + language: language, + languageCode: language, + displayTitle: displayTitle, + channels: channels, + isSelected: isDefault ?? false, + isForced: isForced ?? false + ) + } +} + +/// Baut die Adressen der Bilder. +/// +/// Eigener Typ, weil jedes Bild eine eigene Adresse ist und nicht ein Pfad wie +/// bei Plex. Die Marke (`tag`) gehört dazu: ohne sie liefert der Server zwar +/// ein Bild, aber der Zwischenspeicher merkt nicht, wenn es sich ändert. +public struct JellyfinImagePaths: Sendable { + public init() {} + + public func primary(itemID: String, tag: String?) -> String { + path(itemID: itemID, kind: "Primary", tag: tag) + } + + public func backdrop(itemID: String, tag: String?) -> String { + path(itemID: itemID, kind: "Backdrop", tag: tag) + } + + public func logo(itemID: String, tag: String?) -> String { + path(itemID: itemID, kind: "Logo", tag: tag) + } + + private func path(itemID: String, kind: String, tag: String?) -> String { + var path = "/Items/\(itemID)/Images/\(kind)" + if let tag { path += "?tag=\(tag)" } + return path + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift new file mode 100644 index 0000000..80418b2 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem.swift @@ -0,0 +1,152 @@ +import Foundation +import MediaKit + +/// Ein Eintrag, wie Jellyfin ihn über die Leitung schickt. +/// +/// Die Felder heißen groß und die Zeiten sind Ticks zu hundert Nanosekunden — +/// beides bleibt hier unten. Nach oben geht nur `MediaItem`. +public struct JellyfinItem: Decodable, Sendable { + public let id: String + public let name: String? + public let type: String? + public let overview: String? + public let productionYear: Int? + /// Laufzeit in Ticks zu 100 ns. + public let runTimeTicks: Int64? + public let indexNumber: Int? + public let parentIndexNumber: Int? + public let seriesName: String? + public let seriesID: String? + public let seasonID: String? + public let seasonName: String? + public let officialRating: String? + public let communityRating: Double? + public let criticRating: Double? + public let dateCreated: Date? + public let childCount: Int? + public let recursiveItemCount: Int? + public let genres: [String]? + public let people: [Person]? + public let userData: UserData? + public let imageTags: [String: String]? + public let backdropImageTags: [String]? + public let parentBackdropItemID: String? + public let seriesPrimaryImageTag: String? + public let mediaSources: [JellyfinMediaSource]? + public let collectionType: String? + + public struct Person: Decodable, Sendable { + public let id: String? + public let name: String? + public let role: String? + public let type: String? + public let primaryImageTag: String? + + private enum CodingKeys: String, CodingKey { + case id = "Id", name = "Name", role = "Role", type = "Type" + case primaryImageTag = "PrimaryImageTag" + } + } + + public struct UserData: Decodable, Sendable { + public let playbackPositionTicks: Int64? + public let playCount: Int? + public let played: Bool? + public let isFavorite: Bool? + public let unplayedItemCount: Int? + + private enum CodingKeys: String, CodingKey { + case playbackPositionTicks = "PlaybackPositionTicks" + case playCount = "PlayCount" + case played = "Played" + case isFavorite = "IsFavorite" + case unplayedItemCount = "UnplayedItemCount" + } + } + + private enum CodingKeys: String, CodingKey { + case id = "Id", name = "Name", type = "Type", overview = "Overview" + case productionYear = "ProductionYear", runTimeTicks = "RunTimeTicks" + case indexNumber = "IndexNumber", parentIndexNumber = "ParentIndexNumber" + case seriesName = "SeriesName", seriesID = "SeriesId" + case seasonID = "SeasonId", seasonName = "SeasonName" + case officialRating = "OfficialRating", communityRating = "CommunityRating" + case criticRating = "CriticRating", dateCreated = "DateCreated" + case childCount = "ChildCount", recursiveItemCount = "RecursiveItemCount" + case genres = "Genres", people = "People", userData = "UserData" + case imageTags = "ImageTags", backdropImageTags = "BackdropImageTags" + case parentBackdropItemID = "ParentBackdropItemId" + case seriesPrimaryImageTag = "SeriesPrimaryImageTag" + case mediaSources = "MediaSources", collectionType = "CollectionType" + } +} + +/// Eine Fassung, wie Jellyfin sie meldet. +public struct JellyfinMediaSource: Decodable, Sendable { + public let id: String? + public let container: String? + public let size: Int64? + public let bitrate: Int? + public let runTimeTicks: Int64? + public let mediaStreams: [JellyfinMediaStream]? + public let supportsDirectPlay: Bool? + public let supportsDirectStream: Bool? + public let supportsTranscoding: Bool? + public let transcodingURL: String? + + private enum CodingKeys: String, CodingKey { + case id = "Id", container = "Container", size = "Size", bitrate = "Bitrate" + case runTimeTicks = "RunTimeTicks", mediaStreams = "MediaStreams" + case supportsDirectPlay = "SupportsDirectPlay" + case supportsDirectStream = "SupportsDirectStream" + case supportsTranscoding = "SupportsTranscoding" + case transcodingURL = "TranscodingUrl" + } +} + +/// Eine Spur, wie Jellyfin sie meldet. +public struct JellyfinMediaStream: Decodable, Sendable { + public let index: Int? + public let type: String? + public let codec: String? + public let language: String? + public let displayTitle: String? + public let channels: Int? + public let isDefault: Bool? + public let isForced: Bool? + public let height: Int? + public let width: Int? + + private enum CodingKeys: String, CodingKey { + case index = "Index", type = "Type", codec = "Codec", language = "Language" + case displayTitle = "DisplayTitle", channels = "Channels" + case isDefault = "IsDefault", isForced = "IsForced" + case height = "Height", width = "Width" + } +} + +/// Die Hülle, in der Jellyfin Listen schickt. +public struct JellyfinItemPage: Decodable, Sendable { + public let items: [JellyfinItem] + public let totalRecordCount: Int? + public let startIndex: Int? + + private enum CodingKeys: String, CodingKey { + case items = "Items" + case totalRecordCount = "TotalRecordCount" + case startIndex = "StartIndex" + } +} + +extension Int64 { + /// Ticks zu hundert Nanosekunden in Millisekunden. + /// + /// Jellyfins Einheit. Zehntausend Ticks sind eine Millisekunde — eine + /// Zahl, die man beim Lesen zweimal prüft und deshalb genau einmal + /// aufschreibt. + var millisecondsFromTicks: Int { Int(self / 10_000) } +} + +extension Int { + var ticksFromMilliseconds: Int64 { Int64(self) * 10_000 } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings b/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings new file mode 100644 index 0000000..762c90e --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings @@ -0,0 +1,102 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "row.continueWatching": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiterschauen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue watching" + } + } + } + }, + "row.nextUp": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Als Nächstes" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Next up" + } + } + } + }, + "row.recentlyAdded": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt hinzugefügt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently added" + } + } + } + }, + "search.movies": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Movies" + } + } + } + }, + "search.shows": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Serien" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows" + } + } + } + }, + "search.episodes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Folgen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Episodes" + } + } + } + } + } +} \ No newline at end of file diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/episode.json b/Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/episode.json new file mode 100644 index 0000000..fc2efcb --- /dev/null +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/episode.json @@ -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 +} \ No newline at end of file diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/series.json b/Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/series.json new file mode 100644 index 0000000..168d393 --- /dev/null +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/Fixtures/series.json @@ -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 +} \ No newline at end of file diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift new file mode 100644 index 0000000..61cd272 --- /dev/null +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift @@ -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") + } +}