Anmelde-Adresse wurde doppelt kodiert URLComponents.fragment kodiert beim Setzen erneut, aus %5B wurde %255B. Plex antwortete mit „Diese Anfrage konnte nicht abgeschlossen werden". Die Adresse wird jetzt von Hand zusammengesetzt. Verbindungssuche hing bis zu anderthalb Minuten probeTimeout war deklariert, aber nirgends angewendet — es galt der URLSession-Standard von 60 Sekunden. Zusammen mit der Prüfung Rang für Rang kostete eine schweigende lokale Adresse die volle Zeit, bevor der direkte Weg überhaupt begann. Jetzt laufen alle Adressen gleichzeitig, mit Zeitgrenze, und die Rangordnung entscheidet unter den erreichbaren. Kein Zurück während des Ladens Die Kopfzeile mit dem Schließen-Knopf wurde nur im Zustand .playing gezeichnet. Bei langsamer Verbindung saß man ohne Ausweg vor einem Ladekreis. Sie liegt jetzt als eigene Ebene darüber und erscheint auch beim Vorbereiten — samt Titel, damit erkennbar bleibt, worauf gewartet wird. Sechs neue Tests, darunter drei, die die Zeitgrenze der Verbindungssuche an einer schweigenden Adresse nachstellen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
4.8 KiB
Swift
128 lines
4.8 KiB
Swift
import Foundation
|
|
|
|
/// Sucht den besten erreichbaren Weg zu einem Server.
|
|
///
|
|
/// Alle Adressen werden **gleichzeitig** geprüft, nicht Rang für Rang. Der
|
|
/// Unterschied ist erheblich: geht man die Ränge nacheinander durch, kostet
|
|
/// eine nicht erreichbare lokale Adresse erst die volle Zeitgrenze, bevor der
|
|
/// direkte Weg überhaupt beginnt. Aus zwei Wartezeiten wird so eine.
|
|
///
|
|
/// Die Rangordnung bleibt trotzdem: gewinnt eine lokale Adresse, wird sofort
|
|
/// abgebrochen — besser kann es nicht werden. Sonst wird gesammelt und am Ende
|
|
/// die beste erreichbare genommen. Ein reines „wer zuerst antwortet" wäre
|
|
/// falsch, weil die Vermittlung von Plex sich oft am schnellsten meldet und
|
|
/// zugleich die langsamste Leitung hat.
|
|
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(4)
|
|
) {
|
|
self.identity = identity
|
|
self.transport = transport
|
|
self.probeTimeout = probeTimeout
|
|
}
|
|
|
|
public func selectConnection(for resource: PlexResource) async -> PlexConnection? {
|
|
guard let token = resource.accessToken, !resource.connections.isEmpty else { return nil }
|
|
|
|
return await withTaskGroup(of: PlexConnection?.self) { group in
|
|
for connection in resource.connections {
|
|
group.addTask {
|
|
await reachable(connection, token: token, expecting: resource.clientIdentifier)
|
|
? connection
|
|
: nil
|
|
}
|
|
}
|
|
|
|
var best: PlexConnection?
|
|
for await candidate in group {
|
|
guard let candidate else { continue }
|
|
if best.map({ candidate.tier < $0.tier }) ?? true {
|
|
best = candidate
|
|
}
|
|
// Eine lokale Adresse ist nicht zu schlagen. Auf die übrigen
|
|
// zu warten würde die Anmeldung nur verzögern.
|
|
if best?.tier == 0 {
|
|
group.cancelAll()
|
|
break
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
}
|
|
|
|
/// Fragt `/identity` ab und gleicht die Server-Kennung ab.
|
|
private func reachable(
|
|
_ 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 response = try await withTimeout(probeTimeout) {
|
|
try await client.send(
|
|
PlexRequest(path: "/identity", timeout: probeTimeout),
|
|
as: PlexIdentity.self
|
|
)
|
|
}
|
|
// Der Abgleich 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 response?.machineIdentifier == clientIdentifier
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Bricht ab, wenn die Arbeit zu lange dauert.
|
|
///
|
|
/// Zusätzlich zur Zeitgrenze der Anfrage selbst, damit die Grenze auch bei
|
|
/// einem Transport greift, der sie nicht beachtet — in den Tests etwa.
|
|
/// Liefert `nil`, wenn die Zeit abgelaufen ist.
|
|
private func withTimeout<T: Sendable>(
|
|
_ duration: Duration,
|
|
_ operation: @escaping @Sendable () async throws -> T
|
|
) async throws -> T? {
|
|
try await withThrowingTaskGroup(of: T?.self) { group in
|
|
group.addTask { try await operation() }
|
|
group.addTask {
|
|
try? await Task.sleep(for: duration)
|
|
return nil
|
|
}
|
|
|
|
let first = try await group.next() ?? nil
|
|
group.cancelAll()
|
|
return first
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|