iPad meldete sich nie an Die Ursache ist unangenehm speziell: Wer im Browser schon bei Plex angemeldet ist, wird von app.plex.tv/auth sofort auf watch.plex.tv weitergeschickt — OHNE dass der Code verknüpft wird. Man sieht sich angemeldet, die App wartet vergeblich, und es gibt keinerlei Hinweis. Jetzt wird plex.tv/link mit eingetragenem Code geöffnet. Diese Seite tut nur eine Sache: den Code bestätigen. Die Gerätekennung steckt ohnehin schon im PIN. Dazu ein Ausweg: bleibt es nach fünfzehn Sekunden ohne Token, erscheint der Code im Anmeldebildschirm zum Abtippen. Schließt man das Fenster ohne Erfolg, bleibt er stehen statt abzumelden — er gilt weiter. Download fragte nicht nach der Qualität Jetzt Original, Full HD, HD oder Sparsam, jeweils mit geschätzter Größe. Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein sagt nicht, ob es aufs Telefon passt. Die komprimierten Stufen laufen über den Universal-Transcoder und sind weiterhin NICHT am Server geprüft; steht so im Quelltext. Schwarzes Bild ohne Anzeige Der Player kannte nur „spielt" und „spielt nicht". Wartete er auf Daten, stand man vor einem Standbild ohne Hinweis. Es gibt jetzt einen eigenen Pufferzustand mit Anzeige. Aufmacher war beschnitten Er stand auf 3:4, Plex liefert als `art` aber 16:9 — mit scaledToFill ging links und rechts je ein Viertel verloren, ohne dass man es dem Bild ansah. Die Seitenverhältnisse liegen jetzt an einer Stelle in FlixyAspectRatio. Schwarze Leiste über dem Aufmacher .ultraThinMaterial statt undurchsichtigem Grund: das Bild läuft unscharf hinter der Leiste weiter, statt an einer harten Kante zu enden. Weichgezeichnet, nicht abgedunkelt. 139 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
375 lines
14 KiB
Swift
375 lines
14 KiB
Swift
import Foundation
|
|
import Observation
|
|
import PlexKit
|
|
|
|
/// Der Zustand der Verbindung zum Server.
|
|
///
|
|
/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß,
|
|
/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort,
|
|
/// an dem sich entscheidet, was auf dem Bildschirm erscheint.
|
|
@MainActor
|
|
@Observable
|
|
final class FlixySession {
|
|
enum Phase: Equatable {
|
|
/// Beim Start: liegt ein Token im Schlüsselbund?
|
|
case launching
|
|
case signedOut
|
|
/// Das Anmeldefenster von Plex ist offen.
|
|
case authenticating
|
|
/// Mehrere Server am Konto — einer muss gewählt werden.
|
|
case choosingServer([PlexResource])
|
|
/// Ein Weg zum Server wird gesucht.
|
|
case connecting(serverName: String)
|
|
case ready
|
|
case failed(message: String)
|
|
|
|
static func == (lhs: Phase, rhs: Phase) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready):
|
|
true
|
|
case (.authenticating, .authenticating):
|
|
true
|
|
case (.choosingServer(let l), .choosingServer(let r)):
|
|
l.map(\.clientIdentifier) == r.map(\.clientIdentifier)
|
|
case (.connecting(let l), .connecting(let r)):
|
|
l == r
|
|
case (.failed(let l), .failed(let r)):
|
|
l == r
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum Key {
|
|
static let preferredServer = "flixy.server.clientIdentifier"
|
|
static let cachedConnectionURI = "flixy.server.connectionURI"
|
|
static let cachedServerName = "flixy.server.name"
|
|
static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal"
|
|
}
|
|
|
|
/// Unter welchem Namen der Server-Token im Schlüsselbund liegt.
|
|
///
|
|
/// Nicht in den Voreinstellungen: er gibt vollen Zugriff auf die
|
|
/// Mediathek, genau wie der Konto-Token.
|
|
private static let serverTokenAccount = "plex.server"
|
|
|
|
|
|
/// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den
|
|
/// bestätigten Code darüber diesem Gerät zu.
|
|
var clientIdentifier: String { identity.clientIdentifier }
|
|
|
|
/// Der Code der laufenden Anmeldung.
|
|
///
|
|
/// Wird nach kurzer Zeit im Anmeldebildschirm eingeblendet — als Ausweg,
|
|
/// falls die Browser-Seite den Code nicht verknüpft. Das passiert, wenn
|
|
/// man dort schon angemeldet ist, und ohne diesen Ausweg säße man fest.
|
|
private(set) var pendingCode: String?
|
|
private(set) var showsManualCode = false
|
|
|
|
private(set) var phase: Phase = .launching
|
|
private(set) var server: PlexServer?
|
|
/// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos
|
|
/// erscheinen gar nicht erst.
|
|
private(set) var libraries: [PlexLibrary] = []
|
|
|
|
private let identity: PlexClientIdentity
|
|
private let tokenStore: any TokenStoring
|
|
private let authenticator: PlexAuthenticator
|
|
private let webAuthenticator = PlexWebAuthenticator()
|
|
private let defaults: UserDefaults
|
|
private var authorizationTask: Task<Void, Never>?
|
|
|
|
init(
|
|
identity: PlexClientIdentity,
|
|
tokenStore: any TokenStoring,
|
|
defaults: UserDefaults = .standard
|
|
) {
|
|
self.identity = identity
|
|
self.tokenStore = tokenStore
|
|
self.defaults = defaults
|
|
self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore)
|
|
}
|
|
|
|
// MARK: - Start
|
|
|
|
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
|
|
func restore() async {
|
|
guard let token = authenticator.storedToken else {
|
|
phase = .signedOut
|
|
return
|
|
}
|
|
|
|
// Erst der kurze Weg: die Verbindung, die beim letzten Mal
|
|
// funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an
|
|
// plex.tv plus eine neue Verbindungssuche — zusammen mehrere
|
|
// Sekunden, in denen nur ein Ladekreis zu sehen ist.
|
|
if await restoreFromRememberedConnection() { return }
|
|
|
|
await connect(accountToken: token)
|
|
}
|
|
|
|
/// Nimmt die zuletzt funktionierende Verbindung wieder auf.
|
|
///
|
|
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Server nicht
|
|
/// mehr darüber erreichbar ist — dann übernimmt die vollständige Suche.
|
|
/// Das ist der Fall, wenn man von zuhause weggeht: die lokale Adresse
|
|
/// antwortet nicht mehr, und der direkte Weg muss neu gefunden werden.
|
|
private func restoreFromRememberedConnection() async -> Bool {
|
|
guard let identifier = defaults.string(forKey: Key.preferredServer),
|
|
let uri = defaults.string(forKey: Key.cachedConnectionURI),
|
|
let serverToken = tokenStore.token(for: Self.serverTokenAccount)
|
|
else { return false }
|
|
|
|
let name = defaults.string(forKey: Key.cachedServerName) ?? ""
|
|
phase = .connecting(serverName: name)
|
|
|
|
let connection = PlexConnection(
|
|
uri: uri,
|
|
local: defaults.bool(forKey: Key.cachedConnectionIsLocal)
|
|
)
|
|
let resource = PlexResource(
|
|
name: name,
|
|
clientIdentifier: identifier,
|
|
accessToken: serverToken,
|
|
connections: [connection]
|
|
)
|
|
|
|
// Kurze Zeitgrenze: wenn die gemerkte Adresse nicht sofort antwortet,
|
|
// ist die vollständige Suche schneller als weiteres Warten.
|
|
let selector = PlexConnectionSelector(identity: identity, probeTimeout: .seconds(2))
|
|
guard await selector.selectConnection(for: resource) != nil,
|
|
let server = PlexServer(resource: resource, connection: connection, identity: identity)
|
|
else { return false }
|
|
|
|
self.server = server
|
|
do {
|
|
libraries = try await server.libraries().filter(\.isSupported)
|
|
phase = .ready
|
|
return true
|
|
} catch {
|
|
self.server = nil
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Anmeldung
|
|
|
|
/// Öffnet die Anmeldeseite von Plex.
|
|
///
|
|
/// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem
|
|
/// Plex einen Token herausgibt. Sichtbar ist er nicht mehr.
|
|
///
|
|
/// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den
|
|
/// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet
|
|
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen
|
|
/// — der Token liegt längst bereit, aber die App wartete auf einen
|
|
/// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server
|
|
/// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde.
|
|
func beginSignIn() async {
|
|
phase = .authenticating
|
|
|
|
let pin: PlexPin
|
|
do {
|
|
pin = try await authenticator.requestPin()
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
return
|
|
}
|
|
|
|
guard let url = pin.linkURL else {
|
|
phase = .failed(message: String(localized: "signIn.windowFailed"))
|
|
return
|
|
}
|
|
|
|
pendingCode = pin.code
|
|
showsManualCode = false
|
|
webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
|
|
|
|
// Die Frist entspricht der Gültigkeit des Codes bei Plex.
|
|
for second in 0..<900 {
|
|
try? await Task.sleep(for: .seconds(1))
|
|
|
|
// Nach einer Viertelminute den Code zeigen. Wer bis dahin nicht
|
|
// durch ist, hat vermutlich den Fall erwischt, in dem die Seite
|
|
// nicht verknüpft — dann hilft nur noch das Abtippen.
|
|
if second == 15 { showsManualCode = true }
|
|
|
|
if let token = try? await authenticator.token(for: pin) {
|
|
webAuthenticator.dismiss()
|
|
clearPendingCode()
|
|
await connect(accountToken: token)
|
|
return
|
|
}
|
|
|
|
// Fenster geschlossen, ohne dass ein Token da ist.
|
|
if webAuthenticator.didReturn {
|
|
switch webAuthenticator.outcome {
|
|
case .cancelled, nil:
|
|
// Abgebrochen oder zurückgesprungen, ohne dass der Token
|
|
// schon bereitlag. Noch ein paar Sekunden nachfragen,
|
|
// bevor aufgegeben wird — zwischen Rücksprung und Eintrag
|
|
// bei Plex liegt manchmal eine Sekunde.
|
|
if let token = try? await graceTokenLookup(pin: pin) {
|
|
clearPendingCode()
|
|
await connect(accountToken: token)
|
|
return
|
|
}
|
|
// Fenster zu, kein Token: nicht abmelden, sondern den Code
|
|
// stehen lassen. Er gilt weiter, und das Abtippen bei
|
|
// plex.tv/link ist jetzt der schnellste Weg.
|
|
showsManualCode = true
|
|
case .presentationFailed(let description):
|
|
phase = .failed(message: description)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
webAuthenticator.dismiss()
|
|
clearPendingCode()
|
|
phase = .failed(message: String(localized: "signIn.timedOut"))
|
|
}
|
|
|
|
private func clearPendingCode() {
|
|
pendingCode = nil
|
|
showsManualCode = false
|
|
}
|
|
|
|
/// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
|
|
private func graceTokenLookup(pin: PlexPin) async throws -> String? {
|
|
for _ in 0..<5 {
|
|
if let token = try? await authenticator.token(for: pin) { return token }
|
|
try? await Task.sleep(for: .milliseconds(700))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cancelSignIn() {
|
|
authorizationTask?.cancel()
|
|
authorizationTask = nil
|
|
webAuthenticator.dismiss()
|
|
clearPendingCode()
|
|
phase = .signedOut
|
|
}
|
|
|
|
|
|
func signOut() {
|
|
authorizationTask?.cancel()
|
|
authenticator.signOut()
|
|
forgetRememberedConnection()
|
|
server = nil
|
|
libraries = []
|
|
phase = .signedOut
|
|
}
|
|
|
|
// MARK: - Verbinden
|
|
|
|
private func connect(accountToken: String) async {
|
|
phase = .connecting(serverName: "")
|
|
|
|
let directory = PlexServerDirectory(identity: identity, token: accountToken)
|
|
let servers: [PlexResource]
|
|
do {
|
|
servers = try await directory.servers()
|
|
} catch PlexError.unauthorized {
|
|
// Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde
|
|
// bei jedem Start denselben Fehler erzeugen.
|
|
authenticator.signOut()
|
|
phase = .signedOut
|
|
return
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
return
|
|
}
|
|
|
|
guard !servers.isEmpty else {
|
|
phase = .failed(message: String(localized: "error.noServers"))
|
|
return
|
|
}
|
|
|
|
// Beim zweiten Start soll niemand erneut wählen müssen.
|
|
let remembered = defaults.string(forKey: Key.preferredServer)
|
|
if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement {
|
|
await connect(to: chosen)
|
|
} else {
|
|
phase = .choosingServer(servers)
|
|
}
|
|
}
|
|
|
|
func connect(to resource: PlexResource) async {
|
|
phase = .connecting(serverName: resource.name)
|
|
|
|
let selector = PlexConnectionSelector(identity: identity)
|
|
guard let connection = await selector.selectConnection(for: resource) else {
|
|
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
|
return
|
|
}
|
|
|
|
guard let server = PlexServer(
|
|
resource: resource,
|
|
connection: connection,
|
|
identity: identity
|
|
) else {
|
|
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
|
return
|
|
}
|
|
|
|
self.server = server
|
|
remember(resource: resource, connection: connection)
|
|
|
|
do {
|
|
libraries = try await server.libraries().filter(\.isSupported)
|
|
phase = .ready
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
|
|
/// Merkt sich, worüber es funktioniert hat.
|
|
private func remember(resource: PlexResource, connection: PlexConnection) {
|
|
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
|
|
defaults.set(connection.uri, forKey: Key.cachedConnectionURI)
|
|
defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal)
|
|
defaults.set(resource.name, forKey: Key.cachedServerName)
|
|
if let token = resource.accessToken {
|
|
try? tokenStore.store(token, for: Self.serverTokenAccount)
|
|
}
|
|
}
|
|
|
|
/// Vergisst die gemerkte Verbindung.
|
|
private func forgetRememberedConnection() {
|
|
for key in [Key.preferredServer, Key.cachedConnectionURI,
|
|
Key.cachedConnectionIsLocal, Key.cachedServerName] {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
tokenStore.remove(for: Self.serverTokenAccount)
|
|
}
|
|
|
|
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
|
func retry() async {
|
|
if let token = authenticator.storedToken {
|
|
await connect(accountToken: token)
|
|
} else {
|
|
phase = .signedOut
|
|
}
|
|
}
|
|
|
|
private func message(for error: any Error) -> String {
|
|
guard let plexError = error as? PlexError else { return error.localizedDescription }
|
|
return switch plexError {
|
|
case .unauthorized: String(localized: "error.unauthorized")
|
|
case .notFound: String(localized: "error.notFound")
|
|
case .noReachableConnection: String(localized: "error.serverUnreachable")
|
|
case .server(let status): String(localized: "error.server \(status)")
|
|
case .transport(let description): description
|
|
case .decoding: String(localized: "error.unexpectedResponse")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Array {
|
|
/// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt.
|
|
var singleElement: Element? { count == 1 ? first : nil }
|
|
}
|