From 2b58d7d46e605da2213bc786e52a73ba63d75658 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 25 Nov 2025 17:14:51 -0600 Subject: [PATCH] Enhancement: Ensure "Uncategorized" categories and relations exist for VOD accounts. This improves content management for movies and series without assigned categories. Closes #627 --- apps/m3u/api_views.py | 40 ++++++++++++++++++++ apps/vod/api_views.py | 55 ++++++++++++++++++++++++++- apps/vod/tasks.py | 87 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 878ae7c6..1f16f20f 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -152,6 +152,46 @@ class M3UAccountViewSet(viewsets.ModelViewSet): and not old_vod_enabled and new_vod_enabled ): + # Create Uncategorized categories immediately so they're available in the UI + from apps.vod.models import VODCategory, M3UVODCategoryRelation + + # Create movie Uncategorized category + movie_category, _ = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="movie", + defaults={} + ) + + # Create series Uncategorized category + series_category, _ = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="series", + defaults={} + ) + + # Create relations for both categories (disabled by default until first refresh) + account_custom_props = instance.custom_properties or {} + auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True) + + M3UVODCategoryRelation.objects.get_or_create( + category=movie_category, + m3u_account=instance, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + M3UVODCategoryRelation.objects.get_or_create( + category=series_category, + m3u_account=instance, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + # Trigger full VOD refresh from apps.vod.tasks import refresh_vod_content refresh_vod_content.delay(instance.id) diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 4ff1f82b..8cc55a11 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -18,7 +18,7 @@ from apps.accounts.permissions import ( ) from .models import ( Series, VODCategory, Movie, Episode, VODLogo, - M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation ) from .serializers import ( MovieSerializer, @@ -476,6 +476,59 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): except KeyError: return [Authenticated()] + def list(self, request, *args, **kwargs): + """Override list to ensure Uncategorized categories and relations exist for all XC accounts with VOD enabled""" + from apps.m3u.models import M3UAccount + + # Ensure Uncategorized categories exist + movie_category, _ = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="movie", + defaults={} + ) + + series_category, _ = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="series", + defaults={} + ) + + # Get all active XC accounts with VOD enabled + xc_accounts = M3UAccount.objects.filter( + account_type=M3UAccount.Types.XC, + is_active=True + ) + + for account in xc_accounts: + if account.custom_properties: + custom_props = account.custom_properties or {} + vod_enabled = custom_props.get("enable_vod", False) + + if vod_enabled: + # Ensure relations exist for this account + auto_enable_new = custom_props.get("auto_enable_new_groups_vod", True) + + M3UVODCategoryRelation.objects.get_or_create( + category=movie_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + M3UVODCategoryRelation.objects.get_or_create( + category=series_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + # Now proceed with normal list operation + return super().list(request, *args, **kwargs) + class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): """ViewSet that combines Movies and Series for unified 'All' view""" diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index e34e00e6..1170543a 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -127,6 +127,37 @@ def refresh_movies(client, account, categories_by_provider, relations, scan_star """Refresh movie content using single API call for all movies""" logger.info(f"Refreshing movies for account {account.name}") + # Ensure "Uncategorized" category exists for movies without a category + uncategorized_category, created = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="movie", + defaults={} + ) + + # Ensure there's a relation for the Uncategorized category + account_custom_props = account.custom_properties or {} + auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True) + + uncategorized_relation, rel_created = M3UVODCategoryRelation.objects.get_or_create( + category=uncategorized_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + if created: + logger.info(f"Created 'Uncategorized' category for movies") + if rel_created: + logger.info(f"Created relation for 'Uncategorized' category (enabled={auto_enable_new})") + + # Add uncategorized category to relations dict for easy access + relations[uncategorized_category.id] = uncategorized_relation + + # Add to categories_by_provider with a special key for items without category + categories_by_provider['__uncategorized__'] = uncategorized_category + # Get all movies in a single API call logger.info("Fetching all movies from provider...") all_movies_data = client.get_vod_streams() # No category_id = get all movies @@ -150,6 +181,37 @@ def refresh_series(client, account, categories_by_provider, relations, scan_star """Refresh series content using single API call for all series""" logger.info(f"Refreshing series for account {account.name}") + # Ensure "Uncategorized" category exists for series without a category + uncategorized_category, created = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="series", + defaults={} + ) + + # Ensure there's a relation for the Uncategorized category + account_custom_props = account.custom_properties or {} + auto_enable_new = account_custom_props.get("auto_enable_new_groups_series", True) + + uncategorized_relation, rel_created = M3UVODCategoryRelation.objects.get_or_create( + category=uncategorized_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + if created: + logger.info(f"Created 'Uncategorized' category for series") + if rel_created: + logger.info(f"Created relation for 'Uncategorized' category (enabled={auto_enable_new})") + + # Add uncategorized category to relations dict for easy access + relations[uncategorized_category.id] = uncategorized_relation + + # Add to categories_by_provider with a special key for items without category + categories_by_provider['__uncategorized__'] = uncategorized_category + # Get all series in a single API call logger.info("Fetching all series from provider...") all_series_data = client.get_series() # No category_id = get all series @@ -240,6 +302,7 @@ def batch_create_categories(categories_data, category_type, account): M3UVODCategoryRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) # Delete orphaned category relationships (categories no longer in the M3U source) + # Exclude "Uncategorized" from cleanup as it's a special category we manage current_category_ids = set(existing_categories[name].id for name in category_names) existing_relations = M3UVODCategoryRelation.objects.filter( m3u_account=account, @@ -248,7 +311,7 @@ def batch_create_categories(categories_data, category_type, account): relations_to_delete = [ rel for rel in existing_relations - if rel.category_id not in current_category_ids + if rel.category_id not in current_category_ids and rel.category.name != "Uncategorized" ] if relations_to_delete: @@ -331,7 +394,16 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N logger.debug("Skipping disabled category") continue else: - logger.warning(f"No category ID provided for movie {name}") + # Assign to Uncategorized category if no category_id provided + logger.debug(f"No category ID provided for movie {name}, assigning to 'Uncategorized'") + category = categories.get('__uncategorized__') + if category: + movie_data['_category_id'] = category.id + # Check if uncategorized is disabled + relation = relations.get(category.id, None) + if relation and not relation.enabled: + logger.debug("Skipping disabled 'Uncategorized' category") + continue # Extract metadata year = extract_year_from_data(movie_data, 'name') @@ -633,7 +705,16 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= logger.debug("Skipping disabled category") continue else: - logger.warning(f"No category ID provided for series {name}") + # Assign to Uncategorized category if no category_id provided + logger.debug(f"No category ID provided for series {name}, assigning to 'Uncategorized'") + category = categories.get('__uncategorized__') + if category: + series_data['_category_id'] = category.id + # Check if uncategorized is disabled + relation = relations.get(category.id, None) + if relation and not relation.enabled: + logger.debug("Skipping disabled 'Uncategorized' category") + continue # Extract metadata year = extract_year(series_data.get('releaseDate', ''))