Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift
T
ScarriffleandClaude Opus 5 8dba05b4f9 Wiedergabe: die Plex-Kennung muss in die Adresse, nicht nur in die Kopfzeilen
HTTP 400 vom Transcoder. Die Kennung dieses Geräts reist bei jeder
Anfrage als Kopfzeile mit — aber die Adresse des Stroms ruft nicht die
App ab, sondern AVPlayer, und der schickt keine Kopfzeilen mit. Bei
Dateien fällt das nicht auf; bei Live-TV schon: der Server legt die
Sitzung unter der Gerätekennung an und kann die Abholung ohne sie keiner
Sitzung zuordnen.

Also trägt die Transcoder-Adresse die Kennung jetzt selbst. Das gilt für
alle Ströme, nicht nur für Live — dort fiel es nur zuerst auf.

Dazu: umgerechnet wird die Adresse der Sitzung, nicht die des Teils
darin. Der Transcoder sucht sich den Teil selbst, deshalb steht dort -1.

Und der Entscheider unterscheidet jetzt zwischen „nein" und „keine
Antwort". Ein Schweigen als Ablehnung zu lesen hieße, eine
funktionierende Sendung zu blocken, bloß weil er Live-Adressen womöglich
gar nicht kennt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:02:12 +02:00

245 lines
9.3 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
/// Wer wir für den Server sind.
///
/// Auch hier festgehalten, nicht nur im Client: Adressen, die der
/// Systemplayer abruft, müssen die Kennung selbst tragen — AVPlayer
/// schickt keine Kopfzeilen mit.
nonisolated let identity: PlexClientIdentity
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.identity = identity
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
}
}