Pull advanced info from provider when opening a movie.

This commit is contained in:
SergeantPanda 2025-08-03 22:02:34 -05:00
parent e1f5cb24ec
commit 4accd2be85
9 changed files with 666 additions and 12 deletions

View file

@ -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")

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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}/`);

View file

@ -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' && (
<Box
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
padding: '20px 10px 10px',
color: 'white',
}}
>
<Text size="sm" weight={500} style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.8)' }}>
{metadata.name}
</Text>
{metadata.year && (
<Text size="xs" color="dimmed" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.8)' }}>
{metadata.year}
</Text>
)}
</Box>
)}
{/* Loading overlay - only show when loading */}
{isLoading && (
<Box

View file

@ -50,6 +50,11 @@ const VODCard = ({ vod, onClick }) => {
return <Text weight={500}>{vod.name}</Text>;
};
const handleCardClick = async () => {
// Just pass the basic vod info to the parent handler
onClick(vod);
};
return (
<Card
shadow="sm"
@ -57,7 +62,7 @@ const VODCard = ({ vod, onClick }) => {
radius="md"
withBorder
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
onClick={() => onClick(vod)}
onClick={handleCardClick}
>
<Card.Section>
<Box style={{ position: 'relative', height: 300 }}>
@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={displayVOD.name}
size="xl"
centered
>
<Stack spacing="md">
{loadingDetails && (
<Group spacing="xs" mb="sm">
<Loader size="xs" />
<Text size="xs" color="dimmed">Loading additional details...</Text>
</Group>
)}
{/* Backdrop image if available */}
{displayVOD.backdrop_path && displayVOD.backdrop_path.length > 0 && (
<Box style={{ position: 'relative', height: 200, borderRadius: '8px', overflow: 'hidden' }}>
<Image
src={displayVOD.backdrop_path[0]}
height={200}
alt={`${displayVOD.name} backdrop`}
fit="cover"
/>
</Box>
)}
{/* Movie poster and basic info */}
<Flex gap="md">
{/* Use movie_image or logo */}
{(displayVOD.movie_image || displayVOD.logo?.url) ? (
<Box style={{ flexShrink: 0 }}>
<Image
src={displayVOD.movie_image || displayVOD.logo.url}
width={200}
height={300}
alt={displayVOD.name}
fit="contain"
style={{ borderRadius: '8px' }}
/>
</Box>
) : (
<Box
style={{
width: 200,
height: 300,
backgroundColor: '#404040',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '8px',
flexShrink: 0
}}
>
<Play size={48} color="#666" />
</Box>
)}
<Stack spacing="md" style={{ flex: 1 }}>
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name && displayVOD.o_name !== displayVOD.name && (
<Text size="sm" color="dimmed" style={{ fontStyle: 'italic' }}>
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && <Badge color="blue">{displayVOD.year}</Badge>}
{displayVOD.duration && <Badge color="gray">{formatDuration(displayVOD.duration)}</Badge>}
{displayVOD.rating && <Badge color="yellow">{displayVOD.rating}</Badge>}
{displayVOD.age && <Badge color="orange">{displayVOD.age}</Badge>}
<Badge color="green">Movie</Badge>
</Group>
{/* Release date */}
{displayVOD.release_date && (
<Text size="sm" color="dimmed">
<strong>Release Date:</strong> {displayVOD.release_date}
</Text>
)}
{displayVOD.genre && (
<Text size="sm" color="dimmed">
<strong>Genre:</strong> {displayVOD.genre}
</Text>
)}
{displayVOD.director && (
<Text size="sm" color="dimmed">
<strong>Director:</strong> {displayVOD.director}
</Text>
)}
{displayVOD.actors && (
<Text size="sm" color="dimmed">
<strong>Cast:</strong> {displayVOD.actors}
</Text>
)}
{displayVOD.country && (
<Text size="sm" color="dimmed">
<strong>Country:</strong> {displayVOD.country}
</Text>
)}
{/* Technical info */}
{(displayVOD.bitrate || displayVOD.video || displayVOD.audio) && (
<Stack spacing={4}>
<Text size="sm" weight={500}>Technical Details:</Text>
{displayVOD.bitrate && displayVOD.bitrate > 0 && (
<Text size="xs" color="dimmed">
Bitrate: {displayVOD.bitrate} kbps
</Text>
)}
{displayVOD.video && Object.keys(displayVOD.video).length > 0 && (
<Text size="xs" color="dimmed">
Video: {JSON.stringify(displayVOD.video)}
</Text>
)}
{displayVOD.audio && Object.keys(displayVOD.audio).length > 0 && (
<Text size="xs" color="dimmed">
Audio: {JSON.stringify(displayVOD.audio)}
</Text>
)}
</Stack>
)}
<Button
leftSection={<Play size={16} />}
variant="filled"
color="blue"
size="md"
onClick={handlePlayVOD}
style={{ marginTop: 'auto', alignSelf: 'flex-start' }}
>
Play Movie
</Button>
</Stack>
</Flex>
{/* Description */}
{displayVOD.description && (
<Box>
<Text size="sm" weight={500} mb={8}>Description</Text>
<Text size="sm">
{displayVOD.description}
</Text>
</Box>
)}
{/* YouTube trailer if available */}
{displayVOD.youtube_trailer && (
<Box>
<Text size="sm" weight={500} mb={8}>Trailer</Text>
<Button
variant="outline"
color="red"
component="a"
href={displayVOD.youtube_trailer}
target="_blank"
rel="noopener noreferrer"
>
Watch Trailer on YouTube
</Button>
</Box>
)}
</Stack>
</Modal>
);
};
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 = () => {
</Group>
{/* Content */}
{loading ? (
{initialLoad ? (
<Flex justify="center" py="xl">
<Loader size="lg" />
</Flex>
@ -424,7 +667,7 @@ const VODsPage = () => {
key={vod.id}
style={{ minWidth: MIN_CARD_WIDTH, maxWidth: MAX_CARD_WIDTH, margin: '0 auto' }}
>
<VODCard vod={vod} onClick={handlePlayVOD} />
<VODCard vod={vod} onClick={handleVODCardClick} />
</Grid.Col>
))}
</Grid>
@ -450,6 +693,13 @@ const VODsPage = () => {
opened={seriesModalOpened}
onClose={closeSeriesModal}
/>
{/* VOD Details Modal */}
<VODModal
vod={selectedVOD}
opened={vodModalOpened}
onClose={closeVODModal}
/>
</Box>
);
};

View file

@ -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();

View file

@ -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,
}),
}));