Der Decider brauchte keine Änderung — nur eine andere Richtlinie. Genau dafür war er gebaut. Transcoding - TranscodeRequest baut die Parameter für /video/:/transcode/universal. directStream=1 lässt Plex nur umpacken, wenn der Bildcodec passt, statt pauschal neu zu kodieren. - Vor dem Start wird der Server gefragt (decision); sagt er nein, steht der Grund im Klartext da statt eines schwarzen Bildes. - Beim Verlassen wird der Transcoder angehalten. Ohne dieses Signal rechnet der Server minutenlang für niemanden weiter. - Die adaptive Bitrate kommt mit HLS: AVPlayer wechselt sie selbst. Qualitätseinstellungen - Getrennt für WLAN und Mobilfunk, fünf spürbare Stufen. Voreinstellung: zuhause Original, unterwegs 4 Mbit. - NetworkConditions erkennt Mobilfunk über isExpensive — das deckt auch den Hotspot ab, der technisch WLAN ist und trotzdem Volumen kostet. Spuren und Marken - Ton- und Untertitelwahl im Player, bevorzugte Sprache in den Einstellungen, nachsichtiger Sprachvergleich (de/deu/ger/de-DE). - Intro- und Abspann-Marken mit Überspringen-Knopf, optional automatisch. Bereits übersprungene Marken werden gemerkt, sonst käme man nach einem Rücksprung nie ins Intro zurück. - Nächste Folge inkl. Staffelwechsel; Specials in Staffel 0 werden nicht vorgezogen. Fortschritt - Alle zehn Sekunden an den Server, plus Schlussmeldung beim Verlassen. Erst die nimmt den Titel aus „wird gerade abgespielt" heraus. - markWatched/markUnwatched/rate/refreshLibrary stehen bereit. 86 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
8.2 KiB
Swift
219 lines
8.2 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)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|