From 11bc2e57a9a0573f6c6985df0d40cce7777fbdbb Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 25 Aug 2025 14:37:20 -0400 Subject: [PATCH] optimized vod parsing, added in vod category filtering, added UI individual tabs for movies vs series VOD category filters --- apps/channels/serializers.py | 21 +- apps/m3u/api_views.py | 2 +- apps/vod/tasks.py | 167 +- .../src/components/forms/M3UGroupFilter.jsx | 57 +- .../components/forms/VODCategoryFilter.jsx | 39 +- frontend/src/pages/VODs.jsx | 3449 +++++++++-------- 6 files changed, 2010 insertions(+), 1725 deletions(-) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 98b79283..72320840 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -1,3 +1,4 @@ +import json from rest_framework import serializers from .models import ( Stream, @@ -146,26 +147,26 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"] def to_representation(self, instance): - ret = super().to_representation(instance) - # Ensure custom_properties is always a dict or None - val = ret.get("custom_properties") - if isinstance(val, str): - import json + data = super().to_representation(instance) + + custom_props = {} + if instance.custom_properties: try: - ret["custom_properties"] = json.loads(val) - except Exception: - ret["custom_properties"] = None - return ret + custom_props = json.loads(instance.custom_properties) + except (json.JSONDecodeError, TypeError): + custom_props = {} + + return data def to_internal_value(self, data): # Accept both dict and JSON string for custom_properties val = data.get("custom_properties") if isinstance(val, str): - import json try: data["custom_properties"] = json.loads(val) except Exception: pass + return super().to_internal_value(data) # diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 4c9ca271..65fb1c0a 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -89,7 +89,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet): if enable_vod: from apps.vod.tasks import refresh_categories - refresh_categories.delay(account_id) + refresh_categories(account_id) # After the instance is created, return the response return response diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index a0fe42c1..6cd793c2 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1,4 +1,4 @@ -from celery import shared_task +from celery import shared_task, current_app, group from django.utils import timezone from django.db import transaction, IntegrityError from django.db.models import Q @@ -38,11 +38,18 @@ def refresh_vod_content(account_id): ) as client: movie_categories, series_categories = refresh_categories(account.id, client) + + logger.debug("Fetching relations for filtering category filtering") + relations = { rel.category_id: rel for rel in M3UVODCategoryRelation.objects + .filter(m3u_account=account) + .select_related("category", "m3u_account") + } + # Refresh movies with batch processing - refresh_movies(client, account, movie_categories) + refresh_movies(client, account, movie_categories, relations) # Refresh series with batch processing - refresh_series(client, account, series_categories) + refresh_series(client, account, series_categories, relations) end_time = timezone.now() duration = (end_time - start_time).total_seconds() @@ -54,7 +61,6 @@ def refresh_vod_content(account_id): logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}") return f"VOD refresh failed: {str(e)}" -@shared_task def refresh_categories(account_id, client=None): account = M3UAccount.objects.get(id=account_id, is_active=True) @@ -97,7 +103,7 @@ def refresh_categories(account_id, client=None): return movies_category_id_map, series_category_id_map -def refresh_movies(client, account, categories): +def refresh_movies(client, account, categories_by_provider, relations): """Refresh movie content using single API call for all movies""" logger.info(f"Refreshing movies for account {account.name}") @@ -105,19 +111,6 @@ def refresh_movies(client, account, categories): logger.info("Fetching all movies from provider...") all_movies_data = client.get_vod_streams() # No category_id = get all movies - # Add proper category info to each movie - for movie_data in all_movies_data: - provider_cat_id = str(movie_data.get('category_id', '')) if movie_data.get('category_id') else None - category = categories.get(provider_cat_id) if provider_cat_id else None - - # Store category ID instead of object to avoid JSON serialization issues - movie_data['_category_id'] = category.id if category else None - movie_data['_provider_category_id'] = provider_cat_id - - # Debug logging for first few movies - if len(all_movies_data) > 0 and all_movies_data.index(movie_data) < 3: - logger.info(f"Movie '{movie_data.get('name')}' -> Provider Category ID: {provider_cat_id} -> Our Category: {category.name if category else 'None'} (ID: {category.id if category else 'None'})") - # Process movies in chunks using the simple approach chunk_size = 1000 total_movies = len(all_movies_data) @@ -128,12 +121,12 @@ def refresh_movies(client, account, categories): total_chunks = (total_movies + chunk_size - 1) // chunk_size logger.info(f"Processing movie chunk {chunk_num}/{total_chunks} ({len(chunk)} movies)") - process_movie_batch(account, chunk, categories) + process_movie_batch(account, chunk, categories_by_provider, relations) logger.info(f"Completed processing all {total_movies} movies in {total_chunks} chunks") -def refresh_series(client, account, categories): +def refresh_series(client, account, categories_by_provider, relations): """Refresh series content using single API call for all series""" logger.info(f"Refreshing series for account {account.name}") @@ -141,19 +134,6 @@ def refresh_series(client, account, categories): logger.info("Fetching all series from provider...") all_series_data = client.get_series() # No category_id = get all series - # Add proper category info to each series - for series_data in all_series_data: - provider_cat_id = str(series_data.get('category_id', '')) if series_data.get('category_id') else None - category = categories.get(provider_cat_id) if provider_cat_id else None - - # Store category ID instead of object to avoid JSON serialization issues - series_data['_category_id'] = category.id if category else None - series_data['_provider_category_id'] = provider_cat_id - - # Debug logging for first few series - if len(all_series_data) > 0 and all_series_data.index(series_data) < 3: - logger.info(f"Series '{series_data.get('name')}' -> Provider Category ID: {provider_cat_id} -> Our Category: {category.name if category else 'None'} (ID: {category.id if category else 'None'})") - # Process series in chunks using the simple approach chunk_size = 1000 total_series = len(all_series_data) @@ -164,45 +144,19 @@ def refresh_series(client, account, categories): total_chunks = (total_series + chunk_size - 1) // chunk_size logger.info(f"Processing series chunk {chunk_num}/{total_chunks} ({len(chunk)} series)") - process_series_batch(account, chunk, categories) + process_series_batch(account, chunk, categories_by_provider, relations) logger.info(f"Completed processing all {total_series} series in {total_chunks} chunks") -# Batch processing functions for improved efficiency - -def batch_create_categories_from_names(category_names, category_type): - """Create categories from names and return a mapping""" - # Get existing categories - existing_categories = { - cat.name: cat for cat in VODCategory.objects.filter( - name__in=category_names, - category_type=category_type - ) - } - - # Create missing categories in batch - new_categories = [] - for name in category_names: - if name not in existing_categories: - new_categories.append(VODCategory(name=name, category_type=category_type)) - - if new_categories: - created_categories = VODCategory.bulk_create_and_fetch(new_categories, ignore_conflicts=True) - # Convert to dictionary for easy lookup - newly_created = {cat.name: cat for cat in created_categories} - existing_categories.update(newly_created) - - return existing_categories - - def batch_create_categories(categories_data, category_type, account): """Create categories in batch and return a mapping""" category_names = [cat.get('category_name', 'Unknown') for cat in categories_data] - relations = [] + relations_to_create = [] # Get existing categories + logger.debug(f"Starting VOD {category_type} category refresh") existing_categories = { cat.name: cat for cat in VODCategory.objects.filter( name__in=category_names, @@ -210,38 +164,64 @@ def batch_create_categories(categories_data, category_type, account): ) } + logger.debug(f"Found {len(existing_categories)} existing categories") + # Create missing categories in batch new_categories = [] for name in category_names: if name not in existing_categories: new_categories.append(VODCategory(name=name, category_type=category_type)) else: - relations.append(M3UVODCategoryRelation( + relations_to_create.append(M3UVODCategoryRelation( category=existing_categories[name], m3u_account=account, custom_properties={}, )) + logger.debug(f"{len(new_categories)} new categories found") + logger.debug(f"{len(relations_to_create)} existing categories found for account") + if new_categories: + logger.debug("Creating new categories...") created_categories = VODCategory.bulk_create_and_fetch(new_categories, ignore_conflicts=True) # Convert to dictionary for easy lookup newly_created = {cat.name: cat for cat in created_categories} - relations = relations + [M3UVODCategoryRelation( - category=cat, - m3u_account=account, - custom_properties={}, - ) for cat in newly_created.values()] + relations_to_create += [ + M3UVODCategoryRelation( + category=cat, + m3u_account=account, + custom_properties={}, + ) for cat in newly_created.values() + ] existing_categories.update(newly_created) - M3UVODCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True) + # Create missing relations + logger.debug("Updating category account relations...") + M3UVODCategoryRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) + + # 🔑 Fetch all relations for this account, for all categories + # relations = { rel.id: rel for rel in M3UVODCategoryRelation.objects + # .filter(category__in=existing_categories.values(), m3u_account=account) + # .select_related("category", "m3u_account") + # } + + # Attach relations to category objects + # for rel in relations: + # existing_categories[rel.category.name]['relation'] = { + # "relation_id": rel.id, + # "category_id": rel.category_id, + # "account_id": rel.m3u_account_id, + # } + return existing_categories + @shared_task -def process_movie_batch(account, batch, categories): +def process_movie_batch(account, batch, categories, relations): """Process a batch of movies using simple bulk operations like M3U processing""" logger.info(f"Processing movie batch of {len(batch)} movies for account {account.name}") @@ -256,17 +236,24 @@ def process_movie_batch(account, batch, categories): try: stream_id = str(movie_data.get('stream_id')) name = movie_data.get('name', 'Unknown') - category_id = movie_data.get('_category_id') # Get category with proper error handling category = None - if category_id: - try: - category = VODCategory.objects.get(id=category_id) - logger.debug(f"Found category {category.name} (ID: {category_id}) for movie {name}") - except VODCategory.DoesNotExist: - logger.warning(f"Category ID {category_id} not found for movie {name}") - category = None + + provider_cat_id = str(movie_data.get('category_id', '')) if movie_data.get('category_id') else None + movie_data['_provider_category_id'] = provider_cat_id + movie_data['_category_id'] = None + + logger.debug(f"Checking for existing provider category ID {provider_cat_id}") + if provider_cat_id in categories: + category = categories[provider_cat_id] + movie_data['_category_id'] = category.id + logger.debug(f"Found category {category.name} (ID: {category.id}) for movie {name}") + + relation = relations.get(category.id, None) + if relation and not relation.enabled: + logger.debug("Skipping disabled category") + continue else: logger.warning(f"No category ID provided for movie {name}") @@ -527,7 +514,7 @@ def process_movie_batch(account, batch, categories): @shared_task -def process_series_batch(account, batch, categories): +def process_series_batch(account, batch, categories, relations): """Process a batch of series using simple bulk operations like M3U processing""" logger.info(f"Processing series batch of {len(batch)} series for account {account.name}") @@ -542,17 +529,23 @@ def process_series_batch(account, batch, categories): try: series_id = str(series_data.get('series_id')) name = series_data.get('name', 'Unknown') - category_id = series_data.get('_category_id') # Get category with proper error handling category = None - if category_id: - try: - category = VODCategory.objects.get(id=category_id) - logger.debug(f"Found category {category.name} (ID: {category_id}) for series {name}") - except VODCategory.DoesNotExist: - logger.warning(f"Category ID {category_id} not found for series {name}") - category = None + + provider_cat_id = str(series_data.get('category_id', '')) if series_data.get('category_id') else None + series_data['_provider_category_id'] = provider_cat_id + series_data['_category_id'] = None + + if provider_cat_id in categories: + category = categories[provider_cat_id] + series_data['_category_id'] = category.id + logger.debug(f"Found category {category.name} (ID: {category.id}) for series {name}") + relation = relations.get(category.id, None) + + if relation and not relation.enabled: + logger.debug("Skipping disabled category") + continue else: logger.warning(f"No category ID provided for series {name}") diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 49404934..48f3e011 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -51,7 +51,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); const [groupStates, setGroupStates] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [categoryStates, setCategoryStates] = useState([]); + const [movieCategoryStates, setMovieCategoryStates] = useState([]); + const [seriesCategoryStates, setSeriesCategoryStates] = useState([]); useEffect(() => { if (Object.keys(channelGroups).length === 0) { @@ -87,22 +88,31 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { setIsLoading(true); try { // Prepare groupStates for API: custom_properties must be stringified - const groupSettings = groupStates.map((state) => ({ - ...state, - custom_properties: state.custom_properties - ? JSON.stringify(state.custom_properties) - : undefined, - })).filter(group => group.enabled !== group.original_enabled) + const groupSettings = groupStates + .map((state) => ({ + ...state, + custom_properties: state.custom_properties + ? JSON.stringify(state.custom_properties) + : undefined, + })) + .filter((group) => group.enabled !== group.original_enabled); - const categorySettings = categoryStates.map(state => ({ - ...state, - custom_properties: state.custom_properties - ? JSON.stringify(state.custom_properties) - : undefined, - })).filter(state => state.enabled !== state.original_enabled) + const categorySettings = movieCategoryStates + .concat(seriesCategoryStates) + .map((state) => ({ + ...state, + custom_properties: state.custom_properties + ? JSON.stringify(state.custom_properties) + : undefined, + })) + .filter((state) => state.enabled !== state.original_enabled); // Update group settings via API endpoint - await API.updateM3UGroupSettings(playlist.id, groupSettings, categorySettings); + await API.updateM3UGroupSettings( + playlist.id, + groupSettings, + categorySettings + ); // Show notification about the refresh process notifications.show({ @@ -148,7 +158,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { Live - VOD + VOD - Movies + VOD - Series @@ -159,11 +170,21 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { /> - + + + + + diff --git a/frontend/src/components/forms/VODCategoryFilter.jsx b/frontend/src/components/forms/VODCategoryFilter.jsx index b86fea00..5d744785 100644 --- a/frontend/src/components/forms/VODCategoryFilter.jsx +++ b/frontend/src/components/forms/VODCategoryFilter.jsx @@ -18,6 +18,7 @@ const VODCategoryFilter = ({ playlist = null, categoryStates, setCategoryStates, + type, }) => { const categories = useVODStore((s) => s.categories); const [isLoading, setIsLoading] = useState(false); @@ -28,16 +29,26 @@ const VODCategoryFilter = ({ return; } - setCategoryStates(Object.values(categories).map(cat => { - const match = cat.m3u_accounts.find(acc => acc.m3u_account == playlist.id) - if (match) { - return { - ...cat, - enabled: match.enabled, - original_enabled: match.enabled, - } - } - })); + console.log(categories) + + setCategoryStates( + Object.values(categories) + .filter((cat) => + cat.m3u_accounts.find((acc) => acc.m3u_account == playlist.id) && cat.category_type == type + ) + .map((cat) => { + const match = cat.m3u_accounts.find( + (acc) => acc.m3u_account == playlist.id + ); + if (match) { + return { + ...cat, + enabled: match.enabled, + original_enabled: match.enabled, + }; + } + }) + ); }, [categories]); const toggleEnabled = (id) => { @@ -71,8 +82,6 @@ const VODCategoryFilter = ({ ); }; - console.log(categoryStates) - return ( @@ -98,9 +107,9 @@ const VODCategoryFilter = ({ verticalSpacing="xs" > {categoryStates - .filter((category) => - category.name.toLowerCase().includes(filter.toLowerCase()) - ) + .filter((category) => { + return category.name.toLowerCase().includes(filter.toLowerCase()); + }) .sort((a, b) => a.name.localeCompare(b.name)) .map((category) => ( imdb_id ? `https://www.imdb.com/title/${imdb_id}` : ''; -const tmdbUrl = (tmdb_id, type = 'movie') => tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : ''; +const imdbUrl = (imdb_id) => + imdb_id ? `https://www.imdb.com/title/${imdb_id}` : ''; +const tmdbUrl = (tmdb_id, type = 'movie') => + tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : ''; const formatDuration = (seconds) => { - if (!seconds) return ''; - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; + if (!seconds) return ''; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; }; const formatStreamLabel = (relation) => { - // Create a label for the stream that includes provider name and stream-specific info - const provider = relation.m3u_account.name; - const streamId = relation.stream_id; + // Create a label for the stream that includes provider name and stream-specific info + const provider = relation.m3u_account.name; + const streamId = relation.stream_id; - // Try to extract quality info - prioritizing the new quality_info field from backend - let qualityInfo = ''; + // Try to extract quality info - prioritizing the new quality_info field from backend + let qualityInfo = ''; - // 1. Check the new quality_info field from backend (PRIMARY) - if (relation.quality_info) { - if (relation.quality_info.quality) { - qualityInfo = ` - ${relation.quality_info.quality}`; - } else if (relation.quality_info.resolution) { - qualityInfo = ` - ${relation.quality_info.resolution}`; - } else if (relation.quality_info.bitrate) { - qualityInfo = ` - ${relation.quality_info.bitrate}`; + // 1. Check the new quality_info field from backend (PRIMARY) + if (relation.quality_info) { + if (relation.quality_info.quality) { + qualityInfo = ` - ${relation.quality_info.quality}`; + } else if (relation.quality_info.resolution) { + qualityInfo = ` - ${relation.quality_info.resolution}`; + } else if (relation.quality_info.bitrate) { + qualityInfo = ` - ${relation.quality_info.bitrate}`; + } + } + + // 2. Fallback: Check custom_properties detailed info structure + if (qualityInfo === '' && relation.custom_properties) { + const props = relation.custom_properties; + + // Check detailed_info structure (where the real data is!) + if (qualityInfo === '' && props.detailed_info) { + const detailedInfo = props.detailed_info; + + // Extract from video resolution + if ( + detailedInfo.video && + detailedInfo.video.width && + detailedInfo.video.height + ) { + const width = detailedInfo.video.width; + const height = detailedInfo.video.height; + + // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) + if (width >= 3840) { + qualityInfo = ' - 4K'; + } else if (width >= 1920) { + qualityInfo = ' - 1080p'; + } else if (width >= 1280) { + qualityInfo = ' - 720p'; + } else if (width >= 854) { + qualityInfo = ' - 480p'; + } else { + qualityInfo = ` - ${width}x${height}`; } + } + + // Extract from movie name in detailed_info + if (qualityInfo === '' && detailedInfo.name) { + const name = detailedInfo.name; + if (name.includes('4K') || name.includes('2160p')) { + qualityInfo = ' - 4K'; + } else if (name.includes('1080p') || name.includes('FHD')) { + qualityInfo = ' - 1080p'; + } else if (name.includes('720p') || name.includes('HD')) { + qualityInfo = ' - 720p'; + } else if (name.includes('480p')) { + qualityInfo = ' - 480p'; + } + } + + // Extract from bitrate in detailed_info + if ( + qualityInfo === '' && + detailedInfo.bitrate && + detailedInfo.bitrate > 0 + ) { + const bitrate = detailedInfo.bitrate; + if (bitrate >= 6000) { + qualityInfo = ' - 4K'; + } else if (bitrate >= 3000) { + qualityInfo = ' - 1080p'; + } else if (bitrate >= 1500) { + qualityInfo = ' - 720p'; + } else { + qualityInfo = ` - ${Math.round(bitrate / 1000)}Mbps`; + } + } } - // 2. Fallback: Check custom_properties detailed info structure - if (qualityInfo === '' && relation.custom_properties) { - const props = relation.custom_properties; - - // Check detailed_info structure (where the real data is!) - if (qualityInfo === '' && props.detailed_info) { - const detailedInfo = props.detailed_info; - - // Extract from video resolution - if (detailedInfo.video && detailedInfo.video.width && detailedInfo.video.height) { - const width = detailedInfo.video.width; - const height = detailedInfo.video.height; - - // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) - if (width >= 3840) { - qualityInfo = ' - 4K'; - } else if (width >= 1920) { - qualityInfo = ' - 1080p'; - } else if (width >= 1280) { - qualityInfo = ' - 720p'; - } else if (width >= 854) { - qualityInfo = ' - 480p'; - } else { - qualityInfo = ` - ${width}x${height}`; - } - } - - // Extract from movie name in detailed_info - if (qualityInfo === '' && detailedInfo.name) { - const name = detailedInfo.name; - if (name.includes('4K') || name.includes('2160p')) { - qualityInfo = ' - 4K'; - } else if (name.includes('1080p') || name.includes('FHD')) { - qualityInfo = ' - 1080p'; - } else if (name.includes('720p') || name.includes('HD')) { - qualityInfo = ' - 720p'; - } else if (name.includes('480p')) { - qualityInfo = ' - 480p'; - } - } - - // Extract from bitrate in detailed_info - if (qualityInfo === '' && detailedInfo.bitrate && detailedInfo.bitrate > 0) { - const bitrate = detailedInfo.bitrate; - if (bitrate >= 6000) { - qualityInfo = ' - 4K'; - } else if (bitrate >= 3000) { - qualityInfo = ' - 1080p'; - } else if (bitrate >= 1500) { - qualityInfo = ' - 720p'; - } else { - qualityInfo = ` - ${Math.round(bitrate / 1000)}Mbps`; - } - } - } - - // Check basic_data structure as another fallback - if (qualityInfo === '' && props.basic_data && props.basic_data.name) { - const name = props.basic_data.name; - if (name.includes('4K') || name.includes('2160p')) { - qualityInfo = ' - 4K'; - } else if (name.includes('1080p') || name.includes('FHD')) { - qualityInfo = ' - 1080p'; - } else if (name.includes('720p') || name.includes('HD')) { - qualityInfo = ' - 720p'; - } else if (name.includes('480p')) { - qualityInfo = ' - 480p'; - } - } + // Check basic_data structure as another fallback + if (qualityInfo === '' && props.basic_data && props.basic_data.name) { + const name = props.basic_data.name; + if (name.includes('4K') || name.includes('2160p')) { + qualityInfo = ' - 4K'; + } else if (name.includes('1080p') || name.includes('FHD')) { + qualityInfo = ' - 1080p'; + } else if (name.includes('720p') || name.includes('HD')) { + qualityInfo = ' - 720p'; + } else if (name.includes('480p')) { + qualityInfo = ' - 480p'; + } } + } - // 3. Final fallback: Try to extract from movie/episode name - if (qualityInfo === '') { - const content = relation.movie || relation.episode; - if (content && content.name) { - const name = content.name; - if (name.includes('4K') || name.includes('2160p')) { - qualityInfo = ' - 4K'; - } else if (name.includes('1080p') || name.includes('FHD')) { - qualityInfo = ' - 1080p'; - } else if (name.includes('720p') || name.includes('HD')) { - qualityInfo = ' - 720p'; - } else if (name.includes('480p')) { - qualityInfo = ' - 480p'; - } - } + // 3. Final fallback: Try to extract from movie/episode name + if (qualityInfo === '') { + const content = relation.movie || relation.episode; + if (content && content.name) { + const name = content.name; + if (name.includes('4K') || name.includes('2160p')) { + qualityInfo = ' - 4K'; + } else if (name.includes('1080p') || name.includes('FHD')) { + qualityInfo = ' - 1080p'; + } else if (name.includes('720p') || name.includes('HD')) { + qualityInfo = ' - 720p'; + } else if (name.includes('480p')) { + qualityInfo = ' - 480p'; + } } + } - // If no quality info and multiple streams from same provider, show stream ID - const finalLabel = `${provider}${qualityInfo}${qualityInfo === '' && streamId ? ` - Stream ${streamId}` : ''}`; - return finalLabel; + // If no quality info and multiple streams from same provider, show stream ID + const finalLabel = `${provider}${qualityInfo}${qualityInfo === '' && streamId ? ` - Stream ${streamId}` : ''}`; + return finalLabel; }; // Helper function to get technical details from selected provider or fallback to default VOD const getTechnicalDetails = (selectedProvider, defaultVOD) => { - let source = defaultVOD; // Default fallback + let source = defaultVOD; // Default fallback - // If a provider is selected, try to get technical details from various locations - if (selectedProvider) { - // 1. First try the movie/episode relation content - const content = selectedProvider.movie || selectedProvider.episode; + // If a provider is selected, try to get technical details from various locations + if (selectedProvider) { + // 1. First try the movie/episode relation content + const content = selectedProvider.movie || selectedProvider.episode; - if (content && (content.bitrate || content.video || content.audio)) { - source = content; - } - // 2. Try technical details directly on the relation object - else if (selectedProvider.bitrate || selectedProvider.video || selectedProvider.audio) { - source = selectedProvider; - } - // 3. Try to extract from custom_properties detailed_info (where quality data is stored) - else if (selectedProvider.custom_properties?.detailed_info) { - const detailedInfo = selectedProvider.custom_properties.detailed_info; - - // Create a synthetic source from detailed_info - const syntheticSource = { - bitrate: detailedInfo.bitrate || null, - video: detailedInfo.video || null, - audio: detailedInfo.audio || null - }; - - if (syntheticSource.bitrate || syntheticSource.video || syntheticSource.audio) { - source = syntheticSource; - } - } + if (content && (content.bitrate || content.video || content.audio)) { + source = content; } + // 2. Try technical details directly on the relation object + else if ( + selectedProvider.bitrate || + selectedProvider.video || + selectedProvider.audio + ) { + source = selectedProvider; + } + // 3. Try to extract from custom_properties detailed_info (where quality data is stored) + else if (selectedProvider.custom_properties?.detailed_info) { + const detailedInfo = selectedProvider.custom_properties.detailed_info; - return { - bitrate: source?.bitrate, - video: source?.video, - audio: source?.audio - }; + // Create a synthetic source from detailed_info + const syntheticSource = { + bitrate: detailedInfo.bitrate || null, + video: detailedInfo.video || null, + audio: detailedInfo.audio || null, + }; + + if ( + syntheticSource.bitrate || + syntheticSource.video || + syntheticSource.audio + ) { + source = syntheticSource; + } + } + } + + return { + bitrate: source?.bitrate, + video: source?.video, + audio: source?.audio, + }; }; const VODCard = ({ vod, onClick }) => { - const isEpisode = vod.type === 'episode'; + const isEpisode = vod.type === 'episode'; + const getDisplayTitle = () => { + if (isEpisode && vod.series) { + const seasonEp = + vod.season_number && vod.episode_number + ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}` + : ''; + return ( + + + {vod.series.name} + + + {seasonEp} - {vod.name} + + + ); + } + return {vod.name}; + }; + const handleCardClick = async () => { + // Just pass the basic vod info to the parent handler + onClick(vod); + }; - const getDisplayTitle = () => { - if (isEpisode && vod.series) { - const seasonEp = vod.season_number && vod.episode_number - ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}` - : ''; - return ( - - {vod.series.name} - {seasonEp} - {vod.name} - - ); - } - return {vod.name}; - }; + return ( + + + + {vod.logo?.url ? ( + {vod.name} + ) : ( + + + + )} - const handleCardClick = async () => { - // Just pass the basic vod info to the parent handler - onClick(vod); - }; + { + e.stopPropagation(); + onClick(vod); + }} + > + + - return ( - - - - {vod.logo?.url ? ( - {vod.name} - ) : ( - - - - )} + + {isEpisode ? 'Episode' : 'Movie'} + + + - { - e.stopPropagation(); - onClick(vod); - }} - > - - + + {getDisplayTitle()} - - {isEpisode ? 'Episode' : 'Movie'} - - - + + {vod.year && ( + + + + {vod.year} + + + )} - - {getDisplayTitle()} + {vod.duration && ( + + + + {formatDuration(vod.duration_secs)} + + + )} - - {vod.year && ( - - - {vod.year} - - )} + {vod.rating && ( + + + + {vod.rating} + + + )} + - {vod.duration && ( - - - {formatDuration(vod.duration_secs)} - - )} - - {vod.rating && ( - - - {vod.rating} - - )} - - - {vod.genre && ( - - {vod.genre} - - )} - - - ); + {vod.genre && ( + + {vod.genre} + + )} + + + ); }; const SeriesCard = ({ series, onClick }) => { - return ( - onClick(series)} - > - - - {series.logo?.url ? ( - {series.name} - ) : ( - - - - )} - {/* Add Series badge in the same position as Movie badge */} - - Series - - - + return ( + onClick(series)} + > + + + {series.logo?.url ? ( + {series.name} + ) : ( + + + + )} + {/* Add Series badge in the same position as Movie badge */} + + Series + + + - - {series.name} + + {series.name} - - {series.year && ( - - - {series.year} - - )} - {series.rating && ( - - - {series.rating} - - )} - + + {series.year && ( + + + + {series.year} + + + )} + {series.rating && ( + + + + {series.rating} + + + )} + - {series.genre && ( - - {series.genre} - - )} - - - ); + {series.genre && ( + + {series.genre} + + )} + + + ); }; const SeriesModal = ({ series, opened, onClose }) => { - const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore(); - const showVideo = useVideoStore((s) => s.showVideo); - const env_mode = useSettingsStore((s) => s.environment.env_mode); - const [detailedSeries, setDetailedSeries] = useState(null); - const [loadingDetails, setLoadingDetails] = useState(false); - const [activeTab, setActiveTab] = useState(null); - 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); + const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore(); + const showVideo = useVideoStore((s) => s.showVideo); + const env_mode = useSettingsStore((s) => s.environment.env_mode); + const [detailedSeries, setDetailedSeries] = useState(null); + const [loadingDetails, setLoadingDetails] = useState(false); + const [activeTab, setActiveTab] = useState(null); + 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) { - // Fetch detailed series info which now includes episodes - setLoadingDetails(true); - fetchSeriesInfo(series.id) - .then((details) => { - setDetailedSeries(details); - // Check if episodes were fetched - if (!details.episodes_fetched) { - // Episodes not yet fetched, may need to wait for background fetch - } - }) - .catch((error) => { - console.warn('Failed to fetch series details, using basic info:', error); - setDetailedSeries(series); // Fallback to basic data - }) - .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, fetchSeriesProviders, selectedProvider]); - - useEffect(() => { - if (!opened) { - setDetailedSeries(null); - setLoadingDetails(false); - setProviders([]); - setSelectedProvider(null); - setLoadingProviders(false); - } - }, [opened]); - - // Get episodes from the store based on the series ID - const seriesEpisodes = React.useMemo(() => { - if (!detailedSeries) return []; - - // Try to get episodes from the fetched data - if (detailedSeries.episodesList) { - return detailedSeries.episodesList.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); - }); - } - - // If no episodes in detailed series, return empty array - return []; - }, [detailedSeries]); - - // Group episodes by season - const episodesBySeason = React.useMemo(() => { - const grouped = {}; - seriesEpisodes.forEach(episode => { - const season = episode.season_number || 1; - if (!grouped[season]) { - grouped[season] = []; - } - grouped[season].push(episode); + useEffect(() => { + if (opened && series) { + // Fetch detailed series info which now includes episodes + setLoadingDetails(true); + fetchSeriesInfo(series.id) + .then((details) => { + setDetailedSeries(details); + // Check if episodes were fetched + if (!details.episodes_fetched) { + // Episodes not yet fetched, may need to wait for background fetch + } + }) + .catch((error) => { + console.warn( + 'Failed to fetch series details, using basic info:', + error + ); + setDetailedSeries(series); // Fallback to basic data + }) + .finally(() => { + setLoadingDetails(false); }); - return grouped; - }, [seriesEpisodes]); - // Get available seasons sorted - const seasons = React.useMemo(() => { - return Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b); - }, [episodesBySeason]); + // 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, fetchSeriesProviders, selectedProvider]); - // Update active tab when seasons change or modal opens - React.useEffect(() => { - if (seasons.length > 0) { - if (!activeTab || !seasons.includes(parseInt(activeTab.replace('season-', '')))) { - setActiveTab(`season-${seasons[0]}`); - } + useEffect(() => { + if (!opened) { + setDetailedSeries(null); + setLoadingDetails(false); + setProviders([]); + setSelectedProvider(null); + setLoadingProviders(false); + } + }, [opened]); + + // Get episodes from the store based on the series ID + const seriesEpisodes = React.useMemo(() => { + if (!detailedSeries) return []; + + // Try to get episodes from the fetched data + if (detailedSeries.episodesList) { + return detailedSeries.episodesList.sort((a, b) => { + if (a.season_number !== b.season_number) { + return (a.season_number || 0) - (b.season_number || 0); } - }, [seasons, activeTab]); + return (a.episode_number || 0) - (b.episode_number || 0); + }); + } - // Reset tab when modal closes - React.useEffect(() => { - if (!opened) { - setActiveTab(null); - } - }, [opened]); + // If no episodes in detailed series, return empty array + return []; + }, [detailedSeries]); - const handlePlayEpisode = (episode) => { - let streamUrl = `/proxy/vod/episode/${episode.uuid}`; + // Group episodes by season + const episodesBySeason = React.useMemo(() => { + const grouped = {}; + seriesEpisodes.forEach((episode) => { + const season = episode.season_number || 1; + if (!grouped[season]) { + grouped[season] = []; + } + grouped[season].push(episode); + }); + return grouped; + }, [seriesEpisodes]); - // Add selected provider as query parameter if available - if (selectedProvider) { - // Use stream_id for most specific selection, fallback to account_id - if (selectedProvider.stream_id) { - streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; - } else { - streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; - } - } + // Get available seasons sorted + const seasons = React.useMemo(() => { + return Object.keys(episodesBySeason) + .map(Number) + .sort((a, b) => a - b); + }, [episodesBySeason]); - if (env_mode === 'dev') { - streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; - } else { - streamUrl = `${window.location.origin}${streamUrl}`; - } - showVideo(streamUrl, 'vod', episode); - }; + // Update active tab when seasons change or modal opens + React.useEffect(() => { + if (seasons.length > 0) { + if ( + !activeTab || + !seasons.includes(parseInt(activeTab.replace('season-', ''))) + ) { + setActiveTab(`season-${seasons[0]}`); + } + } + }, [seasons, activeTab]); - const handleEpisodeRowClick = (episode) => { - setExpandedEpisode(expandedEpisode === episode.id ? null : episode.id); - }; + // Reset tab when modal closes + React.useEffect(() => { + if (!opened) { + setActiveTab(null); + } + }, [opened]); - // Helper to get embeddable YouTube URL - const getEmbedUrl = (url) => { - if (!url) return ''; - // Accepts full YouTube URLs or just IDs - const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/); - const videoId = match ? match[1] : url; - return `https://www.youtube.com/embed/${videoId}`; - }; + const handlePlayEpisode = (episode) => { + let streamUrl = `/proxy/vod/episode/${episode.uuid}`; - if (!series) return null; + // Add selected provider as query parameter if available + if (selectedProvider) { + // Use stream_id for most specific selection, fallback to account_id + if (selectedProvider.stream_id) { + streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; + } else { + streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + } + } - // Use detailed data if available, otherwise use basic series data - const displaySeries = detailedSeries || series; + if (env_mode === 'dev') { + streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; + } else { + streamUrl = `${window.location.origin}${streamUrl}`; + } + showVideo(streamUrl, 'vod', episode); + }; - return ( - <> - - - {/* Backdrop image as background */} - {displaySeries.backdrop_path && displaySeries.backdrop_path.length > 0 && ( - <> - {`${displaySeries.name} - {/* Overlay for readability */} - - + const handleEpisodeRowClick = (episode) => { + setExpandedEpisode(expandedEpisode === episode.id ? null : episode.id); + }; + + // Helper to get embeddable YouTube URL + const getEmbedUrl = (url) => { + if (!url) return ''; + // Accepts full YouTube URLs or just IDs + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/); + const videoId = match ? match[1] : url; + return `https://www.youtube.com/embed/${videoId}`; + }; + + if (!series) return null; + + // Use detailed data if available, otherwise use basic series data + const displaySeries = detailedSeries || series; + + return ( + <> + + + {/* Backdrop image as background */} + {displaySeries.backdrop_path && + displaySeries.backdrop_path.length > 0 && ( + <> + {`${displaySeries.name} + {/* Overlay for readability */} + + + )} + + {/* Modal content above backdrop */} + + + {loadingDetails && ( + + + + Loading series details and episodes... + + + )} + + {/* Series poster and basic info */} + + {displaySeries.series_image || displaySeries.logo?.url ? ( + + {displaySeries.name} + + ) : ( + + + + )} + + + {displaySeries.name} + + {/* Original name if different */} + {displaySeries.o_name && + displaySeries.o_name !== displaySeries.name && ( + + Original: {displaySeries.o_name} + )} - {/* Modal content above backdrop */} - - - {loadingDetails && ( - - - Loading series details and episodes... - - )} + + {displaySeries.year && ( + {displaySeries.year} + )} + {displaySeries.rating && ( + {displaySeries.rating} + )} + {displaySeries.age && ( + {displaySeries.age} + )} + Series + {displaySeries.episode_count && ( + + {displaySeries.episode_count} episodes + + )} + {/* imdb_id and tmdb_id badges */} + {displaySeries.imdb_id && ( + + IMDb + + )} + {displaySeries.tmdb_id && ( + + TMDb + + )} + - {/* Series poster and basic info */} - - {(displaySeries.series_image || displaySeries.logo?.url) ? ( - - {displaySeries.name} - - ) : ( - - - - )} + {/* Release date */} + {displaySeries.release_date && ( + + Release Date:{' '} + {displaySeries.release_date} + + )} - - {displaySeries.name} + {displaySeries.genre && ( + + Genre: {displaySeries.genre} + + )} - {/* Original name if different */} - {displaySeries.o_name && displaySeries.o_name !== displaySeries.name && ( - - Original: {displaySeries.o_name} - + {displaySeries.director && ( + + Director: {displaySeries.director} + + )} + + {displaySeries.cast && ( + + Cast: {displaySeries.cast} + + )} + + {displaySeries.country && ( + + Country: {displaySeries.country} + + )} + + {/* Description */} + {displaySeries.description && ( + + + Description + + {displaySeries.description} + + )} + + {/* Watch Trailer button if available */} + {displaySeries.youtube_trailer && ( + + )} + + + + {/* Provider Information */} + + + Stream Selection + {loadingProviders && ( + + )} + + {providers.length === 0 && + !loadingProviders && + displaySeries.m3u_account ? ( + + + {displaySeries.m3u_account.name} + + {displaySeries.m3u_account.account_type && ( + + {displaySeries.m3u_account.account_type === 'XC' + ? 'Xtream Codes' + : 'Standard M3U'} + + )} + + ) : 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[0].stream_id && ( + + Stream {providers[0].stream_id} + + )} + + ) : providers.length > 1 ? ( + ({ - value: provider.id.toString(), - label: formatStreamLabel(provider) - }))} - value={selectedProvider?.id?.toString() || ''} - onChange={(value) => { - const provider = providers.find(p => p.id.toString() === value); - setSelectedProvider(provider); - }} - placeholder="Select stream..." - style={{ maxWidth: 350 }} - disabled={loadingProviders} - /> - ) : null} - - - - - - Episodes - {seriesEpisodes.length > 0 && ( - <> ({seriesEpisodes.length})</> - )} - - - {loadingDetails ? ( - - - - ) : seasons.length > 0 ? ( - - - {seasons.map(season => ( - - Season {season} - - ))} - - - {seasons.map(season => ( - - - - - Ep - Title - Duration - Date - Action - - - - {episodesBySeason[season]?.map(episode => ( - - handleEpisodeRowClick(episode)} - > - - - {episode.episode_number || '?'} - - - - - - {episode.name} - - {episode.genre && ( - - {episode.genre} - - )} - - - - - {formatDuration(episode.duration_secs)} - - - - - {episode.air_date ? new Date(episode.air_date).toLocaleDateString() : 'N/A'} - - - - 0 && !selectedProvider} - onClick={(e) => { - e.stopPropagation(); - handlePlayEpisode(episode); - }} - > - - - - - {expandedEpisode === episode.id && ( - - - - {/* Episode Image and Description Row */} - - {/* Episode Image */} - {episode.movie_image && ( - - - - )} - - {/* Episode Description */} - - {episode.description && ( - - Description - - {episode.description} - - - )} - - - - {/* Additional Episode Details */} - - {episode.rating && ( - - Rating - {episode.rating} - - )} - {/* IMDb and TMDb badges for episode */} - {(episode.imdb_id || displaySeries.tmdb_id) && ( - - - Links - {episode.imdb_id && ( - - IMDb - - )} - {displaySeries.tmdb_id && ( - - TMDb - - )} - - )} - - {episode.director && ( - - Director - {episode.director} - - )} - - {episode.actors && ( - - Cast - {episode.actors} - - )} - - - {/* Technical Details */} - {(episode.bitrate || episode.video || episode.audio) && ( - - Technical Details - - {episode.bitrate && episode.bitrate > 0 && ( - - Bitrate: {episode.bitrate} kbps - - )} - {episode.video && Object.keys(episode.video).length > 0 && ( - - Video:{' '} - {episode.video.codec_long_name || episode.video.codec_name} - {episode.video.width && episode.video.height - ? `, ${episode.video.width}x${episode.video.height}` - : ''} - - )} - {episode.audio && Object.keys(episode.audio).length > 0 && ( - - Audio:{' '} - {episode.audio.codec_long_name || episode.audio.codec_name} - {episode.audio.channels - ? `, ${episode.audio.channels} channels` - : ''} - - )} - - - )} - - {/* Provider Information */} - {episode.m3u_account && ( - - Provider: - - {episode.m3u_account.name || episode.m3u_account} - - - )} - - - - )} - - ))} - -
-
- ))} -
- ) : ( - - No episodes found for this series. - - )} -
-
-
-
- - {/* YouTube Trailer Modal */} - setTrailerModalOpened(false)} - title="Trailer" - size="xl" - centered - > - - {trailerUrl && ( -