Berechtigter Einwand: eine Playlist zu bekommen beweist gar nichts. Sie ist Text und kostet ein Kilobyte; die Datenrate steckt in den Schnipseln. Die vorige Fassung zählte `#EXTINF`-Zeilen und hätte „funktioniert" gemeldet, auch wenn der Server bei jeder Stufe denselben Strom ausliefert. Jetzt lädt die Probe je Stufe den **ersten Schnipsel** und rechnet aus, was er an Datenrate bedeutet — inklusive der unveränderten Stufe als Vergleichsmass. Werden sie kleiner, trägt die Idee. Bleiben sie gleich gross, rechnet der Server trotz anderer Zahl dasselbe, und ein selbstgebautes Menü brächte nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
449 lines
18 KiB
Swift
449 lines
18 KiB
Swift
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.
|
|
|
|
/// Wie der Server die Herkunft dieser Anfrage einordnet.
|
|
///
|
|
/// `isInNetwork` ist genau die Größe, an der Jellyfins ABR-Sperre hängt.
|
|
struct Endpunktlage: Decodable {
|
|
let isLocal: Bool
|
|
let isInNetwork: Bool
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case isLocal = "IsLocal"
|
|
case isInNetwork = "IsInNetwork"
|
|
}
|
|
}
|
|
|
|
/// Welche Fassung der Server hat.
|
|
struct Serverfassung: Decodable {
|
|
let version: String?
|
|
private enum CodingKeys: String, CodingKey { case version = "Version" }
|
|
}
|
|
|
|
/// Die Netzwerkeinstellungen des Servers — nur der Teil, der hier zählt.
|
|
struct Netzwerkeinstellung: Decodable {
|
|
let knownProxies: [String]?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case knownProxies = "KnownProxies"
|
|
}
|
|
}
|
|
|
|
/// 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."
|
|
}
|
|
let text = String(decoding: daten, as: UTF8.self)
|
|
let gelesen = leiter(in: text)
|
|
// **Bei einer Stufe den Wortlaut zeigen.** Ab hier hilft keine
|
|
// Zusammenfassung mehr: was fehlt, sieht man nur im Original.
|
|
guard gelesen.stufen.count <= 1 else { return gelesen.beschreibung }
|
|
return gelesen.beschreibung + "\n ——— Wortlaut ———\n"
|
|
+ text.split(separator: "\n").map { " " + $0 }.joined(separator: "\n")
|
|
} 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).")
|
|
|
|
// Die Fassung des Servers. Ohne sie lese ich womöglich den Quelltext
|
|
// einer anderen — und suche den Fehler an einer Stelle, die es dort
|
|
// gar nicht gibt.
|
|
if let info = try? await client.send(
|
|
JellyfinRequest(path: "/System/Info/Public"),
|
|
as: Serverfassung.self
|
|
) {
|
|
print("Jellyfin \(info.version ?? "unbekannt")")
|
|
}
|
|
print("")
|
|
|
|
let backend = JellyfinBackend(client: client, account: konto, serverName: "Probe")
|
|
|
|
// **Die Frage, die alles andere erklärt, direkt an den Server.**
|
|
//
|
|
// `/System/Endpoint` beantwortet `IsInNetwork` mit demselben Aufruf,
|
|
// mit dem auch die ABR-Sperre entscheidet:
|
|
// `_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())`.
|
|
// Damit muss niemand mehr raten, ob „von zuhause" der Grund ist.
|
|
let lage = try? await client.send(
|
|
JellyfinRequest(path: "/System/Endpoint"),
|
|
as: Endpunktlage.self
|
|
)
|
|
if let lage {
|
|
// Beide Werte ausdrücklich: sie unterscheiden zwei Lagen mit ganz
|
|
// verschiedenen Ursachen.
|
|
print("Der Server sieht: IsLocal=\(lage.isLocal), IsInNetwork=\(lage.isInNetwork)")
|
|
if lage.isLocal {
|
|
print(" → IsLocal heißt: die Anfrage kommt vom Rechner selbst,")
|
|
print(" also über 127.0.0.1. Dann steht der Proxy auf demselben")
|
|
print(" Host, und der Eintrag unter Bekannte Proxys muss 127.0.0.1")
|
|
print(" lauten (bzw. die Adresse im Docker-Netz), nicht 192.168.x.")
|
|
}
|
|
// **Was der Server tatsächlich eingetragen hat.**
|
|
//
|
|
// Ein Eintrag, der gespeichert, aber nicht übernommen wurde, sieht
|
|
// von außen genauso aus wie gar keiner. Hier steht, was gilt.
|
|
if let netz = try? await client.send(
|
|
JellyfinRequest(path: "/System/Configuration/network"),
|
|
as: Netzwerkeinstellung.self
|
|
) {
|
|
let proxys = netz.knownProxies ?? []
|
|
if proxys.isEmpty {
|
|
print(" Bekannte Proxys: KEINE eingetragen.")
|
|
} else {
|
|
print(" Bekannte Proxys: \(proxys.joined(separator: ", "))")
|
|
}
|
|
}
|
|
|
|
if lage.isInNetwork {
|
|
print("Der Server hält diese Verbindung für LOKAL.")
|
|
print(" → Eine Leiter wird er hier grundsätzlich nicht liefern,")
|
|
print(" ganz gleich was wir anfragen. Das Folgende sagt damit")
|
|
print(" nichts darüber, was von unterwegs käme.")
|
|
} else {
|
|
print("Der Server hält diese Verbindung für AUSSERHALB.")
|
|
print(" → Hier ist eine Leiter möglich. Kommt trotzdem nur eine")
|
|
print(" Stufe, liegt es an etwas anderem.")
|
|
}
|
|
print("")
|
|
}
|
|
|
|
// 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)")
|
|
let abfrage = URLComponents(url: adresse, resolvingAgainstBaseURL: true)?.queryItems ?? []
|
|
print(" Abfragewerte: \(abfrage.map(\.name).sorted().joined(separator: ", "))")
|
|
|
|
// **Die Werte nur von den harmlosen Schlüsseln.** Schlüssel, Kennungen
|
|
// und Sitzungsnummern bleiben ungedruckt — aber ob Bild und Ton
|
|
// umgerechnet oder nur kopiert werden, entscheidet mit darüber, ob es
|
|
// überhaupt eine Leiter geben kann, und steht nirgends sonst.
|
|
let unbedenklich = ["VideoCodec", "AudioCodec", "VideoBitrate", "AudioBitrate",
|
|
"TranscodeReasons", "SegmentContainer", "maxHeight"]
|
|
let gezeigt = abfrage
|
|
.filter { unbedenklich.contains($0.name) }
|
|
.map { "\($0.name)=\($0.value ?? "")" }
|
|
.sorted()
|
|
if !gezeigt.isEmpty { print(" Davon: \(gezeigt.joined(separator: ", "))") }
|
|
|
|
if abfrage.contains(where: { $0.name == "AudioCodec" && $0.value?.lowercased() == "copy" })
|
|
|| abfrage.contains(where: { $0.name == "VideoCodec" && $0.value?.lowercased() == "copy" }) {
|
|
print("")
|
|
print(" ACHTUNG: Hier wird kopiert statt gerechnet. Jellyfin liefert")
|
|
print(" dann grundsätzlich keine Leiter — auch von unterwegs nicht.")
|
|
}
|
|
print("")
|
|
|
|
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")
|
|
let mitABR = await prüfePlaylist(
|
|
setzend("enableAdaptiveBitrateStreaming", auf: "true", in: master),
|
|
kopfzeilen: kopf
|
|
)
|
|
print(mitABR)
|
|
|
|
if mitABR.contains("1 Stufe:") {
|
|
erklärungWarumKeineLeiter()
|
|
await prüfeSelbstgebauteLeiter(master: master, kopfzeilen: kopf)
|
|
}
|
|
|
|
// Aufräumen: die Aushandlung hat am Server eine Sitzung angelegt, und
|
|
// ohne dieses Signal rechnet der Transcoder weiter, obwohl niemand
|
|
// zusieht. Ein Messwerkzeug, das Arbeit hinterlässt, ist ein schlechtes.
|
|
if let sitzung = ziel.sessionID {
|
|
await backend.endPlayback(sessionID: sitzung)
|
|
}
|
|
print("")
|
|
print("Transcode-Sitzung beendet.")
|
|
}
|
|
|
|
/// Sagt, woran es liegen kann — aus Jellyfins eigenem Quelltext.
|
|
///
|
|
/// `DynamicHlsHelper.EnableAdaptiveBitrateStreaming` prüft der Reihe nach,
|
|
/// und die erste Hürde ist die überraschendste.
|
|
func erklärungWarumKeineLeiter() {
|
|
print("")
|
|
print(" Warum nur eine Stufe kommt — aus Jellyfins Quelltext,")
|
|
print(" `DynamicHlsHelper.EnableAdaptiveBitrateStreaming`, der Reihe nach:")
|
|
print("")
|
|
print(" 1. Im lokalen Netz liefert Jellyfin grundsätzlich keine Leiter.")
|
|
print(" Wörtlich im Quelltext: „Within the local network this will")
|
|
print(" likely do more harm than good.\" Das gilt auch für ein")
|
|
print(" schwaches WLAN — unterschieden wird da nicht.")
|
|
print(" 2. Ohne den Parameter keine Leiter; im Server steht er auf false.")
|
|
print(" 3. Bei einem laufenden Sender nicht.")
|
|
print(" 4. Wird Bild ODER Ton nur kopiert statt neu gerechnet, nicht.")
|
|
print(" Das trifft oft zu, wenn der Ton schon AAC ist.")
|
|
print("")
|
|
print(" Kam dieser Aufruf von zuhause, ist Punkt 1 die Erklärung — und dann")
|
|
print(" sagt die Messung nichts darüber, was von unterwegs käme.")
|
|
}
|
|
|
|
/// Prüft, ob wir uns die Leiter **selbst** bauen könnten — und ob das etwas
|
|
/// brächte.
|
|
///
|
|
/// Eine Master-Playlist ist nur ein Menü: eine Liste von Adressen. Jellyfins
|
|
/// eigene Leiter bestünde aus derselben `main.m3u8`-Adresse, mehrfach, mit
|
|
/// anderem `VideoBitrate`. Schreiben wir das Menü selbst, brauchen wir seine
|
|
/// Zustimmung nicht.
|
|
///
|
|
/// **Die Playlist zu bekommen beweist aber noch nichts.** Sie ist Text und
|
|
/// kostet nichts; die Datenrate steckt in den Schnipseln. Entscheidend ist
|
|
/// deshalb allein, ob die Schnipsel bei kleinerer Zahl auch kleiner werden —
|
|
/// oder ob der Server stur denselben Strom ausliefert, weil die Sitzung schon
|
|
/// läuft. Deshalb wird hier gewogen, nicht gezählt.
|
|
func prüfeSelbstgebauteLeiter(master: URL, kopfzeilen: [String: String]) async {
|
|
print("")
|
|
print("=== Ginge eine selbstgebaute Leiter — und brächte sie etwas? ===")
|
|
|
|
guard let (daten, _) = try? await URLSession.shared.data(for: anfrage(master, kopfzeilen)),
|
|
let zeile = String(decoding: daten, as: UTF8.self)
|
|
.split(separator: "\n")
|
|
.first(where: { $0.hasPrefix("main.m3u8") }),
|
|
let unter = URL(string: String(zeile), relativeTo: master)?.absoluteURL
|
|
else {
|
|
print(" Keine Unter-Playlist im Menü gefunden.")
|
|
return
|
|
}
|
|
|
|
let versuche: [(String, Int?, Int?)] = [
|
|
("wie gehabt", nil, nil),
|
|
("halbe Bitrate", 3_000_000, nil),
|
|
("ein Viertel", 1_500_000, nil),
|
|
("ein Viertel + 480p", 1_500_000, 480),
|
|
]
|
|
|
|
for (name, bitrate, höhe) in versuche {
|
|
var ziel = unter
|
|
if let bitrate { ziel = setzend("VideoBitrate", auf: String(bitrate), in: ziel) }
|
|
if let höhe { ziel = setzend("maxHeight", auf: String(höhe), in: ziel) }
|
|
print(" \(name): \(await wiegeErstenSchnipsel(ziel, kopfzeilen: kopfzeilen))")
|
|
}
|
|
|
|
print("")
|
|
print(" Werden die Schnipsel kleiner, trägt die Idee. Bleiben sie gleich")
|
|
print(" gross, rechnet der Server trotz anderer Zahl denselben Strom —")
|
|
print(" dann bringt ein selbstgebautes Menü nichts.")
|
|
}
|
|
|
|
/// Holt die Unter-Playlist, lädt den ersten Schnipsel und rechnet aus, was das
|
|
/// an Datenrate bedeutet.
|
|
private func wiegeErstenSchnipsel(_ playlist: URL, kopfzeilen: [String: String]) async -> String {
|
|
guard let (daten, antwort) = try? await URLSession.shared.data(for: anfrage(playlist, kopfzeilen)) else {
|
|
return "nicht erreichbar"
|
|
}
|
|
let status = (antwort as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard (200..<300).contains(status) else { return "HTTP \(status)" }
|
|
|
|
let zeilen = String(decoding: daten, as: UTF8.self).split(separator: "\n")
|
|
guard let dauerZeile = zeilen.firstIndex(where: { $0.hasPrefix("#EXTINF") }),
|
|
zeilen.indices.contains(dauerZeile + 1),
|
|
let schnipselURL = URL(string: String(zeilen[dauerZeile + 1]), relativeTo: playlist)?.absoluteURL
|
|
else { return "HTTP \(status), aber kein Schnipsel darin" }
|
|
|
|
let dauer = Double(
|
|
zeilen[dauerZeile]
|
|
.dropFirst("#EXTINF:".count)
|
|
.prefix { $0 != "," }
|
|
) ?? 0
|
|
|
|
guard let (rohdaten, schnipselAntwort) = try? await URLSession.shared.data(
|
|
for: anfrage(schnipselURL, kopfzeilen)
|
|
) else { return "Playlist ja, Schnipsel nicht erreichbar" }
|
|
|
|
let schnipselStatus = (schnipselAntwort as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard (200..<300).contains(schnipselStatus) else {
|
|
return "Playlist ja, Schnipsel HTTP \(schnipselStatus)"
|
|
}
|
|
|
|
let kilobyte = Double(rohdaten.count) / 1024
|
|
guard dauer > 0 else { return String(format: "%.0f kB", kilobyte) }
|
|
let kilobit = Double(rohdaten.count) * 8 / 1000 / dauer
|
|
return String(format: "%.0f kB für %.1f s → %.0f kbit/s", kilobyte, dauer, kilobit)
|
|
}
|
|
|
|
private func anfrage(_ url: URL, _ kopfzeilen: [String: String]) -> URLRequest {
|
|
var request = URLRequest(url: url)
|
|
for (name, wert) in kopfzeilen { request.setValue(wert, forHTTPHeaderField: name) }
|
|
return request
|
|
}
|