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 Verknüpfungsseite von Plex, mit bereits eingetragenem Code. /// /// Gegenüber `app.plex.tv/auth` der verlässlichere Weg — und zwar genau in /// dem Fall, der sonst stillschweigend scheitert: Wer im Browser schon bei /// Plex angemeldet ist, wird von der Auth-Seite sofort auf watch.plex.tv /// weitergeschickt, **ohne dass der Code verknüpft wird**. Man sieht sich /// angemeldet, die App wartet vergeblich. /// /// Diese Seite tut nur eine Sache: den Code bestätigen. Die Kennung des /// Geräts steckt bereits im PIN — sie kam beim Anlegen über die /// `X-Plex-*`-Kopfzeilen mit. public var linkURL: URL? { let allowed = CharacterSet.alphanumerics guard let code = code.addingPercentEncoding(withAllowedCharacters: allowed) else { return nil } return URL(string: "https://plex.tv/link/?pin=\(code)") } /// Die Adresse, unter der der Nutzer den Code bestätigt. /// /// Von Hand zusammengesetzt statt über `URLComponents`. Der Grund ist die /// Form, die Plex verlangt: die Parameter stehen im Fragment (hinter dem /// `#`), nicht in der Abfrage, und einer davon heißt buchstäblich /// `context[device][product]`. Die eckigen Klammern müssen genau einmal /// kodiert sein. /// /// `URLComponents.fragment` kodiert beim Setzen ein zweites Mal: aus `%5B` /// wird `%255B`. Plex sieht dann einen Parameter, den es nicht kennt, und /// antwortet mit „Diese Anfrage konnte nicht abgeschlossen werden". /// Festgehalten in `AuthorizationURLTests`. /// /// `forwardUrl` schickt den Browser nach der Anmeldung auf eine Adresse /// mit eigenem Schema zurück. Genau darauf wartet /// `ASWebAuthenticationSession`: das Anmeldefenster schließt sich von /// selbst, und die App weiß, dass sie den Token abholen kann. Ohne diese /// Rückadresse bliebe die Seite offen und der Nutzer müsste raten, wann er /// fertig ist. public func authorizationURL( clientIdentifier: String, product: String, forwardURL: URL? = nil ) -> URL? { // `&`, `=` und `+` müssen raus: sie trennen Parameter bzw. stehen für // ein Leerzeichen. In einem Wert würden sie die Adresse zerreißen. let allowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+")) func encode(_ value: String) -> String? { value.addingPercentEncoding(withAllowedCharacters: allowed) } guard let client = encode(clientIdentifier), let code = encode(code), let product = encode(product) else { return nil } var text = """ https://app.plex.tv/auth#?clientID=\(client)&code=\(code)\ &context%5Bdevice%5D%5Bproduct%5D=\(product) """ // Strenger kodiert als die übrigen Werte: die Rückadresse enthält // `:` und `/`, und die lässt `urlQueryAllowed` durch. Als Wert // innerhalb einer Adresse, die Plex selbst noch einmal zerlegt, müssen // sie kodiert sein — sonst endet der Wert für Plex beim ersten // Schrägstrich. let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) if let forwardURL, let forward = forwardURL.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved) { text += "&forwardUrl=\(forward)" } return URL(string: text) } }