Wiedergabe: die Plex-Kennung muss in die Adresse, nicht nur in die Kopfzeilen

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 14:02:12 +02:00
co-authored by Claude Opus 5
parent 861ca805a5
commit 8dba05b4f9
6 changed files with 66 additions and 19 deletions
@@ -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 {
@@ -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)
}
@@ -19,7 +19,7 @@ extension PlexServer {
components.queryItems = request.queryItems + [
URLQueryItem(name: "X-Plex-Token", value: token),
] + clientProfileItems
] + clientProfileItems + identity.queryItems
return components.url
}
@@ -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,