diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index fe3efc2..18aa58f 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -446,7 +446,15 @@ extension PlexServer { // Ohne Profil als dritter Versuch: die Profilangaben stecken // voller `&` und `=`, die in einem Abfragewert mehrdeutig sind. // Trägt es ohne, liegt es daran — und das steht dann in der Spur. - let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)] + // 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), + ] for variant in variants { attempts += 1 let outcome = await attemptStream( @@ -454,6 +462,7 @@ extension PlexServer { ratingKey: channel.id, mediaIndex: variant.index, includeProfile: variant.profile, + keepPathEncoding: variant.keep, maximumBitrate: maximumBitrate, session: session ) @@ -606,6 +615,7 @@ extension PlexServer { ratingKey: String, mediaIndex: Int, includeProfile: Bool, + keepPathEncoding: Bool = true, maximumBitrate: Int?, session: String ) async -> (url: URL?, probe: ChannelProbe) { @@ -619,12 +629,18 @@ extension PlexServer { pathOverride: path ) - guard let url = transcodeURL(for: request, includeProfile: includeProfile) else { + guard let url = transcodeURL( + for: request, + includeProfile: includeProfile, + keepPathEncoding: keepPathEncoding + ) else { return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar")) } let outcome = await probeStream(url: url) - let label = "\(path) (mediaIndex \(mediaIndex)\(includeProfile ? "" : ", ohne Profil"))" + let label = "\(path) (mediaIndex \(mediaIndex)" + + "\(includeProfile ? "" : ", ohne Profil")" + + "\(keepPathEncoding ? "" : ", doppelt kodiert"))" return (outcome.hasPrefix("HTTP 200") ? url : nil, ChannelProbe(path: label, result: outcome)) } @@ -713,6 +729,8 @@ extension PlexServer { } paths += sessionKeys(channel: channel, program: program).map { "\(sessionPath)/\($0)" } + // Und die Sitzung ohne Schlüssel — bisher nie probiert. + paths.append(sessionPath) var seen = Set() return paths.filter { seen.insert($0).inserted } diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index d39e22d..c3781cd 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -9,9 +9,21 @@ extension PlexServer { /// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung /// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung — /// sie wird nicht selbst gerechnet, sondern kommt mit dem Format. + /// - Parameter keepPathEncoding: Ob `path` unverändert eingesetzt wird. + /// + /// Hier steckt der Unterschied zwischen einer laufenden Sendung und + /// „400 Bad Request". Eine Datei in der Mediathek liegt unter + /// `/library/metadata/123` — da gibt es nichts zu kodieren. Eine Sendung + /// beim Programmführer liegt unter + /// `/…/metadata/plex%3A%2F%2Fepisode%2F…`, und der Server dekodiert den + /// Abfragewert einmal, bevor er ihn als Pfad liest. Unverändert + /// eingesetzt kommt bei ihm `plex://episode/…` an — kein gültiger + /// Schlüssel. Er braucht die Kennung ein zweites Mal kodiert, damit nach + /// seinem Dekodieren wieder `%3A%2F%2F` dasteht. public nonisolated func transcodeURL( for request: TranscodeRequest, - includeProfile: Bool = true + includeProfile: Bool = true, + keepPathEncoding: Bool = true ) -> URL? { guard let baseURL = connection.url, var components = URLComponents( @@ -24,16 +36,16 @@ extension PlexServer { URLQueryItem(name: "X-Plex-Token", value: token), ] + (includeProfile ? clientProfileItems : []) + identity.queryItems - // `path` wird von Hand eingesetzt, nachdem alles andere kodiert ist. - // Die Adresse kommt beim Programmanbieter bereits kodiert an, und - // beide Automatiken liegen daneben: `URLComponents` kodiert ein - // zweites Mal (%3A wird zu %253A), vorheriges Entpacken schreibt - // `plex://…` im Klartext in die Anfrage, und daran verschluckt sich - // der Server mit einer nackten 400-Seite. - components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in - item.name == "path" - ? URLQueryItem(name: "path", value: request.percentEncodedPath) - : item + // `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 keepPathEncoding { + components.percentEncodedQueryItems = components.percentEncodedQueryItems?.map { item in + item.name == "path" + ? URLQueryItem(name: "path", value: request.percentEncodedPath) + : item + } } return components.url } diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 86e89d2..c9fde4f 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -170,6 +170,29 @@ struct LiveTVGuideTests { #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" + ) + + 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://")) + } + @Test("Nur was tatsächlich mit 200 antwortet, geht an den Player") func rejectsStreamsTheServerRefuses() async throws { // Vorher galt schon eine gebaute Adresse als Erfolg. Der Fehler