From 5fbe38f8d2621c079051046936e6060643bf0921 Mon Sep 17 00:00:00 2001 From: nemesbak Date: Thu, 28 May 2026 13:39:04 +0200 Subject: [PATCH 1/4] fix(vod): respect relation_id when switching providers in SeriesModal/VODModal The provider-info endpoints for both series and movies always returned data from the highest-priority relation, ignoring the provider the user had selected in the dropdown. Switching sources in the UI produced no change in the episode list or movie details. Root cause: the endpoints had no support for a relation_id query param, and the frontend never passed one when the user changed the selection. Fix (backend): - series provider-info: accept ?relation_id= and query that specific M3USeriesRelation; fall back to highest-priority when omitted - movie provider-info: same treatment for M3UMovieRelation Fix (frontend): - api.getSeriesInfo / api.getMovieProviderInfo accept an optional relationId argument and append it to the query string - useVODStore.fetchSeriesInfo / fetchMovieDetailsFromProvider forward the argument to the API layer - SeriesModal.onChangeSelectedProvider re-fetches series info (episodes included) with the newly selected relation's id - VODModal.onChangeSelectedProvider re-fetches movie details with the newly selected relation's id Fixes #1250 Co-Authored-By: Claude Sonnet 4.6 --- apps/vod/api_views.py | 32 ++++++++++++++++++++----- frontend/src/api.js | 12 ++++++---- frontend/src/components/SeriesModal.jsx | 7 ++++++ frontend/src/components/VODModal.jsx | 5 ++++ frontend/src/store/useVODStore.jsx | 8 +++---- 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 67813251..eb61f98d 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -120,11 +120,21 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): """Get detailed movie information from the original provider, throttled to 24h.""" movie = self.get_object() - # Get the highest priority active relation - relation = M3UMovieRelation.objects.filter( + relation_id = request.query_params.get('relation_id') + qs = M3UMovieRelation.objects.filter( movie=movie, m3u_account__is_active=True - ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + ).select_related('m3u_account') + + if relation_id: + relation = qs.filter(id=relation_id).first() + if not relation: + return Response( + {'error': 'Relation not found or not active'}, + status=status.HTTP_404_NOT_FOUND + ) + else: + relation = qs.order_by('-m3u_account__priority', 'id').first() if not relation: return Response( @@ -326,11 +336,21 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): series = self.get_object() logger.debug(f"Retrieved series: {series.name} (ID: {series.id})") - # Get the highest priority active relation - relation = M3USeriesRelation.objects.filter( + relation_id = request.query_params.get('relation_id') + qs = M3USeriesRelation.objects.filter( series=series, m3u_account__is_active=True - ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + ).select_related('m3u_account') + + if relation_id: + relation = qs.filter(id=relation_id).first() + if not relation: + return Response( + {'error': 'Relation not found or not active'}, + status=status.HTTP_404_NOT_FOUND + ) + else: + relation = qs.order_by('-m3u_account__priority', 'id').first() if not relation: return Response( diff --git a/frontend/src/api.js b/frontend/src/api.js index 2a357a81..def75df5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -3526,10 +3526,11 @@ export default class API { } } - static async getMovieProviderInfo(movieId) { + static async getMovieProviderInfo(movieId, relationId = null) { try { + const params = relationId ? `?relation_id=${relationId}` : ''; const response = await request( - `${host}/api/vod/movies/${movieId}/provider-info/` + `${host}/api/vod/movies/${movieId}/provider-info/${params}` ); return response; } catch (e) { @@ -3568,11 +3569,12 @@ export default class API { } } - static async getSeriesInfo(seriesId) { + static async getSeriesInfo(seriesId, relationId = null) { try { - // Call the provider-info endpoint that includes episodes + const params = new URLSearchParams({ include_episodes: 'true' }); + if (relationId) params.set('relation_id', relationId); const response = await request( - `${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true` + `${host}/api/vod/series/${seriesId}/provider-info/?${params}` ); return response; } catch (e) { diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx index d22e6630..45cb37cd 100644 --- a/frontend/src/components/SeriesModal.jsx +++ b/frontend/src/components/SeriesModal.jsx @@ -470,6 +470,13 @@ const SeriesModal = ({ series, opened, onClose }) => { const onChangeSelectedProvider = (value) => { const provider = providers.find((p) => p.id.toString() === value); setSelectedProvider(provider); + if (provider) { + setLoadingDetails(true); + fetchSeriesInfo(series.id, provider.id) + .then((details) => setDetailedSeries(details)) + .catch(() => {}) + .finally(() => setLoadingDetails(false)); + } }; if (!series) return null; diff --git a/frontend/src/components/VODModal.jsx b/frontend/src/components/VODModal.jsx index c4f5f0cd..679e6544 100644 --- a/frontend/src/components/VODModal.jsx +++ b/frontend/src/components/VODModal.jsx @@ -324,6 +324,11 @@ const VODModal = ({ vod, opened, onClose }) => { const onChangeSelectedProvider = (value) => { const provider = providers.find((p) => p.id.toString() === value); setSelectedProvider(provider); + if (provider) { + fetchMovieDetailsFromProvider(vod.id, provider.id) + .then((details) => setDetailedVOD(details)) + .catch(() => {}); + } } if (!vod) return null; diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index 966c4a3e..527a4a3a 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -240,10 +240,10 @@ const useVODStore = create((set, get) => ({ } }, - fetchMovieDetailsFromProvider: async (movieId) => { + fetchMovieDetailsFromProvider: async (movieId, relationId = null) => { set({ loading: true, error: null }); try { - const response = await api.getMovieProviderInfo(movieId); + const response = await api.getMovieProviderInfo(movieId, relationId); // Transform the response data to match our expected format const movieDetails = getMovieDetailsWithProvider(response, movieId); @@ -346,10 +346,10 @@ const useVODStore = create((set, get) => ({ return { content: updatedContent }; }), - fetchSeriesInfo: async (seriesId) => { + fetchSeriesInfo: async (seriesId, relationId = null) => { set({ loading: true, error: null }); try { - const response = await api.getSeriesInfo(seriesId); + const response = await api.getSeriesInfo(seriesId, relationId); // Transform the response data to match our expected format const seriesInfo = getSeriesDetails(response, seriesId); From 36879c10f23c8cfd2278e55de3888dca145526e0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 29 May 2026 19:23:13 -0500 Subject: [PATCH 2/4] fix(vod): validate relation_id and handle loading state in VODModal --- apps/vod/api_views.py | 22 ++++++++++++++++++++-- frontend/src/components/VODModal.jsx | 4 +++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index eb61f98d..07f5b6fe 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -121,12 +121,21 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): movie = self.get_object() relation_id = request.query_params.get('relation_id') + if relation_id is not None: + try: + relation_id = int(relation_id) + except (TypeError, ValueError): + return Response( + {'error': 'Invalid relation_id'}, + status=status.HTTP_400_BAD_REQUEST + ) + qs = M3UMovieRelation.objects.filter( movie=movie, m3u_account__is_active=True ).select_related('m3u_account') - if relation_id: + if relation_id is not None: relation = qs.filter(id=relation_id).first() if not relation: return Response( @@ -337,12 +346,21 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): logger.debug(f"Retrieved series: {series.name} (ID: {series.id})") relation_id = request.query_params.get('relation_id') + if relation_id is not None: + try: + relation_id = int(relation_id) + except (TypeError, ValueError): + return Response( + {'error': 'Invalid relation_id'}, + status=status.HTTP_400_BAD_REQUEST + ) + qs = M3USeriesRelation.objects.filter( series=series, m3u_account__is_active=True ).select_related('m3u_account') - if relation_id: + if relation_id is not None: relation = qs.filter(id=relation_id).first() if not relation: return Response( diff --git a/frontend/src/components/VODModal.jsx b/frontend/src/components/VODModal.jsx index 679e6544..8331ec9b 100644 --- a/frontend/src/components/VODModal.jsx +++ b/frontend/src/components/VODModal.jsx @@ -325,9 +325,11 @@ const VODModal = ({ vod, opened, onClose }) => { const provider = providers.find((p) => p.id.toString() === value); setSelectedProvider(provider); if (provider) { + setLoadingDetails(true); fetchMovieDetailsFromProvider(vod.id, provider.id) .then((details) => setDetailedVOD(details)) - .catch(() => {}); + .catch(() => {}) + .finally(() => setLoadingDetails(false)); } } From 7cbc71f7fba0e748b3275fb38779293226657053 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 29 May 2026 19:23:51 -0500 Subject: [PATCH 3/4] refactor(vod): Apply prettier. --- frontend/src/components/VODModal.jsx | 52 ++++++++++++---------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/VODModal.jsx b/frontend/src/components/VODModal.jsx index 8331ec9b..da4ee13f 100644 --- a/frontend/src/components/VODModal.jsx +++ b/frontend/src/components/VODModal.jsx @@ -23,7 +23,7 @@ import { formatStreamLabel, getYouTubeEmbedUrl, imdbUrl, - tmdbUrl + tmdbUrl, } from '../utils/components/SeriesModalUtils.js'; import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx'; import { @@ -38,7 +38,7 @@ const Movie = ({ hasMultipleProviders, selectedProvider, detailedVOD, - vod + vod, }) => { const showVideo = useVideoStore((s) => s.showVideo); const env_mode = useSettingsStore((s) => s.environment.env_mode); @@ -71,28 +71,19 @@ const Movie = ({ {displayVOD.name} {/* Original name if different */} - {displayVOD.o_name && - displayVOD.o_name !== displayVOD.name && ( - - Original: {displayVOD.o_name} - - )} + {displayVOD.o_name && displayVOD.o_name !== displayVOD.name && ( + + Original: {displayVOD.o_name} + + )} - {displayVOD.year && ( - {displayVOD.year} - )} + {displayVOD.year && {displayVOD.year}} {displayVOD.duration_secs && ( - - {formatDuration(displayVOD.duration_secs)} - - )} - {displayVOD.rating && ( - {displayVOD.rating} - )} - {displayVOD.age && ( - {displayVOD.age} + {formatDuration(displayVOD.duration_secs)} )} + {displayVOD.rating && {displayVOD.rating}} + {displayVOD.age && {displayVOD.age}} Movie {/* imdb_id and tmdb_id badges */} {displayVOD.imdb_id && ( @@ -203,12 +194,15 @@ const Movie = ({ const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => { const techDetails = getTechnicalDetails(selectedProvider, displayVOD); - const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio; + const hasDetails = + techDetails.bitrate || techDetails.video || techDetails.audio; if (!hasDetails) return null; - const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0; - const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0; + const hasVideo = + techDetails.video && Object.keys(techDetails.video).length > 0; + const hasAudio = + techDetails.audio && Object.keys(techDetails.audio).length > 0; return ( @@ -217,7 +211,9 @@ const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => { {selectedProvider && ( (from {selectedProvider.m3u_account.name} - {selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`}) + {selectedProvider.stream_id && + ` - Stream ${selectedProvider.stream_id}`} + ) )} @@ -315,11 +311,9 @@ const VODModal = ({ vod, opened, onClose }) => { }, [opened]); const onClickYouTubeTrailer = () => { - setTrailerUrl( - getYouTubeEmbedUrl(displayVOD.youtube_trailer) - ); + setTrailerUrl(getYouTubeEmbedUrl(displayVOD.youtube_trailer)); setTrailerModalOpened(true); - } + }; const onChangeSelectedProvider = (value) => { const provider = providers.find((p) => p.id.toString() === value); @@ -331,7 +325,7 @@ const VODModal = ({ vod, opened, onClose }) => { .catch(() => {}) .finally(() => setLoadingDetails(false)); } - } + }; if (!vod) return null; From 06046c8961d37d13476af2ca7379ad7ac234d7e8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 29 May 2026 19:26:20 -0500 Subject: [PATCH 4/4] changelog: Update changelog for vod pr. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afd013ac..496121b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak) - **Per-channel stream profile override was ignored during streaming.** `Channel.get_stream_profile()` read `self.stream_profile` directly, bypassing any `ChannelOverride.stream_profile` set by the user on auto-synced channels. The method now resolves through `effective_stream_profile_obj`, which checks the channel's override record first and falls back to the channel's own field. Channels without an override continue to behave identically to before. (Fixes #1268) - Thanks [@nemesbak](https://github.com/nemesbak) - **Web-player output profile was ignored for live streams started outside the Channels page.** `getShowVideoUrl` in `RecordingCardUtils.js` returned a raw proxy URL without calling `buildLiveStreamUrl`, so the output profile preference stored in `localStorage` was not applied when launching a stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, or Recording Card. Only the Channels page (StreamsTable) was calling `buildLiveStreamUrl` correctly. One additional import and one changed return value fix all affected entry points. (Fixes #1304) - Thanks [@nemesbak](https://github.com/nemesbak) - **Plugins with available updates were not sorting to the top of the Plugin Browse list.** The sort weight function previously treated `update_available` plugins the same as any other installed plugin, leaving them buried. They now receive the highest sort priority (below the search/filter results header) so users can spot pending updates immediately. — Thanks [@sethwv](https://github.com/sethwv)