Live-TV: die Senderliste wird gesucht, nicht geraten — und Serien richtig beschriftet
Vier geratene Adressen, vier 404. Statt ein fünftes Mal zu raten, klopft die App jetzt in einem Durchgang und gleichzeitig ein Dutzend Adressen ab: was /media/providers an Endpunkten angibt, welche Unterverzeichnisse Programmanbieter und Empfänger in ihrer eigenen Antwort führen, die bekannten Formen anderer Serverfassungen, die Senderliste des Tuners selbst — und zuletzt das Programmraster. Dabei zwei Fehlerquellen mitbehoben, die jede einzeln wie „keine Sender" ausgesehen hätten: der Sendername steht je nach Tunertyp in title, name, channelTitle oder callSign, und eine Antwort mit dreißig namenlosen Einträgen sieht aus wie eine Senderliste, ist aber keine. Bleibt es leer, steht jetzt jede abgeklopfte Adresse mit ihrer Antwort in der Ansicht. Das beantwortet „liegt es am Server oder an der App" ohne eine weitere Runde Rückfragen. Dazu drei Sachen bei Serien: - Groß steht, worum es geht — bei einer Folge ihr Titel, bei einer Staffel der Name der Serie. „Staffel 10" als Überschrift sagte nicht, welche Serie gemeint ist. - Die Einordnung steht darunter: Serienname bei Folgen, Staffelnummer bei Staffeln. - Von der Folge führt ein Weg zur Staffel und zur Serie. Wer über „Weiterschauen" bei einer Folge landete, saß dort bisher fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
591cba73a7
commit
7e36f37506
@@ -442,6 +442,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"detail.toSeason": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zur Staffel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Go to season"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"detail.toSeries": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zur Serie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Go to series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"detail.unwatchedCount %lld": {
|
"detail.unwatchedCount %lld": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1760,8 +1792,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"liveTV.channelCount %@": {
|
||||||
|
"comment": "A label displaying the number of channels. The argument is the number of channels.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
"liveTV.channelCount %lld": {
|
"liveTV.channelCount %lld": {
|
||||||
"comment": "Platzhalter: Anzahl Sender",
|
"comment": "Platzhalter: Anzahl Sender",
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1778,6 +1815,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.gap": {
|
"liveTV.gap": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1794,6 +1832,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.mode": {
|
"liveTV.mode": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1810,6 +1849,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.mode.guide": {
|
"liveTV.mode.guide": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1826,6 +1866,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.mode.now": {
|
"liveTV.mode.now": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1941,6 +1982,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.now": {
|
"liveTV.now": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1957,6 +1999,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.runningNow": {
|
"liveTV.runningNow": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1990,6 +2033,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveTV.tune": {
|
"liveTV.tune": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -2579,6 +2623,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"season.short %lld": {
|
||||||
|
"comment": "Platzhalter: Staffelnummer",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Staffel %lld"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Season %lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"serverPicker.owned": {
|
"serverPicker.owned": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -60,15 +60,24 @@ extension PlexItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Die Beschriftung unter einer Kachel.
|
/// Die Beschriftung unter einer Kachel.
|
||||||
|
///
|
||||||
|
/// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu
|
||||||
|
/// einer Staffel ihre Nummer. Die Reihenfolge ist Absicht — wird der
|
||||||
|
/// Platz eng, bleibt der Serienname stehen und die Folgennummer fällt weg.
|
||||||
var cardSubtitle: String? {
|
var cardSubtitle: String? {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .episode:
|
case .episode:
|
||||||
|
var code: String?
|
||||||
if let season = parentIndex, let episode = index {
|
if let season = parentIndex, let episode = index {
|
||||||
return String(localized: "episode.short \(season) \(episode)")
|
code = String(localized: "episode.short \(season) \(episode)")
|
||||||
}
|
}
|
||||||
return grandparentTitle
|
guard let series = grandparentTitle else { return code }
|
||||||
case .movie:
|
return code.map { "\(series) · \($0)" } ?? series
|
||||||
return year.map(String.init)
|
case .season:
|
||||||
|
// Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb
|
||||||
|
// steht die Nummer hier und der Serienname als Titel.
|
||||||
|
if let index { return String(localized: "season.short \(index)") }
|
||||||
|
return parentTitle == nil ? nil : title
|
||||||
default:
|
default:
|
||||||
return year.map(String.init)
|
return year.map(String.init)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
import PlexKit
|
||||||
|
|
||||||
|
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
|
||||||
|
///
|
||||||
|
/// Bisher gab es ihn nicht. Wer über „Weiterschauen" oder „Zuletzt
|
||||||
|
/// hinzugefügt" bei einer einzelnen Folge landete, saß dort fest — die
|
||||||
|
/// übrigen Folgen derselben Staffel waren nur über den Umweg Bibliothek →
|
||||||
|
/// Serie → Staffel erreichbar. Genau dieser Umweg ist der Grund, dass die
|
||||||
|
/// Zeile existiert.
|
||||||
|
struct ParentLinks: View {
|
||||||
|
let item: PlexItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !destinations.isEmpty {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: FlixySpacing.snug) {
|
||||||
|
ForEach(destinations, id: \.route.ratingKey) { destination in
|
||||||
|
NavigationLink(value: destination.route) {
|
||||||
|
Label {
|
||||||
|
Text(destination.route.title)
|
||||||
|
.lineLimit(1)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: destination.symbol)
|
||||||
|
}
|
||||||
|
.font(.flixyCaption)
|
||||||
|
.foregroundStyle(Color.flixyTextPrimary)
|
||||||
|
.padding(.horizontal, FlixySpacing.regular)
|
||||||
|
.padding(.vertical, FlixySpacing.snug)
|
||||||
|
.background(Color.flixyElevated, in: .capsule)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Destination {
|
||||||
|
let route: MediaRoute
|
||||||
|
let symbol: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Stationen über diesem Eintrag.
|
||||||
|
///
|
||||||
|
/// Bei einer Folge zwei — Staffel und Serie; bei einer Staffel eine.
|
||||||
|
/// Plex führt beide Kennungen am Eintrag mit, deshalb braucht es dafür
|
||||||
|
/// keine zusätzliche Abfrage.
|
||||||
|
private var destinations: [Destination] {
|
||||||
|
switch item.kind {
|
||||||
|
case .episode:
|
||||||
|
var stops: [Destination] = []
|
||||||
|
if let season = item.parentRatingKey {
|
||||||
|
let title = item.parentIndex.map { String(localized: "season.short \($0)") }
|
||||||
|
?? item.parentTitle
|
||||||
|
?? String(localized: "detail.toSeason")
|
||||||
|
stops.append(Destination(
|
||||||
|
route: MediaRoute(ratingKey: season, title: title),
|
||||||
|
symbol: "rectangle.stack"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if let show = item.grandparentRatingKey {
|
||||||
|
stops.append(Destination(
|
||||||
|
route: MediaRoute(
|
||||||
|
ratingKey: show,
|
||||||
|
title: item.grandparentTitle ?? String(localized: "detail.toSeries")
|
||||||
|
),
|
||||||
|
symbol: "tv"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return stops
|
||||||
|
|
||||||
|
case .season:
|
||||||
|
guard let show = item.parentRatingKey else { return [] }
|
||||||
|
return [Destination(
|
||||||
|
route: MediaRoute(
|
||||||
|
ratingKey: show,
|
||||||
|
title: item.parentTitle ?? String(localized: "detail.toSeries")
|
||||||
|
),
|
||||||
|
symbol: "tv"
|
||||||
|
)]
|
||||||
|
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ struct PlayableDetailView: View {
|
|||||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||||
header
|
header
|
||||||
|
|
||||||
|
ParentLinks(item: item)
|
||||||
|
|
||||||
if item.hasMultipleVersions {
|
if item.hasMultipleVersions {
|
||||||
VersionPicker(media: item.media, selection: $selectedVersion)
|
VersionPicker(media: item.media, selection: $selectedVersion)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||||
@@ -104,8 +106,13 @@ struct PlayableDetailView: View {
|
|||||||
|
|
||||||
private var metadataLine: String? {
|
private var metadataLine: String? {
|
||||||
var pieces: [String] = []
|
var pieces: [String] = []
|
||||||
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
if item.kind == .episode {
|
||||||
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
// Der Folgentitel steht jetzt groß darüber; ohne die Serie hier
|
||||||
|
// wüsste man auf dieser Seite nicht, wozu die Folge gehört.
|
||||||
|
if let series = item.grandparentTitle { pieces.append(series) }
|
||||||
|
if let season = item.parentIndex, let episode = item.index {
|
||||||
|
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let year = item.year { pieces.append(String(year)) }
|
if let year = item.year { pieces.append(String(year)) }
|
||||||
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ struct SeriesDetailView: View {
|
|||||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||||
header
|
header
|
||||||
|
|
||||||
|
ParentLinks(item: item)
|
||||||
|
|
||||||
if let summary = item.summary, !summary.isEmpty {
|
if let summary = item.summary, !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.flixyBody)
|
.font(.flixyBody)
|
||||||
@@ -38,7 +40,7 @@ struct SeriesDetailView: View {
|
|||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HeroBanner(
|
HeroBanner(
|
||||||
title: item.title,
|
title: item.rowTitle,
|
||||||
metadata: metadataLine,
|
metadata: metadataLine,
|
||||||
summary: nil,
|
summary: nil,
|
||||||
artworkURL: session.server?.artworkURL(
|
artworkURL: session.server?.artworkURL(
|
||||||
@@ -87,6 +89,11 @@ struct SeriesDetailView: View {
|
|||||||
|
|
||||||
private var metadataLine: String? {
|
private var metadataLine: String? {
|
||||||
var pieces: [String] = []
|
var pieces: [String] = []
|
||||||
|
// Bei einer Staffel steht oben der Serienname — welche Staffel es
|
||||||
|
// ist, muss deshalb hierher.
|
||||||
|
if item.kind == .season, let number = item.index {
|
||||||
|
pieces.append(String(localized: "season.short \(number)"))
|
||||||
|
}
|
||||||
if let year = item.year { pieces.append(String(year)) }
|
if let year = item.year { pieces.append(String(year)) }
|
||||||
if let leafCount = item.leafCount {
|
if let leafCount = item.leafCount {
|
||||||
pieces.append(String(localized: "detail.episodeCount \(leafCount)"))
|
pieces.append(String(localized: "detail.episodeCount \(leafCount)"))
|
||||||
|
|||||||
@@ -79,11 +79,19 @@ struct MediaRow: View {
|
|||||||
extension PlexItem {
|
extension PlexItem {
|
||||||
/// Der Titel auf einer Kachel in einer Reihe.
|
/// Der Titel auf einer Kachel in einer Reihe.
|
||||||
///
|
///
|
||||||
/// Bei Folgen der Serientitel: in „Weiterschauen" stehen Folgen
|
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
|
||||||
/// verschiedener Serien nebeneinander, und der Folgentitel allein sagt
|
/// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel —
|
||||||
/// nicht, worum es geht.
|
/// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts
|
||||||
|
/// darüber, welche Serie gemeint ist.
|
||||||
var rowTitle: String {
|
var rowTitle: String {
|
||||||
kind == .episode ? (grandparentTitle ?? title) : title
|
switch kind {
|
||||||
|
case .episode: title
|
||||||
|
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
|
||||||
|
// `grandparentTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
|
||||||
|
// gar keine Beschriftung.
|
||||||
|
case .season: parentTitle ?? grandparentTitle ?? title
|
||||||
|
default: title
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Welches Bild auf die Querformat-Kachel gehört.
|
/// Welches Bild auf die Querformat-Kachel gehört.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct LiveTVView: View {
|
|||||||
@State private var channels: [PlexChannel] = []
|
@State private var channels: [PlexChannel] = []
|
||||||
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
||||||
@State private var channelSource: String?
|
@State private var channelSource: String?
|
||||||
|
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
||||||
|
@State private var trace: [ChannelProbe] = []
|
||||||
@State private var programs: [String: [PlexProgram]] = [:]
|
@State private var programs: [String: [PlexProgram]] = [:]
|
||||||
@State private var isTuning: String?
|
@State private var isTuning: String?
|
||||||
/// Raster oder Senderliste.
|
/// Raster oder Senderliste.
|
||||||
@@ -55,19 +57,12 @@ struct LiveTVView: View {
|
|||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("liveTV.noChannels.title", systemImage: "tv")
|
Label("liveTV.noChannels.title", systemImage: "tv")
|
||||||
} description: {
|
} description: {
|
||||||
VStack(spacing: FlixySpacing.snug) {
|
Text("liveTV.noChannels.description")
|
||||||
Text("liveTV.noChannels.description")
|
|
||||||
if let channelSource {
|
|
||||||
// Ohne diese Angabe ist „keine Sender" eine
|
|
||||||
// Sackgasse: es sagt nicht, ob der Server nichts
|
|
||||||
// hergibt oder ob die App am falschen Ort sucht.
|
|
||||||
Text(channelSource)
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} actions: {
|
} actions: {
|
||||||
Button("action.retry") { Task { await load() } }
|
VStack(spacing: FlixySpacing.regular) {
|
||||||
|
Button("action.retry") { Task { await load() } }
|
||||||
|
searchTrace
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case .available:
|
case .available:
|
||||||
@@ -102,6 +97,50 @@ struct LiveTVView: View {
|
|||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wo überall gesucht wurde, und was dort stand.
|
||||||
|
///
|
||||||
|
/// Unbequem, aber nützlich: die Live-TV-Schnittstelle von Plex hat keine
|
||||||
|
/// Beschreibung, und „keine Sender" allein ist eine Sackgasse. Hier steht
|
||||||
|
/// jede abgeklopfte Adresse mit ihrer Antwort — das beantwortet die Frage
|
||||||
|
/// „liegt es am Server oder an der App" ohne eine weitere Runde.
|
||||||
|
@ViewBuilder
|
||||||
|
private var searchTrace: some View {
|
||||||
|
if !trace.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
||||||
|
if let channelSource {
|
||||||
|
Text(channelSource)
|
||||||
|
.font(.flixyCaption)
|
||||||
|
.foregroundStyle(Color.flixyTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(trace) { probe in
|
||||||
|
Text("\(probe.path) → \(probe.result)")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundStyle(Color.flixyTextMuted)
|
||||||
|
.lineLimit(2)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = trace
|
||||||
|
.map { "\($0.path) → \($0.result)" }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
} label: {
|
||||||
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||||
|
.font(.flixyCaption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
@@ -138,6 +177,7 @@ struct LiveTVView: View {
|
|||||||
channels = guide.channels
|
channels = guide.channels
|
||||||
programs = guide.programs
|
programs = guide.programs
|
||||||
channelSource = guide.source
|
channelSource = guide.source
|
||||||
|
trace = guide.trace
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tune(_ channel: PlexChannel) async {
|
private func tune(_ channel: PlexChannel) async {
|
||||||
|
|||||||
@@ -31,110 +31,193 @@ extension PlexServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die eingeschalteten Sender eines Empfängers, mit Namen und Logos.
|
/// Die Sender eines Empfängers — gefunden, nicht geraten.
|
||||||
///
|
///
|
||||||
/// Zwei Quellen, die zusammengeführt werden müssen — das war der
|
/// Die Live-TV-Schnittstelle von Plex hat keine Beschreibung, und die
|
||||||
/// eigentliche Stolperstein:
|
/// Adresse der Senderliste unterscheidet sich zwischen Serverfassungen,
|
||||||
|
/// Tunertypen und Programmanbietern. Drei Versuche, sie zu erraten,
|
||||||
|
/// endeten auf einem echten Server alle mit 404.
|
||||||
///
|
///
|
||||||
/// - Der Empfänger kennt nur die **Zuordnung**: welche Sender einer
|
/// Deshalb wird sie jetzt gesucht statt geraten, in einem Durchgang und
|
||||||
/// Senderliste eingeschaltet sind, als nackte Schlüssel. Bei einer
|
/// gleichzeitig:
|
||||||
/// Swisscom-Liste sind das 28 von 521. Namen stehen dort keine.
|
|
||||||
/// - Die **Namen, Nummern und Logos** stehen beim Programmanbieter, unter
|
|
||||||
/// der Senderliste.
|
|
||||||
///
|
///
|
||||||
/// Zweimal habe ich vorher die Zuordnung für die Senderliste gehalten.
|
/// 1. Der Server sagt unter `/media/providers` selbst, welche Endpunkte
|
||||||
/// Beim ersten Mal blieb sie leer, beim zweiten hätten alle Sender „—"
|
/// er anbietet.
|
||||||
/// geheißen und dieselbe Kennung getragen.
|
/// 2. Der Programmanbieter und der Empfänger führen Unterverzeichnisse,
|
||||||
|
/// deren Adressen in ihrer eigenen Antwort stehen.
|
||||||
|
/// 3. Dazu die bekannten Formen, die andere Server verwenden.
|
||||||
|
/// 4. Und zuletzt das Programmraster, in dem jeder Eintrag seinen Sender
|
||||||
|
/// mitträgt.
|
||||||
|
///
|
||||||
|
/// Was dabei herauskam, steht in `trace` — jede Adresse mit ihrer
|
||||||
|
/// Antwort. Schlägt alles fehl, ist damit ohne Rückfrage zu sehen, was
|
||||||
|
/// der Server tatsächlich geantwortet hat.
|
||||||
public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
|
public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup {
|
||||||
let enabled = dvr.enabledChannelKeys
|
|
||||||
|
|
||||||
var identifier = dvr.epgIdentifier
|
var identifier = dvr.epgIdentifier
|
||||||
if identifier == nil {
|
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
|
||||||
identifier = await electronicProgramGuideIdentifier()
|
|
||||||
}
|
|
||||||
guard let epgIdentifier = identifier else {
|
guard let epgIdentifier = identifier else {
|
||||||
return ChannelLookup(channels: [], source: .noLineup)
|
return ChannelLookup(channels: [], source: .noLineup, trace: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
let advertised = await providerFeatures(for: epgIdentifier)
|
let paths = await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier)
|
||||||
|
var found: [Int: [PlexChannel]] = [:]
|
||||||
|
var trace: [Int: ChannelProbe] = [:]
|
||||||
|
|
||||||
var lastFailure: String?
|
// Gleichzeitig, nicht nacheinander: ein Dutzend Adressen einzeln
|
||||||
for path in Self.channelPaths(
|
// abzuklopfen dauert bei einem Server, der schweigt statt abzulehnen,
|
||||||
dvr: dvr,
|
// eine Minute. Der Actor gibt an jedem `await` frei, deshalb laufen
|
||||||
epgIdentifier: epgIdentifier,
|
// die Abfragen wirklich nebeneinander.
|
||||||
advertised: advertised
|
await withTaskGroup(of: (Int, [PlexChannel], ChannelProbe).self) { group in
|
||||||
) {
|
for (index, path) in paths.enumerated() {
|
||||||
do {
|
group.addTask {
|
||||||
let container = try await client.send(
|
let outcome = await self.probe(path: path)
|
||||||
PlexRequest(path: path, timeout: .seconds(30)),
|
return (index, outcome.channels, outcome.probe)
|
||||||
as: PlexMediaContainer<PlexChannel>.self
|
|
||||||
)
|
|
||||||
guard !container.content.isEmpty else { continue }
|
|
||||||
|
|
||||||
// Ohne Zuordnung alles zeigen: besser die vollständige Liste
|
|
||||||
// als gar keine, wenn der Empfänger keine Auswahl meldet.
|
|
||||||
let channels = enabled.isEmpty
|
|
||||||
? container.content
|
|
||||||
: container.content.filter { $0.matches(anyOf: enabled) }
|
|
||||||
|
|
||||||
if channels.isEmpty {
|
|
||||||
return ChannelLookup(
|
|
||||||
channels: [],
|
|
||||||
source: .lineupWithoutMatches(
|
|
||||||
available: container.content.count,
|
|
||||||
enabled: enabled.count
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return ChannelLookup(
|
}
|
||||||
channels: channels,
|
for await (index, channels, probe) in group {
|
||||||
source: .lineup(available: container.content.count, path: path)
|
found[index] = channels
|
||||||
)
|
trace[index] = probe
|
||||||
} catch {
|
|
||||||
lastFailure = "\(path): \(error)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ChannelLookup(channels: [], source: .failed(lastFailure ?? "kein Weg lieferte Sender"))
|
let ordered = paths.indices.compactMap { trace[$0] }
|
||||||
|
let enabled = dvr.enabledChannelKeys
|
||||||
|
|
||||||
|
// Die Reihenfolge der Kandidaten ist die Rangfolge: was der Server
|
||||||
|
// selbst angibt, zählt mehr als eine bekannte Form.
|
||||||
|
for index in paths.indices {
|
||||||
|
guard let channels = found[index], !channels.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Ohne Zuordnung alles zeigen, und auch dann, wenn keiner passt:
|
||||||
|
// eine vollständige Liste ist besser als eine leere Ansicht, und
|
||||||
|
// die Schlüssel der Zuordnung weichen je nach Anbieter ab.
|
||||||
|
let matching = enabled.isEmpty ? channels : channels.filter { $0.matches(anyOf: enabled) }
|
||||||
|
let result = matching.isEmpty ? channels : matching
|
||||||
|
|
||||||
|
return ChannelLookup(
|
||||||
|
channels: result,
|
||||||
|
source: .lineup(available: channels.count, path: paths[index]),
|
||||||
|
trace: ordered
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChannelLookup(channels: [], source: .failed("keine der \(paths.count) Adressen lieferte Sender"), trace: ordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Adressen, unter denen die Senderliste stehen könnte.
|
/// Eine Adresse abklopfen: was steht dort, und sind es Sender?
|
||||||
///
|
private func probe(path: String) async -> (channels: [PlexChannel], probe: ChannelProbe) {
|
||||||
/// Mehrere, weil dieser Teil der Plex-API keine Beschreibung hat und sich
|
let data: Data
|
||||||
/// die Form zwischen Serverfassungen unterscheidet. Sie werden der Reihe
|
do {
|
||||||
/// nach probiert; welche gegriffen hat, steht in der Diagnose — damit aus
|
data = try await client.send(PlexRequest(path: path, timeout: .seconds(12)))
|
||||||
/// dem Durchprobieren beim nächsten Mal Wissen wird.
|
} catch {
|
||||||
///
|
return ([], ChannelProbe(path: path, result: Self.shortDescription(of: error)))
|
||||||
/// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt
|
}
|
||||||
/// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt
|
|
||||||
/// jeder dieser Pfade 404.
|
let channels = (try? PlexResponse.decode(PlexMediaContainer<PlexChannel>.self, from: data))?.content ?? []
|
||||||
static func channelPaths(
|
// Namenlose zählen nicht: eine Liste aus dreißig Strichen sieht aus
|
||||||
dvr: PlexDVR,
|
// wie eine Senderliste, ist aber keine — dieser Fehler ist hier schon
|
||||||
epgIdentifier: String,
|
// einmal passiert.
|
||||||
advertised: [PlexProviderFeature] = []
|
let named = channels.filter(\.hasName)
|
||||||
) -> [String] {
|
|
||||||
|
return (
|
||||||
|
named,
|
||||||
|
ChannelProbe(
|
||||||
|
path: path,
|
||||||
|
result: named.isEmpty ? Self.containerSummary(data) : "\(named.count) Sender"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Adressen, an denen die Senderliste stehen könnte — erfragt und bekannt.
|
||||||
|
private func candidatePaths(dvr: PlexDVR, epgIdentifier: String) async -> [String] {
|
||||||
var paths: [String] = []
|
var paths: [String] = []
|
||||||
|
|
||||||
// Zuerst, was der Server selbst angibt. Die Namen der Fähigkeiten sind
|
// 1. Was der Server selbst angibt.
|
||||||
// nicht dokumentiert, deshalb alles, was nach Sendern klingt.
|
for feature in await providerFeatures(for: epgIdentifier)
|
||||||
for feature in advertised where ["channels", "lineups", "livetv", "grid"].contains(feature.type) {
|
where ["channels", "lineups", "livetv", "grid", "tuner"].contains(feature.type) {
|
||||||
if let key = feature.key { paths.append(key) }
|
if let key = feature.key { paths.append(key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Danach die geratenen Formen. Sie haben bei einem Server mit
|
// 2. Was Anbieter und Empfänger an Unterverzeichnissen führen.
|
||||||
// Swisscom-Liste alle 404 ergeben — sie stehen hier nur noch, weil
|
for root in ["/\(epgIdentifier)", "/livetv/dvrs/\(dvr.key)"] {
|
||||||
// andere Serverfassungen sie kennen könnten.
|
for entry in await directories(at: root) {
|
||||||
if let lineup = dvr.lineups.first {
|
guard let key = entry.key else { continue }
|
||||||
paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels")
|
let haystack = (key + " " + (entry.title ?? "")).lowercased()
|
||||||
|
guard ["channel", "sender", "lineup", "grid", "guide"].contains(where: haystack.contains)
|
||||||
|
else { continue }
|
||||||
|
paths.append(key.hasPrefix("/") ? key : "\(root)/\(key)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
paths.append("/\(epgIdentifier)/channels")
|
|
||||||
paths.append("/livetv/dvrs/\(dvr.key)/channels")
|
|
||||||
|
|
||||||
// Doppelte entfernen, Reihenfolge behalten.
|
// 3. Die bekannten Formen.
|
||||||
|
paths.append(contentsOf: Self.knownPaths(dvr: dvr, epgIdentifier: epgIdentifier))
|
||||||
|
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
return paths.filter { seen.insert($0).inserted }
|
return paths.filter { seen.insert($0).inserted }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Adressen, die andere Serverfassungen verwenden.
|
||||||
|
///
|
||||||
|
/// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt
|
||||||
|
/// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt
|
||||||
|
/// jeder dieser Pfade 404 — das war der erste Fehlschlag von vieren.
|
||||||
|
static func knownPaths(dvr: PlexDVR, epgIdentifier: String) -> [String] {
|
||||||
|
var paths = ["/livetv/dvrs/\(dvr.key)/channels"]
|
||||||
|
|
||||||
|
if let lineup = dvr.lineups.first {
|
||||||
|
paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels")
|
||||||
|
paths.append("/livetv/dvrs/\(dvr.key)/lineups/\(lineup.pathIdentifier)/channels")
|
||||||
|
}
|
||||||
|
paths.append("/\(epgIdentifier)/channels")
|
||||||
|
paths.append("/\(epgIdentifier)/lineups")
|
||||||
|
|
||||||
|
// Der Empfänger selbst führt bei Netzwerk-Tunern seine eigene
|
||||||
|
// Senderliste — die vom Gerät gefundenen Sender, mit Namen.
|
||||||
|
for device in dvr.devices {
|
||||||
|
for key in [device.key, device.deviceIdentifier].compactMap({ $0 }) {
|
||||||
|
let base = key.hasPrefix("/") ? key : "/media/grabbers/devices/\(key)"
|
||||||
|
paths.append("\(base)/channels")
|
||||||
|
paths.append("/livetv/dvrs/\(dvr.key)/devices/\(key)/channels")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = Set<String>()
|
||||||
|
return paths.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Unterverzeichnisse an einer Adresse.
|
||||||
|
private func directories(at path: String) async -> [PlexNavigationEntry] {
|
||||||
|
let container = try? await client.send(
|
||||||
|
PlexRequest(path: path, timeout: .seconds(12)),
|
||||||
|
as: PlexMediaContainer<PlexNavigationEntry>.self
|
||||||
|
)
|
||||||
|
return container?.content ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Sammlungen eine Antwort enthält, mit Anzahl.
|
||||||
|
///
|
||||||
|
/// Beantwortet die Frage, die nach einem Fehlschlag als Nächstes kommt:
|
||||||
|
/// war dort etwas, und unter welchem Namen?
|
||||||
|
static func containerSummary(_ data: Data) -> String {
|
||||||
|
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let container = object["MediaContainer"] as? [String: Any]
|
||||||
|
else { return "keine Plex-Antwort (\(data.count) Bytes)" }
|
||||||
|
|
||||||
|
let collections = container
|
||||||
|
.compactMap { key, value -> String? in
|
||||||
|
guard let array = value as? [Any] else { return nil }
|
||||||
|
return "\(key): \(array.count)"
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
return collections.isEmpty ? "leer" : collections.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shortDescription(of error: any Error) -> String {
|
||||||
|
if let plex = error as? PlexError { return String(describing: plex) }
|
||||||
|
return (error as? URLError).map { "URLError \($0.errorCode)" } ?? "Fehler"
|
||||||
|
}
|
||||||
|
|
||||||
/// Was der Programmanbieter an Fähigkeiten angibt.
|
/// Was der Programmanbieter an Fähigkeiten angibt.
|
||||||
public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] {
|
public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] {
|
||||||
guard let container = try? await client.send(
|
guard let container = try? await client.send(
|
||||||
@@ -210,7 +293,12 @@ extension PlexServer {
|
|||||||
var identifier = dvr.epgIdentifier
|
var identifier = dvr.epgIdentifier
|
||||||
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
|
if identifier == nil { identifier = await electronicProgramGuideIdentifier() }
|
||||||
guard let epgIdentifier = identifier else {
|
guard let epgIdentifier = identifier else {
|
||||||
return LiveTVGuide(channels: lookup.channels, programs: [:], source: lookup.description)
|
return LiveTVGuide(
|
||||||
|
channels: lookup.channels,
|
||||||
|
programs: [:],
|
||||||
|
source: lookup.description,
|
||||||
|
trace: lookup.trace
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -227,7 +315,12 @@ extension PlexServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !lookup.channels.isEmpty {
|
if !lookup.channels.isEmpty {
|
||||||
return LiveTVGuide(channels: lookup.channels, programs: programs, source: lookup.description)
|
return LiveTVGuide(
|
||||||
|
channels: lookup.channels,
|
||||||
|
programs: programs,
|
||||||
|
source: lookup.description,
|
||||||
|
trace: lookup.trace
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aus dem Raster: jeden Sender einmal, in der Reihenfolge, in der das
|
// Aus dem Raster: jeden Sender einmal, in der Reihenfolge, in der das
|
||||||
@@ -247,7 +340,13 @@ extension PlexServer {
|
|||||||
programs: programs,
|
programs: programs,
|
||||||
source: channels.isEmpty
|
source: channels.isEmpty
|
||||||
? "Raster: keine Sender (\(lookup.description))"
|
? "Raster: keine Sender (\(lookup.description))"
|
||||||
: "Raster: \(channels.count) von \(fromGrid.count) Sendern"
|
: "Raster: \(channels.count) von \(fromGrid.count) Sendern",
|
||||||
|
trace: lookup.trace + [ChannelProbe(
|
||||||
|
path: "/\(epgIdentifier)/grid",
|
||||||
|
result: fromGrid.isEmpty
|
||||||
|
? "\(entries.count) Sendungen, kein Sender benannt"
|
||||||
|
: "\(fromGrid.count) Sender"
|
||||||
|
)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,12 +406,7 @@ extension PlexServer {
|
|||||||
as: PlexMediaContainer<PlexDVR>.self
|
as: PlexMediaContainer<PlexDVR>.self
|
||||||
).content.first,
|
).content.first,
|
||||||
let epgIdentifier = dvr.epgIdentifier {
|
let epgIdentifier = dvr.epgIdentifier {
|
||||||
let advertised = await providerFeatures(for: epgIdentifier)
|
for path in await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier) {
|
||||||
for path in Self.channelPaths(
|
|
||||||
dvr: dvr,
|
|
||||||
epgIdentifier: epgIdentifier,
|
|
||||||
advertised: advertised
|
|
||||||
) {
|
|
||||||
report.append("\n=== \(path) ===")
|
report.append("\n=== \(path) ===")
|
||||||
report.append(await structure(ofPath: path))
|
report.append(await structure(ofPath: path))
|
||||||
}
|
}
|
||||||
@@ -387,6 +481,24 @@ extension PlexServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eine abgeklopfte Adresse und was sie geantwortet hat.
|
||||||
|
public struct ChannelProbe: Sendable, Equatable, Identifiable {
|
||||||
|
public let path: String
|
||||||
|
/// „28 Sender", „notFound", „Channel: 0" — kurz genug für eine Zeile.
|
||||||
|
public let result: String
|
||||||
|
|
||||||
|
public var id: String { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Verzeichniseintrag, wie ihn Plex in Wurzelantworten führt.
|
||||||
|
///
|
||||||
|
/// Nur Adresse und Beschriftung: mehr braucht es nicht, um zu erkennen, wo
|
||||||
|
/// die Sender stehen, und mehr steht dort oft auch nicht.
|
||||||
|
struct PlexNavigationEntry: Decodable {
|
||||||
|
let key: String?
|
||||||
|
let title: String?
|
||||||
|
}
|
||||||
|
|
||||||
/// Woher die Senderliste kam.
|
/// Woher die Senderliste kam.
|
||||||
public struct ChannelLookup: Sendable {
|
public struct ChannelLookup: Sendable {
|
||||||
public enum Source: Sendable, Equatable {
|
public enum Source: Sendable, Equatable {
|
||||||
@@ -402,9 +514,18 @@ public struct ChannelLookup: Sendable {
|
|||||||
public let channels: [PlexChannel]
|
public let channels: [PlexChannel]
|
||||||
public let source: Source
|
public let source: Source
|
||||||
|
|
||||||
public init(channels: [PlexChannel], source: Source) {
|
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
||||||
|
///
|
||||||
|
/// Steht in der Ansicht, wenn nichts gefunden wurde. Der Grund ist
|
||||||
|
/// unbequem, aber nützlich: bei einer Schnittstelle ohne Beschreibung ist
|
||||||
|
/// „keine Sender" allein eine Sackgasse, und jede Runde Rückfragen
|
||||||
|
/// kostet Tage.
|
||||||
|
public let trace: [ChannelProbe]
|
||||||
|
|
||||||
|
public init(channels: [PlexChannel], source: Source, trace: [ChannelProbe] = []) {
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.source = source
|
self.source = source
|
||||||
|
self.trace = trace
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kurzbeschreibung für die Diagnose.
|
/// Kurzbeschreibung für die Diagnose.
|
||||||
@@ -488,4 +609,6 @@ public struct LiveTVGuide: Sendable {
|
|||||||
public let programs: [String: [PlexProgram]]
|
public let programs: [String: [PlexProgram]]
|
||||||
/// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose.
|
/// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose.
|
||||||
public let source: String
|
public let source: String
|
||||||
|
/// Jede abgeklopfte Adresse mit ihrer Antwort, falls nichts gefunden wurde.
|
||||||
|
public let trace: [ChannelProbe]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,13 +135,15 @@ public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
public let key: String?
|
public let key: String?
|
||||||
public let uuid: String?
|
public let uuid: String?
|
||||||
public let title: String?
|
public let title: String?
|
||||||
|
/// Die Gerätekennung — unter ihr führt der Server die gefundenen Sender.
|
||||||
|
public let deviceIdentifier: String?
|
||||||
/// Welche Sender der Senderliste hier eingeschaltet sind.
|
/// Welche Sender der Senderliste hier eingeschaltet sind.
|
||||||
public let channelMappings: [PlexChannelMapping]
|
public let channelMappings: [PlexChannelMapping]
|
||||||
|
|
||||||
public var id: String { uuid ?? key ?? title ?? "tuner" }
|
public var id: String { uuid ?? key ?? title ?? "tuner" }
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case key, uuid, title
|
case key, uuid, title, deviceId, deviceIdentifier
|
||||||
case channelMapping = "ChannelMapping"
|
case channelMapping = "ChannelMapping"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +152,9 @@ public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
self.key = try? values.decodeIfPresent(String.self, forKey: .key)
|
self.key = try? values.decodeIfPresent(String.self, forKey: .key)
|
||||||
self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid)
|
self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid)
|
||||||
self.title = try values.decodeIfPresent(String.self, forKey: .title)
|
self.title = try values.decodeIfPresent(String.self, forKey: .title)
|
||||||
|
self.deviceIdentifier = (try? values.decodeIfPresent(String.self, forKey: .deviceId))
|
||||||
|
?? (try? values.decodeIfPresent(String.self, forKey: .deviceIdentifier))
|
||||||
|
?? nil
|
||||||
self.channelMappings = try values
|
self.channelMappings = try values
|
||||||
.decodeIfPresent([PlexChannelMapping].self, forKey: .channelMapping) ?? []
|
.decodeIfPresent([PlexChannelMapping].self, forKey: .channelMapping) ?? []
|
||||||
}
|
}
|
||||||
@@ -158,11 +163,13 @@ public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
key: String? = nil,
|
key: String? = nil,
|
||||||
uuid: String? = nil,
|
uuid: String? = nil,
|
||||||
title: String? = nil,
|
title: String? = nil,
|
||||||
|
deviceIdentifier: String? = nil,
|
||||||
channelMappings: [PlexChannelMapping] = []
|
channelMappings: [PlexChannelMapping] = []
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.deviceIdentifier = deviceIdentifier
|
||||||
self.channelMappings = channelMappings
|
self.channelMappings = channelMappings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,16 +243,32 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
public let key: String?
|
public let key: String?
|
||||||
public let id: String
|
public let id: String
|
||||||
|
|
||||||
|
/// Ob dieser Sender einen Namen trägt.
|
||||||
|
///
|
||||||
|
/// Eine Liste aus dreißig Strichen sieht aus wie eine Senderliste, ist
|
||||||
|
/// aber keine — dieser Irrtum hat hier schon einmal eine falsche Quelle
|
||||||
|
/// als die richtige durchgehen lassen.
|
||||||
|
public var hasName: Bool { title != Self.placeholder }
|
||||||
|
|
||||||
|
static let placeholder = "—"
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case channelIdentifier, title, thumb, key
|
case channelIdentifier, title, thumb, key
|
||||||
case identifier = "id"
|
case identifier = "id"
|
||||||
case uuid
|
case uuid, name, callSign, vcn, number, channelTitle, channelCallSign
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.title = try values.decodeIfPresent(String.self, forKey: .title) ?? "—"
|
// Vier Schreibweisen für denselben Namen, je nach Tunertyp und
|
||||||
self.channelIdentifier = try values.decodeIfPresent(String.self, forKey: .channelIdentifier)
|
// Anbieter. Nur `title` zu lesen hieße, die halbe Senderliste als
|
||||||
|
// namenlos zu verwerfen.
|
||||||
|
self.title = [CodingKeys.title, .name, .channelTitle, .callSign, .channelCallSign]
|
||||||
|
.lazy
|
||||||
|
.compactMap { try? values.decodeIfPresent(String.self, forKey: $0) }
|
||||||
|
.first { !$0.isEmpty } ?? Self.placeholder
|
||||||
|
|
||||||
|
self.channelIdentifier = Self.text(in: values, forKeys: [.channelIdentifier, .vcn, .number])
|
||||||
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
|
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
|
||||||
self.key = try values.decodeIfPresent(String.self, forKey: .key)
|
self.key = try values.decodeIfPresent(String.self, forKey: .key)
|
||||||
|
|
||||||
@@ -283,6 +306,22 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
return keys.contains { $0.split(separator: "/").last.map(String.init) == id }
|
return keys.contains { $0.split(separator: "/").last.map(String.init) == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Wert, der als Zahl oder als Zeichenkette kommen kann.
|
||||||
|
private static func text(
|
||||||
|
in values: KeyedDecodingContainer<CodingKeys>,
|
||||||
|
forKeys keys: [CodingKeys]
|
||||||
|
) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
if let text = try? values.decodeIfPresent(String.self, forKey: key), !text.isEmpty {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if let number = try? values.decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return String(number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
public init(id: String, title: String, channelIdentifier: String? = nil, thumb: String? = nil, key: String? = nil) {
|
public init(id: String, title: String, channelIdentifier: String? = nil, thumb: String? = nil, key: String? = nil) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
|
|||||||
@@ -197,23 +197,23 @@ struct ChannelPathTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Test("Die Kennung der Senderliste wird ausgepackt, nicht roh eingesetzt")
|
@Test("Die Kennung der Senderliste wird ausgepackt, nicht roh eingesetzt")
|
||||||
func usesBareIdentifier() {
|
func usesBareIdentifier() throws {
|
||||||
// Der Fehler, der 404 ergab: die volle Adresse mit Schema, Anhang und
|
// Der Fehler, der 404 ergab: die volle Adresse mit Schema, Anhang und
|
||||||
// Prozentzeichen landete unverändert im Pfad.
|
// Prozentzeichen landete unverändert im Pfad.
|
||||||
let paths = PlexServer.channelPaths(dvr: dvr, epgIdentifier: "epg:2")
|
let paths = PlexServer.knownPaths(dvr: dvr, epgIdentifier: "epg:2")
|
||||||
let first = paths[0]
|
let lineupPath = try #require(paths.first { $0.contains("/lineups/") })
|
||||||
|
|
||||||
#expect(first == "/epg:2/lineups/6228f7f2364e02fa5ec8e51c/channels")
|
#expect(lineupPath == "/epg:2/lineups/6228f7f2364e02fa5ec8e51c/channels")
|
||||||
#expect(!first.contains("lineup://"))
|
#expect(!lineupPath.contains("lineup://"))
|
||||||
#expect(!first.contains("#"))
|
#expect(!lineupPath.contains("#"))
|
||||||
#expect(!first.contains("%"))
|
#expect(!lineupPath.contains("%"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Es gibt Rückfalllinien")
|
@Test("Es gibt Rückfalllinien")
|
||||||
func offersFallbacks() {
|
func offersFallbacks() {
|
||||||
// Die Form unterscheidet sich zwischen Serverfassungen, und dieser
|
// Die Form unterscheidet sich zwischen Serverfassungen, und dieser
|
||||||
// Teil der API hat keine Beschreibung.
|
// Teil der API hat keine Beschreibung.
|
||||||
let paths = PlexServer.channelPaths(dvr: dvr, epgIdentifier: "epg:2")
|
let paths = PlexServer.knownPaths(dvr: dvr, epgIdentifier: "epg:2")
|
||||||
#expect(paths.count >= 2)
|
#expect(paths.count >= 2)
|
||||||
#expect(paths.contains("/livetv/dvrs/1/channels"))
|
#expect(paths.contains("/livetv/dvrs/1/channels"))
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ struct ChannelPathTests {
|
|||||||
@Test("Ohne Senderliste bleiben die übrigen Wege")
|
@Test("Ohne Senderliste bleiben die übrigen Wege")
|
||||||
func withoutLineup() {
|
func withoutLineup() {
|
||||||
let bare = PlexDVR(key: "7")
|
let bare = PlexDVR(key: "7")
|
||||||
let paths = PlexServer.channelPaths(dvr: bare, epgIdentifier: "epg:2")
|
let paths = PlexServer.knownPaths(dvr: bare, epgIdentifier: "epg:2")
|
||||||
#expect(!paths.isEmpty)
|
#expect(!paths.isEmpty)
|
||||||
#expect(paths.allSatisfy { !$0.contains("lineups//") })
|
#expect(paths.allSatisfy { !$0.contains("lineups//") })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,63 @@ struct LiveTVGuideTests {
|
|||||||
#expect(guide.channels.isEmpty)
|
#expect(guide.channels.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Die Senderliste des Empfängers wird gefunden, ohne dass sie geraten wird")
|
||||||
|
func sweepsUntilSomethingAnswers() async throws {
|
||||||
|
// Der Fall, der viermal danebenging: die Senderliste steht weder beim
|
||||||
|
// Programmanbieter noch unter dem Empfänger, sondern beim Gerät — und
|
||||||
|
// sie schreibt den Namen in `name` statt in `title`.
|
||||||
|
let device = """
|
||||||
|
{"MediaContainer":{"size":2,"Channel":[
|
||||||
|
{"name":"SRF 1 HD","vcn":1,"key":"/livetv/channels/11","enabled":"1"},
|
||||||
|
{"name":"SRF zwei HD","vcn":2,"key":"/livetv/channels/12","enabled":"1"}
|
||||||
|
]}}
|
||||||
|
"""
|
||||||
|
let server = server(routes: ["/media/grabbers/devices/tuner-7/channels": device])
|
||||||
|
let withTuner = PlexDVR(
|
||||||
|
key: "2",
|
||||||
|
devices: [PlexTunerDevice(key: "tuner-7")],
|
||||||
|
epgIdentifier: "tv.plex.providers.epg.cloud:2"
|
||||||
|
)
|
||||||
|
|
||||||
|
let lookup = await server.channels(inDVR: withTuner)
|
||||||
|
|
||||||
|
#expect(lookup.channels.map(\.title) == ["SRF 1 HD", "SRF zwei HD"])
|
||||||
|
#expect(lookup.channels.first?.channelIdentifier == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Jede abgeklopfte Adresse steht mit ihrer Antwort in der Spur")
|
||||||
|
func recordsWhatEveryPathAnswered() async throws {
|
||||||
|
// Ohne diese Spur ist „keine Sender" eine Sackgasse: sie sagt nicht,
|
||||||
|
// ob der Server nichts hergibt oder die App am falschen Ort sucht.
|
||||||
|
let server = server(routes: [:])
|
||||||
|
let withTuner = PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
|
||||||
|
|
||||||
|
let lookup = await server.channels(inDVR: withTuner)
|
||||||
|
|
||||||
|
#expect(lookup.channels.isEmpty)
|
||||||
|
#expect(lookup.trace.count >= 3)
|
||||||
|
#expect(lookup.trace.allSatisfy { !$0.result.isEmpty })
|
||||||
|
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine Liste aus Strichen zählt nicht als Senderliste")
|
||||||
|
func rejectsNamelessChannels() async throws {
|
||||||
|
// Genau das ist einmal durchgegangen: dreißig Einträge ohne Namen
|
||||||
|
// sahen aus wie eine Senderliste und verdrängten die richtige Quelle.
|
||||||
|
let nameless = """
|
||||||
|
{"MediaContainer":{"size":2,"Channel":[
|
||||||
|
{"channelKey":"a","enabled":"1"},{"channelKey":"b","enabled":"1"}
|
||||||
|
]}}
|
||||||
|
"""
|
||||||
|
let server = server(routes: ["/livetv/dvrs/2/channels": nameless])
|
||||||
|
let withTuner = PlexDVR(key: "2", epgIdentifier: "tv.plex.providers.epg.cloud:2")
|
||||||
|
|
||||||
|
let lookup = await server.channels(inDVR: withTuner)
|
||||||
|
|
||||||
|
#expect(lookup.channels.isEmpty)
|
||||||
|
#expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") })
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Die Einschaltadresse reist mit dem Sender mit")
|
@Test("Die Einschaltadresse reist mit dem Sender mit")
|
||||||
func keepsTuneKey() async throws {
|
func keepsTuneKey() async throws {
|
||||||
let server = server(routes: [
|
let server = server(routes: [
|
||||||
|
|||||||
Reference in New Issue
Block a user