Live-TV: die Sender stehen im Programmraster, nicht in einer Senderliste
Drei geratene Adressen für die Senderliste antworteten auf einem echten Server alle mit 404. Statt ein viertes Mal zu raten, liest die App jetzt zwei Dinge: was /media/providers an Endpunkten angibt, und das Programmraster selbst — die Weboberfläche von Plex zeichnet ihren Guide daraus, und jeder Rastereintrag trägt Sendername, Nummer, Logo und Einschaltweg mit sich. Damit kommen Sender und Programm aus einer Abfrage, und ihre Kennungen passen zueinander. Getrennt geholt taten sie das nicht: das Programm landete in keiner Zeile. Dazu die Ansicht, für die das alles da ist — ein Programmraster mit stehender Senderspalte, mitlaufender Zeitachse und einer Jetzt-Linie. Antippen öffnet die Sendung; läuft sie gerade, schaltet ein Knopf um. Die Geometrie steht in GuideLayout und ist geprüft: in einer View wäre sie unsichtbar falsch. Die Trennlinien gehören in die Zeile statt zwischen die Zeilen — Divider ist auf 3x-Schirmen keinen ganzen Punkt hoch, und der Bruchteil summiert sich, bis die angetippte Zeile zum Nachbarsender gehört. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9f9eec1b7f
commit
591cba73a7
+2518
-2372
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,12 @@ struct LiveTVView: View {
|
||||
@State private var channelSource: String?
|
||||
@State private var programs: [String: [PlexProgram]] = [:]
|
||||
@State private var isTuning: String?
|
||||
/// Raster oder Senderliste.
|
||||
///
|
||||
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
|
||||
/// die Liste zum schnellen Umschalten. Die Wahl bleibt erhalten, weil
|
||||
/// niemand sie zweimal treffen will.
|
||||
@AppStorage("liveTV.showsGuide") private var showsGuide = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -65,17 +71,38 @@ struct LiveTVView: View {
|
||||
}
|
||||
|
||||
case .available:
|
||||
guide
|
||||
if showsGuide {
|
||||
ProgramGuide(
|
||||
channels: channels,
|
||||
programs: programs,
|
||||
tuning: isTuning,
|
||||
onTune: { channel in Task { await tune(channel) } }
|
||||
)
|
||||
} else {
|
||||
channelList
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("sidebar.liveTV"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if case .available = availability, !channels.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Picker("liveTV.mode", selection: $showsGuide) {
|
||||
Image(systemName: "calendar.day.timeline.left").tag(true)
|
||||
Image(systemName: "list.bullet").tag(false)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
.refreshable { await load() }
|
||||
}
|
||||
|
||||
private var guide: some View {
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
@@ -99,26 +126,18 @@ struct LiveTVView: View {
|
||||
|
||||
guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return }
|
||||
|
||||
let lookup = await server.channels(inDVR: dvr)
|
||||
channels = lookup.channels
|
||||
channelSource = lookup.description
|
||||
|
||||
// Der Programmführer kommt getrennt: er kann ausfallen, ohne dass
|
||||
// deshalb das Umschalten unmöglich wäre. Eine Senderliste ohne
|
||||
// Programm ist immer noch nützlich.
|
||||
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
|
||||
let now = Date()
|
||||
let entries = (try? await server.guide(
|
||||
providerIdentifier: provider,
|
||||
from: now.addingTimeInterval(-3_600),
|
||||
to: now.addingTimeInterval(6 * 3_600)
|
||||
)) ?? []
|
||||
|
||||
programs = Dictionary(grouping: entries.compactMap { program in
|
||||
program.channelID.map { (key: $0, program: program) }
|
||||
}, by: \.key).mapValues { pairs in
|
||||
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
|
||||
}
|
||||
// Sender und Programm kommen zusammen: die Senderliste des Anbieters
|
||||
// antwortet auf manchen Servern gar nicht, und dann tragen die
|
||||
// Rastereinträge die Sender selbst. Getrennt geholt passten die
|
||||
// Kennungen zudem nicht zueinander, und das Programm landete in
|
||||
// keiner Zeile.
|
||||
// Sechs Stunden: ein ganzer Fernsehabend. Mehr wäre bei einer Liste
|
||||
// mit fünfhundert Sendern eine Antwort im zweistelligen Megabereich,
|
||||
// und über Mobilfunk wartet man darauf.
|
||||
let guide = await server.liveTVGuide(dvr: dvr, hours: 6)
|
||||
channels = guide.channels
|
||||
programs = guide.programs
|
||||
channelSource = guide.source
|
||||
}
|
||||
|
||||
private func tune(_ channel: PlexChannel) async {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
|
||||
///
|
||||
/// Warum ein Raster und nicht eine Liste mit „läuft gerade": beim Fernsehen
|
||||
/// sucht man nicht einen Sender, sondern eine Sendung — und die findet man
|
||||
/// nur, wenn man sieht, was auf allen Sendern gleichzeitig läuft. Genau das
|
||||
/// ist die Achse.
|
||||
///
|
||||
/// Der Aufbau: die Senderspalte steht **außerhalb** des waagerechten
|
||||
/// Rollbereichs und bleibt deshalb stehen, während das Programm daran
|
||||
/// vorbeizieht. Die Zeitachse steht außerhalb des senkrechten und folgt dem
|
||||
/// waagerechten Versatz von Hand — anders bekommt man zwei Achsen nicht
|
||||
/// gleichzeitig festgenagelt.
|
||||
struct ProgramGuide: View {
|
||||
let channels: [PlexChannel]
|
||||
let programs: [String: [PlexProgram]]
|
||||
let tuning: String?
|
||||
let onTune: (PlexChannel) -> Void
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var scrollX: CGFloat = 0
|
||||
@State private var selection: GuideSelection?
|
||||
@State private var now = Date()
|
||||
|
||||
private let channelWidth: CGFloat = 116
|
||||
private let rowHeight: CGFloat = 64
|
||||
/// Eine halbe Stunde ist gut 130 Punkte breit — schmal genug, dass ein
|
||||
/// Abend auf den Schirm passt, breit genug für einen lesbaren Titel.
|
||||
private let pointsPerMinute: CGFloat = 4.4
|
||||
private let axisHeight: CGFloat = 26
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
axis
|
||||
Divider().overlay(Color.flixySeparator)
|
||||
|
||||
ScrollView(.vertical) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
channelColumn
|
||||
lanes
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.sheet(item: $selection) { selection in
|
||||
ProgramDetailSheet(
|
||||
channel: selection.channel,
|
||||
program: selection.program,
|
||||
isRunning: selection.program.isRunning(at: now),
|
||||
onTune: { onTune(selection.channel) }
|
||||
)
|
||||
}
|
||||
// Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
|
||||
// paar Minuten nicht mehr. Einmal pro Minute reicht dafür.
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
now = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zeitfenster
|
||||
|
||||
/// Zeit in Punkte. Die Rechnungen dahinter stehen in `GuideLayout` und
|
||||
/// sind dort geprüft — hier wären sie unsichtbar falsch.
|
||||
private var layout: GuideLayout {
|
||||
GuideLayout(
|
||||
now: now,
|
||||
latestEnd: programs.values.flatMap { $0 }.map(\.endsAt).max(),
|
||||
pointsPerMinute: pointsPerMinute
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Teile
|
||||
|
||||
private var axis: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("liveTV.channelCount \(channels.count)")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.frame(width: channelWidth, alignment: .leading)
|
||||
.padding(.leading, FlixySpacing.snug)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
ForEach(layout.ticks, id: \.self) { tick in
|
||||
Text(tick.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.frame(width: layout.tickWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(width: layout.totalWidth, alignment: .leading)
|
||||
.offset(x: -scrollX)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: axisHeight)
|
||||
.background(Color.flixyCanvas)
|
||||
}
|
||||
|
||||
private var channelColumn: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
Button { onTune(channel) } label: {
|
||||
HStack(spacing: FlixySpacing.snug) {
|
||||
FlixyArtwork(
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
||||
aspectRatio: 1,
|
||||
fallbackSymbol: "tv"
|
||||
)
|
||||
.frame(width: 30, height: 30)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(channel.title)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.lineLimit(2)
|
||||
if let number = channel.channelIdentifier {
|
||||
Text(number)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if tuning == channel.id {
|
||||
ProgressView().controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.snug)
|
||||
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
||||
.overlay(alignment: .bottom) { rowSeparator }
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: channelWidth)
|
||||
.background(Color.flixyElevated.opacity(0.4))
|
||||
}
|
||||
|
||||
private var lanes: some View {
|
||||
ScrollView(.horizontal) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
lane(for: channel)
|
||||
}
|
||||
}
|
||||
|
||||
// Die Jetzt-Linie läuft mit dem Inhalt mit, nicht mit dem
|
||||
// Blickfeld — sie markiert einen Zeitpunkt, keine Stelle auf
|
||||
// dem Schirm.
|
||||
Rectangle()
|
||||
.fill(accent)
|
||||
.frame(width: 2)
|
||||
.offset(x: layout.x(for: now))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: layout.totalWidth, alignment: .topLeading)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.frame(height: CGFloat(channels.count) * rowHeight)
|
||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||
geometry.contentOffset.x
|
||||
} action: { _, offset in
|
||||
scrollX = offset
|
||||
}
|
||||
}
|
||||
|
||||
private func lane(for channel: PlexChannel) -> some View {
|
||||
let entries = programs[channel.id] ?? []
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
if entries.isEmpty {
|
||||
Text("liveTV.noProgram")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.padding(.horizontal, FlixySpacing.regular)
|
||||
.frame(height: rowHeight, alignment: .leading)
|
||||
}
|
||||
|
||||
ForEach(entries) { program in
|
||||
block(program, on: channel)
|
||||
.offset(x: layout.x(for: program.startsAt))
|
||||
}
|
||||
}
|
||||
.frame(width: layout.totalWidth, height: rowHeight, alignment: .topLeading)
|
||||
.overlay(alignment: .bottom) { rowSeparator }
|
||||
}
|
||||
|
||||
/// Die Trennlinie gehört in die Zeile, nicht zwischen die Zeilen.
|
||||
///
|
||||
/// `Divider()` ist auf einem 3x-Schirm keinen ganzen Punkt hoch. Zwischen
|
||||
/// den Zeilen gestapelt summiert sich dieser Bruchteil, und Senderspalte
|
||||
/// und Programm stehen nach zwanzig Sendern sichtbar versetzt — die
|
||||
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
|
||||
private var rowSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color.flixySeparator)
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private func block(_ program: PlexProgram, on channel: PlexChannel) -> some View {
|
||||
let running = program.isRunning(at: now)
|
||||
|
||||
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(program.title)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.snug)
|
||||
.padding(.vertical, 6)
|
||||
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
|
||||
.background(
|
||||
running ? accent.opacity(0.28) : Color.flixyElevated,
|
||||
in: .rect(cornerRadius: FlixyRadius.control)
|
||||
)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
|
||||
// Umschalten die nützlichste Angabe überhaupt.
|
||||
if running {
|
||||
WatchProgressBar(fraction: program.progress(at: now))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die angetippte Sendung samt ihrem Sender.
|
||||
private struct GuideSelection: Identifiable {
|
||||
let channel: PlexChannel
|
||||
let program: PlexProgram
|
||||
|
||||
var id: String { "\(channel.id)-\(program.id)" }
|
||||
}
|
||||
|
||||
/// Was zu einer Sendung im Raster gehört, in voller Länge.
|
||||
private struct ProgramDetailSheet: View {
|
||||
let channel: PlexChannel
|
||||
let program: PlexProgram
|
||||
let isRunning: Bool
|
||||
let onTune: () -> Void
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
FlixyArtwork(
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 160, height: 160),
|
||||
aspectRatio: 1,
|
||||
fallbackSymbol: "tv"
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(.rect(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(channel.title)
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(program.title)
|
||||
.font(.flixyRowHeader)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
|
||||
if let subtitle = program.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
|
||||
if isRunning {
|
||||
Button { onTune(); dismiss() } label: {
|
||||
Label("liveTV.tune", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
}
|
||||
|
||||
if let summary = program.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(FlixySpacing.screenMargin)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// Die Geometrie eines Programmrasters: Zeit in Punkte.
|
||||
///
|
||||
/// Eigener Typ statt Rechnungen in der Ansicht, weil hier alles schiefgehen
|
||||
/// kann, was man nicht sieht: eine Sendung, die vor dem Fenster begann, säße
|
||||
/// bei negativem Versatz außerhalb; eine Kurzmeldung wäre zwei Punkte breit
|
||||
/// und unlesbar; ein leeres Raster hätte die Breite null. Geprüft werden
|
||||
/// diese Fälle nur, wenn sie außerhalb einer `View` stehen.
|
||||
public struct GuideLayout: Sendable, Equatable {
|
||||
/// Der Anfang des Fensters — die angebrochene halbe Stunde vor „jetzt".
|
||||
///
|
||||
/// Nicht „jetzt" selbst: dann trüge die Achse krumme Zeiten und die
|
||||
/// laufende Sendung wäre immer angeschnitten.
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
public let pointsPerMinute: CGFloat
|
||||
/// Schmaler wird kein Block, egal wie kurz die Sendung ist.
|
||||
public let minimumBlockWidth: CGFloat
|
||||
|
||||
private static let halfHour: TimeInterval = 30 * 60
|
||||
|
||||
public init(
|
||||
now: Date,
|
||||
latestEnd: Date? = nil,
|
||||
pointsPerMinute: CGFloat = 4.4,
|
||||
minimumBlockWidth: CGFloat = 36,
|
||||
minimumHours: Double = 3,
|
||||
maximumHours: Double = 14
|
||||
) {
|
||||
let anchor = (now.timeIntervalSince1970 / Self.halfHour).rounded(.down) * Self.halfHour
|
||||
let start = Date(timeIntervalSince1970: anchor)
|
||||
self.start = start
|
||||
// Unten begrenzt, damit ein Raster mit einer einzigen bekannten
|
||||
// Sendung nicht zur Briefmarke wird; oben, damit es bei einem
|
||||
// Anbieter mit zwei Wochen Vorschau nicht endlos wird.
|
||||
let bounded = min(
|
||||
max(latestEnd ?? start, start.addingTimeInterval(minimumHours * 3_600)),
|
||||
start.addingTimeInterval(maximumHours * 3_600)
|
||||
)
|
||||
// Auch das Ende auf die halbe Stunde, nach oben. Sonst endet die
|
||||
// Achse mit einem Stummel, dessen Beschriftung keine Spalte mehr hat.
|
||||
self.end = Date(
|
||||
timeIntervalSince1970: (bounded.timeIntervalSince1970 / Self.halfHour).rounded(.up) * Self.halfHour
|
||||
)
|
||||
self.pointsPerMinute = pointsPerMinute
|
||||
self.minimumBlockWidth = minimumBlockWidth
|
||||
}
|
||||
|
||||
public var totalWidth: CGFloat {
|
||||
CGFloat(end.timeIntervalSince(start) / 60) * pointsPerMinute
|
||||
}
|
||||
|
||||
/// Der waagerechte Versatz eines Zeitpunkts.
|
||||
///
|
||||
/// Nie negativ: was vor dem Fenster liegt, klebt an dessen linkem Rand.
|
||||
public func x(for date: Date) -> CGFloat {
|
||||
max(0, CGFloat(date.timeIntervalSince(start) / 60) * pointsPerMinute)
|
||||
}
|
||||
|
||||
/// Die Breite einer Sendung, auf das Fenster beschnitten.
|
||||
public func width(from begin: Date, to finish: Date) -> CGFloat {
|
||||
let visibleStart = max(begin, start)
|
||||
let visibleEnd = min(finish, end)
|
||||
let minutes = max(0, visibleEnd.timeIntervalSince(visibleStart) / 60)
|
||||
return max(CGFloat(minutes) * pointsPerMinute, minimumBlockWidth)
|
||||
}
|
||||
|
||||
/// Die Beschriftungen der Zeitachse, halbstündlich.
|
||||
public var ticks: [Date] {
|
||||
stride(from: 0, to: end.timeIntervalSince(start), by: Self.halfHour)
|
||||
.map { start.addingTimeInterval($0) }
|
||||
}
|
||||
|
||||
/// Wie breit eine Achsenbeschriftung ist.
|
||||
public var tickWidth: CGFloat { CGFloat(Self.halfHour / 60) * pointsPerMinute }
|
||||
}
|
||||
@@ -56,8 +56,14 @@ extension PlexServer {
|
||||
return ChannelLookup(channels: [], source: .noLineup)
|
||||
}
|
||||
|
||||
let advertised = await providerFeatures(for: epgIdentifier)
|
||||
|
||||
var lastFailure: String?
|
||||
for path in Self.channelPaths(dvr: dvr, epgIdentifier: epgIdentifier) {
|
||||
for path in Self.channelPaths(
|
||||
dvr: dvr,
|
||||
epgIdentifier: epgIdentifier,
|
||||
advertised: advertised
|
||||
) {
|
||||
do {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: path, timeout: .seconds(30)),
|
||||
@@ -102,14 +108,41 @@ extension PlexServer {
|
||||
/// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt
|
||||
/// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt
|
||||
/// jeder dieser Pfade 404.
|
||||
static func channelPaths(dvr: PlexDVR, epgIdentifier: String) -> [String] {
|
||||
static func channelPaths(
|
||||
dvr: PlexDVR,
|
||||
epgIdentifier: String,
|
||||
advertised: [PlexProviderFeature] = []
|
||||
) -> [String] {
|
||||
var paths: [String] = []
|
||||
|
||||
// Zuerst, was der Server selbst angibt. Die Namen der Fähigkeiten sind
|
||||
// nicht dokumentiert, deshalb alles, was nach Sendern klingt.
|
||||
for feature in advertised where ["channels", "lineups", "livetv", "grid"].contains(feature.type) {
|
||||
if let key = feature.key { paths.append(key) }
|
||||
}
|
||||
|
||||
// Danach die geratenen Formen. Sie haben bei einem Server mit
|
||||
// Swisscom-Liste alle 404 ergeben — sie stehen hier nur noch, weil
|
||||
// andere Serverfassungen sie kennen könnten.
|
||||
if let lineup = dvr.lineups.first {
|
||||
paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels")
|
||||
}
|
||||
paths.append("/\(epgIdentifier)/channels")
|
||||
paths.append("/livetv/dvrs/\(dvr.key)/channels")
|
||||
return paths
|
||||
|
||||
// Doppelte entfernen, Reihenfolge behalten.
|
||||
var seen = Set<String>()
|
||||
return paths.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
/// Was der Programmanbieter an Fähigkeiten angibt.
|
||||
public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] {
|
||||
guard let container = try? await client.send(
|
||||
PlexRequest(path: "/media/providers"),
|
||||
as: PlexMediaContainer<PlexMediaProvider>.self
|
||||
) else { return [] }
|
||||
|
||||
return container.content.first { $0.identifier == identifier }?.features ?? []
|
||||
}
|
||||
|
||||
/// Die Kennung des Programmanbieters.
|
||||
@@ -126,7 +159,7 @@ extension PlexServer {
|
||||
return container.content.first { $0.identifier.contains("epg") }?.identifier
|
||||
}
|
||||
|
||||
/// Das Programmraster für einen Zeitraum.
|
||||
/// Das Programmraster für einen Zeitraum, mit Sendern.
|
||||
///
|
||||
/// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender
|
||||
/// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden
|
||||
@@ -135,7 +168,7 @@ extension PlexServer {
|
||||
providerIdentifier: String,
|
||||
from start: Date,
|
||||
to end: Date
|
||||
) async throws(PlexError) -> [PlexProgram] {
|
||||
) async throws(PlexError) -> [PlexGuideEntry] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(
|
||||
path: "/\(providerIdentifier)/grid",
|
||||
@@ -151,14 +184,73 @@ extension PlexServer {
|
||||
)
|
||||
|
||||
return container.content.compactMap { entry in
|
||||
// Die Senderzuordnung steckt im Medien-Block. Fehlt sie, lässt
|
||||
// sich die Sendung keiner Spalte zuordnen — dann ist sie für ein
|
||||
// Raster wertlos.
|
||||
let channelID = entry.media?.first?.parts.first?.key
|
||||
return entry.program(channelID: channelID)
|
||||
// Sender und Sendung stammen aus demselben Eintrag — deshalb
|
||||
// passen die Kennungen zueinander. Getrennt geholt taten sie das
|
||||
// nicht, und das Programm landete in keiner Spalte.
|
||||
let channel = entry.channel
|
||||
guard let program = entry.program(channelID: channel?.id) else { return nil }
|
||||
return PlexGuideEntry(channel: channel, program: program)
|
||||
}
|
||||
}
|
||||
|
||||
/// Senderliste und Programm in einem Zug.
|
||||
///
|
||||
/// Der Weg über die Senderliste des Anbieters ist der erste Versuch; wo
|
||||
/// er greift, sind Logos und Nummern vollständiger. Wo er nicht greift —
|
||||
/// und bei einem Server mit Swisscom-Liste tut er das nicht, alle
|
||||
/// bekannten Adressen antworten mit 404 — tragen die Rastereinträge die
|
||||
/// Sender selbst. Die Weboberfläche von Plex zeichnet ihren Guide genau
|
||||
/// daraus.
|
||||
public func liveTVGuide(
|
||||
dvr: PlexDVR,
|
||||
hours: Int = 6
|
||||
) async -> LiveTVGuide {
|
||||
let lookup = await channels(inDVR: dvr)
|
||||
|
||||
var identifier = dvr.epgIdentifier
|
||||
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
|
||||
guard let epgIdentifier = identifier else {
|
||||
return LiveTVGuide(channels: lookup.channels, programs: [:], source: lookup.description)
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let entries = (try? await guide(
|
||||
providerIdentifier: epgIdentifier,
|
||||
from: now.addingTimeInterval(-3_600),
|
||||
to: now.addingTimeInterval(TimeInterval(hours) * 3_600)
|
||||
)) ?? []
|
||||
|
||||
let programs = Dictionary(grouping: entries.compactMap { entry in
|
||||
entry.program.channelID.map { (key: $0, program: entry.program) }
|
||||
}, by: \.key).mapValues { pairs in
|
||||
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
|
||||
}
|
||||
|
||||
if !lookup.channels.isEmpty {
|
||||
return LiveTVGuide(channels: lookup.channels, programs: programs, source: lookup.description)
|
||||
}
|
||||
|
||||
// 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,
|
||||
programs: programs,
|
||||
source: channels.isEmpty
|
||||
? "Raster: keine Sender (\(lookup.description))"
|
||||
: "Raster: \(channels.count) von \(fromGrid.count) Sendern"
|
||||
)
|
||||
}
|
||||
|
||||
/// Schaltet einen Sender ein.
|
||||
///
|
||||
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
|
||||
@@ -202,6 +294,11 @@ extension PlexServer {
|
||||
var report = ["=== /livetv/dvrs ==="]
|
||||
report.append(await structure(ofPath: "/livetv/dvrs"))
|
||||
|
||||
// Was der Server an Endpunkten angibt. Nach drei geratenen Adressen
|
||||
// mit 404 ist das die Stelle, an der die Antwort stehen muss.
|
||||
report.append("\n=== /media/providers ===")
|
||||
report.append(await structure(ofPath: "/media/providers"))
|
||||
|
||||
// Und die Senderliste beim Anbieter — dort stehen die Namen. Ohne sie
|
||||
// im selben Abzug bliebe nach einem Fehlschlag wieder eine Runde
|
||||
// Raten übrig.
|
||||
@@ -210,10 +307,29 @@ extension PlexServer {
|
||||
as: PlexMediaContainer<PlexDVR>.self
|
||||
).content.first,
|
||||
let epgIdentifier = dvr.epgIdentifier {
|
||||
for path in Self.channelPaths(dvr: dvr, epgIdentifier: epgIdentifier) {
|
||||
let advertised = await providerFeatures(for: epgIdentifier)
|
||||
for path in Self.channelPaths(
|
||||
dvr: dvr,
|
||||
epgIdentifier: epgIdentifier,
|
||||
advertised: advertised
|
||||
) {
|
||||
report.append("\n=== \(path) ===")
|
||||
report.append(await structure(ofPath: path))
|
||||
}
|
||||
// Und das Raster: wenn keine Senderliste antwortet, muss die
|
||||
// Antwort hier stehen — die Weboberfläche zeichnet ihren Guide
|
||||
// daraus.
|
||||
let now = Int(Date().timeIntervalSince1970)
|
||||
let gridPath = "/\(epgIdentifier)/grid"
|
||||
report.append("\n=== \(gridPath) ===")
|
||||
report.append(await structure(
|
||||
ofPath: gridPath,
|
||||
query: [
|
||||
URLQueryItem(name: "type", value: "1"),
|
||||
URLQueryItem(name: "beginsAt>", value: String(now - 3_600)),
|
||||
URLQueryItem(name: "endsAt<", value: String(now + 3_600)),
|
||||
]
|
||||
))
|
||||
} else {
|
||||
report.append("\n=== Senderliste ===\nKeine Kennung des Programmanbieters am Empfänger")
|
||||
}
|
||||
@@ -221,10 +337,10 @@ extension PlexServer {
|
||||
return report.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func structure(ofPath path: String) async -> String {
|
||||
private func structure(ofPath path: String, query: [URLQueryItem] = []) async -> String {
|
||||
let data: Data
|
||||
do {
|
||||
data = try await client.send(PlexRequest(path: path, timeout: .seconds(30)))
|
||||
data = try await client.send(PlexRequest(path: path, query: query, timeout: .seconds(30)))
|
||||
} catch {
|
||||
return "Abfrage gescheitert: \(error)"
|
||||
}
|
||||
@@ -245,7 +361,8 @@ extension PlexServer {
|
||||
case let dictionary as [String: Any]:
|
||||
return dictionary.keys.sorted().flatMap { key -> [String] in
|
||||
let child = dictionary[key]!
|
||||
if ["title", "channelIdentifier", "lineupTitle"].contains(key),
|
||||
if ["title", "channelIdentifier", "lineupTitle", "type", "key", "identifier",
|
||||
"channelTitle", "channelCallSign", "channelVcn"].contains(key),
|
||||
let text = child as? String {
|
||||
return ["\(padding)\(key): \(text)"]
|
||||
}
|
||||
@@ -310,6 +427,65 @@ public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let identifier: String
|
||||
public let title: String?
|
||||
public let types: String?
|
||||
/// Was dieser Anbieter kann, und unter welcher Adresse.
|
||||
///
|
||||
/// Der Grund, warum hier überhaupt etwas steht: Plex sagt selbst, welche
|
||||
/// Endpunkte es anbietet. Drei Versuche, die Adresse der Senderliste zu
|
||||
/// erraten, endeten in 404 — sie hier abzulesen ist kein Ratespiel mehr.
|
||||
public let features: [PlexProviderFeature]
|
||||
|
||||
public var id: String { identifier }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case identifier, title, types
|
||||
case features = "Feature"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.identifier = try values.decode(String.self, forKey: .identifier)
|
||||
self.title = try values.decodeIfPresent(String.self, forKey: .title)
|
||||
self.types = try values.decodeIfPresent(String.self, forKey: .types)
|
||||
self.features = try values.decodeIfPresent([PlexProviderFeature].self, forKey: .features) ?? []
|
||||
}
|
||||
|
||||
public init(identifier: String, title: String? = nil, types: String? = nil, features: [PlexProviderFeature] = []) {
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.types = types
|
||||
self.features = features
|
||||
}
|
||||
|
||||
/// Die Adresse für eine Fähigkeit, falls der Anbieter sie anbietet.
|
||||
public func key(forFeature type: String) -> String? {
|
||||
features.first { $0.type == type }?.key
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Fähigkeit eines Anbieters, mit der Adresse dahinter.
|
||||
public struct PlexProviderFeature: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let type: String
|
||||
public let key: String?
|
||||
|
||||
public var id: String { key ?? type }
|
||||
|
||||
public init(type: String, key: String? = nil) {
|
||||
self.type = type
|
||||
self.key = key
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Rastereintrag: eine Sendung und der Sender, auf dem sie läuft.
|
||||
public struct PlexGuideEntry: Sendable {
|
||||
public let channel: PlexChannel?
|
||||
public let program: PlexProgram
|
||||
}
|
||||
|
||||
/// Senderliste und Programm, so wie die Live-TV-Ansicht sie braucht.
|
||||
public struct LiveTVGuide: Sendable {
|
||||
public let channels: [PlexChannel]
|
||||
/// Sendungen je Sender, nach Anfangszeit sortiert.
|
||||
public let programs: [String: [PlexProgram]]
|
||||
/// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose.
|
||||
public let source: String
|
||||
}
|
||||
|
||||
@@ -167,6 +167,65 @@ public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Medien-Block einer Sendung im Raster.
|
||||
///
|
||||
/// Er trägt die Angaben zum Sender mit: das Raster ist bei Plex nicht nur
|
||||
/// eine Liste von Sendungen, sondern die einzige Antwort, in der Sendername,
|
||||
/// Nummer, Logo und Einschaltweg zusammen stehen. Drei geratene
|
||||
/// Senderlisten-Adressen ergaben 404 — diese hier liefert die
|
||||
/// Weboberfläche selbst aus.
|
||||
struct PlexGridMedia: Decodable {
|
||||
let channelIdentifier: String?
|
||||
let channelTitle: String?
|
||||
let channelCallSign: String?
|
||||
let channelThumb: String?
|
||||
let channelVcn: String?
|
||||
let parts: [PlexGridPart]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn
|
||||
case part = "Part"
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.channelIdentifier = try? values.decodeIfPresent(String.self, forKey: .channelIdentifier)
|
||||
self.channelTitle = try values.decodeIfPresent(String.self, forKey: .channelTitle)
|
||||
self.channelCallSign = try values.decodeIfPresent(String.self, forKey: .channelCallSign)
|
||||
self.channelThumb = try values.decodeIfPresent(String.self, forKey: .channelThumb)
|
||||
// Die Sendernummer kommt mal als Zahl, mal als Zeichenkette.
|
||||
if let text = try? values.decodeIfPresent(String.self, forKey: .channelVcn) {
|
||||
self.channelVcn = text
|
||||
} else if let number = try? values.decodeIfPresent(Int.self, forKey: .channelVcn) {
|
||||
self.channelVcn = String(number)
|
||||
} else {
|
||||
self.channelVcn = nil
|
||||
}
|
||||
self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
|
||||
}
|
||||
|
||||
/// Der Sender, sofern dieser Block einen benennt.
|
||||
///
|
||||
/// Ohne Namen kein Sender: eine Zeile, die nur „—" hieße, ist für die
|
||||
/// 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
|
||||
return PlexChannel(
|
||||
id: identity,
|
||||
title: name,
|
||||
channelIdentifier: channelVcn ?? channelIdentifier,
|
||||
thumb: channelThumb,
|
||||
key: parts.first?.key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Strom hinter einer Rastersendung.
|
||||
struct PlexGridPart: Decodable {
|
||||
let key: String?
|
||||
}
|
||||
|
||||
/// Ein Sender.
|
||||
public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
||||
/// Die Nummer, unter der der Sender läuft — etwa `"3.1"`.
|
||||
@@ -295,13 +354,19 @@ struct PlexGridEntry: Decodable {
|
||||
let endsAt: Int?
|
||||
|
||||
/// Die Sendermedien; darin steht, zu welchem Sender die Sendung gehört.
|
||||
let media: [PlexMedia]?
|
||||
let media: [PlexGridMedia]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ratingKey, title, summary, grandparentTitle, thumb, beginsAt, endsAt
|
||||
case media = "Media"
|
||||
}
|
||||
|
||||
/// Der Sender, auf dem diese Sendung läuft.
|
||||
///
|
||||
/// Hier steht der Sender vollständig: Name, Nummer, Logo, Einschaltweg.
|
||||
/// Das ist der Grund, warum das Raster die Senderliste ersetzen kann.
|
||||
var channel: PlexChannel? { media?.compactMap(\.channel).first }
|
||||
|
||||
func program(channelID: String?) -> PlexProgram? {
|
||||
guard let beginsAt, let endsAt, let title else { return nil }
|
||||
return PlexProgram(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FlixyDesign
|
||||
|
||||
/// Die Geometrie des Programmrasters.
|
||||
@Suite("Raster-Geometrie")
|
||||
struct GuideLayoutTests {
|
||||
|
||||
/// 13:47 Uhr — bewusst krumm, denn genau das ist der Normalfall.
|
||||
private let now = Date(timeIntervalSince1970: 1_757_684_820)
|
||||
|
||||
private var layout: GuideLayout {
|
||||
GuideLayout(now: now, latestEnd: now.addingTimeInterval(6 * 3_600), pointsPerMinute: 4)
|
||||
}
|
||||
|
||||
@Test("Das Fenster beginnt auf der halben Stunde, nicht jetzt")
|
||||
func snapsToHalfHour() {
|
||||
let start = layout.start
|
||||
let minutes = Calendar(identifier: .gregorian).component(.minute, from: start)
|
||||
|
||||
#expect(minutes % 30 == 0)
|
||||
#expect(start <= now)
|
||||
#expect(now.timeIntervalSince(start) < 30 * 60)
|
||||
}
|
||||
|
||||
@Test("Eine Sendung, die vor dem Fenster begann, klebt am linken Rand")
|
||||
func clampsEarlierStart() {
|
||||
// Sonst säße die laufende Sendung bei negativem Versatz außerhalb —
|
||||
// ausgerechnet die, die man am ehesten sehen will.
|
||||
#expect(layout.x(for: now.addingTimeInterval(-9 * 3_600)) == 0)
|
||||
}
|
||||
|
||||
@Test("Eine angebrochene Sendung behält nur ihren sichtbaren Teil")
|
||||
func clipsToWindow() {
|
||||
let start = layout.start.addingTimeInterval(-1_800)
|
||||
let end = layout.start.addingTimeInterval(1_800)
|
||||
|
||||
// Eine Stunde lang, aber nur die zweite Hälfte liegt im Fenster.
|
||||
#expect(layout.width(from: start, to: end) == 120.0)
|
||||
}
|
||||
|
||||
@Test("Eine Kurzmeldung bleibt lesbar breit")
|
||||
func keepsShortProgramsReadable() {
|
||||
let start = layout.start
|
||||
let end = start.addingTimeInterval(120)
|
||||
|
||||
#expect(layout.width(from: start, to: end) == layout.minimumBlockWidth)
|
||||
}
|
||||
|
||||
@Test("Ohne Programm hat das Raster trotzdem Breite")
|
||||
func emptyGuideHasWidth() {
|
||||
let empty = GuideLayout(now: now, latestEnd: nil, pointsPerMinute: 4)
|
||||
|
||||
#expect(empty.totalWidth > 0)
|
||||
#expect(empty.end.timeIntervalSince(empty.start) == 3 * 3_600)
|
||||
}
|
||||
|
||||
@Test("Das Fenster endet auf der halben Stunde")
|
||||
func snapsEndToHalfHour() {
|
||||
let minutes = Calendar(identifier: .gregorian).component(.minute, from: layout.end)
|
||||
|
||||
#expect(minutes % 30 == 0)
|
||||
#expect(layout.end >= now.addingTimeInterval(6 * 3_600))
|
||||
}
|
||||
|
||||
@Test("Zwei Wochen Vorschau werden auf vierzehn Stunden gekürzt")
|
||||
func capsLongGuides() {
|
||||
let long = GuideLayout(now: now, latestEnd: now.addingTimeInterval(14 * 24 * 3_600))
|
||||
|
||||
#expect(long.end.timeIntervalSince(long.start) == 14 * 3_600)
|
||||
}
|
||||
|
||||
@Test("Die Achse ist halbstündlich und deckt das Fenster ab")
|
||||
func ticksCoverWindow() {
|
||||
// 13:30 bis 19:47, aufgerundet auf 20:00 — dreizehn halbe Stunden.
|
||||
#expect(layout.ticks.count == 13)
|
||||
#expect(layout.ticks.first == layout.start)
|
||||
#expect(layout.ticks.allSatisfy { $0 < layout.end })
|
||||
#expect(layout.tickWidth == 120.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"MediaContainer": {
|
||||
"size": 3,
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey": "epg://1",
|
||||
"title": "Tagesschau",
|
||||
"summary": "Nachrichten",
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757682900,
|
||||
"Media": [
|
||||
{
|
||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11",
|
||||
"channelTitle": "SRF 1 HD",
|
||||
"channelCallSign": "SRF1",
|
||||
"channelThumb": "/library/metadata/11/thumb",
|
||||
"channelVcn": "1",
|
||||
"Part": [{ "key": "/livetv/channels/11" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ratingKey": "epg://2",
|
||||
"title": "Meteo",
|
||||
"beginsAt": 1757682900,
|
||||
"endsAt": 1757683200,
|
||||
"Media": [
|
||||
{
|
||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-11",
|
||||
"channelTitle": "SRF 1 HD",
|
||||
"channelVcn": 1,
|
||||
"Part": [{ "key": "/livetv/channels/11" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ratingKey": "epg://3",
|
||||
"title": "Sport aktuell",
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757685600,
|
||||
"Media": [
|
||||
{
|
||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
|
||||
"channelCallSign": "SRF2",
|
||||
"channelVcn": "2",
|
||||
"Part": [{ "key": "/livetv/channels/12" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import PlexKit
|
||||
|
||||
/// Woher die Senderliste kommt.
|
||||
///
|
||||
/// Drei Adressen, unter denen die Senderliste stehen sollte, antworteten auf
|
||||
/// einem echten Server mit 404 — geraten war jede. Das Programmraster ist
|
||||
/// keine Vermutung: die Weboberfläche von Plex zeichnet ihren Guide daraus,
|
||||
/// und jeder Eintrag trägt den Sender mit sich. Diese Tests halten fest, dass
|
||||
/// die Sender dort auch dann herauskommen, wenn sonst nichts antwortet.
|
||||
@Suite("Senderliste aus dem Programmraster")
|
||||
struct LiveTVGuideTests {
|
||||
|
||||
private func server(routes: [String: String]) -> PlexServer {
|
||||
PlexServer(
|
||||
resource: PlexResource(
|
||||
name: "Test",
|
||||
clientIdentifier: "srv",
|
||||
accessToken: "token",
|
||||
connections: [PlexConnection(uri: "http://server:32400", local: true)]
|
||||
),
|
||||
connection: PlexConnection(uri: "http://server:32400", local: true),
|
||||
identity: .test,
|
||||
transport: FakeTransport.routes(routes)
|
||||
)!
|
||||
}
|
||||
|
||||
private func fixture(_ name: String) throws -> String {
|
||||
let url = try #require(Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"))
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
|
||||
private var dvr: PlexDVR {
|
||||
PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
|
||||
}
|
||||
|
||||
@Test("Jeder Sender kommt genau einmal, obwohl er in mehreren Sendungen steht")
|
||||
func derivesChannelsFromGrid() async throws {
|
||||
let server = server(routes: [
|
||||
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
|
||||
])
|
||||
|
||||
let guide = await server.liveTVGuide(dvr: dvr)
|
||||
|
||||
#expect(guide.channels.count == 2)
|
||||
#expect(guide.channels.map(\.title) == ["SRF 1 HD", "SRF2"])
|
||||
}
|
||||
|
||||
@Test("Fehlt der Sendername, trägt das Kürzel den Sender")
|
||||
func fallsBackToCallSign() async throws {
|
||||
let server = server(routes: [
|
||||
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
|
||||
])
|
||||
|
||||
let guide = await server.liveTVGuide(dvr: dvr)
|
||||
let second = try #require(guide.channels.last)
|
||||
|
||||
#expect(second.title == "SRF2")
|
||||
// Die Sendernummer kommt mal als Zahl, mal als Zeichenkette — beides
|
||||
// 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")
|
||||
func groupsProgramsByChannel() async throws {
|
||||
let server = server(routes: [
|
||||
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
|
||||
])
|
||||
|
||||
let guide = await server.liveTVGuide(dvr: dvr)
|
||||
let first = try #require(guide.channels.first)
|
||||
let programs = try #require(guide.programs[first.id])
|
||||
|
||||
#expect(programs.map(\.title) == ["Tagesschau", "Meteo"])
|
||||
#expect(guide.programs.count == 2)
|
||||
}
|
||||
|
||||
@Test("Ein Sender ohne Namen taucht nicht als Strich auf")
|
||||
func skipsNamelessChannels() async throws {
|
||||
let json = """
|
||||
{"MediaContainer":{"size":1,"Metadata":[
|
||||
{"ratingKey":"x","title":"Irgendwas","beginsAt":1757682000,"endsAt":1757685600,
|
||||
"Media":[{"channelIdentifier":"nur-eine-kennung","Part":[{"key":"/livetv/channels/9"}]}]}
|
||||
]}}
|
||||
"""
|
||||
let server = server(routes: ["/tv.plex.providers.epg.cloud:2/grid": json])
|
||||
|
||||
let guide = await server.liveTVGuide(dvr: dvr)
|
||||
|
||||
#expect(guide.channels.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Die Einschaltadresse reist mit dem Sender mit")
|
||||
func keepsTuneKey() async throws {
|
||||
let server = server(routes: [
|
||||
"/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"),
|
||||
])
|
||||
|
||||
let guide = await server.liveTVGuide(dvr: dvr)
|
||||
|
||||
#expect(guide.channels.first?.key == "/livetv/channels/11")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user