From effdcd4532315150edb23bdc330c22710faa1e5b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 12:37:37 +0200 Subject: [PATCH] =?UTF-8?q?MediaBackend:=20der=20Vertrag,=20den=20Plex=20u?= =?UTF-8?q?nd=20Jellyfin=20beide=20erf=C3=BCllen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Protokoll für das, was jeder Server können muss — blättern, Bilder liefern, Zustand melden. Bewusst schmal: alles, was nur einer kann, steht nicht darin, sondern als abfragbare Fähigkeit. Ein gemeinsamer Nenner, der alles umfasst, wäre keiner mehr. Deshalb `BackendCapability`. Plex kennt keine Favoriten, Jellyfin keine Plex-Empfänger — und eine Oberfläche, die Knöpfe für Dinge zeigt, die der Server nicht kann, ist schlimmer als eine, die sie weglässt. Plex meldet hier ausdrücklich keine Favoriten und wirft, wenn jemand es trotzdem versucht; stillschweigend nichts zu tun wäre die schlechtere Antwort. `MediaQuery` ersetzt `LibraryQuery` nach oben hin. Deren Sortierwerte waren Plex' Drahtformat („titleSort:asc"), was hieß: entweder lernt Jellyfin Plex' Schreibweise, oder jede Ansicht muss wissen, mit wem sie spricht. `PlexBackend` ist eine Hülle, keine Umschreibung — die Endpunkte bleiben, wo sie sind. Der Fehlerübersetzer nimmt den ungetypten Wurf, weil Swift getippte Fehler durch eine Closure hindurch nicht ableiten kann; der zweite `catch` ist dabei keine Zierde, sondern verhindert, dass ein unerwarteter Fehler durchfällt und die Signatur zur Lüge macht. Die App benutzt noch nichts davon. Co-Authored-By: Claude Opus 5 --- .../Sources/MediaKit/MediaBackend.swift | 91 ++++++++ .../Sources/MediaKit/MediaQuery.swift | 61 ++++++ .../Sources/PlexKit/PlexBackend.swift | 194 ++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift create mode 100644 Packages/FlixyKit/Sources/MediaKit/MediaQuery.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift new file mode 100644 index 0000000..955337a --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Was ein Server können muss, damit die App mit ihm arbeiten kann. +/// +/// Der Vertrag ist bewusst schmal. Alles, was nur ein Server kann — Plex' PIN- +/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers — +/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner, +/// der alles umfasst, wäre kein Nenner mehr. +public protocol MediaBackend: Sendable { + /// Wie der Server heißt, so wie er sich selbst nennt. + nonisolated var serverName: String { get } + /// Was dieser Server darüber hinaus beherrscht. + nonisolated var capabilities: Set { get } + + // MARK: - Blättern + + func libraries() async throws(MediaBackendError) -> [MediaLibrary] + func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] + func items( + inLibrary library: MediaLibrary, + query: MediaQuery, + offset: Int, + limit: Int + ) async throws(MediaBackendError) -> MediaPage + func item(id: String) async throws(MediaBackendError) -> MediaItem + func children(of id: String) async throws(MediaBackendError) -> [MediaItem] + func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] + /// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek. + func filterOptions( + inLibrary library: MediaLibrary, + field: MediaQuery.Field + ) async throws(MediaBackendError) -> [MediaFilterOption] + /// Die nächste Folge nach dieser, sofern es eine gibt. + func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? + + // MARK: - Bilder + + /// Ein Bild in der gewünschten Größe. + /// + /// `nonisolated`, weil die Adresse ohne Netzzugriff entsteht und der + /// Systemplayer sie ohne Kopfzeilen abruft — der Token steckt deshalb in + /// der Adresse selbst. + nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? + nonisolated func rawImageURL(path: String?) -> URL? + + // MARK: - Zustand melden + + func markWatched(id: String) async throws(MediaBackendError) + func markUnwatched(id: String) async throws(MediaBackendError) + func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) + func rate(id: String, rating: Int) async throws(MediaBackendError) + func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) +} + +/// Was ein Server über das Nötigste hinaus kann. +/// +/// Abgefragt statt vorausgesetzt: Plex kennt keine Favoriten, Jellyfin keine +/// Plex-Empfänger, und eine Oberfläche, die Knöpfe für Dinge zeigt, die der +/// Server nicht kann, ist schlimmer als eine, die sie weglässt. +public enum BackendCapability: String, Sendable, Hashable { + case liveTV + case favourites + case ratings + case libraryScan + /// Die Verwaltung des Servers aus der App heraus. + case administration + case transcoding +} + +/// Eine Seite aus einer Mediathek. +public struct MediaPage: Sendable, Equatable { + public let items: [MediaItem] + /// Wie viele es insgesamt gibt — Grundlage fürs Nachladen. + public let totalCount: Int? + + public init(items: [MediaItem], totalCount: Int? = nil) { + self.items = items + self.totalCount = totalCount + } +} + +/// Ein möglicher Wert für einen Filter. +public struct MediaFilterOption: Sendable, Equatable, Identifiable { + public let id: String + public let title: String + + public init(id: String, title: String) { + self.id = id + self.title = title + } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaQuery.swift b/Packages/FlixyKit/Sources/MediaKit/MediaQuery.swift new file mode 100644 index 0000000..5c159cd --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaQuery.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Wonach eine Mediathek durchsucht wird. +/// +/// Gefiltert und sortiert wird **auf dem Server**. Alles hierher zu holen und +/// selbst zu sieben wäre bei einer Mediathek mit fünftausend Folgen +/// aussichtslos — und die Server können es ohnehin besser. +/// +/// Die Sortierarten sind neutral benannt. Vorher standen hier Plex' +/// Drahtwerte (`"titleSort:asc"`), was bedeutete: entweder Jellyfin lernt +/// Plex' Schreibweise, oder jede Ansicht muss wissen, mit wem sie spricht. +public struct MediaQuery: Sendable, Equatable { + public enum Sort: String, Sendable, Equatable, CaseIterable { + case titleAscending, titleDescending + case recentlyAdded + case releaseDate + case rating + case lastPlayed + } + + /// Wonach gefiltert werden kann. + public enum Field: String, Sendable, Equatable { + case genre, year, contentRating, resolution + } + + public var sort: Sort + public var unwatchedOnly: Bool + public var favouritesOnly: Bool + public var genre: String? + public var year: Int? + public var contentRating: String? + public var resolution: String? + /// Freitextsuche innerhalb der Mediathek. + public var searchText: String? + + public init( + sort: Sort = .titleAscending, + unwatchedOnly: Bool = false, + favouritesOnly: Bool = false, + genre: String? = nil, + year: Int? = nil, + contentRating: String? = nil, + resolution: String? = nil, + searchText: String? = nil + ) { + self.sort = sort + self.unwatchedOnly = unwatchedOnly + self.favouritesOnly = favouritesOnly + self.genre = genre + self.year = year + self.contentRating = contentRating + self.resolution = resolution + self.searchText = searchText + } + + /// Ob überhaupt eingeschränkt wird — steuert die Anzeige des Filterknopfs. + public var isFiltering: Bool { + unwatchedOnly || favouritesOnly || genre != nil || year != nil + || contentRating != nil || resolution != nil + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift new file mode 100644 index 0000000..32456f9 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -0,0 +1,194 @@ +import Foundation +import MediaKit + +/// Plex als `MediaBackend`. +/// +/// Eine Hülle um `PlexServer`, keine Umschreibung: die Endpunkte bleiben, wo +/// sie sind, und werden hier nur in die neutrale Form gebracht. Das ist der +/// ganze Zweck — die Oberfläche fragt nach `MediaItem`, und was dafür über die +/// Leitung geht, ist ihre Sache nicht. +public struct PlexBackend: MediaBackend { + /// Der darunterliegende Server. + /// + /// Noch öffentlich, weil Player, Downloads und Live-TV vorerst direkt mit + /// ihm sprechen — diese drei Bereiche wandern als Nächstes. + public let server: PlexServer + + public init(server: PlexServer) { + self.server = server + } + + public nonisolated var serverName: String { server.name } + + public nonisolated var capabilities: Set { + // Keine Favoriten: Plex kennt sie nicht, und ein Knopf, der nichts + // bewirkt, ist schlimmer als keiner. + [.liveTV, .ratings, .libraryScan, .transcoding] + } + + // MARK: - Blättern + + public func libraries() async throws(MediaBackendError) -> [MediaLibrary] { + try await mapping { try await server.libraries().map(\.asMediaLibrary) } + } + + public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] { + try await mapping { try await server.homeHubs().map(\.asMediaRowGroup) } + } + + public func items( + inLibrary library: MediaLibrary, + query: MediaQuery, + offset: Int, + limit: Int + ) async throws(MediaBackendError) -> MediaPage { + try await mapping { + let page = try await server.items( + inLibrary: plexLibrary(library), + query: LibraryQuery(query), + offset: offset, + limit: limit + ) + return MediaPage(items: page.items.map(\.asMediaItem), totalCount: page.totalSize) + } + } + + public func item(id: String) async throws(MediaBackendError) -> MediaItem { + try await mapping { try await server.metadata(ratingKey: id).asMediaItem } + } + + public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] { + try await mapping { try await server.children(of: id).map(\.asMediaItem) } + } + + public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] { + try await mapping { try await server.search(text, limit: limit).map(\.asMediaRowGroup) } + } + + public func filterOptions( + inLibrary library: MediaLibrary, + field: MediaQuery.Field + ) async throws(MediaBackendError) -> [MediaFilterOption] { + try await mapping { + try await server + .filterOptions(inLibrary: plexLibrary(library), field: field.rawValue) + .map { MediaFilterOption(id: $0.key, title: $0.title) } + } + } + + public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? { + try await mapping { + // Der Weg führt über die Geschwister; dafür braucht es den + // vollständigen Plex-Eintrag, nicht nur die Kennung. + let plex = try await server.metadata(ratingKey: item.id) + return try await server.nextEpisode(after: plex)?.asMediaItem + } + } + + // MARK: - Bilder + + public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? { + server.artworkURL(path: path, width: width, height: height) + } + + public nonisolated func rawImageURL(path: String?) -> URL? { + server.rawImageURL(path: path) + } + + // MARK: - Zustand melden + + public func markWatched(id: String) async throws(MediaBackendError) { + try await mapping { try await server.markWatched(ratingKey: id) } + } + + public func markUnwatched(id: String) async throws(MediaBackendError) { + try await mapping { try await server.markUnwatched(ratingKey: id) } + } + + public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) { + // Plex kennt keine Favoriten. Stillschweigend nichts zu tun wäre + // schlimmer als zu sagen, dass es das hier nicht gibt. + throw .notFound + } + + public func rate(id: String, rating: Int) async throws(MediaBackendError) { + try await mapping { try await server.rate(ratingKey: id, rating: rating) } + } + + public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) { + try await mapping { try await server.refreshLibrary(plexLibrary(library)) } + } + + // MARK: - Hilfen + + /// Baut aus der neutralen Mediathek die Plex-Form zurück. + /// + /// Nur `key` und `type` werden gebraucht; alles andere hat der Server + /// ohnehin. Ein zweiter Abruf, nur um ein Modell vollständig zu machen, + /// das gleich wieder zu einer Adresse wird, wäre verschwendet. + private func plexLibrary(_ library: MediaLibrary) -> PlexLibrary { + let type: String = switch library.kind { + case .movie: "movie" + case .show: "show" + case .music: "artist" + case .photo: "photo" + case .liveTV: "livetv" + case .other: "unknown" + } + return PlexLibrary(key: library.id, title: library.title, type: type) + } + + /// Führt einen Plex-Aufruf aus und übersetzt seinen Fehler. + /// + /// Der Block wirft ungetypt, weil Swift den getippten Wurf durch eine + /// Closure hindurch nicht ableiten kann. Der zweite `catch` ist deshalb + /// nicht Zierde: gäbe es ihn nicht, fiele ein unerwarteter Fehler durch + /// und die Signatur wäre eine Lüge. + private func mapping( + _ work: () async throws -> Value + ) async throws(MediaBackendError) -> Value { + do { + return try await work() + } catch let error as PlexError { + throw error.asMediaBackendError + } catch { + throw .transport(description: String(describing: error)) + } + } +} + +extension PlexError { + public var asMediaBackendError: MediaBackendError { + switch self { + case .unauthorized: .unauthorized + case .notFound: .notFound + case .noReachableConnection: .noReachableConnection + case .server(let status): .server(status: status) + case .transport(let description): .transport(description: description) + case .decoding(let description): .decoding(description: description) + } + } +} + +extension LibraryQuery { + /// Übersetzt die neutrale Abfrage in Plex' Schreibweise. + init(_ query: MediaQuery) { + let sort: Sort = switch query.sort { + case .titleAscending: .titleAscending + case .titleDescending: .titleDescending + case .recentlyAdded: .recentlyAdded + case .releaseDate: .releaseDate + case .rating: .rating + case .lastPlayed: .lastViewed + } + + self.init( + sort: sort, + unwatchedOnly: query.unwatchedOnly, + genre: query.genre, + year: query.year, + contentRating: query.contentRating, + resolution: query.resolution + ) + } +}