diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index f4e7c17..b31bbcc 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -500,12 +500,7 @@ extension PlexServer { // Doppelt kodiert zuerst: der Server dekodiert den Abfragewert // einmal, bevor er ihn als Pfad liest. Nur so kommt bei ihm // wieder `plex%3A%2F%2Fepisode%2F…` an statt `plex://episode/…`. - let variants: [(index: Int, profile: Bool, keep: Bool)] = [ - (0, true, false), - (-1, true, false), - (0, true, true), - (0, false, false), - ] + let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)] for variant in variants { attempts += 1 let outcome = await attemptStream( @@ -513,7 +508,6 @@ extension PlexServer { ratingKey: channel.id, mediaIndex: variant.index, includeProfile: variant.profile, - keepPathEncoding: variant.keep, maximumBitrate: maximumBitrate, session: session ) @@ -848,7 +842,6 @@ extension PlexServer { ratingKey: String, mediaIndex: Int, includeProfile: Bool, - keepPathEncoding: Bool = true, minimal: Bool = false, maximumBitrate: Int?, session: String @@ -866,7 +859,6 @@ extension PlexServer { guard let url = transcodeURL( for: request, includeProfile: includeProfile, - keepPathEncoding: keepPathEncoding, minimal: minimal ) else { return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar")) @@ -875,7 +867,6 @@ extension PlexServer { let outcome = await probeStream(url: url) let label = "\(path) (mediaIndex \(mediaIndex)" + "\(includeProfile ? "" : ", ohne Profil")" - + "\(keepPathEncoding ? "" : ", doppelt kodiert")" + "\(minimal ? ", nur das Nötigste" : "") )" // Die gebaute Adresse mit in die Spur. Ohne sie diskutiert man über @@ -1304,21 +1295,17 @@ extension PlexServer { } var report: [String] = [] - for keepEncoding in [true, false] { + do { let request = TranscodeRequest( ratingKey: channel.id, isLocal: connection.local, pathOverride: metadataKey ) - guard let url = transcodeURL( - for: request, - keepPathEncoding: keepEncoding - ) else { continue } + guard let url = transcodeURL(for: request), + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + else { return "Adresse nicht baubar" } - guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - else { continue } - - report.append("--- Entscheider, \(keepEncoding ? "einfach" : "doppelt") kodiert ---") + report.append("--- Entscheider ---") do { let data = try await client.send(PlexRequest( path: "/video/:/transcode/universal/decision", diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 0b92653..36c3f28 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -23,7 +23,6 @@ extension PlexServer { public nonisolated func transcodeURL( for request: TranscodeRequest, includeProfile: Bool = true, - keepPathEncoding: Bool = true, minimal: Bool = false ) -> URL? { guard let baseURL = connection.url, @@ -51,48 +50,34 @@ extension PlexServer { : request.queryItems + [ URLQueryItem(name: "X-Plex-Token", value: token), ] + (includeProfile ? clientProfileItems : []) + identity.queryItems - components.queryItems = items - components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in - // `path` von Hand einsetzen heißt: genau einmal kodiert. Das ist - // für Dateien richtig. Für eine Sendung beim Programmführer muss - // die Kennung ein zweites Mal kodiert werden — dann übernimmt das - // `URLComponents` von selbst, und hier ist nichts zu tun. - if item.name == "path", keepPathEncoding { - return URLQueryItem(name: "path", value: request.percentEncodedPath) + // Jeder Wert streng kodiert, ohne Ausnahme — genauso macht es Plex' + // eigener Client, der alle Parameter durch `qs.stringify` schickt. + // + // `URLComponents` allein tut das nicht: es lässt `/`, `:`, `&`, `=`, + // `+` und Klammern in Werten stehen. Damit kam beim Server + // `path=/livetv/sessions/…` an statt `path=%2Flivetv%2Fsessions%2F…`, + // und das Geräteprofil zerfiel an seinen `&` in zwei Dutzend eigene + // Angaben. Beides beantwortet er mit einer nackten 400. + // + // Nebenbei löst diese eine Regel die Kodierfrage, die mich mehrere + // Anläufe gekostet hat: eine Kennung, die bereits `%3A` enthält, wird + // hier zu `%253A` — und ist damit genau einmal richtig kodiert. + components.percentEncodedQuery = items + .map { item in + let value = item.value? + .addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed) ?? "" + return "\(item.name)=\(value)" } + .joined(separator: "&") - // Das Geräteprofil ist ein Wert voller `&`, `=` und Klammern — - // und `URLComponents` lässt genau die unangetastet. Der Server - // liest sie dann als eigene Parameter: aus einem Profil werden - // zwei Dutzend Angaben, darunter ein zweites `protocol=`. Das - // steckte in **jeder** Transcoder-Adresse, seit es sie gibt. - if item.name.hasSuffix("Profile-Extra"), - // Der **ursprüngliche** Wert, nicht der bereits von - // `URLComponents` behandelte: den ein zweites Mal zu kodieren - // macht aus `%3D` ein `%253D`. - let value = items.first(where: { $0.name == item.name })?.value { - return URLQueryItem( - name: item.name, - value: value.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed) - ?? value - ) - } - - return item - } return components.url } /// Baut die Adresse eines Stroms, den der Server schon bereitstellt. /// - /// Für Live-TV. Das Belegen eines Tuners liefert eine fertige - /// HLS-Abspielliste — die gehört abgerufen, nicht umgerechnet. Sie durch - /// den Transcoder zu schicken heißt, ihn um die Umrechnung von etwas zu - /// bitten, das bereits fertig ist; er antwortet mit einer nackten 400. - /// - /// Token und Gerätekennung müssen in die Adresse, weil sie der - /// Systemplayer abruft und der keine Kopfzeilen mitschickt. + /// Für Live-TV. Token und Gerätekennung müssen in die Adresse, weil sie + /// der Systemplayer abruft und der keine Kopfzeilen mitschickt. public nonisolated func directStreamURL(forPath path: String) -> URL? { guard let baseURL = connection.url, var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 6e01747..8e27e07 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -150,47 +150,24 @@ struct LiveTVGuideTests { #expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") }) } - @Test("Die Adresse der Sendung wird genau einmal kodiert") - func encodesPathExactlyOnce() async throws { - // Beide naheliegenden Wege liegen daneben, und beide habe ich - // durchgemacht: unverändert durchgereicht kodiert `URLComponents` ein - // zweites Mal (%3A wird %253A); vorher entpackt steht `plex://…` im - // Klartext in der Anfrage, und daran verschluckt sich der Server mit - // einer nackten „400 Bad Request"-Seite. + @Test("Jeder Wert wird streng kodiert, Schrägstriche eingeschlossen") + func encodesEveryValueStrictly() async throws { + // Plex' eigener Client schickt alle Parameter durch `qs.stringify`, + // und das kodiert alles. `URLComponents` allein lässt `/`, `:`, `&` + // und `=` in Werten stehen — beim Server kam `path=/livetv/…` an + // statt `path=%2Flivetv%2F…`, und die Antwort war eine nackte 400. let server = server(routes: [:]) let url = try #require(await server.transcodeURL(for: TranscodeRequest( - ratingKey: "5", - isLocal: true, - pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc" - ))) - let text = url.absoluteString - - #expect(text.contains("path=/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc")) - #expect(!text.contains("%253A")) - #expect(!text.contains("plex://")) - } - - @Test("Für eine Sendung wird die Kennung zweimal kodiert") - func doubleEncodesForLive() async throws { - // Der Server dekodiert den Abfragewert einmal, bevor er ihn als Pfad - // liest. Genau einmal kodiert kommt bei ihm `plex://episode/…` an — - // kein gültiger Schlüssel, und die Antwort ist eine nackte 400. - let server = server(routes: [:]) - let request = TranscodeRequest( ratingKey: "5", isLocal: true, pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc" - ) + ))).absoluteString - let once = try #require(await server.transcodeURL(for: request)).absoluteString - let twice = try #require( - await server.transcodeURL(for: request, keepPathEncoding: false) - ).absoluteString - - #expect(once.contains("plex%3A%2F%2Fepisode%2Fabc")) - #expect(twice.contains("plex%253A%252F%252Fepisode%252Fabc")) - // Und niemals im Klartext: daran verschluckt sich der Server. - #expect(!twice.contains("plex://")) + #expect(url.contains("path=%2Ftv.plex.providers.epg.cloud%3A2%2Fmetadata%2F")) + // Eine Kennung, die schon `%3A` trägt, wird dabei zu `%253A` — und + // ist damit genau einmal kodiert. + #expect(url.contains("plex%253A%252F%252Fepisode%252Fabc")) + #expect(!url.contains("plex://")) } @Test("Nur was tatsächlich mit 200 antwortet, geht an den Player") @@ -367,6 +344,7 @@ struct LiveTVGuideTests { // Das ganze Profil steht in genau einem Parameter. #expect(profile.contains("%26")) #expect(profile.contains("add-transcode-target%28")) + #expect(profile.contains("%2B")) // Und `protocol` kommt nur ein einziges Mal vor. #expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1) }