Live-TV: die Sender waren da, zwei eigene Fehler haben sie verschluckt
Der Abzug vom Server zeigt beides schwarz auf weiß. Erstens: /media/grabbers/devices/1/channels antwortet mit 278 Sendern — unter dem Sammelnamen `DeviceChannel`. Die Hülle kannte nur `Channel` und hat die vollständige Liste als leer verworfen. Zweitens: das Programmraster antwortet, aber nicht auf `type=1`. Die 1 steht bei Plex für Film, und im Programm laufen auch Folgen, Sport und Nachrichten — der Filter hat das Raster geleert. Ohne ihn kommen die Sendungen samt Sendername, Nummer, Logo und Kennung. Das Raster hat jetzt Vorrang vor der Tunerliste, auch wenn beide antworten: nur dort gehören Sender und Programm zusammen, es enthält ohnehin nur die eingeschalteten Sender, und die Tunerliste führt alle 278. Einschalten läuft über die Adresse der laufenden Sendung beim Programmanbieter — Plex behandelt eine laufende Sendung wie einen Film. Die Adresse wird einmal entpackt, bevor sie in die Anfrage geht: sonst kodiert URLComponents ein zweites Mal, und aus %3A wird %253A. Die Tests stehen jetzt auf den echten Antworten aus dem Abzug, nicht auf erfundenen: DeviceChannel mit `name` und `key`, Rastereinträge mit channelVcn, channelID und einer Folge neben einem Film. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7e36f37506
commit
2362865ba9
+2521
-2506
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,7 @@ struct LiveTVView: View {
|
|||||||
channels: channels,
|
channels: channels,
|
||||||
programs: programs,
|
programs: programs,
|
||||||
tuning: isTuning,
|
tuning: isTuning,
|
||||||
onTune: { channel in Task { await tune(channel) } }
|
onTune: { channel, program in Task { await tune(channel, program: program) } }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
channelList
|
channelList
|
||||||
@@ -180,13 +180,20 @@ struct LiveTVView: View {
|
|||||||
trace = guide.trace
|
trace = guide.trace
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tune(_ channel: PlexChannel) async {
|
private func tune(_ channel: PlexChannel, program: PlexProgram? = nil) async {
|
||||||
guard let server = session.server, isTuning == nil else { return }
|
guard let server = session.server, isTuning == nil else { return }
|
||||||
isTuning = channel.id
|
isTuning = channel.id
|
||||||
defer { isTuning = nil }
|
defer { isTuning = nil }
|
||||||
|
|
||||||
|
// Die laufende Sendung mitgeben: über ihre Adresse beim
|
||||||
|
// Programmanbieter führt der direkte Weg zum Strom.
|
||||||
|
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
||||||
let policy = settings.policy(for: network)
|
let policy = settings.policy(for: network)
|
||||||
guard (try? await server.tune(channel: channel, maximumBitrate: policy.maximumBitrate)) != nil
|
guard (try? await server.tune(
|
||||||
|
channel: channel,
|
||||||
|
program: running,
|
||||||
|
maximumBitrate: policy.maximumBitrate
|
||||||
|
)) != nil
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
// Live-TV läuft über denselben Player wie alles andere. Der Schlüssel
|
// Live-TV läuft über denselben Player wie alles andere. Der Schlüssel
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct ProgramGuide: View {
|
|||||||
let channels: [PlexChannel]
|
let channels: [PlexChannel]
|
||||||
let programs: [String: [PlexProgram]]
|
let programs: [String: [PlexProgram]]
|
||||||
let tuning: String?
|
let tuning: String?
|
||||||
let onTune: (PlexChannel) -> Void
|
let onTune: (PlexChannel, PlexProgram?) -> Void
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
@@ -53,7 +53,7 @@ struct ProgramGuide: View {
|
|||||||
channel: selection.channel,
|
channel: selection.channel,
|
||||||
program: selection.program,
|
program: selection.program,
|
||||||
isRunning: selection.program.isRunning(at: now),
|
isRunning: selection.program.isRunning(at: now),
|
||||||
onTune: { onTune(selection.channel) }
|
onTune: { onTune(selection.channel, selection.program) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
|
// Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
|
||||||
@@ -105,10 +105,15 @@ struct ProgramGuide: View {
|
|||||||
.background(Color.flixyCanvas)
|
.background(Color.flixyCanvas)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was auf diesem Sender gerade läuft.
|
||||||
|
private func running(on channel: PlexChannel) -> PlexProgram? {
|
||||||
|
programs[channel.id]?.first { $0.isRunning(at: now) }
|
||||||
|
}
|
||||||
|
|
||||||
private var channelColumn: some View {
|
private var channelColumn: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(channels) { channel in
|
ForEach(channels) { channel in
|
||||||
Button { onTune(channel) } label: {
|
Button { onTune(channel, running(on: channel)) } label: {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: FlixySpacing.snug) {
|
||||||
FlixyArtwork(
|
FlixyArtwork(
|
||||||
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
||||||
|
|||||||
@@ -256,8 +256,9 @@ extension PlexServer {
|
|||||||
PlexRequest(
|
PlexRequest(
|
||||||
path: "/\(providerIdentifier)/grid",
|
path: "/\(providerIdentifier)/grid",
|
||||||
query: [
|
query: [
|
||||||
// 1 steht bei Plex für „Sendung".
|
// Ohne Typfilter. `type=1` steht bei Plex für Filme, und
|
||||||
URLQueryItem(name: "type", value: "1"),
|
// im Programm laufen auch Folgen, Sport und Nachrichten —
|
||||||
|
// der Filter hat das Raster leer zurückgegeben.
|
||||||
URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
|
URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
|
||||||
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
|
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
|
||||||
],
|
],
|
||||||
@@ -314,6 +315,26 @@ extension PlexServer {
|
|||||||
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
|
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aus dem Raster: jeden Sender einmal, nach Sendernummer sortiert.
|
||||||
|
var seen = Set<String>()
|
||||||
|
let fromGrid = entries
|
||||||
|
.compactMap(\.channel)
|
||||||
|
.filter { seen.insert($0.id).inserted }
|
||||||
|
.sorted { ($0.channelIdentifier ?? "") < ($1.channelIdentifier ?? "") }
|
||||||
|
|
||||||
|
// Das Raster hat Vorrang vor der Senderliste des Tuners, auch wenn
|
||||||
|
// beide antworten: nur dort gehören Sender und Programm zusammen,
|
||||||
|
// stehen Logos dabei, und es enthält ohnehin nur die eingeschalteten
|
||||||
|
// Sender. Die Tunerliste führt alle 278, die meisten davon aus.
|
||||||
|
if !fromGrid.isEmpty {
|
||||||
|
return LiveTVGuide(
|
||||||
|
channels: fromGrid,
|
||||||
|
programs: programs,
|
||||||
|
source: "Raster: \(fromGrid.count) Sender",
|
||||||
|
trace: lookup.trace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if !lookup.channels.isEmpty {
|
if !lookup.channels.isEmpty {
|
||||||
return LiveTVGuide(
|
return LiveTVGuide(
|
||||||
channels: lookup.channels,
|
channels: lookup.channels,
|
||||||
@@ -323,24 +344,10 @@ extension PlexServer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aus dem Raster: jeden Sender einmal, in der Reihenfolge, in der das
|
|
||||||
// Raster ihn nennt. Plex sortiert dort bereits nach Sendernummer.
|
|
||||||
var seen = Set<String>()
|
|
||||||
let fromGrid = entries.compactMap(\.channel).filter { seen.insert($0.id).inserted }
|
|
||||||
|
|
||||||
let enabled = dvr.enabledChannelKeys
|
|
||||||
let filtered = enabled.isEmpty ? fromGrid : fromGrid.filter { $0.matches(anyOf: enabled) }
|
|
||||||
// Passt keiner zur Zuordnung, ist die ganze Liste besser als keine:
|
|
||||||
// die Zuordnung zu erzwingen hieße, bei abweichenden Schlüsseln alles
|
|
||||||
// wegzuwerfen, was der Server gerade geliefert hat.
|
|
||||||
let channels = filtered.isEmpty ? fromGrid : filtered
|
|
||||||
|
|
||||||
return LiveTVGuide(
|
return LiveTVGuide(
|
||||||
channels: channels,
|
channels: [],
|
||||||
programs: programs,
|
programs: programs,
|
||||||
source: channels.isEmpty
|
source: "Raster: keine Sender (\(lookup.description))",
|
||||||
? "Raster: keine Sender (\(lookup.description))"
|
|
||||||
: "Raster: \(channels.count) von \(fromGrid.count) Sendern",
|
|
||||||
trace: lookup.trace + [ChannelProbe(
|
trace: lookup.trace + [ChannelProbe(
|
||||||
path: "/\(epgIdentifier)/grid",
|
path: "/\(epgIdentifier)/grid",
|
||||||
result: fromGrid.isEmpty
|
result: fromGrid.isEmpty
|
||||||
@@ -356,10 +363,31 @@ extension PlexServer {
|
|||||||
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
|
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
|
||||||
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
|
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
|
||||||
/// DirectPlay der Normalfall ist.
|
/// DirectPlay der Normalfall ist.
|
||||||
public func tune(channel: PlexChannel, maximumBitrate: Int?) async throws(PlexError) -> URL? {
|
///
|
||||||
|
/// Zwei Wege, je nachdem, woher der Sender kam. Steht die laufende
|
||||||
|
/// Sendung im Programmraster, ist ihre Adresse beim Programmanbieter der
|
||||||
|
/// direkte Weg: Plex behandelt eine laufende Sendung wie einen Film.
|
||||||
|
/// Stammt der Sender dagegen aus der Liste des Tuners, bleibt der Umweg
|
||||||
|
/// über eine Live-Sitzung.
|
||||||
|
public func tune(
|
||||||
|
channel: PlexChannel,
|
||||||
|
program: PlexProgram? = nil,
|
||||||
|
maximumBitrate: Int?
|
||||||
|
) async throws(PlexError) -> URL? {
|
||||||
|
let session = UUID().uuidString
|
||||||
|
|
||||||
|
if let metadataKey = program?.metadataKey {
|
||||||
|
return transcodeURL(for: TranscodeRequest(
|
||||||
|
ratingKey: channel.id,
|
||||||
|
maximumBitrate: maximumBitrate,
|
||||||
|
isLocal: connection.local,
|
||||||
|
sessionIdentifier: session,
|
||||||
|
pathOverride: metadataKey
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
guard let key = channel.key else { return nil }
|
guard let key = channel.key else { return nil }
|
||||||
|
|
||||||
let session = UUID().uuidString
|
|
||||||
let container = try await client.send(
|
let container = try await client.send(
|
||||||
PlexRequest(
|
PlexRequest(
|
||||||
path: "/livetv/sessions/\(session)/\(key)",
|
path: "/livetv/sessions/\(session)/\(key)",
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ public struct TranscodeRequest: Sendable, Equatable {
|
|||||||
/// Welche Untertitelspur eingebrannt werden soll.
|
/// Welche Untertitelspur eingebrannt werden soll.
|
||||||
public let subtitleStreamID: Int?
|
public let subtitleStreamID: Int?
|
||||||
public let audioStreamID: Int?
|
public let audioStreamID: Int?
|
||||||
|
/// Eine andere Adresse als die der Mediathek.
|
||||||
|
///
|
||||||
|
/// Live-TV liegt nicht unter `/library/metadata`, sondern beim
|
||||||
|
/// Programmanbieter. Der Transcoder nimmt beides — aber nur, wenn man
|
||||||
|
/// ihm die richtige Adresse nennt.
|
||||||
|
public let pathOverride: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
ratingKey: String,
|
ratingKey: String,
|
||||||
@@ -35,7 +41,8 @@ public struct TranscodeRequest: Sendable, Equatable {
|
|||||||
isLocal: Bool,
|
isLocal: Bool,
|
||||||
sessionIdentifier: String = UUID().uuidString,
|
sessionIdentifier: String = UUID().uuidString,
|
||||||
subtitleStreamID: Int? = nil,
|
subtitleStreamID: Int? = nil,
|
||||||
audioStreamID: Int? = nil
|
audioStreamID: Int? = nil,
|
||||||
|
pathOverride: String? = nil
|
||||||
) {
|
) {
|
||||||
self.ratingKey = ratingKey
|
self.ratingKey = ratingKey
|
||||||
self.mediaIndex = mediaIndex
|
self.mediaIndex = mediaIndex
|
||||||
@@ -46,11 +53,19 @@ public struct TranscodeRequest: Sendable, Equatable {
|
|||||||
self.sessionIdentifier = sessionIdentifier
|
self.sessionIdentifier = sessionIdentifier
|
||||||
self.subtitleStreamID = subtitleStreamID
|
self.subtitleStreamID = subtitleStreamID
|
||||||
self.audioStreamID = audioStreamID
|
self.audioStreamID = audioStreamID
|
||||||
|
self.pathOverride = pathOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryItems: [URLQueryItem] {
|
var queryItems: [URLQueryItem] {
|
||||||
var items = [
|
var items = [
|
||||||
URLQueryItem(name: "path", value: "/library/metadata/\(ratingKey)"),
|
// 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: "mediaIndex", value: String(mediaIndex)),
|
URLQueryItem(name: "mediaIndex", value: String(mediaIndex)),
|
||||||
URLQueryItem(name: "partIndex", value: String(partIndex)),
|
URLQueryItem(name: "partIndex", value: String(partIndex)),
|
||||||
URLQueryItem(name: "protocol", value: "hls"),
|
URLQueryItem(name: "protocol", value: "hls"),
|
||||||
|
|||||||
@@ -205,7 +205,10 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decod
|
|||||||
/// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der
|
/// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der
|
||||||
/// Reihe nach zu probieren.
|
/// Reihe nach zu probieren.
|
||||||
private static var contentKeys: [String] {
|
private static var contentKeys: [String] {
|
||||||
["Metadata", "Directory", "Hub", "Dvr", "MediaProvider", "Channel", "ChannelMapping", "Device"]
|
["Metadata", "Directory", "Hub", "Dvr", "MediaProvider",
|
||||||
|
// Der Tuner führt seine Sender unter `DeviceChannel`, nicht unter
|
||||||
|
// `Channel`. Die 278 Sender waren da, die Hülle hat sie verworfen.
|
||||||
|
"Channel", "DeviceChannel", "ChannelMapping", "Device"]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DynamicKey: CodingKey {
|
private struct DynamicKey: CodingKey {
|
||||||
|
|||||||
@@ -187,10 +187,12 @@ struct PlexGridMedia: Decodable {
|
|||||||
let channelCallSign: String?
|
let channelCallSign: String?
|
||||||
let channelThumb: String?
|
let channelThumb: String?
|
||||||
let channelVcn: String?
|
let channelVcn: String?
|
||||||
|
/// Die laufende Nummer des Senders am Server.
|
||||||
|
let channelID: String?
|
||||||
let parts: [PlexGridPart]
|
let parts: [PlexGridPart]
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn
|
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
|
||||||
case part = "Part"
|
case part = "Part"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +210,13 @@ struct PlexGridMedia: Decodable {
|
|||||||
} else {
|
} else {
|
||||||
self.channelVcn = nil
|
self.channelVcn = nil
|
||||||
}
|
}
|
||||||
|
if let text = try? values.decodeIfPresent(String.self, forKey: .channelID) {
|
||||||
|
self.channelID = text
|
||||||
|
} else if let number = try? values.decodeIfPresent(Int.self, forKey: .channelID) {
|
||||||
|
self.channelID = String(number)
|
||||||
|
} else {
|
||||||
|
self.channelID = nil
|
||||||
|
}
|
||||||
self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
|
self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,15 +226,27 @@ struct PlexGridMedia: Decodable {
|
|||||||
/// Senderliste wertlos und stünde bei jedem Eintrag doppelt.
|
/// Senderliste wertlos und stünde bei jedem Eintrag doppelt.
|
||||||
var channel: PlexChannel? {
|
var channel: PlexChannel? {
|
||||||
guard let name = channelTitle ?? channelCallSign else { return nil }
|
guard let name = channelTitle ?? channelCallSign else { return nil }
|
||||||
let identity = channelIdentifier ?? parts.first?.key ?? name
|
let identity = channelIdentifier ?? channelID ?? parts.first?.key ?? name
|
||||||
return PlexChannel(
|
return PlexChannel(
|
||||||
id: identity,
|
id: identity,
|
||||||
title: name,
|
title: Self.withoutLeadingNumber(name, number: channelVcn),
|
||||||
channelIdentifier: channelVcn ?? channelIdentifier,
|
channelIdentifier: channelVcn,
|
||||||
thumb: channelThumb,
|
thumb: channelThumb,
|
||||||
key: parts.first?.key
|
key: parts.first?.key ?? channelID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Entfernt die Sendernummer aus dem Namen.
|
||||||
|
///
|
||||||
|
/// Plex schreibt sie dort mit hinein — „006 ZDFGERH (ZDF HD)". Da die
|
||||||
|
/// Nummer in der Senderspalte ohnehin eigens steht, stünde sie sonst
|
||||||
|
/// zweimal nebeneinander.
|
||||||
|
static func withoutLeadingNumber(_ name: String, number: String?) -> String {
|
||||||
|
guard let number, !number.isEmpty else { return name }
|
||||||
|
let prefix = number + " "
|
||||||
|
guard name.hasPrefix(prefix) else { return name }
|
||||||
|
return String(name.dropFirst(prefix.count))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Strom hinter einer Rastersendung.
|
/// Der Strom hinter einer Rastersendung.
|
||||||
@@ -268,7 +289,12 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
.compactMap { try? values.decodeIfPresent(String.self, forKey: $0) }
|
.compactMap { try? values.decodeIfPresent(String.self, forKey: $0) }
|
||||||
.first { !$0.isEmpty } ?? Self.placeholder
|
.first { !$0.isEmpty } ?? Self.placeholder
|
||||||
|
|
||||||
self.channelIdentifier = Self.text(in: values, forKeys: [.channelIdentifier, .vcn, .number])
|
// Beim Tuner heißt die Nummer schlicht `key` — „300". Ohne diesen
|
||||||
|
// Rückfall stünde die halbe Senderliste ohne Nummer da.
|
||||||
|
let number = Self.text(in: values, forKeys: [.channelIdentifier, .vcn, .number])
|
||||||
|
let keyValue = Self.text(in: values, forKeys: [.key])
|
||||||
|
self.channelIdentifier = number
|
||||||
|
?? 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 = try values.decodeIfPresent(String.self, forKey: .key)
|
||||||
|
|
||||||
@@ -341,6 +367,8 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
|||||||
public let endsAt: Date
|
public let endsAt: Date
|
||||||
public let channelID: String?
|
public let channelID: String?
|
||||||
public let thumb: String?
|
public let thumb: String?
|
||||||
|
/// Die Adresse dieser Sendung beim Programmanbieter.
|
||||||
|
public let metadataKey: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -350,7 +378,8 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
|||||||
startsAt: Date,
|
startsAt: Date,
|
||||||
endsAt: Date,
|
endsAt: Date,
|
||||||
channelID: String? = nil,
|
channelID: String? = nil,
|
||||||
thumb: String? = nil
|
thumb: String? = nil,
|
||||||
|
metadataKey: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -360,6 +389,7 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
|||||||
self.endsAt = endsAt
|
self.endsAt = endsAt
|
||||||
self.channelID = channelID
|
self.channelID = channelID
|
||||||
self.thumb = thumb
|
self.thumb = thumb
|
||||||
|
self.metadataKey = metadataKey
|
||||||
}
|
}
|
||||||
|
|
||||||
public var duration: Duration {
|
public var duration: Duration {
|
||||||
@@ -385,6 +415,9 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
|||||||
/// die Oberfläche keine Zeitstempel rechnen muss.
|
/// die Oberfläche keine Zeitstempel rechnen muss.
|
||||||
struct PlexGridEntry: Decodable {
|
struct PlexGridEntry: Decodable {
|
||||||
let ratingKey: String?
|
let ratingKey: String?
|
||||||
|
/// Die Adresse der Sendung beim Programmanbieter — über sie läuft das
|
||||||
|
/// Einschalten, denn Plex behandelt eine laufende Sendung wie einen Film.
|
||||||
|
let key: String?
|
||||||
let title: String?
|
let title: String?
|
||||||
let summary: String?
|
let summary: String?
|
||||||
let grandparentTitle: String?
|
let grandparentTitle: String?
|
||||||
@@ -396,7 +429,7 @@ struct PlexGridEntry: Decodable {
|
|||||||
let media: [PlexGridMedia]?
|
let media: [PlexGridMedia]?
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case ratingKey, title, summary, grandparentTitle, thumb, beginsAt, endsAt
|
case ratingKey, key, title, summary, grandparentTitle, thumb, beginsAt, endsAt
|
||||||
case media = "Media"
|
case media = "Media"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +449,8 @@ struct PlexGridEntry: Decodable {
|
|||||||
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
|
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
|
||||||
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
|
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
|
||||||
channelID: channelID,
|
channelID: channelID,
|
||||||
thumb: thumb
|
thumb: thumb,
|
||||||
|
metadataKey: key
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,47 +3,53 @@
|
|||||||
"size": 3,
|
"size": 3,
|
||||||
"Metadata": [
|
"Metadata": [
|
||||||
{
|
{
|
||||||
"ratingKey": "epg://1",
|
"ratingKey": "5f1",
|
||||||
"title": "Tagesschau",
|
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2F6254592919e23c64f6c5605d",
|
||||||
"summary": "Nachrichten",
|
"title": "Endlich Witwer",
|
||||||
|
"summary": "Komödie",
|
||||||
|
"type": "movie",
|
||||||
"beginsAt": 1757682000,
|
"beginsAt": 1757682000,
|
||||||
"endsAt": 1757682900,
|
"endsAt": 1757685600,
|
||||||
"Media": [
|
"Media": [
|
||||||
{
|
{
|
||||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11",
|
"channelCallSign": "ZDFGERH",
|
||||||
"channelTitle": "SRF 1 HD",
|
"channelID": 5,
|
||||||
"channelCallSign": "SRF1",
|
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
|
||||||
"channelThumb": "/library/metadata/11/thumb",
|
"channelThumb": "/library/metadata/5/thumb",
|
||||||
"channelVcn": "1",
|
"channelTitle": "006 ZDFGERH (ZDF HD)",
|
||||||
"Part": [{ "key": "/livetv/channels/11" }]
|
"channelVcn": "006",
|
||||||
|
"onAir": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ratingKey": "epg://2",
|
"ratingKey": "5f2",
|
||||||
"title": "Meteo",
|
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc",
|
||||||
"beginsAt": 1757682900,
|
"title": "heute journal",
|
||||||
"endsAt": 1757683200,
|
"type": "episode",
|
||||||
|
"beginsAt": 1757685600,
|
||||||
|
"endsAt": 1757687400,
|
||||||
"Media": [
|
"Media": [
|
||||||
{
|
{
|
||||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11",
|
"channelID": 5,
|
||||||
"channelTitle": "SRF 1 HD",
|
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
|
||||||
"channelVcn": 1,
|
"channelTitle": "006 ZDFGERH (ZDF HD)",
|
||||||
"Part": [{ "key": "/livetv/channels/11" }]
|
"channelVcn": "006"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ratingKey": "epg://3",
|
"ratingKey": "5f3",
|
||||||
|
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fshow%2Fxyz",
|
||||||
"title": "Sport aktuell",
|
"title": "Sport aktuell",
|
||||||
"beginsAt": 1757682000,
|
"beginsAt": 1757682000,
|
||||||
"endsAt": 1757685600,
|
"endsAt": 1757685600,
|
||||||
"Media": [
|
"Media": [
|
||||||
{
|
{
|
||||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
|
|
||||||
"channelCallSign": "SRF2",
|
"channelCallSign": "SRF2",
|
||||||
"channelVcn": "2",
|
"channelID": 12,
|
||||||
"Part": [{ "key": "/livetv/channels/12" }]
|
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
|
||||||
|
"channelVcn": "002"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ struct LiveTVGuideTests {
|
|||||||
let guide = await server.liveTVGuide(dvr: dvr)
|
let guide = await server.liveTVGuide(dvr: dvr)
|
||||||
|
|
||||||
#expect(guide.channels.count == 2)
|
#expect(guide.channels.count == 2)
|
||||||
#expect(guide.channels.map(\.title) == ["SRF 1 HD", "SRF2"])
|
// Nach Sendernummer, und ohne die Nummer noch einmal im Namen:
|
||||||
|
// Plex schreibt sie dort mit hinein.
|
||||||
|
#expect(guide.channels.map(\.title) == ["SRF2", "ZDFGERH (ZDF HD)"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Fehlt der Sendername, trägt das Kürzel den Sender")
|
@Test("Fehlt der Sendername, trägt das Kürzel den Sender")
|
||||||
@@ -54,12 +56,10 @@ struct LiveTVGuideTests {
|
|||||||
])
|
])
|
||||||
|
|
||||||
let guide = await server.liveTVGuide(dvr: dvr)
|
let guide = await server.liveTVGuide(dvr: dvr)
|
||||||
let second = try #require(guide.channels.last)
|
let callSignOnly = try #require(guide.channels.first)
|
||||||
|
|
||||||
#expect(second.title == "SRF2")
|
#expect(callSignOnly.title == "SRF2")
|
||||||
// Die Sendernummer kommt mal als Zahl, mal als Zeichenkette — beides
|
#expect(callSignOnly.channelIdentifier == "002")
|
||||||
// muss lesbar sein, sonst fehlt sie bei der Hälfte der Sender.
|
|
||||||
#expect(second.channelIdentifier == "2")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Sendungen landen beim richtigen Sender, nach Anfangszeit sortiert")
|
@Test("Sendungen landen beim richtigen Sender, nach Anfangszeit sortiert")
|
||||||
@@ -69,10 +69,12 @@ struct LiveTVGuideTests {
|
|||||||
])
|
])
|
||||||
|
|
||||||
let guide = await server.liveTVGuide(dvr: dvr)
|
let guide = await server.liveTVGuide(dvr: dvr)
|
||||||
let first = try #require(guide.channels.first)
|
let zdf = try #require(guide.channels.last)
|
||||||
let programs = try #require(guide.programs[first.id])
|
let programs = try #require(guide.programs[zdf.id])
|
||||||
|
|
||||||
#expect(programs.map(\.title) == ["Tagesschau", "Meteo"])
|
// Auch die Folge ist dabei: mit `type=1` kam nur „Film" durch, und
|
||||||
|
// das Raster blieb leer.
|
||||||
|
#expect(programs.map(\.title) == ["Endlich Witwer", "heute journal"])
|
||||||
#expect(guide.programs.count == 2)
|
#expect(guide.programs.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,14 +150,47 @@ struct LiveTVGuideTests {
|
|||||||
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
|
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Die Einschaltadresse reist mit dem Sender mit")
|
@Test("Die Adresse der laufenden Sendung führt zum Strom")
|
||||||
func keepsTuneKey() async throws {
|
func tunesThroughTheProgram() async throws {
|
||||||
|
// Das Raster nennt keine Teil-Adresse. Der Weg führt über die
|
||||||
|
// Sendung selbst: Plex behandelt eine laufende Sendung wie einen Film.
|
||||||
let server = server(routes: [
|
let server = server(routes: [
|
||||||
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
|
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
|
||||||
])
|
])
|
||||||
|
|
||||||
let guide = await server.liveTVGuide(dvr: dvr)
|
let guide = await server.liveTVGuide(dvr: dvr)
|
||||||
|
let zdf = try #require(guide.channels.last)
|
||||||
|
let program = try #require(guide.programs[zdf.id]?.first)
|
||||||
|
let url = try #require(await server.tune(channel: zdf, program: program, maximumBitrate: nil))
|
||||||
|
let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?.first { $0.name == "path" }?.value)
|
||||||
|
|
||||||
#expect(guide.channels.first?.key == "/livetv/channels/11")
|
#expect(path == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/6254592919e23c64f6c5605d")
|
||||||
|
// Doppelt kodiert fände der Server nichts — aus %3A würde %253A.
|
||||||
|
#expect(!url.absoluteString.contains("%253A"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Sender des Tuners stehen unter DeviceChannel")
|
||||||
|
func readsDeviceChannelKey() async throws {
|
||||||
|
// Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte
|
||||||
|
// nur `Channel`. Der Tuner schreibt `DeviceChannel`.
|
||||||
|
let json = """
|
||||||
|
{"MediaContainer":{"size":2,"DeviceChannel":[
|
||||||
|
{"drm":false,"favorite":false,"hd":true,"identifier":"300","key":"300","name":"ZDF HD"},
|
||||||
|
{"hd":true,"identifier":"301","key":"301","name":"SRF 1 HD"}
|
||||||
|
]}}
|
||||||
|
"""
|
||||||
|
let server = server(routes: ["/media/grabbers/devices/1/channels": json])
|
||||||
|
let withTuner = PlexDVR(
|
||||||
|
key: "2",
|
||||||
|
devices: [PlexTunerDevice(key: "1")],
|
||||||
|
epgIdentifier: "tv.plex.providers.epg.cloud:2"
|
||||||
|
)
|
||||||
|
|
||||||
|
let lookup = await server.channels(inDVR: withTuner)
|
||||||
|
|
||||||
|
#expect(lookup.channels.map(\.title) == ["ZDF HD", "SRF 1 HD"])
|
||||||
|
// Die Nummer steht beim Tuner schlicht im Schlüssel.
|
||||||
|
#expect(lookup.channels.first?.channelIdentifier == "300")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user