From a4df1f1fb80153fa0828cb86e0e0e3e2d4b192d5 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 Aug 2025 13:14:16 -0500 Subject: [PATCH] Allow specifying which account to play from. --- apps/proxy/vod_proxy/views.py | 75 ++++++++--- frontend/src/api.js | 18 +++ frontend/src/pages/VODs.jsx | 202 +++++++++++++++++++++++------ frontend/src/store/useVODStore.jsx | 20 +++ 4 files changed, 262 insertions(+), 53 deletions(-) diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 00ab72ea..ce13bebb 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -142,8 +142,17 @@ class VODStreamView(View): headers={'Location': redirect_url} ) + # Extract preferred M3U account ID from query parameters + preferred_m3u_account_id = request.GET.get('m3u_account_id') + if preferred_m3u_account_id: + try: + preferred_m3u_account_id = int(preferred_m3u_account_id) + except (ValueError, TypeError): + logger.warning(f"[VOD-PARAM] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") + preferred_m3u_account_id = None + # Get the content object and its relation - content_obj, relation = self._get_content_and_relation(content_type, content_id) + content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id) if not content_obj or not relation: logger.error(f"[VOD-ERROR] Content or relation not found: {content_type} {content_id}") raise Http404(f"Content not found: {content_type} {content_id}") @@ -239,8 +248,17 @@ class VODStreamView(View): session_url = request.path logger.info(f"[VOD-HEAD] Using existing session: {session_id}") + # Extract preferred M3U account ID from query parameters + preferred_m3u_account_id = request.GET.get('m3u_account_id') + if preferred_m3u_account_id: + try: + preferred_m3u_account_id = int(preferred_m3u_account_id) + except (ValueError, TypeError): + logger.warning(f"[VOD-HEAD] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") + preferred_m3u_account_id = None + # Get content and relation (same as GET) - content_obj, relation = self._get_content_and_relation(content_type, content_id) + content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id) if not content_obj or not relation: logger.error(f"[VOD-HEAD] Content or relation not found: {content_type} {content_id}") return HttpResponse("Content not found", status=404) @@ -336,19 +354,29 @@ class VODStreamView(View): logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True) return HttpResponse(f"HEAD error: {str(e)}", status=500) - def _get_content_and_relation(self, content_type, content_id): + def _get_content_and_relation(self, content_type, content_id, preferred_m3u_account_id=None): """Get the content object and its M3U relation""" try: logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}") + if preferred_m3u_account_id: + logger.info(f"[CONTENT-LOOKUP] Preferred M3U account ID: {preferred_m3u_account_id}") if content_type == 'movie': content_obj = get_object_or_404(Movie, uuid=content_id) logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})") - # Get the highest priority active relation - relation = content_obj.m3u_relations.filter( - m3u_account__is_active=True - ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + # Filter by preferred M3U account if specified + relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True) + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Get the highest priority active relation (fallback or default) + relation = relations_query.select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() if relation: logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") @@ -359,10 +387,18 @@ class VODStreamView(View): content_obj = get_object_or_404(Episode, uuid=content_id) logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})") - # Get the highest priority active relation - relation = content_obj.m3u_relations.filter( - m3u_account__is_active=True - ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + # Filter by preferred M3U account if specified + relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True) + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Get the highest priority active relation (fallback or default) + relation = relations_query.select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() if relation: logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") @@ -379,10 +415,19 @@ class VODStreamView(View): return None, None logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})") - # Get the highest priority active relation - relation = episode.m3u_relations.filter( - m3u_account__is_active=True - ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + + # Filter by preferred M3U account if specified + relations_query = episode.m3u_relations.filter(m3u_account__is_active=True) + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return episode, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Get the highest priority active relation (fallback or default) + relation = relations_query.select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() if relation: logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") diff --git a/frontend/src/api.js b/frontend/src/api.js index 52a89fdf..28a4d36e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1786,6 +1786,24 @@ export default class API { } } + static async getMovieProviders(movieId) { + try { + const response = await request(`${host}/api/vod/movies/${movieId}/providers/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve movie providers', e); + } + } + + static async getSeriesProviders(seriesId) { + try { + const response = await request(`${host}/api/vod/series/${seriesId}/providers/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve series providers', e); + } + } + static async getVODCategories() { try { const response = await request(`${host}/api/vod/categories/`); diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx index cc8ee180..47751103 100644 --- a/frontend/src/pages/VODs.jsx +++ b/frontend/src/pages/VODs.jsx @@ -233,7 +233,7 @@ const SeriesCard = ({ series, onClick }) => { }; const SeriesModal = ({ series, opened, onClose }) => { - const { fetchSeriesInfo, loading, episodes } = useVODStore(); + const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore(); const showVideo = useVideoStore((s) => s.showVideo); const env_mode = useSettingsStore((s) => s.environment.env_mode); const [detailedSeries, setDetailedSeries] = useState(null); @@ -242,6 +242,9 @@ const SeriesModal = ({ series, opened, onClose }) => { const [expandedEpisode, setExpandedEpisode] = useState(null); const [trailerModalOpened, setTrailerModalOpened] = useState(false); const [trailerUrl, setTrailerUrl] = useState(''); + const [providers, setProviders] = useState([]); + const [selectedProvider, setSelectedProvider] = useState(null); + const [loadingProviders, setLoadingProviders] = useState(false); useEffect(() => { if (opened && series) { @@ -263,13 +266,34 @@ const SeriesModal = ({ series, opened, onClose }) => { .finally(() => { setLoadingDetails(false); }); + + // Fetch available providers + setLoadingProviders(true); + fetchSeriesProviders(series.id) + .then((providersData) => { + setProviders(providersData); + // Set the first provider as default if none selected + if (providersData.length > 0 && !selectedProvider) { + setSelectedProvider(providersData[0]); + } + }) + .catch((error) => { + console.error('Failed to fetch series providers:', error); + setProviders([]); + }) + .finally(() => { + setLoadingProviders(false); + }); } - }, [opened, series, fetchSeriesInfo]); + }, [opened, series, fetchSeriesInfo, fetchSeriesProviders, selectedProvider]); useEffect(() => { if (!opened) { setDetailedSeries(null); setLoadingDetails(false); + setProviders([]); + setSelectedProvider(null); + setLoadingProviders(false); } }, [opened]); @@ -277,7 +301,7 @@ const SeriesModal = ({ series, opened, onClose }) => { const seriesEpisodes = React.useMemo(() => { if (!detailedSeries) return []; - // First try to get episodes from the fetched data + // Try to get episodes from the fetched data if (detailedSeries.episodesList) { return detailedSeries.episodesList.sort((a, b) => { if (a.season_number !== b.season_number) { @@ -287,16 +311,9 @@ const SeriesModal = ({ series, opened, onClose }) => { }); } - // Otherwise, filter episodes from the global store - return Object.values(episodes) - .filter(episode => episode.series?.id === detailedSeries.id) - .sort((a, b) => { - if (a.season_number !== b.season_number) { - return (a.season_number || 0) - (b.season_number || 0); - } - return (a.episode_number || 0) - (b.episode_number || 0); - }); - }, [detailedSeries, episodes]); + // If no episodes in detailed series, return empty array + return []; + }, [detailedSeries]); // Group episodes by season const episodesBySeason = React.useMemo(() => { @@ -334,6 +351,12 @@ const SeriesModal = ({ series, opened, onClose }) => { const handlePlayEpisode = (episode) => { let streamUrl = `/proxy/vod/episode/${episode.uuid}`; + + // Add selected provider as query parameter if available + if (selectedProvider) { + streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + } + if (env_mode === 'dev') { streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; } else { @@ -549,9 +572,14 @@ const SeriesModal = ({ series, opened, onClose }) => { {/* Provider Information */} - {displaySeries.m3u_account && ( - - Provider Information + + + Provider Information + {loadingProviders && ( + + )} + + {providers.length === 0 && !loadingProviders && displaySeries.m3u_account ? ( {displaySeries.m3u_account.name} @@ -562,8 +590,34 @@ const SeriesModal = ({ series, opened, onClose }) => { )} - - )} + ) : providers.length === 1 ? ( + + + {providers[0].m3u_account.name} + + {providers[0].m3u_account.account_type && ( + + {providers[0].m3u_account.account_type === 'XC' ? 'Xtream Codes' : 'Standard M3U'} + + )} + + ) : providers.length > 1 ? ( + ({ + value: provider.id.toString(), + label: `${provider.m3u_account.name} (${provider.m3u_account.account_type === 'XC' ? 'Xtream Codes' : 'Standard M3U'})` + }))} + value={selectedProvider?.id?.toString() || ''} + onChange={(value) => { + const provider = providers.find(p => p.id.toString() === value); + setSelectedProvider(provider); + }} + placeholder="Select provider..." + style={{ minWidth: 250 }} + disabled={loadingProviders} + /> + )} + + )} + + {/* Fallback provider info if no providers loaded yet */} + {providers.length === 0 && !loadingProviders && vod?.m3u_account && ( IPTV Provider @@ -1103,19 +1230,19 @@ const VODModal = ({ vod, opened, onClose }) => { )} - {/* Play Button (always shown) */} + {/* Play Button */} @@ -1240,9 +1367,8 @@ const VODsPage = () => { const { movies, series, - episodes, + // episodes, loading - removed as unused categories, - loading, filters, currentPage, totalCount, @@ -1254,7 +1380,7 @@ const VODsPage = () => { fetchCategories } = useVODStore(); - const showVideo = useVideoStore((s) => s.showVideo); + // const showVideo = useVideoStore((s) => s.showVideo); - removed as unused const [selectedSeries, setSelectedSeries] = useState(null); const [selectedVOD, setSelectedVOD] = useState(null); const [seriesModalOpened, { open: openSeriesModal, close: closeSeriesModal }] = useDisclosure(false); diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index 6693f617..8acbedf0 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -212,6 +212,26 @@ const useVODStore = create((set, get) => ({ } }, + fetchMovieProviders: async (movieId) => { + try { + const response = await api.getMovieProviders(movieId); + return response || []; + } catch (error) { + console.error('Failed to fetch movie providers:', error); + throw error; + } + }, + + fetchSeriesProviders: async (seriesId) => { + try { + const response = await api.getSeriesProviders(seriesId); + return response || []; + } catch (error) { + console.error('Failed to fetch series providers:', error); + throw error; + } + }, + fetchCategories: async () => { try { const response = await api.getVODCategories();