Die Reihe lieferte viermal dasselbe — 480p bei 1,1 Mbit, gleichgültig ob mit `videoResolution` oder mit 20 Mbit. Das sieht nach einem Fehler in der Anfrage aus und ist wahrscheinlich keiner: gemessen wurde „2 Fast 2 Furious", der erste Titel im Alphabet, und vermutlich ein 540p-Rip. Plex rechnet nichts hoch. Damit misst die Reihe die **Datei** statt die Einstellungen — und aus einer Messung, die immer dasselbe sagt, liest man leicht das Falsche heraus. Die Probe nimmt jetzt den Titel mit der höchsten Bitrate aus einer Stichprobe von sechzig und druckt die Quelle dazu: Auflösung, Bitrate, Codec. Damit ist auf einen Blick zu sehen, ob eine kleine Antwort eine kleine Quelle ist oder ein echter Deckel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
15 KiB
Swift
376 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: "--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
|
||
}
|