Erster Schritt zu Jellyfin, und er besteht vor allem aus Wegnehmen. `PlaybackDecider` nahm bisher Plex-Modelle entgegen und las daraus sechs Felder. Das band die Entscheidung an einen bestimmten Server, obwohl an der Frage „kann iOS das öffnen?" nichts serverspezifisch ist. Jetzt nimmt er ein `MediaProfile` — Container, Codecs, Bitrate, ob die Datei untersucht ist. Wie ernst die Kopplung war, zeigte der Player selbst: für die Offline-Wiedergabe baute er sich **erfundene** Plex-Werte zusammen — eine Media mit Kennung 0, ein Part mit dem Dateinamen als Schlüssel, zusammengesetzte Spurlisten —, nur damit die Signatur passte. Für eine Datei, die längst auf dem Gerät lag und mit Plex nichts mehr zu tun hatte. Dieser Hack ist weg. Neu ist das Target `MediaKit`: Typen und Rechnungen, die für jeden Server gelten, ohne eine einzige Abhängigkeit. Darin `MediaProfile` und die `Duration`-Brücke, die bisher in PlexKit stand — „weil es die unterste Schicht ist". Das stimmte, solange es nur einen Server gab. Damit kennen Wiedergabe und Downloads Plex nicht mehr. Zwei der Abhängigkeiten waren ohnehin tot; die dritte trug nur diese Brücke. Die wichtigste Testtabelle des Projekts ist mitgewandert und dabei kürzer geworden: dort steht jetzt, worauf es ankommt, statt vollständiger Modelle mit Kennungen und Dateipfaden, von denen niemand etwas las. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
132 lines
5.2 KiB
Swift
132 lines
5.2 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// 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
|
|
)
|
|
}
|
|
|
|
/// Fragt einmal nach, ob der Code inzwischen bestätigt wurde.
|
|
///
|
|
/// Gibt `nil` zurück, solange er es nicht ist. Für Aufrufer, die ihre
|
|
/// eigene Schleife führen — etwa weil sie nebenher noch auf das Schließen
|
|
/// eines Anmeldefensters achten müssen.
|
|
public func token(for pin: PlexPin) async throws(PlexError) -> String? {
|
|
let current = try await client.send(
|
|
PlexRequest(path: "/api/v2/pins/\(pin.id)"),
|
|
as: PlexPin.self
|
|
)
|
|
guard let token = current.authToken, !token.isEmpty else { return nil }
|
|
|
|
try? tokenStore.store(token, for: Self.accountTokenAccount)
|
|
await client.setToken(token)
|
|
return token
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Die Wartezeit ist an die Gültigkeit des Codes angelehnt. Kürzer wäre
|
|
/// verkehrt herum: die App gäbe auf, obwohl der Code noch gilt, und wer
|
|
/// zwischendurch etwas anderes gemacht hat, stünde vor „Zeit abgelaufen"
|
|
/// bei einem Code, der funktioniert hätte.
|
|
public func waitForAuthorization(
|
|
pin: PlexPin,
|
|
pollInterval: Duration = .seconds(2),
|
|
timeout: Duration = .seconds(900)
|
|
) 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)
|
|
}
|
|
}
|