Der Anmeldebildschirm kannte nur Plex. JellyfinKit war fertig, aber von der App aus nicht erreichbar — „ich sehe nix von Jellyfin". Quick Connect steht vorn: der Server nennt einen sechsstelligen Code, den man in einem bereits angemeldeten Client bestätigt. Das Passwort erreicht Flixy dabei nie, dieselbe Zusage wie bei Plex. Benutzername und Passwort sind der Rückfall für Server, auf denen Quick Connect abgeschaltet ist; das Passwort wird dann für die Dauer einer Anfrage gehalten und danach weggeworfen. Die Adresse wird nicht erraten, sondern durchprobiert. „192.168.1.50" kann alles Mögliche heißen, und die Reihenfolge ist der Kern von JellyfinAddress: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein Domänenname fast immer. Andersherum wartet man bei jedem Server ohne Zertifikat erst auf eine Zeitüberschreitung. Die Sitzung hält von hier an einen Server hinter dem neutralen Vertrag, nicht mehr einen Plex-Server. Die Ansichten haben das nicht gemerkt — genau dafür war MediaKit da. Dabei fällt ab, was Jellyfin kann und Plex nicht: Favoriten stehen im Aktionsmenü, sobald der Server sie kennt, Sterne nur dort, wo es sie gibt. Was noch nicht geht und deshalb auch nicht behauptet wird: Wiedergabe und Live-TV sprechen weiter unmittelbar mit Plex. Bei Jellyfin verschwindet der Live-TV-Eintrag aus der Seitenleiste, und der Player sagt, dass die Wiedergabe der nächste Schritt ist, statt „Server nicht erreichbar" zu melden — er ist ja erreichbar. Nebenbei zwei Stellen, die der Übersetzer aus Plex hinterlassen hat: `catch is CancellationError` griff seit den getippten Fehlern nie mehr, weil eine abgebrochene Netzanfrage als Übertragungsfehler zurückkommt; und das Ersatz-Hintergrundbild einer Folge war toter Code, weil die linke Seite des `??` gar nicht optional war. TokenStore wandert nach MediaKit — Tokens sind nichts Plex-Eigenes. Die Konten sind nach Server getrennt, damit ein späteres Umschalten nicht jedes Mal eine neue Anmeldung kostet; „Abmelden" räumt trotzdem beide ab. 248 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223 lines
8.3 KiB
Swift
223 lines
8.3 KiB
Swift
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.
|
||
|
||
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
|
||
}()
|
||
|
||
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
|
||
}
|