diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 057ebb4..4a2164c 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -401,27 +401,27 @@ extension PlexServer { ].joined(separator: ", ") )] - // Zuerst der Weg über den Empfänger, mit **seiner** Sendernummer. - // Alles bisher Probierte sprach den Empfänger in der Sprache des - // Programmführers an; das musste 404 geben. - if let deviceChannel, let dvr { - for path in [ - "/livetv/dvrs/\(dvr.key)/channels/\(deviceChannel)", - "/livetv/sessions/\(session)/\(deviceChannel)", - ] { - attempts += 1 - let outcome = await attemptStream( - path: path, - ratingKey: channel.id, - mediaIndex: -1, - includeProfile: true, - maximumBitrate: maximumBitrate, - session: session - ) - trace.append(outcome.probe) - if let url = outcome.url { - return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) - } + // Der Weg, den Plex' eigener Client geht. Nachgelesen in seinem + // Quelltext, nicht erraten: + // + // POST /livetv/dvrs/{dvr}/channels/{kennung}/tune + // X-Plex-Session-Identifier: + // + // **POST**, nicht GET. Genau diesen Pfad hatte ich seit mehreren + // Runden richtig — und jedes Mal mit der falschen Methode abgefragt. + // Ein GET darauf antwortet mit 404, und das sah aus wie „gibt es + // nicht" statt wie „nicht so". + if let dvr { + attempts += 1 + let outcome = await tuneThroughDVR( + channel: channel, + dvr: dvr, + session: session, + maximumBitrate: maximumBitrate + ) + trace.append(contentsOf: outcome.probes) + if let url = outcome.url { + return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) } } @@ -634,6 +634,83 @@ extension PlexServer { return lines.joined(separator: "\n") } + /// Belegt einen Tuner über den Empfänger und liefert den Strom. + /// + /// Die Antwort auf das POST trägt den Vorgang mitsamt Metadaten-Eintrag, + /// und **dort** steht die abspielbare Teil-Adresse — nicht im Raster und + /// nicht bei der Sendung. Danach ist es gewöhnliches Umrechnen. + private func tuneThroughDVR( + channel: PlexChannel, + dvr: PlexDVR, + session: String, + maximumBitrate: Int? + ) async -> (url: URL?, probes: [ChannelProbe]) { + let identifier = channel.id.addingPercentEncoding( + withAllowedCharacters: .uriComponentAllowed + ) ?? channel.id + let path = "/livetv/dvrs/\(dvr.key)/channels/\(identifier)/tune" + + let data: Data + do { + data = try await client.send(PlexRequest( + method: .post, + path: path, + additionalHeaders: ["X-Plex-Session-Identifier": session], + timeout: .seconds(30), + isPathEncoded: true + )) + } catch { + return (nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))]) + } + + guard let part = Self.firstPartKey(in: data) else { + return (nil, [ChannelProbe( + path: "POST \(path)", + result: "belegt, aber ohne Teil-Adresse — \(Self.containerSummary(data))" + )]) + } + + var probes = [ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)")] + let outcome = await attemptStream( + path: part, + ratingKey: channel.id, + mediaIndex: -1, + includeProfile: true, + maximumBitrate: maximumBitrate, + session: session + ) + probes.append(outcome.probe) + return (outcome.url, probes) + } + + /// Sucht die erste abspielbare Teil-Adresse irgendwo in einer Antwort. + /// + /// Bewusst suchend statt an einer festen Stelle lesend: der Vorgang + /// liegt je nach Serverfassung unterschiedlich tief verschachtelt, und + /// ein falscher Pfad in die Antwort hinein sähe wieder aus wie „nichts da". + static func firstPartKey(in data: Data) -> String? { + guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil } + return firstPartKey(in: root) + } + + private static func firstPartKey(in value: Any) -> String? { + if let dictionary = value as? [String: Any] { + if let parts = dictionary["Part"] as? [[String: Any]], + let key = parts.first?["key"] as? String { + return key + } + for nested in dictionary.values { + if let found = firstPartKey(in: nested) { return found } + } + } + if let array = value as? [Any] { + for nested in array { + if let found = firstPartKey(in: nested) { return found } + } + } + return nil + } + /// Baut eine Stromadresse und ruft sie einmal ab. /// /// Getrennte Methode statt einer Hilfsfunktion in `tune`: eine solche @@ -1143,3 +1220,16 @@ extension PlexServer { text.replacingOccurrences(of: token, with: "«token»") } } + + +extension CharacterSet { + /// Was `encodeURIComponent` unangetastet lässt. + /// + /// Nicht `.alphanumerics`: das kodiert auch den Bindestrich, und + /// Senderkennungen bestehen aus zwei Hälften mit einem dazwischen. Aus + /// `6228f7f2-5fc705fa` würde `6228f7f2%2D5fc705fa`, und der Server fände + /// den Sender nicht. + static let uriComponentAllowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()" + ) +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 1529d9c..e132c8e 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -295,6 +295,48 @@ struct LiveTVGuideTests { #expect(path == "/livetv/sessions/xyz/stream") } + @Test("Der Tuner wird per POST belegt, und die Antwort trägt den Strom") + func tunesThroughDVRWithPost() async throws { + // Nachgelesen im Quelltext von Plex' eigenem Client: + // POST /livetv/dvrs/{dvr}/channels/{kennung}/tune + // Genau diesen Pfad hatte ich seit Runden richtig — und jedes Mal + // mit GET abgefragt. Die Antwort darauf ist 404, und das sah aus wie + // „gibt es nicht" statt wie „nicht so". + let tuned = """ + {"MediaContainer":{"size":1,"MediaSubscription":[{"key":"77", + "MetadataItem":{"ratingKey":"live-77","key":"/livetv/sessions/abc","type":"clip", + "title":"blue ZOOM HD", + "Media":[{"id":9,"Part":[{"id":9,"key":"/livetv/sessions/abc/parts/9"}]}]}}]}} + """ + let server = server(routes: [ + "/channels/6228f7f2-5fc705fa/tune": tuned, + "/start.m3u8": "#EXTM3U", + ]) + let channel = PlexChannel(id: "6228f7f2-5fc705fa", title: "blue ZOOM HD", key: "1") + let dvr = PlexDVR(key: "2") + + let result = await server.tune(channel: channel, dvr: dvr, 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/abc/parts/9") + #expect(result.trace.contains { $0.path.hasPrefix("POST ") }) + } + + @Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt") + func findsPartAtAnyDepth() throws { + // Der Vorgang liegt je nach Serverfassung unterschiedlich tief + // verschachtelt. Ein fest verdrahteter Weg hinein sähe wieder aus + // wie „nichts da". + let json = Data(""" + {"MediaContainer":{"Tief":{"Tiefer":[{"Media":[{"Part":[{"key":"/gefunden"}]}]}]}}} + """.utf8) + + #expect(PlexServer.firstPartKey(in: json) == "/gefunden") + #expect(PlexServer.firstPartKey(in: Data("{}".utf8)) == nil) + } + @Test("Die Zuordnung übersetzt zwischen Programmführer und Empfänger") func translatesChannelKeyToDeviceChannel() { // Der Programmführer kennt den Sender als `6228f7f2…-5fc706…`, der