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 ? (
+
@@ -639,6 +693,7 @@ const SeriesModal = ({ series, opened, onClose }) => {
variant="filled"
color="blue"
size="sm"
+ disabled={providers.length > 0 && !selectedProvider}
onClick={(e) => {
e.stopPropagation();
handlePlayEpisode(episode);
@@ -840,26 +895,50 @@ const VODModal = ({ vod, opened, onClose }) => {
const [loadingDetails, setLoadingDetails] = useState(false);
const [trailerModalOpened, setTrailerModalOpened] = useState(false);
const [trailerUrl, setTrailerUrl] = useState('');
- const { fetchMovieDetailsFromProvider } = useVODStore();
+ const [providers, setProviders] = useState([]);
+ const [selectedProvider, setSelectedProvider] = useState(null);
+ const [loadingProviders, setLoadingProviders] = useState(false);
+ const { fetchMovieDetailsFromProvider, fetchMovieProviders } = useVODStore();
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
useEffect(() => {
- if (opened && vod && !detailedVOD) {
- setLoadingDetails(true);
- fetchMovieDetailsFromProvider(vod.id)
- .then((details) => {
- setDetailedVOD(details);
+ if (opened && vod) {
+ // Fetch detailed VOD info if not already loaded
+ if (!detailedVOD) {
+ setLoadingDetails(true);
+ fetchMovieDetailsFromProvider(vod.id)
+ .then((details) => {
+ setDetailedVOD(details);
+ })
+ .catch((error) => {
+ console.warn('Failed to fetch provider details, using basic info:', error);
+ setDetailedVOD(vod); // Fallback to basic data
+ })
+ .finally(() => {
+ setLoadingDetails(false);
+ });
+ }
+
+ // Fetch available providers
+ setLoadingProviders(true);
+ fetchMovieProviders(vod.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.warn('Failed to fetch provider details, using basic info:', error);
- setDetailedVOD(vod); // Fallback to basic data
+ console.error('Failed to fetch providers:', error);
+ setProviders([]);
})
.finally(() => {
- setLoadingDetails(false);
+ setLoadingProviders(false);
});
}
- }, [opened, vod, detailedVOD, fetchMovieDetailsFromProvider]);
+ }, [opened, vod, detailedVOD, fetchMovieDetailsFromProvider, fetchMovieProviders, selectedProvider]);
useEffect(() => {
if (!opened) {
@@ -867,6 +946,9 @@ const VODModal = ({ vod, opened, onClose }) => {
setLoadingDetails(false);
setTrailerModalOpened(false);
setTrailerUrl('');
+ setProviders([]);
+ setSelectedProvider(null);
+ setLoadingProviders(false);
}
}, [opened]);
@@ -875,6 +957,12 @@ const VODModal = ({ vod, opened, onClose }) => {
if (!vodToPlay) return;
let streamUrl = `/proxy/vod/movie/${vod.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 {
@@ -1085,9 +1173,48 @@ const VODModal = ({ vod, opened, onClose }) => {
{/* Provider Information & Play Button Row */}
-
- {/* Provider Information (conditional) */}
- {vod?.m3u_account && (
+
+ {/* Provider Selection */}
+ {providers.length > 0 && (
+
+
+ IPTV Provider
+ {loadingProviders && (
+
+ )}
+
+ {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'}
+
+ )}
+
+ ) : (
+
+ )}
+
+ {/* 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 */}
}
variant="filled"
color="blue"
size="md"
onClick={handlePlayVOD}
- style={{ alignSelf: 'flex-start' }}
+ disabled={providers.length > 0 && !selectedProvider}
>
Play Movie
- {vod?.m3u_account && (
+ {selectedProvider && (
- {`(via ${vod.m3u_account.name}${displayVOD.bitrate ? `, ~${displayVOD.bitrate} kbps` : ''})`}
+ (via {selectedProvider.m3u_account.name})
)}
@@ -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();