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
@@ -256,8 +256,9 @@ extension PlexServer {
|
||||
PlexRequest(
|
||||
path: "/\(providerIdentifier)/grid",
|
||||
query: [
|
||||
// 1 steht bei Plex für „Sendung".
|
||||
URLQueryItem(name: "type", value: "1"),
|
||||
// Ohne Typfilter. `type=1` steht bei Plex für Filme, und
|
||||
// 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: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
|
||||
],
|
||||
@@ -314,6 +315,26 @@ extension PlexServer {
|
||||
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 {
|
||||
return LiveTVGuide(
|
||||
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(
|
||||
channels: channels,
|
||||
channels: [],
|
||||
programs: programs,
|
||||
source: channels.isEmpty
|
||||
? "Raster: keine Sender (\(lookup.description))"
|
||||
: "Raster: \(channels.count) von \(fromGrid.count) Sendern",
|
||||
source: "Raster: keine Sender (\(lookup.description))",
|
||||
trace: lookup.trace + [ChannelProbe(
|
||||
path: "/\(epgIdentifier)/grid",
|
||||
result: fromGrid.isEmpty
|
||||
@@ -356,10 +363,31 @@ extension PlexServer {
|
||||
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
|
||||
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
|
||||
/// 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 }
|
||||
|
||||
let session = UUID().uuidString
|
||||
let container = try await client.send(
|
||||
PlexRequest(
|
||||
path: "/livetv/sessions/\(session)/\(key)",
|
||||
|
||||
@@ -25,6 +25,12 @@ public struct TranscodeRequest: Sendable, Equatable {
|
||||
/// Welche Untertitelspur eingebrannt werden soll.
|
||||
public let subtitleStreamID: 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(
|
||||
ratingKey: String,
|
||||
@@ -35,7 +41,8 @@ public struct TranscodeRequest: Sendable, Equatable {
|
||||
isLocal: Bool,
|
||||
sessionIdentifier: String = UUID().uuidString,
|
||||
subtitleStreamID: Int? = nil,
|
||||
audioStreamID: Int? = nil
|
||||
audioStreamID: Int? = nil,
|
||||
pathOverride: String? = nil
|
||||
) {
|
||||
self.ratingKey = ratingKey
|
||||
self.mediaIndex = mediaIndex
|
||||
@@ -46,11 +53,19 @@ public struct TranscodeRequest: Sendable, Equatable {
|
||||
self.sessionIdentifier = sessionIdentifier
|
||||
self.subtitleStreamID = subtitleStreamID
|
||||
self.audioStreamID = audioStreamID
|
||||
self.pathOverride = pathOverride
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
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: "partIndex", value: String(partIndex)),
|
||||
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
|
||||
/// Reihe nach zu probieren.
|
||||
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 {
|
||||
|
||||
@@ -187,10 +187,12 @@ struct PlexGridMedia: Decodable {
|
||||
let channelCallSign: String?
|
||||
let channelThumb: String?
|
||||
let channelVcn: String?
|
||||
/// Die laufende Nummer des Senders am Server.
|
||||
let channelID: String?
|
||||
let parts: [PlexGridPart]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn
|
||||
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
|
||||
case part = "Part"
|
||||
}
|
||||
|
||||
@@ -208,6 +210,13 @@ struct PlexGridMedia: Decodable {
|
||||
} else {
|
||||
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) ?? []
|
||||
}
|
||||
|
||||
@@ -217,15 +226,27 @@ struct PlexGridMedia: Decodable {
|
||||
/// Senderliste wertlos und stünde bei jedem Eintrag doppelt.
|
||||
var channel: PlexChannel? {
|
||||
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(
|
||||
id: identity,
|
||||
title: name,
|
||||
channelIdentifier: channelVcn ?? channelIdentifier,
|
||||
title: Self.withoutLeadingNumber(name, number: channelVcn),
|
||||
channelIdentifier: channelVcn,
|
||||
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.
|
||||
@@ -268,7 +289,12 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
||||
.compactMap { try? values.decodeIfPresent(String.self, forKey: $0) }
|
||||
.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.key = try values.decodeIfPresent(String.self, forKey: .key)
|
||||
|
||||
@@ -341,6 +367,8 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
||||
public let endsAt: Date
|
||||
public let channelID: String?
|
||||
public let thumb: String?
|
||||
/// Die Adresse dieser Sendung beim Programmanbieter.
|
||||
public let metadataKey: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -350,7 +378,8 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
channelID: String? = nil,
|
||||
thumb: String? = nil
|
||||
thumb: String? = nil,
|
||||
metadataKey: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -360,6 +389,7 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
||||
self.endsAt = endsAt
|
||||
self.channelID = channelID
|
||||
self.thumb = thumb
|
||||
self.metadataKey = metadataKey
|
||||
}
|
||||
|
||||
public var duration: Duration {
|
||||
@@ -385,6 +415,9 @@ public struct PlexProgram: Sendable, Equatable, Identifiable {
|
||||
/// die Oberfläche keine Zeitstempel rechnen muss.
|
||||
struct PlexGridEntry: Decodable {
|
||||
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 summary: String?
|
||||
let grandparentTitle: String?
|
||||
@@ -396,7 +429,7 @@ struct PlexGridEntry: Decodable {
|
||||
let media: [PlexGridMedia]?
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -416,7 +449,8 @@ struct PlexGridEntry: Decodable {
|
||||
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
|
||||
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
|
||||
channelID: channelID,
|
||||
thumb: thumb
|
||||
thumb: thumb,
|
||||
metadataKey: key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user