diff --git a/Flixy/Session/PlaybackCoordinator.swift b/Flixy/Session/PlaybackCoordinator.swift index d0447a9..0cd6f67 100644 --- a/Flixy/Session/PlaybackCoordinator.swift +++ b/Flixy/Session/PlaybackCoordinator.swift @@ -19,6 +19,10 @@ struct PlaybackRequest: Identifiable, Hashable { /// Daran ist das Einschalten gescheitert: die Adresse war längst /// berechnet und wurde weggeworfen. var streamURL: URL? + /// Womit der Tuner nach dem Zusehen wieder freigegeben wird. + /// + /// Bei vier Tunern ist der fünfte Sender sonst nicht mehr einzuschalten. + var liveSubscriptionKey: String? static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 5e53405..c2c9be3 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -231,7 +231,8 @@ struct LiveTVView: View { title: channel.title, mediaIndex: 0, startTime: .zero, - streamURL: stream + streamURL: stream, + liveSubscriptionKey: result.subscriptionKey ) } } diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 82d29b6..802b826 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -386,6 +386,13 @@ final class PlayerModel { await server.stopTranscode(sessionIdentifier: transcodeSession) } + // Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware: + // vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten + // Einschalten — bis der Server von selbst merkt, dass niemand zusieht. + if let subscription = request.liveSubscriptionKey, let server { + await server.endLiveSession(subscriptionKey: subscription) + } + engine?.teardown() engine = nil } diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 4a2164c..5b21d97 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -421,7 +421,17 @@ extension PlexServer { ) trace.append(contentsOf: outcome.probes) if let url = outcome.url { - return LiveTuneResult(url: url, sessionIdentifier: session, trace: trace) + 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) } } @@ -644,7 +654,7 @@ extension PlexServer { dvr: PlexDVR, session: String, maximumBitrate: Int? - ) async -> (url: URL?, probes: [ChannelProbe]) { + ) async -> (url: URL?, subscription: String?, probes: [ChannelProbe]) { let identifier = channel.id.addingPercentEncoding( withAllowedCharacters: .uriComponentAllowed ) ?? channel.id @@ -660,17 +670,32 @@ extension PlexServer { isPathEncoded: true )) } catch { - return (nil, [ChannelProbe(path: "POST \(path)", result: Self.shortDescription(of: error))]) + 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, [ChannelProbe( + return (nil, subscription, [ChannelProbe( path: "POST \(path)", result: "belegt, aber ohne Teil-Adresse — \(Self.containerSummary(data))" )]) } var probes = [ChannelProbe(path: "POST \(path)", result: "Tuner belegt: \(part)")] + + // Erst unmittelbar: was beim Belegen herauskommt, ist bereits eine + // fertige HLS-Abspielliste. Sie an den Transcoder zu geben heißt, ihn + // um die Umrechnung von etwas zu bitten, das schon fertig ist. + if let direct = directStreamURL(forPath: part) { + let outcome = await probeStream(url: direct) + probes.append(ChannelProbe(path: "\(part) (unmittelbar)", result: outcome)) + if outcome.hasPrefix("HTTP 200") { return (direct, subscription, probes) } + } + let outcome = await attemptStream( path: part, ratingKey: channel.id, @@ -680,7 +705,38 @@ extension PlexServer { session: session ) probes.append(outcome.probe) - return (outcome.url, probes) + return (outcome.url, subscription, probes) + } + + /// 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. @@ -1139,6 +1195,8 @@ public struct LiveTVGuide: Sendable { 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] } diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index c3781cd..62f474b 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -50,6 +50,35 @@ extension PlexServer { return components.url } + /// Baut die Adresse eines Stroms, den der Server schon bereitstellt. + /// + /// Für Live-TV. Das Belegen eines Tuners liefert eine fertige + /// HLS-Abspielliste — die gehört abgerufen, nicht umgerechnet. Sie durch + /// den Transcoder zu schicken heißt, ihn um die Umrechnung von etwas zu + /// bitten, das bereits fertig ist; er antwortet mit einer nackten 400. + /// + /// Token und Gerätekennung müssen in die Adresse, weil sie der + /// Systemplayer abruft und der keine Kopfzeilen mitschickt. + public nonisolated func directStreamURL(forPath path: String) -> URL? { + guard let baseURL = connection.url, + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + else { return nil } + + // Die Adresse aus der Sitzung bringt ihre eigene Abfrage mit + // (`?offset=-1.000000`); die darf nicht verlorengehen. + let pieces = path.split(separator: "?", maxSplits: 1) + components.percentEncodedPath = String(pieces[0]) + + var items = pieces.count > 1 + ? (URLComponents(string: "?\(pieces[1])")?.queryItems ?? []) + : [] + items.append(URLQueryItem(name: "X-Plex-Token", value: token)) + items += identity.queryItems + components.queryItems = items + + return components.url + } + /// Fragt den Server, ob er diese Wiedergabe zulässt. public func transcodeDecision(for request: TranscodeRequest) async throws(PlexError) -> TranscodeDecision { try await client.send( diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index e132c8e..5829733 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -306,22 +306,32 @@ struct LiveTVGuideTests { {"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"}]}]}}]}} + "Media":[{"id":9,"Part":[{"id":9, + "key":"/livetv/sessions/abc/SITZUNG/index.m3u8?offset=-1.000000"}]}]}}]}} """ let server = server(routes: [ "/channels/6228f7f2-5fc705fa/tune": tuned, - "/start.m3u8": "#EXTM3U", + "/index.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") + // Unmittelbar abgerufen, nicht umgerechnet: was beim Belegen + // herauskommt, ist bereits eine fertige HLS-Abspielliste. + #expect(url.path() == "/livetv/sessions/abc/SITZUNG/index.m3u8") + #expect(!url.absoluteString.contains("/transcode/")) + // Die eigene Abfrage der Sitzung darf nicht verlorengehen. + let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(items.contains { $0.name == "offset" && $0.value == "-1.000000" }) + // Und der Systemplayer schickt keine Kopfzeilen mit. + #expect(items.contains { $0.name == "X-Plex-Token" }) + #expect(items.contains { $0.name == "X-Plex-Client-Identifier" }) #expect(result.trace.contains { $0.path.hasPrefix("POST ") }) + // Womit der Tuner hinterher wieder freigegeben wird. + #expect(result.subscriptionKey == "77") } @Test("Die Teil-Adresse wird gefunden, egal wie tief sie liegt")