import Foundation /// Ein verbundener Plex-Server. /// /// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen /// weder Pfade noch Abfrageparameter — sie rufen Methoden auf. public actor PlexServer { // `nonisolated`, weil beide unveränderlich und Sendable sind: die // Oberfläche muss für „welche Verbindung läuft gerade" nicht auf den // Actor warten. Über Modulgrenzen hinweg leitet der Compiler das nicht // von selbst her. public nonisolated let resource: PlexResource public nonisolated let connection: PlexConnection /// Modulweit sichtbar, weil die Endpunkte auf mehrere Dateien verteilt /// sind — Blättern hier, Wiedergabe in PlexServer+Playback. Nach außen /// bleibt er verborgen: die App soll Methoden aufrufen, keine Pfade bauen. let client: PlexHTTPClient nonisolated let token: String public init?( resource: PlexResource, connection: PlexConnection, identity: PlexClientIdentity, transport: any PlexTransport = URLSession.shared ) { guard let baseURL = connection.url, let token = resource.accessToken else { return nil } self.resource = resource self.connection = connection self.token = token self.client = PlexHTTPClient( baseURL: baseURL, identity: identity, token: token, transport: transport ) } public nonisolated var name: String { resource.name } // MARK: - Mediatheken public func libraries() async throws(PlexError) -> [PlexLibrary] { let container = try await client.send( PlexRequest(path: "/library/sections"), as: PlexMediaContainer.self ) return container.content } // MARK: - Startseite /// Die fertig zusammengestellten Reihen für die Startseite. public func homeHubs() async throws(PlexError) -> [PlexHub] { let container = try await client.send( PlexRequest(path: "/hubs", query: [ // Leere Reihen wären auf der Startseite nur Überschriften ohne // Inhalt. URLQueryItem(name: "includeEmpty", value: "0"), URLQueryItem(name: "count", value: "20"), ]), as: PlexMediaContainer.self ) return container.content.filter { !$0.items.isEmpty } } /// Was als Nächstes dran ist: angefangene Filme und die nächste ungesehene /// Folge je Serie. public func continueWatching() async throws(PlexError) -> [PlexItem] { let container = try await client.send( PlexRequest(path: "/hubs/continueWatching/items"), as: PlexMediaContainer.self ) return container.content } // MARK: - Blättern public func items( inLibrary library: PlexLibrary, query: LibraryQuery = LibraryQuery(), offset: Int = 0, limit: Int = 60 ) async throws(PlexError) -> PlexPage { let container = try await client.send( PlexRequest( path: "/library/sections/\(library.key)/all", query: query.queryItems(libraryKind: library.kind), // Die Seitengröße steht in Kopfzeilen, nicht in der Adresse — // eine Eigenheit von Plex. additionalHeaders: [ "X-Plex-Container-Start": String(offset), "X-Plex-Container-Size": String(limit), ] ), as: PlexMediaContainer.self ) return PlexPage( items: container.content, offset: container.offset ?? offset, // `totalSize` fehlt, wenn alles auf eine Seite passt. Dann ist die // Gesamtzahl gleich der Anzahl der gelieferten Einträge. totalSize: container.totalSize ?? (offset + container.content.count) ) } /// Ein einzelner Titel mit allem, was die Detailseite braucht. public func metadata(ratingKey: String) async throws(PlexError) -> PlexItem { let container = try await client.send( PlexRequest(path: "/library/metadata/\(ratingKey)", query: [ // Marker für Intro und Abspann. Sie werden schon jetzt // mitgeholt, obwohl sie erst in Phase 2 ausgewertet werden — // sie kosten nichts und ersparen später eine zweite Anfrage. URLQueryItem(name: "includeMarkers", value: "1"), URLQueryItem(name: "includeChapters", value: "1"), URLQueryItem(name: "includeRelated", value: "1"), ]), as: PlexMediaContainer.self ) guard let item = container.content.first else { throw .notFound } return item } /// Die Staffeln einer Serie bzw. die Folgen einer Staffel. public func children(of ratingKey: String) async throws(PlexError) -> [PlexItem] { let container = try await client.send( PlexRequest(path: "/library/metadata/\(ratingKey)/children"), as: PlexMediaContainer.self ) return container.content } // MARK: - Suche /// Sucht über alle Mediatheken. Plex gruppiert das Ergebnis selbst nach /// Filmen, Serien, Folgen und Personen. public func search(_ query: String, limit: Int = 30) async throws(PlexError) -> [PlexHub] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } let container = try await client.send( PlexRequest(path: "/hubs/search", query: [ URLQueryItem(name: "query", value: trimmed), URLQueryItem(name: "limit", value: String(limit)), ]), as: PlexMediaContainer.self ) return container.content.filter { !$0.items.isEmpty } } // MARK: - Adressen /// Die Adresse eines Bildes in der gewünschten Größe. public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? { guard let path, !path.isEmpty else { return nil } return client.artworkURL(path: path, width: width, height: height, token: token) } /// Ein Bild unverändert, ohne den Umweg über den Bild-Transcoder. /// /// Für freigestellte Schriftzüge zwingend: `/photo/:/transcode` liefert /// JPEG, und JPEG kennt keine Transparenz. Ein Logo käme als weißer /// Schriftzug auf schwarzem Kasten heraus statt freigestellt auf dem /// Hintergrundbild. public nonisolated func rawImageURL(path: String?) -> URL? { guard let path, !path.isEmpty, let baseURL = connection.url, var components = URLComponents( url: baseURL.appending(path: path), resolvingAgainstBaseURL: false ) else { return nil } components.queryItems = [URLQueryItem(name: "X-Plex-Token", value: token)] return components.url } /// Die Adresse für DirectPlay: die Datei, unverändert. public nonisolated func directPlayURL(part: PlexPart) -> URL? { streamURL(partKey: part.key, download: false) } /// Dieselbe Datei, aber als Download angefordert. Der Unterschied liegt /// nur im Schalter — Plex setzt daraufhin Kopfzeilen, die das Weiterladen /// nach einem Abbruch erlauben. public nonisolated func downloadURL(part: PlexPart) -> URL? { streamURL(partKey: part.key, download: true) } private nonisolated func streamURL(partKey: String, download: Bool) -> URL? { guard let baseURL = connection.url, var components = URLComponents( url: baseURL.appending(path: partKey), resolvingAgainstBaseURL: false ) else { return nil } var query = [URLQueryItem(name: "X-Plex-Token", value: token)] if download { query.append(URLQueryItem(name: "download", value: "1")) } components.queryItems = query return components.url } } // MARK: - Filterwerte /// Ein Wert, nach dem gefiltert werden kann — ein Genre, ein Jahr, eine /// Altersfreigabe. public struct PlexFilterOption: Sendable, Equatable, Decodable, Identifiable { public let key: String public let title: String public var id: String { key } public init(key: String, title: String) { self.key = key self.title = title } } extension PlexServer { /// Die Werte, die es in dieser Mediathek für ein Filterfeld gibt. /// /// Wird vom Server berechnet, nicht aus der Titelliste abgeleitet: die /// Liste ist seitenweise geladen und kennt die Genres der übrigen /// Tausenden Titel gar nicht. public func filterOptions( inLibrary library: PlexLibrary, field: String ) async throws(PlexError) -> [PlexFilterOption] { let container = try await client.send( PlexRequest(path: "/library/sections/\(library.key)/\(field)"), as: PlexMediaContainer.self ) return container.content } }