Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift
T
ScarriffleandClaude Opus 5 a9a45e685d Live-TV: jeder Weg wird bis zum Ende geprüft, bevor der Player ihn bekommt
Wieder HTTP 400, und wieder ohne Begründung. Der Grund, dass das dreimal
passieren konnte: eine gebaute Adresse galt schon als Erfolg. Ob der
Server sie überhaupt annimmt, stellte sich erst im Player heraus — und
dort steht nur „HTTP 400 (unhandled)".

Jetzt ruft die App die Adresse einmal selbst ab, ohne Plex-Kopfzeilen,
genau so wie AVPlayer es tut. Nur was mit 200 antwortet, geht an den
Player. Was nicht, steht mit dem Wortlaut des Servers in der Spur.

Weil weder der Schlüssel für die Sitzung noch die anschließend
umzurechnende Adresse für diesen Teil der Plex-Schnittstelle beschrieben
sind, werden die Kombinationen der Reihe nach durchprobiert — Adresse der
Sitzung, Adresse aus der Antwort, Adresse des Teils darin, je mit
mediaIndex -1 und 0, höchstens acht Versuche. Antwortet keiner, wird der
Transcoder wieder angehalten, statt für nichts weiterzurechnen.

Die Spur steht in einem Blatt statt in einem Dialog: die Antwort des
Servers ist mehrzeilig, und ein Dialog schneidet sie ab — dann stünde
dort wieder nur, dass es nicht ging.

Dazu ein Nebeneffekt, der beim zweiten Sender zugeschlagen hätte: der
Player kannte die Sitzungskennung des Live-Stroms nicht und hat den Tuner
beim Schließen nicht freigegeben.

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

248 lines
9.5 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
/// Für Abrufe ohne Plex-Kopfzeilen — so, wie der Systemplayer sie macht.
nonisolated let transport: any PlexTransport
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.transport = transport
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
}
}