Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift
T
GuidoandClaude Opus 5 ed84b5bf22 Live-TV-Sender gefunden, Film-Schriftzüge ergänzt
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>
2026-09-12 09:28:21 +02:00

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