From a19bd14a844f78376ee08be1035dc2234a7d0d13 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 22 Aug 2025 16:59:00 -0400 Subject: [PATCH] added vod category filtering --- apps/channels/api_views.py | 4 +- ...er_channelgroupm3uaccount_channel_group.py | 19 + apps/channels/models.py | 4 +- apps/channels/serializers.py | 74 +- apps/m3u/api_views.py | 25 +- .../migrations/0002_m3uvodcategoryrelation.py | 32 + apps/vod/models.py | 23 + apps/vod/serializers.py | 22 +- apps/vod/tasks.py | 88 ++- frontend/src/api.js | 48 +- .../src/components/forms/LiveGroupFilter.jsx | 717 ++++++++++++++++++ .../src/components/forms/M3UGroupFilter.jsx | 600 ++------------- .../components/forms/VODCategoryFilter.jsx | 146 ++++ frontend/src/components/theme/Button.jsx | 19 + 14 files changed, 1184 insertions(+), 637 deletions(-) create mode 100644 apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py create mode 100644 apps/vod/migrations/0002_m3uvodcategoryrelation.py create mode 100644 frontend/src/components/forms/LiveGroupFilter.jsx create mode 100644 frontend/src/components/forms/VODCategoryFilter.jsx create mode 100644 frontend/src/components/theme/Button.jsx diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ee3ef8b9..849a0c9b 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -195,7 +195,7 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): from django.db.models import Count return ChannelGroup.objects.annotate( channel_count=Count('channels', distinct=True), - m3u_account_count=Count('m3u_account', distinct=True) + m3u_account_count=Count('m3u_accounts', distinct=True) ) def update(self, request, *args, **kwargs): @@ -237,7 +237,7 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): # Find groups with no channels and no M3U account associations unused_groups = ChannelGroup.objects.annotate( channel_count=Count('channels', distinct=True), - m3u_account_count=Count('m3u_account', distinct=True) + m3u_account_count=Count('m3u_accounts', distinct=True) ).filter( channel_count=0, m3u_account_count=0 diff --git a/apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py b/apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py new file mode 100644 index 00000000..7ee5544c --- /dev/null +++ b/apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.4 on 2025-08-22 20:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'), + ] + + operations = [ + migrations.AlterField( + model_name='channelgroupm3uaccount', + name='channel_group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_accounts', to='dispatcharr_channels.channelgroup'), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index d6c3faef..13cf0f54 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -95,7 +95,7 @@ class Stream(models.Model): ) last_seen = models.DateTimeField(db_index=True, default=datetime.now) custom_properties = models.TextField(null=True, blank=True) - + # Stream statistics fields stream_stats = models.JSONField( null=True, @@ -560,7 +560,7 @@ class ChannelStream(models.Model): class ChannelGroupM3UAccount(models.Model): channel_group = models.ForeignKey( - ChannelGroup, on_delete=models.CASCADE, related_name="m3u_account" + ChannelGroup, on_delete=models.CASCADE, related_name="m3u_accounts" ) m3u_account = models.ForeignKey( M3UAccount, on_delete=models.CASCADE, related_name="channel_group" diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 7c5ddd54..f89f6fe3 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -134,16 +134,54 @@ class StreamSerializer(serializers.ModelSerializer): return fields +class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): + m3u_accounts = serializers.IntegerField(source="m3u_account.id", read_only=True) + enabled = serializers.BooleanField() + auto_channel_sync = serializers.BooleanField(default=False) + auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False) + custom_properties = serializers.JSONField(required=False) + + class Meta: + model = ChannelGroupM3UAccount + 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 + try: + ret["custom_properties"] = json.loads(val) + except Exception: + ret["custom_properties"] = None + return ret + + 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) + # # Channel Group # class ChannelGroupSerializer(serializers.ModelSerializer): channel_count = serializers.IntegerField(read_only=True) m3u_account_count = serializers.IntegerField(read_only=True) + m3u_accounts = ChannelGroupM3UAccountSerializer( + many=True, + read_only=True + ) class Meta: model = ChannelGroup - fields = ["id", "name", "channel_count", "m3u_account_count"] + fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"] class ChannelProfileSerializer(serializers.ModelSerializer): @@ -347,40 +385,6 @@ class ChannelSerializer(serializers.ModelSerializer): return None -class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): - enabled = serializers.BooleanField() - auto_channel_sync = serializers.BooleanField(default=False) - auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False) - custom_properties = serializers.JSONField(required=False) - - class Meta: - model = ChannelGroupM3UAccount - fields = ["id", "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 - try: - ret["custom_properties"] = json.loads(val) - except Exception: - ret["custom_properties"] = None - return ret - - 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) - - class RecordingSerializer(serializers.ModelSerializer): class Meta: model = Recording diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 3eee3f01..4c9ca271 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -21,6 +21,7 @@ from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from core.models import UserAgent from apps.channels.models import ChannelGroupM3UAccount from core.serializers import UserAgentSerializer +from apps.vod.models import M3UVODCategoryRelation from .serializers import ( M3UAccountSerializer, @@ -86,9 +87,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet): # Check if VOD is enabled enable_vod = request.data.get("enable_vod", False) if enable_vod: - from apps.vod.tasks import refresh_vod_content + from apps.vod.tasks import refresh_categories - refresh_vod_content.delay(account_id) + refresh_categories.delay(account_id) # After the instance is created, return the response return response @@ -217,6 +218,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet): """Update auto channel sync settings for M3U account groups""" account = self.get_object() group_settings = request.data.get("group_settings", []) + category_settings = request.data.get("category_settings", []) try: for setting in group_settings: @@ -242,6 +244,25 @@ class M3UAccountViewSet(viewsets.ModelViewSet): }, ) + for setting in category_settings: + category_id = setting.get("id") + enabled = setting.get("enabled", True) + custom_properties = setting.get("custom_properties", {}) + + if category_id: + M3UVODCategoryRelation.objects.update_or_create( + category_id=category_id, + m3u_account=account, + defaults={ + "enabled": enabled, + "custom_properties": ( + custom_properties + if isinstance(custom_properties, str) + else json.dumps(custom_properties) + ), + }, + ) + return Response({"message": "Group settings updated successfully"}) except Exception as e: diff --git a/apps/vod/migrations/0002_m3uvodcategoryrelation.py b/apps/vod/migrations/0002_m3uvodcategoryrelation.py new file mode 100644 index 00000000..0f768a23 --- /dev/null +++ b/apps/vod/migrations/0002_m3uvodcategoryrelation.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.4 on 2025-08-22 18:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0016_m3uaccount_priority'), + ('vod', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='M3UVODCategoryRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enabled', models.BooleanField(default=True, help_text='Set to false to deactivate this category for the M3U account')), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.vodcategory')), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_relations', to='m3u.m3uaccount')), + ], + options={ + 'verbose_name': 'M3U VOD Category Relation', + 'verbose_name_plural': 'M3U VOD Category Relations', + 'unique_together': {('m3u_account', 'category')}, + }, + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index e4a99a0d..f2007b09 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -252,3 +252,26 @@ class M3UEpisodeRelation(models.Model): # We might support non XC accounts in the future # For now, return None return None + +class M3UVODCategoryRelation(models.Model): + """Links M3U accounts to categories with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='category_relations') + category = models.ForeignKey(VODCategory, on_delete=models.CASCADE, related_name='m3u_relations') + + enabled = models.BooleanField( + default=True, help_text="Set to false to deactivate this category for the M3U account" + ) + + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'M3U VOD Category Relation' + verbose_name_plural = 'M3U VOD Category Relations' + unique_together = [('m3u_account', 'category')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.category.name}" diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py index c8063190..5a672b33 100644 --- a/apps/vod/serializers.py +++ b/apps/vod/serializers.py @@ -1,19 +1,34 @@ from rest_framework import serializers from .models import ( Series, VODCategory, Movie, Episode, - M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation ) from apps.channels.serializers import LogoSerializer from apps.m3u.serializers import M3UAccountSerializer +class M3UVODCategoryRelationSerializer(serializers.ModelSerializer): + category = serializers.IntegerField(source="category.id") + m3u_account = serializers.IntegerField(source="m3u_account.id") + + class Meta: + model = M3UVODCategoryRelation + fields = ["category", "m3u_account", "enabled"] + + class VODCategorySerializer(serializers.ModelSerializer): category_type_display = serializers.CharField(source='get_category_type_display', read_only=True) + m3u_accounts = M3UVODCategoryRelationSerializer(many=True, source="m3u_relations", read_only=True) class Meta: model = VODCategory - fields = '__all__' - + fields = [ + "id", + "name", + "category_type", + "category_type_display", + "m3u_accounts", + ] class SeriesSerializer(serializers.ModelSerializer): logo = LogoSerializer(read_only=True) @@ -220,4 +235,3 @@ class EnhancedSeriesSerializer(serializers.ModelSerializer): def get_episode_count(self, obj): return obj.episodes.count() - diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index f34e5f40..8ee57185 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -6,7 +6,7 @@ from apps.m3u.models import M3UAccount from core.xtream_codes import Client as XtreamCodesClient from .models import ( VODCategory, Series, Movie, Episode, - M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation ) from apps.channels.models import Logo from datetime import datetime @@ -37,11 +37,12 @@ def refresh_vod_content(account_id): account.get_user_agent().user_agent ) as client: + movie_categories, series_categories = refresh_categories(account.id, client) # Refresh movies with batch processing - refresh_movies(client, account) + refresh_movies(client, account, movie_categories) # Refresh series with batch processing - refresh_series(client, account) + refresh_series(client, account, series_categories) end_time = timezone.now() duration = (end_time - start_time).total_seconds() @@ -53,24 +54,52 @@ 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) -def refresh_movies(client, account): - """Refresh movie content using single API call for all movies""" - logger.info(f"Refreshing movies for account {account.name}") + if not client: + client = XtreamCodesClient( + account.server_url, + account.username, + account.password, + account.get_user_agent().user_agent + ) + logger.info(f"Refreshing movie categories for account {account.name}") # First, get the category list to properly map category IDs and names logger.info("Fetching movie categories from provider...") categories_data = client.get_vod_categories() - category_map = batch_create_categories(categories_data, 'movie') + category_map = batch_create_categories(categories_data, 'movie', account) # Create a mapping from provider category IDs to our category objects - provider_category_id_map = {} + movies_category_id_map = {} for cat_data in categories_data: cat_name = cat_data.get('category_name', 'Unknown') provider_cat_id = cat_data.get('category_id') our_category = category_map.get(cat_name) if provider_cat_id and our_category: - provider_category_id_map[str(provider_cat_id)] = our_category + movies_category_id_map[str(provider_cat_id)] = our_category + + # Get the category list to properly map category IDs and names + logger.info("Fetching series categories from provider...") + categories_data = client.get_series_categories() + category_map = batch_create_categories(categories_data, 'series', account) + + # Create a mapping from provider category IDs to our category objects + series_category_id_map = {} + for cat_data in categories_data: + cat_name = cat_data.get('category_name', 'Unknown') + provider_cat_id = cat_data.get('category_id') + our_category = category_map.get(cat_name) + if provider_cat_id and our_category: + series_category_id_map[str(provider_cat_id)] = our_category + + return movies_category_id_map, series_category_id_map + +def refresh_movies(client, account, categories): + """Refresh movie content using single API call for all movies""" + logger.info(f"Refreshing movies for account {account.name}") # Get all movies in a single API call logger.info("Fetching all movies from provider...") @@ -79,7 +108,7 @@ def refresh_movies(client, account): # 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 = provider_category_id_map.get(provider_cat_id) if provider_cat_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 @@ -104,24 +133,10 @@ def refresh_movies(client, account): logger.info(f"Completed processing all {total_movies} movies in {total_chunks} chunks") -def refresh_series(client, account): +def refresh_series(client, account, categories): """Refresh series content using single API call for all series""" logger.info(f"Refreshing series for account {account.name}") - # First, get the category list to properly map category IDs and names - logger.info("Fetching series categories from provider...") - categories_data = client.get_series_categories() - category_map = batch_create_categories(categories_data, 'series') - - # Create a mapping from provider category IDs to our category objects - provider_category_id_map = {} - for cat_data in categories_data: - cat_name = cat_data.get('category_name', 'Unknown') - provider_cat_id = cat_data.get('category_id') - our_category = category_map.get(cat_name) - if provider_cat_id and our_category: - provider_category_id_map[str(provider_cat_id)] = our_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 @@ -129,7 +144,7 @@ def refresh_series(client, account): # 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 = provider_category_id_map.get(provider_cat_id) if provider_cat_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 @@ -186,10 +201,12 @@ def batch_create_categories_from_names(category_names, category_type): return existing_categories -def batch_create_categories(categories_data, category_type): +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 = [] + # Get existing categories existing_categories = { cat.name: cat for cat in VODCategory.objects.filter( @@ -203,9 +220,15 @@ def batch_create_categories(categories_data, category_type): 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( + category=existing_categories[name], + m3u_account=account, + custom_properties={}, + )) if new_categories: - VODCategory.objects.bulk_create(new_categories, ignore_conflicts=True) + VODCategory.objects.bulk_create_and_fetch(new_categories, ignore_conflicts=True) # Fetch the newly created categories newly_created = { cat.name: cat for cat in VODCategory.objects.filter( @@ -213,8 +236,17 @@ def batch_create_categories(categories_data, category_type): category_type=category_type ) } + + relations = relations + [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) + return existing_categories diff --git a/frontend/src/api.js b/frontend/src/api.js index 7719777a..a197ae4e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -735,13 +735,20 @@ export default class API { } } - static async updateM3UGroupSettings(playlistId, groupSettings) { + static async updateM3UGroupSettings( + playlistId, + groupSettings = [], + categorySettings = [] + ) { try { const response = await request( `${host}/api/m3u/accounts/${playlistId}/group-settings/`, { method: 'PATCH', - body: { group_settings: groupSettings }, + body: { + group_settings: groupSettings, + category_settings: categorySettings, + }, } ); // Fetch the updated playlist and update the store @@ -806,9 +813,12 @@ export default class API { } static async refreshVODContent(accountId) { try { - const response = await request(`${host}/api/m3u/accounts/${accountId}/refresh-vod/`, { - method: 'POST' - }); + const response = await request( + `${host}/api/m3u/accounts/${accountId}/refresh-vod/`, + { + method: 'POST', + } + ); return response; } catch (e) { errorNotification('Failed to refresh VOD content', e); @@ -1821,7 +1831,9 @@ export default class API { static async getMovieProviderInfo(movieId) { try { - const response = await request(`${host}/api/vod/movies/${movieId}/provider-info/`); + const response = await request( + `${host}/api/vod/movies/${movieId}/provider-info/` + ); return response; } catch (e) { errorNotification('Failed to retrieve movie provider info', e); @@ -1830,7 +1842,9 @@ export default class API { static async getMovieProviders(movieId) { try { - const response = await request(`${host}/api/vod/movies/${movieId}/providers/`); + const response = await request( + `${host}/api/vod/movies/${movieId}/providers/` + ); return response; } catch (e) { errorNotification('Failed to retrieve movie providers', e); @@ -1839,7 +1853,9 @@ export default class API { static async getSeriesProviders(seriesId) { try { - const response = await request(`${host}/api/vod/series/${seriesId}/providers/`); + const response = await request( + `${host}/api/vod/series/${seriesId}/providers/` + ); return response; } catch (e) { errorNotification('Failed to retrieve series providers', e); @@ -1855,11 +1871,12 @@ export default class API { } } - static async getSeriesInfo(seriesId) { try { // Call the provider-info endpoint that includes episodes - const response = await request(`${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true`); + const response = await request( + `${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true` + ); return response; } catch (e) { errorNotification('Failed to retrieve series info', e); @@ -1868,10 +1885,13 @@ export default class API { static async updateVODPosition(vodUuid, clientId, position) { try { - const response = await request(`${host}/proxy/vod/stream/${vodUuid}/position/`, { - method: 'POST', - body: { client_id: clientId, position } - }); + const response = await request( + `${host}/proxy/vod/stream/${vodUuid}/position/`, + { + method: 'POST', + body: { client_id: clientId, position }, + } + ); return response; } catch (e) { errorNotification('Failed to update playback position', e); diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx new file mode 100644 index 00000000..4e0aa4b8 --- /dev/null +++ b/frontend/src/components/forms/LiveGroupFilter.jsx @@ -0,0 +1,717 @@ +// Modal.js +import React, { useState, useEffect, forwardRef } from 'react'; +import { + TextInput, + Button, + Checkbox, + Flex, + Select, + Stack, + Group, + SimpleGrid, + Text, + NumberInput, + Divider, + Alert, + Box, + MultiSelect, + Tooltip, +} from '@mantine/core'; +import { Info } from 'lucide-react'; +import useChannelsStore from '../../store/channels'; +import { CircleCheck, CircleX } from 'lucide-react'; + +// Custom item component for MultiSelect with tooltip +const OptionWithTooltip = forwardRef( + ({ label, description, ...others }, ref) => ( + +
+ {label} +
+
+ ) +); + +const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => { + const channelGroups = useChannelsStore((s) => s.channelGroups); + const profiles = useChannelsStore((s) => s.profiles); + const [groupFilter, setGroupFilter] = useState(''); + + useEffect(() => { + if (Object.keys(channelGroups).length === 0) { + return; + } + + setGroupStates( + playlist.channel_groups.map((group) => { + // Parse custom_properties if present + let customProps = {}; + if (group.custom_properties) { + try { + customProps = + typeof group.custom_properties === 'string' + ? JSON.parse(group.custom_properties) + : group.custom_properties; + } catch (e) { + customProps = {}; + } + } + return { + ...group, + name: channelGroups[group.channel_group].name, + auto_channel_sync: group.auto_channel_sync || false, + auto_sync_channel_start: group.auto_sync_channel_start || 1.0, + custom_properties: customProps, + original_enabled: group.enabled, + }; + }) + ); + }, [playlist, channelGroups]); + + const toggleGroupEnabled = (id) => { + setGroupStates( + groupStates.map((state) => ({ + ...state, + enabled: state.channel_group == id ? !state.enabled : state.enabled, + })) + ); + }; + + const toggleAutoSync = (id) => { + setGroupStates( + groupStates.map((state) => ({ + ...state, + auto_channel_sync: + state.channel_group == id + ? !state.auto_channel_sync + : state.auto_channel_sync, + })) + ); + }; + + const updateChannelStart = (id, value) => { + setGroupStates( + groupStates.map((state) => ({ + ...state, + auto_sync_channel_start: + state.channel_group == id ? value : state.auto_sync_channel_start, + })) + ); + }; + + // Toggle force_dummy_epg in custom_properties for a group + const toggleForceDummyEPG = (id) => { + setGroupStates( + groupStates.map((state) => { + if (state.channel_group == id) { + const customProps = { ...(state.custom_properties || {}) }; + customProps.force_dummy_epg = !customProps.force_dummy_epg; + return { + ...state, + custom_properties: customProps, + }; + } + return state; + }) + ); + }; + + const selectAll = () => { + setGroupStates( + groupStates.map((state) => ({ + ...state, + enabled: state.name.toLowerCase().includes(groupFilter.toLowerCase()) + ? true + : state.enabled, + })) + ); + }; + + const deselectAll = () => { + setGroupStates( + groupStates.map((state) => ({ + ...state, + enabled: state.name.toLowerCase().includes(groupFilter.toLowerCase()) + ? false + : state.enabled, + })) + ); + }; + + return ( + + } color="blue" variant="light"> + + Auto Channel Sync: When enabled, channels will be + automatically created for all streams in the group during M3U updates, + and removed when streams are no longer present. Set a starting channel + number for each group to organize your channels. + + + + + setGroupFilter(event.currentTarget.value)} + style={{ flex: 1 }} + size="xs" + /> + + + + + + + + + {groupStates + .filter((group) => + group.name.toLowerCase().includes(groupFilter.toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((group) => ( + + {/* Group Enable/Disable Button */} + + + {/* Auto Sync Controls */} + + + toggleAutoSync(group.channel_group)} + size="xs" + /> + + + {group.auto_channel_sync && group.enabled && ( + <> + + updateChannelStart(group.channel_group, value) + } + min={1} + step={1} + size="xs" + precision={1} + /> + + {/* Auto Channel Sync Options Multi-Select */} + { + const selectedValues = []; + if (group.custom_properties?.force_dummy_epg) { + selectedValues.push('force_dummy_epg'); + } + if ( + group.custom_properties?.group_override !== + undefined + ) { + selectedValues.push('group_override'); + } + if ( + group.custom_properties?.name_regex_pattern !== + undefined || + group.custom_properties?.name_replace_pattern !== + undefined + ) { + selectedValues.push('name_regex'); + } + if ( + group.custom_properties?.name_match_regex !== + undefined + ) { + selectedValues.push('name_match_regex'); + } + if ( + group.custom_properties?.channel_profile_ids !== + undefined + ) { + selectedValues.push('profile_assignment'); + } + if ( + group.custom_properties?.channel_sort_order !== + undefined + ) { + selectedValues.push('channel_sort_order'); + } + return selectedValues; + })()} + onChange={(values) => { + // MultiSelect always returns an array + const selectedOptions = values || []; + + setGroupStates( + groupStates.map((state) => { + if (state.channel_group === group.channel_group) { + let newCustomProps = { + ...(state.custom_properties || {}), + }; + + // Handle force_dummy_epg + if ( + selectedOptions.includes('force_dummy_epg') + ) { + newCustomProps.force_dummy_epg = true; + } else { + delete newCustomProps.force_dummy_epg; + } + + // Handle group_override + if ( + selectedOptions.includes('group_override') + ) { + if ( + newCustomProps.group_override === undefined + ) { + newCustomProps.group_override = null; + } + } else { + delete newCustomProps.group_override; + } + + // Handle name_regex + if (selectedOptions.includes('name_regex')) { + if ( + newCustomProps.name_regex_pattern === + undefined + ) { + newCustomProps.name_regex_pattern = ''; + } + if ( + newCustomProps.name_replace_pattern === + undefined + ) { + newCustomProps.name_replace_pattern = ''; + } + } else { + delete newCustomProps.name_regex_pattern; + delete newCustomProps.name_replace_pattern; + } + + // Handle name_match_regex + if ( + selectedOptions.includes('name_match_regex') + ) { + if ( + newCustomProps.name_match_regex === + undefined + ) { + newCustomProps.name_match_regex = ''; + } + } else { + delete newCustomProps.name_match_regex; + } + + // Handle profile_assignment + if ( + selectedOptions.includes('profile_assignment') + ) { + if ( + newCustomProps.channel_profile_ids === + undefined + ) { + newCustomProps.channel_profile_ids = []; + } + } else { + delete newCustomProps.channel_profile_ids; + } + // Handle channel_sort_order + if ( + selectedOptions.includes('channel_sort_order') + ) { + if ( + newCustomProps.channel_sort_order === + undefined + ) { + newCustomProps.channel_sort_order = ''; + } + // Keep channel_sort_reverse if it exists + if ( + newCustomProps.channel_sort_reverse === + undefined + ) { + newCustomProps.channel_sort_reverse = false; + } + } else { + delete newCustomProps.channel_sort_order; + delete newCustomProps.channel_sort_reverse; // Remove reverse when sort is removed + } + + return { + ...state, + custom_properties: newCustomProps, + }; + } + return state; + }) + ); + }} + clearable + size="xs" + /> + {/* Show only channel_sort_order if selected */} + {group.custom_properties?.channel_sort_order !== + undefined && ( + <> + { + const newValue = value ? parseInt(value) : null; + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === group.channel_group + ) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + group_override: newValue, + }, + }; + } + return state; + }) + ); + }} + data={Object.values(channelGroups).map((g) => ({ + value: g.id.toString(), + label: g.name, + }))} + clearable + searchable + size="xs" + /> + + )} + + {/* Show regex fields only if name_regex is selected */} + {(group.custom_properties?.name_regex_pattern !== + undefined || + group.custom_properties?.name_replace_pattern !== + undefined) && ( + <> + + { + const val = e.currentTarget.value; + setGroupStates( + groupStates.map((state) => + state.channel_group === group.channel_group + ? { + ...state, + custom_properties: { + ...state.custom_properties, + name_regex_pattern: val, + }, + } + : state + ) + ); + }} + size="xs" + /> + + + { + const val = e.currentTarget.value; + setGroupStates( + groupStates.map((state) => + state.channel_group === group.channel_group + ? { + ...state, + custom_properties: { + ...state.custom_properties, + name_replace_pattern: val, + }, + } + : state + ) + ); + }} + size="xs" + /> + + + )} + + {/* Show name_match_regex field only if selected */} + {group.custom_properties?.name_match_regex !== + undefined && ( + + { + const val = e.currentTarget.value; + setGroupStates( + groupStates.map((state) => + state.channel_group === group.channel_group + ? { + ...state, + custom_properties: { + ...state.custom_properties, + name_match_regex: val, + }, + } + : state + ) + ); + }} + size="xs" + /> + + )} + + )} + + + ))} + + + + ); +}; + +export default LiveGroupFilter; diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 64c1d356..49404934 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -27,27 +27,31 @@ import { Box, MultiSelect, Tooltip, + Tabs, } from '@mantine/core'; import { Info } from 'lucide-react'; import useChannelsStore from '../../store/channels'; import { CircleCheck, CircleX } from 'lucide-react'; import { notifications } from '@mantine/notifications'; +import LiveGroupFilter from './LiveGroupFilter'; +import VODCategoryFilter from './VODCategoryFilter'; // Custom item component for MultiSelect with tooltip -const OptionWithTooltip = forwardRef(({ label, description, ...others }, ref) => ( - -
- {label} -
-
-)); +const OptionWithTooltip = forwardRef( + ({ label, description, ...others }, ref) => ( + +
+ {label} +
+
+ ) +); const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); - const profiles = useChannelsStore((s) => s.profiles); const [groupStates, setGroupStates] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [groupFilter, setGroupFilter] = useState(''); + const [categoryStates, setCategoryStates] = useState([]); useEffect(() => { if (Object.keys(channelGroups).length === 0) { @@ -60,9 +64,10 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { let customProps = {}; if (group.custom_properties) { try { - customProps = typeof group.custom_properties === 'string' - ? JSON.parse(group.custom_properties) - : group.custom_properties; + customProps = + typeof group.custom_properties === 'string' + ? JSON.parse(group.custom_properties) + : group.custom_properties; } catch (e) { customProps = {}; } @@ -78,63 +83,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { ); }, [playlist, channelGroups]); - const toggleGroupEnabled = (id) => { - setGroupStates( - groupStates.map((state) => ({ - ...state, - enabled: state.channel_group == id ? !state.enabled : state.enabled, - })) - ); - }; - - const toggleAutoSync = (id) => { - setGroupStates( - groupStates.map((state) => ({ - ...state, - auto_channel_sync: state.channel_group == id ? !state.auto_channel_sync : state.auto_channel_sync, - })) - ); - }; - - const updateChannelStart = (id, value) => { - setGroupStates( - groupStates.map((state) => ({ - ...state, - auto_sync_channel_start: state.channel_group == id ? value : state.auto_sync_channel_start, - })) - ); - }; - - // Toggle force_dummy_epg in custom_properties for a group - const toggleForceDummyEPG = (id) => { - setGroupStates( - groupStates.map((state) => { - if (state.channel_group == id) { - const customProps = { ...(state.custom_properties || {}) }; - customProps.force_dummy_epg = !customProps.force_dummy_epg; - return { - ...state, - custom_properties: customProps, - }; - } - return state; - }) - ); - }; - const submit = async () => { setIsLoading(true); try { // Prepare groupStates for API: custom_properties must be stringified - const payload = groupStates.map((state) => ({ + 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) // Update group settings via API endpoint - await API.updateM3UGroupSettings(playlist.id, payload); + await API.updateM3UGroupSettings(playlist.id, groupSettings, categorySettings); // Show notification about the refresh process notifications.show({ @@ -149,7 +117,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { notifications.show({ title: 'M3U Refresh Started', - message: 'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.', + message: + 'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.', color: 'blue', autoClose: 5000, }); @@ -162,28 +131,6 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { } }; - const selectAll = () => { - setGroupStates( - groupStates.map((state) => ({ - ...state, - enabled: state.name.toLowerCase().includes(groupFilter.toLowerCase()) - ? true - : state.enabled, - })) - ); - }; - - const deselectAll = () => { - setGroupStates( - groupStates.map((state) => ({ - ...state, - enabled: state.name.toLowerCase().includes(groupFilter.toLowerCase()) - ? false - : state.enabled, - })) - ); - }; - if (!isOpen) { return <>; } @@ -198,475 +145,28 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { > - } color="blue" variant="light"> - - Auto Channel Sync: When enabled, channels will be automatically created for all streams in the group during M3U updates, - and removed when streams are no longer present. Set a starting channel number for each group to organize your channels. - - + + + Live + VOD + - - setGroupFilter(event.currentTarget.value)} - style={{ flex: 1 }} - size="xs" - /> - - - + + + - - - - - {groupStates - .filter((group) => - group.name.toLowerCase().includes(groupFilter.toLowerCase()) - ) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((group) => ( - - {/* Group Enable/Disable Button */} - - - {/* Auto Sync Controls */} - - - toggleAutoSync(group.channel_group)} - size="xs" - /> - - - {group.auto_channel_sync && group.enabled && ( - <> - updateChannelStart(group.channel_group, value)} - min={1} - step={1} - size="xs" - precision={1} - /> - - {/* Auto Channel Sync Options Multi-Select */} - { - const selectedValues = []; - if (group.custom_properties?.force_dummy_epg) { - selectedValues.push('force_dummy_epg'); - } - if (group.custom_properties?.group_override !== undefined) { - selectedValues.push('group_override'); - } - if ( - group.custom_properties?.name_regex_pattern !== undefined || - group.custom_properties?.name_replace_pattern !== undefined - ) { - selectedValues.push('name_regex'); - } - if (group.custom_properties?.name_match_regex !== undefined) { - selectedValues.push('name_match_regex'); - } - if (group.custom_properties?.channel_profile_ids !== undefined) { - selectedValues.push('profile_assignment'); - } - if (group.custom_properties?.channel_sort_order !== undefined) { - selectedValues.push('channel_sort_order'); - } - return selectedValues; - })()} - onChange={(values) => { - // MultiSelect always returns an array - const selectedOptions = values || []; - - setGroupStates( - groupStates.map((state) => { - if (state.channel_group === group.channel_group) { - let newCustomProps = { ...(state.custom_properties || {}) }; - - // Handle force_dummy_epg - if (selectedOptions.includes('force_dummy_epg')) { - newCustomProps.force_dummy_epg = true; - } else { - delete newCustomProps.force_dummy_epg; - } - - // Handle group_override - if (selectedOptions.includes('group_override')) { - if (newCustomProps.group_override === undefined) { - newCustomProps.group_override = null; - } - } else { - delete newCustomProps.group_override; - } - - // Handle name_regex - if (selectedOptions.includes('name_regex')) { - if (newCustomProps.name_regex_pattern === undefined) { - newCustomProps.name_regex_pattern = ''; - } - if (newCustomProps.name_replace_pattern === undefined) { - newCustomProps.name_replace_pattern = ''; - } - } else { - delete newCustomProps.name_regex_pattern; - delete newCustomProps.name_replace_pattern; - } - - // Handle name_match_regex - if (selectedOptions.includes('name_match_regex')) { - if (newCustomProps.name_match_regex === undefined) { - newCustomProps.name_match_regex = ''; - } - } else { - delete newCustomProps.name_match_regex; - } - - // Handle profile_assignment - if (selectedOptions.includes('profile_assignment')) { - if (newCustomProps.channel_profile_ids === undefined) { - newCustomProps.channel_profile_ids = []; - } - } else { - delete newCustomProps.channel_profile_ids; - } - // Handle channel_sort_order - if (selectedOptions.includes('channel_sort_order')) { - if (newCustomProps.channel_sort_order === undefined) { - newCustomProps.channel_sort_order = ''; - } - // Keep channel_sort_reverse if it exists - if (newCustomProps.channel_sort_reverse === undefined) { - newCustomProps.channel_sort_reverse = false; - } - } else { - delete newCustomProps.channel_sort_order; - delete newCustomProps.channel_sort_reverse; // Remove reverse when sort is removed - } - - return { - ...state, - custom_properties: newCustomProps, - }; - } - return state; - }) - ); - }} - clearable - size="xs" - /> - {/* Show only channel_sort_order if selected */} - {group.custom_properties?.channel_sort_order !== undefined && ( - <> - { - const newValue = value ? parseInt(value) : null; - setGroupStates( - groupStates.map((state) => { - if (state.channel_group === group.channel_group) { - return { - ...state, - custom_properties: { - ...state.custom_properties, - group_override: newValue, - }, - }; - } - return state; - }) - ); - }} - data={Object.values(channelGroups).map((g) => ({ - value: g.id.toString(), - label: g.name, - }))} - clearable - searchable - size="xs" - /> - - )} - - {/* Show regex fields only if name_regex is selected */} - {(group.custom_properties?.name_regex_pattern !== undefined || - group.custom_properties?.name_replace_pattern !== undefined) && ( - <> - - { - const val = e.currentTarget.value; - setGroupStates( - groupStates.map(state => - state.channel_group === group.channel_group - ? { - ...state, - custom_properties: { - ...state.custom_properties, - name_regex_pattern: val, - }, - } - : state - ) - ); - }} - size="xs" - /> - - - { - const val = e.currentTarget.value; - setGroupStates( - groupStates.map(state => - state.channel_group === group.channel_group - ? { - ...state, - custom_properties: { - ...state.custom_properties, - name_replace_pattern: val, - }, - } - : state - ) - ); - }} - size="xs" - /> - - - )} - - {/* Show name_match_regex field only if selected */} - {group.custom_properties?.name_match_regex !== undefined && ( - - { - const val = e.currentTarget.value; - setGroupStates( - groupStates.map(state => - state.channel_group === group.channel_group - ? { - ...state, - custom_properties: { - ...state.custom_properties, - name_match_regex: val, - }, - } - : state - ) - ); - }} - size="xs" - /> - - )} - - )} - - - ))} - - + + + + + + + + + + {categoryStates + .filter((category) => + category.name.toLowerCase().includes(filter.toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((category) => ( + + {/* Group Enable/Disable Button */} + + + ))} + + + + ); +}; + +export default VODCategoryFilter; diff --git a/frontend/src/components/theme/Button.jsx b/frontend/src/components/theme/Button.jsx new file mode 100644 index 00000000..3b62c6fa --- /dev/null +++ b/frontend/src/components/theme/Button.jsx @@ -0,0 +1,19 @@ +import { Button as MantineButton } from '@mantine/core'; + +const Button = (props) => { + return ( + + ); +}; + +export default Button;