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