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 && (
+ <>
+
+
+ ))}
+
+
+
+ );
+};
+
+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 && (
- <>
- {
- setGroupStates(
- groupStates.map((state) => {
- if (state.channel_group === group.channel_group) {
- return {
- ...state,
- custom_properties: {
- ...state.custom_properties,
- channel_sort_order: value || '',
- },
- };
- }
- return state;
- })
- );
- }}
- data={[
- { value: '', label: 'Provider Order (Default)' },
- { value: 'name', label: 'Name' },
- { value: 'tvg_id', label: 'TVG ID' },
- { value: 'updated_at', label: 'Updated At' },
- ]}
- clearable
- searchable
- size="xs"
- />
-
- {/* Add reverse sort checkbox when sort order is selected (including default) */}
- {group.custom_properties?.channel_sort_order !== undefined && (
-
- {
- setGroupStates(
- groupStates.map((state) => {
- if (state.channel_group === group.channel_group) {
- return {
- ...state,
- custom_properties: {
- ...state.custom_properties,
- channel_sort_reverse: event.target.checked,
- },
- };
- }
- return state;
- })
- );
- }}
- size="xs"
- />
-
- )}
- >
- )}
-
- {/* Show profile selection only if profile_assignment is selected */}
- {group.custom_properties?.channel_profile_ids !== undefined && (
-
- {
- setGroupStates(
- groupStates.map((state) => {
- if (state.channel_group === group.channel_group) {
- return {
- ...state,
- custom_properties: {
- ...state.custom_properties,
- channel_profile_ids: value || [],
- },
- };
- }
- return state;
- })
- );
- }}
- data={Object.values(profiles).map((profile) => ({
- value: profile.id.toString(),
- label: profile.name,
- }))}
- clearable
- searchable
- size="xs"
- />
-
- )}
-
- {/* Show group select only if group_override is selected */}
- {group.custom_properties?.group_override !== 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"
- />
-
- )}
- >
- )}
-
-
- ))}
-
-
+
+
+
+