Die Sprachwahl vergleicht Kürzel, und Kürzel lügen — beim ZDF-Fall ist die als deutsch ausgezeichnete Spur die mit Audiodeskription. Ob der **Name** der Spur das rettet, sieht man nur, wenn man beides nebeneinander legt. Alles andere wäre geraten, und davon hatten wir heute genug. `--spuren <host> <suchbegriff>` sucht einen Titel und druckt zu jeder Tonspur: Kürzel, Name aus der Datei, wie Jellyfin es zusammensetzt, Codec, Kanäle und die Kennzeichen. Dafür holt der Decoder jetzt auch `Title`, `IsHearingImpaired` und `IsOriginal`. `Title` ist das zweite Signal neben dem Kürzel und manchmal das einzige richtige: wo „eng" steht, kann im Namen „Deutsch" stehen. Wir haben es bisher weggeworfen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
15 KiB
Swift
383 lines
15 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.
|
||
|
||
// 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: "--spuren"),
|
||
let host = CommandLine.arguments.dropFirst(index + 1).first,
|
||
let suche = CommandLine.arguments.dropFirst(index + 2).first {
|
||
await zeigeSpuren(host: host, suche: suche)
|
||
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
|
||
}()
|
||
|
||
/// Der **größte** Titel aus einer Stichprobe — nicht der erste im Alphabet.
|
||
///
|
||
/// Für die Frage „wie gross liefert der Server" taugt eine kleine Datei
|
||
/// nicht: Plex rechnet nichts hoch, und dann misst man die Quelle statt die
|
||
/// Einstellungen. Genau daran ist eine Messung schon gescheitert — „2 Fast 2
|
||
/// Furious" steht alphabetisch vorn und ist ein 540p-Rip.
|
||
let biggestMovie = await { () async -> PlexItem? in
|
||
guard let movieLibrary else { return nil }
|
||
let stichprobe = (try? await server.items(inLibrary: movieLibrary, limit: 60).items) ?? []
|
||
return stichprobe.max { links, rechts in
|
||
(links.media.first?.bitrate ?? 0) < (rechts.media.first?.bitrate ?? 0)
|
||
}
|
||
}()
|
||
|
||
// 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 = biggestMovie ?? firstMovie else {
|
||
print("Keine Datei zum Prüfen gefunden.")
|
||
exit(0)
|
||
}
|
||
|
||
// **Die Quelle zuerst nennen.** Ohne sie misst man womöglich die Datei
|
||
// statt die Einstellungen: Plex rechnet nichts hoch, und eine kleine
|
||
// Quelle liefert klein, ganz gleich was man anfragt.
|
||
let quelle = movie.media.first
|
||
print("Leiter-Probe mit: \(movie.title)")
|
||
print(" Quelle: \(quelle?.width ?? 0)x\(quelle?.height ?? 0), "
|
||
+ "\((quelle?.bitrate ?? 0)) kbit, \(quelle?.videoCodec ?? "–")\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
|
||
)
|
||
// **Über den Entscheider, nicht über start.m3u8.** Jede Anfrage dort
|
||
// wirft einen Transcoder an; sechs in Folge bringen den Server dazu,
|
||
// auch das abzulehnen, was vorher ging. Der Entscheider sagt, was er
|
||
// täte, ohne etwas zu tun.
|
||
let wortlaut = await server.decisionText(for: probe)
|
||
let auflösung = herausziehen("videoResolution", aus: wortlaut)
|
||
?? herausziehen("height", aus: wortlaut)
|
||
let bitrate = herausziehen("bitrate", aus: wortlaut)
|
||
let entscheidung = herausziehen("transcodeDecisionText", aus: wortlaut)
|
||
?? herausziehen("generalDecisionText", aus: wortlaut)
|
||
print(" \(name):")
|
||
print(" Auflösung \(auflösung ?? "–") · Bitrate \(bitrate ?? "–")")
|
||
if let entscheidung { print(" \(entscheidung)") }
|
||
}
|
||
print()
|
||
|
||
// Zwischen den beiden echten Sitzungen Luft lassen: Plex nimmt eine
|
||
// schnelle Folge von Transcode-Starts übel und lehnt danach alles ab.
|
||
for (name, anfrage) in [("ohne autoAdjustQuality", ohne), ("mit autoAdjustQuality=1", mit)] {
|
||
try? await Task.sleep(for: .seconds(3))
|
||
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
|
||
}
|
||
|
||
|
||
/// Zieht einen Wert aus der Antwort des Entscheiders.
|
||
///
|
||
/// Plex antwortet je nach Kopfzeile als XML oder JSON; beide Formen tragen
|
||
/// den Wert als `name="wert"` bzw. `"name":"wert"`. Statt zwei Parser zu
|
||
/// bauen, wird hier gesucht — es geht um eine Anzeige, nicht um Daten, mit
|
||
/// denen weitergerechnet wird.
|
||
func herausziehen(_ name: String, aus text: String) -> String? {
|
||
for muster in ["\(name)=\"", "\"\(name)\":\""] {
|
||
guard let bereich = text.range(of: muster) else { continue }
|
||
let rest = text[bereich.upperBound...]
|
||
let wert = String(rest.prefix { $0 != "\"" })
|
||
if !wert.isEmpty { return wert }
|
||
}
|
||
// JSON-Zahlen stehen ohne Anführungszeichen.
|
||
if let bereich = text.range(of: "\"\(name)\":") {
|
||
let rest = text[bereich.upperBound...].drop { $0 == " " }
|
||
let wert = String(rest.prefix { $0.isNumber })
|
||
if !wert.isEmpty { return wert }
|
||
}
|
||
return nil
|
||
}
|