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