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 /// Live-TV mit Gedächtnis. /// /// Ein Actor, also ein Referenztyp: Kopien dieses Struct teilen sich /// denselben und damit die geladene Senderliste. Warum das nötig ist, /// steht bei `PlexLiveTVSession`. let live: PlexLiveTVSession public init(server: PlexServer) { self.server = server self.live = PlexLiveTVSession(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), offset: page.offset, 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: - Herunterladen public nonisolated func downloadURL(for file: MediaFile) -> URL? { guard let key = file.key else { return nil } // Nur der Schlüssel wird gebraucht; alles andere steckt schon in der // Adresse oder ist für den Abruf ohne Belang. return server.downloadURL(part: PlexPart(id: 0, key: key)) } public nonisolated func compressedDownloadURL( itemID: String, versionIndex: Int, maximumBitrate: Int, resolution: String? ) -> URL? { server.transcodedDownloadURL( ratingKey: itemID, mediaIndex: versionIndex, maximumBitrate: maximumBitrate, resolution: resolution ) } // MARK: - Abspielen public func preparePlayback( _ intent: PlaybackIntent ) async throws(MediaBackendError) -> PlaybackTarget { guard let transcode = intent.transcode else { // Unverändert übertragen: die Datei selbst, ohne Sitzung — es gibt // nichts anzuhalten, was der Server angelegt hätte. guard let key = intent.file.key, let url = server.directPlayURL(part: PlexPart(id: 0, key: key)) else { throw .notFound } return PlaybackTarget(url: url) } let request = TranscodeRequest( ratingKey: intent.itemID, mediaIndex: intent.versionIndex, maximumBitrate: transcode.maximumBitrate, resolution: transcode.resolution, isLocal: intent.isLocalConnection ) // Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier schon // nein, steht der Grund im Klartext da — statt eines schwarzen Bildes // nach zwanzig Sekunden Warten. let decision = try await mapping { try await server.transcodeDecision(for: request) } guard decision.isAllowed else { throw .refused(reason: decision.explanation ?? "") } guard let url = server.transcodeURL(for: request) else { throw .notFound } return PlaybackTarget(url: url, sessionID: request.sessionIdentifier) } public func endPlayback(sessionID: String) async { await server.stopTranscode(sessionIdentifier: sessionID) } public func reportProgress(_ report: PlaybackReport) async { await server.reportProgress( ratingKey: report.itemID, state: report.state, time: report.time, duration: report.duration ) } // 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 ) } }