diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 4be83683..7192608b 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -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//channels//', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), + path('profiles//channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), ] urlpatterns += router.urls diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 085111c7..1ff0f5fa 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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) diff --git a/apps/channels/migrations/0012_channelprofile_channelprofilemembership.py b/apps/channels/migrations/0012_channelprofile_channelprofilemembership.py new file mode 100644 index 00000000..53fbdeff --- /dev/null +++ b/apps/channels/migrations/0012_channelprofile_channelprofilemembership.py @@ -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')}, + }, + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 90b9cfd3..f6424342 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -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) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 91e2ce89..45529f57 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -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 diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 9e23086f..7fcb452e 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -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 + ]) diff --git a/apps/output/urls.py b/apps/output/urls.py index 02e43c83..92774adb 100644 --- a/apps/output/urls.py +++ b/apps/output/urls.py @@ -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[^/]+))?/?$', generate_m3u, name='generate_m3u'), + + # Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/` + re_path(r'^epg(?:/(?P[^/]+))?/?$', generate_epg, name='generate_epg'), + # Allow both `/stream/` and `/stream//` re_path(r'^stream/(?P[0-9a-fA-F\-]+)/?$', stream_view, name='stream'), ] diff --git a/apps/output/views.py b/apps/output/views.py index 717dd614..b89df07a 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -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_lines.append('') + 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 diff --git a/frontend/src/api.js b/frontend/src/api.js index 9a1f1689..271aa992 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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); + } } diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 0402a0bd..d3de2c00 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -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 ( + + + + + + + + + + setName(event.currentTarget.value)} + size="xs" + /> + + + + + + + + ); +}; 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: , + size: 40, + enableSorting: false, + accessorFn: (row) => { + if (selectedProfileId == '0') { + return true; + } + + return selectedProfileChannels.find((channel) => row.id == channel.id) + .enabled; + }, + Cell: ({ row, cell }) => ( + { + 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 }) => ( +
+ {cell.getValue()} +
+ ), Header: ({ column }) => ( e.stopPropagation()}> - Remove - + value={selectedProfileId} + onChange={setSelectedProfileId} + data={[{ label: 'All', value: '0' }].concat( + Object.values(profiles).map((profile) => ({ + label: profile.name, + value: `${profile.id}`, + })) + )} + renderOption={renderProfileOption} + /> - + + + + + + + - - + + + + + + + + - - - - - + + + {/* Table or ghost empty state inside Paper */} diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index b7f2c44d..2355d125 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -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(), diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index baf1867c..e9c69d48 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -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,