Die Gegenprobe ist eindeutig:
Gegenprobe — Transcoder mit: Mogli: Legende des Dschungels
→ HTTP 400
Eine gewöhnliche Datei scheitert genauso. Damit ist die Suche der letzten
Tage an der falschen Stelle gelaufen: Transcoding hat in Flixy noch nie
funktioniert, es ist nur nie aufgefallen. Filme laufen über DirectPlay,
da wird der Transcoder gar nicht gefragt — Live-TV ist das Erste, was ihn
zwingend braucht.
Diese Annahme habe ich tagelang mitgeschleppt, ohne sie zu prüfen. Sie zu
prüfen hat zehn Zeilen gekostet.
Jetzt wird richtig halbiert, an einer Datei statt an einem Sender: von
„nur Pfad und Token" aufwärts, Angabe für Angabe, bis eine davon die 400
auslöst. Dazu derselbe Satz an den Entscheider, der in Plex' Format
antwortet statt mit HTML.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
5.7 KiB
Swift
163 lines
5.7 KiB
Swift
import Foundation
|
||
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.
|
||
|
||
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.") }
|
||
|
||
let selector = PlexConnectionSelector(identity: identity)
|
||
guard let connection = await selector.selectConnection(for: resource),
|
||
let server = PlexServer(resource: resource, connection: connection, identity: identity)
|
||
else { fatalError("Kein Weg zu \(resource.name) erreichbar.") }
|
||
|
||
print("Server: \(resource.name) über \(connection.local ? "lokal" : "entfernt")\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.
|
||
let requested = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("--") }
|
||
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.
|
||
if let hubs = try? await server.homeHubs(),
|
||
let movie = hubs.flatMap(\.items).first(where: { $0.kind == .movie }) {
|
||
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")
|
||
|
||
guard let channel = guide.channels.first 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
|
||
}
|