import Foundation /// Was beim Live-TV herauskommen kann. public enum LiveTVAvailability: Sendable, Equatable { /// Es gibt einen Empfänger und Sender. case available(dvrs: [PlexDVR]) /// Der Server läuft, hat aber keinen Empfänger eingerichtet. /// /// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte /// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt /// einen Fehler zu zeigen. case notConfigured case failed(message: String) } extension PlexServer { /// Prüft, ob der Server überhaupt Live-TV kann. public func liveTVAvailability() async -> LiveTVAvailability { do { let container = try await client.send( PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)), as: PlexMediaContainer.self ) return container.content.isEmpty ? .notConfigured : .available(dvrs: container.content) } catch PlexError.notFound { // Ältere Serverfassungen kennen den Endpunkt gar nicht. Das ist // dasselbe Ergebnis wie „kein Empfänger": es gibt nichts zu sehen. return .notConfigured } catch { return .failed(message: String(describing: error)) } } /// Die Sender eines Empfängers — gefunden, nicht geraten. /// /// 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. /// /// Deshalb wird sie jetzt gesucht statt geraten, in einem Durchgang und /// gleichzeitig: /// /// 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 { var identifier = dvr.epgIdentifier if identifier == nil { identifier = await electronicProgramGuideIdentifier() } guard let epgIdentifier = identifier else { return ChannelLookup(channels: [], source: .noLineup, trace: []) } let paths = await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier) var found: [Int: [PlexChannel]] = [:] var trace: [Int: ChannelProbe] = [:] // 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) } } for await (index, channels, probe) in group { found[index] = channels trace[index] = probe } } 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) } /// 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] = [] // 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) } } // 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)") } } // 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( PlexRequest(path: "/media/providers"), as: PlexMediaContainer.self ) else { return [] } return container.content.first { $0.identifier == identifier }?.features ?? [] } /// Die Kennung des Programmanbieters. /// /// Sie wechselt je nach Server und Einrichtung (etwa /// `tv.plex.providers.epg.cloud:2`) und steckt in der Anbieterliste. Fest /// verdrahtet wäre sie auf der Hälfte der Server falsch. public func electronicProgramGuideIdentifier() async -> String? { guard let container = try? await client.send( PlexRequest(path: "/media/providers"), as: PlexMediaContainer.self ) else { return nil } return container.content.first { $0.identifier.contains("epg") }?.identifier } /// Das Programmraster für einen Zeitraum, mit Sendern. /// /// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender /// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden /// braucht niemand beim Umschalten. public func guide( providerIdentifier: String, from start: Date, to end: Date ) async throws(PlexError) -> [PlexGuideEntry] { let container = try await client.send( PlexRequest( path: "/\(providerIdentifier)/grid", query: [ // Ohne Typfilter. `type=1` steht bei Plex für Filme, und // im Programm laufen auch Folgen, Sport und Nachrichten — // der Filter hat das Raster leer zurückgegeben. URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))), URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))), ], timeout: .seconds(30) ), as: PlexMediaContainer.self ) return container.content.compactMap { entry in // Sender und Sendung stammen aus demselben Eintrag — deshalb // passen die Kennungen zueinander. Getrennt geholt taten sie das // nicht, und das Programm landete in keiner Spalte. let channel = entry.channel guard let program = entry.program(channelID: channel?.id) else { return nil } return PlexGuideEntry(channel: channel, program: program) } } /// Senderliste und Programm in einem Zug. /// /// Der Weg über die Senderliste des Anbieters ist der erste Versuch; wo /// er greift, sind Logos und Nummern vollständiger. Wo er nicht greift — /// und bei einem Server mit Swisscom-Liste tut er das nicht, alle /// bekannten Adressen antworten mit 404 — tragen die Rastereinträge die /// Sender selbst. Die Weboberfläche von Plex zeichnet ihren Guide genau /// daraus. public func liveTVGuide( dvr: PlexDVR, hours: Int = 6 ) async -> LiveTVGuide { let lookup = await channels(inDVR: dvr) var identifier = dvr.epgIdentifier if identifier == nil { identifier = await electronicProgramGuideIdentifier() } guard let epgIdentifier = identifier else { return LiveTVGuide( channels: lookup.channels, programs: [:], source: lookup.description, trace: lookup.trace ) } let now = Date() let entries = (try? await guide( providerIdentifier: epgIdentifier, from: now.addingTimeInterval(-3_600), to: now.addingTimeInterval(TimeInterval(hours) * 3_600) )) ?? [] let programs = Dictionary(grouping: entries.compactMap { entry in entry.program.channelID.map { (key: $0, program: entry.program) } }, by: \.key).mapValues { pairs in pairs.map(\.program).sorted { $0.startsAt < $1.startsAt } } // Aus dem Raster: jeden Sender einmal, nach Sendernummer sortiert. var seen = Set() let fromGrid = entries .compactMap(\.channel) .filter { seen.insert($0.id).inserted } .sorted { ($0.channelIdentifier ?? "") < ($1.channelIdentifier ?? "") } // Das Raster hat Vorrang vor der Senderliste des Tuners, auch wenn // beide antworten: nur dort gehören Sender und Programm zusammen, // stehen Logos dabei, und es enthält ohnehin nur die eingeschalteten // Sender. Die Tunerliste führt alle 278, die meisten davon aus. if !fromGrid.isEmpty { return LiveTVGuide( channels: fromGrid, programs: programs, source: "Raster: \(fromGrid.count) Sender", trace: lookup.trace ) } if !lookup.channels.isEmpty { return LiveTVGuide( channels: lookup.channels, programs: programs, source: lookup.description, trace: lookup.trace ) } return LiveTVGuide( channels: [], programs: programs, source: "Raster: keine Sender (\(lookup.description))", trace: lookup.trace + [ChannelProbe( path: "/\(epgIdentifier)/grid", result: fromGrid.isEmpty ? "\(entries.count) Sendungen, kein Sender benannt" : "\(fromGrid.count) Sender" )] ) } /// Schaltet einen Sender ein. /// /// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen /// Transportstrom, den kein iPhone direkt abspielt. /// /// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen /// hatte: **der Server muss den Tuner belegen**. Erst danach gibt es /// etwas umzurechnen. /// /// Weder der Schlüssel für die Sitzung noch die Adresse, die anschließend /// umzurechnen ist, sind für diesen Teil der Plex-Schnittstelle /// beschrieben. Deshalb wird jede Kombination **bis zum Ende geprüft**: /// die Adresse wird einmal selbst abgerufen, ohne Kopfzeilen — genau so, /// wie der Systemplayer es tut. Erst was dabei mit 200 antwortet, geht an /// den Player. Vorher galt schon eine gebaute Adresse als Erfolg, und der /// Fehler tauchte erst im Player auf, als „HTTP 400" ohne Begründung. public func tune( channel: PlexChannel, program: PlexProgram? = nil, dvr: PlexDVR? = nil, maximumBitrate: Int? ) async -> LiveTuneResult { let session = UUID().uuidString var attempts = 0 // Womit überhaupt gearbeitet wird. Ich habe den Raster-Schlüssel // benutzt, ohne ihn je gesehen zu haben — und im Suchraum nur seinen // letzten Abschnitt verwendet. // Die Sendernummer am Empfänger. Der Programmführer kennt den Sender // als `6228f7f2…-5fc706…`, der Tuner als `300` — ohne diese // Übersetzung antwortet der Empfänger auf alles mit 404. let deviceChannel = dvr?.deviceChannel(forChannelKey: channel.id) var trace: [ChannelProbe] = [ChannelProbe( path: "Sender-Schlüssel", result: [ "gridKey=\(channel.gridKey ?? "–")", "key=\(channel.key ?? "–")", "id=\(channel.id)", "nummer=\(channel.channelIdentifier ?? "–")", "amEmpfänger=\(deviceChannel ?? "–")", ].joined(separator: ", ") )] // 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, subscriptionKey: outcome.subscription, trace: trace ) } // Belegt, aber nicht abspielbar: den Tuner sofort wieder // freigeben, statt ihn für nichts zu halten. if let subscription = outcome.subscription { await endLiveSession(subscriptionKey: subscription) // Und hier aufhören. Dass das Belegen klappt, beantwortet die // Frage nach dem Weg — die übrigen zwanzig Abfragen sind dann // nur noch Lärm im Bericht und Last für den Server. return LiveTuneResult( url: nil, sessionIdentifier: session, subscriptionKey: nil, trace: trace ) } } // Der Raster-Schlüssel als Adresse, unverkürzt. Bisher habe ich nur // seinen letzten Abschnitt als Sitzungsschlüssel probiert — als // vollständige Adresse war er nie an der Reihe. if let gridKey = channel.gridKey, gridKey.hasPrefix("/") { attempts += 1 let outcome = await attemptStream( path: gridKey, 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) } } // 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. // Trägt es ohne, liegt es daran — und das steht dann in der Spur. // Doppelt kodiert zuerst: der Server dekodiert den Abfragewert // einmal, bevor er ihn als Pfad liest. Nur so kommt bei ihm // wieder `plex%3A%2F%2Fepisode%2F…` an statt `plex://episode/…`. let variants: [(index: Int, profile: Bool)] = [(0, true), (-1, true), (0, false)] for variant in variants { attempts += 1 let outcome = await attemptStream( path: metadataKey, ratingKey: channel.id, mediaIndex: variant.index, includeProfile: variant.profile, maximumBitrate: maximumBitrate, session: session ) trace.append(outcome.probe) if let url = outcome.url { return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) } } } // Was der Programmanbieter an Endpunkten angibt — für den Fall, dass // dieser Server den Tuner doch selbst belegt haben will. Steht mit in // der Spur, damit auch ein Fehlschlag etwas verrät. if let epgIdentifier = program?.metadataKey? .split(separator: "/").first.map(String.init) { let features = await providerFeatures(for: epgIdentifier) trace.append(ChannelProbe( path: "Angebotene Endpunkte", result: features.isEmpty ? "keine" : features.map { "\($0.type)=\($0.key ?? "–")" }.joined(separator: ", ") )) } 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( PlexRequest( path: openPath, query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)], timeout: .seconds(30) ), as: PlexMediaContainer.self ).content.first } catch { trace.append(ChannelProbe(path: openPath, result: Self.shortDescription(of: error))) continue } guard let item else { trace.append(ChannelProbe(path: openPath, result: "leer")) continue } trace.append(ChannelProbe(path: openPath, result: "Sitzung offen")) for candidate in Self.streamCandidates(item: item, sessionPath: sessionPath) { guard attempts < 14 else { break } attempts += 1 let outcome = await attemptStream( path: candidate.path, ratingKey: item.ratingKey, mediaIndex: candidate.mediaIndex, includeProfile: true, maximumBitrate: maximumBitrate, session: session ) trace.append(outcome.probe) if let url = outcome.url { return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) } } } // Kein Weg hat mit 200 geantwortet. Den Transcoder anhalten, sonst // rechnet der Server für eine Wiedergabe weiter, die es nicht gibt. await stopTranscode(sessionIdentifier: session) 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) guard parts.isEmpty else { return (parts, item.ratingKey, "\(parts.count) Teil-Adressen") } // Keine Teil-Adresse — dann zählt, was stattdessen dort steht. // „1 Fassung, keine Teil-Adresse" beendet nichts; der Aufbau der // Antwort schon, denn irgendwo muss der Server sagen, wie diese // Sendung abzuspielen ist. return ([], item.ratingKey, "ohne Teil-Adresse\n" + Self.mediaOutline(data)) } /// Der Aufbau des Medien-Blocks einer Antwort, kurz gehalten. static func mediaOutline(_ data: Data) -> String { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let container = object["MediaContainer"] as? [String: Any], let first = (container["Metadata"] as? [[String: Any]])?.first else { return Self.containerSummary(data) } var lines: [String] = [] if let media = (first["Media"] as? [[String: Any]])?.first { lines += media.keys.sorted().map { key in let value = media[key]! if let text = value as? String { return " Media.\(key) = \(text)" } if let number = value as? NSNumber { return " Media.\(key) = \(number)" } if let array = value as? [Any] { return " Media.\(key): \(array.count) Einträge" } return " Media.\(key): \(type(of: value))" } } else { lines.append(" kein Media-Block") } // Und die Kurzform der Sendung selbst — dort stehen `key` und `guid`. for key in ["key", "guid", "ratingKey", "type", "live", "onAir"] where first[key] != nil { lines.append(" \(key) = \(first[key]!)") } 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?, subscription: String?, 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, nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))]) } // Die Kennung des Vorgangs. Ohne sie bleibt der Tuner nach dem // Zusehen belegt, und bei vier Tunern ist der fünfte Sender dann // nicht mehr einzuschalten. let subscription = Self.subscriptionKey(in: data) guard let part = Self.firstPartKey(in: data) else { return (nil, subscription, [ChannelProbe( path: "POST \(path)", result: "belegt, aber ohne Teil-Adresse — \(Self.containerSummary(data))" )]) } // Die ganze Antwort, nicht nur das, wonach ich gesucht habe. Ich // habe sie mehrfach durchsucht und dabei übersehen, was sonst noch // darin steht — Behälter, Teil-Kennungen, vielleicht eine fertige // Transcode-Sitzung. var probes = [ ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)"), ChannelProbe( path: "Antwort auf das Belegen", result: redacted(String(decoding: data.prefix(900), as: UTF8.self)) ), ] // Abwarten, bis der Empfänger abgestimmt hat. Er meldet // `status: inprogress` und `currentSize: 0` — es gibt in dem Moment // noch keinen Strom, und jede Frage danach musste scheitern. Ein // Tuner ist ein Stück Hardware; er braucht Sekunden, keine // Millisekunden. if let operation = Self.grabOperationKey(in: data) { probes.append(ChannelProbe( path: "Abstimmen abwarten", result: await waitForGrab(atKey: operation) )) } // Und wie die Sitzung danach aussieht — dorthin zeigt der Pfad, den // der Transcoder bekommt. if let sessionKey = Self.firstSessionKey(in: data) { probes.append(ChannelProbe( path: sessionKey, result: await rawResponse(forPath: sessionKey, limit: 700) )) } // Der Weg des Plex-Clients: die **Sitzung**, die der Server gerade // angelegt hat, wird umgerechnet — nicht die Abspielliste darin und // nicht meine eigene Kennung. Ihr Schlüssel steht in der Antwort. if let sessionKey = Self.firstSessionKey(in: data) { // Voll, ohne Profil, und auf das Nötigste zusammengestrichen. // Eine nackte 400 sagt nicht, welche Angabe sie stört; die drei // Formen nebeneinander sagen es. let variants: [(profile: Bool, minimal: Bool)] = [(true, false), (false, false), (true, true)] for variant in variants { let outcome = await attemptStream( path: sessionKey, ratingKey: channel.id, mediaIndex: 0, includeProfile: variant.profile, minimal: variant.minimal, maximumBitrate: maximumBitrate, session: session ) probes.append(outcome.probe) if let url = outcome.url { return (url, subscription, probes) } } // Dieselben Angaben an den Entscheider. Der Transcoder antwortet // mit HTML und schweigt sich aus; der Entscheider antwortet in // Plex' eigenem Format und nennt den Grund. let request = TranscodeRequest( ratingKey: channel.id, maximumBitrate: maximumBitrate, isLocal: connection.local, sessionIdentifier: session, pathOverride: sessionKey ) if let url = transcodeURL(for: request), let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedQueryItems { probes.append(ChannelProbe( path: "Entscheider zur Sitzung", result: await rawDecision(percentEncodedItems: items) )) } } // Und die Abspielliste unmittelbar. Sie antwortete mit 404 statt 400: // die Adresse stimmt, die Ressource steht nur nicht bereit. Ein // Empfänger ist ein Stück Hardware — er braucht einen Moment, bis er // abgestimmt hat. Deshalb ein zweiter Versuch nach kurzer Pause. if let direct = directStreamURL(forPath: part) { for attempt in 0..<2 { if attempt > 0 { try? await Task.sleep(for: .seconds(2)) } let outcome = await probeStream(url: direct) probes.append(ChannelProbe( path: "\(part) (unmittelbar\(attempt > 0 ? ", nach 2 s" : ""))", result: outcome )) if outcome.hasPrefix("HTTP 200") { return (direct, subscription, probes) } } } let outcome = await attemptStream( path: part, ratingKey: channel.id, mediaIndex: -1, includeProfile: true, maximumBitrate: maximumBitrate, session: session ) probes.append(outcome.probe) return (outcome.url, subscription, probes) } /// Die Adresse, unter der dem Empfänger beim Abstimmen zuzusehen ist. static func grabOperationKey(in data: Data) -> String? { guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil } return firstKey(in: root, matching: "/media/grabbers/operations/") } private static func firstKey(in value: Any, matching prefix: String) -> String? { if let dictionary = value as? [String: Any] { if let key = dictionary["key"] as? String, key.hasPrefix(prefix) { return key } for nested in dictionary.values { if let found = firstKey(in: nested, matching: prefix) { return found } } } if let array = value as? [Any] { for nested in array { if let found = firstKey(in: nested, matching: prefix) { return found } } } return nil } /// Wartet, bis der Empfänger Daten liefert. /// /// Höchstens zwölf Sekunden. Länger zu warten hieße, den Nutzer vor einem /// schwarzen Bild sitzen zu lassen; gar nicht zu warten hieß bisher, nach /// einem Strom zu fragen, den es noch nicht gibt. private func waitForGrab(atKey key: String, limit: Int = 12) async -> String { for second in 1...limit { try? await Task.sleep(for: .seconds(1)) guard let data = try? await client.send( PlexRequest(path: key, timeout: .seconds(10)) ) else { continue } guard let root = try? JSONSerialization.jsonObject(with: data), let size = Self.firstNumber(in: root, forKey: "currentSize") else { continue } if size > 0 { return "nach \(second) s: \(size) Bytes" } } return "nach \(limit) s noch keine Daten" } private static func firstNumber(in value: Any, forKey name: String) -> Int? { if let dictionary = value as? [String: Any] { if let number = dictionary[name] as? NSNumber { return number.intValue } for nested in dictionary.values { if let found = firstNumber(in: nested, forKey: name) { return found } } } if let array = value as? [Any] { for nested in array { if let found = firstNumber(in: nested, forKey: name) { return found } } } return nil } /// Die Antwort des Entscheiders im Wortlaut. private func rawDecision(percentEncodedItems items: [URLQueryItem]) async -> String { guard let baseURL = connection.url, var components = URLComponents( url: baseURL.appending(path: "/video/:/transcode/universal/decision"), resolvingAgainstBaseURL: false ) else { return "Adresse nicht baubar" } components.percentEncodedQueryItems = items guard let url = components.url else { return "Adresse nicht baubar" } var request = URLRequest(url: url) request.timeoutInterval = 20 for (field, value) in identity.headers { request.setValue(value, forHTTPHeaderField: field) } request.setValue(token, forHTTPHeaderField: "X-Plex-Token") do { let (data, response) = try await transport.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let body = String(decoding: data.prefix(700), as: UTF8.self) .trimmingCharacters(in: .whitespacesAndNewlines) return "HTTP \(status): \(redacted(body))" } catch { return Self.shortDescription(of: error) } } /// Der Schlüssel der Live-Sitzung, die der Server angelegt hat. /// /// `/livetv/sessions/1268be73-…` — die Sitzung des **Servers**, nicht die /// Kennung, die der Client für seine Wiedergabe erzeugt. Ich habe /// mehrfach die eigene eingesetzt und mich über 404 gewundert. static func firstSessionKey(in data: Data) -> String? { guard let root = try? JSONSerialization.jsonObject(with: data) else { return nil } return firstSessionKey(in: root) } private static func firstSessionKey(in value: Any) -> String? { if let dictionary = value as? [String: Any] { if let key = dictionary["key"] as? String, key.hasPrefix("/livetv/sessions/"), !key.hasSuffix(".m3u8") { return key } for nested in dictionary.values { if let found = firstSessionKey(in: nested) { return found } } } if let array = value as? [Any] { for nested in array { if let found = firstSessionKey(in: nested) { return found } } } return nil } /// Die Kennung des Aufnahme-/Live-Vorgangs in einer Antwort. static func subscriptionKey(in data: Data) -> String? { guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let container = root["MediaContainer"] as? [String: Any] else { return nil } for name in ["MediaSubscription", "MediaGrabOperation"] { if let entries = container[name] as? [[String: Any]], let key = entries.first?["key"] { return String(describing: key) } if let entry = container[name] as? [String: Any], let key = entry["key"] { return String(describing: key) } } return nil } /// Gibt den Tuner wieder frei. /// /// So macht es Plex' eigener Client: `DELETE /media/subscriptions/{id}`. /// Ohne dieses Signal bleibt der Tuner belegt, bis der Server es von /// selbst merkt — und solange fehlt er beim nächsten Einschalten. public func endLiveSession(subscriptionKey: String) async { _ = try? await client.send(PlexRequest( method: .delete, path: "/media/subscriptions/\(subscriptionKey)", timeout: .seconds(10) )) } /// 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 /// Funktion greift über jede Unterbrechung hinweg auf die veränderlichen /// Werte daneben zu, und genau das lässt der Nebenläufigkeitsprüfer von /// Swift 6 nicht durchgehen. private func attemptStream( path: String, ratingKey: String, mediaIndex: Int, includeProfile: Bool, minimal: Bool = false, maximumBitrate: Int?, session: String ) async -> (url: URL?, probe: ChannelProbe) { let request = TranscodeRequest( ratingKey: ratingKey, mediaIndex: mediaIndex, partIndex: mediaIndex, maximumBitrate: maximumBitrate, isLocal: connection.local, sessionIdentifier: session, pathOverride: path ) guard let url = transcodeURL( for: request, includeProfile: includeProfile, minimal: minimal ) else { return (nil, ChannelProbe(path: path, result: "Adresse nicht baubar")) } let outcome = await probeStream(url: url) let label = "\(path) (mediaIndex \(mediaIndex)" + "\(includeProfile ? "" : ", ohne Profil")" + "\(minimal ? ", nur das Nötigste" : "") )" // Die gebaute Adresse mit in die Spur. Ohne sie diskutiert man über // eine Anfrage, die niemand gesehen hat — und genau das habe ich // mehrere Runden lang getan. let detail = outcome.hasPrefix("HTTP 200") ? outcome : "\(outcome)\n \(redacted(url.absoluteString))" return (outcome.hasPrefix("HTTP 200") ? url : nil, ChannelProbe(path: label, result: detail)) } /// Welche Adresse mit welchem Index umzurechnen ist. /// /// Die Adresse der Sitzung zuerst — der Transcoder sucht sich den Teil /// dann selbst, und dafür steht der Index -1. Die übrigen Formen stehen /// dahinter, weil sich dieser Teil der Schnittstelle zwischen /// Serverfassungen unterscheidet und nirgends beschrieben ist. static func streamCandidates( item: PlexItem, sessionPath: String ) -> [(path: String, mediaIndex: Int)] { var paths = [sessionPath] if item.key.hasPrefix("/livetv/"), !paths.contains(item.key) { paths.append(item.key) } if let part = item.media.first?.parts.first?.key, !paths.contains(part) { paths.append(part) } return paths.flatMap { path in [(path: path, mediaIndex: -1), (path: path, mediaIndex: 0)] } } /// Holt eine Stromadresse einmal selbst ab und sagt, was zurückkam. /// /// **Ohne Plex-Kopfzeilen**, weil AVPlayer sie ebenfalls nicht schickt. /// Mit Kopfzeilen zu prüfen hieße, etwas anderes zu prüfen als das, was /// später wirklich passiert — und dann stünde der Fehler wieder erst im /// Player, wo niemand mehr sieht, woran es lag. public func probeStream(url: URL) async -> String { var request = URLRequest(url: url) request.timeoutInterval = 20 do { let (data, response) = try await transport.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard status != 200 else { return "HTTP 200" } // Plex legt dem Fehler eine Begründung bei. Genau die fehlte // bisher: der Player zeigte „HTTP 400 (unhandled)" und sonst nichts. let body = String(decoding: data.prefix(300), as: UTF8.self) .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "\n", with: " ") return body.isEmpty ? "HTTP \(status)" : "HTTP \(status): \(body)" } catch { return Self.shortDescription(of: error) } } /// 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 { // Die Nummer am Empfänger zuerst: nur sie spricht seine Sprache. let keys = ([dvr.deviceChannel(forChannelKey: channel.id)] + [channel.gridKey, channel.key, channel.channelIdentifier, channel.id]) .compactMap { $0 } .map { $0.split(separator: "/").last.map(String.init) ?? $0 } for key in keys { paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)/tune") paths.append("/livetv/dvrs/\(dvr.key)/channels/\(key)") } // Und über das Gerät. Es ist die einzige Stelle, die auf diesem // Server überhaupt Sender führt — 278 Stück unter `DeviceChannel`. // Wenn der DVR keine kennt, kennt sie das Gerät. for device in dvr.devices { guard let deviceKey = device.key else { continue } let base = deviceKey.hasPrefix("/") ? deviceKey : "/media/grabbers/devices/\(deviceKey)" for key in keys.prefix(3) { paths.append("\(base)/channels/\(key)/tune") paths.append("\(base)/channels/\(key)") } } } paths += sessionKeys(channel: channel, program: program).map { "\(sessionPath)/\($0)" } // Und die Sitzung ohne Schlüssel — bisher nie probiert. paths.append(sessionPath) 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 /// das Programmraster ihn führt, aus dem auch die Senderliste kommt. static func sessionKeys(channel: PlexChannel, program: PlexProgram?) -> [String] { var keys = [ channel.gridKey, program?.metadataKey.flatMap { $0.split(separator: "/").last.map(String.init) }, program?.id, channel.key, channel.id, ].compactMap { $0 } // Volle Adressen taugen hier nicht: an `/livetv/sessions//` // gehört ein Schlüssel, kein zweiter Pfad. keys = keys.map { $0.split(separator: "/").last.map(String.init) ?? $0 } // Und ein Schlüssel, der noch `%3A` enthält, ist als Pfadabschnitt // keiner — die entpackte Form gehört mit in die Auswahl. Aber nur, // solange dabei kein Schrägstrich herauskommt: `plex://movie/abc` ist // als ein Pfadabschnitt genauso wenig brauchbar. keys += keys.compactMap { key in guard key.contains("%"), let plain = key.removingPercentEncoding, !plain.contains("/") else { return nil } return plain } var seen = Set() return keys.filter { !$0.isEmpty && seen.insert($0).inserted } } /// Fragt den Server, ob er diesen Strom zulässt — und warum nicht. public func liveTVVerdict(for url: URL) async -> LiveTVVerdict { // Dieselben Angaben, nur an den Entscheider statt an den Transcoder. // Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die // Wiedergabe wurde unterbrochen", und das sagt nichts. guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return .unknown("keine Abfrageteile in der Adresse") } do { let decision = try await client.send( PlexRequest(path: "/video/:/transcode/universal/decision", query: items), as: TranscodeDecision.self ) guard decision.isAllowed else { return .refused(decision.explanation ?? "ohne Begründung") } return .allowed } catch { // Antwortet der Entscheider gar nicht, heißt das **nicht** nein. // Live-Adressen kennt er womöglich nicht; darauf die Wiedergabe // zu verweigern hieße, eine funktionierende Sendung zu blocken. return .unknown(Self.shortDescription(of: error)) } } } extension PlexServer { /// Der Aufbau der Antwort auf `/livetv/dvrs`, zum Ansehen. /// /// Nicht zum Anzeigen im Alltag, sondern zum Nachsehen: die /// Live-TV-Endpunkte von Plex haben keine offizielle Beschreibung, und /// ohne die tatsächliche Antwort bleibt jede Vermutung darüber, wo die /// Sender stehen, eine Vermutung. Diese Ausgabe beendet das Raten. /// /// Werte werden nicht gezeigt, nur Struktur und Anzahl — das genügt, um /// den richtigen Schlüssel zu finden, und nichts davon ist vertraulich. public func liveTVStructure() async -> String { var report = ["=== /livetv/dvrs ==="] report.append(await structure(ofPath: "/livetv/dvrs")) // Was der Server an Endpunkten angibt. Nach drei geratenen Adressen // mit 404 ist das die Stelle, an der die Antwort stehen muss. report.append("\n=== /media/providers ===") report.append(await structure(ofPath: "/media/providers")) // Und die Senderliste beim Anbieter — dort stehen die Namen. Ohne sie // im selben Abzug bliebe nach einem Fehlschlag wieder eine Runde // Raten übrig. if let dvr = try? await client.send( PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)), as: PlexMediaContainer.self ).content.first, let epgIdentifier = dvr.epgIdentifier { for path in await candidatePaths(dvr: dvr, epgIdentifier: epgIdentifier) { report.append("\n=== \(path) ===") report.append(await structure(ofPath: path)) } // Und das Raster: wenn keine Senderliste antwortet, muss die // Antwort hier stehen — die Weboberfläche zeichnet ihren Guide // daraus. let now = Int(Date().timeIntervalSince1970) let gridPath = "/\(epgIdentifier)/grid" report.append("\n=== \(gridPath) ===") report.append(await structure( ofPath: gridPath, query: [ URLQueryItem(name: "type", value: "1"), URLQueryItem(name: "beginsAt>", value: String(now - 3_600)), URLQueryItem(name: "endsAt<", value: String(now + 3_600)), ] )) } else { report.append("\n=== Senderliste ===\nKeine Kennung des Programmanbieters am Empfänger") } return report.joined(separator: "\n") } private func structure(ofPath path: String, query: [URLQueryItem] = []) async -> String { let data: Data do { data = try await client.send(PlexRequest(path: path, query: query, timeout: .seconds(30))) } catch { return "Abfrage gescheitert: \(error)" } guard let object = try? JSONSerialization.jsonObject(with: data) else { return "Antwort ist kein JSON (\(data.count) Bytes)" } return Self.outline(object).joined(separator: "\n") } /// Beschreibt den Aufbau eines JSON-Werts, ohne die Werte selbst. /// /// Ausnahme sind Titel und Sendernummern: die stehen ohnehin im /// Programmführer und sind beim Suchen die nützlichste Angabe. private static func outline(_ value: Any, indent: Int = 0) -> [String] { let padding = String(repeating: " ", count: indent) switch value { case let dictionary as [String: Any]: return dictionary.keys.sorted().flatMap { key -> [String] in let child = dictionary[key]! if ["title", "channelIdentifier", "lineupTitle", "type", "key", "identifier", "channelTitle", "channelCallSign", "channelVcn"].contains(key), let text = child as? String { return ["\(padding)\(key): \(text)"] } switch child { case is [Any], is [String: Any]: return ["\(padding)\(key):"] + outline(child, indent: indent + 1) default: return ["\(padding)\(key): \(type(of: child))"] } } case let array as [Any]: guard let first = array.first else { return ["\(padding)(leer)"] } // Nur das erste Element ausklappen — bei dreißig Sendern wäre die // vollständige Ausgabe unlesbar, und alle haben denselben Aufbau. return ["\(padding)[\(array.count) Einträge, erster:]"] + outline(first, indent: indent + 1) default: return ["\(padding)\(type(of: value))"] } } } /// 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 { /// 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. case noLineup case failed(String) } public let channels: [PlexChannel] public let 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. public var description: String { switch source { 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: "Am Empfänger hängt keine Senderliste" case .failed(let reason): "Abfrage gescheitert: \(reason)" } } } /// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken. public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable { public let identifier: String public let title: String? public let types: String? /// Was dieser Anbieter kann, und unter welcher Adresse. /// /// Der Grund, warum hier überhaupt etwas steht: Plex sagt selbst, welche /// Endpunkte es anbietet. Drei Versuche, die Adresse der Senderliste zu /// erraten, endeten in 404 — sie hier abzulesen ist kein Ratespiel mehr. public let features: [PlexProviderFeature] public var id: String { identifier } private enum CodingKeys: String, CodingKey { case identifier, title, types case features = "Feature" } public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) self.identifier = try values.decode(String.self, forKey: .identifier) self.title = try values.decodeIfPresent(String.self, forKey: .title) self.types = try values.decodeIfPresent(String.self, forKey: .types) self.features = try values.decodeIfPresent([PlexProviderFeature].self, forKey: .features) ?? [] } public init(identifier: String, title: String? = nil, types: String? = nil, features: [PlexProviderFeature] = []) { self.identifier = identifier self.title = title self.types = types self.features = features } /// Die Adresse für eine Fähigkeit, falls der Anbieter sie anbietet. public func key(forFeature type: String) -> String? { features.first { $0.type == type }?.key } } /// Eine Fähigkeit eines Anbieters, mit der Adresse dahinter. public struct PlexProviderFeature: Sendable, Equatable, Decodable, Identifiable { public let type: String public let key: String? public var id: String { key ?? type } public init(type: String, key: String? = nil) { self.type = type self.key = key } } /// Ein Rastereintrag: eine Sendung und der Sender, auf dem sie läuft. public struct PlexGuideEntry: Sendable { public let channel: PlexChannel? public let program: PlexProgram } /// Senderliste und Programm, so wie die Live-TV-Ansicht sie braucht. public struct LiveTVGuide: Sendable { public let channels: [PlexChannel] /// Sendungen je Sender, nach Anfangszeit sortiert. 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] } /// Was beim Einschalten herauskam. public struct LiveTuneResult: Sendable { public let url: URL? public let sessionIdentifier: String /// Womit der Tuner wieder freigegeben wird. public var subscriptionKey: String? /// Jeder versuchte Weg mit seiner Antwort. public let trace: [ChannelProbe] } /// Was der Server zu einem Strom sagt. public enum LiveTVVerdict: Sendable, Equatable { case allowed /// Ausdrücklich abgelehnt, mit Begründung. case refused(String) /// Der Entscheider hat nicht geantwortet — kein Grund, es nicht zu versuchen. case unknown(String) } extension PlexServer { /// Was der Entscheider zu einer Sendung sagt — im Wortlaut. /// /// Der Transcoder antwortet mit einer nackten „400 Bad Request"-Seite und /// verrät nichts. Der Entscheider bekommt dieselben Angaben, gibt aber /// eine Plex-Antwort zurück, in der steht, woran es liegt. Das ist die /// letzte Stelle, an der dieser Server noch etwas erklärt. public func decisionDump(channel: PlexChannel, program: PlexProgram?) async -> String { guard let metadataKey = program?.metadataKey else { return "Keine Sendung — ohne sie gibt es nichts zu entscheiden." } var report: [String] = [] do { let request = TranscodeRequest( ratingKey: channel.id, isLocal: connection.local, pathOverride: metadataKey ) guard let url = transcodeURL(for: request), let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return "Adresse nicht baubar" } report.append("--- Entscheider ---") do { let data = try await client.send(PlexRequest( path: "/video/:/transcode/universal/decision", query: items, timeout: .seconds(20) )) report.append(redacted(String(decoding: data.prefix(900), as: UTF8.self))) } catch { report.append(Self.shortDescription(of: error)) } } return report.joined(separator: "\n") } /// Die rohe Antwort auf eine Adresse, gekürzt und ohne Token. /// /// Zum Erkunden gedacht: die Live-Schnittstelle von Plex ist nirgends /// beschrieben, und die Antwort auf „was steht unter dieser Adresse" ist /// die einzige verlässliche Quelle. Der Token wird aus der Ausgabe /// entfernt, falls der Server ihn irgendwo zurückspiegelt. public func rawResponse(forPath path: String, limit: Int = 1_200) async -> String { let encoded = path.contains("%") do { let data = try await client.send(PlexRequest( path: path, timeout: .seconds(20), isPathEncoded: encoded )) let text = String(decoding: data.prefix(limit), as: UTF8.self) return redacted(text) } catch { return Self.shortDescription(of: error) } } nonisolated func redacted(_ text: String) -> String { 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-_.!~*'()" ) } extension PlexServer { /// Klopft den Transcoder mit verschiedenen Angaben ab. /// /// Eine gewöhnliche Datei scheitert genauso wie Live-TV: der Transcoder /// antwortet auf alles mit einer nackten 400. Damit ist es kein /// Live-Problem, sondern eines der Anfrage selbst — und welche Angabe /// stört, sagt nur das Halbieren. public func transcoderBisect(ratingKey: String) async -> [String] { let session = UUID().uuidString let path = "/library/metadata/\(ratingKey)" let encoded = path.addingPercentEncoding(withAllowedCharacters: .plexParameterAllowed) ?? path let variants: [(String, [URLQueryItem])] = [ ("nur Pfad und Token", [ URLQueryItem(name: "path", value: encoded), URLQueryItem(name: "X-Plex-Token", value: token), ]), ("Pfad unkodiert", [ URLQueryItem(name: "path", value: path), URLQueryItem(name: "X-Plex-Token", value: token), ]), ("mit Protokoll und Indizes", [ URLQueryItem(name: "path", value: encoded), URLQueryItem(name: "protocol", value: "hls"), URLQueryItem(name: "mediaIndex", value: "0"), URLQueryItem(name: "partIndex", value: "0"), URLQueryItem(name: "X-Plex-Token", value: token), ]), ("dazu die Gerätekennung", [ URLQueryItem(name: "path", value: encoded), URLQueryItem(name: "protocol", value: "hls"), URLQueryItem(name: "mediaIndex", value: "0"), URLQueryItem(name: "partIndex", value: "0"), URLQueryItem(name: "X-Plex-Token", value: token), URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier), ]), ("dazu hasMDE", [ URLQueryItem(name: "path", value: encoded), URLQueryItem(name: "protocol", value: "hls"), URLQueryItem(name: "hasMDE", value: "1"), URLQueryItem(name: "mediaIndex", value: "0"), URLQueryItem(name: "partIndex", value: "0"), URLQueryItem(name: "X-Plex-Token", value: token), URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier), ]), ("dazu die Sitzung", [ URLQueryItem(name: "path", value: encoded), URLQueryItem(name: "protocol", value: "hls"), URLQueryItem(name: "hasMDE", value: "1"), URLQueryItem(name: "mediaIndex", value: "0"), URLQueryItem(name: "partIndex", value: "0"), URLQueryItem(name: "session", value: session), URLQueryItem(name: "X-Plex-Session-Identifier", value: session), URLQueryItem(name: "X-Plex-Token", value: token), URLQueryItem(name: "X-Plex-Client-Identifier", value: identity.clientIdentifier), ]), ] var lines: [String] = [] for (name, items) in variants { lines.append(" \(name): \(await probe(items: items, endpoint: "start.m3u8"))") } lines.append(" Entscheider: \(await probe(items: variants.last!.1, endpoint: "decision"))") // Lebt der Transcoder überhaupt? `ping` und `stop` gehören zu // derselben Familie und brauchen nur eine Sitzungskennung. Wenn auch // sie mit 400 antworten, ist nicht die Anfrage falsch, sondern der // ganze Zweig nicht da. let session2 = [URLQueryItem(name: "session", value: session), URLQueryItem(name: "X-Plex-Token", value: token)] lines.append(" ping: \(await probe(items: session2, endpoint: "ping"))") lines.append(" stop: \(await probe(items: session2, endpoint: "stop"))") // Und zur Gegenkontrolle etwas, von dem wir wissen, dass es geht: // die Bilder kommen über denselben `/:/`-Aufbau. lines.append(" Mediathek: \(await rawResponse(forPath: "/library/sections", limit: 90))") lines.append(" Server sagt über sich: \(await capabilities())") return lines } /// Was der Server über seine eigenen Fähigkeiten sagt. /// /// Die Felder stehen in jeder Antwort der Wurzel, und ihre Werte habe ich /// nie angesehen. Steht dort `transcoderVideo: false`, kann dieser Server /// kein Video umrechnen — und dann war die ganze Suche der letzten Tage /// die Suche nach einer Fähigkeit, die es nicht gibt. public func capabilities() async -> String { guard let data = try? await client.send(PlexRequest(path: "/", timeout: .seconds(15))), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let container = root["MediaContainer"] as? [String: Any] else { return "keine Antwort" } let interesting = [ "transcoderVideo", "transcoderAudio", "transcoderSubtitles", "transcoderActiveVideoSessions", "transcoderVideoBitrates", "offlineTranscode", "allowSync", "version", "platform", ] return interesting .compactMap { key in container[key].map { "\(key)=\($0)" } } .joined(separator: ", ") } private func probe(items: [URLQueryItem], endpoint: String) async -> String { guard let baseURL = connection.url, var components = URLComponents( url: baseURL.appending(path: "/video/:/transcode/universal/\(endpoint)"), resolvingAgainstBaseURL: false ) else { return "Adresse nicht baubar" } components.percentEncodedQuery = items .map { "\($0.name)=\($0.value ?? "")" } .joined(separator: "&") guard let url = components.url else { return "Adresse nicht baubar" } return await probeStream(url: url) } }