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:
co-authored by
Claude Opus 5
parent
861ca805a5
commit
8dba05b4f9
@@ -218,10 +218,12 @@ struct LiveTVView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Den Server fragen, bevor der Player es versucht. Lehnt er ab, sagt
|
// Den Server fragen, bevor der Player es versucht. Lehnt er
|
||||||
// er im Klartext warum — der Player meldet nur, dass es nicht ging.
|
// ausdrücklich ab, sagt er im Klartext warum — der Player meldet nur,
|
||||||
if let verdict = await server.liveTVVerdict(for: stream) {
|
// dass es nicht ging. Schweigt der Entscheider dagegen, wird trotzdem
|
||||||
tuneFailure = verdict
|
// gespielt: Live-Adressen kennt er womöglich gar nicht.
|
||||||
|
if case .refused(let reason) = await server.liveTVVerdict(for: stream) {
|
||||||
|
tuneFailure = reason
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ public struct PlexClientIdentity: Sendable, Equatable {
|
|||||||
"Accept": "application/json",
|
"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 {
|
extension PlexClientIdentity {
|
||||||
|
|||||||
@@ -397,11 +397,14 @@ extension PlexServer {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Sitzung hat jetzt einen Tuner. Die Adresse, unter der
|
// Die Sitzung hat jetzt einen Tuner. Umgerechnet wird die
|
||||||
// sie läuft, ist der Weg zum Transcoder — nicht die des
|
// Adresse der **Sitzung** — nicht die des Senders, nicht die
|
||||||
// Senders und nicht die der Sendung.
|
// der Sendung und nicht die des Teils darin. Der Transcoder
|
||||||
let streamPath = item.media.first?.parts.first?.key
|
// sucht sich den Teil selbst; deshalb steht unten auch -1.
|
||||||
?? (item.key.isEmpty ? "/livetv/sessions/\(session)" : item.key)
|
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)"))
|
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.
|
/// 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.
|
// Dieselben Angaben, nur an den Entscheider statt an den Transcoder.
|
||||||
// Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die
|
// Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die
|
||||||
// Wiedergabe wurde unterbrochen", und das sagt nichts.
|
// Wiedergabe wurde unterbrochen", und das sagt nichts.
|
||||||
guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
|
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(
|
do {
|
||||||
PlexRequest(path: "/video/:/transcode/universal/decision", query: items),
|
let decision = try await client.send(
|
||||||
as: TranscodeDecision.self
|
PlexRequest(path: "/video/:/transcode/universal/decision", query: items),
|
||||||
) else { return nil }
|
as: TranscodeDecision.self
|
||||||
|
)
|
||||||
return decision.isAllowed ? nil : decision.explanation
|
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.
|
/// Jeder versuchte Weg mit seiner Antwort.
|
||||||
public let trace: [ChannelProbe]
|
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 + [
|
components.queryItems = request.queryItems + [
|
||||||
URLQueryItem(name: "X-Plex-Token", value: token),
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
||||||
] + clientProfileItems
|
] + clientProfileItems + identity.queryItems
|
||||||
return components.url
|
return components.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public actor PlexServer {
|
|||||||
/// bleibt er verborgen: die App soll Methoden aufrufen, keine Pfade bauen.
|
/// bleibt er verborgen: die App soll Methoden aufrufen, keine Pfade bauen.
|
||||||
let client: PlexHTTPClient
|
let client: PlexHTTPClient
|
||||||
nonisolated let token: String
|
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?(
|
public init?(
|
||||||
resource: PlexResource,
|
resource: PlexResource,
|
||||||
@@ -27,6 +33,7 @@ public actor PlexServer {
|
|||||||
self.resource = resource
|
self.resource = resource
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self.identity = identity
|
||||||
self.client = PlexHTTPClient(
|
self.client = PlexHTTPClient(
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
|
|||||||
@@ -191,9 +191,13 @@ struct LiveTVGuideTests {
|
|||||||
let url = try #require(result.url)
|
let url = try #require(result.url)
|
||||||
let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
|
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.
|
// Ein laufender Sender hat keine Fassungen zur Auswahl.
|
||||||
#expect(items.first { $0.name == "mediaIndex" }?.value == "-1")
|
#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")
|
@Test("Der Raster-Schlüssel wird zuerst versucht, und nie ein ganzer Pfad")
|
||||||
|
|||||||
Reference in New Issue
Block a user