diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index a470679..9a5d849 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -18,8 +18,8 @@ struct LiveTVView: View { @State private var trace: [ChannelProbe] = [] @State private var programs: [String: [PlexProgram]] = [:] @State private var isTuning: String? - /// Warum das Einschalten nicht geklappt hat. - @State private var tuneFailure: String? + /// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg. + @State private var tuneFailure: TuneFailure? /// Raster oder Senderliste. /// /// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern, @@ -100,13 +100,11 @@ struct LiveTVView: View { } .task { await load() } .refreshable { await load() } - .alert( - Text("liveTV.tuneFailed"), - isPresented: Binding(get: { tuneFailure != nil }, set: { if !$0 { tuneFailure = nil } }) - ) { - Button("action.close", role: .cancel) { tuneFailure = nil } - } message: { - if let tuneFailure { Text(tuneFailure) } + // Ein Blatt statt eines Dialogs: die Antwort des Servers ist + // mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort + // wieder nur, dass es nicht ging. + .sheet(item: $tuneFailure) { failure in + TuneFailureSheet(failure: failure) } } @@ -212,18 +210,11 @@ struct LiveTVView: View { guard let stream = result.url else { // Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf // den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung. - tuneFailure = ([String(localized: "liveTV.noStream")] - + result.trace.map { "\($0.path) → \($0.result)" }) - .joined(separator: "\n") - return - } - - // Den Server fragen, bevor der Player es versucht. Lehnt er - // ausdrücklich ab, sagt er im Klartext warum — der Player meldet nur, - // dass es nicht ging. Schweigt der Entscheider dagegen, wird trotzdem - // gespielt: Live-Adressen kennt er womöglich gar nicht. - if case .refused(let reason) = await server.liveTVVerdict(for: stream) { - tuneFailure = reason + tuneFailure = TuneFailure( + channel: channel.title, + reason: String(localized: "liveTV.noStream"), + trace: result.trace.map { "\($0.path)\n → \($0.result)" } + ) return } @@ -317,3 +308,67 @@ private struct ChannelRow: View { .accessibilityElement(children: .combine) } } + + +/// Was beim Einschalten schiefging, in voller Länge. +struct TuneFailure: Identifiable { + let id = UUID() + let channel: String + let reason: String + /// Jeder versuchte Weg mit der Antwort des Servers. + let trace: [String] +} + +/// Zeigt die Antwort des Servers, statt sie zu verschlucken. +/// +/// Die Live-Schnittstelle von Plex ist nirgends beschrieben. Ohne den +/// Wortlaut dessen, was der Server abgelehnt hat, bleibt nur Raten — und +/// jede Runde davon kostet Tage. +private struct TuneFailureSheet: View { + let failure: TuneFailure + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text(failure.channel) + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + + Text(failure.reason) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextSecondary) + + ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color.flixyTextMuted) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button { + UIPasteboard.general.string = failure.trace.joined(separator: "\n") + } label: { + Label("diagnostics.copy", systemImage: "doc.on.doc") + .font(.flixyCaption) + } + .buttonStyle(.bordered) + } + .padding(FlixySpacing.screenMargin) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.flixyCanvas) + .navigationTitle(Text("liveTV.tuneFailed")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("action.close") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index f6b0f17..82d29b6 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -219,6 +219,12 @@ final class PlayerModel { isLive = true title = request.title + // Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der + // Tuner nach dem Schließen belegt — und beim nächsten Sender hat der + // Server keinen freien mehr. + transcodeSession = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "session" }?.value + // Live kommt immer umgerechnet: der Tuner liefert einen // Transportstrom, den kein iPhone direkt abspielt. let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: []) diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index f626b97..ccf3679 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -364,14 +364,15 @@ extension PlexServer { /// /// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen /// hatte: **der Server muss den Tuner belegen**. Erst danach gibt es - /// etwas umzurechnen. Ohne diesen Schritt begann die Wiedergabe und starb - /// sofort wieder — „die Wiedergabe wurde unterbrochen", ohne dass ein - /// Fehler zu sehen gewesen wäre. + /// etwas umzurechnen. /// - /// Unter welchem Schlüssel die Sitzung eröffnet wird, unterscheidet sich - /// je nach Anbieter, deshalb werden die in Frage kommenden der Reihe nach - /// probiert. Was dabei herauskam, steht in `trace` — scheitert alles, ist - /// ohne Rückfrage zu sehen, was der Server geantwortet hat. + /// 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, @@ -379,75 +380,131 @@ extension PlexServer { ) async -> LiveTuneResult { let session = UUID().uuidString var trace: [ChannelProbe] = [] + var attempts = 0 for key in Self.sessionKeys(channel: channel, program: program) { - let path = "/livetv/sessions/\(session)/\(key)" + let sessionPath = "/livetv/sessions/\(session)" + let openPath = "\(sessionPath)/\(key)" + + let item: PlexItem? do { - let container = try await client.send( + item = try await client.send( PlexRequest( - path: path, + path: openPath, query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)], timeout: .seconds(30) ), as: PlexMediaContainer.self - ) - - guard let item = container.content.first else { - trace.append(ChannelProbe(path: path, result: "leer")) - continue - } - - // Die Sitzung hat jetzt einen Tuner. Umgerechnet wird die - // Adresse der **Sitzung** — nicht die des Senders, nicht die - // der Sendung und nicht die des Teils darin. Der Transcoder - // sucht sich den Teil selbst; deshalb steht unten auch -1. - let sessionPath = "/livetv/sessions/\(session)" - let streamPath = item.key.hasPrefix("/livetv/sessions/") - ? item.key - : (item.media.first?.parts.first?.key ?? sessionPath) - - trace.append(ChannelProbe(path: path, result: "Sitzung: \(streamPath)")) - - return LiveTuneResult( - url: transcodeURL(for: TranscodeRequest( - ratingKey: item.ratingKey, - // -1 heißt „was auch immer die Sitzung liefert". Ein - // laufender Sender hat keine Fassungen zur Auswahl. - mediaIndex: -1, - partIndex: -1, - maximumBitrate: maximumBitrate, - isLocal: connection.local, - sessionIdentifier: session, - pathOverride: streamPath - )), - sessionIdentifier: session, - trace: trace - ) + ).content.first } catch { - trace.append(ChannelProbe(path: path, result: Self.shortDescription(of: error))) + trace.append(ChannelProbe(path: openPath, result: Self.shortDescription(of: error))) + continue } - } - // Letzter Versuch: die Sendung unmittelbar umrechnen lassen. Auf - // Servern, die den Tuner selbst belegen, genügt das. - if let metadataKey = program?.metadataKey { - trace.append(ChannelProbe(path: metadataKey, result: "direkt versucht")) - return LiveTuneResult( - url: transcodeURL(for: TranscodeRequest( - ratingKey: channel.id, + 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 < 8 else { break } + attempts += 1 + + let request = TranscodeRequest( + ratingKey: item.ratingKey, + mediaIndex: candidate.mediaIndex, + partIndex: candidate.mediaIndex, maximumBitrate: maximumBitrate, isLocal: connection.local, sessionIdentifier: session, - pathOverride: metadataKey - )), - sessionIdentifier: session, - trace: trace - ) + pathOverride: candidate.path + ) + guard let url = transcodeURL(for: request) else { continue } + + let outcome = await probeStream(url: url) + trace.append(ChannelProbe( + path: "\(candidate.path) (mediaIndex \(candidate.mediaIndex))", + result: outcome + )) + + if outcome.hasPrefix("HTTP 200") { + return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + } + } } + // Letzter Versuch ohne eigene Sitzung: die Sendung unmittelbar + // umrechnen lassen. Auf Servern, die den Tuner selbst belegen, genügt + // das — und geprüft wird es genauso wie alles andere. + if let metadataKey = program?.metadataKey, attempts < 8 { + let request = TranscodeRequest( + ratingKey: channel.id, + maximumBitrate: maximumBitrate, + isLocal: connection.local, + sessionIdentifier: session, + pathOverride: metadataKey + ) + if let url = transcodeURL(for: request) { + let outcome = await probeStream(url: url) + trace.append(ChannelProbe(path: metadataKey, result: outcome)) + if outcome.hasPrefix("HTTP 200") { + 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) } + /// 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) + } + } + /// 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/Sources/PlexKit/Endpoints/PlexServer.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift index b4133c9..e64ec9d 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift @@ -22,6 +22,8 @@ public actor PlexServer { /// Systemplayer abruft, müssen die Kennung selbst tragen — AVPlayer /// schickt keine Kopfzeilen mit. nonisolated let identity: PlexClientIdentity + /// Für Abrufe ohne Plex-Kopfzeilen — so, wie der Systemplayer sie macht. + nonisolated let transport: any PlexTransport public init?( resource: PlexResource, @@ -34,6 +36,7 @@ public actor PlexServer { self.connection = connection self.token = token self.identity = identity + self.transport = transport self.client = PlexHTTPClient( baseURL: baseURL, identity: identity, diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index cecc8e9..52b3367 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -150,27 +150,37 @@ struct LiveTVGuideTests { #expect(lookup.trace.contains { $0.path == "/livetv/dvrs/2/channels" && $0.result.contains("Channel: 2") }) } - @Test("Die Adresse der laufenden Sendung führt zum Strom") - func tunesThroughTheProgram() async throws { - // Das Raster nennt keine Teil-Adresse. Der Weg führt über die - // Sendung selbst: Plex behandelt eine laufende Sendung wie einen Film. - let server = server(routes: [ - "/tv.plex.providers.epg.cloud:2/grid": try fixture("livetv-grid"), - ]) + @Test("Eine Adresse beim Programmanbieter wird nicht doppelt kodiert") + func doesNotDoubleEncode() throws { + // Derselbe Fehler wie einst beim Anmelde-Link: `URLComponents` kodiert + // ein zweites Mal, aus %3A wird %253A, und der Server findet nichts. + let request = TranscodeRequest( + ratingKey: "5", + isLocal: true, + pathOverride: "/tv.plex.providers.epg.cloud:2/metadata/plex%3A%2F%2Fmovie%2Fabc" + ) + let path = try #require(request.queryItems.first { $0.name == "path" }?.value) - let guide = await server.liveTVGuide(dvr: dvr) - let zdf = try #require(guide.channels.last) - let program = try #require(guide.programs[zdf.id]?.first) - // Hier antwortet keine Sitzung; dann bleibt der direkte Weg über die - // Sendung — auf Servern, die den Tuner selbst belegen, genügt er. - let result = await server.tune(channel: zdf, 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 == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/abc") + } - #expect(path == "/tv.plex.providers.epg.cloud:2/metadata/plex://movie/6254592919e23c64f6c5605d") - // Doppelt kodiert fände der Server nichts — aus %3A würde %253A. - #expect(!url.absoluteString.contains("%253A")) + @Test("Nur was tatsächlich mit 200 antwortet, geht an den Player") + func rejectsStreamsTheServerRefuses() async throws { + // Vorher galt schon eine gebaute Adresse als Erfolg. Der Fehler + // tauchte dann erst im Player auf — als „HTTP 400" ohne Begründung. + let session = """ + {"MediaContainer":{"size":1,"Metadata":[ + {"ratingKey":"live-1","key":"/livetv/sessions/abc","type":"clip","title":"ZDF HD"} + ]}} + """ + let server = server(routes: ["/gk-5": session]) + let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5") + + let result = await server.tune(channel: channel, maximumBitrate: nil) + + #expect(result.url == nil) + // Und die Antwort des Servers steht in der Spur, nicht nur ein Code. + #expect(result.trace.contains { $0.result.hasPrefix("HTTP 404") }) } @Test("Erst den Tuner belegen, dann umrechnen lassen") @@ -183,16 +193,19 @@ struct LiveTVGuideTests { "Media":[{"id":1,"Part":[{"id":1,"key":"/livetv/sessions/abc/stream"}]}]} ]}} """ - // Nur unter dem Raster-Schlüssel antwortet eine Sitzung. - let server = server(routes: ["/gk-5": session]) + // Nur unter dem Raster-Schlüssel antwortet eine Sitzung, und der + // Transcoder liefert erst danach eine Abspielliste. + let server = server(routes: ["/gk-5": session, "/start.m3u8": "#EXTM3U"]) let channel = PlexChannel(id: "5", title: "ZDF HD", key: "5", gridKey: "gk-5") let result = await server.tune(channel: channel, maximumBitrate: nil) let url = try #require(result.url) let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - // Die Adresse der Sitzung, nicht die des Teils darin. - #expect(items.first { $0.name == "path" }?.value == "/livetv/sessions/abc") + // Die Adresse der Sitzung, nicht die des Senders und nicht die der + // Sendung — der Transcoder sucht sich den Teil darin selbst. + let path = try #require(items.first { $0.name == "path" }?.value) + #expect(path.hasPrefix("/livetv/sessions/")) // Ein laufender Sender hat keine Fassungen zur Auswahl. #expect(items.first { $0.name == "mediaIndex" }?.value == "-1") // Ohne die Gerätekennung in der Adresse kann der Server die Abholung