From cdff53fa0d1e4832aea96a74ba7cbcc456239826 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 27 Sep 2026 15:26:57 +0200 Subject: [PATCH] Eine Leiter bestellen statt einer einzelnen Stufe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Views/Player/PlayerModel.swift | 10 ++- .../FlixyKit/Sources/FlixyProbe/Leiter.swift | 23 +++++- .../JellyfinBackend+Playback.swift | 44 ++++++++++- .../Sources/MediaKit/MediaPlayback.swift | 14 +++- .../PlexKit/Endpoints/TranscodeRequest.swift | 41 +++++++++- .../Sources/PlexKit/PlexBackend.swift | 13 +++- .../JellyfinKitTests/AdaptiveURLTests.swift | 76 +++++++++++++++++++ .../PlexKitTests/TranscodeRequestTests.swift | 45 +++++++++++ 8 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 Packages/FlixyKit/Tests/JellyfinKitTests/AdaptiveURLTests.swift diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 89d2108..4244c22 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -491,7 +491,15 @@ final class PlayerModel { let transcode: TranscodeSettings? = switch decision.method { case .directPlay: nil case .transcode(let maximumBitrate): - TranscodeSettings(maximumBitrate: maximumBitrate, resolution: quality.resolution) + TranscodeSettings( + maximumBitrate: maximumBitrate, + resolution: quality.resolution, + // **Nur bei „automatisch".** Eine ausdrücklich gewählte Stufe + // ist eine Ansage, keine Obergrenze: wer „720p · 3 Mbps" + // antippt, bekommt das und nicht „höchstens 3, meistens + // weniger" unter unverändertem Etikett. + prefersAdaptive: quality == .automatic + ) } do { diff --git a/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift b/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift index d0e1fc5..487710c 100644 --- a/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift +++ b/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift @@ -188,7 +188,28 @@ func jellyfinLeiter(host: String) async { 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") + 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)) diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift index 2955cfd..7eff6ff 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift @@ -56,8 +56,11 @@ extension JellyfinBackend { )) } + var adresse = Self.limiting(height: transcode.resolution, in: url) + if transcode.prefersAdaptive { adresse = Self.adaptive(adresse) } + return PlaybackTarget( - url: Self.limiting(height: transcode.resolution, in: url), + url: adresse, sessionID: info.playSessionID, versionID: source?.id ?? sourceID ) @@ -200,6 +203,45 @@ extension JellyfinBackend { components.queryItems = items return components.url ?? url } + + /// Bestellt eine **Leiter** statt einer einzelnen Stufe. + /// + /// Zwei Handgriffe, und beide sind nötig: + /// + /// **`main.m3u8` wird zu `master.m3u8`.** Die eine ist die Liste der + /// Schnipsel *einer* Stufe, die andere die Liste der Stufen. Nur auf der + /// zweiten kann der Parameter überhaupt wirken — auf der ersten gibt es + /// keine Varianten, über die zu entscheiden wäre. Heißt die Adresse schon + /// `master.m3u8`, bleibt sie. + /// + /// **Der Parameter wird gesetzt, nicht danebengestellt.** Die + /// Voreinstellung im Server ist `false`; ohne ihn kommt in jedem Fall nur + /// eine Stufe. Und bei zwei gleichnamigen Werten gilt beim Server der + /// erste — dieselbe Falle wie bei `maxHeight` eine Funktion weiter oben. + /// + /// **Was das nicht kann.** Jellyfin verweigert die Leiter im lokalen Netz + /// grundsätzlich, und zwar mit Absicht: „Within the local network this + /// will likely do more harm than good." Ein schwaches WLAN zählt dort mit. + /// Ebenso fällt sie weg, wenn Bild **oder** Ton nur kopiert statt neu + /// gerechnet werden. Ob am Ende wirklich mehrere Stufen ankommen, steht + /// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und + /// das ist die einzige belastbare Auskunft darüber. + static func adaptive(_ url: URL) -> URL { + var url = url + if url.lastPathComponent == "main.m3u8" { + url = url.deletingLastPathComponent().appending(path: "master.m3u8") + } + + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + var items = (components.queryItems ?? []).filter { + $0.name.lowercased() != "enableadaptivebitratestreaming" + } + items.append(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true")) + components.queryItems = items + return components.url ?? url + } } /// Was der Server für eine Wiedergabe vorbereitet hat. diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift index 08ac434..5604a95 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift @@ -43,10 +43,22 @@ public struct TranscodeSettings: Sendable, Equatable { public let maximumBitrate: Int? /// Etwa „1280x720". `nil` lässt die Auflösung, wie sie ist. public let resolution: String? + /// Ob der Server eine **Leiter** liefern soll statt einer festen Stufe. + /// + /// Dann enthält die Playlist mehrere Varianten, und der Player wechselt + /// zwischen ihnen, ohne dass der Strom neu aufgebaut werden muss. Die + /// angegebene Bitrate bleibt dabei die **oberste** Sprosse; was darunter + /// liegt, erfindet der Server. + /// + /// Nur bei `automatic` gesetzt. Wer im Blatt ausdrücklich „720p · 3 Mbps" + /// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger" + /// unter unverändertem Etikett. + public let prefersAdaptive: Bool - public init(maximumBitrate: Int?, resolution: String?) { + public init(maximumBitrate: Int?, resolution: String?, prefersAdaptive: Bool = false) { self.maximumBitrate = maximumBitrate self.resolution = resolution + self.prefersAdaptive = prefersAdaptive } } diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift index 9e0a89d..c6bbb3f 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift @@ -31,6 +31,8 @@ public struct TranscodeRequest: Sendable, Equatable { /// Programmanbieter. Der Transcoder nimmt beides — aber nur, wenn man /// ihm die richtige Adresse nennt. public let pathOverride: String? + /// Ob Plex die Qualität selbst nachregeln soll. + public let adaptive: Bool public init( ratingKey: String, @@ -42,7 +44,8 @@ public struct TranscodeRequest: Sendable, Equatable { sessionIdentifier: String = UUID().uuidString, subtitleStreamID: Int? = nil, audioStreamID: Int? = nil, - pathOverride: String? = nil + pathOverride: String? = nil, + adaptive: Bool = false ) { self.ratingKey = ratingKey self.mediaIndex = mediaIndex @@ -54,6 +57,28 @@ public struct TranscodeRequest: Sendable, Equatable { self.subtitleStreamID = subtitleStreamID self.audioStreamID = audioStreamID self.pathOverride = pathOverride + self.adaptive = adaptive + } + + /// Dieselbe Anfrage, aber mit Leiter. + /// + /// Dieselbe Sitzungskennung: es ist **eine** Wiedergabe, und der Server + /// soll sie nicht für zwei halten — sonst bliebe beim Aufräumen ein + /// Transcoder stehen. + func askingForLadder() -> TranscodeRequest { + TranscodeRequest( + ratingKey: ratingKey, + mediaIndex: mediaIndex, + partIndex: partIndex, + maximumBitrate: maximumBitrate, + resolution: resolution, + isLocal: isLocal, + sessionIdentifier: sessionIdentifier, + subtitleStreamID: subtitleStreamID, + audioStreamID: audioStreamID, + pathOverride: pathOverride, + adaptive: true + ) } /// Die Adresse, die umgerechnet werden soll — so, wie sie in die Anfrage @@ -96,6 +121,20 @@ public struct TranscodeRequest: Sendable, Equatable { URLQueryItem(name: "X-Plex-Session-Identifier", value: sessionIdentifier), ] + // **Eine Leiter statt einer Stufe.** + // + // Plex' Universal Transcoder liefert dann eine Playlist mit mehreren + // Varianten; die bestellte Bitrate bleibt die oberste. Ohne diesen + // Wert gibt es genau eine, und AVPlayer hat nichts zum Umschalten. + // + // Als Eigenschaft und nicht als feste Zeile, weil `queryItems` auch + // die Adresse zum **Herunterladen** speist — dort ergäbe eine Leiter + // keinen Sinn, und mit einer Eigenschaft muss dort nichts + // herausgefiltert werden. + if adaptive { + items.append(URLQueryItem(name: "autoAdjustQuality", value: "1")) + } + if let maximumBitrate { items.append(URLQueryItem(name: "maxVideoBitrate", value: String(maximumBitrate))) // `videoQuality` skaliert mit: ohne sie ignoriert Plex die diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift index 094706d..f6b102b 100644 --- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -155,13 +155,22 @@ public struct PlexBackend: MediaBackend { // Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier schon // nein, steht der Grund im Klartext da — statt eines schwarzen Bildes // nach zwanzig Sekunden Warten. + // + // **Ohne `autoAdjustQuality`**, und das mit Absicht: der Entscheider + // antwortet auf Unbekanntes mit nackten 400ern — dafür gibt es in + // diesem Paket eigens `transcoderBisect`. Er beantwortet ohnehin nur + // „darf das gespielt werden", und daran ändert eine Leiter nichts. + // Stünde der Wert hier und der Server nähme ihn übel, wäre nicht die + // Leiter kaputt, sondern die Wiedergabe. let decision = try await mapping { try await server.transcodeDecision(for: request) } guard decision.isAllowed else { throw .refused(reason: decision.explanation ?? "") } - guard let url = server.transcodeURL(for: request) else { throw .notFound } - return PlaybackTarget(url: url, sessionID: request.sessionIdentifier) + // Erst für die Adresse, die wirklich gespielt wird. + let start = transcode.prefersAdaptive ? request.askingForLadder() : request + guard let url = server.transcodeURL(for: start) else { throw .notFound } + return PlaybackTarget(url: url, sessionID: start.sessionIdentifier) } public func endPlayback(sessionID: String) async { diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/AdaptiveURLTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/AdaptiveURLTests.swift new file mode 100644 index 0000000..046ba51 --- /dev/null +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/AdaptiveURLTests.swift @@ -0,0 +1,76 @@ +import Testing +import Foundation +import MediaKit +@testable import JellyfinKit + +@Suite("Leiter bestellen") +struct AdaptiveURLTests { + private func url(_ text: String) -> URL { URL(string: text)! } + + private func werte(_ url: URL, _ name: String) -> [String] { + (URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []) + .filter { $0.name.lowercased() == name.lowercased() } + .compactMap(\.value) + } + + /// `main.m3u8` ist die Liste der Schnipsel **einer** Stufe. Auf ihr kann + /// der Parameter nichts bewirken — es gibt dort keine Varianten. + @Test("Aus der Schnipselliste wird die Stufenliste") + func mainBecomesMaster() { + let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/main.m3u8?Tag=1")) + #expect(ergebnis.lastPathComponent == "master.m3u8") + // Die übrige Abfrage bleibt unangetastet. + #expect(werte(ergebnis, "Tag") == ["1"]) + } + + @Test("Eine Stufenliste bleibt, wie sie ist") + func masterStays() { + let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8")) + #expect(ergebnis.lastPathComponent == "master.m3u8") + } + + /// Die Falle, an der `maxHeight` schon einmal hängen geblieben ist: bei + /// zwei gleichnamigen Werten gilt beim Server der erste. Ein + /// danebengestellter zweiter bewirkt nichts. + @Test("Der Parameter steht genau einmal da") + func parameterAppearsOnce() { + let ergebnis = JellyfinBackend.adaptive( + url("http://h/videos/abc/master.m3u8?enableAdaptiveBitrateStreaming=false&Tag=1") + ) + #expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"]) + #expect(werte(ergebnis, "Tag") == ["1"]) + } + + @Test("Ohne vorhandenen Wert kommt er dazu") + func parameterIsAdded() { + let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8?Tag=1")) + #expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"]) + } + + /// Groß- und Kleinschreibung ist beim Server gleichgültig — beim + /// Herausfiltern darf sie es nicht sein, sonst steht er doch zweimal da. + @Test("Auch anders geschrieben wird er ersetzt") + func parameterIsReplacedRegardlessOfCase() { + let ergebnis = JellyfinBackend.adaptive( + url("http://h/videos/abc/master.m3u8?EnableAdaptiveBitrateStreaming=false") + ) + let alle = (URLComponents(url: ergebnis, resolvingAgainstBaseURL: false)?.queryItems ?? []) + .filter { $0.name.lowercased() == "enableadaptivebitratestreaming" } + #expect(alle.count == 1) + #expect(alle.first?.value == "true") + } + + /// Die Höhe gehört weiterhin dran: die Leiter ist die Abstufung, die + /// Obergrenze bleibt unsere. + @Test("Höhenbegrenzung und Leiter vertragen sich") + func heightAndLadderCoexist() { + let begrenzt = JellyfinBackend.limiting( + height: "1280x720", + in: url("http://h/videos/abc/main.m3u8") + ) + let ergebnis = JellyfinBackend.adaptive(begrenzt) + #expect(werte(ergebnis, "maxHeight") == ["720"]) + #expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"]) + #expect(ergebnis.lastPathComponent == "master.m3u8") + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift index 640f205..4e99f31 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift @@ -139,3 +139,48 @@ struct TranscodeDecisionTests { #expect(decision.isAllowed) } } + +@Suite("Leiter bei Plex") +struct PlexLadderTests { + private func werte(_ request: TranscodeRequest, _ name: String) -> [String] { + request.queryItems + .filter { $0.name == name } + .compactMap(\.value) + } + + @Test("Ohne Wunsch keine Leiter") + func noLadderByDefault() { + let request = TranscodeRequest(ratingKey: "1", isLocal: false) + #expect(werte(request, "autoAdjustQuality").isEmpty) + } + + @Test("Mit Wunsch steht der Wert genau einmal da") + func ladderIsRequested() { + let request = TranscodeRequest(ratingKey: "1", isLocal: false, adaptive: true) + #expect(werte(request, "autoAdjustQuality") == ["1"]) + } + + /// Die Bitrate bleibt die **oberste** Sprosse, nicht die einzige — beide + /// Angaben gehören zusammen in die Anfrage. + @Test("Die Obergrenze bleibt neben der Leiter stehen") + func ceilingSurvives() { + let request = TranscodeRequest( + ratingKey: "1", + maximumBitrate: 8_000, + isLocal: false, + adaptive: true + ) + #expect(werte(request, "autoAdjustQuality") == ["1"]) + #expect(werte(request, "maxVideoBitrate") == ["8000"]) + } + + /// Dieselbe Wiedergabe, nicht zwei: sonst bliebe beim Aufräumen ein + /// Transcoder stehen, weil die Kennung nicht mehr passt. + @Test("Die Sitzungskennung bleibt dieselbe") + func sessionSurvives() { + let original = TranscodeRequest(ratingKey: "1", isLocal: false) + let mitLeiter = original.askingForLadder() + #expect(mitLeiter.sessionIdentifier == original.sessionIdentifier) + #expect(mitLeiter.adaptive) + } +}