diff --git a/apps/output/views.py b/apps/output/views.py index 2e496220..554c4806 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1099,7 +1099,7 @@ def xc_get_vod_streams(request, user, category_id=None): ), #'stream_icon': movie.logo.url if movie.logo else '', "rating": movie.rating or "0", - "rating_5based": float(movie.rating or 0) / 2 if movie.rating else 0, + "rating_5based": round(float(movie.rating or 0) / 2, 2) if movie.rating else 0, "added": str(movie.created_at.timestamp()), "is_adult": 0, "tmdb_id": movie.tmdb_id or "", @@ -1197,17 +1197,18 @@ def xc_get_series(request, user, category_id=None): ) ), "plot": series.description or "", - "cast": "", - "director": "", + "cast": series.custom_properties.get('cast', '') if series.custom_properties else "", + "director": series.custom_properties.get('director', '') if series.custom_properties else "", "genre": series.genre or "", - "release_date": str(series.year) if series.year else "", + "release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), "last_modified": int(relation.updated_at.timestamp()), "rating": series.rating or "0", - "rating_5based": float(series.rating or 0) / 2 if series.rating else 0, - "backdrop_path": [], - "youtube_trailer": "", - "episode_run_time": "", + "rating_5based": round(float(series.rating or 0) / 2, 2) if series.rating else 0, + "backdrop_path": series.custom_properties.get('backdrop_path', []) if series.custom_properties else [], + "youtube_trailer": series.custom_properties.get('youtube_trailer', '') if series.custom_properties else "", + "episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "", "category_id": str(relation.category.id) if relation.category else "0", + "category_ids": [int(relation.category.id)] if relation.category else [], }) return series_list @@ -1241,6 +1242,31 @@ def xc_get_series_info(request, user, series_id): except M3USeriesRelation.DoesNotExist: raise Http404() + # Check if we need to refresh detailed info (similar to vod api_views pattern) + try: + should_refresh = ( + not series_relation.last_episode_refresh or + series_relation.last_episode_refresh < timezone.now() - timedelta(hours=24) + ) + + # Check if detailed data has been fetched + custom_props = series_relation.custom_properties or {} + episodes_fetched = custom_props.get('episodes_fetched', False) + detailed_fetched = custom_props.get('detailed_fetched', False) + + # Force refresh if episodes/details have never been fetched or time interval exceeded + if not episodes_fetched or not detailed_fetched or should_refresh: + from apps.vod.tasks import refresh_series_episodes + account = series_relation.m3u_account + if account and account.is_active: + refresh_series_episodes(account, series, series_relation.external_series_id) + # Refresh objects from database after task completion + series.refresh_from_db() + series_relation.refresh_from_db() + + except Exception as e: + logger.error(f"Error refreshing series data for relation {series_relation.id}: {str(e)}") + # Get episodes for this series from the same M3U account episode_relations = M3UEpisodeRelation.objects.filter( episode__series=series, @@ -1284,11 +1310,63 @@ def xc_get_series_info(request, user, series_id): } }) - # Build response + # Build response using potentially refreshed data + series_data = { + 'name': series.name, + 'description': series.description or '', + 'year': series.year, + 'genre': series.genre or '', + 'rating': series.rating or '0', + 'cast': '', + 'director': '', + 'youtube_trailer': '', + 'episode_run_time': '', + 'backdrop_path': [], + } + + # Add detailed info from custom_properties if available + try: + if series.custom_properties: + custom_data = series.custom_properties + series_data.update({ + 'cast': custom_data.get('cast', ''), + 'director': custom_data.get('director', ''), + 'youtube_trailer': custom_data.get('youtube_trailer', ''), + 'episode_run_time': custom_data.get('episode_run_time', ''), + 'backdrop_path': custom_data.get('backdrop_path', []), + }) + + # Check relation custom_properties for detailed_info + if series_relation.custom_properties and 'detailed_info' in series_relation.custom_properties: + detailed_info = series_relation.custom_properties['detailed_info'] + + # Override with detailed_info values where available + for key in ['name', 'description', 'year', 'genre', 'rating']: + if detailed_info.get(key): + series_data[key] = detailed_info[key] + + # Handle plot vs description + if detailed_info.get('plot'): + series_data['description'] = detailed_info['plot'] + elif detailed_info.get('description'): + series_data['description'] = detailed_info['description'] + + # Update additional fields from detailed info + series_data.update({ + 'cast': detailed_info.get('cast', series_data['cast']), + 'director': detailed_info.get('director', series_data['director']), + 'youtube_trailer': detailed_info.get('youtube_trailer', series_data['youtube_trailer']), + 'episode_run_time': detailed_info.get('episode_run_time', series_data['episode_run_time']), + 'backdrop_path': detailed_info.get('backdrop_path', series_data['backdrop_path']), + }) + + except Exception as e: + logger.error(f"Error parsing series custom_properties: {str(e)}") + info = { "seasons": list(seasons.keys()), "info": { - "name": series.name, + "name": series_data['name'], "cover": ( None if not series.logo else build_absolute_uri_with_port( @@ -1296,17 +1374,17 @@ def xc_get_series_info(request, user, series_id): reverse("api:channels:logo-cache", args=[series.logo.id]) ) ), - "plot": series.description or "", - "cast": "", - "director": "", - "genre": series.genre or "", - "release_date": str(series.year) if series.year else "", + "plot": series_data['description'], + "cast": series_data['cast'], + "director": series_data['director'], + "genre": series_data['genre'], + "release_date": str(series_data['year']) if series_data['year'] else "", "last_modified": int(series_relation.updated_at.timestamp()), - "rating": series.rating or "0", - "rating_5based": float(series.rating or 0) / 2 if series.rating else 0, - "backdrop_path": [], - "youtube_trailer": "", - "episode_run_time": "", + "rating": series_data['rating'], + "rating_5based": round(float(series_data['rating'] or 0) / 2, 2) if series_data['rating'] else 0, + "backdrop_path": series_data['backdrop_path'], + "youtube_trailer": series_data['youtube_trailer'], + "episode_run_time": series_data['episode_run_time'], "category_id": str(series_relation.category.id) if series_relation.category else "0", }, "episodes": dict(seasons) diff --git a/apps/vod/admin.py b/apps/vod/admin.py index 0fc8f28d..c660f310 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -22,7 +22,7 @@ class SeriesAdmin(admin.ModelAdmin): @admin.register(Movie) class MovieAdmin(admin.ModelAdmin): - list_display = ['name', 'year', 'genre', 'duration', 'created_at'] + list_display = ['name', 'year', 'genre', 'duration_secs', 'created_at'] list_filter = ['year', 'created_at'] search_fields = ['name', 'description', 'tmdb_id', 'imdb_id'] readonly_fields = ['uuid', 'created_at', 'updated_at'] @@ -33,7 +33,7 @@ class MovieAdmin(admin.ModelAdmin): @admin.register(Episode) class EpisodeAdmin(admin.ModelAdmin): - list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'created_at'] + list_display = ['name', 'series', 'season_number', 'episode_number', 'duration_secs', 'created_at'] list_filter = ['series', 'season_number', 'created_at'] search_fields = ['name', 'description', 'series__name'] readonly_fields = ['uuid', 'created_at', 'updated_at'] diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index e4269e08..76e6419e 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -77,14 +77,6 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): serializer = M3UMovieRelationSerializer(relations, many=True) return Response(serializer.data) - def _convert_duration_to_minutes(self, duration_secs): - """Convert duration from seconds to minutes""" - if not duration_secs: - return 0 - try: - return int(duration_secs) // 60 - except (ValueError, TypeError): - return 0 @action(detail=True, methods=['get'], url_path='provider-info') def provider_info(self, request, pk=None): @@ -144,8 +136,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): 'tmdb_id': movie.tmdb_id or info.get('tmdb_id', ''), 'imdb_id': movie.imdb_id or info.get('imdb_id', ''), 'youtube_trailer': (movie.custom_properties or {}).get('youtube_trailer') or info.get('youtube_trailer') or info.get('trailer', ''), - 'duration': movie.duration or (int(info.get('duration_secs', 0)) // 60 if info.get('duration_secs') else None), - 'duration_secs': info.get('duration_secs', (movie.duration or 0) * 60), + 'duration_secs': movie.duration_secs or info.get('duration_secs'), 'age': info.get('age', ''), 'backdrop_path': (movie.custom_properties or {}).get('backdrop_path') or info.get('backdrop_path', []), 'cover': info.get('cover_big', ''), @@ -365,7 +356,7 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): 'description': episode.description, 'air_date': episode.air_date, 'plot': episode.description, - 'duration': episode.duration, + 'duration_secs': episode.duration_secs, 'rating': episode.rating, 'tmdb_id': episode.tmdb_id, 'imdb_id': episode.imdb_id, diff --git a/apps/vod/migrations/0002_episode_custom_properties.py b/apps/vod/migrations/0002_episode_custom_properties.py new file mode 100644 index 00000000..2035a47e --- /dev/null +++ b/apps/vod/migrations/0002_episode_custom_properties.py @@ -0,0 +1,16 @@ +# Generated by Django A.I. on 2025-08-09 +from django.db import migrations, models + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='episode', + name='custom_properties', + field=models.JSONField(blank=True, null=True, help_text='Custom properties for this episode'), + ), + ] diff --git a/apps/vod/migrations/0003_duration_secs.py b/apps/vod/migrations/0003_duration_secs.py new file mode 100644 index 00000000..0a3e81a0 --- /dev/null +++ b/apps/vod/migrations/0003_duration_secs.py @@ -0,0 +1,31 @@ +# Generated by Django A.I. on 2025-08-09 +from django.db import migrations, models + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0002_episode_custom_properties'), + ] + + operations = [ + migrations.RenameField( + model_name='movie', + old_name='duration', + new_name='duration_secs', + ), + migrations.AlterField( + model_name='movie', + name='duration_secs', + field=models.IntegerField(blank=True, null=True, help_text='Duration in seconds'), + ), + migrations.RenameField( + model_name='episode', + old_name='duration', + new_name='duration_secs', + ), + migrations.AlterField( + model_name='episode', + name='duration_secs', + field=models.IntegerField(blank=True, null=True, help_text='Duration in seconds'), + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index e53f8c5b..1607cf0a 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -78,7 +78,7 @@ class Movie(models.Model): year = models.IntegerField(blank=True, null=True) rating = models.CharField(max_length=10, blank=True, null=True) genre = models.CharField(max_length=255, blank=True, null=True) - duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes") + duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds") logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True) # Metadata IDs for deduplication @@ -113,7 +113,7 @@ class Episode(models.Model): description = models.TextField(blank=True, null=True) air_date = models.DateField(blank=True, null=True) rating = models.CharField(max_length=10, blank=True, null=True) - duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes") + duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds") # Episode specific fields series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='episodes') @@ -124,6 +124,9 @@ class Episode(models.Model): tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True) imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True) + # Custom properties for episode + custom_properties = models.JSONField(blank=True, null=True, help_text="Custom properties for this episode") + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 529125d3..f4cd07c5 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -120,26 +120,26 @@ def process_movie_basic(client, account, movie_data, category): # Extract trailer trailer = movie_data.get('trailer') or movie_data.get('youtube_trailer') or '' - duration_minutes = None + duration_secs = None # Try to extract duration from various possible fields if movie_data.get('duration_secs'): - duration_minutes = convert_duration_to_minutes(movie_data.get('duration_secs')) + duration_secs = int(movie_data.get('duration_secs')) elif movie_data.get('duration'): # Handle duration that might be in different formats duration_str = str(movie_data.get('duration')) if duration_str.isdigit(): - duration_minutes = int(duration_str) # Assume minutes if just a number + duration_secs = int(duration_str) * 60 # Assume minutes if just a number else: # Try to parse time format like "01:30:00" try: time_parts = duration_str.split(':') if len(time_parts) == 3: hours, minutes, seconds = map(int, time_parts) - duration_minutes = (hours * 60) + minutes + duration_secs = (hours * 3600) + (minutes * 60) + seconds elif len(time_parts) == 2: minutes, seconds = map(int, time_parts) - duration_minutes = minutes + duration_secs = minutes * 60 + seconds except (ValueError, AttributeError): pass @@ -148,7 +148,7 @@ def process_movie_basic(client, account, movie_data, category): 'plot': description, 'rating': rating, 'genre': genre, - 'duration_secs': movie_data.get('duration_secs'), + 'duration_secs': duration_secs, 'trailer': trailer, } @@ -213,35 +213,15 @@ def process_series_basic(client, account, series_data, category): tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id') imdb_id = series_data.get('imdb') or series_data.get('imdb_id') - # Extract additional metadata that matches the actual API response - description = series_data.get('plot') or series_data.get('description') or series_data.get('overview') or '' - rating = series_data.get('rating') or series_data.get('vote_average') or '' - genre = series_data.get('genre') or '' - - # Extract trailer - youtube_trailer = series_data.get('trailer') or series_data.get('youtube_trailer') or '' - - # Extract backdrop path - backdrop_path = series_data.get('backdrop_path') or '' - - # Build info dict with all extracted data - info = { - 'plot': description, - 'rating': rating, - 'genre': genre, - 'youtube_trailer': youtube_trailer, - 'backdrop_path': backdrop_path, - } - # Use find_or_create_series to handle duplicates properly + logger.debug(f"Processing series: {name} ({year})") series = find_or_create_series( name=name, year=year, tmdb_id=tmdb_id, imdb_id=imdb_id, - info=info + info=series_data ) - # Handle logo from basic data if available if series_data.get('cover'): logo, _ = Logo.objects.get_or_create( @@ -362,7 +342,7 @@ def process_episode(account, series, episode_data, season_number): 'description': description, 'rating': rating, 'air_date': air_date, - 'duration': convert_duration_to_minutes(info.get('duration_secs')), + 'duration_secs': info.get('duration_secs'), 'tmdb_id': info.get('tmdb_id'), 'imdb_id': info.get('imdb_id'), } @@ -433,9 +413,9 @@ def find_or_create_movie(name, year, tmdb_id, imdb_id, info): movie.imdb_id = imdb_id updated = True - duration = convert_duration_to_minutes(info.get('duration_secs')) - if duration and duration != movie.duration: - movie.duration = duration + duration_secs = info.get('duration_secs') + if duration_secs and duration_secs != movie.duration_secs: + movie.duration_secs = duration_secs updated = True # Update custom_properties with trailer and other metadata @@ -471,7 +451,7 @@ def find_or_create_movie(name, year, tmdb_id, imdb_id, info): description=info.get('plot') or info.get('description', ''), rating=info.get('rating', ''), genre=info.get('genre', ''), - duration=convert_duration_to_minutes(info.get('duration_secs')), + duration_secs=info.get('duration_secs'), custom_properties=custom_props if custom_props else None ) @@ -479,7 +459,7 @@ def find_or_create_movie(name, year, tmdb_id, imdb_id, info): def find_or_create_series(name, year, tmdb_id, imdb_id, info): """Find existing series or create new one based on metadata""" series = None - + updated = False # Try to find by TMDB ID first if tmdb_id: series = Series.objects.filter(tmdb_id=tmdb_id).first() @@ -496,56 +476,9 @@ def find_or_create_series(name, year, tmdb_id, imdb_id, info): if not series: series = Series.objects.filter(name=name).first() - # If we found an existing series, update it - if series: - updated = False - if info.get('plot') and info.get('plot') != series.description: - series.description = info.get('plot') - updated = True - if info.get('rating') and info.get('rating') != series.rating: - series.rating = info.get('rating') - updated = True - if info.get('genre') and info.get('genre') != series.genre: - series.genre = info.get('genre') - updated = True - if year and year != series.year: - series.year = year - updated = True - if tmdb_id and tmdb_id != series.tmdb_id: - series.tmdb_id = tmdb_id - updated = True - if imdb_id and imdb_id != series.imdb_id: - series.imdb_id = imdb_id - updated = True - - # Update custom_properties with trailer and other metadata - custom_props = series.custom_properties or {} - custom_props_updated = False - if info.get('trailer') and info.get('trailer') != custom_props.get('youtube_trailer'): - custom_props['youtube_trailer'] = info.get('trailer') - custom_props_updated = True - if info.get('youtube_trailer') and info.get('youtube_trailer') != custom_props.get('youtube_trailer'): - custom_props['youtube_trailer'] = info.get('youtube_trailer') - custom_props_updated = True - if info.get('backdrop_path') and info.get('backdrop_path') != custom_props.get('backdrop_path'): - custom_props['backdrop_path'] = info.get('backdrop_path') - custom_props_updated = True - if custom_props_updated: - series.custom_properties = custom_props - updated = True - - if updated: - series.save() - return series - - # Create new series if not found - custom_props = {} - if info.get('youtube_trailer'): - custom_props['youtube_trailer'] = info.get('youtube_trailer') - if info.get('backdrop_path'): - custom_props['backdrop_path'] = info.get('backdrop_path') - - return Series.objects.create( + # If still not found, create a new series + if not series: + series = Series.objects.create( name=name, year=year, tmdb_id=tmdb_id, @@ -553,9 +486,72 @@ def find_or_create_series(name, year, tmdb_id, imdb_id, info): description=info.get('plot', ''), rating=info.get('rating', ''), genre=info.get('genre', ''), - custom_properties=custom_props if custom_props else None ) + # Update series metadata + if info.get('plot') and info.get('plot') != series.description: + series.description = info.get('plot') + updated = True + if info.get('rating') and info.get('rating') != series.rating: + series.rating = info.get('rating') + updated = True + if info.get('genre') and info.get('genre') != series.genre: + series.genre = info.get('genre') + updated = True + if year and year != series.year: + series.year = year + updated = True + if tmdb_id and tmdb_id != series.tmdb_id: + series.tmdb_id = tmdb_id + updated = True + if imdb_id and imdb_id != series.imdb_id: + series.imdb_id = imdb_id + updated = True + + # Update custom_properties with trailer and other metadata + custom_props = series.custom_properties or {} + custom_props_updated = False + if info.get('trailer') and info.get('trailer') != custom_props.get('youtube_trailer'): + custom_props['youtube_trailer'] = info.get('trailer') + custom_props_updated = True + if info.get('youtube_trailer') and info.get('youtube_trailer') != custom_props.get('youtube_trailer'): + custom_props['youtube_trailer'] = info.get('youtube_trailer') + custom_props_updated = True + if info.get('backdrop_path') and info.get('backdrop_path') != custom_props.get('backdrop_path'): + custom_props['backdrop_path'] = info.get('backdrop_path') + custom_props_updated = True + if info.get('episode_run_time') and info.get('episode_run_time') != custom_props.get('episode_run_time'): + custom_props['episode_run_time'] = info.get('episode_run_time') + custom_props_updated = True + if info.get('cast') and info.get('cast') != custom_props.get('cast'): + custom_props['cast'] = info.get('cast') + custom_props_updated = True + if info.get('director') and info.get('director') != custom_props.get('director'): + custom_props['director'] = info.get('director') + custom_props_updated = True + if ( + (info.get('release_date') and info.get('release_date') != custom_props.get('release_date')) or + (info.get('releaseDate') and info.get('releaseDate') != custom_props.get('release_date')) or + (info.get('releasedate') and info.get('releasedate') != custom_props.get('release_date')) + ): + # Prefer release_date, then releaseDate, then releasedate + release_date_val = ( + info.get('release_date') or + info.get('releaseDate') or + info.get('releasedate') + ) + custom_props['release_date'] = release_date_val + custom_props_updated = True + if not year and custom_props.get('release_date'): + year = extract_year(custom_props.get('release_date')) + updated = True + if custom_props_updated: + series.custom_properties = custom_props + updated = True + if updated: + series.save() + return series + def extract_year(date_string): """Extract year from date string""" @@ -628,14 +624,6 @@ def extract_year_from_data(data, title_key='name'): return None -def convert_duration_to_minutes(duration_secs): - """Convert duration from seconds to minutes""" - if not duration_secs: - return None - try: - return int(duration_secs) // 60 - except (ValueError, TypeError): - return None @shared_task def cleanup_orphaned_vod_content(): @@ -727,9 +715,9 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False): movie.genre = info.get('genre') updated = True if info.get('duration_secs'): - duration = int(info.get('duration_secs')) // 60 - if duration != movie.duration: - movie.duration = duration + duration_secs = int(info.get('duration_secs')) + if duration_secs != movie.duration_secs: + movie.duration_secs = duration_secs updated = True # Check for releasedate or release_date release_date_value = info.get('releasedate') or info.get('release_date')