From 8dba05b4f98e817e900b9572630da2226ae2b38e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 14:02:12 +0200 Subject: [PATCH] Wiedergabe: die Plex-Kennung muss in die Adresse, nicht nur in die Kopfzeilen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP 400 vom Transcoder. Die Kennung dieses Geräts reist bei jeder Anfrage als Kopfzeile mit — aber die Adresse des Stroms ruft nicht die App ab, sondern AVPlayer, und der schickt keine Kopfzeilen mit. Bei Dateien fällt das nicht auf; bei Live-TV schon: der Server legt die Sitzung unter der Gerätekennung an und kann die Abholung ohne sie keiner Sitzung zuordnen. Also trägt die Transcoder-Adresse die Kennung jetzt selbst. Das gilt für alle Ströme, nicht nur für Live — dort fiel es nur zuerst auf. Dazu: umgerechnet wird die Adresse der Sitzung, nicht die des Teils darin. Der Transcoder sucht sich den Teil selbst, deshalb steht dort -1. Und der Entscheider unterscheidet jetzt zwischen „nein" und „keine Antwort". Ein Schweigen als Ablehnung zu lesen hieße, eine funktionierende Sendung zu blocken, bloß weil er Live-Adressen womöglich gar nicht kennt. Co-Authored-By: Claude Opus 5 --- Flixy/Views/LiveTV/LiveTVView.swift | 10 ++-- .../PlexKit/Client/PlexClientIdentity.swift | 13 +++++ .../PlexKit/Endpoints/PlexServer+LiveTV.swift | 47 ++++++++++++++----- .../Endpoints/PlexServer+Playback.swift | 2 +- .../PlexKit/Endpoints/PlexServer.swift | 7 +++ .../Tests/PlexKitTests/LiveTVGuideTests.swift | 6 ++- 6 files changed, 66 insertions(+), 19 deletions(-) diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 0cfeb8e..a470679 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -218,10 +218,12 @@ struct LiveTVView: View { return } - // Den Server fragen, bevor der Player es versucht. Lehnt er ab, sagt - // er im Klartext warum — der Player meldet nur, dass es nicht ging. - if let verdict = await server.liveTVVerdict(for: stream) { - tuneFailure = verdict + // Den Server fragen, bevor der Player es versucht. Lehnt er + // ausdrücklich ab, sagt er im Klartext warum — der Player meldet nur, + // dass es nicht ging. Schweigt der Entscheider dagegen, wird trotzdem + // gespielt: Live-Adressen kennt er womöglich gar nicht. + if case .refused(let reason) = await server.liveTVVerdict(for: stream) { + tuneFailure = reason return } diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift index b4b807c..c9adadc 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift @@ -53,6 +53,19 @@ public struct PlexClientIdentity: Sendable, Equatable { "Accept": "application/json", ] } + + /// Dieselben Angaben als Abfrageteile der Adresse. + /// + /// Für Adressen, die nicht die App abruft, sondern der Systemplayer: + /// AVPlayer schickt keine Plex-Kopfzeilen mit. Bei Live-TV ist das + /// entscheidend — der Server legt die Sitzung unter der Gerätekennung an + /// und lehnt die Abholung ohne sie mit „HTTP 400" ab. + public var queryItems: [URLQueryItem] { + headers + .filter { $0.key.hasPrefix("X-Plex-") } + .map { URLQueryItem(name: $0.key, value: $0.value) } + .sorted { $0.name < $1.name } + } } extension PlexClientIdentity { diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 1618df2..f626b97 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -397,11 +397,14 @@ extension PlexServer { continue } - // Die Sitzung hat jetzt einen Tuner. Die Adresse, unter der - // sie läuft, ist der Weg zum Transcoder — nicht die des - // Senders und nicht die der Sendung. - let streamPath = item.media.first?.parts.first?.key - ?? (item.key.isEmpty ? "/livetv/sessions/\(session)" : item.key) + // Die Sitzung hat jetzt einen Tuner. Umgerechnet wird die + // Adresse der **Sitzung** — nicht die des Senders, nicht die + // der Sendung und nicht die des Teils darin. Der Transcoder + // sucht sich den Teil selbst; deshalb steht unten auch -1. + let sessionPath = "/livetv/sessions/\(session)" + let streamPath = item.key.hasPrefix("/livetv/sessions/") + ? item.key + : (item.media.first?.parts.first?.key ?? sessionPath) trace.append(ChannelProbe(path: path, result: "Sitzung: \(streamPath)")) @@ -467,19 +470,28 @@ extension PlexServer { } /// Fragt den Server, ob er diesen Strom zulässt — und warum nicht. - public func liveTVVerdict(for url: URL) async -> String? { + public func liveTVVerdict(for url: URL) async -> LiveTVVerdict { // Dieselben Angaben, nur an den Entscheider statt an den Transcoder. // Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die // Wiedergabe wurde unterbrochen", und das sagt nichts. guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - else { return nil } + else { return .unknown("keine Abfrageteile in der Adresse") } - guard let decision = try? await client.send( - PlexRequest(path: "/video/:/transcode/universal/decision", query: items), - as: TranscodeDecision.self - ) else { return nil } - - return decision.isAllowed ? nil : decision.explanation + do { + let decision = try await client.send( + PlexRequest(path: "/video/:/transcode/universal/decision", query: items), + as: TranscodeDecision.self + ) + guard decision.isAllowed else { + return .refused(decision.explanation ?? "ohne Begründung") + } + return .allowed + } catch { + // Antwortet der Entscheider gar nicht, heißt das **nicht** nein. + // Live-Adressen kennt er womöglich nicht; darauf die Wiedergabe + // zu verweigern hieße, eine funktionierende Sendung zu blocken. + return .unknown(Self.shortDescription(of: error)) + } } } @@ -724,3 +736,12 @@ public struct LiveTuneResult: Sendable { /// Jeder versuchte Weg mit seiner Antwort. public let trace: [ChannelProbe] } + +/// Was der Server zu einem Strom sagt. +public enum LiveTVVerdict: Sendable, Equatable { + case allowed + /// Ausdrücklich abgelehnt, mit Begründung. + case refused(String) + /// Der Entscheider hat nicht geantwortet — kein Grund, es nicht zu versuchen. + case unknown(String) +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 738e52b..c3530e9 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -19,7 +19,7 @@ extension PlexServer { components.queryItems = request.queryItems + [ URLQueryItem(name: "X-Plex-Token", value: token), - ] + clientProfileItems + ] + clientProfileItems + identity.queryItems return components.url } diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift index 73af848..b4133c9 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift @@ -16,6 +16,12 @@ public actor PlexServer { /// bleibt er verborgen: die App soll Methoden aufrufen, keine Pfade bauen. let client: PlexHTTPClient nonisolated let token: String + /// Wer wir für den Server sind. + /// + /// Auch hier festgehalten, nicht nur im Client: Adressen, die der + /// Systemplayer abruft, müssen die Kennung selbst tragen — AVPlayer + /// schickt keine Kopfzeilen mit. + nonisolated let identity: PlexClientIdentity public init?( resource: PlexResource, @@ -27,6 +33,7 @@ public actor PlexServer { self.resource = resource self.connection = connection self.token = token + self.identity = identity self.client = PlexHTTPClient( baseURL: baseURL, identity: identity, diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index e08a77c..cecc8e9 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -191,9 +191,13 @@ struct LiveTVGuideTests { let url = try #require(result.url) let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - #expect(items.first { $0.name == "path" }?.value == "/livetv/sessions/abc/stream") + // Die Adresse der Sitzung, nicht die des Teils darin. + #expect(items.first { $0.name == "path" }?.value == "/livetv/sessions/abc") // Ein laufender Sender hat keine Fassungen zur Auswahl. #expect(items.first { $0.name == "mediaIndex" }?.value == "-1") + // Ohne die Gerätekennung in der Adresse kann der Server die Abholung + // keiner Sitzung zuordnen: AVPlayer schickt keine Kopfzeilen mit. + #expect(items.contains { $0.name == "X-Plex-Client-Identifier" }) } @Test("Der Raster-Schlüssel wird zuerst versucht, und nie ein ganzer Pfad")