Der Kern des Ruckelproblems: die App bestellte bei beiden Servern eine HLS-Liste mit genau einer Variante. AVPlayer kann nur zwischen Stufen wechseln, die in der Liste stehen — bei einer gibt es nichts zu wechseln, und wenn die Leitung einbricht, kann nichts nachgeben. **Jellyfin.** `enableAdaptiveBitrateStreaming` wird gesetzt, und zwar ersetzend: die Voreinstellung im Server ist `false`, und bei zwei gleichnamigen Werten gilt beim Server der erste — dieselbe Falle wie bei `maxHeight` eine Funktion weiter oben. Dazu wird `main.m3u8` zu `master.m3u8`, falls der Server die Schnipselliste nennt; nur auf der Stufenliste kann der Parameter wirken. Am Geräteprofil ändert sich **nichts**. Mehrere `TranscodingProfiles` wären der naheliegende Fehlgriff — die beschreiben Formate, nicht Bitraten. `MaxStreamingBitrate` bleibt die oberste Sprosse; was darunter liegt, erfindet der Server. **Plex.** `autoAdjustQuality=1`, als Eigenschaft der Anfrage statt als feste Zeile: `queryItems` speist auch die Adresse zum Herunterladen, und dort ergäbe eine Leiter keinen Sinn. Bewusst **nicht** am Entscheider — der antwortet auf Unbekanntes mit nackten 400ern, wofür es hier eigens `transcoderBisect` gibt. Er beantwortet ohnehin nur „darf das gespielt werden". Stünde der Wert dort und nähme der Server ihn übel, wäre nicht die Leiter kaputt, sondern die Wiedergabe. Beides nur bei „automatisch". Wer im Blatt ausdrücklich „720p · 3 Mbps" antippt, bekommt das und nicht „höchstens 3, meistens weniger" unter unverändertem Etikett. **Was das nicht kann, und das gehört dazugesagt:** Jellyfin verweigert die Leiter im lokalen Netz grundsätzlich — „Within the local network this will likely do more harm than good." Ein schwaches WLAN zählt dort mit, und vom Client aus ist das nicht zu überstimmen. Ebenso fällt sie weg, wenn Bild oder Ton nur kopiert statt gerechnet werden. Ob am Ende wirklich mehrere Stufen ankommen, steht deshalb nicht im Code, sondern in `PlaybackStatistics.variantCount`. Elf neue Tests, darunter der doppelte Parameter, die andere Schreibweise, und dass die Sitzungskennung beim Umbau dieselbe bleibt — sonst bliebe beim Aufräumen ein Transcoder stehen. Die Probe zeigt jetzt außerdem, ob kopiert statt gerechnet wird. Das ist die zweite Hürde, und sie ist von zuhause aus nicht sichtbar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
10 KiB
Swift
263 lines
10 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.
|
|
|
|
/// 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)")
|
|
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() }
|
|
|
|
// 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.")
|
|
}
|