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:
co-authored by
Claude Opus 5
parent
903bfe5f63
commit
afab93f53a
@@ -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
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user