diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 08aeebd..4037804 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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": { "extractionState": "stale", "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": { "comment": "Platzhalter: Anzahl Sender", + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1778,6 +1815,7 @@ } }, "liveTV.gap": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1794,6 +1832,7 @@ } }, "liveTV.mode": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1810,6 +1849,7 @@ } }, "liveTV.mode.guide": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1826,6 +1866,7 @@ } }, "liveTV.mode.now": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1941,6 +1982,7 @@ } }, "liveTV.now": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1957,6 +1999,7 @@ } }, "liveTV.runningNow": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1990,6 +2033,7 @@ } }, "liveTV.tune": { + "extractionState": "stale", "localizations": { "de": { "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": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Session/PlaybackCoordinator.swift b/Flixy/Session/PlaybackCoordinator.swift index 144409b..63e58ba 100644 --- a/Flixy/Session/PlaybackCoordinator.swift +++ b/Flixy/Session/PlaybackCoordinator.swift @@ -60,15 +60,24 @@ extension PlexItem { } /// 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? { switch kind { case .episode: + var code: String? if let season = parentIndex, let episode = index { - return String(localized: "episode.short \(season) \(episode)") + code = String(localized: "episode.short \(season) \(episode)") } - return grandparentTitle - case .movie: - return year.map(String.init) + guard let series = grandparentTitle else { return code } + return code.map { "\(series) · \($0)" } ?? series + 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: return year.map(String.init) } diff --git a/Flixy/Views/Detail/ParentLinks.swift b/Flixy/Views/Detail/ParentLinks.swift new file mode 100644 index 0000000..af4b9de --- /dev/null +++ b/Flixy/Views/Detail/ParentLinks.swift @@ -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 [] + } + } +} diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 9ac7a60..b325272 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -19,6 +19,8 @@ struct PlayableDetailView: View { VStack(alignment: .leading, spacing: FlixySpacing.section) { header + ParentLinks(item: item) + if item.hasMultipleVersions { VersionPicker(media: item.media, selection: $selectedVersion) .padding(.horizontal, FlixySpacing.screenMargin) @@ -104,8 +106,13 @@ struct PlayableDetailView: View { private var metadataLine: String? { var pieces: [String] = [] - if item.kind == .episode, let season = item.parentIndex, let episode = item.index { - pieces.append(String(localized: "episode.short \(season) \(episode)")) + if item.kind == .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 duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) } diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index dfaecaa..36f652a 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -17,6 +17,8 @@ struct SeriesDetailView: View { VStack(alignment: .leading, spacing: FlixySpacing.section) { header + ParentLinks(item: item) + if let summary = item.summary, !summary.isEmpty { Text(summary) .font(.flixyBody) @@ -38,7 +40,7 @@ struct SeriesDetailView: View { private var header: some View { HeroBanner( - title: item.title, + title: item.rowTitle, metadata: metadataLine, summary: nil, artworkURL: session.server?.artworkURL( @@ -87,6 +89,11 @@ struct SeriesDetailView: View { private var metadataLine: 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 leafCount = item.leafCount { pieces.append(String(localized: "detail.episodeCount \(leafCount)")) diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift index 2d5de55..a1456f8 100644 --- a/Flixy/Views/Home/MediaRow.swift +++ b/Flixy/Views/Home/MediaRow.swift @@ -79,11 +79,19 @@ struct MediaRow: View { extension PlexItem { /// Der Titel auf einer Kachel in einer Reihe. /// - /// Bei Folgen der Serientitel: in „Weiterschauen" stehen Folgen - /// verschiedener Serien nebeneinander, und der Folgentitel allein sagt - /// nicht, worum es geht. + /// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei + /// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel — + /// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts + /// darüber, welche Serie gemeint ist. 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. diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index df1be63..656fca9 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -14,6 +14,8 @@ struct LiveTVView: View { @State private var channels: [PlexChannel] = [] /// Woher die Senderliste kam — steht in der leeren Ansicht. @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 isTuning: String? /// Raster oder Senderliste. @@ -55,19 +57,12 @@ struct LiveTVView: View { ContentUnavailableView { Label("liveTV.noChannels.title", systemImage: "tv") } description: { - VStack(spacing: FlixySpacing.snug) { - 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) - } - } + Text("liveTV.noChannels.description") } actions: { - Button("action.retry") { Task { await load() } } + VStack(spacing: FlixySpacing.regular) { + Button("action.retry") { Task { await load() } } + searchTrace + } } case .available: @@ -102,6 +97,50 @@ struct LiveTVView: View { .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 { ScrollView { LazyVStack(spacing: 0) { @@ -138,6 +177,7 @@ struct LiveTVView: View { channels = guide.channels programs = guide.programs channelSource = guide.source + trace = guide.trace } private func tune(_ channel: PlexChannel) async { diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 7f37b86..4689d3e 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -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 - /// eigentliche Stolperstein: + /// Die Live-TV-Schnittstelle von Plex hat keine Beschreibung, und die + /// 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 - /// Senderliste eingeschaltet sind, als nackte Schlüssel. Bei einer - /// Swisscom-Liste sind das 28 von 521. Namen stehen dort keine. - /// - Die **Namen, Nummern und Logos** stehen beim Programmanbieter, unter - /// der Senderliste. + /// Deshalb wird sie jetzt gesucht statt geraten, in einem Durchgang und + /// gleichzeitig: /// - /// Zweimal habe ich vorher die Zuordnung für die Senderliste gehalten. - /// Beim ersten Mal blieb sie leer, beim zweiten hätten alle Sender „—" - /// geheißen und dieselbe Kennung getragen. + /// 1. Der Server sagt unter `/media/providers` selbst, welche Endpunkte + /// er anbietet. + /// 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 { - let enabled = dvr.enabledChannelKeys - var identifier = dvr.epgIdentifier - if identifier == nil { - identifier = await electronicProgramGuideIdentifier() - } + if identifier == nil { identifier = await electronicProgramGuideIdentifier() } 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? - for path in Self.channelPaths( - dvr: dvr, - epgIdentifier: epgIdentifier, - advertised: advertised - ) { - do { - let container = try await client.send( - PlexRequest(path: path, timeout: .seconds(30)), - as: PlexMediaContainer.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 - ) - ) + // Gleichzeitig, nicht nacheinander: ein Dutzend Adressen einzeln + // abzuklopfen dauert bei einem Server, der schweigt statt abzulehnen, + // eine Minute. Der Actor gibt an jedem `await` frei, deshalb laufen + // die Abfragen wirklich nebeneinander. + await withTaskGroup(of: (Int, [PlexChannel], ChannelProbe).self) { group in + for (index, path) in paths.enumerated() { + group.addTask { + let outcome = await self.probe(path: path) + return (index, outcome.channels, outcome.probe) } - return ChannelLookup( - channels: channels, - source: .lineup(available: container.content.count, path: path) - ) - } catch { - lastFailure = "\(path): \(error)" + } + for await (index, channels, probe) in group { + found[index] = channels + trace[index] = probe } } - 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. - /// - /// Mehrere, weil dieser Teil der Plex-API keine Beschreibung hat und sich - /// die Form zwischen Serverfassungen unterscheidet. Sie werden der Reihe - /// nach probiert; welche gegriffen hat, steht in der Diagnose — damit aus - /// dem Durchprobieren beim nächsten Mal Wissen wird. - /// - /// Entscheidend ist `pathIdentifier`: die Kennung der Senderliste kommt - /// als vollständige Adresse mit Schema und Anhang. Roh eingesetzt ergibt - /// jeder dieser Pfade 404. - static func channelPaths( - dvr: PlexDVR, - epgIdentifier: String, - advertised: [PlexProviderFeature] = [] - ) -> [String] { + /// Eine Adresse abklopfen: was steht dort, und sind es Sender? + private func probe(path: String) async -> (channels: [PlexChannel], probe: ChannelProbe) { + let data: Data + do { + data = try await client.send(PlexRequest(path: path, timeout: .seconds(12))) + } catch { + return ([], ChannelProbe(path: path, result: Self.shortDescription(of: error))) + } + + let channels = (try? PlexResponse.decode(PlexMediaContainer.self, from: data))?.content ?? [] + // Namenlose zählen nicht: eine Liste aus dreißig Strichen sieht aus + // wie eine Senderliste, ist aber keine — dieser Fehler ist hier schon + // einmal passiert. + let named = channels.filter(\.hasName) + + 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] = [] - // Zuerst, was der Server selbst angibt. Die Namen der Fähigkeiten sind - // nicht dokumentiert, deshalb alles, was nach Sendern klingt. - for feature in advertised where ["channels", "lineups", "livetv", "grid"].contains(feature.type) { + // 1. Was der Server selbst angibt. + for feature in await providerFeatures(for: epgIdentifier) + where ["channels", "lineups", "livetv", "grid", "tuner"].contains(feature.type) { if let key = feature.key { paths.append(key) } } - // Danach die geratenen Formen. Sie haben bei einem Server mit - // Swisscom-Liste alle 404 ergeben — sie stehen hier nur noch, weil - // andere Serverfassungen sie kennen könnten. - if let lineup = dvr.lineups.first { - paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels") + // 2. Was Anbieter und Empfänger an Unterverzeichnissen führen. + for root in ["/\(epgIdentifier)", "/livetv/dvrs/\(dvr.key)"] { + for entry in await directories(at: root) { + guard let key = entry.key else { continue } + 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() 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() + 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.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. public func providerFeatures(for identifier: String) async -> [PlexProviderFeature] { guard let container = try? await client.send( @@ -210,7 +293,12 @@ extension PlexServer { var identifier = dvr.epgIdentifier if identifier == nil { identifier = await electronicProgramGuideIdentifier() } 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() @@ -227,7 +315,12 @@ extension PlexServer { } 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 @@ -247,7 +340,13 @@ extension PlexServer { programs: programs, source: channels.isEmpty ? "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.self ).content.first, let epgIdentifier = dvr.epgIdentifier { - let advertised = await providerFeatures(for: epgIdentifier) - for path in Self.channelPaths( - dvr: dvr, - epgIdentifier: epgIdentifier, - advertised: advertised - ) { + for path in await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier) { report.append("\n=== \(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. public struct ChannelLookup: Sendable { public enum Source: Sendable, Equatable { @@ -402,9 +514,18 @@ public struct ChannelLookup: Sendable { public let channels: [PlexChannel] 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.source = source + self.trace = trace } /// Kurzbeschreibung für die Diagnose. @@ -488,4 +609,6 @@ public struct LiveTVGuide: Sendable { public let programs: [String: [PlexProgram]] /// Woher die Sender kamen — steht in der leeren Ansicht und in der Diagnose. public let source: String + /// Jede abgeklopfte Adresse mit ihrer Antwort, falls nichts gefunden wurde. + public let trace: [ChannelProbe] } diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift index bb77311..08b1b2b 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift @@ -135,13 +135,15 @@ public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable { public let key: String? public let uuid: 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. public let channelMappings: [PlexChannelMapping] public var id: String { uuid ?? key ?? title ?? "tuner" } private enum CodingKeys: String, CodingKey { - case key, uuid, title + case key, uuid, title, deviceId, deviceIdentifier case channelMapping = "ChannelMapping" } @@ -150,6 +152,9 @@ public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable { self.key = try? values.decodeIfPresent(String.self, forKey: .key) self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid) 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 .decodeIfPresent([PlexChannelMapping].self, forKey: .channelMapping) ?? [] } @@ -158,11 +163,13 @@ public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable { key: String? = nil, uuid: String? = nil, title: String? = nil, + deviceIdentifier: String? = nil, channelMappings: [PlexChannelMapping] = [] ) { self.key = key self.uuid = uuid self.title = title + self.deviceIdentifier = deviceIdentifier self.channelMappings = channelMappings } } @@ -236,16 +243,32 @@ public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable { public let key: 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 { case channelIdentifier, title, thumb, key case identifier = "id" - case uuid + case uuid, name, callSign, vcn, number, channelTitle, channelCallSign } public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) - self.title = try values.decodeIfPresent(String.self, forKey: .title) ?? "—" - self.channelIdentifier = try values.decodeIfPresent(String.self, forKey: .channelIdentifier) + // Vier Schreibweisen für denselben Namen, je nach Tunertyp und + // 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.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 } } + /// Ein Wert, der als Zahl oder als Zeichenkette kommen kann. + private static func text( + in values: KeyedDecodingContainer, + 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) { self.id = id self.title = title diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift index 54e6274..fcb5d5b 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift @@ -197,23 +197,23 @@ struct ChannelPathTests { ) @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 // Prozentzeichen landete unverändert im Pfad. - let paths = PlexServer.channelPaths(dvr: dvr, epgIdentifier: "epg:2") - let first = paths[0] + let paths = PlexServer.knownPaths(dvr: dvr, epgIdentifier: "epg:2") + let lineupPath = try #require(paths.first { $0.contains("/lineups/") }) - #expect(first == "/epg:2/lineups/6228f7f2364e02fa5ec8e51c/channels") - #expect(!first.contains("lineup://")) - #expect(!first.contains("#")) - #expect(!first.contains("%")) + #expect(lineupPath == "/epg:2/lineups/6228f7f2364e02fa5ec8e51c/channels") + #expect(!lineupPath.contains("lineup://")) + #expect(!lineupPath.contains("#")) + #expect(!lineupPath.contains("%")) } @Test("Es gibt Rückfalllinien") func offersFallbacks() { // Die Form unterscheidet sich zwischen Serverfassungen, und dieser // 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.contains("/livetv/dvrs/1/channels")) } @@ -221,7 +221,7 @@ struct ChannelPathTests { @Test("Ohne Senderliste bleiben die übrigen Wege") func withoutLineup() { 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.allSatisfy { !$0.contains("lineups//") }) } diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 43e9f3a..fb569d7 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -91,6 +91,63 @@ struct LiveTVGuideTests { #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") func keepsTuneKey() async throws { let server = server(routes: [