From b4eb853048bf970ad838914339e96866ec34b7b6 Mon Sep 17 00:00:00 2001 From: kevbot Date: Fri, 17 Jul 2026 05:27:32 -0400 Subject: [PATCH] fix(livetv): release live streams so the tuner doesn't wedge (#1799) Co-authored-by: Claude Opus 4.8 Co-authored-by: Gauvino --- app/(auth)/player/direct-player.tsx | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 1a29ffc02..652434dca 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -7,6 +7,7 @@ import { RepeatMode, } from "@jellyfin/sdk/lib/generated-client"; import { + getMediaInfoApi, getPlaystateApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; @@ -310,6 +311,37 @@ export default function DirectPlayerPage() { // Ref to store the stream fetch function for refreshing subtitle tracks const refetchStreamRef = useRef<(() => Promise) | null>(null); + // Live TV opens a server-side live stream via autoOpenLiveStream. If it is + // never closed, Jellyfin's M3U tuner limit fills up and every channel then + // fails with "simultaneous stream limit has been reached". reportPlaybackStopped + // handles a clean stop, but a channel switch (the player re-fetches in place) + // or an unmount after an error bypass it, so track the open live stream and + // release it on those paths too. + const apiRef = useRef(api); + useEffect(() => { + apiRef.current = api; + }, [api]); + + const releaseLiveStream = useCallback( + (liveStreamId: string | null) => { + if (!liveStreamId || !apiRef.current || offline) return; + // Best effort: a failed close must not break teardown, and the slot is + // also freed by the server's reap as a backstop. + getMediaInfoApi(apiRef.current) + .closeLiveStream({ liveStreamId }) + .catch(() => {}); + }, + [offline], + ); + + // The effect cleanup releases the live stream both when it changes (channel + // switch, which re-runs the effect) and when the player unmounts, so no + // manual previous-id tracking is needed. + useEffect(() => { + const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null; + return () => releaseLiveStream(liveStreamId); + }, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]); + useEffect(() => { const fetchStreamData = async (): Promise => { setStreamStatus({ isLoading: true, isError: false }); @@ -445,6 +477,12 @@ export default function DirectPlayerPage() { MediaSourceId: mediaSourceId, PositionTicks: currentTimeInTicks, PlaySessionId: stream.sessionId, + // Release the server-side live stream (and its tuner slot) on stop. + // Jellyfin only closes a live stream opened via autoOpenLiveStream when + // the stop report carries its LiveStreamId; without it the stream leaks + // and Live TV eventually fails for everyone with "M3U simultaneous + // stream limit has been reached". Undefined for non-live items (no-op). + LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined, }, }); }, [api, item, mediaSourceId, stream, progress, offline]);