mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
m3u group filters
This commit is contained in:
parent
a59a3c3274
commit
d6e05445f3
18 changed files with 513 additions and 130 deletions
|
|
@ -148,9 +148,11 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
||||
enabled = serializers.BooleanField()
|
||||
|
||||
class Meta:
|
||||
model = ChannelGroupM3UAccount
|
||||
fields = ['channel_group', 'enabled']
|
||||
fields = ['id', 'channel_group', 'enabled']
|
||||
|
||||
# Optionally, if you only need the id of the ChannelGroup, you can customize it like this:
|
||||
channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all())
|
||||
# channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all())
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class EPGSource(models.Model):
|
|||
class EPGData(models.Model):
|
||||
# Removed the Channel foreign key. We now just store the original tvg_id
|
||||
# and a name (which might simply be the tvg_id if no real channel exists).
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ def parse_channels_only(file_path):
|
|||
epg_obj.save()
|
||||
logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}")
|
||||
|
||||
parse_programs_for_tvg_id(file_path, tvg_id)
|
||||
|
||||
logger.info("Finished parsing channel info.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from django.core.cache import cache
|
|||
# Import all models, including UserAgent.
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
from core.models import UserAgent
|
||||
from apps.channels.models import ChannelGroupM3UAccount
|
||||
from core.serializers import UserAgentSerializer
|
||||
# Import all serializers, including the UserAgentSerializer.
|
||||
from .serializers import (
|
||||
|
|
@ -24,10 +25,43 @@ from .tasks import refresh_single_m3u_account, refresh_m3u_accounts
|
|||
|
||||
class M3UAccountViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U accounts"""
|
||||
queryset = M3UAccount.objects.all()
|
||||
queryset = M3UAccount.objects.prefetch_related('channel_group')
|
||||
serializer_class = M3UAccountSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Get the M3UAccount instance we're updating
|
||||
instance = self.get_object()
|
||||
|
||||
# Handle updates to the 'enabled' flag of the related ChannelGroupM3UAccount instances
|
||||
updates = request.data.get('channel_groups', [])
|
||||
|
||||
for update_data in updates:
|
||||
channel_group_id = update_data.get('channel_group')
|
||||
enabled = update_data.get('enabled')
|
||||
|
||||
try:
|
||||
# Get the specific relationship to update
|
||||
relationship = ChannelGroupM3UAccount.objects.get(
|
||||
m3u_account=instance, channel_group_id=channel_group_id
|
||||
)
|
||||
relationship.enabled = enabled
|
||||
relationship.save()
|
||||
except ChannelGroupM3UAccount.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "ChannelGroupM3UAccount not found for the given M3UAccount and ChannelGroup."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# After updating the ChannelGroupM3UAccount relationships, reload the M3UAccount instance
|
||||
instance.refresh_from_db()
|
||||
|
||||
refresh_single_m3u_account.delay(instance.id)
|
||||
|
||||
# Serialize and return the updated M3UAccount data
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
class M3UFilterViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U filters"""
|
||||
queryset = M3UFilter.objects.all()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from rest_framework import serializers
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
from core.models import UserAgent
|
||||
from apps.channels.models import ChannelGroup
|
||||
from apps.channels.serializers import ChannelGroupM3UAccountSerializer
|
||||
from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
|
||||
from apps.channels.serializers import ChannelGroupM3UAccountSerializer, ChannelGroupSerializer
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class M3UFilterSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for M3U Filters"""
|
||||
|
|
@ -40,14 +43,74 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
profiles = M3UAccountProfileSerializer(many=True, read_only=True)
|
||||
read_only_fields = ['locked']
|
||||
# channel_groups = serializers.SerializerMethodField()
|
||||
channel_groups = ChannelGroupM3UAccountSerializer(source='channel_group.all', many=True, required=False)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = M3UAccount
|
||||
fields = [
|
||||
'id', 'name', 'server_url', 'uploaded_file', 'server_group',
|
||||
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles', 'locked'
|
||||
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles', 'locked',
|
||||
'channel_groups',
|
||||
]
|
||||
|
||||
# def get_channel_groups(self, obj):
|
||||
# # Retrieve related ChannelGroupM3UAccount records for this M3UAccount
|
||||
# relations = ChannelGroupM3UAccount.objects.filter(m3u_account=obj).select_related('channel_group')
|
||||
|
||||
# # Serialize the channel groups with their enabled status
|
||||
# return [
|
||||
# {
|
||||
# 'channel_group_name': relation.channel_group.name,
|
||||
# 'channel_group_id': relation.channel_group.id,
|
||||
# 'enabled': relation.enabled,
|
||||
# }
|
||||
# for relation in relations
|
||||
# ]
|
||||
|
||||
# def to_representation(self, instance):
|
||||
# """Override the default to_representation method to include channel_groups"""
|
||||
# representation = super().to_representation(instance)
|
||||
|
||||
# # Manually add the channel_groups to the representation
|
||||
# channel_groups = ChannelGroupM3UAccount.objects.filter(m3u_account=instance).select_related('channel_group')
|
||||
# representation['channel_groups'] = [
|
||||
# {
|
||||
# 'id': relation.id,
|
||||
# 'channel_group_name': relation.channel_group.name,
|
||||
# 'channel_group_id': relation.channel_group.id,
|
||||
# 'enabled': relation.enabled,
|
||||
# }
|
||||
# for relation in channel_groups
|
||||
# ]
|
||||
|
||||
# return representation
|
||||
|
||||
# def update(self, instance, validated_data):
|
||||
# logger.info(validated_data)
|
||||
# channel_groups_data = validated_data.pop('channel_groups', None)
|
||||
# instance = super().update(instance, validated_data)
|
||||
|
||||
# if channel_groups_data is not None:
|
||||
# logger.info(json.dumps(channel_groups_data))
|
||||
# # Remove existing relationships not included in the request
|
||||
# existing_groups = {cg.channel_group_id: cg for cg in instance.channel_group.all()}
|
||||
|
||||
# # for group_id in set(existing_groups.keys()) - sent_group_ids:
|
||||
# # existing_groups[group_id].delete()
|
||||
|
||||
# # Create or update relationships
|
||||
# for cg_data in channel_groups_data:
|
||||
# logger.info(json.dumps(cg_data))
|
||||
# ChannelGroupM3UAccount.objects.update_or_create(
|
||||
# channel_group=existing_groups[cg_data['channel_group_id']],
|
||||
# m3u_account=instance,
|
||||
# defaults={'enabled': cg_data.get('enabled', True)}
|
||||
# )
|
||||
|
||||
# return instance
|
||||
|
||||
class ServerGroupSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Server Group"""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from .models import M3UAccount
|
||||
from .tasks import refresh_single_m3u_account
|
||||
from .tasks import refresh_single_m3u_account, refresh_m3u_groups
|
||||
|
||||
@receiver(post_save, sender=M3UAccount)
|
||||
def refresh_account_on_save(sender, instance, created, **kwargs):
|
||||
|
|
@ -11,5 +11,5 @@ def refresh_account_on_save(sender, instance, created, **kwargs):
|
|||
call a Celery task that fetches & parses that single account
|
||||
if it is active or newly created.
|
||||
"""
|
||||
if created or instance.is_active:
|
||||
refresh_single_m3u_account.delay(instance.id)
|
||||
if created:
|
||||
refresh_m3u_groups(instance.id)
|
||||
|
|
|
|||
|
|
@ -25,21 +25,31 @@ logger = logging.getLogger(__name__)
|
|||
LOCK_EXPIRE = 300
|
||||
BATCH_SIZE = 1000
|
||||
SKIP_EXTS = {}
|
||||
m3u_dir = os.path.join(settings.MEDIA_ROOT, "cached_m3u")
|
||||
|
||||
def fetch_m3u_lines(account, use_cache=False):
|
||||
os.makedirs(m3u_dir, exist_ok=True)
|
||||
file_path = os.path.join(m3u_dir, f"{account.id}.m3u")
|
||||
|
||||
def fetch_m3u_lines(account):
|
||||
"""Fetch M3U file lines efficiently."""
|
||||
if account.server_url:
|
||||
headers = {"User-Agent": account.user_agent.user_agent}
|
||||
logger.info(f"Fetching from URL {account.server_url}")
|
||||
try:
|
||||
# Perform the HTTP request with stream and handle any potential issues
|
||||
with requests.get(account.server_url, timeout=60, headers=headers, stream=True) as response:
|
||||
if not use_cache or not os.path.exists(file_path):
|
||||
headers = {"User-Agent": account.user_agent.user_agent}
|
||||
logger.info(f"Fetching from URL {account.server_url}")
|
||||
try:
|
||||
response = requests.get(account.server_url, headers=headers, stream=True)
|
||||
response.raise_for_status() # This will raise an HTTPError if the status is not 200
|
||||
# Return an iterator for the lines
|
||||
return response.iter_lines(decode_unicode=True)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching M3U from URL {account.server_url}: {e}")
|
||||
return [] # Return an empty list in case of error
|
||||
with open(file_path, 'wb') as file:
|
||||
# Stream the content in chunks and write to the file
|
||||
for chunk in response.iter_content(chunk_size=8192): # You can adjust the chunk size
|
||||
if chunk: # Ensure chunk is not empty
|
||||
file.write(chunk)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching M3U from URL {account.server_url}: {e}")
|
||||
return [] # Return an empty list in case of error
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.readlines()
|
||||
elif account.uploaded_file:
|
||||
try:
|
||||
# Open the file and return the lines as a list or iterator
|
||||
|
|
@ -137,6 +147,7 @@ def process_groups(account, group_names):
|
|||
groups = []
|
||||
groups_to_create = []
|
||||
for group_name in group_names:
|
||||
logger.info(f"Handling group: {group_name}")
|
||||
if group_name in existing_groups:
|
||||
groups.append(existing_groups[group_name])
|
||||
else:
|
||||
|
|
@ -166,7 +177,10 @@ def process_groups(account, group_names):
|
|||
def process_m3u_batch(account_id, batch, group_names, hash_keys):
|
||||
"""Processes a batch of M3U streams using bulk operations."""
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
existing_groups = {group.name: group for group in ChannelGroup.objects.filter(name__in=group_names)}
|
||||
existing_groups = {group.name: group for group in ChannelGroup.objects.filter(
|
||||
m3u_account__m3u_account=account, # Filter by the M3UAccount
|
||||
m3u_account__enabled=True # Filter by the enabled flag in the join table
|
||||
)}
|
||||
|
||||
streams_to_create = []
|
||||
streams_to_update = []
|
||||
|
|
@ -174,12 +188,17 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys):
|
|||
|
||||
# compiled_filters = [(f.filter_type, re.compile(f.regex_pattern, re.IGNORECASE)) for f in filters]
|
||||
|
||||
logger.info(f"Processing batch of {len(batch)}")
|
||||
logger.debug(f"Processing batch of {len(batch)}")
|
||||
for stream_info in batch:
|
||||
name, url = stream_info["name"], stream_info["url"]
|
||||
tvg_id, tvg_logo = stream_info["attributes"].get("tvg-id", ""), stream_info["attributes"].get("tvg-logo", "")
|
||||
group_title = stream_info["attributes"].get("group-title", "Default Group")
|
||||
|
||||
# Filter out disabled groups for this account
|
||||
if group_title not in existing_groups:
|
||||
logger.debug(f"Skipping stream in disabled group: {group_title}")
|
||||
continue
|
||||
|
||||
# if any(url.lower().endswith(ext) for ext in SKIP_EXTS) or len(url) > 2000:
|
||||
# continue
|
||||
|
||||
|
|
@ -250,8 +269,53 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys):
|
|||
|
||||
return f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated."
|
||||
|
||||
def refresh_m3u_groups(account_id):
|
||||
if not acquire_lock('refresh_m3u_account_groups', account_id):
|
||||
return f"Task already running for account_id={account_id}."
|
||||
|
||||
# Record start time
|
||||
start_time = time.time()
|
||||
send_progress_update(0, account_id)
|
||||
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id, is_active=True)
|
||||
except M3UAccount.DoesNotExist:
|
||||
release_lock('refresh_m3u_account_groups', account_id)
|
||||
return f"M3UAccount with ID={account_id} not found or inactive."
|
||||
|
||||
lines = fetch_m3u_lines(account)
|
||||
extinf_data = []
|
||||
groups = set(["Default Group"])
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith("#EXTINF"):
|
||||
parsed = parse_extinf_line(line)
|
||||
if parsed:
|
||||
if "group-title" in parsed["attributes"]:
|
||||
groups.add(parsed["attributes"]["group-title"])
|
||||
|
||||
extinf_data.append(parsed)
|
||||
elif extinf_data and line.startswith("http"):
|
||||
# Associate URL with the last EXTINF line
|
||||
extinf_data[-1]["url"] = line
|
||||
|
||||
groups = list(groups)
|
||||
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"extinf_data": extinf_data,
|
||||
"groups": groups,
|
||||
}, f)
|
||||
|
||||
process_groups(account, groups)
|
||||
|
||||
release_lock('refresh_m3u_account_groups`', account_id)
|
||||
|
||||
return extinf_data, groups
|
||||
|
||||
@shared_task
|
||||
def refresh_single_m3u_account(account_id):
|
||||
def refresh_single_m3u_account(account_id, use_cache=False):
|
||||
"""Splits M3U processing into chunks and dispatches them as parallel tasks."""
|
||||
if not acquire_lock('refresh_single_m3u_account', account_id):
|
||||
return f"Task already running for account_id={account_id}."
|
||||
|
|
@ -269,47 +333,19 @@ def refresh_single_m3u_account(account_id):
|
|||
|
||||
# Fetch M3U lines and handle potential issues
|
||||
# lines = fetch_m3u_lines(account) # Extracted fetch logic into separate function
|
||||
|
||||
lines = []
|
||||
if account.server_url:
|
||||
if not account.user_agent:
|
||||
err_msg = f"User-Agent not provided for account id {account_id}."
|
||||
logger.error(err_msg)
|
||||
release_lock('refresh_single_m3u_account', account_id)
|
||||
return err_msg
|
||||
|
||||
headers = {"User-Agent": account.user_agent.user_agent}
|
||||
response = requests.get(account.server_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
lines = response.text.splitlines()
|
||||
elif account.uploaded_file:
|
||||
file_path = account.uploaded_file.path
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.read().splitlines()
|
||||
|
||||
extinf_data = []
|
||||
stream_hashes = []
|
||||
groups = set("Default Group")
|
||||
groups = None
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith("#EXTINF"):
|
||||
parsed = parse_extinf_line(line)
|
||||
if parsed:
|
||||
groups.add(parsed["attributes"].get("group-title", "Default Group"))
|
||||
extinf_data.append(parsed)
|
||||
elif extinf_data and line.startswith("http"):
|
||||
# Associate URL with the last EXTINF line
|
||||
extinf_data[-1]["url"] = line
|
||||
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
|
||||
if use_cache and os.path.exists(cache_path):
|
||||
with open(cache_path, 'r') as file:
|
||||
data = json.load(file)
|
||||
|
||||
extinf_data = data['extinf_data']
|
||||
groups = data['groups']
|
||||
|
||||
if not extinf_data:
|
||||
release_lock('refresh_single_m3u_account', account_id)
|
||||
return "No valid EXTINF data found."
|
||||
|
||||
groups = list(groups)
|
||||
# Retrieve all unique groups so we can create / associate them before
|
||||
# processing the streams themselves
|
||||
process_groups(account, groups)
|
||||
extinf_data, groups = refresh_m3u_groups(account_id)
|
||||
|
||||
hash_keys = CoreSettings.get_m3u_hash_key().split(",")
|
||||
|
||||
|
|
|
|||
|
|
@ -715,8 +715,14 @@ class ProxyServer:
|
|||
try:
|
||||
# Send worker heartbeat first
|
||||
if self.redis_client:
|
||||
worker_heartbeat_key = f"ts_proxy:worker:{self.worker_id}:heartbeat"
|
||||
self.redis_client.setex(worker_heartbeat_key, 30, str(time.time()))
|
||||
while True:
|
||||
try:
|
||||
worker_heartbeat_key = f"ts_proxy:worker:{self.worker_id}:heartbeat"
|
||||
self.redis_client.setex(worker_heartbeat_key, 30, str(time.time()))
|
||||
break
|
||||
except:
|
||||
logger.debug("Waiting for redis connection...")
|
||||
time.sleep(1)
|
||||
|
||||
# Refresh channel registry
|
||||
self.refresh_channel_registry()
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class StreamProfile(models.Model):
|
|||
def save(self, *args, **kwargs):
|
||||
if self.pk: # Only check existing records
|
||||
orig = StreamProfile.objects.get(pk=self.pk)
|
||||
if orig.is_protected:
|
||||
if orig.locked:
|
||||
allowed_fields = {"user_agent_id"} # Only allow this field to change
|
||||
for field in self._meta.fields:
|
||||
field_name = field.name
|
||||
|
|
@ -91,7 +91,7 @@ class StreamProfile(models.Model):
|
|||
def update(cls, pk, **kwargs):
|
||||
instance = cls.objects.get(pk=pk)
|
||||
|
||||
if instance.is_protected:
|
||||
if instance.locked:
|
||||
allowed_fields = {"user_agent_id"} # Only allow updating this field
|
||||
|
||||
for field_name, new_value in kwargs.items():
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import React, { useEffect, useRef } from 'react';
|
|||
import Draggable from 'react-draggable';
|
||||
import useVideoStore from '../store/useVideoStore';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { ActionIcon, Flex } from '@mantine/core';
|
||||
import { SquareX } from 'lucide-react';
|
||||
|
||||
export default function FloatingVideo() {
|
||||
const { isVisible, streamUrl, hideVideo } = useVideoStore();
|
||||
|
|
@ -65,28 +67,11 @@ export default function FloatingVideo() {
|
|||
}}
|
||||
>
|
||||
{/* Simple header row with a close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={hideVideo}
|
||||
style={{
|
||||
background: 'red',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
padding: '2px 8px',
|
||||
}}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<Flex justify="flex-end" style={{ padding: 3 }}>
|
||||
<ActionIcon variant="transparent" onClick={hideVideo}>
|
||||
<SquareX color="red" size="30" />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
|
||||
{/* The <video> element used by mpegts.js */}
|
||||
<video
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export default function M3URefreshNotification() {
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('starting progress bar');
|
||||
const notificationId = notifications.show({
|
||||
loading: true,
|
||||
title: `M3U Refresh: ${playlist.name}`,
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ import {
|
|||
Select,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
import M3UGroupFilter from './M3UGroupFilter';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose }) => {
|
||||
const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('');
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
if (file) {
|
||||
|
|
@ -43,6 +46,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
max_streams: Yup.string().required('Max streams is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
let newPlaylist;
|
||||
if (playlist?.id) {
|
||||
await API.updatePlaylist({
|
||||
id: playlist.id,
|
||||
|
|
@ -50,7 +54,8 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
uploaded_file: file,
|
||||
});
|
||||
} else {
|
||||
await API.addPlaylist({
|
||||
setLoadingText('Loading groups...');
|
||||
newPlaylist = await API.addPlaylist({
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
});
|
||||
|
|
@ -59,10 +64,19 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
resetForm();
|
||||
setFile(null);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
onClose(newPlaylist);
|
||||
},
|
||||
});
|
||||
|
||||
const closeGroupFilter = () => {
|
||||
setGroupFilterModalOpen(false);
|
||||
if (playlistCreated) {
|
||||
formik.resetForm();
|
||||
setFile(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist) {
|
||||
formik.setValues({
|
||||
|
|
@ -77,15 +91,25 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
}
|
||||
}, [playlist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playlistCreated) {
|
||||
setGroupFilterModalOpen(true);
|
||||
}
|
||||
}, [playlist, playlistCreated]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Account">
|
||||
<div style={{ width: 400, position: 'relative' }}>
|
||||
<LoadingOverlay visible={formik.isSubmitting} overlayBlur={2} />
|
||||
<LoadingOverlay
|
||||
visible={formik.isSubmitting}
|
||||
overlayBlur={2}
|
||||
loaderProps={loadingText ? { children: loadingText } : {}}
|
||||
/>
|
||||
|
||||
<div style={{ width: 400, position: 'relative' }}>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
fullWidth
|
||||
|
|
@ -156,14 +180,24 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
{playlist && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setGroupFilterModalOpen(true)}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
@ -176,11 +210,18 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
</Button>
|
||||
</Flex>
|
||||
{playlist && (
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
<>
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
<M3UGroupFilter
|
||||
isOpen={groupFilterModalOpen}
|
||||
playlist={playlist}
|
||||
onClose={closeGroupFilter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
144
frontend/src/components/forms/M3UGroupFilter.jsx
Normal file
144
frontend/src/components/forms/M3UGroupFilter.jsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import M3UProfiles from './M3UProfiles';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Select,
|
||||
Space,
|
||||
Chip,
|
||||
Stack,
|
||||
Group,
|
||||
Center,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { CircleCheck, CircleX } from 'lucide-react';
|
||||
|
||||
const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
||||
const { channelGroups } = useChannelsStore();
|
||||
const [groupStates, setGroupStates] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [groupFilter, setGroupFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
console.log(playlist.channel_groups);
|
||||
setGroupStates(
|
||||
playlist.channel_groups.map((group) => ({
|
||||
...group,
|
||||
name: channelGroups[group.channel_group].name,
|
||||
}))
|
||||
);
|
||||
}, [channelGroups]);
|
||||
|
||||
const toggleGroupEnabled = (id) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => ({
|
||||
...state,
|
||||
enabled: state.channel_group == id ? !state.enabled : state.enabled,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setIsLoading(true);
|
||||
await API.updatePlaylist({
|
||||
...playlist,
|
||||
channel_groups: groupStates,
|
||||
});
|
||||
setIsLoading(false);
|
||||
|
||||
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 <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Group Filter" size="xl">
|
||||
<LoadingOverlay visible={isLoading} overlayBlur={2} />
|
||||
<Stack>
|
||||
<Flex gap="sm">
|
||||
<TextInput
|
||||
placeholder="Filter"
|
||||
value={groupFilter}
|
||||
onChange={(event) => setGroupFilter(event.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="default" size="sm" onClick={selectAll}>
|
||||
Select Visible
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={deselectAll}>
|
||||
Deselect Visible
|
||||
</Button>
|
||||
</Flex>
|
||||
<SimpleGrid cols={4}>
|
||||
{groupStates
|
||||
.filter((group) =>
|
||||
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
||||
)
|
||||
.map((group) => (
|
||||
<Button
|
||||
color={group.enabled ? 'green' : 'gray'}
|
||||
variant="filled"
|
||||
checked={group.enabled}
|
||||
onClick={() => toggleGroupEnabled(group.channel_group)}
|
||||
radius="xl"
|
||||
leftSection={group.enabled ? <CircleCheck /> : <CircleX />}
|
||||
justify="left"
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
onClick={submit}
|
||||
>
|
||||
Save and Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UGroupFilter;
|
||||
|
|
@ -295,7 +295,7 @@ const ChannelsTable = ({}) => {
|
|||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
|
||||
<img src={cell.getValue() || logo} height="20" alt="channel logo" />
|
||||
</Grid>
|
||||
),
|
||||
meta: {
|
||||
|
|
@ -384,7 +384,6 @@ const ChannelsTable = ({}) => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const closeChannelForm = () => {
|
||||
setChannel(null);
|
||||
setChannelModalOpen(false);
|
||||
|
|
|
|||
|
|
@ -38,12 +38,15 @@ import {
|
|||
IconSortAscendingNumbers,
|
||||
IconSquarePlus,
|
||||
} from '@tabler/icons-react'; // Import custom icons
|
||||
import M3UGroupFilter from '../forms/M3UGroupFilter';
|
||||
|
||||
const Example = () => {
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
const [playlistCreated, setPlaylistCreated] = useState(false);
|
||||
|
||||
const { playlists, setRefreshProgress } = usePlaylistsStore();
|
||||
|
||||
|
|
@ -116,9 +119,15 @@ const Example = () => {
|
|||
await API.deletePlaylist(id);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setPlaylistModalOpen(false);
|
||||
setPlaylist(null);
|
||||
const closeModal = (newPlaylist = null) => {
|
||||
if (newPlaylist) {
|
||||
setPlaylistCreated(true);
|
||||
setPlaylist(newPlaylist);
|
||||
} else {
|
||||
setPlaylistModalOpen(false);
|
||||
setPlaylist(null);
|
||||
setPlaylistCreated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlaylists = async (ids) => {
|
||||
|
|
@ -267,6 +276,7 @@ const Example = () => {
|
|||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={closeModal}
|
||||
playlistCreated={playlistCreated}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,15 +15,18 @@ import {
|
|||
Flex,
|
||||
Button,
|
||||
useMantineTheme,
|
||||
Center,
|
||||
Switch,
|
||||
} from '@mantine/core';
|
||||
import { IconSquarePlus } from '@tabler/icons-react';
|
||||
import { SquareMinus, SquarePen, Check, X } from 'lucide-react';
|
||||
import { SquareMinus, SquarePen, Check, X, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const StreamProfiles = () => {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
const [hideInactive, setHideInactive] = useState(false);
|
||||
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
const { settings } = useSettingsStore();
|
||||
|
|
@ -36,27 +39,37 @@ const StreamProfiles = () => {
|
|||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Command',
|
||||
accessorKey: 'command',
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
header: 'Parameters',
|
||||
accessorKey: 'parameters',
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
whiteSpace: 'nowrap',
|
||||
// maxWidth: 400,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{cell.getValue() ? <Check color="green" /> : <X color="red" />}
|
||||
</Box>
|
||||
size: 50,
|
||||
Cell: ({ row, cell }) => (
|
||||
<Center>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={cell.getValue()}
|
||||
onChange={() => toggleProfileIsActive(row.original)}
|
||||
/>
|
||||
</Center>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
|
|
@ -124,10 +137,26 @@ const StreamProfiles = () => {
|
|||
}
|
||||
}, [sorting]);
|
||||
|
||||
const toggleHideInactive = () => {
|
||||
setHideInactive(!hideInactive);
|
||||
};
|
||||
|
||||
const toggleProfileIsActive = async (profile) => {
|
||||
await API.updateStreamProfile({
|
||||
id: profile.id,
|
||||
...profile,
|
||||
is_active: !profile.is_active,
|
||||
});
|
||||
};
|
||||
|
||||
const filteredData = streamProfiles.filter((profile) =>
|
||||
hideInactive && !profile.is_active ? false : true
|
||||
);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: streamProfiles,
|
||||
data: filteredData,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
// enableRowSelection: true,
|
||||
|
|
@ -144,6 +173,11 @@ const StreamProfiles = () => {
|
|||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
|
|
@ -151,6 +185,7 @@ const StreamProfiles = () => {
|
|||
variant="transparent"
|
||||
color="yellow.5"
|
||||
size="sm"
|
||||
disabled={row.original.locked}
|
||||
onClick={() => editStreamProfile(row.original)}
|
||||
>
|
||||
<SquarePen size="18" /> {/* Small icon size */}
|
||||
|
|
@ -159,6 +194,7 @@ const StreamProfiles = () => {
|
|||
variant="transparent"
|
||||
size="sm"
|
||||
color="red.9"
|
||||
disabled={row.original.locked}
|
||||
onClick={() => deleteStreamProfile(row.original.id)}
|
||||
>
|
||||
<SquareMinus fontSize="small" /> {/* Small icon size */}
|
||||
|
|
@ -217,6 +253,21 @@ const StreamProfiles = () => {
|
|||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label={hideInactive ? 'Show All' : 'Hide Inactive'}>
|
||||
<Center>
|
||||
<ActionIcon
|
||||
onClick={toggleHideInactive}
|
||||
variant="filled"
|
||||
color="gray"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'white',
|
||||
}}
|
||||
>
|
||||
{hideInactive ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</ActionIcon>
|
||||
</Center>
|
||||
</Tooltip>
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
Group,
|
||||
NumberInput,
|
||||
NativeSelect,
|
||||
MultiSelect,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
|
|
@ -133,12 +134,11 @@ const StreamsTable = ({}) => {
|
|||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorFn: (row) =>
|
||||
channelGroups.find((group) => group.id === row.channel_group)?.name,
|
||||
accessorFn: (row) => channelGroups[row.channel_group].name,
|
||||
size: 100,
|
||||
Header: ({ column }) => (
|
||||
<Box onClick={handleSelectClick}>
|
||||
<Select
|
||||
<MultiSelect
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { notifications } from '@mantine/notifications';
|
|||
const useChannelsStore = create((set, get) => ({
|
||||
channels: [],
|
||||
channelsByUUID: {},
|
||||
channelGroups: [],
|
||||
channelGroups: {},
|
||||
channelsPageSelection: [],
|
||||
stats: {},
|
||||
activeChannels: {},
|
||||
|
|
@ -38,7 +38,13 @@ const useChannelsStore = create((set, get) => ({
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const channelGroups = await api.getChannelGroups();
|
||||
set({ channelGroups: channelGroups, isLoading: false });
|
||||
set({
|
||||
channelGroups: channelGroups.reduce((acc, group) => {
|
||||
acc[group.id] = group;
|
||||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channel groups:', error);
|
||||
set({ error: 'Failed to load channel groups.', isLoading: false });
|
||||
|
|
@ -108,14 +114,16 @@ const useChannelsStore = create((set, get) => ({
|
|||
|
||||
addChannelGroup: (newChannelGroup) =>
|
||||
set((state) => ({
|
||||
channelGroups: [...state.channelGroups, newChannelGroup],
|
||||
channelGroups: {
|
||||
...state.channelGroups,
|
||||
[newChannelGroup.id]: newChannelGroup,
|
||||
},
|
||||
})),
|
||||
|
||||
updateChannelGroup: (channelGroup) =>
|
||||
set((state) => ({
|
||||
channelGroups: state.channelGroups.map((group) =>
|
||||
group.id === channelGroup.id ? channelGroup : group
|
||||
),
|
||||
...state.channelGroups,
|
||||
[channelGroup.id]: channelGroup,
|
||||
})),
|
||||
|
||||
setChannelsPageSelection: (channelsPageSelection) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue