mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 10:45:27 +00:00
fully supported channel profiles
This commit is contained in:
parent
bd3c526668
commit
a41b205629
12 changed files with 584 additions and 93 deletions
|
|
@ -7,6 +7,9 @@ from .api_views import (
|
|||
BulkDeleteStreamsAPIView,
|
||||
BulkDeleteChannelsAPIView,
|
||||
LogoViewSet,
|
||||
ChannelProfileViewSet,
|
||||
UpdateChannelMembershipAPIView,
|
||||
BulkUpdateChannelMembershipAPIView,
|
||||
)
|
||||
|
||||
app_name = 'channels' # for DRF routing
|
||||
|
|
@ -16,11 +19,14 @@ router.register(r'streams', StreamViewSet, basename='stream')
|
|||
router.register(r'groups', ChannelGroupViewSet, basename='channel-group')
|
||||
router.register(r'channels', ChannelViewSet, basename='channel')
|
||||
router.register(r'logos', LogoViewSet, basename='logos')
|
||||
router.register(r'profiles', ChannelProfileViewSet, basename='profiles')
|
||||
|
||||
urlpatterns = [
|
||||
# Bulk delete is a single APIView, not a ViewSet
|
||||
path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'),
|
||||
path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'),
|
||||
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
|
||||
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ from django.shortcuts import get_object_or_404
|
|||
from django.db import transaction
|
||||
import os, json
|
||||
|
||||
from .models import Stream, Channel, ChannelGroup, Logo
|
||||
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer, LogoSerializer
|
||||
from .models import Stream, Channel, ChannelGroup, Logo, ChannelProfile, ChannelProfileMembership
|
||||
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer, LogoSerializer, ChannelProfileMembershipSerializer, BulkChannelProfileMembershipSerializer, ChannelProfileSerializer
|
||||
from .tasks import match_epg_channels
|
||||
import django_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
|
@ -442,3 +442,51 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
})
|
||||
|
||||
return Response({'id': logo.id, 'name': logo.name, 'url': logo.url}, status=status.HTTP_201_CREATED)
|
||||
|
||||
class ChannelProfileViewSet(viewsets.ModelViewSet):
|
||||
queryset = ChannelProfile.objects.all()
|
||||
serializer_class = ChannelProfileSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class UpdateChannelMembershipAPIView(APIView):
|
||||
def patch(self, request, profile_id, channel_id):
|
||||
"""Enable or disable a channel for a specific group"""
|
||||
channel_profile = get_object_or_404(ChannelProfile, id=profile_id)
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
membership = get_object_or_404(ChannelProfileMembership, channel_profile=channel_profile, channel=channel)
|
||||
|
||||
serializer = ChannelProfileMembershipSerializer(membership, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class BulkUpdateChannelMembershipAPIView(APIView):
|
||||
def patch(self, request, group_id):
|
||||
"""Bulk enable or disable channels for a specific profile"""
|
||||
channel_profile = get_object_or_404(ChannelProfile, id=group_id)
|
||||
serializer = BulkChannelProfileMembershipSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
updates = serializer.validated_data['channels']
|
||||
channel_ids = [entry['channel_id'] for entry in updates]
|
||||
|
||||
memberships = ChannelProfileMembership.objects.filter(
|
||||
channel_profile=channel_profile,
|
||||
channel_id__in=channel_ids
|
||||
)
|
||||
|
||||
membership_dict = {m.channel.id: m for m in memberships}
|
||||
|
||||
for entry in updates:
|
||||
channel_id = entry['channel_id']
|
||||
enabled_status = entry['enabled']
|
||||
if channel_id in membership_dict:
|
||||
membership_dict[channel_id].enabled = enabled_status
|
||||
|
||||
ChannelProfileMembership.objects.bulk_update(memberships, ['enabled'])
|
||||
|
||||
return Response({"status": "success"}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 5.1.6 on 2025-04-02 23:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0011_logo_remove_channel_logo_file_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ChannelProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ChannelProfileMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channel')),
|
||||
('channel_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channelprofile')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('channel_profile', 'channel')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -354,6 +354,18 @@ class Channel(models.Model):
|
|||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
|
||||
|
||||
class ChannelProfile(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
|
||||
class ChannelProfileMembership(models.Model):
|
||||
channel_profile = models.ForeignKey(ChannelProfile, on_delete=models.CASCADE)
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
enabled = models.BooleanField(default=True) # Track if the channel is enabled for this group
|
||||
|
||||
class Meta:
|
||||
unique_together = ('channel_profile', 'channel')
|
||||
|
||||
class ChannelStream(models.Model):
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
stream = models.ForeignKey(Stream, on_delete=models.CASCADE)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount, Logo
|
||||
from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount, Logo, ChannelProfile, ChannelProfileMembership
|
||||
from apps.epg.serializers import EPGDataSerializer
|
||||
from core.models import StreamProfile
|
||||
from apps.epg.models import EPGData
|
||||
|
|
@ -58,6 +58,40 @@ class ChannelGroupSerializer(serializers.ModelSerializer):
|
|||
model = ChannelGroup
|
||||
fields = ['id', 'name']
|
||||
|
||||
class ChannelProfileSerializer(serializers.ModelSerializer):
|
||||
channels = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ChannelProfile
|
||||
fields = ['id', 'name', 'channels']
|
||||
|
||||
def get_channels(self, obj):
|
||||
memberships = ChannelProfileMembership.objects.filter(channel_profile=obj)
|
||||
return [
|
||||
{
|
||||
'id': membership.channel.id,
|
||||
'enabled': membership.enabled
|
||||
}
|
||||
for membership in memberships
|
||||
]
|
||||
|
||||
class ChannelProfileMembershipSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ChannelProfileMembership
|
||||
fields = ['channel', 'enabled']
|
||||
|
||||
class BulkChannelProfileMembershipSerializer(serializers.Serializer):
|
||||
channels = serializers.ListField(
|
||||
child=serializers.DictField(
|
||||
child=serializers.BooleanField(),
|
||||
allow_empty=False
|
||||
)
|
||||
)
|
||||
|
||||
def validate_channels(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("At least one channel must be provided.")
|
||||
return value
|
||||
|
||||
#
|
||||
# Channel
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from django.db.models.signals import m2m_changed, pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
from .models import Channel, Stream
|
||||
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
||||
import logging
|
||||
|
|
@ -44,3 +44,21 @@ def set_default_m3u_account(sender, instance, **kwargs):
|
|||
def refresh_epg_programs(sender, instance, created, **kwargs):
|
||||
if instance.epg_data:
|
||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||
|
||||
@receiver(post_save, sender=Channel)
|
||||
def add_new_channel_to_groups(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
profiles = ChannelProfile.objects.all()
|
||||
ChannelProfileMembership.objects.bulk_create([
|
||||
ChannelProfileMembership(channel_profile=profile, channel=instance)
|
||||
for profile in profiles
|
||||
])
|
||||
|
||||
@receiver(post_save, sender=ChannelProfile)
|
||||
def create_profile_memberships(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
channels = Channel.objects.all()
|
||||
ChannelProfileMembership.objects.bulk_create([
|
||||
ChannelProfileMembership(channel_profile=instance, channel=channel)
|
||||
for channel in channels
|
||||
])
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ from core.views import stream_view
|
|||
app_name = 'output'
|
||||
|
||||
urlpatterns = [
|
||||
# Allow both `/m3u` and `/m3u/`
|
||||
re_path(r'^m3u/?$', generate_m3u, name='generate_m3u'),
|
||||
|
||||
# Allow both `/epg` and `/epg/`
|
||||
re_path(r'^epg/?$', generate_epg, name='generate_epg'),
|
||||
|
||||
# Allow `/m3u`, `/m3u/`, `/m3u/profile_name`, and `/m3u/profile_name/`
|
||||
re_path(r'^m3u(?:/(?P<profile_name>[^/]+))?/?$', generate_m3u, name='generate_m3u'),
|
||||
|
||||
# Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/`
|
||||
re_path(r'^epg(?:/(?P<profile_name>[^/]+))?/?$', generate_epg, name='generate_epg'),
|
||||
|
||||
# Allow both `/stream/<int:stream_id>` and `/stream/<int:stream_id>/`
|
||||
re_path(r'^stream/(?P<channel_uuid>[0-9a-fA-F\-]+)/?$', stream_view, name='stream'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from apps.channels.models import Channel
|
||||
from apps.channels.models import Channel, ChannelProfile
|
||||
from apps.epg.models import ProgramData
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def generate_m3u(request):
|
||||
def generate_m3u(request, profile_name=None):
|
||||
"""
|
||||
Dynamically generate an M3U file from channels.
|
||||
The stream URL now points to the new stream_view that uses StreamProfile.
|
||||
"""
|
||||
if profile_name is not None:
|
||||
channel_profile = ChannelProfile.objects.get(name=profile_name)
|
||||
channels = Channel.objects.filter(
|
||||
channelprofilemembership__channel_profile=channel_profile,
|
||||
channelprofilemembership__enabled=True
|
||||
).order_by('channel_number')
|
||||
else:
|
||||
channels = Channel.objects.order_by('channel_number')
|
||||
|
||||
m3u_content = "#EXTM3U\n"
|
||||
channels = Channel.objects.order_by('channel_number')
|
||||
for channel in channels:
|
||||
group_title = channel.channel_group.name if channel.channel_group else "Default"
|
||||
tvg_id = channel.tvg_id or ""
|
||||
|
|
@ -57,7 +65,7 @@ def generate_dummy_epg(name, channel_id, num_days=7, interval_hours=4):
|
|||
|
||||
return xml_lines
|
||||
|
||||
def generate_epg(request):
|
||||
def generate_epg(request, profile_name=None):
|
||||
"""
|
||||
Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
|
||||
Since the EPG data is stored independently of Channels, we group programmes
|
||||
|
|
@ -68,8 +76,16 @@ def generate_epg(request):
|
|||
xml_lines.append('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
xml_lines.append('<tv generator-info-name="Dispatcharr" generator-info-url="https://example.com">')
|
||||
|
||||
if profile_name is not None:
|
||||
channel_profile = ChannelProfile.objects.get(name=profile_name)
|
||||
channels = Channel.objects.filter(
|
||||
channelprofilemembership__channel_profile=channel_profile,
|
||||
channelprofilemembership__enabled=True
|
||||
)
|
||||
else:
|
||||
channels = Channel.objects.all()
|
||||
|
||||
# Retrieve all active channels
|
||||
channels = Channel.objects.all()
|
||||
for channel in channels:
|
||||
channel_id = channel.epg_data.tvg_id if channel.epg_data else f"default-{channel.id}"
|
||||
display_name = channel.epg_data.name if channel.epg_data else channel.name
|
||||
|
|
|
|||
|
|
@ -932,4 +932,83 @@ export default class API {
|
|||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getChannelProfiles() {
|
||||
const response = await fetch(`${host}/api/channels/profiles/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addChannelProfile(values) {
|
||||
const response = await fetch(`${host}/api/channels/profiles/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().addProfile(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateChannelProfile(values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(`${host}/api/channels/profiles/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().updateProfile(retval);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteChannelProfile(id) {
|
||||
const response = await fetch(`${host}/api/channels/profiles/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useChannelsStore.getState().removeProfiles([id]);
|
||||
}
|
||||
|
||||
static async updateProfileChannel(channelId, profileId, enabled) {
|
||||
const response = await fetch(
|
||||
`${host}/api/channels/profiles/${profileId}/channels/${channelId}/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
}
|
||||
);
|
||||
|
||||
useChannelsStore
|
||||
.getState()
|
||||
.updateProfileChannel(channelId, profileId, enabled);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
ArrowDown01,
|
||||
SquarePlus,
|
||||
Copy,
|
||||
CircleCheck,
|
||||
ScanEye,
|
||||
} from 'lucide-react';
|
||||
import ghostImage from '../../images/ghost.svg';
|
||||
import {
|
||||
|
|
@ -39,6 +41,7 @@ import {
|
|||
useMantineTheme,
|
||||
Center,
|
||||
Container,
|
||||
Switch,
|
||||
} from '@mantine/core';
|
||||
|
||||
const ChannelStreams = ({ channel, isExpanded }) => {
|
||||
|
|
@ -173,15 +176,90 @@ const ChannelStreams = ({ channel, isExpanded }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const m3uUrl = `${window.location.protocol}//${window.location.host}/output/m3u`;
|
||||
const epgUrl = `${window.location.protocol}//${window.location.host}/output/epg`;
|
||||
const hdhrUrl = `${window.location.protocol}//${window.location.host}/hdhr`;
|
||||
const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
|
||||
const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
|
||||
const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`;
|
||||
|
||||
const CreateProfilePopover = ({}) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const setOpen = () => {
|
||||
setName('');
|
||||
setOpened(!opened);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
await API.addChannelProfile({ name });
|
||||
setName('');
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpen}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={setOpen}
|
||||
>
|
||||
<SquarePlus />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
>
|
||||
<CircleCheck />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelsTable = ({}) => {
|
||||
const {
|
||||
channels,
|
||||
isLoading: channelsLoading,
|
||||
fetchChannels,
|
||||
setChannelsPageSelection,
|
||||
profiles,
|
||||
selectedProfileId,
|
||||
setSelectedProfileId,
|
||||
selectedProfileChannels,
|
||||
} = useChannelsStore();
|
||||
|
||||
const [channel, setChannel] = useState(null);
|
||||
const [channelModalOpen, setChannelModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [channelGroupOptions, setChannelGroupOptions] = useState([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState(
|
||||
profiles[selectedProfileId]
|
||||
);
|
||||
|
||||
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
|
||||
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
|
||||
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
|
||||
|
||||
const [textToCopy, setTextToCopy] = useState('');
|
||||
|
||||
|
|
@ -191,12 +269,16 @@ const ChannelsTable = ({}) => {
|
|||
const theme = useMantineTheme();
|
||||
|
||||
const { showVideo } = useVideoStore();
|
||||
const {
|
||||
channels,
|
||||
isLoading: channelsLoading,
|
||||
fetchChannels,
|
||||
setChannelsPageSelection,
|
||||
} = useChannelsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProfile(profiles[selectedProfileId]);
|
||||
|
||||
const profileString =
|
||||
selectedProfileId != '0' ? `/${profiles[selectedProfileId].name}` : '';
|
||||
setHDHRUrl(`${hdhrUrlBase}${profileString}`);
|
||||
setEPGUrl(`${epgUrlBase}${profileString}`);
|
||||
setM3UUrl(`${m3uUrlBase}${profileString}`);
|
||||
}, [selectedProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
setChannelGroupOptions([
|
||||
|
|
@ -221,9 +303,37 @@ const ChannelsTable = ({}) => {
|
|||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
||||
const toggleChannelEnabled = async (channelId, enabled) => {
|
||||
await API.updateProfileChannel(channelId, selectedProfileId, enabled);
|
||||
};
|
||||
|
||||
// Configure columns
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'enabled',
|
||||
header: <ScanEye size="16" />,
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
accessorFn: (row) => {
|
||||
if (selectedProfileId == '0') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return selectedProfileChannels.find((channel) => row.id == channel.id)
|
||||
.enabled;
|
||||
},
|
||||
Cell: ({ row, cell }) => (
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={cell.getValue()}
|
||||
onChange={() => {
|
||||
toggleChannelEnabled(row.original.id, !cell.getValue());
|
||||
}}
|
||||
disabled={selectedProfileId == '0'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '#',
|
||||
size: 50,
|
||||
|
|
@ -262,6 +372,17 @@ const ChannelsTable = ({}) => {
|
|||
{
|
||||
header: 'Group',
|
||||
accessorFn: (row) => row.channel_group?.name || '',
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
Header: ({ column }) => (
|
||||
<Box onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
|
|
@ -269,10 +390,10 @@ const ChannelsTable = ({}) => {
|
|||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onChange={(e, value) => {
|
||||
e.stopPropagation();
|
||||
handleGroupChange(value);
|
||||
}}
|
||||
// onChange={(e, value) => {
|
||||
// e.stopPropagation();
|
||||
// handleGroupChange(value);
|
||||
// }}
|
||||
data={channelGroupOptions}
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
|
|
@ -302,12 +423,14 @@ const ChannelsTable = ({}) => {
|
|||
<img src={cell.getValue() || logo} height="20" alt="channel logo" />
|
||||
</Grid>
|
||||
),
|
||||
meta: {
|
||||
filterVariant: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[channelGroupOptions, filterValues]
|
||||
[
|
||||
channelGroupOptions,
|
||||
filterValues,
|
||||
selectedProfile,
|
||||
selectedProfileChannels,
|
||||
]
|
||||
);
|
||||
|
||||
// Access the row virtualizer instance (optional)
|
||||
|
|
@ -431,22 +554,13 @@ const ChannelsTable = ({}) => {
|
|||
|
||||
// Example copy URLs
|
||||
const copyM3UUrl = () => {
|
||||
handleCopy(
|
||||
`${window.location.protocol}//${window.location.host}/output/m3u`,
|
||||
m3uUrlRef
|
||||
);
|
||||
handleCopy(m3uUrl, m3uUrlRef);
|
||||
};
|
||||
const copyEPGUrl = () => {
|
||||
handleCopy(
|
||||
`${window.location.protocol}//${window.location.host}/output/epg`,
|
||||
epgUrlRef
|
||||
);
|
||||
handleCopy(epgUrl, epgUrlRef);
|
||||
};
|
||||
const copyHDHRUrl = () => {
|
||||
handleCopy(
|
||||
`${window.location.protocol}//${window.location.host}/hdhr`,
|
||||
hdhrUrlRef
|
||||
);
|
||||
handleCopy(hdhrUrl, hdhrUrlRef);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -464,6 +578,31 @@ const ChannelsTable = ({}) => {
|
|||
)
|
||||
);
|
||||
|
||||
const deleteProfile = async (id) => {
|
||||
await API.deleteChannelProfile(id);
|
||||
};
|
||||
|
||||
const renderProfileOption = ({ option, checked }) => {
|
||||
return (
|
||||
<Group justify="space-between" style={{ width: '100%' }}>
|
||||
<Box>{option.label}</Box>
|
||||
{option.value != '0' && (
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteProfile(option.value);
|
||||
}}
|
||||
>
|
||||
<SquareMinus />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
|
|
@ -725,64 +864,85 @@ const ChannelsTable = ({}) => {
|
|||
}}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Button
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
<Group justify="space-between">
|
||||
<Group gap={5} style={{ paddingLeft: 10 }}>
|
||||
<Select
|
||||
size="xs"
|
||||
onClick={deleteChannels}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
value={selectedProfileId}
|
||||
onChange={setSelectedProfileId}
|
||||
data={[{ label: 'All', value: '0' }].concat(
|
||||
Object.values(profiles).map((profile) => ({
|
||||
label: profile.name,
|
||||
value: `${profile.id}`,
|
||||
}))
|
||||
)}
|
||||
renderOption={renderProfileOption}
|
||||
/>
|
||||
|
||||
<Tooltip label="Assign Channel #s">
|
||||
<Tooltip label="Create Profile">
|
||||
<CreateProfilePopover />
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Button
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={assignChannels}
|
||||
p={5}
|
||||
onClick={deleteChannels}
|
||||
>
|
||||
Assign
|
||||
Remove
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Auto-Match EPG">
|
||||
<Tooltip label="Assign Channel #s">
|
||||
<Button
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={assignChannels}
|
||||
p={5}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Auto-Match EPG">
|
||||
<Button
|
||||
leftSection={<Binary size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={matchEpg}
|
||||
p={5}
|
||||
>
|
||||
Auto-Match
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
leftSection={<Binary size={18} />}
|
||||
variant="default"
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={matchEpg}
|
||||
onClick={() => editChannel()}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Auto-Match
|
||||
Add
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editChannel()}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* Table or ghost empty state inside Paper */}
|
||||
<Box>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const useAuthStore = create((set, get) => ({
|
|||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
useChannelsStore.getState().fetchLogos(),
|
||||
useChannelsStore.getState().fetchChannelProfiles(),
|
||||
useUserAgentsStore.getState().fetchUserAgents(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
useEPGsStore.getState().fetchEPGs(),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ const useChannelsStore = create((set, get) => ({
|
|||
channels: [],
|
||||
channelsByUUID: {},
|
||||
channelGroups: {},
|
||||
profiles: {},
|
||||
selectedProfileId: '0',
|
||||
selectedProfileChannels: [],
|
||||
channelsPageSelection: [],
|
||||
stats: {},
|
||||
activeChannels: {},
|
||||
|
|
@ -52,7 +55,25 @@ const useChannelsStore = create((set, get) => ({
|
|||
}
|
||||
},
|
||||
|
||||
addChannel: (newChannel) =>
|
||||
fetchChannelProfiles: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const profiles = await api.getChannelProfiles();
|
||||
set({
|
||||
profiles: profiles.reduce((acc, profile) => {
|
||||
acc[profile.id] = profile;
|
||||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channel profiles:', error);
|
||||
set({ error: 'Failed to load channel profiles.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addChannel: (newChannel) => {
|
||||
get().fetchChannelProfiles();
|
||||
set((state) => ({
|
||||
channels: {
|
||||
...state.channels,
|
||||
|
|
@ -62,9 +83,11 @@ const useChannelsStore = create((set, get) => ({
|
|||
...state.channelsByUUID,
|
||||
[newChannel.uuid]: newChannel.id,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
addChannels: (newChannels) => {
|
||||
get().fetchChannelProfiles();
|
||||
const channelsByUUID = {};
|
||||
const logos = {};
|
||||
const channelsByID = newChannels.reduce((acc, channel) => {
|
||||
|
|
@ -103,7 +126,7 @@ const useChannelsStore = create((set, get) => ({
|
|||
},
|
||||
})),
|
||||
|
||||
removeChannels: (channelIds) =>
|
||||
removeChannels: (channelIds) => {
|
||||
set((state) => {
|
||||
const updatedChannels = { ...state.channels };
|
||||
const channelsByUUID = { ...state.channelsByUUID };
|
||||
|
|
@ -119,7 +142,8 @@ const useChannelsStore = create((set, get) => ({
|
|||
}
|
||||
|
||||
return { channels: updatedChannels, channelsByUUID };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
addChannelGroup: (newChannelGroup) =>
|
||||
set((state) => ({
|
||||
|
|
@ -166,9 +190,69 @@ const useChannelsStore = create((set, get) => ({
|
|||
},
|
||||
})),
|
||||
|
||||
addProfile: (profile) =>
|
||||
set((state) => ({
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[profile.id]: profile,
|
||||
},
|
||||
})),
|
||||
|
||||
updateProfile: (profile) =>
|
||||
set((state) => ({
|
||||
channels: {
|
||||
...state.profiles,
|
||||
[profile.id]: profile,
|
||||
},
|
||||
})),
|
||||
|
||||
removeProfiles: (profileIds) =>
|
||||
set((state) => {
|
||||
const updatedProfiles = { ...state.profiles };
|
||||
for (const id of profileIds) {
|
||||
delete updatedProfiles[id];
|
||||
}
|
||||
|
||||
return { profiles: updatedProfiles };
|
||||
}),
|
||||
|
||||
updateProfileChannel: (channelId, profileId, enabled) =>
|
||||
set((state) => {
|
||||
// Get the specific profile
|
||||
const profile = state.profiles[profileId];
|
||||
if (!profile) return state; // Profile doesn't exist, no update needed
|
||||
|
||||
// Efficiently update only the specific channel
|
||||
return {
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[profileId]: {
|
||||
...profile,
|
||||
channels: profile.channels.map((channel) =>
|
||||
channel.id === channelId
|
||||
? { ...channel, enabled } // Update enabled flag
|
||||
: channel
|
||||
),
|
||||
},
|
||||
},
|
||||
selectedProfileChannels: state.selectedProfileChannels.map(
|
||||
(channel) => ({
|
||||
id: channel.id,
|
||||
enabled: channel.id == channelId ? enabled : channel.enabled,
|
||||
})
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
setChannelsPageSelection: (channelsPageSelection) =>
|
||||
set((state) => ({ channelsPageSelection })),
|
||||
|
||||
setSelectedProfileId: (id) =>
|
||||
set((state) => ({
|
||||
selectedProfileId: id,
|
||||
selectedProfileChannels: id == '0' ? [] : state.profiles[id].channels,
|
||||
})),
|
||||
|
||||
setChannelStats: (stats) => {
|
||||
const {
|
||||
channels,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue