diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index c568e02..8047db1 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -691,16 +691,18 @@ extension PlexServer { // angelegt hat, wird umgerechnet — nicht die Abspielliste darin und // nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort. if let sessionKey = Self.firstSessionKey(in: data) { - let outcome = await attemptStream( - path: sessionKey, - ratingKey: channel.id, - mediaIndex: 0, - includeProfile: true, - maximumBitrate: maximumBitrate, - session: session - ) - probes.append(outcome.probe) - if let url = outcome.url { return (url, subscription, probes) } + for withProfile in [true, false] { + let outcome = await attemptStream( + path: sessionKey, + ratingKey: channel.id, + mediaIndex: 0, + includeProfile: withProfile, + maximumBitrate: maximumBitrate, + session: session + ) + probes.append(outcome.probe) + if let url = outcome.url { return (url, subscription, probes) } + } } // Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400: diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 62f474b..e7e716d 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -32,20 +32,38 @@ extension PlexServer { ) else { return nil } - components.queryItems = request.queryItems + [ + let items = request.queryItems + [ URLQueryItem(name: "X-Plex-Token", value: token), ] + (includeProfile ? clientProfileItems : []) + identity.queryItems + components.queryItems = items - // `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 + 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) } + + // 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 } @@ -298,3 +316,15 @@ extension PlexServer { return components.url } } + + +extension CharacterSet { + /// Was in einem Abfragewert unangetastet bleiben darf. + /// + /// Streng gehalten: alles andere wird kodiert. `&` und `=` sind hier die + /// eigentlichen Übeltäter — sie trennen Parameter, und `URLComponents` + /// kodiert sie in Werten nicht von selbst. + static let plexParameterAllowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 6993836..6e01747 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -350,6 +350,27 @@ struct LiveTVGuideTests { == "/livetv/sessions/1268be73-1598-4fb9-ac9e-35825eba3fdd") } + @Test("Das Geräteprofil zerfällt nicht in eigene Parameter") + func encodesProfileExtra() async throws { + // `URLComponents` lässt `&` und `=` in einem Wert unangetastet. Das + // Geräteprofil steckt voll davon, und der Server las es als zwei + // Dutzend eigene Angaben — darunter ein zweites `protocol=`. Der + // Fehler steckte in jeder Transcoder-Adresse, seit es sie gibt. + let server = server(routes: [:]) + let url = try #require( + await server.transcodeURL(for: TranscodeRequest(ratingKey: "5", isLocal: true)) + ) + let text = url.absoluteString + let profile = try #require(text.split(separator: "&") + .first { $0.hasPrefix("X-Plex-Client-Profile-Extra=") }) + + // Das ganze Profil steht in genau einem Parameter. + #expect(profile.contains("%26")) + #expect(profile.contains("add-transcode-target%28")) + // Und `protocol` kommt nur ein einziges Mal vor. + #expect(text.split(separator: "&").filter { $0.hasPrefix("protocol=") }.count == 1) + } + @Test("Jede Wiedergabe meldet, dass sie die Entscheidungsantwort versteht") func alwaysSendsHasMDE() { // Plex' eigener Client schickt `hasMDE=1` bei jeder Wiedergabe mit.