FlixyProbe kann die Leiter zählen

Die Frage, von der alles Weitere abhängt, ist eine Tatsachenfrage:
Liefert der Server eine HLS-Liste mit mehreren Stufen oder mit einer?
Sie über einen Build aufs Gerät zu beantworten kostet einen halben Tag;
über HTTP kostet sie Sekunden. Genau dafür gibt es dieses Werkzeug.

`--playlist <adresse>` holt eine Playlist und zählt die
`EXT-X-STREAM-INF`-Zeilen mitsamt Bandbreite und Auflösung. Es
unterscheidet dabei die drei Fälle, die man auseinanderhalten muss: eine
Master-Playlist mit mehreren Stufen, eine mit genau einer — und eine
Varianten-Playlist, die gar keine Stufen führt, sondern Schnipsel. Die
letzte Verwechslung ist die naheliegende: `main.m3u8` sieht aus wie eine
Playlist und ist die falsche.

`--jellyfin <host>` meldet sich an, holt einen Film, lässt den Server
eine Transcode-Adresse aushandeln und prüft sie dreimal: unverändert,
als `master.m3u8`, und als `master.m3u8` mit
`enableAdaptiveBitrateStreaming=true`. Damit steht danach fest, ob der
Umbau in JellyfinBackend überhaupt etwas bringen kann.

Das Passwort wird eingetippt statt übergeben, ohne Echo und ohne Ablage.
Gedruckt werden Pfad und die **Namen** der Abfragewerte, nie die
vollständige Adresse mit Schlüssel — dieselbe Regel, nach der das
Werkzeug schon vorher gebaut war.

Beide Modi laufen vor der Plex-Anmeldung: die eine Frage hat mit Plex
nichts zu tun, die andere bringt ihre eigene Anmeldung mit.

Der Leser selbst ist gegen drei erfundene Playlists geprüft — mehrstufig,
einstufig, und eine Schnipselliste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-27 15:02:43 +02:00
co-authored by Claude Opus 5
parent 903bfe5f63
commit afab93f53a
3 changed files with 227 additions and 1 deletions
@@ -0,0 +1,207 @@
import Foundation
import JellyfinKit
import MediaKit
// Die Frage, für die dieser Teil des Werkzeugs gebaut ist:
//
// Liefert der Server eine HLS-Liste mit **mehreren** Stufen — oder mit
// einer?
//
// Davon hängt alles ab. AVPlayer kann nur zwischen Varianten umschalten, die
// in der Liste stehen; bei einer gibt es nichts zu wechseln, und jedes
// Nachregeln greift ins Leere. Bisher war das eine Annahme, und zwei
// Kommentare im Code haben sie als Tatsache ausgegeben.
//
// Geprüft wird über HTTP, nicht über einen Build aufs Gerät. Der Token bleibt
// im Schlüsselbund; gedruckt werden Stufen und Zahlen, nie eine vollständige
// Adresse.
/// Was in einer Playlist steht.
struct Leiter {
var stufen: [(bandbreite: Int, auflösung: String?)] = []
var istMaster = false
var zeilen = 0
var beschreibung: String {
guard istMaster else {
return " Keine Master-Playlist (\(zeilen) Zeilen) — das ist eine Liste von Schnipseln, keine von Stufen."
}
guard !stufen.isEmpty else {
return " Master-Playlist ohne eine einzige Stufe."
}
var text = " \(stufen.count) Stufe\(stufen.count == 1 ? "" : "n"):\n"
for stufe in stufen.sorted(by: { $0.bandbreite < $1.bandbreite }) {
let mbit = String(format: "%.1f", Double(stufe.bandbreite) / 1_000_000)
text += " \(mbit) Mbit/s"
if let auflösung = stufe.auflösung { text += " · \(auflösung)" }
text += "\n"
}
if stufen.count == 1 {
text += " → Eine Stufe. Es gibt nichts, worauf der Player ausweichen könnte.\n"
}
return text
}
}
/// Liest eine Playlist und zählt, was drinsteht.
func leiter(in text: String) -> Leiter {
var ergebnis = Leiter()
let zeilen = text.split(separator: "\n", omittingEmptySubsequences: false)
ergebnis.zeilen = zeilen.count
for zeile in zeilen where zeile.hasPrefix("#EXT-X-STREAM-INF:") {
ergebnis.istMaster = true
let attribute = zeile.dropFirst("#EXT-X-STREAM-INF:".count)
func wert(_ name: String) -> String? {
// Reicht für BANDWIDTH und RESOLUTION: beide sind unquotiert.
guard let bereich = attribute.range(of: "\(name)=") else { return nil }
let rest = attribute[bereich.upperBound...]
return String(rest.prefix { $0 != "," })
}
guard let roh = wert("BANDWIDTH"), let bandbreite = Int(roh) else { continue }
ergebnis.stufen.append((bandbreite, wert("RESOLUTION")))
}
return ergebnis
}
/// Holt eine Playlist und beurteilt sie.
func prüfePlaylist(_ url: URL, kopfzeilen: [String: String] = [:]) async -> String {
var request = URLRequest(url: url)
for (name, wert) in kopfzeilen { request.setValue(wert, forHTTPHeaderField: name) }
do {
let (daten, antwort) = try await URLSession.shared.data(for: request)
let status = (antwort as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else {
return " HTTP \(status) — keine Playlist bekommen."
}
return leiter(in: String(decoding: daten, as: UTF8.self)).beschreibung
} catch {
return " Nicht erreichbar: \(error.localizedDescription)"
}
}
/// Setzt einen Abfragewert — vorhandene gleichen Namens werden **ersetzt**.
///
/// Dieselbe Falle wie bei `maxHeight` im Backend: bei zwei gleichnamigen
/// Werten gilt beim Server der erste, und ein danebengestellter zweiter
/// bewirkt nichts.
func setzend(_ name: String, auf wert: String, in url: URL) -> URL {
guard var teile = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return url }
var werte = (teile.queryItems ?? []).filter { $0.name.lowercased() != name.lowercased() }
werte.append(URLQueryItem(name: name, value: wert))
teile.queryItems = werte
return teile.url ?? url
}
/// Macht aus einer Varianten-Playlist die Master-Playlist.
///
/// `main.m3u8` ist die Liste der **Schnipsel** einer Stufe, `master.m3u8` die
/// Liste der **Stufen**. Nur auf letzterer kann der Parameter für mehrere
/// Varianten überhaupt wirken.
func alsMaster(_ url: URL) -> URL {
guard url.lastPathComponent == "main.m3u8" else { return url }
return url.deletingLastPathComponent().appending(path: "master.m3u8")
}
// MARK: - Jellyfin
/// Fragt einen Jellyfin-Server, welche Playlist er für einen Titel ausgibt.
///
/// Das Passwort wird hier eingetippt und nirgends aufbewahrt; der Token bleibt
/// in diesem Prozess. Gedruckt wird der **Pfad** der Adresse, nie die
/// vollständige mit Schlüssel.
func jellyfinLeiter(host: String) async {
guard let basis = URL(string: host.contains("://") ? host : "http://\(host)") else {
print("Keine brauchbare Adresse: \(host)")
return
}
print("Benutzername: ", terminator: "")
guard let name = readLine(), !name.isEmpty else { return }
// Ohne Echo, damit nichts im Bild und nichts im Verlauf steht.
guard let passwort = String(validatingCString: getpass("Passwort: ")), !passwort.isEmpty else {
return
}
let identity = JellyfinIdentity(
client: "Flixy-Probe",
device: "Mac",
deviceID: "flixy-probe-" + UUID().uuidString,
version: "1.0"
)
let client = JellyfinClient(baseURL: basis, identity: identity)
let konto: JellyfinAccount
do {
konto = try await JellyfinAuthenticator(client: client).signIn(
userName: name,
password: passwort
)
} catch {
print("Anmeldung gescheitert: \(error)")
return
}
print("Angemeldet als \(konto.userName).\n")
let backend = JellyfinBackend(client: client, account: konto, serverName: "Probe")
// Irgendeinen Film. Es geht um die Form der Antwort, nicht um den Inhalt.
guard let mediathek = (try? await backend.libraries())?.first(where: { $0.kind == .movie }),
let kachel = try? await backend.items(
inLibrary: mediathek,
query: MediaQuery(),
offset: 0,
limit: 1
).items.first
else {
print("Keine Filmmediathek mit einem Titel gefunden.")
return
}
// Die Kachel aus der Liste trägt keine Dateiangaben — die kommen erst mit
// den Einzelheiten. Ohne sie lässt sich keine Wiedergabe vorbereiten.
let film = (try? await backend.item(id: kachel.id)) ?? kachel
guard let fassung = film.preferredVersion, let datei = fassung.files.first else {
print("Zu „\(film.title)“ meldet der Server keine Datei.")
return
}
print("Titel: \(film.title)\n")
guard let ziel = try? await backend.preparePlayback(PlaybackIntent(
itemID: film.id,
versionIndex: 0,
file: datei,
// Ausdrücklich umrechnen lassen: nur dann gibt es überhaupt eine
// Playlist, über die sich die Frage stellen lässt.
transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil)
)) else {
print("Der Server gibt für diesen Titel keine Transcode-Adresse her.")
return
}
let kopf = ["X-Emby-Token": konto.token]
let adresse = ziel.url
print("Was der Server von sich aus liefert:")
print(" Pfad: \(adresse.path)")
print(" Abfragewerte: \((URLComponents(url: adresse, resolvingAgainstBaseURL: true)?.queryItems ?? []).map(\.name).sorted().joined(separator: ", "))\n")
print("1) Unverändert")
print(await prüfePlaylist(adresse, kopfzeilen: kopf))
let master = alsMaster(adresse)
if master != adresse {
print("2) Als master.m3u8")
print(await prüfePlaylist(master, kopfzeilen: kopf))
}
print("3) Als master.m3u8 mit enableAdaptiveBitrateStreaming=true")
print(await prüfePlaylist(
setzend("enableAdaptiveBitrateStreaming", auf: "true", in: master),
kopfzeilen: kopf
))
}
@@ -14,6 +14,25 @@ import PlexKit
// 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",