Live-TV: das Programm zeigte die falsche Sendung als laufend
Der Fehler steckte in der Abfrage, und er ist grob:
beginsAt> = jetzt − 1 Std.
endsAt< = jetzt + 6 Std.
Beide Grenzen schließen genau das aus, was man sehen will. Ein
Zweistünder, der um halb vier begann, war um Viertel nach fünf
unsichtbar — und angezeigt wurde stattdessen die nächste Sendung, die
durch den Filter passte. Deshalb wich die Anzeige von Plex' eigener ab.
Jetzt wird nur der **Anfang** eingegrenzt, mit sechs Stunden Vorlauf für
lange Sendungen, und keine Grenze auf das Ende. Zurück kommt, was den
Zeitraum berührt, statt was vollständig hineinpasst.
Gefiltert wird danach nur das Programm, nicht die Senderliste: ein Sender
ohne frische Daten soll „kein Programm" sagen, statt zu verschwinden.
Dazu zwei Wünsche aus dem Feldtest:
Die Kachel lässt sich in der Größe ziehen. Der Griff sitzt in der Ecke,
die zur Bildschirmmitte zeigt — am äußeren Rand läge er halb außerhalb
des Daumens. Gespeichert wird die begrenzte Breite, nicht die gezogene,
sonst bliebe eine einmal zu weit gezogene Kachel für immer zu groß.
Und eine Senderauswahl. Über zwanzig Sender zu zeichnen und ihr Programm
zu holen kostet Zeit, und die meisten sieht man nie an. Eine leere
Auswahl heißt weiterhin „alle" — sonst stünde man beim ersten Öffnen vor
einer leeren Liste.
Die Zeiten im Raster-Abzug liegen jetzt in der Zukunft. Mit den echten
Zeitstempeln von damals wäre er ab dem Tag nach der Aufnahme leer
gewesen, sobald auf Frische gefiltert wird.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
26c909a2e2
commit
af45bebceb
@@ -118,6 +118,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"action.done": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fertig"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Done"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"action.markUnwatched": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2114,6 +2130,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.selection": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sender auswählen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Choose channels"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.selection.all": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Alle"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "All"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.selection.hint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ohne Auswahl werden alle Sender gezeigt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "With nothing selected, all channels are shown."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.timeRange %@ %@": {
|
||||
"comment": "Platzhalter: Anfangs- und Endzeit",
|
||||
"localizations": {
|
||||
@@ -2181,6 +2245,7 @@
|
||||
}
|
||||
},
|
||||
"miniPlayer.expand": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -2196,6 +2261,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.resize": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Größe ändern"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Resize"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"placeholder.description": {
|
||||
"comment": "Platzhalter-Bildschirme",
|
||||
"localizations": {
|
||||
@@ -2247,6 +2328,7 @@
|
||||
}
|
||||
},
|
||||
"player.close": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Welche Sender überhaupt angezeigt werden.
|
||||
///
|
||||
/// Der Empfänger liefert alles, was die Senderliste hergibt — bei einer
|
||||
/// Swisscom-Liste sind das mehr als zwanzig, und die meisten sieht man nie an.
|
||||
/// Jeden davon zu zeichnen und sein Programm zu holen kostet Zeit und macht
|
||||
/// die Ansicht unübersichtlich.
|
||||
struct ChannelSelectionSheet: View {
|
||||
let channels: [PlexChannel]
|
||||
@Binding var selection: Set<String>
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(channels) { channel in
|
||||
row(for: channel)
|
||||
}
|
||||
} footer: {
|
||||
Text("liveTV.selection.hint")
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text("liveTV.selection"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
// „Alle" statt „keine": eine leere Auswahl bedeutet
|
||||
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
|
||||
// wäre verwirrend.
|
||||
Button("liveTV.selection.all") { selection.removeAll() }
|
||||
.disabled(selection.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for channel: PlexChannel) -> some View {
|
||||
Button {
|
||||
// Beim ersten Antippen aus „alle" eine echte Auswahl machen:
|
||||
// sonst hakt man einen Sender an und alle anderen bleiben
|
||||
// trotzdem sichtbar.
|
||||
if selection.isEmpty {
|
||||
selection = Set(channels.map(\.id)).subtracting([channel.id])
|
||||
} else if selection.contains(channel.id) {
|
||||
selection.remove(channel.id)
|
||||
} else {
|
||||
selection.insert(channel.id)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
FlixyArtwork(
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
||||
aspectRatio: 1,
|
||||
fallbackSymbol: "tv"
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
if let number = channel.channelIdentifier {
|
||||
Text(number)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
|
||||
Text(channel.title)
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isShown(channel) ? accent : Color.flixyTextMuted)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func isShown(_ channel: PlexChannel) -> Bool {
|
||||
ChannelSelection.shows(channel, selection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Regel, nach der ausgewählt wird.
|
||||
///
|
||||
/// Eigener Typ, weil sie an zwei Stellen gilt — in der Auswahl und in der
|
||||
/// Anzeige — und beide dasselbe meinen müssen. Eine **leere** Auswahl bedeutet
|
||||
/// „alle": so sieht man beim ersten Öffnen sein Programm, ohne vorher etwas
|
||||
/// angehakt zu haben.
|
||||
enum ChannelSelection {
|
||||
static func shows(_ channel: PlexChannel, selection: Set<String>) -> Bool {
|
||||
selection.isEmpty || selection.contains(channel.id)
|
||||
}
|
||||
|
||||
static func apply(_ selection: Set<String>, to channels: [PlexChannel]) -> [PlexChannel] {
|
||||
guard !selection.isEmpty else { return channels }
|
||||
let kept = channels.filter { selection.contains($0.id) }
|
||||
// Wählt jemand Sender ab, die es nicht mehr gibt, bliebe sonst eine
|
||||
// leere Liste stehen, ohne dass er es ändern könnte.
|
||||
return kept.isEmpty ? channels : kept
|
||||
}
|
||||
|
||||
/// Die Auswahl als Text, wie sie in den Einstellungen liegt.
|
||||
static func decode(_ stored: String) -> Set<String> {
|
||||
Set(stored.split(separator: "\n").map(String.init))
|
||||
}
|
||||
|
||||
static func encode(_ selection: Set<String>) -> String {
|
||||
selection.sorted().joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,15 @@ struct LiveTVView: View {
|
||||
/// die Liste zum schnellen Umschalten. Die Wahl bleibt erhalten, weil
|
||||
/// niemand sie zweimal treffen will.
|
||||
@AppStorage("liveTV.showsGuide") private var showsGuide = true
|
||||
/// Welche Sender gezeigt werden. Leer heißt alle.
|
||||
@AppStorage("liveTV.selection") private var storedSelection = ""
|
||||
@State private var showsSelection = false
|
||||
|
||||
/// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und
|
||||
/// Liste nicht auseinanderlaufen.
|
||||
private var shownChannels: [PlexChannel] {
|
||||
ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -72,7 +81,7 @@ struct LiveTVView: View {
|
||||
case .available:
|
||||
if showsGuide {
|
||||
ProgramGuide(
|
||||
channels: channels,
|
||||
channels: shownChannels,
|
||||
programs: programs,
|
||||
tuning: isTuning,
|
||||
artwork: { channel in
|
||||
@@ -90,6 +99,14 @@ struct LiveTVView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if case .available = availability, !channels.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showsSelection = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel(Text("liveTV.selection"))
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Picker("liveTV.mode", selection: $showsGuide) {
|
||||
Image(systemName: "calendar.day.timeline.left").tag(true)
|
||||
@@ -102,6 +119,15 @@ struct LiveTVView: View {
|
||||
}
|
||||
.task { await load() }
|
||||
.refreshable { await load() }
|
||||
.sheet(isPresented: $showsSelection) {
|
||||
ChannelSelectionSheet(
|
||||
channels: channels,
|
||||
selection: Binding(
|
||||
get: { ChannelSelection.decode(storedSelection) },
|
||||
set: { storedSelection = ChannelSelection.encode($0) }
|
||||
)
|
||||
)
|
||||
}
|
||||
// Ein Blatt statt eines Dialogs: die Antwort des Servers ist
|
||||
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
|
||||
// wieder nur, dass es nicht ging.
|
||||
@@ -157,7 +183,7 @@ struct LiveTVView: View {
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
ForEach(shownChannels) { channel in
|
||||
ChannelRow(
|
||||
channel: channel,
|
||||
programs: programs[channel.id] ?? [],
|
||||
|
||||
@@ -30,23 +30,43 @@ struct MiniPlayer: View {
|
||||
MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing
|
||||
}
|
||||
|
||||
/// Auf dem iPad darf sie größer sein — dort ist genug Fläche, und bei
|
||||
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
|
||||
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
|
||||
/// Änderung während des Ziehens am Griff, noch nicht übernommen.
|
||||
@State private var widthChange: CGFloat = 0
|
||||
/// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt.
|
||||
///
|
||||
/// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und
|
||||
/// eine einmal zu weit gezogene Kachel bliebe für immer zu groß.
|
||||
@State private var lastWidth: CGFloat = 0
|
||||
|
||||
/// Auf dem iPad größer voreingestellt — dort ist genug Fläche, und bei
|
||||
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
|
||||
private var tileWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
|
||||
private var tileSize: CGSize {
|
||||
CGSize(width: tileWidth, height: (tileWidth / FlixyAspectRatio.landscape).rounded())
|
||||
private var defaultWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
|
||||
|
||||
private func tileSize(in bounds: CGSize) -> CGSize {
|
||||
let chosen = storedWidth > 0 ? CGFloat(storedWidth) : defaultWidth
|
||||
// Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter
|
||||
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
||||
let upper = max(160, min(bounds.width * 0.75, 560))
|
||||
let width = min(max(chosen + widthChange, 140), upper)
|
||||
return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let tileSize = tileSize(in: geometry.size)
|
||||
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
|
||||
let resting = placement.center(for: corner)
|
||||
let position = placement.clamped(
|
||||
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
|
||||
)
|
||||
|
||||
tile
|
||||
tile(corner: corner)
|
||||
.frame(width: tileSize.width, height: tileSize.height)
|
||||
.onChange(of: tileSize.width, initial: true) { _, width in
|
||||
lastWidth = width
|
||||
}
|
||||
.position(position)
|
||||
.gesture(dragGesture(in: placement, from: resting))
|
||||
// Nur während des Ziehens ohne Animation, sonst würde die
|
||||
@@ -56,7 +76,7 @@ struct MiniPlayer: View {
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private var tile: some View {
|
||||
private func tile(corner: MiniPlayerPlacement.Corner) -> some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
@@ -68,6 +88,7 @@ struct MiniPlayer: View {
|
||||
|
||||
if showsControls {
|
||||
controls.transition(.opacity)
|
||||
resizeHandle(corner: corner).transition(.opacity)
|
||||
}
|
||||
|
||||
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
|
||||
@@ -135,6 +156,46 @@ struct MiniPlayer: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Griff zum Größerziehen.
|
||||
///
|
||||
/// Er sitzt in der Ecke, die zur Bildschirmmitte zeigt — dort ist er
|
||||
/// erreichbar, egal in welcher Ecke die Kachel klebt. Am äußeren Rand
|
||||
/// läge er halb außerhalb des Daumens.
|
||||
private func resizeHandle(corner: MiniPlayerPlacement.Corner) -> some View {
|
||||
VStack {
|
||||
if corner.isTop { Spacer() }
|
||||
HStack {
|
||||
if corner.isLeading { Spacer() }
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(FlixySpacing.snug)
|
||||
.contentShape(.rect)
|
||||
.gesture(resizeGesture(corner: corner))
|
||||
.accessibilityLabel(Text("miniPlayer.resize"))
|
||||
if !corner.isLeading { Spacer() }
|
||||
}
|
||||
if !corner.isTop { Spacer() }
|
||||
}
|
||||
// Gespiegelt, damit das Symbol in die Richtung zeigt, in die es
|
||||
// tatsächlich größer wird.
|
||||
.rotationEffect(.degrees(corner.isTop == corner.isLeading ? 0 : 90))
|
||||
}
|
||||
|
||||
private func resizeGesture(corner: MiniPlayerPlacement.Corner) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
hideTask?.cancel()
|
||||
// Klebt die Kachel rechts, wächst sie beim Ziehen nach links.
|
||||
widthChange = corner.isLeading ? value.translation.width : -value.translation.width
|
||||
}
|
||||
.onEnded { _ in
|
||||
storedWidth = Double(lastWidth)
|
||||
widthChange = 0
|
||||
reveal()
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: Double? {
|
||||
guard let duration = model.duration, duration.seconds > 0 else { return nil }
|
||||
return model.currentTime.seconds / duration.seconds
|
||||
|
||||
@@ -12,8 +12,8 @@ public struct MiniPlayerPlacement: Sendable, Equatable {
|
||||
public enum Corner: String, Sendable, CaseIterable, Codable {
|
||||
case topLeading, topTrailing, bottomLeading, bottomTrailing
|
||||
|
||||
var isTop: Bool { self == .topLeading || self == .topTrailing }
|
||||
var isLeading: Bool { self == .topLeading || self == .bottomLeading }
|
||||
public var isTop: Bool { self == .topLeading || self == .topTrailing }
|
||||
public var isLeading: Bool { self == .topLeading || self == .bottomLeading }
|
||||
}
|
||||
|
||||
/// Der sichtbare Bereich, in dem die Kachel sich bewegen darf.
|
||||
|
||||
@@ -247,11 +247,24 @@ extension PlexServer {
|
||||
/// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender
|
||||
/// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden
|
||||
/// braucht niemand beim Umschalten.
|
||||
///
|
||||
/// Zurück kommt, was den Zeitraum **berührt** — nicht, was vollständig
|
||||
/// hineinpasst. Das ist der Unterschied, an dem das Programm vorher falsch
|
||||
/// war: gefiltert wurde auf `endsAt <` Fensterende und `beginsAt >`
|
||||
/// Fensteranfang, und damit fiel ausgerechnet die laufende Sendung heraus.
|
||||
/// Ein Zweistünder, der um halb vier begann, war um Viertel nach fünf
|
||||
/// unsichtbar, und angezeigt wurde stattdessen die nächste, die durch den
|
||||
/// Filter passte — sichtbar falsch, und anders als Plex' eigene Anzeige.
|
||||
public func guide(
|
||||
providerIdentifier: String,
|
||||
from start: Date,
|
||||
to end: Date
|
||||
) async throws(PlexError) -> [PlexGuideEntry] {
|
||||
// Nur der **Anfang** wird eingegrenzt, und mit Vorlauf. Eine Grenze
|
||||
// auf das Ende schließt jede Sendung aus, die über das Fenster
|
||||
// hinausläuft — also gerade die längsten.
|
||||
let lead = start.addingTimeInterval(-Self.longestProgramme)
|
||||
|
||||
let container = try await client.send(
|
||||
PlexRequest(
|
||||
path: "/\(providerIdentifier)/grid",
|
||||
@@ -259,8 +272,8 @@ extension PlexServer {
|
||||
// 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))),
|
||||
URLQueryItem(name: "beginsAt>", value: String(Int(lead.timeIntervalSince1970))),
|
||||
URLQueryItem(name: "beginsAt<", value: String(Int(end.timeIntervalSince1970))),
|
||||
],
|
||||
timeout: .seconds(30)
|
||||
),
|
||||
@@ -277,6 +290,12 @@ extension PlexServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie weit vor dem Fenster gesucht wird.
|
||||
///
|
||||
/// Sechs Stunden decken alles ab, was im Fernsehen am Stück läuft — lange
|
||||
/// Spielfilme, Sportübertragungen, Wahlabende.
|
||||
static var longestProgramme: TimeInterval { 6 * 3_600 }
|
||||
|
||||
/// Senderliste und Programm in einem Zug.
|
||||
///
|
||||
/// Der Weg über die Senderliste des Anbieters ist der erste Versuch; wo
|
||||
@@ -309,7 +328,14 @@ extension PlexServer {
|
||||
to: now.addingTimeInterval(TimeInterval(hours) * 3_600)
|
||||
)) ?? []
|
||||
|
||||
let programs = Dictionary(grouping: entries.compactMap { entry in
|
||||
// Der Vorlauf der Abfrage holt auch Abgelaufenes mit — das gehört
|
||||
// nicht ins Programm. **Nur** die Sendungen werden so gefiltert, nicht
|
||||
// die Sender: ein Sender ohne frische Programmdaten soll trotzdem in
|
||||
// der Liste stehen, sonst verschwindet er, statt „kein Programm" zu
|
||||
// sagen.
|
||||
let fresh = entries.filter { $0.program.endsAt > now }
|
||||
|
||||
let programs = Dictionary(grouping: fresh.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 }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"MediaContainer": {
|
||||
"size": 2,
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey": "long",
|
||||
"title": "Der lange Film",
|
||||
"Media": [
|
||||
{
|
||||
"channelIdentifier": "ch-1",
|
||||
"channelTitle": "SRF 1 HD",
|
||||
"channelVcn": "002",
|
||||
"beginsAt": 1789205400,
|
||||
"endsAt": 1789212600,
|
||||
"Part": [
|
||||
{
|
||||
"key": "/livetv/channels/2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ratingKey": "after",
|
||||
"title": "Was danach kommt",
|
||||
"Media": [
|
||||
{
|
||||
"channelIdentifier": "ch-1",
|
||||
"channelTitle": "SRF 1 HD",
|
||||
"channelVcn": "002",
|
||||
"beginsAt": 1789212600,
|
||||
"endsAt": 1789216200,
|
||||
"Part": [
|
||||
{
|
||||
"key": "/livetv/channels/2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@
|
||||
"channelTitle": "006 ZDFGERH (ZDF HD)",
|
||||
"channelVcn": "006",
|
||||
"onAir": true,
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757685600
|
||||
"beginsAt": 4102444800,
|
||||
"endsAt": 4102445700
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -33,8 +33,8 @@
|
||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
|
||||
"channelTitle": "006 ZDFGERH (ZDF HD)",
|
||||
"channelVcn": "006",
|
||||
"beginsAt": 1757685600,
|
||||
"endsAt": 1757687400
|
||||
"beginsAt": 4102445700,
|
||||
"endsAt": 4102446000
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -48,8 +48,8 @@
|
||||
"channelID": 12,
|
||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
|
||||
"channelVcn": "002",
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757685600
|
||||
"beginsAt": 4102444800,
|
||||
"endsAt": 4102448400
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,9 +9,32 @@ import Testing
|
||||
/// 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.
|
||||
/// Hält fest, womit tatsächlich gefragt wurde.
|
||||
///
|
||||
/// Ein Actor, weil der Transport aus einem anderen Zusammenhang schreibt und
|
||||
/// Swift 6 das sonst zu Recht verweigert.
|
||||
private actor QueryRecorder {
|
||||
private(set) var items: [URLQueryItem] = []
|
||||
func note(_ new: [URLQueryItem]) { items = new }
|
||||
}
|
||||
|
||||
@Suite("Senderliste aus dem Programmraster")
|
||||
struct LiveTVGuideTests {
|
||||
|
||||
private func server(transport: FakeTransport) -> 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: transport
|
||||
)!
|
||||
}
|
||||
|
||||
private func server(routes: [String: String]) -> PlexServer {
|
||||
PlexServer(
|
||||
resource: PlexResource(
|
||||
@@ -26,6 +49,9 @@ struct LiveTVGuideTests {
|
||||
)!
|
||||
}
|
||||
|
||||
// Die Zeiten in `livetv-grid.json` liegen bewusst weit in der Zukunft:
|
||||
// das Programm wird auf Frische gefiltert, und ein Abzug mit den echten
|
||||
// Zeitstempeln von damals wäre ab dem Tag nach der Aufnahme leer gewesen.
|
||||
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)
|
||||
@@ -78,6 +104,39 @@ struct LiveTVGuideTests {
|
||||
#expect(guide.programs.count == 2)
|
||||
}
|
||||
|
||||
@Test("Die laufende Sendung fällt nicht aus dem Fenster")
|
||||
func keepsTheRunningProgramme() async throws {
|
||||
// Der Fehler, der das Programm sichtbar falsch machte: gefiltert wurde
|
||||
// auf „endet vor Fensterende" und „beginnt nach Fensteranfang". Ein
|
||||
// Zweistünder ab 15:30 war damit um 17:18 unsichtbar, und angezeigt
|
||||
// wurde die nächste Sendung, die durch den Filter passte.
|
||||
let body = try fixture("livetv-grid-overlap")
|
||||
let recorder = QueryRecorder()
|
||||
let server = server(transport: FakeTransport { request in
|
||||
await recorder.note(
|
||||
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
)
|
||||
return (Data(body.utf8), FakeTransport.response(for: request, status: 200))
|
||||
})
|
||||
|
||||
// 17:18 — mitten im langen Film.
|
||||
let now = Date(timeIntervalSince1970: 1_789_211_880)
|
||||
let entries = try await server.guide(
|
||||
providerIdentifier: "tv.plex.providers.epg.cloud:2",
|
||||
from: now,
|
||||
to: now.addingTimeInterval(6 * 3_600)
|
||||
)
|
||||
|
||||
#expect(entries.map(\.program.title).contains("Der lange Film"))
|
||||
#expect(entries.first?.program.isRunning(at: now) == true)
|
||||
|
||||
// Und der Grund dafür steht in der Abfrage: nur der Anfang wird
|
||||
// eingegrenzt, mit Vorlauf — keine Grenze auf das Ende.
|
||||
let seen = await recorder.items
|
||||
#expect(seen.contains { $0.name == "beginsAt<" })
|
||||
#expect(!seen.contains { $0.name == "endsAt<" })
|
||||
}
|
||||
|
||||
@Test("Ein Sender ohne Namen taucht nicht als Strich auf")
|
||||
func skipsNamelessChannels() async throws {
|
||||
let json = """
|
||||
|
||||
Reference in New Issue
Block a user