Senderliste
Plex liefert die Sender verschachtelt in der Antwort auf /livetv/dvrs
mit — jeder Empfänger bringt seine eigenen unter `Device` → `Channel`.
Mein getrennter Endpunkt /livetv/dvrs/{id}/channels war eine Vermutung
und lieferte nichts. Bei einem Server mit über dreißig eingerichteten
Sendern stand deshalb „Keine Sender".
Die verschachtelte Liste hat jetzt Vorrang, der Endpunkt bleibt als
Rückfalllinie. `ChannelMapping` wird als ältere Schreibweise ebenfalls
gelesen, und der Empfänger-Schlüssel kommt je nach Serverfassung als
Zahl oder als Zeichenkette.
Bleibt die Liste leer, sagt die Ansicht jetzt welcher Weg was ergeben
hat. In einem Bereich ohne offizielle Beschreibung ist das die Angabe,
mit der sich der nächste Fehler finden lässt.
Schriftzüge
Plex liefert bei vielen Filmen ein `clearLogo` — den Schriftzug in
seiner eigenen Type, freigestellt. Der tritt jetzt im Aufmacher an die
Stelle des gesetzten Titels.
Dabei ein Fallstrick: /photo/:/transcode liefert JPEG, und JPEG kennt
keine Transparenz. Über den Bild-Transcoder geladen wäre aus dem
freigestellten Logo ein schwarzer Kasten geworden. Es wird deshalb
unverändert geladen. Fehlt der Schriftzug oder scheitert das Laden,
bleibt es beim Titel.
161 Tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
9.0 KiB
Swift
238 lines
9.0 KiB
Swift
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<PlexLibrary>.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<PlexHub>.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<PlexItem>.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<PlexItem> {
|
|
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<PlexItem>.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<PlexItem>.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<PlexItem>.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<PlexHub>.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<PlexFilterOption>.self
|
|
)
|
|
return container.content
|
|
}
|
|
}
|