From afab93f53a0e64c81f579da93b956705cfc3f30c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 27 Sep 2026 15:02:43 +0200 Subject: [PATCH] =?UTF-8?q?FlixyProbe=20kann=20die=20Leiter=20z=C3=A4hlen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 ` 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 --- Packages/FlixyKit/Package.swift | 2 +- .../FlixyKit/Sources/FlixyProbe/Leiter.swift | 207 ++++++++++++++++++ .../FlixyKit/Sources/FlixyProbe/main.swift | 19 ++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift index 2a65509..5f01de2 100644 --- a/Packages/FlixyKit/Package.swift +++ b/Packages/FlixyKit/Package.swift @@ -43,7 +43,7 @@ let package = Package( // Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche, // ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt // eines halben Tages, und das Upload-Kontingent bleibt für Builds. - .executableTarget(name: "FlixyProbe", dependencies: ["MediaKit", "PlexKit"]), + .executableTarget(name: "FlixyProbe", dependencies: ["MediaKit", "PlexKit", "JellyfinKit"]), .testTarget( name: "PlexKitTests", dependencies: ["PlexKit"], diff --git a/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift b/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift new file mode 100644 index 0000000..5872f31 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift @@ -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 + )) +} diff --git a/Packages/FlixyKit/Sources/FlixyProbe/main.swift b/Packages/FlixyKit/Sources/FlixyProbe/main.swift index 642dcd9..244bd7a 100644 --- a/Packages/FlixyKit/Sources/FlixyProbe/main.swift +++ b/Packages/FlixyKit/Sources/FlixyProbe/main.swift @@ -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",