From 1803dd670f17b49f05689cb6f1a97b71d13f1650 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 14:18:52 +0200 Subject: [PATCH] Live-TV: die Adresse der Sendung wird genau einmal kodiert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die nackte „400 Bad Request"-Seite war keine Plex-Meldung, sondern der Webserver: er hat die Anfrage selbst nicht verstanden. Grund war mein eigener Fix von vorhin — ich habe `%3A%2F%2F` entpackt, und damit stand `plex://episode/…` im Klartext in der Anfrage. Beide naheliegenden Wege liegen daneben, und ich habe beide durchgemacht: unverändert durchgereicht kodiert URLComponents ein zweites Mal, aus %3A wird %253A. Vorher entpackt bricht sich der Server die Adresse. Richtig ist genau einmal — dafür wird `path` von Hand eingesetzt, nachdem alles andere kodiert ist. Dazu aus der Spur gelernt: jede Form von /livetv/sessions/… antwortet auf diesem Server mit 404. Den Tuner belegt er offenbar selbst, sobald die Sendung umgerechnet werden soll. Deshalb steht die Adresse der Sendung jetzt an erster Stelle, und die Sitzungen kommen nur noch dahinter. Als dritte Form ein Versuch ohne das Geräteprofil: dessen Angaben stecken voller `&` und `=`, die in einem Abfragewert mehrdeutig sind. Trägt es ohne, liegt es daran, und das steht dann in der Spur. Ebenso, was der Programmanbieter überhaupt an Endpunkten angibt. Co-Authored-By: Claude Opus 5 --- .../PlexKit/Endpoints/PlexServer+LiveTV.swift | 123 +++++++++++++----- .../Endpoints/PlexServer+Playback.swift | 19 ++- .../PlexKit/Endpoints/TranscodeRequest.swift | 24 ++-- .../Tests/PlexKitTests/LiveTVGuideTests.swift | 25 ++-- 4 files changed, 137 insertions(+), 54 deletions(-) diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index ccf3679..bacb819 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -382,6 +382,45 @@ extension PlexServer { var trace: [ChannelProbe] = [] var attempts = 0 + // Zuerst die Sendung selbst. Auf diesem Server antwortet jede Form von + // `/livetv/sessions/…` mit 404 — den Tuner belegt er offenbar von + // sich aus, sobald die Sendung umgerechnet werden soll. + if let metadataKey = program?.metadataKey { + // 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)] + for variant in variants { + attempts += 1 + let outcome = await attemptStream( + path: metadataKey, + ratingKey: channel.id, + mediaIndex: variant.index, + includeProfile: variant.profile, + maximumBitrate: maximumBitrate, + session: session + ) + trace.append(outcome.probe) + if let url = outcome.url { + return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + } + } + } + + // Was der Programmanbieter an Endpunkten angibt — für den Fall, dass + // dieser Server den Tuner doch selbst belegt haben will. Steht mit in + // der Spur, damit auch ein Fehlschlag etwas verrät. + if let epgIdentifier = program?.metadataKey? + .split(separator: "/").first.map(String.init) { + let features = await providerFeatures(for: epgIdentifier) + trace.append(ChannelProbe( + path: "Angebotene Endpunkte", + result: features.isEmpty + ? "keine" + : features.map { "\($0.type)=\($0.key ?? "–")" }.joined(separator: ", ") + )) + } + for key in Self.sessionKeys(channel: channel, program: program) { let sessionPath = "/livetv/sessions/\(session)" let openPath = "\(sessionPath)/\(key)" @@ -408,47 +447,19 @@ extension PlexServer { trace.append(ChannelProbe(path: openPath, result: "Sitzung offen")) for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) { - guard attempts < 8 else { break } + guard attempts < 10 else { break } attempts += 1 - let request = TranscodeRequest( + let outcome = await attemptStream( + path: candidate.path, ratingKey: item.ratingKey, mediaIndex: candidate.mediaIndex, - partIndex: candidate.mediaIndex, + includeProfile: true, maximumBitrate: maximumBitrate, - isLocal: connection.local, - sessionIdentifier: session, - pathOverride: candidate.path + session: session ) - guard let url = transcodeURL(for: request) else { continue } - - let outcome = await probeStream(url: url) - trace.append(ChannelProbe( - path: "\(candidate.path) (mediaIndex \(candidate.mediaIndex))", - result: outcome - )) - - if outcome.hasPrefix("HTTP 200") { - return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) - } - } - } - - // Letzter Versuch ohne eigene Sitzung: die Sendung unmittelbar - // umrechnen lassen. Auf Servern, die den Tuner selbst belegen, genügt - // das — und geprüft wird es genauso wie alles andere. - if let metadataKey = program?.metadataKey, attempts < 8 { - let request = TranscodeRequest( - ratingKey: channel.id, - maximumBitrate: maximumBitrate, - isLocal: connection.local, - sessionIdentifier: session, - pathOverride: metadataKey - ) - if let url = transcodeURL(for: request) { - let outcome = await probeStream(url: url) - trace.append(ChannelProbe(path: metadataKey, result: outcome)) - if outcome.hasPrefix("HTTP 200") { + trace.append(outcome.probe) + if let url = outcome.url { return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) } } @@ -460,6 +471,39 @@ extension PlexServer { return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace) } + /// Baut eine Stromadresse und ruft sie einmal ab. + /// + /// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche + /// Funktion greift über jede Unterbrechung hinweg auf die veränderlichen + /// Werte daneben zu, und genau das lässt der Nebenläufigkeitsprüfer von + /// Swift 6 nicht durchgehen. + private func attemptStream( + path: String, + ratingKey: String, + mediaIndex: Int, + includeProfile: Bool, + maximumBitrate: Int?, + session: String + ) async -> (url: URL?, probe: ChannelProbe) { + let request = TranscodeRequest( + ratingKey: ratingKey, + mediaIndex: mediaIndex, + partIndex: mediaIndex, + maximumBitrate: maximumBitrate, + isLocal: connection.local, + sessionIdentifier: session, + pathOverride: path + ) + + guard let url = transcodeURL(for: request, includeProfile: includeProfile) else { + return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar")) + } + + let outcome = await probeStream(url: url) + let label = "\(path) (mediaIndex \(mediaIndex)\(includeProfile ? "" : ", ohne Profil"))" + return (outcome.hasPrefix("HTTP 200") ? url : nil, ChannelProbe(path: label, result: outcome)) + } + /// Welche Adresse mit welchem Index umzurechnen ist. /// /// Die Adresse der Sitzung zuerst — der Transcoder sucht sich den Teil @@ -521,6 +565,15 @@ extension PlexServer { // Volle Adressen taugen hier nicht: an `/livetv/sessions//` // gehört ein Schlüssel, kein zweiter Pfad. keys = keys.map { $0.split(separator: "/").last.map(String.init) ?? $0 } + // Und ein Schlüssel, der noch `%3A` enthält, ist als Pfadabschnitt + // keiner — die entpackte Form gehört mit in die Auswahl. Aber nur, + // solange dabei kein Schrägstrich herauskommt: `plex://movie/abc` ist + // als ein Pfadabschnitt genauso wenig brauchbar. + keys += keys.compactMap { key in + guard key.contains("%"), let plain = key.removingPercentEncoding, + !plain.contains("/") else { return nil } + return plain + } var seen = Set() return keys.filter { !$0.isEmpty && 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 c3530e9..d39e22d 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -9,7 +9,10 @@ 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. - public nonisolated func transcodeURL(for request: TranscodeRequest) -> URL? { + public nonisolated func transcodeURL( + for request: TranscodeRequest, + includeProfile: Bool = true + ) -> URL? { guard let baseURL = connection.url, var components = URLComponents( url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"), @@ -19,7 +22,19 @@ extension PlexServer { components.queryItems = request.queryItems + [ URLQueryItem(name: "X-Plex-Token", value: token), - ] + clientProfileItems + identity.queryItems + ] + (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 + } return components.url } diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift index dabb847..bc4f407 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift @@ -56,16 +56,24 @@ public struct TranscodeRequest: Sendable, Equatable { self.pathOverride = pathOverride } + /// Die Adresse, die umgerechnet werden soll — so, wie sie in die Anfrage + /// gehört: **bereits kodiert**. + /// + /// Hier lagen beide naheliegenden Wege daneben. Unverändert durch + /// `URLComponents` geschickt, wird sie ein zweites Mal kodiert: aus `%3A` + /// wird `%253A`. Vorher entpackt, steht `plex://episode/…` im Klartext in + /// der Anfrage, und daran verschluckt sich der Server — er antwortet mit + /// einer nackten „400 Bad Request"-Seite, ohne Plex-Meldung. + /// + /// Richtig ist: genau einmal kodiert. Darum kümmert sich `transcodeURL`, + /// indem es diesen Wert unangetastet einsetzt. + var percentEncodedPath: String { + pathOverride ?? "/library/metadata/\(ratingKey)" + } + var queryItems: [URLQueryItem] { var items = [ - // Einmal entpackt, weil die Adresse beim Programmanbieter schon - // kodiert ankommt und `URLComponents` sonst ein zweites Mal - // kodiert — aus %3A wird %253A, und der Server findet nichts. - URLQueryItem( - name: "path", - value: pathOverride.map { $0.removingPercentEncoding ?? $0 } - ?? "/library/metadata/\(ratingKey)" - ), + URLQueryItem(name: "path", value: percentEncodedPath), URLQueryItem(name: "mediaIndex", value: String(mediaIndex)), URLQueryItem(name: "partIndex", value: String(partIndex)), URLQueryItem(name: "protocol", value: "hls"), diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 52b3367..5da8a8b 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -150,18 +150,24 @@ struct LiveTVGuideTests { #expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") }) } - @Test("Eine Adresse beim Programmanbieter wird nicht doppelt kodiert") - func doesNotDoubleEncode() throws { - // Derselbe Fehler wie einst beim Anmelde-Link: `URLComponents` kodiert - // ein zweites Mal, aus %3A wird %253A, und der Server findet nichts. - let request = TranscodeRequest( + @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. + 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 path = try #require(request.queryItems.first { $0.name == "path" }?.value) + ))) + let text = url.absoluteString - #expect(path == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/abc") + #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("Nur was tatsächlich mit 200 antwortet, geht an den Player") @@ -227,7 +233,8 @@ struct LiveTVGuideTests { let keys = PlexServer.sessionKeys(channel: channel, program: program) #expect(keys.first == "gk-5") - // An `/livetv/sessions//` gehört ein Schlüssel, kein zweiter Pfad. + // An `/livetv/sessions//` gehört ein Schlüssel, kein zweiter + // Pfad — auch nicht nach dem Entpacken. #expect(keys.allSatisfy { !$0.contains("/") }) #expect(keys.contains("5")) }