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
@@ -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