Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player
Die Datenschicht: - plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund - Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor Vermittlung), mit Abgleich der Server-Kennung gegen veraltete plex.direct-Adressen - PlexServer als einzige Stelle, an der die App Endpunkte kennt: Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten, Staffeln/Folgen, Suche Die Oberfläche: - Startseite mit Aufmacher und den Reihen vom Server - Mediathek als Raster oder Liste, mit Filter- und Sortierblatt - Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung, Fassungsauswahl und aufklappbaren technischen Angaben - Suche mit gemerkten Anfragen - Vollbildplayer mit eigenem Chrome Der Player: - AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll - VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test` in FlixyKit nicht das 920-MB-Binärpaket mitzieht Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf, dass `strong=true` einen dreißigstelligen Code liefert, der sich bei plex.tv/link nicht eintippen lässt — jetzt der kurze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// Führt die Anmeldung bei plex.tv durch.
|
||||
public actor PlexAuthenticator {
|
||||
/// Wo das Konto-Token im Schlüsselbund steht.
|
||||
public static let accountTokenAccount = "plex.account"
|
||||
|
||||
private let client: PlexHTTPClient
|
||||
private let identity: PlexClientIdentity
|
||||
private let tokenStore: any TokenStoring
|
||||
private let clock: any Clock<Duration>
|
||||
|
||||
public init(
|
||||
identity: PlexClientIdentity,
|
||||
tokenStore: any TokenStoring,
|
||||
transport: any PlexTransport = URLSession.shared,
|
||||
clock: any Clock<Duration> = ContinuousClock()
|
||||
) {
|
||||
self.identity = identity
|
||||
self.tokenStore = tokenStore
|
||||
self.clock = clock
|
||||
self.client = PlexHTTPClient(
|
||||
baseURL: URL(string: "https://plex.tv")!,
|
||||
identity: identity,
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
/// Der bereits gespeicherte Token, falls vorhanden.
|
||||
public nonisolated var storedToken: String? {
|
||||
tokenStore.token(for: Self.accountTokenAccount)
|
||||
}
|
||||
|
||||
/// Fordert einen neuen Anmeldecode an.
|
||||
///
|
||||
/// Bewusst ohne `strong=true`: das liefert zwar einen schwerer zu
|
||||
/// erratenden Code, aber einen dreißigstelligen. Bei plex.tv/link
|
||||
/// abzutippen ist der nicht — und genau das ist der Weg, den Flixy
|
||||
/// anbietet. Der kurze Code ist vier Zeichen lang und läuft nach wenigen
|
||||
/// Minuten ab, was das Raten ausreichend aussichtslos macht.
|
||||
public func requestPin() async throws(PlexError) -> PlexPin {
|
||||
try await client.send(
|
||||
PlexRequest(method: .post, path: "/api/v2/pins"),
|
||||
as: PlexPin.self
|
||||
)
|
||||
}
|
||||
|
||||
/// Wartet darauf, dass der Nutzer den Code im Browser bestätigt.
|
||||
///
|
||||
/// Plex bietet keinen Rückkanal — es bleibt nur regelmäßiges Nachfragen.
|
||||
/// Das Intervall ist bewusst großzügig: der Weg über den Browser dauert
|
||||
/// ohnehin Sekunden, und sekündliches Anklopfen brächte nichts außer Last.
|
||||
public func waitForAuthorization(
|
||||
pin: PlexPin,
|
||||
pollInterval: Duration = .seconds(2),
|
||||
timeout: Duration = .seconds(300)
|
||||
) async throws(PlexError) -> String {
|
||||
// Über die Anzahl der Versuche statt über einen Zeitpunkt: eine
|
||||
// existenzielle `any Clock` gibt ihren Instant-Typ nicht preis, und
|
||||
// deshalb lassen sich zwei ihrer Zeitpunkte nicht vergleichen. Das
|
||||
// Ergebnis ist dasselbe, und in Tests reicht eine Uhr, die sofort
|
||||
// zurückkehrt.
|
||||
//
|
||||
// Nach oben begrenzt, damit ein versehentlich winziges Intervall nicht
|
||||
// in einen Überlauf oder Millionen Anfragen läuft.
|
||||
let attempts = pollInterval > .zero
|
||||
? min(1000, max(1, Int(timeout / pollInterval)))
|
||||
: 1
|
||||
|
||||
for _ in 0..<attempts {
|
||||
do {
|
||||
try await clock.sleep(for: pollInterval)
|
||||
} catch {
|
||||
// Abbruch durch die Oberfläche, etwa weil das Anmeldeblatt
|
||||
// geschlossen wurde. Kein Fehlerfall.
|
||||
throw .transport(description: "Anmeldung abgebrochen")
|
||||
}
|
||||
|
||||
let current = try await client.send(
|
||||
PlexRequest(path: "/api/v2/pins/\(pin.id)"),
|
||||
as: PlexPin.self
|
||||
)
|
||||
|
||||
if let token = current.authToken, !token.isEmpty {
|
||||
try? tokenStore.store(token, for: Self.accountTokenAccount)
|
||||
await client.setToken(token)
|
||||
return token
|
||||
}
|
||||
|
||||
if let expiresAt = current.expiresAt, expiresAt < Date() {
|
||||
throw .transport(description: "Der Anmeldecode ist abgelaufen")
|
||||
}
|
||||
}
|
||||
|
||||
throw .transport(description: "Zeit für die Anmeldung abgelaufen")
|
||||
}
|
||||
|
||||
/// Meldet das Gerät ab. Der Token verschwindet aus dem Schlüsselbund;
|
||||
/// am Konto bleibt der Eintrag stehen, bis er dort entfernt wird.
|
||||
///
|
||||
/// `nonisolated`, weil nur der Schlüsselbund angefasst wird — der ist
|
||||
/// selbst abgesichert. Die Oberfläche soll beim Abmelden nicht auf den
|
||||
/// Actor warten müssen, denn das kann mitten in einer laufenden Abfrage
|
||||
/// passieren.
|
||||
public nonisolated func signOut() {
|
||||
tokenStore.remove(for: Self.accountTokenAccount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// Der Anmeldecode aus dem PIN-Verfahren.
|
||||
///
|
||||
/// Plex legt auf Anfrage einen vierstelligen Code an. Der Nutzer bestätigt ihn
|
||||
/// im Browser bei plex.tv, danach trägt derselbe Datensatz den Token. Die App
|
||||
/// selbst bekommt nie Benutzername oder Passwort zu sehen — deshalb ist dieser
|
||||
/// Weg dem manuellen Eintippen eines Tokens vorzuziehen.
|
||||
public struct PlexPin: Sendable, Equatable, Decodable {
|
||||
public let id: Int
|
||||
public let code: String
|
||||
public let authToken: String?
|
||||
public let expiresAt: Date?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, code, authToken, expiresAt
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try values.decode(Int.self, forKey: .id)
|
||||
self.code = try values.decode(String.self, forKey: .code)
|
||||
self.authToken = try values.decodeIfPresent(String.self, forKey: .authToken)
|
||||
|
||||
// Plex liefert ISO-8601 mit Zeitzone. Ein eigener Formatierer statt
|
||||
// einer Decoder-Strategie, weil sonst alle anderen Datumsfelder in
|
||||
// dieser Antwort mitbetroffen wären.
|
||||
if let text = try values.decodeIfPresent(String.self, forKey: .expiresAt) {
|
||||
self.expiresAt = ISO8601DateFormatter().date(from: text)
|
||||
} else {
|
||||
self.expiresAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
public init(id: Int, code: String, authToken: String? = nil, expiresAt: Date? = nil) {
|
||||
self.id = id
|
||||
self.code = code
|
||||
self.authToken = authToken
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
|
||||
/// Die Adresse, unter der der Nutzer den Code bestätigt.
|
||||
///
|
||||
/// `forwardUrl` fehlt bewusst: der Rücksprung in die App wäre bequemer,
|
||||
/// verlangt aber ein eigenes URL-Schema. Solange die App den Code ohnehin
|
||||
/// im Hintergrund abfragt, merkt niemand einen Unterschied.
|
||||
public func authorizationURL(clientIdentifier: String, product: String) -> URL? {
|
||||
var components = URLComponents(string: "https://app.plex.tv/auth")
|
||||
components?.fragment = "?clientID=\(clientIdentifier)&code=\(code)"
|
||||
+ "&context%5Bdevice%5D%5Bproduct%5D=\(product.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? product)"
|
||||
return components?.url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Legt Tokens im Schlüsselbund ab.
|
||||
///
|
||||
/// Nicht in `UserDefaults`: ein Plex-Token gibt vollen Zugriff auf die
|
||||
/// Mediathek und bleibt gültig, bis er am Konto zurückgezogen wird. In den
|
||||
/// Voreinstellungen läge er als Klartext in einer Datei, die jedes Backup
|
||||
/// mitnimmt.
|
||||
public protocol TokenStoring: Sendable {
|
||||
func store(_ token: String, for account: String) throws
|
||||
func token(for account: String) -> String?
|
||||
func remove(for account: String)
|
||||
}
|
||||
|
||||
public struct KeychainTokenStore: TokenStoring {
|
||||
private let service: String
|
||||
|
||||
public init(service: String) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
public enum Failure: Error, Equatable {
|
||||
case keychain(status: OSStatus)
|
||||
}
|
||||
|
||||
public func store(_ token: String, for account: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: Data(token.utf8),
|
||||
// Ohne Gerätesperre kein Zugriff, und nicht in Backups übertragbar.
|
||||
// Ein Token, der auf einem anderen Gerät wieder auftaucht, wäre
|
||||
// ein stiller Zweitzugang zur Mediathek.
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
]
|
||||
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
let insert = query.merging(attributes) { current, _ in current }
|
||||
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else { throw Failure.keychain(status: addStatus) }
|
||||
default:
|
||||
throw Failure.keychain(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
public func token(for account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data
|
||||
else { return nil }
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func remove(for account: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Ablageort im Arbeitsspeicher — für Tests und SwiftUI-Vorschauen.
|
||||
public final class InMemoryTokenStore: TokenStoring, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var tokens: [String: String] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func store(_ token: String, for account: String) throws {
|
||||
lock.withLock { tokens[account] = token }
|
||||
}
|
||||
|
||||
public func token(for account: String) -> String? {
|
||||
lock.withLock { tokens[account] }
|
||||
}
|
||||
|
||||
public func remove(for account: String) {
|
||||
_ = lock.withLock { tokens.removeValue(forKey: account) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
/// Führt Anfragen gegen einen Host aus.
|
||||
///
|
||||
/// Es gibt zwei davon: einen für plex.tv (Anmeldung, Serverliste) und einen je
|
||||
/// Server. Sie unterscheiden sich nur in Basisadresse und Token — deshalb
|
||||
/// dieselbe Klasse statt zweier ähnlicher.
|
||||
public actor PlexHTTPClient {
|
||||
private let baseURL: URL
|
||||
private let identity: PlexClientIdentity
|
||||
private let transport: any PlexTransport
|
||||
private var token: String?
|
||||
|
||||
public init(
|
||||
baseURL: URL,
|
||||
identity: PlexClientIdentity,
|
||||
token: String? = nil,
|
||||
transport: any PlexTransport = URLSession.shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.identity = identity
|
||||
self.token = token
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
public func setToken(_ token: String?) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
public var currentToken: String? { token }
|
||||
|
||||
public func send(_ request: PlexRequest) async throws(PlexError) -> Data {
|
||||
let urlRequest = try request.urlRequest(baseURL: baseURL, identity: identity, token: token)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await transport.data(for: urlRequest)
|
||||
} catch let error as URLError {
|
||||
// Der Klartext von URLError nennt Zeitüberschreitung, fehlende
|
||||
// Verbindung oder Zertifikatsprobleme beim Namen. Das ist der
|
||||
// Unterschied zwischen einer brauchbaren Fehlermeldung und
|
||||
// „Etwas ist schiefgelaufen".
|
||||
throw .transport(description: error.localizedDescription)
|
||||
} catch {
|
||||
throw .transport(description: String(describing: error))
|
||||
}
|
||||
|
||||
return try PlexResponse.validate(data: data, response: response)
|
||||
}
|
||||
|
||||
public func send<T: Decodable & Sendable>(
|
||||
_ request: PlexRequest,
|
||||
as type: T.Type
|
||||
) async throws(PlexError) -> T {
|
||||
let data = try await send(request)
|
||||
return try PlexResponse.decode(type, from: data)
|
||||
}
|
||||
|
||||
/// Baut die Adresse eines Bildes, ohne es zu laden.
|
||||
///
|
||||
/// Plex kann Bilder serverseitig verkleinern — ein Poster in Kachelgröße
|
||||
/// statt in Originalauflösung spart bei einer Reihe aus zwanzig Titeln ein
|
||||
/// Vielfaches an Übertragung.
|
||||
public nonisolated func artworkURL(
|
||||
path: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
token: String
|
||||
) -> URL? {
|
||||
guard var components = URLComponents(
|
||||
url: baseURL.appending(path: "/photo/:/transcode"),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else { return nil }
|
||||
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "width", value: String(width)),
|
||||
URLQueryItem(name: "height", value: String(height)),
|
||||
URLQueryItem(name: "minSize", value: "1"),
|
||||
URLQueryItem(name: "upscale", value: "1"),
|
||||
URLQueryItem(name: "url", value: path),
|
||||
// Hier muss der Token ausnahmsweise in die Adresse: das Bild lädt
|
||||
// AsyncImage selbst, und dort lassen sich keine Kopfzeilen setzen.
|
||||
URLQueryItem(name: "X-Plex-Token", value: token),
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
/// Sucht den besten erreichbaren Weg zu einem Server.
|
||||
///
|
||||
/// Geht die Wege nach Rang durch — erst lokal, dann direkt von außen, zuletzt
|
||||
/// über die Vermittlung von Plex. Innerhalb eines Rangs laufen die Versuche
|
||||
/// gleichzeitig, weil sie sich gegenseitig nicht ausschließen und ein
|
||||
/// unerreichbarer Weg sonst die volle Zeitüberschreitung kostet, bevor der
|
||||
/// nächste überhaupt beginnt.
|
||||
public struct PlexConnectionSelector: Sendable {
|
||||
private let identity: PlexClientIdentity
|
||||
private let transport: any PlexTransport
|
||||
private let probeTimeout: Duration
|
||||
|
||||
public init(
|
||||
identity: PlexClientIdentity,
|
||||
transport: any PlexTransport = URLSession.shared,
|
||||
probeTimeout: Duration = .seconds(5)
|
||||
) {
|
||||
self.identity = identity
|
||||
self.transport = transport
|
||||
self.probeTimeout = probeTimeout
|
||||
}
|
||||
|
||||
public func selectConnection(for resource: PlexResource) async -> PlexConnection? {
|
||||
guard let token = resource.accessToken else { return nil }
|
||||
|
||||
let tiers = Dictionary(grouping: resource.connections, by: \.tier)
|
||||
for tier in tiers.keys.sorted() {
|
||||
guard let candidates = tiers[tier] else { continue }
|
||||
if let winner = await race(candidates, token: token, expecting: resource.clientIdentifier) {
|
||||
return winner
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Lässt die Wege eines Rangs gegeneinander antreten; der erste, der sich
|
||||
/// korrekt ausweist, gewinnt.
|
||||
private func race(
|
||||
_ connections: [PlexConnection],
|
||||
token: String,
|
||||
expecting clientIdentifier: String
|
||||
) async -> PlexConnection? {
|
||||
await withTaskGroup(of: PlexConnection?.self) { group in
|
||||
for connection in connections {
|
||||
group.addTask {
|
||||
await probe(connection, token: token, expecting: clientIdentifier) ? connection : nil
|
||||
}
|
||||
}
|
||||
|
||||
for await result in group {
|
||||
if let result {
|
||||
// Die übrigen Versuche interessieren nicht mehr. Ohne das
|
||||
// Abbrechen liefe eine unerreichbare Adresse noch bis zur
|
||||
// Zeitüberschreitung weiter und hielte die Aufgabe offen.
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Fragt `/identity` ab — der einzige Endpunkt, der ohne Token antwortet
|
||||
/// und die Kennung des Servers nennt.
|
||||
private func probe(
|
||||
_ connection: PlexConnection,
|
||||
token: String,
|
||||
expecting clientIdentifier: String
|
||||
) async -> Bool {
|
||||
guard let baseURL = connection.url else { return false }
|
||||
|
||||
let client = PlexHTTPClient(
|
||||
baseURL: baseURL,
|
||||
identity: identity,
|
||||
token: token,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
do {
|
||||
let identityResponse = try await client.send(
|
||||
PlexRequest(path: "/identity"),
|
||||
as: PlexIdentity.self
|
||||
)
|
||||
// Der Abgleich der Kennung ist keine Förmlichkeit: eine veraltete
|
||||
// plex.direct-Adresse kann auf einen fremden Server zeigen, und
|
||||
// ohne Prüfung würde die App fremde Mediatheken anzeigen.
|
||||
return identityResponse.machineIdentifier == clientIdentifier
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Antwort von `/identity`.
|
||||
public struct PlexIdentity: Sendable, Decodable {
|
||||
public let machineIdentifier: String
|
||||
public let version: String?
|
||||
|
||||
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
|
||||
private enum InnerKeys: String, CodingKey { case machineIdentifier, version }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let outer = try decoder.container(keyedBy: OuterKeys.self)
|
||||
let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer)
|
||||
self.machineIdentifier = try inner.decode(String.self, forKey: .machineIdentifier)
|
||||
self.version = try inner.decodeIfPresent(String.self, forKey: .version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Gerät, das am Plex-Konto hängt — für uns nur Server interessant.
|
||||
public struct PlexResource: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let name: String
|
||||
public let product: String?
|
||||
public let productVersion: String?
|
||||
public let platform: String?
|
||||
public let clientIdentifier: String
|
||||
public let provides: String
|
||||
public let owned: Bool
|
||||
/// Der Token für genau diesen Server.
|
||||
///
|
||||
/// Nicht derselbe wie der Konto-Token: bei geteilten Servern bekommt man
|
||||
/// einen eigenen, eingeschränkten. Wer stattdessen den Konto-Token
|
||||
/// verwendet, bekommt bei fremden Servern wortlos 401.
|
||||
public let accessToken: String?
|
||||
public let connections: [PlexConnection]
|
||||
|
||||
public var id: String { clientIdentifier }
|
||||
|
||||
/// Ob es sich um einen Medienserver handelt — `provides` kann auch
|
||||
/// „client", „player" oder eine Kombination sein.
|
||||
public var isServer: Bool {
|
||||
provides.split(separator: ",").contains("server")
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, product, productVersion, platform, clientIdentifier
|
||||
case provides, owned, accessToken, connections
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try values.decode(String.self, forKey: .name)
|
||||
self.product = try values.decodeIfPresent(String.self, forKey: .product)
|
||||
self.productVersion = try values.decodeIfPresent(String.self, forKey: .productVersion)
|
||||
self.platform = try values.decodeIfPresent(String.self, forKey: .platform)
|
||||
self.clientIdentifier = try values.decode(String.self, forKey: .clientIdentifier)
|
||||
self.provides = try values.decodeIfPresent(String.self, forKey: .provides) ?? ""
|
||||
self.owned = try values.decodeIfPresent(Bool.self, forKey: .owned) ?? false
|
||||
self.accessToken = try values.decodeIfPresent(String.self, forKey: .accessToken)
|
||||
self.connections = try values.decodeIfPresent([PlexConnection].self, forKey: .connections) ?? []
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
clientIdentifier: String,
|
||||
provides: String = "server",
|
||||
owned: Bool = true,
|
||||
accessToken: String? = nil,
|
||||
connections: [PlexConnection] = [],
|
||||
product: String? = nil,
|
||||
productVersion: String? = nil,
|
||||
platform: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.product = product
|
||||
self.productVersion = productVersion
|
||||
self.platform = platform
|
||||
self.clientIdentifier = clientIdentifier
|
||||
self.provides = provides
|
||||
self.owned = owned
|
||||
self.accessToken = accessToken
|
||||
self.connections = connections
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Weg zu einem Server.
|
||||
public struct PlexConnection: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let uri: String
|
||||
public let address: String?
|
||||
public let port: Int?
|
||||
public let local: Bool
|
||||
/// Ob der Weg über die Vermittlung von Plex läuft. Der ist immer
|
||||
/// erreichbar, aber auf wenige Mbit gedrosselt — taugt für Metadaten,
|
||||
/// nicht für 4K.
|
||||
public let relay: Bool
|
||||
public let `protocol`: String?
|
||||
public let iPv6: Bool
|
||||
|
||||
public var id: String { uri }
|
||||
public var url: URL? { URL(string: uri) }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case uri, address, port, local, relay
|
||||
case `protocol`
|
||||
case iPv6 = "IPv6"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.uri = try values.decode(String.self, forKey: .uri)
|
||||
self.address = try values.decodeIfPresent(String.self, forKey: .address)
|
||||
self.port = try values.decodeIfPresent(Int.self, forKey: .port)
|
||||
self.local = try values.decodeIfPresent(Bool.self, forKey: .local) ?? false
|
||||
self.relay = try values.decodeIfPresent(Bool.self, forKey: .relay) ?? false
|
||||
self.protocol = try values.decodeIfPresent(String.self, forKey: .protocol)
|
||||
self.iPv6 = try values.decodeIfPresent(Bool.self, forKey: .iPv6) ?? false
|
||||
}
|
||||
|
||||
public init(
|
||||
uri: String,
|
||||
local: Bool = false,
|
||||
relay: Bool = false,
|
||||
address: String? = nil,
|
||||
port: Int? = nil,
|
||||
protocol: String? = nil,
|
||||
iPv6: Bool = false
|
||||
) {
|
||||
self.uri = uri
|
||||
self.local = local
|
||||
self.relay = relay
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.protocol = `protocol`
|
||||
self.iPv6 = iPv6
|
||||
}
|
||||
|
||||
/// In welcher Reihenfolge die Wege probiert werden.
|
||||
///
|
||||
/// Nicht „wer zuerst antwortet, gewinnt": die Vermittlung von Plex meldet
|
||||
/// sich oft schneller als der Server im eigenen Netz, ist aber gedrosselt.
|
||||
/// Ein Rennen ohne Rangordnung würde also ausgerechnet den langsamsten Weg
|
||||
/// prämieren.
|
||||
public var tier: Int {
|
||||
if local { return 0 }
|
||||
if relay { return 2 }
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Holt die Serverliste vom Konto.
|
||||
public actor PlexServerDirectory {
|
||||
private let client: PlexHTTPClient
|
||||
|
||||
public init(
|
||||
identity: PlexClientIdentity,
|
||||
token: String,
|
||||
transport: any PlexTransport = URLSession.shared
|
||||
) {
|
||||
self.client = PlexHTTPClient(
|
||||
baseURL: URL(string: "https://plex.tv")!,
|
||||
identity: identity,
|
||||
token: token,
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
/// Alle Medienserver, auf die das Konto Zugriff hat — eigene und geteilte.
|
||||
public func servers() async throws(PlexError) -> [PlexResource] {
|
||||
let resources = try await client.send(
|
||||
PlexRequest(path: "/api/v2/resources", query: [
|
||||
// Ohne diese beiden Schalter fehlen in der Antwort die
|
||||
// plex.direct-Adressen und die Vermittlung — übrig bliebe nur
|
||||
// die lokale IP, und außer Haus ginge gar nichts.
|
||||
URLQueryItem(name: "includeHttps", value: "1"),
|
||||
URLQueryItem(name: "includeRelay", value: "1"),
|
||||
]),
|
||||
as: [PlexResource].self
|
||||
)
|
||||
return resources.filter(\.isServer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// Filter und Sortierung einer Mediatheksansicht.
|
||||
///
|
||||
/// Wird zu Abfrageparametern für Plex — die Filterung passiert also auf dem
|
||||
/// Server. Bei zehntausend Titeln wäre alles andere aussichtslos: die Liste
|
||||
/// erst vollständig zu laden, um sie dann lokal zu durchsuchen, dauert
|
||||
/// Minuten und belegt entsprechend Speicher.
|
||||
public struct LibraryQuery: Sendable, Equatable {
|
||||
public enum Sort: String, Sendable, Equatable, CaseIterable {
|
||||
case titleAscending = "titleSort:asc"
|
||||
case titleDescending = "titleSort:desc"
|
||||
case recentlyAdded = "addedAt:desc"
|
||||
case releaseDate = "originallyAvailableAt:desc"
|
||||
case rating = "rating:desc"
|
||||
case lastViewed = "lastViewedAt:desc"
|
||||
}
|
||||
|
||||
public var sort: Sort
|
||||
public var unwatchedOnly: Bool
|
||||
public var genre: String?
|
||||
public var year: Int?
|
||||
public var contentRating: String?
|
||||
public var resolution: String?
|
||||
|
||||
public init(
|
||||
sort: Sort = .titleAscending,
|
||||
unwatchedOnly: Bool = false,
|
||||
genre: String? = nil,
|
||||
year: Int? = nil,
|
||||
contentRating: String? = nil,
|
||||
resolution: String? = nil
|
||||
) {
|
||||
self.sort = sort
|
||||
self.unwatchedOnly = unwatchedOnly
|
||||
self.genre = genre
|
||||
self.year = year
|
||||
self.contentRating = contentRating
|
||||
self.resolution = resolution
|
||||
}
|
||||
|
||||
/// Ob überhaupt gefiltert wird — steuert, ob die Oberfläche den
|
||||
/// Filterknopf hervorhebt.
|
||||
public var isFiltering: Bool {
|
||||
unwatchedOnly || genre != nil || year != nil || contentRating != nil || resolution != nil
|
||||
}
|
||||
|
||||
func queryItems(libraryKind: PlexLibrary.Kind?) -> [URLQueryItem] {
|
||||
var items: [URLQueryItem] = [URLQueryItem(name: "sort", value: sort.rawValue)]
|
||||
|
||||
// Plex nummeriert die Arten: 1 Film, 2 Serie, 4 Folge. Ohne diese
|
||||
// Angabe liefert eine Serienmediathek alle Folgen einzeln statt der
|
||||
// Serien.
|
||||
switch libraryKind {
|
||||
case .movie: items.append(URLQueryItem(name: "type", value: "1"))
|
||||
case .show: items.append(URLQueryItem(name: "type", value: "2"))
|
||||
default: break
|
||||
}
|
||||
|
||||
if unwatchedOnly { items.append(URLQueryItem(name: "unwatched", value: "1")) }
|
||||
if let genre { items.append(URLQueryItem(name: "genre", value: genre)) }
|
||||
if let year { items.append(URLQueryItem(name: "year", value: String(year))) }
|
||||
if let contentRating { items.append(URLQueryItem(name: "contentRating", value: contentRating)) }
|
||||
if let resolution { items.append(URLQueryItem(name: "resolution", value: resolution)) }
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Ausschnitt aus einer längeren Liste.
|
||||
public struct PlexPage<Element: Sendable>: Sendable {
|
||||
public let items: [Element]
|
||||
public let offset: Int
|
||||
/// Wie viele Einträge es insgesamt gibt.
|
||||
public let totalSize: Int
|
||||
|
||||
public init(items: [Element], offset: Int, totalSize: Int) {
|
||||
self.items = items
|
||||
self.offset = offset
|
||||
self.totalSize = totalSize
|
||||
}
|
||||
|
||||
public var hasMore: Bool { offset + items.count < totalSize }
|
||||
public var nextOffset: Int { offset + items.count }
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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 {
|
||||
public let resource: PlexResource
|
||||
public let connection: PlexConnection
|
||||
fileprivate let client: PlexHTTPClient
|
||||
private 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
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import Foundation
|
||||
/// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf
|
||||
/// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das
|
||||
/// Aufteilen bei jedem Abruf.
|
||||
public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
|
||||
/// Nur `Decodable`: geschrieben wird dieser Typ nirgends. Der Offline-Bestand
|
||||
/// in Phase 3 bekommt ein eigenes SwiftData-Modell, weil er andere Felder
|
||||
/// braucht — lokaler Dateipfad, Ladezeitpunkt — und andere nicht.
|
||||
public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
|
||||
public enum Kind: String, Sendable, Codable {
|
||||
case movie, show, season, episode, clip, collection
|
||||
}
|
||||
@@ -58,6 +61,15 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
|
||||
|
||||
public let media: [PlexMedia]
|
||||
|
||||
// Schlagwörter. Plex liefert sie nur bei der Einzelabfrage eines Titels
|
||||
// mit, nicht in Listen — deshalb sind sie dort leer.
|
||||
public let genres: [PlexTag]
|
||||
public let directors: [PlexTag]
|
||||
public let writers: [PlexTag]
|
||||
/// Die Besetzung, mit Porträt und Rollennamen.
|
||||
public let roles: [PlexTag]
|
||||
public let markers: [PlexMarker]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ratingKey, key, guid, type, title, titleSort, summary, year, duration
|
||||
case contentRating, rating, audienceRating
|
||||
@@ -67,6 +79,11 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
|
||||
case viewCount, viewOffset, lastViewedAt, addedAt
|
||||
case leafCount, viewedLeafCount, childCount
|
||||
case media = "Media"
|
||||
case genres = "Genre"
|
||||
case directors = "Director"
|
||||
case writers = "Writer"
|
||||
case roles = "Role"
|
||||
case markers = "Marker"
|
||||
}
|
||||
|
||||
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen
|
||||
@@ -112,6 +129,11 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
|
||||
self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount)
|
||||
|
||||
self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? []
|
||||
self.genres = try values.decodeIfPresent([PlexTag].self, forKey: .genres) ?? []
|
||||
self.directors = try values.decodeIfPresent([PlexTag].self, forKey: .directors) ?? []
|
||||
self.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? []
|
||||
self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? []
|
||||
self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? []
|
||||
}
|
||||
|
||||
/// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt
|
||||
@@ -162,6 +184,7 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decod
|
||||
case size, totalSize, offset, title1, title2
|
||||
case metadata = "Metadata"
|
||||
case directory = "Directory"
|
||||
case hub = "Hub"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -174,13 +197,15 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decod
|
||||
self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1)
|
||||
self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2)
|
||||
|
||||
// `Metadata` bei Titeln, `Directory` bei Bibliotheken und Staffeln.
|
||||
// Eine leere Mediathek liefert gar kein Feld — das ist kein Fehler,
|
||||
// sondern bedeutet schlicht null Einträge.
|
||||
// `Metadata` bei Titeln, `Directory` bei Mediatheken, `Hub` bei den
|
||||
// Reihen der Startseite. Eine leere Mediathek liefert gar kein Feld —
|
||||
// das ist kein Fehler, sondern bedeutet schlicht null Einträge.
|
||||
if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) {
|
||||
self.content = metadata
|
||||
} else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) {
|
||||
self.content = directory
|
||||
} else if let hub = try inner.decodeIfPresent([Content].self, forKey: .hub) {
|
||||
self.content = hub
|
||||
} else {
|
||||
self.content = []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine Mediathek auf dem Server.
|
||||
public struct PlexLibrary: Sendable, Equatable, Decodable, Identifiable {
|
||||
public enum Kind: String, Sendable, Codable {
|
||||
case movie, show, artist, photo
|
||||
/// Live-TV und Aufnahmen.
|
||||
case livetv
|
||||
}
|
||||
|
||||
/// Die Nummer des Abschnitts, als Zeichenkette. Steckt in fast jedem
|
||||
/// weiteren Pfad.
|
||||
public let key: String
|
||||
public let title: String
|
||||
public let type: String
|
||||
public let uuid: String?
|
||||
public let art: String?
|
||||
public let thumb: String?
|
||||
public let updatedAt: Int?
|
||||
|
||||
public var id: String { key }
|
||||
public var kind: Kind? { Kind(rawValue: type) }
|
||||
|
||||
/// Ob Flixy diese Mediathek darstellen kann. Musik und Fotos sind laut
|
||||
/// Plan nicht vorgesehen — sie erscheinen deshalb gar nicht erst in der
|
||||
/// Seitenleiste, statt dort auf einen leeren Bildschirm zu führen.
|
||||
public var isSupported: Bool {
|
||||
kind == .movie || kind == .show
|
||||
}
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
title: String,
|
||||
type: String,
|
||||
uuid: String? = nil,
|
||||
art: String? = nil,
|
||||
thumb: String? = nil,
|
||||
updatedAt: Int? = nil
|
||||
) {
|
||||
self.key = key
|
||||
self.title = title
|
||||
self.type = type
|
||||
self.uuid = uuid
|
||||
self.art = art
|
||||
self.thumb = thumb
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Reihe auf der Startseite.
|
||||
///
|
||||
/// Plex nennt sie „Hub" und liefert sie fertig zusammengestellt — Weiterschauen,
|
||||
/// Zuletzt hinzugefügt, Sammlungen. Die App muss diese Reihen also nicht selbst
|
||||
/// bauen, sondern nur darstellen.
|
||||
public struct PlexHub: Sendable, Equatable, Decodable, Identifiable {
|
||||
/// Stabiler Bezeichner wie `home.continue` oder `movie.recentlyadded`.
|
||||
/// Danach richtet sich die Sortierung in den Einstellungen.
|
||||
public let hubIdentifier: String
|
||||
public let title: String
|
||||
public let type: String?
|
||||
public let key: String?
|
||||
public let size: Int?
|
||||
/// Ob es mehr Einträge gibt, als die Reihe zeigt — Grundlage für ein
|
||||
/// „Alle anzeigen" am Ende.
|
||||
public let more: Bool
|
||||
public let items: [PlexItem]
|
||||
|
||||
public var id: String { hubIdentifier }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case hubIdentifier, title, type, key, size, more
|
||||
case items = "Metadata"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.title = try values.decode(String.self, forKey: .title)
|
||||
// Ältere Serverfassungen liefern keinen `hubIdentifier`. Der Titel ist
|
||||
// dann der einzige verfügbare Schlüssel — schlechter, aber besser als
|
||||
// eine Antwort, die gar nicht lesbar ist.
|
||||
self.hubIdentifier = try values.decodeIfPresent(String.self, forKey: .hubIdentifier) ?? title
|
||||
self.type = try values.decodeIfPresent(String.self, forKey: .type)
|
||||
self.key = try values.decodeIfPresent(String.self, forKey: .key)
|
||||
self.size = try values.decodeIfPresent(Int.self, forKey: .size)
|
||||
self.more = try values.decodeIfPresent(Bool.self, forKey: .more) ?? false
|
||||
self.items = try values.decodeIfPresent([PlexItem].self, forKey: .items) ?? []
|
||||
}
|
||||
|
||||
public init(
|
||||
hubIdentifier: String,
|
||||
title: String,
|
||||
type: String? = nil,
|
||||
key: String? = nil,
|
||||
size: Int? = nil,
|
||||
more: Bool = false,
|
||||
items: [PlexItem] = []
|
||||
) {
|
||||
self.hubIdentifier = hubIdentifier
|
||||
self.title = title
|
||||
self.type = type
|
||||
self.key = key
|
||||
self.size = size
|
||||
self.more = more
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Schlagwort am Titel: Genre, Regie, Rolle, Land.
|
||||
///
|
||||
/// Plex gibt sie alle in derselben Form aus und unterscheidet nur über den
|
||||
/// Feldnamen. `role` ist dabei nur bei Besetzungseinträgen gefüllt und trägt
|
||||
/// den Namen der gespielten Figur.
|
||||
public struct PlexTag: Sendable, Equatable, Hashable, Decodable, Identifiable {
|
||||
public let tagKey: String?
|
||||
public let tag: String
|
||||
public let role: String?
|
||||
public let thumb: String?
|
||||
|
||||
private let rawID: Int?
|
||||
|
||||
/// Eigene Kennung statt `id` vom Server: Genres kommen ohne, und zwei
|
||||
/// Einträge ohne Kennung würden in `ForEach` zusammenfallen.
|
||||
public var id: String { tagKey ?? rawID.map(String.init) ?? tag }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rawID = "id"
|
||||
case tagKey, tag, role, thumb
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.rawID = try values.decodeIfPresent(Int.self, forKey: .rawID)
|
||||
self.tagKey = try values.decodeIfPresent(String.self, forKey: .tagKey)
|
||||
self.tag = try values.decode(String.self, forKey: .tag)
|
||||
self.role = try values.decodeIfPresent(String.self, forKey: .role)
|
||||
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
|
||||
}
|
||||
|
||||
public init(tag: String, role: String? = nil, thumb: String? = nil, tagKey: String? = nil) {
|
||||
self.rawID = nil
|
||||
self.tagKey = tagKey
|
||||
self.tag = tag
|
||||
self.role = role
|
||||
self.thumb = thumb
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Sprungmarke in einer Datei: Intro oder Abspann.
|
||||
///
|
||||
/// Wird schon in Phase 1 mitgelesen, obwohl sie erst in Phase 2 ausgewertet
|
||||
/// wird — sie kostet nichts und erspart später eine zweite Anfrage.
|
||||
public struct PlexMarker: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let id: Int
|
||||
public let type: String
|
||||
/// In Millisekunden ab Beginn.
|
||||
public let startTimeOffset: Int
|
||||
public let endTimeOffset: Int
|
||||
|
||||
public var isIntro: Bool { type == "intro" }
|
||||
public var isCredits: Bool { type == "credits" }
|
||||
|
||||
public init(id: Int, type: String, startTimeOffset: Int, endTimeOffset: Int) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.startTimeOffset = startTimeOffset
|
||||
self.endTimeOffset = endTimeOffset
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user