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 )) }