From b4eb853048bf970ad838914339e96866ec34b7b6 Mon Sep 17 00:00:00 2001 From: kevbot Date: Fri, 17 Jul 2026 05:27:32 -0400 Subject: [PATCH 1/2] 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]); From 6dd98ccae89f6e5d60e99bf5c0ddb7c209a4910d Mon Sep 17 00:00:00 2001 From: Gauvain Date: Fri, 17 Jul 2026 12:00:23 +0200 Subject: [PATCH 2/2] fix(home): black episode thumbnails in Next Up & Continue Watching (#1815) --- components/ContinueWatchingPoster.tsx | 9 ++++++--- components/home/TVHeroCarousel.tsx | 7 ++++--- components/tv/TVPosterCard.tsx | 7 ++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index c0f4a2650..fc20e1dfc 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC = ({ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; } if (item.Type === "Episode") { - if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; + // Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the + // backdrop owner — otherwise the Thumb tag is requested on the wrong item → black. + if (item.ParentThumbItemId && item.ParentThumbImageTag) { + return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; } return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; @@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC = ({ } return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; - }, [item]); + // useEpisodePoster in deps so flipping the prop re-computes the URL live. + }, [api, item, useEpisodePoster]); if (!url) return ; diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 596f2d369..a62538013 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -65,10 +65,11 @@ const HeroCard: React.FC = React.memo( const posterUrl = useMemo(() => { if (!api) return null; - // For episodes, always use series thumb + // For episodes, always use series thumb. + // Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId. if (item.Type === "Episode") { - if (item.ParentThumbImageTag) { - return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; + if (item.ParentThumbItemId && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; } if (item.SeriesId) { return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`; diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 9a24a5034..3c80c2291 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -139,9 +139,10 @@ export const TVPosterCard: React.FC = ({ if (orientation === "horizontal") { // Episode: prefer series thumb image for consistent look (like hero section) if (item.Type === "Episode") { - // First try parent/series thumb (horizontal series artwork) - if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; + // First try parent/series thumb (horizontal series artwork). + // Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId. + if (item.ParentThumbItemId && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; } // Fall back to episode's own primary image if (item.ImageTags?.Primary) {