diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 7bf5f52..f903fbe 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -48,41 +48,68 @@ extension PlexServer { public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup { let enabled = dvr.enabledChannelKeys - // `??` mit einem asynchronen Aufruf rechts geht nicht — die - // Kurzschlussauswertung ist nicht nebenläufigkeitsfähig. var identifier = dvr.epgIdentifier if identifier == nil { identifier = await electronicProgramGuideIdentifier() } - - guard let epgIdentifier = identifier, let lineup = dvr.lineups.first else { + guard let epgIdentifier = identifier else { return ChannelLookup(channels: [], source: .noLineup) } - do { - let container = try await client.send( - PlexRequest( - path: "/\(epgIdentifier)/lineups/\(lineup.id)/channels", - timeout: .seconds(30) - ), - as: PlexMediaContainer.self - ) + var lastFailure: String? + for path in Self.channelPaths(dvr: dvr, epgIdentifier: epgIdentifier) { + 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) } + // 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) } - return ChannelLookup( - channels: channels, - source: channels.isEmpty - ? .lineupWithoutMatches(available: container.content.count, enabled: enabled.count) - : .lineup(available: container.content.count) - ) - } catch { - return ChannelLookup(channels: [], source: .failed(String(describing: error))) + if channels.isEmpty { + return ChannelLookup( + channels: [], + source: .lineupWithoutMatches( + available: container.content.count, + enabled: enabled.count + ) + ) + } + return ChannelLookup( + channels: channels, + source: .lineup(available: container.content.count, path: path) + ) + } catch { + lastFailure = "\(path): \(error)" + } } + + return ChannelLookup(channels: [], source: .failed(lastFailure ?? "kein Weg lieferte Sender")) + } + + /// 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) -> [String] { + var paths: [String] = [] + if let lineup = dvr.lineups.first { + paths.append("/\(epgIdentifier)/lineups/\(lineup.pathIdentifier)/channels") + } + paths.append("/\(epgIdentifier)/channels") + paths.append("/livetv/dvrs/\(dvr.key)/channels") + return paths } /// Die Kennung des Programmanbieters. @@ -182,13 +209,13 @@ extension PlexServer { PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)), as: PlexMediaContainer.self ).content.first, - let epgIdentifier = dvr.epgIdentifier, - let lineup = dvr.lineups.first { - let path = "/\(epgIdentifier)/lineups/\(lineup.id)/channels" - report.append("\n=== \(path) ===") - report.append(await structure(ofPath: path)) + let epgIdentifier = dvr.epgIdentifier { + for path in Self.channelPaths(dvr: dvr, epgIdentifier: epgIdentifier) { + report.append("\n=== \(path) ===") + report.append(await structure(ofPath: path)) + } } else { - report.append("\n=== Senderliste ===\nKeine Kennung oder Senderliste am Empfänger") + report.append("\n=== Senderliste ===\nKeine Kennung des Programmanbieters am Empfänger") } return report.joined(separator: "\n") @@ -246,8 +273,8 @@ extension PlexServer { /// Woher die Senderliste kam. public struct ChannelLookup: Sendable { public enum Source: Sendable, Equatable { - /// Aus der Senderliste des Programmanbieters. - case lineup(available: Int) + /// Aus der Senderliste des Programmanbieters, über diesen Pfad. + case lineup(available: Int, path: String) /// Die Senderliste kam an, aber keiner passte zur Zuordnung. case lineupWithoutMatches(available: Int, enabled: Int) /// Am Empfänger hängt keine Senderliste oder kein Programmanbieter. @@ -266,8 +293,8 @@ public struct ChannelLookup: Sendable { /// Kurzbeschreibung für die Diagnose. public var description: String { switch source { - case .lineup(let available): - "\(channels.count) von \(available) Sendern eingeschaltet" + case .lineup(let available, let path): + "\(channels.count) von \(available) Sendern · \(path)" case .lineupWithoutMatches(let available, let enabled): "Senderliste hat \(available) Sender, \(enabled) sind zugeordnet — keiner passte zusammen" case .noLineup: diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift index f6727c8..a11f7ea 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift @@ -82,9 +82,28 @@ public struct PlexDVR: Sendable, Equatable, Decodable, Identifiable { /// Eine Senderliste des Anbieters — bei Kabel oder IPTV oft mehrere hundert. public struct PlexLineup: Sendable, Equatable, Decodable, Identifiable { + /// Die Kennung, wie Plex sie liefert. + /// + /// Trotz des Namens keine Kennung, sondern eine vollständige Adresse: + /// + /// lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV + /// + /// Mit Schema, Doppelschrägstrich, Prozentzeichen und einem Anhang hinter + /// der Raute. Roh in einen Pfad gesetzt ergibt das zwangsläufig 404 — + /// genau daran scheiterte die Senderabfrage. public let id: String public let title: String? + /// Der nackte Teil in der Mitte, als Pfadabschnitt verwendbar. + /// + /// Aus der Adresse oben wird `6228f7f2364e02fa5ec8e51c`. + public var pathIdentifier: String { + // Der Anhang hinter der Raute ist der Anzeigename und gehört nicht in + // die Adresse. + let withoutFragment = id.split(separator: "#", maxSplits: 1).first.map(String.init) ?? id + return withoutFragment.split(separator: "/").last.map(String.init) ?? withoutFragment + } + public init(id: String, title: 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 d11bca6..54e6274 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift @@ -153,3 +153,76 @@ struct ChannelMatchingTests { #expect(!channel.matches(anyOf: ["srf1", "srf2"])) } } + +@Suite("Kennung der Senderliste") +struct LineupIdentifierTests { + + @Test("Aus der vollen Adresse wird der nackte Teil") + func extractsBareIdentifier() { + // Genau der Wert von einem echten Server. Roh in den Pfad gesetzt + // ergab er 404: Schema, Doppelschrägstrich, Prozentzeichen und ein + // Anhang hinter der Raute. + let lineup = PlexLineup( + id: "lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV%20%28521%20channels%29" + ) + #expect(lineup.pathIdentifier == "6228f7f2364e02fa5ec8e51c") + } + + @Test("Eine Adresse ohne Anhang funktioniert auch") + func withoutFragment() { + let lineup = PlexLineup(id: "lineup://tv.plex.providers.epg.cloud/abc123") + #expect(lineup.pathIdentifier == "abc123") + } + + @Test("Eine bereits nackte Kennung bleibt unverändert") + func alreadyBare() { + // Nicht jeder Anbieter liefert eine Adresse — manche geben die + // Kennung direkt. + #expect(PlexLineup(id: "abc123").pathIdentifier == "abc123") + } + + @Test("Ein leerer Anhang stört nicht") + func emptyFragment() { + #expect(PlexLineup(id: "lineup://host/abc123#").pathIdentifier == "abc123") + } +} + +@Suite("Adressen der Senderliste") +struct ChannelPathTests { + private let dvr = PlexDVR( + key: "1", + lineups: [PlexLineup( + id: "lineup://tv.plex.providers.epg.cloud/6228f7f2364e02fa5ec8e51c#Swisscom%20Blue%20TV" + )] + ) + + @Test("Die Kennung der Senderliste wird ausgepackt, nicht roh eingesetzt") + func usesBareIdentifier() { + // 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] + + #expect(first == "/epg:2/lineups/6228f7f2364e02fa5ec8e51c/channels") + #expect(!first.contains("lineup://")) + #expect(!first.contains("#")) + #expect(!first.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") + #expect(paths.count >= 2) + #expect(paths.contains("/livetv/dvrs/1/channels")) + } + + @Test("Ohne Senderliste bleiben die übrigen Wege") + func withoutLineup() { + let bare = PlexDVR(key: "7") + let paths = PlexServer.channelPaths(dvr: bare, epgIdentifier: "epg:2") + #expect(!paths.isEmpty) + #expect(paths.allSatisfy { !$0.contains("lineups//") }) + } +}