diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 9a5d849..5e53405 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -11,6 +11,8 @@ struct LiveTVView: View { @Environment(NetworkConditions.self) private var network @State private var availability: LiveTVAvailability? + /// Der Empfänger, über den eingeschaltet wird. + @State private var dvr: PlexDVR? @State private var channels: [PlexChannel] = [] /// Woher die Senderliste kam — steht in der leeren Ansicht. @State private var channelSource: String? @@ -175,6 +177,7 @@ struct LiveTVView: View { self.availability = availability guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return } + self.dvr = dvr // Sender und Programm kommen zusammen: die Senderliste des Anbieters // antwortet auf manchen Servern gar nicht, und dann tragen die @@ -204,6 +207,9 @@ struct LiveTVView: View { let result = await server.tune( channel: channel, program: running, + // Der Programmanbieter kennt keinen Weg zum Einschalten — der + // Empfänger schon, und ohne ihn bleibt der halbe Suchraum zu. + dvr: dvr, maximumBitrate: policy.maximumBitrate ) diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift index 9da57f0..0a184e3 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift @@ -31,18 +31,23 @@ public struct PlexRequest: Sendable, Equatable { /// summiert es sich über mehrere Adressen. public var timeout: Duration + /// Ob `path` bereits kodiert ist und unverändert übernommen werden soll. + public let isPathEncoded: Bool + public init( method: Method = .get, path: String, query: [URLQueryItem] = [], additionalHeaders: [String: String] = [:], - timeout: Duration = .seconds(20) + timeout: Duration = .seconds(20), + isPathEncoded: Bool = false ) { self.method = method self.path = path self.query = query self.additionalHeaders = additionalHeaders self.timeout = timeout + self.isPathEncoded = isPathEncoded } /// Setzt die Anfrage gegen einen Host zusammen. @@ -52,11 +57,17 @@ public struct PlexRequest: Sendable, Equatable { token: String? ) throws(PlexError) -> URLRequest { guard var components = URLComponents( - url: baseURL.appending(path: path), + url: baseURL.appending(path: isPathEncoded ? "/" : path), resolvingAgainstBaseURL: false ) else { throw .transport(description: "Ungültige Basisadresse: \(baseURL)") } + // Adressen beim Programmanbieter enthalten bereits kodierte Zeichen + // (`plex%3A%2F%2Fmovie%2F…`). `appending(path:)` kodiert das Prozent + // ein zweites Mal, und der Server findet nichts mehr. + if isPathEncoded { + components.percentEncodedPath = path + } components.queryItems = query.isEmpty ? nil : query guard let url = components.url else { diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index bacb819..b474584 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -376,15 +376,41 @@ extension PlexServer { public func tune( channel: PlexChannel, program: PlexProgram? = nil, + dvr: PlexDVR? = nil, maximumBitrate: Int? ) async -> LiveTuneResult { let session = UUID().uuidString var trace: [ChannelProbe] = [] var attempts = 0 - // Zuerst die Sendung selbst. Auf diesem Server antwortet jede Form von - // `/livetv/sessions/…` mit 404 — den Tuner belegt er offenbar von - // sich aus, sobald die Sendung umgerechnet werden soll. + // Die Sendung erst nachschlagen. Das Raster nennt keine abspielbare + // Adresse — die Einzelabfrage beim Programmanbieter womöglich schon. + // Ihre Adresse blind an den Transcoder zu geben, hat dreimal eine + // nackte 400 ergeben: eine Sendung beim Cloud-Programmführer hat + // keine Datei, die sich umrechnen ließe. + if let metadataKey = program?.metadataKey { + let lookup = await programMedia(atKey: metadataKey) + trace.append(ChannelProbe(path: "\(metadataKey) (nachgeschlagen)", result: lookup.summary)) + + for part in lookup.parts { + guard attempts < 14 else { break } + attempts += 1 + + let outcome = await attemptStream( + path: part, + ratingKey: lookup.ratingKey ?? channel.id, + mediaIndex: 0, + includeProfile: true, + maximumBitrate: maximumBitrate, + session: session + ) + trace.append(outcome.probe) + if let url = outcome.url { + return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + } + } + } + if let metadataKey = program?.metadataKey { // Ohne Profil als dritter Versuch: die Profilangaben stecken // voller `&` und `=`, die in einem Abfragewert mehrdeutig sind. @@ -421,10 +447,13 @@ extension PlexServer { )) } - for key in Self.sessionKeys(channel: channel, program: program) { - let sessionPath = "/livetv/sessions/\(session)" - let openPath = "\(sessionPath)/\(key)" - + let sessionPath = "/livetv/sessions/\(session)" + for openPath in Self.openPaths( + channel: channel, + program: program, + dvr: dvr, + sessionPath: sessionPath + ) { let item: PlexItem? do { item = try await client.send( @@ -447,7 +476,7 @@ extension PlexServer { trace.append(ChannelProbe(path: openPath, result: "Sitzung offen")) for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) { - guard attempts < 10 else { break } + guard attempts < 14 else { break } attempts += 1 let outcome = await attemptStream( @@ -471,6 +500,39 @@ extension PlexServer { return LiveTuneResult(url: nil, sessionIdentifier: session, trace: trace) } + /// Schlägt eine Sendung beim Programmanbieter nach. + /// + /// Liefert die abspielbaren Teil-Adressen und eine Kurzbeschreibung + /// dessen, was zurückkam. Die Beschreibung steht auch dann in der Spur, + /// wenn nichts Abspielbares dabei war — sonst bliebe nach einem + /// Fehlschlag wieder nur Raten, wo die Adresse steckt. + private func programMedia(atKey key: String) async -> (parts: [String], ratingKey: String?, summary: String) { + let data: Data + do { + data = try await client.send(PlexRequest( + path: key, + query: [URLQueryItem(name: "includeChildren", value: "1")], + timeout: .seconds(20), + isPathEncoded: true + )) + } catch { + return ([], nil, Self.shortDescription(of: error)) + } + + guard let item = (try? PlexResponse.decode(PlexMediaContainer.self, from: data))? + .content.first + else { + return ([], nil, Self.containerSummary(data)) + } + + let parts = item.media.flatMap(\.parts).compactMap(\.key) + let summary = parts.isEmpty + ? "\(item.media.count) Fassungen, keine Teil-Adresse — \(Self.containerSummary(data))" + : "\(parts.count) Teil-Adressen" + + return (parts, item.ratingKey, summary) + } + /// Baut eine Stromadresse und ruft sie einmal ab. /// /// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche @@ -549,6 +611,37 @@ extension PlexServer { } } + /// Wo sich ein Tuner belegen lässt. + /// + /// Der Programmanbieter gibt unter `/media/providers` keinen einzigen + /// Endpunkt zum Einschalten an — er kann Inhalt, Metadaten und + /// Programmraster, mehr nicht. Der Empfänger muss also über den DVR + /// belegt werden, und dessen Adressen stehen hier neben den + /// Sitzungsformen, weil dieser Teil der Schnittstelle nirgends + /// beschrieben ist. + static func openPaths( + channel: PlexChannel, + program: PlexProgram?, + dvr: PlexDVR?, + sessionPath: String + ) -> [String] { + var paths: [String] = [] + + if let dvr { + for key in [channel.gridKey, channel.key, channel.channelIdentifier, channel.id] + .compactMap({ $0 }) + .map({ $0.split(separator: "/").last.map(String.init) ?? $0 }) { + paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)/tune") + paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)") + } + } + + paths += sessionKeys(channel: channel, program: program).map { "\(sessionPath)/\($0)" } + + var seen = Set() + return paths.filter { seen.insert($0).inserted } + } + /// Unter welchen Schlüsseln sich eine Live-Sitzung eröffnen lässt. /// /// Der Raster-Schlüssel zuerst: er bezeichnet den Sender genau so, wie diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 5da8a8b..86e89d2 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -239,6 +239,39 @@ struct LiveTVGuideTests { #expect(keys.contains("5")) } + @Test("Die nachgeschlagene Sendung liefert die abspielbare Adresse") + func usesPartFromLookup() async throws { + // Das Raster nennt keine Teil-Adresse, die Einzelabfrage schon. Sie + // blind an den Transcoder zu geben, ergab dreimal eine nackte 400: + // eine Sendung beim Cloud-Programmführer hat keine Datei. + let metadata = """ + {"MediaContainer":{"size":1,"Metadata":[ + {"ratingKey":"epg-1","key":"/tv.plex.providers.epg.cloud:2/metadata/x","type":"movie", + "title":"Tagesschau", + "Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/xyz/stream"}]}]} + ]}} + """ + let server = server(routes: [ + "/metadata/plex%3A%2F%2Fmovie%2Fabc": metadata, + "/start.m3u8": "#EXTM3U", + ]) + let channel = PlexChannel(id: "5", title: "SRF 1 HD", key: "5") + let program = PlexProgram( + id: "p1", + title: "Tagesschau", + startsAt: .now, + endsAt: .now.addingTimeInterval(1800), + metadataKey: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc" + ) + + let result = await server.tune(channel: channel, program: program, maximumBitrate: nil) + let url = try #require(result.url) + let path = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "path" }?.value) + + #expect(path == "/livetv/sessions/xyz/stream") + } + @Test("Die Sender des Tuners stehen unter DeviceChannel") func readsDeviceChannelKey() async throws { // Der Server lieferte 278 Sender, die Hülle verwarf sie: sie kannte