Live-TV: das Programm kam nie an, und die Einschalt-Adresse wurde weggeworfen
Drei Fehler, alle im eigenen Code. Die Sendezeiten stehen beim Programmanbieter im Medien-Block, nicht am Eintrag darüber. Ich habe sie oben gesucht, nicht gefunden und jede Sendung verworfen — übrig blieb „Kein Programm verfügbar" auf jedem Sender, und ohne Sendungen fiel auch die Senderliste auf die Tunerliste zurück. Die Senderspalte im Raster fehlte ganz. Die Zeitachse ist mehrere tausend Punkte breit, und der HStack hat den Nachbarn dafür auf null gedrückt. Jetzt hat die Spalte Vorrang, und die Achse liegt als Auflage über einer durchsichtigen Fläche — eine Auflage zählt bei der Größenrechnung nicht mit. Der Abstand der Achsenbeschriftung gehört in die Spaltenbreite hinein statt daneben, sonst steht die ganze Achse acht Punkte neben dem Programm darunter. Und das Einschalten: die Adresse wurde berechnet und verworfen, dann bekam der Player eine Senderkennung, zu der er einen Film in der Mediathek suchte. Den gibt es nicht. Jetzt reicht die Anfrage die fertige Adresse durch, der Player spielt sie ohne Umweg, und meldet weder Fortschritt noch sucht er Metadaten. Scheitert es doch, steht ab jetzt eine Meldung da statt gar nichts. Geprüft wurde das Raster diesmal mit den Augen: ein Gerüst im Simulator zeichnet es mit erfundenen Sendern. Dafür bekommt es die Bildadresse als Abschluss übergeben, statt sie aus der Sitzung zu lesen. Dazu: Downloads zeigen jetzt an, wenn sie auf WLAN warten. Die Hintergrund-Sitzung wartet in dem Fall von sich aus — kein Fehler, nur Stillstand, und das sah bisher aus wie ein hängender Download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2362865ba9
commit
bbe47b83be
+2613
-2517
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,13 @@ struct PlaybackRequest: Identifiable, Hashable {
|
||||
let mediaIndex: Int
|
||||
/// Ab wo — aus dem gemeldeten Fortschritt.
|
||||
let startTime: Duration
|
||||
/// Eine bereits feststehende Adresse.
|
||||
///
|
||||
/// Für Live-TV. Ein Sender liegt nicht in der Mediathek, und der übliche
|
||||
/// Weg — Kennung nachschlagen, Datei suchen — findet für ihn nichts.
|
||||
/// Daran ist das Einschalten gescheitert: die Adresse war längst
|
||||
/// berechnet und wurde weggeworfen.
|
||||
var streamURL: URL?
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
|
||||
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
|
||||
/// Was offline ist und was gerade geladen wird.
|
||||
struct DownloadsView: View {
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var itemPendingRemoval: DownloadItem?
|
||||
@@ -43,6 +45,15 @@ struct DownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Was die Übertragung gerade aufhält.
|
||||
private var hold: TransferHold {
|
||||
TransferHold.current(
|
||||
isWiFiOnly: downloads.isWiFiOnly,
|
||||
isReachable: network.isReachable,
|
||||
isExpensive: network.isExpensive
|
||||
)
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
StorageSection()
|
||||
@@ -51,8 +62,18 @@ struct DownloadsView: View {
|
||||
// während etwas lädt, will meistens genau das sehen.
|
||||
if !active.isEmpty {
|
||||
Section("downloads.active") {
|
||||
// Die Hintergrund-Sitzung wartet in diesen Fällen von
|
||||
// sich aus: kein Fehler, nur Stillstand. Ohne diesen
|
||||
// Hinweis sieht ein wartender Download genauso aus wie
|
||||
// ein hängender.
|
||||
if hold.isHolding { HoldNotice(hold: hold) }
|
||||
|
||||
ForEach(active) { item in
|
||||
DownloadRow(item: item, onRemove: { itemPendingRemoval = item })
|
||||
DownloadRow(
|
||||
item: item,
|
||||
hold: hold,
|
||||
onRemove: { itemPendingRemoval = item }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +81,8 @@ struct DownloadsView: View {
|
||||
ForEach(completedGroups, id: \.title) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.items) { item in
|
||||
DownloadRow(item: item, onRemove: { itemPendingRemoval = item })
|
||||
// Fertige Downloads wartet nichts mehr auf.
|
||||
DownloadRow(item: item, hold: .none, onRemove: { itemPendingRemoval = item })
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { play(item) }
|
||||
}
|
||||
@@ -132,6 +154,7 @@ private struct StorageSection: View {
|
||||
/// Eine Zeile in der Downloads-Ansicht.
|
||||
private struct DownloadRow: View {
|
||||
let item: DownloadItem
|
||||
let hold: TransferHold
|
||||
let onRemove: () -> Void
|
||||
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@@ -161,6 +184,17 @@ private struct DownloadRow: View {
|
||||
progress
|
||||
}
|
||||
|
||||
// Nur bei dem, was tatsächlich warten würde. Ein vom Nutzer
|
||||
// angehaltener Download wartet nicht auf WLAN, sondern auf ihn.
|
||||
if hold.isHolding, item.state == .downloading || item.state == .queued {
|
||||
Label(
|
||||
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
||||
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
||||
)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
|
||||
if let message = item.failureMessage {
|
||||
Text(message)
|
||||
.font(.flixyCaption)
|
||||
@@ -244,3 +278,34 @@ private struct DownloadRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Der Hinweis über der Liste, wenn nichts läuft und nichts kaputt ist.
|
||||
private struct HoldNotice: View {
|
||||
let hold: TransferHold
|
||||
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
||||
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
||||
.font(.title3)
|
||||
.foregroundStyle(accent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
|
||||
Text(hold == .offline
|
||||
? "downloads.hold.offline.description"
|
||||
: "downloads.hold.wifi.description")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlixySpacing.tight)
|
||||
.listRowBackground(Color.flixySurface)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ struct LiveTVView: View {
|
||||
@State private var trace: [ChannelProbe] = []
|
||||
@State private var programs: [String: [PlexProgram]] = [:]
|
||||
@State private var isTuning: String?
|
||||
/// Warum das Einschalten nicht geklappt hat.
|
||||
@State private var tuneFailure: String?
|
||||
/// Raster oder Senderliste.
|
||||
///
|
||||
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
|
||||
@@ -71,6 +73,9 @@ struct LiveTVView: View {
|
||||
channels: channels,
|
||||
programs: programs,
|
||||
tuning: isTuning,
|
||||
artwork: { channel in
|
||||
session.server?.artworkURL(path: channel.thumb, width: 120, height: 120)
|
||||
},
|
||||
onTune: { channel, program in Task { await tune(channel, program: program) } }
|
||||
)
|
||||
} else {
|
||||
@@ -95,6 +100,14 @@ struct LiveTVView: View {
|
||||
}
|
||||
.task { await load() }
|
||||
.refreshable { await load() }
|
||||
.alert(
|
||||
Text("liveTV.tuneFailed"),
|
||||
isPresented: Binding(get: { tuneFailure != nil }, set: { if !$0 { tuneFailure = nil } })
|
||||
) {
|
||||
Button("action.close", role: .cancel) { tuneFailure = nil }
|
||||
} message: {
|
||||
if let tuneFailure { Text(tuneFailure) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Wo überall gesucht wurde, und was dort stand.
|
||||
@@ -189,21 +202,34 @@ struct LiveTVView: View {
|
||||
// Programmanbieter führt der direkte Weg zum Strom.
|
||||
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
||||
let policy = settings.policy(for: network)
|
||||
guard (try? await server.tune(
|
||||
channel: channel,
|
||||
program: running,
|
||||
maximumBitrate: policy.maximumBitrate
|
||||
)) != nil
|
||||
else { return }
|
||||
|
||||
// Live-TV läuft über denselben Player wie alles andere. Der Schlüssel
|
||||
// ist der Sender, nicht ein Titel — der Player holt sich den Strom
|
||||
// über die Einschalt-Adresse.
|
||||
let stream: URL?
|
||||
do {
|
||||
stream = try await server.tune(
|
||||
channel: channel,
|
||||
program: running,
|
||||
maximumBitrate: policy.maximumBitrate
|
||||
)
|
||||
} catch {
|
||||
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
|
||||
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
|
||||
tuneFailure = PlexErrorText.describe(error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let stream else {
|
||||
tuneFailure = String(localized: "liveTV.noStream")
|
||||
return
|
||||
}
|
||||
|
||||
// Live-TV läuft über denselben Player wie alles andere, bekommt aber
|
||||
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
|
||||
playback.request = PlaybackRequest(
|
||||
ratingKey: channel.id,
|
||||
title: channel.title,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero
|
||||
startTime: .zero,
|
||||
streamURL: stream
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,15 @@ struct ProgramGuide: View {
|
||||
let channels: [PlexChannel]
|
||||
let programs: [String: [PlexProgram]]
|
||||
let tuning: String?
|
||||
/// Die Adresse des Senderlogos.
|
||||
///
|
||||
/// Als Abschluss übergeben statt aus der Umgebung gelesen: so lässt sich
|
||||
/// das Raster außerhalb der App mit erfundenen Sendern zeichnen und
|
||||
/// ansehen. Die Senderspalte hat einmal gefehlt, und das war ohne einen
|
||||
/// Blick darauf nicht zu finden.
|
||||
let artwork: (PlexChannel) -> URL?
|
||||
let onTune: (PlexChannel, PlexProgram?) -> Void
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var scrollX: CGFloat = 0
|
||||
@@ -41,7 +47,12 @@ struct ProgramGuide: View {
|
||||
|
||||
ScrollView(.vertical) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
channelColumn
|
||||
// Ohne Vorrang verliert die Spalte den Platzstreit gegen
|
||||
// das Raster: dessen Breite ist die volle Zeitachse —
|
||||
// mehrere tausend Punkte —, und der HStack drückt den
|
||||
// Nachbarn dafür auf null. Genau so war die Senderspalte
|
||||
// verschwunden.
|
||||
channelColumn.layoutPriority(1)
|
||||
lanes
|
||||
}
|
||||
}
|
||||
@@ -51,6 +62,7 @@ struct ProgramGuide: View {
|
||||
.sheet(item: $selection) { selection in
|
||||
ProgramDetailSheet(
|
||||
channel: selection.channel,
|
||||
artworkURL: artwork(selection.channel),
|
||||
program: selection.program,
|
||||
isRunning: selection.program.isRunning(at: now),
|
||||
onTune: { onTune(selection.channel, selection.program) }
|
||||
@@ -85,21 +97,33 @@ struct ProgramGuide: View {
|
||||
Text("liveTV.channelCount \(channels.count)")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.frame(width: channelWidth, alignment: .leading)
|
||||
// Der Abstand gehört in die Spaltenbreite hinein, nicht
|
||||
// dazu: außen addiert er sich, und die ganze Zeitachse steht
|
||||
// acht Punkte neben dem Programm darunter.
|
||||
.padding(.leading, FlixySpacing.snug)
|
||||
.frame(width: channelWidth, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
|
||||
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)
|
||||
// Der Streifen liegt als Auflage über einer durchsichtigen
|
||||
// Fläche, statt selbst im Stapel zu stehen: eine Auflage zählt
|
||||
// bei der Größenrechnung nicht mit. Stünde er selbst darin,
|
||||
// verlangte er die Breite der ganzen Zeitachse und schöbe die
|
||||
// Beschriftung links aus dem Bild.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .leading) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
.offset(x: -scrollX)
|
||||
}
|
||||
}
|
||||
.frame(width: layout.totalWidth, alignment: .leading)
|
||||
.offset(x: -scrollX)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.clipped()
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: axisHeight)
|
||||
.background(Color.flixyCanvas)
|
||||
@@ -115,11 +139,7 @@ struct ProgramGuide: View {
|
||||
ForEach(channels) { channel in
|
||||
Button { onTune(channel, running(on: channel)) } label: {
|
||||
HStack(spacing: FlixySpacing.snug) {
|
||||
FlixyArtwork(
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
||||
aspectRatio: 1,
|
||||
fallbackSymbol: "tv"
|
||||
)
|
||||
FlixyArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
|
||||
.frame(width: 30, height: 30)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
@@ -263,11 +283,11 @@ private struct GuideSelection: Identifiable {
|
||||
/// Was zu einer Sendung im Raster gehört, in voller Länge.
|
||||
private struct ProgramDetailSheet: View {
|
||||
let channel: PlexChannel
|
||||
let artworkURL: URL?
|
||||
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
|
||||
|
||||
@@ -276,11 +296,7 @@ private struct ProgramDetailSheet: View {
|
||||
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"
|
||||
)
|
||||
FlixyArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(.rect(cornerRadius: 6))
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ final class PlayerModel {
|
||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
||||
private(set) var isOffline = false
|
||||
/// Ob ein laufender Sender abgespielt wird.
|
||||
private(set) var isLive = false
|
||||
/// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen.
|
||||
private(set) var quality: PlayerQuality = .automatic
|
||||
/// Ob gerade auf eine andere Stufe umgestellt wird.
|
||||
@@ -110,6 +112,11 @@ final class PlayerModel {
|
||||
return
|
||||
}
|
||||
|
||||
if let stream = request.streamURL {
|
||||
await startLiveStream(url: stream)
|
||||
return
|
||||
}
|
||||
|
||||
guard let server else {
|
||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||
return
|
||||
@@ -202,6 +209,23 @@ final class PlayerModel {
|
||||
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
|
||||
/// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim
|
||||
/// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen.
|
||||
/// Live-TV: die Adresse steht schon fest.
|
||||
///
|
||||
/// Ohne Metadatenabruf, weil es nichts abzurufen gibt — ein laufender
|
||||
/// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es
|
||||
/// gibt keine Stelle, an der man ihn später fortsetzen könnte.
|
||||
private func startLiveStream(url: URL) async {
|
||||
diagnostics.note("Live-Strom öffnen")
|
||||
isLive = true
|
||||
title = request.title
|
||||
|
||||
// Live kommt immer umgerechnet: der Tuner liefert einen
|
||||
// Transportstrom, den kein iPhone direkt abspielt.
|
||||
let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: [])
|
||||
self.decision = decision
|
||||
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
||||
}
|
||||
|
||||
private func startLocal(url: URL) async {
|
||||
let offline = downloads.offlineMedia(forRatingKey: request.ratingKey)
|
||||
|
||||
@@ -258,7 +282,8 @@ final class PlayerModel {
|
||||
engine.play()
|
||||
phase = .playing
|
||||
diagnostics.note("Motor gestartet")
|
||||
if !isOffline { startReporting() }
|
||||
// Weder offline noch live gibt es einen Fortschritt zu melden.
|
||||
if !isOffline, !isLive { startReporting() }
|
||||
} catch {
|
||||
diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)")
|
||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// Warum gerade nichts übertragen wird, obwohl etwas in der Schlange steht.
|
||||
///
|
||||
/// Die Hintergrund-Sitzung wartet in diesen Fällen von sich aus — sie meldet
|
||||
/// keinen Fehler, sie tut nur nichts. Für den Betrachter sieht das aus wie
|
||||
/// ein hängender Download: der Balken steht, und nichts sagt, worauf.
|
||||
public enum TransferHold: Sendable, Equatable {
|
||||
case none
|
||||
/// „Nur über WLAN" ist an, und das Gerät hängt am Mobilfunk.
|
||||
case waitingForWiFi
|
||||
/// Gar keine Verbindung.
|
||||
case offline
|
||||
|
||||
/// Was die Übertragung gerade aufhält.
|
||||
///
|
||||
/// `isExpensive` statt einer Abfrage auf den Schnittstellentyp: der Wert
|
||||
/// deckt auch den persönlichen Hotspot ab — technisch WLAN, in der
|
||||
/// Abrechnung Mobilfunk. Wer dort lädt, zahlt es trotzdem.
|
||||
public static func current(
|
||||
isWiFiOnly: Bool,
|
||||
isReachable: Bool,
|
||||
isExpensive: Bool
|
||||
) -> TransferHold {
|
||||
// Ohne Verbindung hilft auch ein ausgeschalteter WLAN-Zwang nicht —
|
||||
// deshalb zuerst geprüft.
|
||||
if !isReachable { return .offline }
|
||||
if isWiFiOnly, isExpensive { return .waitingForWiFi }
|
||||
return .none
|
||||
}
|
||||
|
||||
public var isHolding: Bool { self != .none }
|
||||
}
|
||||
@@ -189,10 +189,18 @@ struct PlexGridMedia: Decodable {
|
||||
let channelVcn: String?
|
||||
/// Die laufende Nummer des Senders am Server.
|
||||
let channelID: String?
|
||||
/// Anfang und Ende der Sendung.
|
||||
///
|
||||
/// Sie stehen hier und **nicht** am Eintrag darüber. Genau daran ist das
|
||||
/// Raster gescheitert: oben gesucht, nicht gefunden, jede Sendung
|
||||
/// verworfen — und übrig blieb „Kein Programm verfügbar" auf jedem Sender.
|
||||
let beginsAt: Int?
|
||||
let endsAt: Int?
|
||||
let parts: [PlexGridPart]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channelIdentifier, channelTitle, channelCallSign, channelThumb, channelVcn, channelID
|
||||
case beginsAt, endsAt
|
||||
case part = "Part"
|
||||
}
|
||||
|
||||
@@ -217,6 +225,8 @@ struct PlexGridMedia: Decodable {
|
||||
} else {
|
||||
self.channelID = nil
|
||||
}
|
||||
self.beginsAt = try? values.decodeIfPresent(Int.self, forKey: .beginsAt)
|
||||
self.endsAt = try? values.decodeIfPresent(Int.self, forKey: .endsAt)
|
||||
self.parts = try values.decodeIfPresent([PlexGridPart].self, forKey: .part) ?? []
|
||||
}
|
||||
|
||||
@@ -440,6 +450,11 @@ struct PlexGridEntry: Decodable {
|
||||
var channel: PlexChannel? { media?.compactMap(\.channel).first }
|
||||
|
||||
func program(channelID: String?) -> PlexProgram? {
|
||||
// Die Zeiten stehen beim Programmanbieter im Medien-Block, nicht am
|
||||
// Eintrag. Beide Stellen lesen, damit es auf beiden Serverfassungen
|
||||
// trägt.
|
||||
let beginsAt = self.beginsAt ?? media?.first?.beginsAt
|
||||
let endsAt = self.endsAt ?? media?.first?.endsAt
|
||||
guard let beginsAt, let endsAt, let title else { return nil }
|
||||
return PlexProgram(
|
||||
id: ratingKey ?? "\(channelID ?? "")-\(beginsAt)",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Testing
|
||||
@testable import FlixyDownloads
|
||||
|
||||
/// Warum gerade nichts übertragen wird.
|
||||
@Suite("Übertragungssperre")
|
||||
struct TransferHoldTests {
|
||||
|
||||
@Test("Mobilfunk hält auf, solange nur WLAN erlaubt ist")
|
||||
func cellularHolds() {
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: true, isReachable: true, isExpensive: true)
|
||||
== .waitingForWiFi
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Im WLAN läuft es")
|
||||
func wifiRuns() {
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: true, isReachable: true, isExpensive: false)
|
||||
== .none
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Ohne den WLAN-Zwang hält Mobilfunk nicht auf")
|
||||
func cellularAllowed() {
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: false, isReachable: true, isExpensive: true)
|
||||
== .none
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Ohne Verbindung hilft auch ein ausgeschalteter Zwang nichts")
|
||||
func offlineWins() {
|
||||
// Sonst hieße es „wartet auf WLAN", während schlicht gar nichts geht —
|
||||
// und der Betrachter sucht den Fehler an der falschen Stelle.
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: false, isReachable: false, isExpensive: false)
|
||||
== .offline
|
||||
)
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: true, isReachable: false, isExpensive: true)
|
||||
== .offline
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@
|
||||
"title": "Endlich Witwer",
|
||||
"summary": "Komödie",
|
||||
"type": "movie",
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757685600,
|
||||
"Media": [
|
||||
{
|
||||
"channelCallSign": "ZDFGERH",
|
||||
@@ -18,7 +16,9 @@
|
||||
"channelThumb": "/library/metadata/5/thumb",
|
||||
"channelTitle": "006 ZDFGERH (ZDF HD)",
|
||||
"channelVcn": "006",
|
||||
"onAir": true
|
||||
"onAir": true,
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757685600
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -27,14 +27,14 @@
|
||||
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fepisode%2Fabc",
|
||||
"title": "heute journal",
|
||||
"type": "episode",
|
||||
"beginsAt": 1757685600,
|
||||
"endsAt": 1757687400,
|
||||
"Media": [
|
||||
{
|
||||
"channelID": 5,
|
||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849",
|
||||
"channelTitle": "006 ZDFGERH (ZDF HD)",
|
||||
"channelVcn": "006"
|
||||
"channelVcn": "006",
|
||||
"beginsAt": 1757685600,
|
||||
"endsAt": 1757687400
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -42,17 +42,17 @@
|
||||
"ratingKey": "5f3",
|
||||
"key": "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fshow%2Fxyz",
|
||||
"title": "Sport aktuell",
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757685600,
|
||||
"Media": [
|
||||
{
|
||||
"channelCallSign": "SRF2",
|
||||
"channelID": 12,
|
||||
"channelIdentifier": "6228f7f2364e02fa5ec8e51c-12",
|
||||
"channelVcn": "002"
|
||||
"channelVcn": "002",
|
||||
"beginsAt": 1757682000,
|
||||
"endsAt": 1757685600
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user