fully supported channel profiles

This commit is contained in:
dekzter 2025-04-03 08:59:06 -04:00
parent bd3c526668
commit a41b205629
12 changed files with 584 additions and 93 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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