Live-TV: erst den Tuner belegen, dann umrechnen lassen
Der übersprungene Schritt. Ich habe die Sendung direkt an den Transcoder gegeben — ohne dass jemand den Tuner belegt hätte. Der Server fing an, merkte, dass er nichts zu senden hat, und brach ab: „die Wiedergabe wurde unterbrochen", ohne weitere Angabe. Jetzt zwei Schritte. Erst eine Live-Sitzung eröffnen, dann die Adresse dieser Sitzung umrechnen lassen — nicht die des Senders und nicht die der Sendung. mediaIndex und partIndex sind dabei -1: ein laufender Sender hat keine Fassungen zur Auswahl. Unter welchem Schlüssel sich die Sitzung eröffnen lässt, unterscheidet sich je nach Anbieter, deshalb werden die in Frage kommenden der Reihe nach probiert — der Raster-Schlüssel zuerst, weil er den Sender genauso bezeichnet wie die Liste, aus der er stammt. Dazu zwei Stellen, an denen Gründe bisher verschwanden: Vor dem Abspielen wird der Server gefragt, ob er den Strom überhaupt zulässt. Er antwortet im Klartext; der Player meldet nur, dass es nicht ging. Und wenn der Motor scheitert, steht sein Grund ab jetzt auf dem Bildschirm statt nur im Protokoll. „Die Wiedergabe wurde unterbrochen" allein sagt weder, ob der Server ablehnt, noch ob der Motor das Format nicht mag — und genau diese Unterscheidung hat diese Runde gekostet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bbe47b83be
commit
861ca805a5
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
"%@ GB": {
|
|
||||||
"comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
|
||||||
"isCommentAutoGenerated": true
|
|
||||||
},
|
|
||||||
"%@ → %@" : {
|
"%@ → %@" : {
|
||||||
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
|
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -17,8 +13,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"1080p": {
|
"%@ GB" : {
|
||||||
"comment": "A label for a 1080p video.",
|
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"4K" : {
|
"4K" : {
|
||||||
@@ -29,8 +25,8 @@
|
|||||||
"comment" : "A label for a 720p video.",
|
"comment" : "A label for a 720p video.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"SD": {
|
"1080p" : {
|
||||||
"comment": "A label for a 720p movie.",
|
"comment" : "A label for a 1080p video.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"action.apply" : {
|
"action.apply" : {
|
||||||
@@ -955,6 +951,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.hold.offline" : {
|
"downloads.hold.offline" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -971,6 +968,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.hold.offline.description" : {
|
"downloads.hold.offline.description" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -987,6 +985,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.hold.wifi" : {
|
"downloads.hold.wifi" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1003,6 +1002,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.hold.wifi.description" : {
|
"downloads.hold.wifi.description" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2027,6 +2027,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.noStream" : {
|
"liveTV.noStream" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2428,23 +2429,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.subtitleTracks": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Untertitel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Subtitles"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player.subtitlesOff" : {
|
"player.subtitlesOff" : {
|
||||||
"comment" : "Eintrag, der die Untertitel abschaltet",
|
"comment" : "Eintrag, der die Untertitel abschaltet",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -2463,6 +2447,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.subtitleTracks" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Untertitel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Subtitles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.tracks" : {
|
"player.tracks" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2685,6 +2686,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SD" : {
|
||||||
|
"comment" : "A label for a 720p movie.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"search.hint" : {
|
"search.hint" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
|
|||||||
@@ -203,22 +203,25 @@ struct LiveTVView: View {
|
|||||||
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
||||||
let policy = settings.policy(for: network)
|
let policy = settings.policy(for: network)
|
||||||
|
|
||||||
let stream: URL?
|
let result = await server.tune(
|
||||||
do {
|
|
||||||
stream = try await server.tune(
|
|
||||||
channel: channel,
|
channel: channel,
|
||||||
program: running,
|
program: running,
|
||||||
maximumBitrate: policy.maximumBitrate
|
maximumBitrate: policy.maximumBitrate
|
||||||
)
|
)
|
||||||
} catch {
|
|
||||||
|
guard let stream = result.url else {
|
||||||
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
|
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
|
||||||
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
|
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
|
||||||
tuneFailure = PlexErrorText.describe(error)
|
tuneFailure = ([String(localized: "liveTV.noStream")]
|
||||||
|
+ result.trace.map { "\($0.path) → \($0.result)" })
|
||||||
|
.joined(separator: "\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let stream else {
|
// Den Server fragen, bevor der Player es versucht. Lehnt er ab, sagt
|
||||||
tuneFailure = String(localized: "liveTV.noStream")
|
// er im Klartext warum — der Player meldet nur, dass es nicht ging.
|
||||||
|
if let verdict = await server.liveTVVerdict(for: stream) {
|
||||||
|
tuneFailure = verdict
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -483,7 +483,14 @@ final class PlayerModel {
|
|||||||
diagnostics.note("Zustand: \(state)")
|
diagnostics.note("Zustand: \(state)")
|
||||||
if case .failed(let failure) = state {
|
if case .failed(let failure) = state {
|
||||||
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
|
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
|
||||||
phase = .failed(message: String(localized: "player.playbackFailed"))
|
// Der Grund gehört auf den Bildschirm, nicht nur ins
|
||||||
|
// Protokoll: „die Wiedergabe wurde unterbrochen" allein
|
||||||
|
// ist eine Sackgasse — es sagt weder, ob der Server
|
||||||
|
// ablehnt, noch ob der Motor das Format nicht mag.
|
||||||
|
phase = .failed(message: [
|
||||||
|
String(localized: "player.playbackFailed"),
|
||||||
|
String(describing: failure),
|
||||||
|
].joined(separator: "\n\n"))
|
||||||
}
|
}
|
||||||
case .timeChanged(let time):
|
case .timeChanged(let time):
|
||||||
currentTime = time
|
currentTime = time
|
||||||
|
|||||||
@@ -360,50 +360,126 @@ extension PlexServer {
|
|||||||
/// Schaltet einen Sender ein.
|
/// Schaltet einen Sender ein.
|
||||||
///
|
///
|
||||||
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
|
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
|
||||||
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
|
/// Transportstrom, den kein iPhone direkt abspielt.
|
||||||
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
|
|
||||||
/// DirectPlay der Normalfall ist.
|
|
||||||
///
|
///
|
||||||
/// Zwei Wege, je nachdem, woher der Sender kam. Steht die laufende
|
/// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen
|
||||||
/// Sendung im Programmraster, ist ihre Adresse beim Programmanbieter der
|
/// hatte: **der Server muss den Tuner belegen**. Erst danach gibt es
|
||||||
/// direkte Weg: Plex behandelt eine laufende Sendung wie einen Film.
|
/// etwas umzurechnen. Ohne diesen Schritt begann die Wiedergabe und starb
|
||||||
/// Stammt der Sender dagegen aus der Liste des Tuners, bleibt der Umweg
|
/// sofort wieder — „die Wiedergabe wurde unterbrochen", ohne dass ein
|
||||||
/// über eine Live-Sitzung.
|
/// Fehler zu sehen gewesen wäre.
|
||||||
|
///
|
||||||
|
/// Unter welchem Schlüssel die Sitzung eröffnet wird, unterscheidet sich
|
||||||
|
/// je nach Anbieter, deshalb werden die in Frage kommenden der Reihe nach
|
||||||
|
/// probiert. Was dabei herauskam, steht in `trace` — scheitert alles, ist
|
||||||
|
/// ohne Rückfrage zu sehen, was der Server geantwortet hat.
|
||||||
public func tune(
|
public func tune(
|
||||||
channel: PlexChannel,
|
channel: PlexChannel,
|
||||||
program: PlexProgram? = nil,
|
program: PlexProgram? = nil,
|
||||||
maximumBitrate: Int?
|
maximumBitrate: Int?
|
||||||
) async throws(PlexError) -> URL? {
|
) async -> LiveTuneResult {
|
||||||
let session = UUID().uuidString
|
let session = UUID().uuidString
|
||||||
|
var trace: [ChannelProbe] = []
|
||||||
|
|
||||||
if let metadataKey = program?.metadataKey {
|
for key in Self.sessionKeys(channel: channel, program: program) {
|
||||||
return transcodeURL(for: TranscodeRequest(
|
let path = "/livetv/sessions/\(session)/\(key)"
|
||||||
ratingKey: channel.id,
|
do {
|
||||||
maximumBitrate: maximumBitrate,
|
|
||||||
isLocal: connection.local,
|
|
||||||
sessionIdentifier: session,
|
|
||||||
pathOverride: metadataKey
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let key = channel.key else { return nil }
|
|
||||||
|
|
||||||
let container = try await client.send(
|
let container = try await client.send(
|
||||||
PlexRequest(
|
PlexRequest(
|
||||||
path: "/livetv/sessions/\(session)/\(key)",
|
path: path,
|
||||||
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
|
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
|
||||||
timeout: .seconds(30)
|
timeout: .seconds(30)
|
||||||
),
|
),
|
||||||
as: PlexMediaContainer<PlexItem>.self
|
as: PlexMediaContainer<PlexItem>.self
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let item = container.content.first else { throw .notFound }
|
guard let item = container.content.first else {
|
||||||
return transcodeURL(for: TranscodeRequest(
|
trace.append(ChannelProbe(path: path, result: "leer"))
|
||||||
|
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)
|
||||||
|
|
||||||
|
trace.append(ChannelProbe(path: path, result: "Sitzung: \(streamPath)"))
|
||||||
|
|
||||||
|
return LiveTuneResult(
|
||||||
|
url: transcodeURL(for: TranscodeRequest(
|
||||||
ratingKey: item.ratingKey,
|
ratingKey: item.ratingKey,
|
||||||
|
// -1 heißt „was auch immer die Sitzung liefert". Ein
|
||||||
|
// laufender Sender hat keine Fassungen zur Auswahl.
|
||||||
|
mediaIndex: -1,
|
||||||
|
partIndex: -1,
|
||||||
maximumBitrate: maximumBitrate,
|
maximumBitrate: maximumBitrate,
|
||||||
isLocal: connection.local,
|
isLocal: connection.local,
|
||||||
sessionIdentifier: session
|
sessionIdentifier: session,
|
||||||
))
|
pathOverride: streamPath
|
||||||
|
)),
|
||||||
|
sessionIdentifier: session,
|
||||||
|
trace: trace
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
trace.append(ChannelProbe(path: path, result: Self.shortDescription(of: error)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzter Versuch: die Sendung unmittelbar umrechnen lassen. Auf
|
||||||
|
// Servern, die den Tuner selbst belegen, genügt das.
|
||||||
|
if let metadataKey = program?.metadataKey {
|
||||||
|
trace.append(ChannelProbe(path: metadataKey, result: "direkt versucht"))
|
||||||
|
return LiveTuneResult(
|
||||||
|
url: transcodeURL(for: TranscodeRequest(
|
||||||
|
ratingKey: channel.id,
|
||||||
|
maximumBitrate: maximumBitrate,
|
||||||
|
isLocal: connection.local,
|
||||||
|
sessionIdentifier: session,
|
||||||
|
pathOverride: metadataKey
|
||||||
|
)),
|
||||||
|
sessionIdentifier: session,
|
||||||
|
trace: trace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unter welchen Schlüsseln sich eine Live-Sitzung eröffnen lässt.
|
||||||
|
///
|
||||||
|
/// Der Raster-Schlüssel zuerst: er bezeichnet den Sender genau so, wie
|
||||||
|
/// das Programmraster ihn führt, aus dem auch die Senderliste kommt.
|
||||||
|
static func sessionKeys(channel: PlexChannel, program: PlexProgram?) -> [String] {
|
||||||
|
var keys = [
|
||||||
|
channel.gridKey,
|
||||||
|
program?.metadataKey.flatMap { $0.split(separator: "/").last.map(String.init) },
|
||||||
|
program?.id,
|
||||||
|
channel.key,
|
||||||
|
channel.id,
|
||||||
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
// Volle Adressen taugen hier nicht: an `/livetv/sessions/<uuid>/`
|
||||||
|
// gehört ein Schlüssel, kein zweiter Pfad.
|
||||||
|
keys = keys.map { $0.split(separator: "/").last.map(String.init) ?? $0 }
|
||||||
|
|
||||||
|
var seen = Set<String>()
|
||||||
|
return keys.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt den Server, ob er diesen Strom zulässt — und warum nicht.
|
||||||
|
public func liveTVVerdict(for url: URL) async -> String? {
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,3 +716,11 @@ public struct LiveTVGuide: Sendable {
|
|||||||
/// Jede abgeklopfte Adresse mit ihrer Antwort, falls nichts gefunden wurde.
|
/// Jede abgeklopfte Adresse mit ihrer Antwort, falls nichts gefunden wurde.
|
||||||
public let trace: [ChannelProbe]
|
public let trace: [ChannelProbe]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was beim Einschalten herauskam.
|
||||||
|
public struct LiveTuneResult: Sendable {
|
||||||
|
public let url: URL?
|
||||||
|
public let sessionIdentifier: String
|
||||||
|
/// Jeder versuchte Weg mit seiner Antwort.
|
||||||
|
public let trace: [ChannelProbe]
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ struct PlexGridMedia: Decodable {
|
|||||||
let channelVcn: String?
|
let channelVcn: String?
|
||||||
/// Die laufende Nummer des Senders am Server.
|
/// Die laufende Nummer des Senders am Server.
|
||||||
let channelID: String?
|
let channelID: String?
|
||||||
|
/// Der Schlüssel des Senders im Raster — ein Kandidat fürs Einschalten.
|
||||||
|
let gridKey: String?
|
||||||
/// Anfang und Ende der Sendung.
|
/// Anfang und Ende der Sendung.
|
||||||
///
|
///
|
||||||
/// Sie stehen hier und **nicht** am Eintrag darüber. Genau daran ist das
|
/// Sie stehen hier und **nicht** am Eintrag darüber. Genau daran ist das
|
||||||
@@ -200,7 +202,7 @@ struct PlexGridMedia: Decodable {
|
|||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
|
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
|
||||||
case beginsAt, endsAt
|
case gridKey, beginsAt, endsAt
|
||||||
case part = "Part"
|
case part = "Part"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +227,7 @@ struct PlexGridMedia: Decodable {
|
|||||||
} else {
|
} else {
|
||||||
self.channelID = nil
|
self.channelID = nil
|
||||||
}
|
}
|
||||||
|
self.gridKey = try? values.decodeIfPresent(String.self, forKey: .gridKey)
|
||||||
self.beginsAt = try? values.decodeIfPresent(Int.self, forKey: .beginsAt)
|
self.beginsAt = try? values.decodeIfPresent(Int.self, forKey: .beginsAt)
|
||||||
self.endsAt = try? values.decodeIfPresent(Int.self, forKey: .endsAt)
|
self.endsAt = try? values.decodeIfPresent(Int.self, forKey: .endsAt)
|
||||||
self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
|
self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
|
||||||
@@ -242,7 +245,8 @@ struct PlexGridMedia: Decodable {
|
|||||||
title: Self.withoutLeadingNumber(name, number: channelVcn),
|
title: Self.withoutLeadingNumber(name, number: channelVcn),
|
||||||
channelIdentifier: channelVcn,
|
channelIdentifier: channelVcn,
|
||||||
thumb: channelThumb,
|
thumb: channelThumb,
|
||||||
key: parts.first?.key ?? channelID
|
key: parts.first?.key ?? channelID,
|
||||||
|
gridKey: gridKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +276,8 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
public let thumb: String?
|
public let thumb: String?
|
||||||
/// Der Schlüssel, über den sich der Sender einschalten lässt.
|
/// Der Schlüssel, über den sich der Sender einschalten lässt.
|
||||||
public let key: String?
|
public let key: String?
|
||||||
|
/// Der Schlüssel des Senders im Programmraster.
|
||||||
|
public let gridKey: String?
|
||||||
public let id: String
|
public let id: String
|
||||||
|
|
||||||
/// Ob dieser Sender einen Namen trägt.
|
/// Ob dieser Sender einen Namen trägt.
|
||||||
@@ -284,7 +290,7 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
static let placeholder = "—"
|
static let placeholder = "—"
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case channelIdentifier, title, thumb, key
|
case channelIdentifier, title, thumb, key, gridKey
|
||||||
case identifier = "id"
|
case identifier = "id"
|
||||||
case uuid, name, callSign, vcn, number, channelTitle, channelCallSign
|
case uuid, name, callSign, vcn, number, channelTitle, channelCallSign
|
||||||
}
|
}
|
||||||
@@ -306,7 +312,8 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
self.channelIdentifier = number
|
self.channelIdentifier = number
|
||||||
?? keyValue.flatMap { $0.allSatisfy { character in character.isNumber || character == "." } ? $0 : nil }
|
?? keyValue.flatMap { $0.allSatisfy { character in character.isNumber || character == "." } ? $0 : nil }
|
||||||
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
|
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
|
||||||
self.key = try values.decodeIfPresent(String.self, forKey: .key)
|
self.key = Self.text(in: values, forKeys: [.key])
|
||||||
|
self.gridKey = try? values.decodeIfPresent(String.self, forKey: .gridKey)
|
||||||
|
|
||||||
// Plex liefert die Kennung mal als Zahl, mal als Zeichenkette, mal als
|
// Plex liefert die Kennung mal als Zahl, mal als Zeichenkette, mal als
|
||||||
// `uuid`. Ein einzelner erwarteter Typ hätte hier bei manchen
|
// `uuid`. Ein einzelner erwarteter Typ hätte hier bei manchen
|
||||||
@@ -358,12 +365,20 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(id: String, title: String, channelIdentifier: String? = nil, thumb: String? = nil, key: String? = nil) {
|
public init(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
channelIdentifier: String? = nil,
|
||||||
|
thumb: String? = nil,
|
||||||
|
key: String? = nil,
|
||||||
|
gridKey: String? = nil
|
||||||
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.channelIdentifier = channelIdentifier
|
self.channelIdentifier = channelIdentifier
|
||||||
self.thumb = thumb
|
self.thumb = thumb
|
||||||
self.key = key
|
self.key = key
|
||||||
|
self.gridKey = gridKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,10 @@ struct LiveTVGuideTests {
|
|||||||
let guide = await server.liveTVGuide(dvr: dvr)
|
let guide = await server.liveTVGuide(dvr: dvr)
|
||||||
let zdf = try #require(guide.channels.last)
|
let zdf = try #require(guide.channels.last)
|
||||||
let program = try #require(guide.programs[zdf.id]?.first)
|
let program = try #require(guide.programs[zdf.id]?.first)
|
||||||
let url = try #require(await server.tune(channel: zdf, program: program, maximumBitrate: nil))
|
// Hier antwortet keine Sitzung; dann bleibt der direkte Weg über die
|
||||||
|
// Sendung — auf Servern, die den Tuner selbst belegen, genügt er.
|
||||||
|
let result = await server.tune(channel: zdf, program: program, maximumBitrate: nil)
|
||||||
|
let url = try #require(result.url)
|
||||||
let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
.queryItems?.first { $0.name == "path" }?.value)
|
.queryItems?.first { $0.name == "path" }?.value)
|
||||||
|
|
||||||
@@ -170,6 +173,48 @@ struct LiveTVGuideTests {
|
|||||||
#expect(!url.absoluteString.contains("%253A"))
|
#expect(!url.absoluteString.contains("%253A"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Erst den Tuner belegen, dann umrechnen lassen")
|
||||||
|
func opensSessionBeforeTranscoding() async throws {
|
||||||
|
// Der übersprungene Schritt: ohne belegten Tuner beginnt die
|
||||||
|
// Wiedergabe und stirbt sofort — „die Wiedergabe wurde unterbrochen".
|
||||||
|
let session = """
|
||||||
|
{"MediaContainer":{"size":1,"Metadata":[
|
||||||
|
{"ratingKey":"live-1","key":"/livetv/sessions/abc","type":"clip","title":"ZDF HD",
|
||||||
|
"Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/abc/stream"}]}]}
|
||||||
|
]}}
|
||||||
|
"""
|
||||||
|
// Nur unter dem Raster-Schlüssel antwortet eine Sitzung.
|
||||||
|
let server = server(routes: ["/gk-5": session])
|
||||||
|
let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5")
|
||||||
|
|
||||||
|
let result = await server.tune(channel: channel, maximumBitrate: nil)
|
||||||
|
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")
|
||||||
|
// Ein laufender Sender hat keine Fassungen zur Auswahl.
|
||||||
|
#expect(items.first { $0.name == "mediaIndex" }?.value == "-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Raster-Schlüssel wird zuerst versucht, und nie ein ganzer Pfad")
|
||||||
|
func sessionKeyOrder() {
|
||||||
|
let channel = PlexChannel(id: "hash-5", title: "ZDF HD", key: "5", gridKey: "gk-5")
|
||||||
|
let program = PlexProgram(
|
||||||
|
id: "p1",
|
||||||
|
title: "heute",
|
||||||
|
startsAt: .now,
|
||||||
|
endsAt: .now.addingTimeInterval(1800),
|
||||||
|
metadataKey: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc"
|
||||||
|
)
|
||||||
|
|
||||||
|
let keys = PlexServer.sessionKeys(channel: channel, program: program)
|
||||||
|
|
||||||
|
#expect(keys.first == "gk-5")
|
||||||
|
// An `/livetv/sessions/<uuid>/` gehört ein Schlüssel, kein zweiter Pfad.
|
||||||
|
#expect(keys.allSatisfy { !$0.contains("/") })
|
||||||
|
#expect(keys.contains("5"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Die Sender des Tuners stehen unter DeviceChannel")
|
@Test("Die Sender des Tuners stehen unter DeviceChannel")
|
||||||
func readsDeviceChannelKey() async throws {
|
func readsDeviceChannelKey() async throws {
|
||||||
// Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte
|
// Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte
|
||||||
|
|||||||
Reference in New Issue
Block a user