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>
216 lines
7.2 KiB
Swift
216 lines
7.2 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
|
|
/// Der Code steht bereit, der Nutzer bestätigt ihn im Browser.
|
|
case awaitingAuthorization(PlexPin)
|
|
/// 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 (.awaitingAuthorization(let l), .awaitingAuthorization(let r)):
|
|
l.id == r.id
|
|
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"
|
|
}
|
|
|
|
/// 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 }
|
|
|
|
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 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
|
|
}
|
|
await connect(accountToken: token)
|
|
}
|
|
|
|
// MARK: - Anmeldung
|
|
|
|
func beginSignIn() async {
|
|
do {
|
|
let pin = try await authenticator.requestPin()
|
|
phase = .awaitingAuthorization(pin)
|
|
waitForAuthorization(of: pin)
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
|
|
func cancelSignIn() {
|
|
authorizationTask?.cancel()
|
|
authorizationTask = nil
|
|
phase = .signedOut
|
|
}
|
|
|
|
private func waitForAuthorization(of pin: PlexPin) {
|
|
authorizationTask?.cancel()
|
|
authorizationTask = Task { [authenticator] in
|
|
do {
|
|
let token = try await authenticator.waitForAuthorization(pin: pin)
|
|
guard !Task.isCancelled else { return }
|
|
await connect(accountToken: token)
|
|
} catch {
|
|
guard !Task.isCancelled else { return }
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func signOut() {
|
|
authorizationTask?.cancel()
|
|
authenticator.signOut()
|
|
defaults.removeObject(forKey: Key.preferredServer)
|
|
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
|
|
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
|
|
|
|
do {
|
|
libraries = try await server.libraries().filter(\.isSupported)
|
|
phase = .ready
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
|
|
/// 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 }
|
|
}
|