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