import Foundation import MediaKit import PlexKit // Ein Werkzeug für die Fragen, die reines HTTP sind. // // Der Grund, dass es das gibt: die Live-TV-Schnittstelle von Plex ist // nirgends beschrieben, und jede Vermutung darüber ging bisher als // TestFlight-Build zum Nutzer und kam einen halben Tag später als Screenshot // zurück. Irgendwann war das Upload-Kontingent des Tages aufgebraucht — für // eine Frage, die keine Oberfläche braucht. // // Der Token wird über die übliche Anmeldung geholt und liegt im Schlüsselbund // dieses Rechners. Er steht nirgends in der Ausgabe: gedruckt werden Pfade, // Kennungen und Antworten, keine vollständigen Adressen. // MARK: - Die Leiter-Frage // // Beide Modi laufen **vor** der Plex-Anmeldung: die eine Frage hat mit Plex // nichts zu tun, die andere bringt ihre eigene Anmeldung mit. if let index = CommandLine.arguments.firstIndex(of: "--playlist"), let roh = CommandLine.arguments.dropFirst(index + 1).first, let url = URL(string: roh) { print("=== \(url.lastPathComponent) ===") print(await prüfePlaylist(url)) exit(0) } if let index = CommandLine.arguments.firstIndex(of: "--jellyfin"), let host = CommandLine.arguments.dropFirst(index + 1).first { await jellyfinLeiter(host: host) exit(0) } let identity = PlexClientIdentity( product: "Flixy-Probe", version: "1.0", clientIdentifier: storedClientIdentifier(), platform: "macOS", platformVersion: ProcessInfo.processInfo.operatingSystemVersionString, device: "Mac", deviceName: Host.current().localizedName ?? "Mac" ) let store = KeychainTokenStore(service: "com.scarriffleservices.flixy.probe") let authenticator = PlexAuthenticator(identity: identity, tokenStore: store) let token: String if let existing = authenticator.storedToken { print("Angemeldet (Token aus dem Schlüsselbund).") token = existing } else { let pin = try await authenticator.requestPin() print(""" Anmelden: https://plex.tv/link Code: \(pin.code) Warte … """) var found: String? for _ in 0..<150 where found == nil { try await Task.sleep(for: .seconds(2)) found = try await authenticator.token(for: pin) } guard let found else { fatalError("Anmeldung abgelaufen.") } token = found print("Angemeldet.\n") } let directory = PlexServerDirectory(identity: identity, token: token) let servers = try await directory.servers() guard let resource = servers.first else { fatalError("Kein Server am Konto.") } // Welche Wege es zu diesem Server gibt. Die Fehlerseiten sehen nicht nach // Plex aus, sondern nach einem Webserver davor — dann macht es einen // Unterschied, ob die Anfrage durch die Portweiterleitung geht oder direkt. print("Wege zu \(resource.name):") for candidate in resource.connections { print(" \(candidate.local ? "lokal " : "entfernt") \(candidate.uri)") } let forceLocal = CommandLine.arguments.contains("--local") let selector = PlexConnectionSelector(identity: identity) let chosen: PlexConnection? if forceLocal { chosen = resource.connections.first { $0.local } } else if let index = CommandLine.arguments.firstIndex(of: "--weg"), let number = CommandLine.arguments.dropFirst(index + 1).first.flatMap(Int.init), resource.connections.indices.contains(number) { // Ein bestimmter Weg, zum Vergleich: die Vermittlung von Plex nimmt // einen anderen Weg als die Portweiterleitung nach Hause. chosen = resource.connections[number] } else { chosen = await selector.selectConnection(for: resource) } guard let connection = chosen, let server = PlexServer(resource: resource, connection: connection, identity: identity) else { fatalError("Kein Weg zu \(resource.name) erreichbar.") } print("\nGenommen: \(connection.local ? "lokal" : "entfernt") — \(connection.uri)\n") // MARK: - Freie Abfrage // `swift run FlixyProbe /pfad /noch-ein-pfad …` fragt genau diese Adressen ab // und zeigt die Antwort. Dafür ist das Werkzeug da: eine Schnittstelle ohne // Beschreibung erkundet man, indem man sie fragt, nicht indem man rät. // Was hinter `--try` steht, ist der Einzelversuch und keine freie Abfrage. let tryTarget = CommandLine.arguments.firstIndex(of: "--try") .flatMap { CommandLine.arguments.dropFirst($0 + 1).first } if let tryTarget { print(await server.attempt(target: tryTarget, headers: !CommandLine.arguments.contains("--bare"))) exit(0) } var requested = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("--") } // Was hinter `--channel` steht, ist keine Adresse. if let wanted = CommandLine.arguments.drop(while: { $0 != "--channel" }).dropFirst().first { requested.removeAll { $0 == wanted } } if !requested.isEmpty { for path in requested { print("=== \(path) ===") print(await server.rawResponse(forPath: path)) print() } exit(0) } // MARK: - Gegenprobe // Bevor weiter an Live-TV gesucht wird: tut der Transcoder überhaupt etwas? // Jede Anfrage an ihn endet mit einer nackten 400 — auch die an den // Entscheider. Das ist eine Annahme, die ich die ganze Zeit ungeprüft // mitgeschleppt habe, und sie gehört als Erstes geprüft: läuft eine // gewöhnliche Datei durch, liegt es an Live-TV. Läuft sie nicht durch, habe // ich tagelang am falschen Ende gesucht. // Aus einer echten Mediathek, nicht aus den Startseiten-Reihen: dort stehen // auch Einträge von plex.tv, deren Kennung kein Schlüssel dieses Servers ist. // Mit so einem Eintrag prüft die Gegenprobe nichts. let libraries = (try? await server.libraries()) ?? [] let movieLibrary = libraries.first { $0.kind == .movie } ?? libraries.first let firstMovie = await { () async -> PlexItem? in guard let movieLibrary else { return nil } return try? await server.items(inLibrary: movieLibrary, limit: 1).items.first }() // MARK: - Die Leiter bei Plex // Dieselbe Frage wie bei Jellyfin, und aus demselben Grund gestellt: bei Plex // steht `autoAdjustQuality=1` im Code, und ob sein Transcoder daraufhin // mehrere Stufen liefert, war nie geprüft. Eine ungeprüfte Annahme über // Serververhalten hat uns bei Jellyfin vier Runden gekostet. if CommandLine.arguments.contains("--leiter") { guard let movie = firstMovie else { print("Keine Datei zum Prüfen gefunden.") exit(0) } print("Leiter-Probe mit: \(movie.title)\n") // **Zwei getrennte Sitzungen, und das ist der Kern des Vergleichs.** // // `askingForLadder()` behält die Sitzungskennung bei — richtig für die // App, weil sonst beim Aufräumen ein Transcoder stehen bliebe. Für eine // Gegenüberstellung ist es tödlich: die zweite Anfrage bekommt dann die // laufende Sitzung der ersten zurück, die Antwort ist zwangsläufig // identisch, und man hält das für ein Ergebnis. Genau das ist hier // passiert. let ohne = TranscodeRequest( ratingKey: movie.ratingKey, maximumBitrate: 8_000, isLocal: connection.local ) let mit = TranscodeRequest( ratingKey: movie.ratingKey, maximumBitrate: 8_000, isLocal: connection.local, adaptive: true ) print("Sitzungen: \(ohne.sessionIdentifier.prefix(8)) / \(mit.sessionIdentifier.prefix(8))\n") // **Warum kommt so wenig zurück?** // // Die App schickt bei „automatisch" `maxVideoBitrate` und `videoQuality`, // aber **kein** `videoResolution` — dort steht `nil`. Verdacht: Plex nimmt // dann seine eigene Voreinstellung für die Qualitätsstufe, und die ist // klein. Das wäre kein Servereinstellungs-Problem, sondern unser eigenes. print("=== Woran hängt die gelieferte Größe? ===") let proben: [(String, Int, String?)] = [ ("wie die App heute (ohne Auflösung)", 8_000, nil), ("mit videoResolution=1920x1080", 8_000, "1920x1080"), ("20 Mbit, ohne Auflösung", 20_000, nil), ("20 Mbit + 1920x1080", 20_000, "1920x1080"), ] for (name, bitrate, auflösung) in proben { let probe = TranscodeRequest( ratingKey: movie.ratingKey, maximumBitrate: bitrate, resolution: auflösung, isLocal: connection.local ) guard let url = server.transcodeURL(for: probe) else { continue } let antwort = await prüfePlaylist(url) let zeile = antwort .split(separator: "\n") .first { $0.contains("Mbit/s") } .map { $0.trimmingCharacters(in: .whitespaces) } ?? "keine Stufe" print(" \(name): \(zeile)") await server.stopTranscode(sessionIdentifier: probe.sessionIdentifier) } print() for (name, anfrage) in [("ohne autoAdjustQuality", ohne), ("mit autoAdjustQuality=1", mit)] { print("=== \(name) ===") guard let url = server.transcodeURL(for: anfrage) else { print(" Keine Adresse zu bauen.\n") continue } print(await prüfePlaylist(url)) print() } // Aufräumen: **beide** Anfragen haben je einen Transcoder angeworfen. await server.stopTranscode(sessionIdentifier: ohne.sessionIdentifier) await server.stopTranscode(sessionIdentifier: mit.sessionIdentifier) print("Beide Transcoder angehalten.") exit(0) } if let movie = firstMovie { let request = TranscodeRequest( ratingKey: movie.ratingKey, maximumBitrate: 4_000, isLocal: connection.local ) if let url = server.transcodeURL(for: request) { print("Gegenprobe — Transcoder mit: \(movie.title)") print(" volle Anfrage: \(await server.probeStream(url: url))") for line in await server.transcoderBisect(ratingKey: movie.ratingKey) { print(line) } print() } } else { print("Gegenprobe: keine Datei zum Prüfen gefunden.\n") } // MARK: - Live-TV let availability = await server.liveTVAvailability() guard case .available(let dvrs) = availability, let dvr = dvrs.first else { print("Live-TV: \(availability)") exit(0) } print("Empfänger: \(dvr.key), Programmführer: \(dvr.epgIdentifier ?? "–")") let guide = await server.liveTVGuide(dvr: dvr, hours: 2) print("Sender: \(guide.channels.count) — \(guide.source)\n") // `--channel 002` wählt einen bestimmten Sender. Sonst der erste, auf dem // überhaupt etwas läuft — ein Sender ohne Sendung sagt über das // Einschalten wenig aus. let wanted = CommandLine.arguments.drop(while: { $0 != "--channel" }).dropFirst().first let channel = guide.channels.first { candidate in guard let wanted else { return false } return candidate.channelIdentifier == wanted || candidate.channelIdentifier == wanted.trimmingCharacters(in: CharacterSet(charactersIn: "0")) || candidate.title.localizedCaseInsensitiveContains(wanted) } ?? guide.channels.first { guide.programs[$0.id]?.contains { $0.isRunning() } == true } ?? guide.channels.first guard let channel else { exit(0) } let program = guide.programs[channel.id]?.first { $0.isRunning() } print("Einschalten: \(channel.title) (\(channel.channelIdentifier ?? "–"))") print("Läuft: \(program?.title ?? "–")\n") let result = await server.tune( channel: channel, program: program, dvr: dvr, maximumBitrate: nil ) for probe in result.trace { print(probe.path) print(" → \(probe.result)") } print("\n" + (result.url == nil ? "Kein Weg trug." : "Erfolg.")) if result.url == nil { // Der Transcoder antwortet mit einer nackten 400-Seite. Der Entscheider // bekommt dieselben Angaben und erklärt sich — die letzte Stelle, an der // dieser Server noch sagt, woran es liegt. print("\n=== Was der Entscheider dazu sagt ===") print(await server.decisionDump(channel: channel, program: program)) print("\n=== Empfänger und Gerät ===") for path in ["/livetv/dvrs/\(dvr.key)", "/media/grabbers/devices/1", "/livetv/sessions"] { print("--- \(path) ---") print(await server.rawResponse(forPath: path, limit: 700)) } } /// Eine Kennung, die über Läufe hinweg gleich bleibt. /// /// Wechselt sie, gilt die Probe am Konto als neues Gerät, und in der /// Geräteliste sammelt sich Müll an. func storedClientIdentifier() -> String { let key = "flixy.probe.clientIdentifier" if let stored = UserDefaults.standard.string(forKey: key) { return stored } let created = UUID().uuidString UserDefaults.standard.set(created, forKey: key) return created }