diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index c7cd81b8..d0fc7668 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -5,6 +5,7 @@ from rest_framework.filters import SearchFilter, OrderingFilter from django_filters.rest_framework import DjangoFilterBackend from django.shortcuts import get_object_or_404 import django_filters +import logging from apps.accounts.permissions import ( Authenticated, permission_classes_by_action, @@ -17,6 +18,9 @@ from .serializers import ( VODCategorySerializer, VODConnectionSerializer ) +from core.xtream_codes import Client as XtreamCodesClient + +logger = logging.getLogger(__name__) class MovieFilter(django_filters.FilterSet): @@ -54,6 +58,105 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): 'category', 'logo', 'm3u_account' ).filter(m3u_account__is_active=True) + def _extract_year(self, date_string): + """Extract year from date string""" + if not date_string: + return None + try: + return int(date_string.split('-')[0]) + except (ValueError, IndexError): + return None + + def _convert_duration_to_minutes(self, duration_secs): + """Convert duration from seconds to minutes""" + if not duration_secs: + return 0 + try: + return int(duration_secs) // 60 + except (ValueError, TypeError): + return 0 + + @action(detail=True, methods=['get'], url_path='provider-info') + def provider_info(self, request, pk=None): + """Get detailed movie information from the original provider""" + logger.debug(f"MovieViewSet.provider_info called for movie ID: {pk}") + movie = self.get_object() + logger.debug(f"Retrieved movie: {movie.name} (ID: {movie.id})") + + if not movie.m3u_account: + return Response( + {'error': 'No M3U account associated with this movie'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Create XtreamCodes client + with XtreamCodesClient( + server_url=movie.m3u_account.server_url, + username=movie.m3u_account.username, + password=movie.m3u_account.password, + user_agent=movie.m3u_account.user_agent + ) as client: + # Get detailed VOD info from provider + logger.debug(f"Fetching VOD info for movie {movie.id} with stream ID {movie.stream_id} from provider") + vod_info = client.get_vod_info(movie.stream_id) + + if not vod_info or 'info' not in vod_info: + return Response( + {'error': 'No information available from provider'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Extract and format the info + info = vod_info.get('info', {}) + movie_data = vod_info.get('movie_data', {}) + + # Build response with all available fields + response_data = { + 'id': movie.id, + 'stream_id': movie.stream_id, + 'name': info.get('name', movie.name), + 'o_name': info.get('o_name', ''), + 'description': info.get('description', info.get('plot', '')), + 'plot': info.get('plot', info.get('description', '')), + 'year': self._extract_year(info.get('releasedate', '')), + 'release_date': info.get('releasedate', ''), + 'releasedate': info.get('releasedate', ''), + 'genre': info.get('genre', ''), + 'director': info.get('director', ''), + 'actors': info.get('actors', info.get('cast', '')), + 'cast': info.get('cast', info.get('actors', '')), + 'country': info.get('country', ''), + 'rating': info.get('rating', 0), + 'tmdb_id': info.get('tmdb_id', ''), + 'youtube_trailer': info.get('youtube_trailer', ''), + 'duration': self._convert_duration_to_minutes(info.get('duration_secs', 0)), + 'duration_secs': info.get('duration_secs', 0), + 'episode_run_time': info.get('episode_run_time', 0), + 'age': info.get('age', ''), + 'backdrop_path': info.get('backdrop_path', []), + 'cover': info.get('cover_big', ''), + 'cover_big': info.get('cover_big', ''), + 'movie_image': info.get('movie_image', ''), + 'bitrate': info.get('bitrate', 0), + 'video': info.get('video', {}), + 'audio': info.get('audio', {}), + # Include movie_data fields + 'container_extension': movie_data.get('container_extension', 'mp4'), + 'direct_source': movie_data.get('direct_source', ''), + 'category_id': movie_data.get('category_id', ''), + 'added': movie_data.get('added', ''), + } + + return Response(response_data) + + except Exception as e: + logger.error(f"Error fetching VOD info from provider for movie {pk}: {str(e)}") + return Response( + {'error': f'Failed to fetch information from provider: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + class EpisodeFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr="icontains") diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 8702806c..f237ac77 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -8,6 +8,7 @@ from datetime import timedelta from .models import Series, VODCategory, VODConnection, Movie, Episode from apps.m3u.models import M3UAccount from apps.channels.models import Logo +from core.xtream_codes import Client as XtreamCodesClient logger = logging.getLogger(__name__) @@ -371,4 +372,4 @@ def cleanup_inactive_vod_connections(): inactive_connections.delete() logger.info(f"Cleaned up {count} inactive VOD connections") - return count + return count \ No newline at end of file diff --git a/core/xtream_codes.py b/core/xtream_codes.py index d068bacb..b8b4d862 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -196,6 +196,180 @@ class Client: """Get the playback URL for a stream""" return f"{self.server_url}/live/{self.username}/{self.password}/{stream_id}.ts" + def get_vod_categories(self): + """Get VOD categories""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_vod_categories' + } + + categories = self._make_request(endpoint, params) + + if not isinstance(categories, list): + error_msg = f"Invalid VOD categories response: {categories}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(categories)} VOD categories") + return categories + except Exception as e: + logger.error(f"Failed to get VOD categories: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_vod_streams(self, category_id=None): + """Get VOD streams for a specific category""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_vod_streams' + } + + if category_id: + params['category_id'] = category_id + + streams = self._make_request(endpoint, params) + + if not isinstance(streams, list): + error_msg = f"Invalid VOD streams response for category {category_id}: {streams}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(streams)} VOD streams for category {category_id}") + return streams + except Exception as e: + logger.error(f"Failed to get VOD streams for category {category_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_vod_info(self, vod_id): + """Get detailed information for a specific VOD""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_vod_info', + 'vod_id': vod_id + } + + vod_info = self._make_request(endpoint, params) + + if not isinstance(vod_info, dict): + error_msg = f"Invalid VOD info response for vod_id {vod_id}: {vod_info}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved VOD info for vod_id {vod_id}") + return vod_info + except Exception as e: + logger.error(f"Failed to get VOD info for vod_id {vod_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_series_categories(self): + """Get series categories""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_series_categories' + } + + categories = self._make_request(endpoint, params) + + if not isinstance(categories, list): + error_msg = f"Invalid series categories response: {categories}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(categories)} series categories") + return categories + except Exception as e: + logger.error(f"Failed to get series categories: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_series(self, category_id=None): + """Get series for a specific category""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_series' + } + + if category_id: + params['category_id'] = category_id + + series = self._make_request(endpoint, params) + + if not isinstance(series, list): + error_msg = f"Invalid series response for category {category_id}: {series}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(series)} series for category {category_id}") + return series + except Exception as e: + logger.error(f"Failed to get series for category {category_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_series_info(self, series_id): + """Get detailed information for a specific series including episodes""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_series_info', + 'series_id': series_id + } + + series_info = self._make_request(endpoint, params) + + if not isinstance(series_info, dict): + error_msg = f"Invalid series info response for series_id {series_id}: {series_info}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved series info for series_id {series_id}") + return series_info + except Exception as e: + logger.error(f"Failed to get series info for series_id {series_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_vod_stream_url(self, vod_id, container_extension="mp4"): + """Get the playback URL for a VOD""" + return f"{self.server_url}/movie/{self.username}/{self.password}/{vod_id}.{container_extension}" + def close(self): """Close the session and cleanup resources""" if hasattr(self, 'session') and self.session: diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 143b6e4c..94742024 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -65,8 +65,7 @@ urlpatterns = [ path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), # Optionally, serve the raw Swagger JSON path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"), - # VOD - path("api/vod/", include("apps.vod.urls")), + path("proxy/vod/", include("apps.proxy.vod_proxy.urls")), # Catch-all routes should always be last path("", TemplateView.as_view(template_name="index.html")), # React entry point diff --git a/frontend/src/api.js b/frontend/src/api.js index 748754c8..67cdec57 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1751,6 +1751,15 @@ export default class API { } } + static async getVODInfoFromProvider(vodId) { + try { + const response = await request(`${host}/api/vod/movies/${vodId}/provider-info/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve VOD info from provider', e); + } + } + static async getSeriesInfo(seriesId) { try { const response = await request(`${host}/api/vod/series/${seriesId}/`); diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index f09f384f..1a1b628a 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -9,6 +9,7 @@ export default function FloatingVideo() { const isVisible = useVideoStore((s) => s.isVisible); const streamUrl = useVideoStore((s) => s.streamUrl); const contentType = useVideoStore((s) => s.contentType); + const metadata = useVideoStore((s) => s.metadata); const hideVideo = useVideoStore((s) => s.hideVideo); const videoRef = useRef(null); const playerRef = useRef(null); @@ -331,10 +332,34 @@ export default function FloatingVideo() { }} // Add poster for VOD if available {...(contentType === 'vod' && { - poster: undefined, // Could add poster support later + poster: metadata?.logo?.url, // Use VOD poster if available })} /> + {/* VOD title overlay when not loading */} + {!isLoading && metadata && contentType === 'vod' && ( + + + {metadata.name} + + {metadata.year && ( + + {metadata.year} + + )} + + )} + {/* Loading overlay - only show when loading */} {isLoading && ( { return {vod.name}; }; + const handleCardClick = async () => { + // Just pass the basic vod info to the parent handler + onClick(vod); + }; + return ( { radius="md" withBorder style={{ cursor: 'pointer', backgroundColor: '#27272A' }} - onClick={() => onClick(vod)} + onClick={handleCardClick} > @@ -274,6 +279,236 @@ const SeriesModal = ({ series, opened, onClose }) => { ); }; +const VODModal = ({ vod, opened, onClose }) => { + const [detailedVOD, setDetailedVOD] = useState(null); + const [loadingDetails, setLoadingDetails] = useState(false); + const { fetchVODDetailsFromProvider } = useVODStore(); + const showVideo = useVideoStore((s) => s.showVideo); + const env_mode = useSettingsStore((s) => s.environment.env_mode); + + useEffect(() => { + if (opened && vod && !detailedVOD) { + setLoadingDetails(true); + fetchVODDetailsFromProvider(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); + }); + } + }, [opened, vod, detailedVOD, fetchVODDetailsFromProvider]); + + useEffect(() => { + if (!opened) { + setDetailedVOD(null); + setLoadingDetails(false); + } + }, [opened]); + + const handlePlayVOD = () => { + const vodToPlay = detailedVOD || vod; + if (!vodToPlay) return; + + let streamUrl = vodToPlay.stream_url; + if (env_mode === 'dev') { + streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${vodToPlay.stream_url}`; + } else { + streamUrl = `${window.location.origin}${vodToPlay.stream_url}`; + } + showVideo(streamUrl, 'vod', vodToPlay); + }; + + const formatDuration = (minutes) => { + if (!minutes) return ''; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; + }; + + if (!vod) return null; + + // Use detailed data if available, otherwise use basic vod data + const displayVOD = detailedVOD || vod; + + return ( + + + {loadingDetails && ( + + + Loading additional details... + + )} + + {/* Backdrop image if available */} + {displayVOD.backdrop_path && displayVOD.backdrop_path.length > 0 && ( + + {`${displayVOD.name} + + )} + + {/* Movie poster and basic info */} + + {/* Use movie_image or logo */} + {(displayVOD.movie_image || displayVOD.logo?.url) ? ( + + {displayVOD.name} + + ) : ( + + + + )} + + + {displayVOD.name} + + {/* Original name if different */} + {displayVOD.o_name && displayVOD.o_name !== displayVOD.name && ( + + Original: {displayVOD.o_name} + + )} + + + {displayVOD.year && {displayVOD.year}} + {displayVOD.duration && {formatDuration(displayVOD.duration)}} + {displayVOD.rating && {displayVOD.rating}} + {displayVOD.age && {displayVOD.age}} + Movie + + + {/* Release date */} + {displayVOD.release_date && ( + + Release Date: {displayVOD.release_date} + + )} + + {displayVOD.genre && ( + + Genre: {displayVOD.genre} + + )} + + {displayVOD.director && ( + + Director: {displayVOD.director} + + )} + + {displayVOD.actors && ( + + Cast: {displayVOD.actors} + + )} + + {displayVOD.country && ( + + Country: {displayVOD.country} + + )} + + {/* Technical info */} + {(displayVOD.bitrate || displayVOD.video || displayVOD.audio) && ( + + Technical Details: + {displayVOD.bitrate && displayVOD.bitrate > 0 && ( + + Bitrate: {displayVOD.bitrate} kbps + + )} + {displayVOD.video && Object.keys(displayVOD.video).length > 0 && ( + + Video: {JSON.stringify(displayVOD.video)} + + )} + {displayVOD.audio && Object.keys(displayVOD.audio).length > 0 && ( + + Audio: {JSON.stringify(displayVOD.audio)} + + )} + + )} + + + + + + {/* Description */} + {displayVOD.description && ( + + Description + + {displayVOD.description} + + + )} + + {/* YouTube trailer if available */} + {displayVOD.youtube_trailer && ( + + Trailer + + + )} + + + ); +}; + const MIN_CARD_WIDTH = 260; const MAX_CARD_WIDTH = 320; @@ -316,7 +551,10 @@ const VODsPage = () => { const showVideo = useVideoStore((s) => s.showVideo); const [selectedSeries, setSelectedSeries] = useState(null); + const [selectedVOD, setSelectedVOD] = useState(null); const [seriesModalOpened, { open: openSeriesModal, close: closeSeriesModal }] = useDisclosure(false); + const [vodModalOpened, { open: openVODModal, close: closeVODModal }] = useDisclosure(false); + const [initialLoad, setInitialLoad] = useState(true); const columns = useCardColumns(); useEffect(() => { @@ -325,9 +563,9 @@ const VODsPage = () => { useEffect(() => { if (filters.type === 'series') { - fetchSeries(); + fetchSeries().finally(() => setInitialLoad(false)); } else { - fetchVODs(); + fetchVODs().finally(() => setInitialLoad(false)); } }, [filters, currentPage, fetchVODs, fetchSeries]); @@ -339,7 +577,12 @@ const VODsPage = () => { } else { streamUrl = `${window.location.origin}${vod.stream_url}`; } - showVideo(streamUrl, 'vod'); // Specify VOD content type + showVideo(streamUrl, 'vod', vod); + }; + + const handleVODCardClick = (vod) => { + setSelectedVOD(vod); + openVODModal(); }; const handleSeriesClick = (series) => { @@ -395,7 +638,7 @@ const VODsPage = () => { {/* Content */} - {loading ? ( + {initialLoad ? ( @@ -424,7 +667,7 @@ const VODsPage = () => { key={vod.id} style={{ minWidth: MIN_CARD_WIDTH, maxWidth: MAX_CARD_WIDTH, margin: '0 auto' }} > - + ))} @@ -450,6 +693,13 @@ const VODsPage = () => { opened={seriesModalOpened} onClose={closeSeriesModal} /> + + {/* VOD Details Modal */} + ); }; diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index cb79701b..249182a8 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -28,8 +28,8 @@ const useVODStore = create((set, get) => ({ })), fetchVODs: async () => { - set({ loading: true, error: null }); try { + set({ loading: true, error: null }); const state = get(); const params = new URLSearchParams(); @@ -126,6 +126,96 @@ const useVODStore = create((set, get) => ({ } }, + fetchVODDetails: async (vodId) => { + set({ loading: true, error: null }); + try { + const response = await api.getVODInfo(vodId); + + // Transform the response data to match our expected format + const vodDetails = { + id: response.id || vodId, + name: response.name || '', + description: response.description || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration: response.duration || null, + stream_url: response.stream_url || '', + logo: response.logo || null, + type: 'movie', + director: response.director || '', + actors: response.actors || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + youtube_trailer: response.youtube_trailer || '', + }; + + set((state) => ({ + vods: { + ...state.vods, + [vodDetails.id]: vodDetails, + }, + loading: false, + })); + + return vodDetails; + } catch (error) { + console.error('Failed to fetch VOD details:', error); + set({ error: 'Failed to load VOD details.', loading: false }); + throw error; + } + }, + + fetchVODDetailsFromProvider: async (vodId) => { + set({ loading: true, error: null }); + try { + const response = await api.getVODInfoFromProvider(vodId); + + // Transform the response data to match our expected format + const vodDetails = { + id: response.id || vodId, + name: response.name || '', + description: response.description || response.plot || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration: response.duration || null, + stream_url: response.stream_url || '', + logo: response.logo || response.cover || null, + type: 'movie', + director: response.director || '', + actors: response.actors || response.cast || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + youtube_trailer: response.youtube_trailer || '', + // Additional provider fields + backdrop_path: response.backdrop_path || [], + release_date: response.release_date || response.releasedate || '', + movie_image: response.movie_image || null, + o_name: response.o_name || '', + age: response.age || '', + episode_run_time: response.episode_run_time || null, + bitrate: response.bitrate || 0, + video: response.video || {}, + audio: response.audio || {}, + }; + + set((state) => ({ + vods: { + ...state.vods, + [vodDetails.id]: vodDetails, + }, + loading: false, + })); + + return vodDetails; + } catch (error) { + console.error('Failed to fetch VOD details from provider:', error); + set({ error: 'Failed to load VOD details from provider.', loading: false }); + throw error; + } + }, + fetchCategories: async () => { try { const response = await api.getVODCategories(); diff --git a/frontend/src/store/useVideoStore.jsx b/frontend/src/store/useVideoStore.jsx index 4aa721ed..1ac21542 100644 --- a/frontend/src/store/useVideoStore.jsx +++ b/frontend/src/store/useVideoStore.jsx @@ -8,12 +8,14 @@ const useVideoStore = create((set) => ({ isVisible: false, streamUrl: null, contentType: 'live', // 'live' for MPEG-TS streams, 'vod' for MP4/MKV files + metadata: null, // Store additional metadata for VOD content - showVideo: (url, type = 'live') => + showVideo: (url, type = 'live', metadata = null) => set({ isVisible: true, streamUrl: url, contentType: type, + metadata: metadata, }), hideVideo: () => @@ -21,6 +23,7 @@ const useVideoStore = create((set) => ({ isVisible: false, streamUrl: null, contentType: 'live', + metadata: null, }), }));