added vod category filtering

This commit is contained in:
dekzter 2025-08-22 16:59:00 -04:00
parent e45082f5d6
commit a19bd14a84
14 changed files with 1184 additions and 637 deletions

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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"

View file

@ -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

View file

@ -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:

View file

@ -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')},
},
),
]

View file

@ -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}"

View file

@ -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()

View file

@ -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

View file

@ -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);

View file

@ -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) => (
<Tooltip label={description} withArrow>
<div ref={ref} {...others}>
{label}
</div>
</Tooltip>
)
);
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 (
<Stack style={{ paddingTop: 10 }}>
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
<strong>Auto Channel Sync:</strong> 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.
</Text>
</Alert>
<Flex gap="sm">
<TextInput
placeholder="Filter groups..."
value={groupFilter}
onChange={(event) => setGroupFilter(event.currentTarget.value)}
style={{ flex: 1 }}
size="xs"
/>
<Button variant="default" size="xs" onClick={selectAll}>
Select Visible
</Button>
<Button variant="default" size="xs" onClick={deselectAll}>
Deselect Visible
</Button>
</Flex>
<Divider label="Groups & Auto Sync Settings" labelPosition="center" />
<Box style={{ maxHeight: '50vh', overflowY: 'auto' }}>
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="xs"
verticalSpacing="xs"
>
{groupStates
.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => (
<Group
key={group.channel_group}
spacing="xs"
style={{
padding: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: group.enabled ? '#2A2A2E' : '#1E1E22',
flexDirection: 'column',
alignItems: 'stretch',
}}
>
{/* Group Enable/Disable Button */}
<Button
color={group.enabled ? 'green' : 'gray'}
variant="filled"
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="md"
size="xs"
leftSection={
group.enabled ? (
<CircleCheck size={14} />
) : (
<CircleX size={14} />
)
}
fullWidth
>
<Text size="xs" truncate>
{group.name}
</Text>
</Button>
{/* Auto Sync Controls */}
<Stack spacing="xs" style={{ '--stack-gap': '4px' }}>
<Flex align="center" gap="xs">
<Checkbox
label="Auto Channel Sync"
checked={group.auto_channel_sync && group.enabled}
disabled={!group.enabled}
onChange={() => toggleAutoSync(group.channel_group)}
size="xs"
/>
</Flex>
{group.auto_channel_sync && group.enabled && (
<>
<NumberInput
label="Start Channel #"
value={group.auto_sync_channel_start}
onChange={(value) =>
updateChannelStart(group.channel_group, value)
}
min={1}
step={1}
size="xs"
precision={1}
/>
{/* Auto Channel Sync Options Multi-Select */}
<MultiSelect
label="Advanced Options"
placeholder="Select options..."
data={[
{
value: 'force_dummy_epg',
label: 'Force Dummy EPG',
description:
'Assign a dummy EPG to all channels in this group if no EPG is matched',
},
{
value: 'group_override',
label: 'Override Channel Group',
description:
'Override the group assignment for all channels in this group',
},
{
value: 'name_regex',
label: 'Channel Name Find & Replace (Regex)',
description:
'Find and replace part of the channel name using a regex pattern',
},
{
value: 'name_match_regex',
label: 'Channel Name Filter (Regex)',
description:
'Only include channels whose names match this regex pattern',
},
{
value: 'profile_assignment',
label: 'Channel Profile Assignment',
description:
'Specify which channel profiles the auto-synced channels should be added to',
},
{
value: 'channel_sort_order',
label: 'Channel Sort Order',
description:
'Specify the order in which channels are created (name, tvg_id, updated_at)',
},
]}
itemComponent={OptionWithTooltip}
value={(() => {
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 && (
<>
<Select
label="Channel Sort Order"
placeholder="Select sort order..."
value={
group.custom_properties?.channel_sort_order || ''
}
onChange={(value) => {
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 && (
<Flex align="center" gap="xs" mt="xs">
<Checkbox
label="Reverse Sort Order"
checked={
group.custom_properties
?.channel_sort_reverse || false
}
onChange={(event) => {
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"
/>
</Flex>
)}
</>
)}
{/* Show profile selection only if profile_assignment is selected */}
{group.custom_properties?.channel_profile_ids !==
undefined && (
<Tooltip
label="Select which channel profiles the auto-synced channels should be added to. Leave empty to add to all profiles."
withArrow
>
<MultiSelect
label="Channel Profiles"
placeholder="Select profiles..."
value={
group.custom_properties?.channel_profile_ids || []
}
onChange={(value) => {
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"
/>
</Tooltip>
)}
{/* Show group select only if group_override is selected */}
{group.custom_properties?.group_override !==
undefined && (
<Tooltip
label="Select a group to override the assignment for all channels in this group."
withArrow
>
<Select
label="Override Channel Group"
placeholder="Choose group..."
value={
group.custom_properties?.group_override?.toString() ||
null
}
onChange={(value) => {
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"
/>
</Tooltip>
)}
{/* Show regex fields only if name_regex is selected */}
{(group.custom_properties?.name_regex_pattern !==
undefined ||
group.custom_properties?.name_replace_pattern !==
undefined) && (
<>
<Tooltip
label="Regex pattern to find in the channel name. Example: ^.*? - PPV\\d+ - (.+)$"
withArrow
>
<TextInput
label="Channel Name Find (Regex)"
placeholder="e.g. ^.*? - PPV\\d+ - (.+)$"
value={
group.custom_properties?.name_regex_pattern ||
''
}
onChange={(e) => {
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"
/>
</Tooltip>
<Tooltip
label="Replacement pattern for the channel name. Example: $1"
withArrow
>
<TextInput
label="Channel Name Replace"
placeholder="e.g. $1"
value={
group.custom_properties?.name_replace_pattern ||
''
}
onChange={(e) => {
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"
/>
</Tooltip>
</>
)}
{/* Show name_match_regex field only if selected */}
{group.custom_properties?.name_match_regex !==
undefined && (
<Tooltip
label="Only channels whose names match this regex will be included. Example: ^Sports.*"
withArrow
>
<TextInput
label="Channel Name Filter (Regex)"
placeholder="e.g. ^Sports.*"
value={
group.custom_properties?.name_match_regex || ''
}
onChange={(e) => {
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"
/>
</Tooltip>
)}
</>
)}
</Stack>
</Group>
))}
</SimpleGrid>
</Box>
</Stack>
);
};
export default LiveGroupFilter;

View file

@ -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) => (
<Tooltip label={description} withArrow>
<div ref={ref} {...others}>
{label}
</div>
</Tooltip>
));
const OptionWithTooltip = forwardRef(
({ label, description, ...others }, ref) => (
<Tooltip label={description} withArrow>
<div ref={ref} {...others}>
{label}
</div>
</Tooltip>
)
);
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 }) => {
>
<LoadingOverlay visible={isLoading} overlayBlur={2} />
<Stack>
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
<strong>Auto Channel Sync:</strong> 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.
</Text>
</Alert>
<Tabs defaultValue="live">
<Tabs.List>
<Tabs.Tab value="live">Live</Tabs.Tab>
<Tabs.Tab value="vod">VOD</Tabs.Tab>
</Tabs.List>
<Flex gap="sm">
<TextInput
placeholder="Filter groups..."
value={groupFilter}
onChange={(event) => setGroupFilter(event.currentTarget.value)}
style={{ flex: 1 }}
size="xs"
/>
<Button variant="default" size="xs" onClick={selectAll}>
Select Visible
</Button>
<Button variant="default" size="xs" onClick={deselectAll}>
Deselect Visible
</Button>
</Flex>
<Tabs.Panel value="live">
<LiveGroupFilter
playlist={playlist}
groupStates={groupStates}
setGroupStates={setGroupStates}
/>
</Tabs.Panel>
<Divider label="Groups & Auto Sync Settings" labelPosition="center" />
<Box style={{ maxHeight: '50vh', overflowY: 'auto' }}>
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="xs"
verticalSpacing="xs"
>
{groupStates
.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => (
<Group key={group.channel_group} spacing="xs" style={{
padding: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: group.enabled ? '#2A2A2E' : '#1E1E22',
flexDirection: 'column',
alignItems: 'stretch'
}}>
{/* Group Enable/Disable Button */}
<Button
color={group.enabled ? 'green' : 'gray'}
variant="filled"
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="md"
size="xs"
leftSection={
group.enabled ? (
<CircleCheck size={14} />
) : (
<CircleX size={14} />
)
}
fullWidth
>
<Text size="xs" truncate>
{group.name}
</Text>
</Button>
{/* Auto Sync Controls */}
<Stack spacing="xs" style={{ '--stack-gap': '4px' }}>
<Flex align="center" gap="xs">
<Checkbox
label="Auto Channel Sync"
checked={group.auto_channel_sync && group.enabled}
disabled={!group.enabled}
onChange={() => toggleAutoSync(group.channel_group)}
size="xs"
/>
</Flex>
{group.auto_channel_sync && group.enabled && (
<>
<NumberInput
label="Start Channel #"
value={group.auto_sync_channel_start}
onChange={(value) => updateChannelStart(group.channel_group, value)}
min={1}
step={1}
size="xs"
precision={1}
/>
{/* Auto Channel Sync Options Multi-Select */}
<MultiSelect
label="Advanced Options"
placeholder="Select options..."
data={[
{
value: 'force_dummy_epg',
label: 'Force Dummy EPG',
description: 'Assign a dummy EPG to all channels in this group if no EPG is matched',
},
{
value: 'group_override',
label: 'Override Channel Group',
description: 'Override the group assignment for all channels in this group',
},
{
value: 'name_regex',
label: 'Channel Name Find & Replace (Regex)',
description: 'Find and replace part of the channel name using a regex pattern',
},
{
value: 'name_match_regex',
label: 'Channel Name Filter (Regex)',
description: 'Only include channels whose names match this regex pattern',
},
{
value: 'profile_assignment',
label: 'Channel Profile Assignment',
description: 'Specify which channel profiles the auto-synced channels should be added to',
},
{
value: 'channel_sort_order',
label: 'Channel Sort Order',
description: 'Specify the order in which channels are created (name, tvg_id, updated_at)',
},
]}
itemComponent={OptionWithTooltip}
value={(() => {
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 && (
<>
<Select
label="Channel Sort Order"
placeholder="Select sort order..."
value={group.custom_properties?.channel_sort_order || ''}
onChange={(value) => {
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 && (
<Flex align="center" gap="xs" mt="xs">
<Checkbox
label="Reverse Sort Order"
checked={group.custom_properties?.channel_sort_reverse || false}
onChange={(event) => {
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"
/>
</Flex>
)}
</>
)}
{/* Show profile selection only if profile_assignment is selected */}
{group.custom_properties?.channel_profile_ids !== undefined && (
<Tooltip
label="Select which channel profiles the auto-synced channels should be added to. Leave empty to add to all profiles."
withArrow
>
<MultiSelect
label="Channel Profiles"
placeholder="Select profiles..."
value={group.custom_properties?.channel_profile_ids || []}
onChange={(value) => {
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"
/>
</Tooltip>
)}
{/* Show group select only if group_override is selected */}
{group.custom_properties?.group_override !== undefined && (
<Tooltip
label="Select a group to override the assignment for all channels in this group."
withArrow
>
<Select
label="Override Channel Group"
placeholder="Choose group..."
value={group.custom_properties?.group_override?.toString() || null}
onChange={(value) => {
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"
/>
</Tooltip>
)}
{/* Show regex fields only if name_regex is selected */}
{(group.custom_properties?.name_regex_pattern !== undefined ||
group.custom_properties?.name_replace_pattern !== undefined) && (
<>
<Tooltip
label="Regex pattern to find in the channel name. Example: ^.*? - PPV\\d+ - (.+)$"
withArrow
>
<TextInput
label="Channel Name Find (Regex)"
placeholder="e.g. ^.*? - PPV\\d+ - (.+)$"
value={group.custom_properties?.name_regex_pattern || ''}
onChange={e => {
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"
/>
</Tooltip>
<Tooltip
label="Replacement pattern for the channel name. Example: $1"
withArrow
>
<TextInput
label="Channel Name Replace"
placeholder="e.g. $1"
value={group.custom_properties?.name_replace_pattern || ''}
onChange={e => {
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"
/>
</Tooltip>
</>
)}
{/* Show name_match_regex field only if selected */}
{group.custom_properties?.name_match_regex !== undefined && (
<Tooltip
label="Only channels whose names match this regex will be included. Example: ^Sports.*"
withArrow
>
<TextInput
label="Channel Name Filter (Regex)"
placeholder="e.g. ^Sports.*"
value={group.custom_properties?.name_match_regex || ''}
onChange={e => {
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"
/>
</Tooltip>
)}
</>
)}
</Stack>
</Group>
))}
</SimpleGrid>
</Box>
<Tabs.Panel value="vod">
<VODCategoryFilter
playlist={playlist}
categoryStates={categoryStates}
setCategoryStates={setCategoryStates}
/>
</Tabs.Panel>
</Tabs>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button variant="default" onClick={onClose} size="xs">
@ -687,4 +187,4 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
);
};
export default M3UGroupFilter;
export default M3UGroupFilter;

View file

@ -0,0 +1,146 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import {
TextInput,
Button,
Flex,
Stack,
Group,
SimpleGrid,
Text,
Divider,
Box,
} from '@mantine/core';
import { CircleCheck, CircleX } from 'lucide-react';
import useVODStore from '../../store/useVODStore';
const VODCategoryFilter = ({
playlist = null,
categoryStates,
setCategoryStates,
}) => {
const categories = useVODStore((s) => s.categories);
const [isLoading, setIsLoading] = useState(false);
const [filter, setFilter] = useState('');
useEffect(() => {
if (Object.keys(categories).length === 0) {
return;
}
setCategoryStates(Object.values(categories).map(cat => {
const match = cat.m3u_accounts.find(acc => acc.m3u_account == playlist.id)
if (match) {
return {
...cat,
enabled: match.enabled,
original_enabled: match.enabled,
}
}
}));
}, [categories]);
const toggleEnabled = (id) => {
setCategoryStates(
categoryStates.map((state) => ({
...state,
enabled: state.id == id ? !state.enabled : state.enabled,
}))
);
};
const selectAll = () => {
setCategoryStates(
categoryStates.map((state) => ({
...state,
enabled: state.name.toLowerCase().includes(filter.toLowerCase())
? true
: state.enabled,
}))
);
};
const deselectAll = () => {
setCategoryStates(
categoryStates.map((state) => ({
...state,
enabled: state.name.toLowerCase().includes(filter.toLowerCase())
? false
: state.enabled,
}))
);
};
console.log(categoryStates)
return (
<Stack style={{ paddingTop: 10 }}>
<Flex gap="sm">
<TextInput
placeholder="Filter categories..."
value={filter}
onChange={(event) => setFilter(event.currentTarget.value)}
style={{ flex: 1 }}
size="xs"
/>
<Button variant="default" size="xs" onClick={selectAll}>
Select Visible
</Button>
<Button variant="default" size="xs" onClick={deselectAll}>
Deselect Visible
</Button>
</Flex>
<Box style={{ maxHeight: '50vh', overflowY: 'auto' }}>
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="xs"
verticalSpacing="xs"
>
{categoryStates
.filter((category) =>
category.name.toLowerCase().includes(filter.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((category) => (
<Group
key={category.id}
spacing="xs"
style={{
padding: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: category.enabled ? '#2A2A2E' : '#1E1E22',
flexDirection: 'column',
alignItems: 'stretch',
}}
>
{/* Group Enable/Disable Button */}
<Button
color={category.enabled ? 'green' : 'gray'}
variant="filled"
onClick={() => toggleEnabled(category.id)}
radius="md"
size="xs"
leftSection={
category.enabled ? (
<CircleCheck size={14} />
) : (
<CircleX size={14} />
)
}
fullWidth
>
<Text size="xs" truncate>
{category.name}
</Text>
</Button>
</Group>
))}
</SimpleGrid>
</Box>
</Stack>
);
};
export default VODCategoryFilter;

View file

@ -0,0 +1,19 @@
import { Button as MantineButton } from '@mantine/core';
const Button = (props) => {
return (
<MantineButton
{...props}
style={{
color: 'black',
// fontWeight: '400',
backgroundColor: '#14917E',
'&:hover': {
backgroundColor: '#14917E',
},
}}
/>
);
};
export default Button;