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 ? (
+
+ ) : (
+
+
+
+ )}
- const handleCardClick = async () => {
- // Just pass the basic vod info to the parent handler
- onClick(vod);
- };
+ {
+ e.stopPropagation();
+ onClick(vod);
+ }}
+ >
+
+
- return (
-
-
-
- {vod.logo?.url ? (
-
- ) : (
-
-
-
- )}
+
+ {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 ? (
-
- ) : (
-
-
-
- )}
- {/* Add Series badge in the same position as Movie badge */}
-
- Series
-
-
-
+ return (
+ onClick(series)}
+ >
+
+
+ {series.logo?.url ? (
+
+ ) : (
+
+
+
+ )}
+ {/* 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 && (
- <>
-
- {/* 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 && (
+ <>
+
+ {/* 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}
+
+ {/* 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) ? (
-
-
-
- ) : (
-
-
-
- )}
+ {/* 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 ? (
+
+
+
+
+
+ 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}
+
)}
-
-
- {displaySeries.year && {displaySeries.year}}
- {displaySeries.rating && {displaySeries.rating}}
- {displaySeries.age && {displaySeries.age}}
- Series
- {displaySeries.episode_count && (
- {displaySeries.episode_count} episodes
+
+
+
+
+ {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 && (
+
+
+
)}
- {/* imdb_id and tmdb_id badges */}
- {displaySeries.imdb_id && (
-
+ {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 && (
-
+ )}
+ {displaySeries.tmdb_id && (
+
+ >
TMDb
-
+
+ )}
+
)}
-
- {/* Release date */}
- {displaySeries.release_date && (
-
- Release Date: {displaySeries.release_date}
-
- )}
-
- {displaySeries.genre && (
-
- Genre: {displaySeries.genre}
-
- )}
-
- {displaySeries.director && (
-
- Director: {displaySeries.director}
-
- )}
-
- {displaySeries.cast && (
-
- Cast: {displaySeries.cast}
-
- )}
-
- {displaySeries.country && (
-
- Country: {displaySeries.country}
-
- )}
-
- {/* Description */}
- {displaySeries.description && (
-
- Description
-
- {displaySeries.description}
+ {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`
+ : ''}
+
+ )}
+
- )}
+ )}
- {/* Watch Trailer button if available */}
- {displaySeries.youtube_trailer && (
-
- )}
-
-
+ {/* Provider Information */}
+ {episode.m3u_account && (
+
+
+ Provider:
+
+
+ {episode.m3u_account.name ||
+ episode.m3u_account}
+
+
+ )}
+
+
+
+ )}
+
+ ))}
+
+
+
+ ))}
+
+ ) : (
+
+ No episodes found for this series.
+
+ )}
+
+
+
+
- {/* 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 ? (
-
-
-
-
-
- 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 && (
-
- )}
-
-
- >
- );
+ {/* YouTube Trailer Modal */}
+ setTrailerModalOpened(false)}
+ title="Trailer"
+ size="xl"
+ centered
+ >
+
+ {trailerUrl && (
+
+ )}
+
+
+ >
+ );
};
const VODModal = ({ vod, opened, onClose }) => {
- const [detailedVOD, setDetailedVOD] = useState(null);
- const [loadingDetails, setLoadingDetails] = useState(false);
- const [trailerModalOpened, setTrailerModalOpened] = useState(false);
- const [trailerUrl, setTrailerUrl] = useState('');
- 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);
+ const [detailedVOD, setDetailedVOD] = useState(null);
+ const [loadingDetails, setLoadingDetails] = useState(false);
+ const [trailerModalOpened, setTrailerModalOpened] = useState(false);
+ const [trailerUrl, setTrailerUrl] = useState('');
+ 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) {
- // 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.error('Failed to fetch providers:', error);
- setProviders([]);
- })
- .finally(() => {
- setLoadingProviders(false);
- });
- }
- }, [opened, vod, detailedVOD, fetchMovieDetailsFromProvider, fetchMovieProviders, selectedProvider]);
-
- useEffect(() => {
- if (!opened) {
- setDetailedVOD(null);
+ useEffect(() => {
+ 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);
- setTrailerModalOpened(false);
- setTrailerUrl('');
- setProviders([]);
- setSelectedProvider(null);
- setLoadingProviders(false);
- }
- }, [opened]);
+ });
+ }
- const handlePlayVOD = () => {
- const vodToPlay = detailedVOD || vod;
- if (!vodToPlay) return;
+ // 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.error('Failed to fetch providers:', error);
+ setProviders([]);
+ })
+ .finally(() => {
+ setLoadingProviders(false);
+ });
+ }
+ }, [
+ opened,
+ vod,
+ detailedVOD,
+ fetchMovieDetailsFromProvider,
+ fetchMovieProviders,
+ selectedProvider,
+ ]);
- let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
+ useEffect(() => {
+ if (!opened) {
+ setDetailedVOD(null);
+ setLoadingDetails(false);
+ setTrailerModalOpened(false);
+ setTrailerUrl('');
+ setProviders([]);
+ setSelectedProvider(null);
+ setLoadingProviders(false);
+ }
+ }, [opened]);
- // 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}`;
- }
- }
+ const handlePlayVOD = () => {
+ const vodToPlay = detailedVOD || vod;
+ if (!vodToPlay) return;
- if (env_mode === 'dev') {
- streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
- } else {
- streamUrl = `${window.location.origin}${streamUrl}`;
- }
- showVideo(streamUrl, 'vod', vodToPlay);
- };
+ let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
+ // 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}`;
+ }
+ }
- // 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 (env_mode === 'dev') {
+ streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
+ } else {
+ streamUrl = `${window.location.origin}${streamUrl}`;
+ }
+ showVideo(streamUrl, 'vod', vodToPlay);
+ };
- if (!vod) return null;
+ // 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}`;
+ };
- // Use detailed data if available, otherwise use basic vod data
- const displayVOD = detailedVOD || vod;
+ if (!vod) return null;
- return (
- <>
-
-
- {/* Backdrop image as background */}
- {displayVOD.backdrop_path && displayVOD.backdrop_path.length > 0 && (
- <>
-
- {/* Overlay for readability */}
-
- >
+ // Use detailed data if available, otherwise use basic vod data
+ const displayVOD = detailedVOD || vod;
+
+ return (
+ <>
+
+
+ {/* Backdrop image as background */}
+ {displayVOD.backdrop_path && displayVOD.backdrop_path.length > 0 && (
+ <>
+
+ {/* Overlay for readability */}
+
+ >
+ )}
+ {/* Modal content above backdrop */}
+
+
+ {loadingDetails && (
+
+
+
+ Loading additional details...
+
+
+ )}
+
+ {/* Movie poster and basic info */}
+
+ {/* Use movie_image or logo */}
+ {displayVOD.movie_image || displayVOD.logo?.url ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {displayVOD.name}
+
+ {/* Original name if different */}
+ {displayVOD.o_name &&
+ displayVOD.o_name !== displayVOD.name && (
+
+ Original: {displayVOD.o_name}
+
)}
- {/* Modal content above backdrop */}
-
-
- {loadingDetails && (
-
-
- Loading additional details...
-
- )}
- {/* Movie poster and basic info */}
-
- {/* Use movie_image or logo */}
- {(displayVOD.movie_image || displayVOD.logo?.url) ? (
-
-
-
- ) : (
-
-
-
- )}
+
+ {displayVOD.year && (
+ {displayVOD.year}
+ )}
+ {displayVOD.duration_secs && (
+
+ {formatDuration(displayVOD.duration_secs)}
+
+ )}
+ {displayVOD.rating && (
+ {displayVOD.rating}
+ )}
+ {displayVOD.age && (
+ {displayVOD.age}
+ )}
+ Movie
+ {/* imdb_id and tmdb_id badges */}
+ {displayVOD.imdb_id && (
+
+ IMDb
+
+ )}
+ {displayVOD.tmdb_id && (
+
+ TMDb
+
+ )}
+
-
- {displayVOD.name}
+ {/* Release date */}
+ {displayVOD.release_date && (
+
+ Release Date: {displayVOD.release_date}
+
+ )}
- {/* Original name if different */}
- {displayVOD.o_name && displayVOD.o_name !== displayVOD.name && (
-
- Original: {displayVOD.o_name}
-
- )}
+ {displayVOD.genre && (
+
+ Genre: {displayVOD.genre}
+
+ )}
-
- {displayVOD.year && {displayVOD.year}}
- {displayVOD.duration_secs && {formatDuration(displayVOD.duration_secs)}}
- {displayVOD.rating && {displayVOD.rating}}
- {displayVOD.age && {displayVOD.age}}
- Movie
- {/* imdb_id and tmdb_id badges */}
- {displayVOD.imdb_id && (
-
- IMDb
-
- )}
- {displayVOD.tmdb_id && (
-
- TMDb
-
- )}
-
+ {displayVOD.director && (
+
+ Director: {displayVOD.director}
+
+ )}
- {/* Release date */}
- {displayVOD.release_date && (
-
- Release Date: {displayVOD.release_date}
-
- )}
+ {displayVOD.actors && (
+
+ Cast: {displayVOD.actors}
+
+ )}
- {displayVOD.genre && (
-
- Genre: {displayVOD.genre}
-
- )}
+ {displayVOD.country && (
+
+ Country: {displayVOD.country}
+
+ )}
- {displayVOD.director && (
-
- Director: {displayVOD.director}
-
- )}
-
- {displayVOD.actors && (
-
- Cast: {displayVOD.actors}
-
- )}
-
- {displayVOD.country && (
-
- Country: {displayVOD.country}
-
- )}
-
- {/* Description */}
- {displayVOD.description && (
-
- Description
-
- {displayVOD.description}
-
-
- )}
-
- {/* Watch Trailer button at top */}
- {displayVOD.youtube_trailer && (
-
- )}
-
-
-
- {/* Provider Information & Play Button Row */}
-
- {/* Provider Selection */}
- {providers.length > 0 && (
-
-
- Stream Selection
- {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'}
-
- )}
- {providers[0].stream_id && (
-
- Stream {providers[0].stream_id}
-
- )}
-
- ) : (
-
- )}
-
- {/* Fallback provider info if no providers loaded yet */}
- {providers.length === 0 && !loadingProviders && vod?.m3u_account && (
-
- Stream Selection
-
-
- {vod.m3u_account.name}
-
- {vod.m3u_account.account_type && (
-
- {vod.m3u_account.account_type === 'XC' ? 'Xtream Codes' : 'Standard M3U'}
-
- )}
-
-
- )}
-
- {/* Play Button */}
- }
- variant="filled"
- color="blue"
- size="md"
- onClick={handlePlayVOD}
- disabled={providers.length > 0 && !selectedProvider}
- >
- Play Movie
- {selectedProvider && (
-
- ({selectedProvider.stream_id ? `Stream ${selectedProvider.stream_id}` : selectedProvider.m3u_account.name})
-
- )}
-
-
-
- {/* Technical Details */}
- {(() => {
- const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
- const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
-
- return hasDetails && (
-
-
- Technical Details:
- {selectedProvider && (
-
- (from {selectedProvider.m3u_account.name}
- {selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
-
- )}
-
- {techDetails.bitrate && techDetails.bitrate > 0 && (
-
- Bitrate: {techDetails.bitrate} kbps
-
- )}
- {techDetails.video && Object.keys(techDetails.video).length > 0 && (
-
- Video:{' '}
- {(techDetails.video.codec_long_name && techDetails.video.codec_long_name !== 'unknown')
- ? techDetails.video.codec_long_name
- : techDetails.video.codec_name}
- {techDetails.video.profile ? ` (${techDetails.video.profile})` : ''}
- {techDetails.video.width && techDetails.video.height
- ? `, ${techDetails.video.width}x${techDetails.video.height}`
- : ''}
- {techDetails.video.display_aspect_ratio
- ? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}`
- : ''}
- {techDetails.video.bit_rate
- ? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps`
- : ''}
- {techDetails.video.r_frame_rate
- ? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps`
- : ''}
- {techDetails.video.tags?.encoder
- ? `, Encoder: ${techDetails.video.tags.encoder}`
- : ''}
-
- )}
- {techDetails.audio && Object.keys(techDetails.audio).length > 0 && (
-
- Audio:{' '}
- {(techDetails.audio.codec_long_name && techDetails.audio.codec_long_name !== 'unknown')
- ? techDetails.audio.codec_long_name
- : techDetails.audio.codec_name}
- {techDetails.audio.profile ? ` (${techDetails.audio.profile})` : ''}
- {techDetails.audio.channel_layout
- ? `, Channels: ${techDetails.audio.channel_layout}`
- : techDetails.audio.channels
- ? `, Channels: ${techDetails.audio.channels}`
- : ''}
- {techDetails.audio.sample_rate
- ? `, Sample Rate: ${techDetails.audio.sample_rate} Hz`
- : ''}
- {techDetails.audio.bit_rate
- ? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps`
- : ''}
- {techDetails.audio.tags?.handler_name
- ? `, Handler: ${techDetails.audio.tags.handler_name}`
- : ''}
-
- )}
-
- );
- })()}
- {/* YouTube trailer if available */}
-
+ {/* Description */}
+ {displayVOD.description && (
+
+
+ Description
+
+ {displayVOD.description}
-
-
- {/* YouTube Trailer Modal */}
- setTrailerModalOpened(false)}
- title="Trailer"
- size="xl"
- centered
- withCloseButton
- >
-
- {trailerUrl && (
-
+ )}
+
+ {/* Watch Trailer button at top */}
+ {displayVOD.youtube_trailer && (
+
+ )}
+
+
+
+ {/* Provider Information & Play Button Row */}
+
+ {/* Provider Selection */}
+ {providers.length > 0 && (
+
+
+ Stream Selection
+ {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'}
+
+ )}
+ {providers[0].stream_id && (
+
+ Stream {providers[0].stream_id}
+
+ )}
+
+ ) : (
+
-
- >
- );
+
+ )}
+
+ {/* Fallback provider info if no providers loaded yet */}
+ {providers.length === 0 &&
+ !loadingProviders &&
+ vod?.m3u_account && (
+
+
+ Stream Selection
+
+
+
+ {vod.m3u_account.name}
+
+ {vod.m3u_account.account_type && (
+
+ {vod.m3u_account.account_type === 'XC'
+ ? 'Xtream Codes'
+ : 'Standard M3U'}
+
+ )}
+
+
+ )}
+
+ {/* Play Button */}
+ }
+ variant="filled"
+ color="blue"
+ size="md"
+ onClick={handlePlayVOD}
+ disabled={providers.length > 0 && !selectedProvider}
+ >
+ Play Movie
+ {selectedProvider && (
+
+ (
+ {selectedProvider.stream_id
+ ? `Stream ${selectedProvider.stream_id}`
+ : selectedProvider.m3u_account.name}
+ )
+
+ )}
+
+
+
+ {/* Technical Details */}
+ {(() => {
+ const techDetails = getTechnicalDetails(
+ selectedProvider,
+ displayVOD
+ );
+ const hasDetails =
+ techDetails.bitrate || techDetails.video || techDetails.audio;
+
+ return (
+ hasDetails && (
+
+
+ Technical Details:
+ {selectedProvider && (
+
+ (from {selectedProvider.m3u_account.name}
+ {selectedProvider.stream_id &&
+ ` - Stream ${selectedProvider.stream_id}`}
+ )
+
+ )}
+
+ {techDetails.bitrate && techDetails.bitrate > 0 && (
+
+ Bitrate: {techDetails.bitrate} kbps
+
+ )}
+ {techDetails.video &&
+ Object.keys(techDetails.video).length > 0 && (
+
+ Video:{' '}
+ {techDetails.video.codec_long_name &&
+ techDetails.video.codec_long_name !== 'unknown'
+ ? techDetails.video.codec_long_name
+ : techDetails.video.codec_name}
+ {techDetails.video.profile
+ ? ` (${techDetails.video.profile})`
+ : ''}
+ {techDetails.video.width && techDetails.video.height
+ ? `, ${techDetails.video.width}x${techDetails.video.height}`
+ : ''}
+ {techDetails.video.display_aspect_ratio
+ ? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}`
+ : ''}
+ {techDetails.video.bit_rate
+ ? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps`
+ : ''}
+ {techDetails.video.r_frame_rate
+ ? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps`
+ : ''}
+ {techDetails.video.tags?.encoder
+ ? `, Encoder: ${techDetails.video.tags.encoder}`
+ : ''}
+
+ )}
+ {techDetails.audio &&
+ Object.keys(techDetails.audio).length > 0 && (
+
+ Audio:{' '}
+ {techDetails.audio.codec_long_name &&
+ techDetails.audio.codec_long_name !== 'unknown'
+ ? techDetails.audio.codec_long_name
+ : techDetails.audio.codec_name}
+ {techDetails.audio.profile
+ ? ` (${techDetails.audio.profile})`
+ : ''}
+ {techDetails.audio.channel_layout
+ ? `, Channels: ${techDetails.audio.channel_layout}`
+ : techDetails.audio.channels
+ ? `, Channels: ${techDetails.audio.channels}`
+ : ''}
+ {techDetails.audio.sample_rate
+ ? `, Sample Rate: ${techDetails.audio.sample_rate} Hz`
+ : ''}
+ {techDetails.audio.bit_rate
+ ? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps`
+ : ''}
+ {techDetails.audio.tags?.handler_name
+ ? `, Handler: ${techDetails.audio.tags.handler_name}`
+ : ''}
+
+ )}
+
+ )
+ );
+ })()}
+ {/* YouTube trailer if available */}
+
+
+
+
+ {/* YouTube Trailer Modal */}
+ setTrailerModalOpened(false)}
+ title="Trailer"
+ size="xl"
+ centered
+ withCloseButton
+ >
+
+ {trailerUrl && (
+
+ )}
+
+
+ >
+ );
};
const MIN_CARD_WIDTH = 260;
const MAX_CARD_WIDTH = 320;
const useCardColumns = () => {
- const [columns, setColumns] = useState(4);
+ const [columns, setColumns] = useState(4);
- useEffect(() => {
- const calcColumns = () => {
- const container = document.getElementById('vods-container');
- const width = container ? container.offsetWidth : window.innerWidth;
- let colCount = Math.floor(width / MIN_CARD_WIDTH);
- if (colCount < 1) colCount = 1;
- if (colCount > 6) colCount = 6;
- setColumns(colCount);
- };
- calcColumns();
- window.addEventListener('resize', calcColumns);
- return () => window.removeEventListener('resize', calcColumns);
- }, []);
+ useEffect(() => {
+ const calcColumns = () => {
+ const container = document.getElementById('vods-container');
+ const width = container ? container.offsetWidth : window.innerWidth;
+ let colCount = Math.floor(width / MIN_CARD_WIDTH);
+ if (colCount < 1) colCount = 1;
+ if (colCount > 6) colCount = 6;
+ setColumns(colCount);
+ };
+ calcColumns();
+ window.addEventListener('resize', calcColumns);
+ return () => window.removeEventListener('resize', calcColumns);
+ }, []);
- return columns;
+ return columns;
};
const VODsPage = () => {
- const {
- movies,
- series,
- // episodes, loading - removed as unused
- categories,
- filters,
- currentPage,
- totalCount,
- pageSize,
- setFilters,
- setPage,
- fetchMovies,
- fetchSeries,
- fetchCategories
- } = useVODStore();
+ const movies = useVODStore((s) => s.movies);
+ const series = useVODStore((s) => s.series);
+ const allCategories = useVODStore((s) => s.categories);
+ const filters = useVODStore((s) => s.filters);
+ const currentPage = useVODStore((s) => s.currentPage);
+ const totalCount = useVODStore((s) => s.totalCount);
+ const pageSize = useVODStore((s) => s.pageSize);
+ const setFilters = useVODStore((s) => s.setFilters);
+ const setPage = useVODStore((s) => s.setPage);
+ const fetchMovies = useVODStore((s) => s.fetchMovies);
+ const fetchSeries = useVODStore((s) => s.fetchSeries);
+ const fetchCategories = useVODStore((s) => s.fetchCategories);
- // 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);
- const [vodModalOpened, { open: openVODModal, close: closeVODModal }] = useDisclosure(false);
- const [initialLoad, setInitialLoad] = useState(true);
- const columns = useCardColumns();
+ // 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);
+ const [vodModalOpened, { open: openVODModal, close: closeVODModal }] =
+ useDisclosure(false);
+ const [initialLoad, setInitialLoad] = useState(true);
+ const columns = useCardColumns();
+ const [categories, setCategories] = useState({})
- // Helper function to get display data based on current filters
- const getDisplayData = () => {
- if (filters.type === 'series') {
- return Object.values(series).map(item => ({ ...item, _vodType: 'series' }));
- } else if (filters.type === 'movies') {
- return Object.values(movies).map(item => ({ ...item, _vodType: 'movie' }));
- } else {
- // 'all' - combine movies and series, tagging each with its type
- return [
- ...Object.values(movies).map(item => ({ ...item, _vodType: 'movie' })),
- ...Object.values(series).map(item => ({ ...item, _vodType: 'series' }))
- ];
+ // Helper function to get display data based on current filters
+ const getDisplayData = () => {
+ if (filters.type === 'series') {
+ return Object.values(series).map((item) => ({
+ ...item,
+ _vodType: 'series',
+ }));
+ } else if (filters.type === 'movies') {
+ return Object.values(movies).map((item) => ({
+ ...item,
+ _vodType: 'movie',
+ }));
+ } else {
+ // 'all' - combine movies and series, tagging each with its type
+ return [
+ ...Object.values(movies).map((item) => ({
+ ...item,
+ _vodType: 'movie',
+ })),
+ ...Object.values(series).map((item) => ({
+ ...item,
+ _vodType: 'series',
+ })),
+ ];
+ }
+ };
+
+ useEffect(() => {
+ // setCategories(allCategories)
+ setCategories(Object.keys(allCategories).reduce((acc, key) => {
+ const enabled = allCategories[key].m3u_accounts.find(account => account.enabled === true)
+ if (enabled) {
+ acc[key] = allCategories[key]
}
- };
- useEffect(() => {
- fetchCategories();
- }, [fetchCategories]);
+ return acc
+ }, {}))
+ }, [allCategories])
- useEffect(() => {
- if (filters.type === 'series') {
- fetchSeries().finally(() => setInitialLoad(false));
- } else {
- fetchMovies().finally(() => setInitialLoad(false));
- }
- }, [filters, currentPage, fetchMovies, fetchSeries]);
+ useEffect(() => {
+ fetchCategories();
+ }, [fetchCategories]);
- const handleVODCardClick = (vod) => {
- setSelectedVOD(vod);
- openVODModal();
- };
+ useEffect(() => {
+ if (filters.type === 'series') {
+ fetchSeries().finally(() => setInitialLoad(false));
+ } else {
+ fetchMovies().finally(() => setInitialLoad(false));
+ }
+ }, [filters, currentPage, fetchMovies, fetchSeries]);
- const handleSeriesClick = (series) => {
- setSelectedSeries(series);
- openSeriesModal();
- };
+ const handleVODCardClick = (vod) => {
+ setSelectedVOD(vod);
+ openVODModal();
+ };
- const categoryOptions = [
- { value: '', label: 'All Categories' },
- ...Object.values(categories).map(cat => ({
- value: `${cat.name}|${cat.category_type}`, // Combine name and type for uniqueness
- label: `${cat.name} (${cat.category_type})` // Show type in label for clarity
- }))
- ];
+ const handleSeriesClick = (series) => {
+ setSelectedSeries(series);
+ openSeriesModal();
+ };
- const totalPages = Math.ceil(totalCount / pageSize);
+ const onCategoryChange = (value) => {
+ setFilters({ category: value })
+ setPage(1)
+ }
- return (
-
-
-
- Video on Demand
-
+ const categoryOptions = [
+ { value: '', label: 'All Categories' },
+ ...Object.values(categories).map((cat) => ({
+ value: `${cat.name}|${cat.category_type}`, // Combine name and type for uniqueness
+ label: `${cat.name} (${cat.category_type})`, // Show type in label for clarity
+ })),
+ ];
- {/* Filters */}
-
- setFilters({ type: value })}
- data={[
- { label: 'All', value: 'all' },
- { label: 'Movies', value: 'movies' },
- { label: 'Series', value: 'series' }
- ]}
+ const totalPages = Math.ceil(totalCount / pageSize);
+
+ return (
+
+
+
+ Video on Demand
+
+
+ {/* Filters */}
+
+ setFilters({ type: value })}
+ data={[
+ { label: 'All', value: 'all' },
+ { label: 'Movies', value: 'movies' },
+ { label: 'Series', value: 'series' },
+ ]}
+ />
+
+ }
+ value={filters.search}
+ onChange={(e) => setFilters({ search: e.target.value })}
+ style={{ minWidth: 200 }}
+ />
+
+
+
+
+ {/* Content */}
+ {initialLoad ? (
+
+
+
+ ) : (
+ <>
+ {filters.type === 'series' ? (
+
+ {Object.values(series).map((seriesItem) => (
+
+
+
+ ))}
+
+ ) : (
+
+ {getDisplayData().map((item) => (
+
+ {item._vodType === 'series' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ )}
- }
- value={filters.search}
- onChange={(e) => setFilters({ search: e.target.value })}
- style={{ minWidth: 200 }}
- />
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ )}
+ >
+ )}
+
-
+ {/* Series Episodes Modal */}
+
- {/* Content */}
- {initialLoad ? (
-
-
-
- ) : (
- <>
- {filters.type === 'series' ? (
-
- {Object.values(series).map(seriesItem => (
-
-
-
- ))}
-
- ) : (
-
- {getDisplayData().map(item => (
-
- {item._vodType === 'series' ? (
-
- ) : (
-
- )}
-
- ))}
-
- )}
-
- {/* Pagination */}
- {totalPages > 1 && (
-
-
-
- )}
- >
- )}
-
-
- {/* Series Episodes Modal */}
-
-
- {/* VOD Details Modal */}
-
-
- );
+ {/* VOD Details Modal */}
+
+
+ );
};
export default VODsPage;