Sie hat am Server eine Transcode-Sitzung angelegt und nicht wieder beendet — ein Messwerkzeug, das Arbeit hinterlässt, ist ein schlechtes. Jetzt läuft `endPlayback` am Ende. Und sie erklärt das Ergebnis, statt es nur hinzustellen. Kommt nur eine Stufe zurück, nennt sie die Hürden aus Jellyfins eigenem Quelltext (`DynamicHlsHelper.EnableAdaptiveBitrateStreaming`) — allen voran die erste und überraschendste: im lokalen Netz liefert Jellyfin **grundsätzlich** keine Leiter. Wörtlich dort: „Within the local network this will likely do more harm than good." Ein schwaches WLAN ist für Jellyfin auch nur lokales Netz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
9.4 KiB
Swift
242 lines
9.4 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)")
|
|
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")
|
|
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.")
|
|
}
|