mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
added vod category filtering
This commit is contained in:
parent
e45082f5d6
commit
a19bd14a84
14 changed files with 1184 additions and 637 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
32
apps/vod/migrations/0002_m3uvodcategoryrelation.py
Normal file
32
apps/vod/migrations/0002_m3uvodcategoryrelation.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
717
frontend/src/components/forms/LiveGroupFilter.jsx
Normal file
717
frontend/src/components/forms/LiveGroupFilter.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
146
frontend/src/components/forms/VODCategoryFilter.jsx
Normal file
146
frontend/src/components/forms/VODCategoryFilter.jsx
Normal 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;
|
||||
19
frontend/src/components/theme/Button.jsx
Normal file
19
frontend/src/components/theme/Button.jsx
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue