From ccdb8ab00d32972e0d300366aec73ff6b2942998 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 19 Apr 2025 08:37:43 -0400 Subject: [PATCH] more table bug fixes, query optimizations, re-added channel expansion stream table with reworked drag-and-drop --- apps/channels/api_views.py | 14 +- ...016_channelstream_unique_channel_stream.py | 38 +++ ..._channel_number_alter_channelgroup_name.py | 23 ++ apps/channels/models.py | 7 +- apps/channels/serializers.py | 58 ++-- frontend/package-lock.json | 71 +++++ frontend/package.json | 4 + frontend/src/api.js | 48 ++- frontend/src/components/forms/Channel.jsx | 26 +- .../components/tables/ChannelTableStreams.jsx | 252 +++++++++++++++ .../src/components/tables/ChannelsTable.jsx | 287 ++++++++++++------ .../ChannelsTable/ChannelsTableBody.jsx | 86 ------ .../tables/ChannelsTable/ChannelsTableRow.jsx | 61 ---- .../tables/CustomTable/CustomTable.jsx | 134 ++++++++ .../tables/CustomTable/CustomTableHeader.jsx | 171 +++++++++++ .../src/components/tables/StreamsTable.jsx | 24 +- frontend/src/components/tables/table.css | 2 +- frontend/src/pages/Guide.jsx | 36 ++- frontend/src/store/auth.jsx | 2 +- frontend/src/store/channels.jsx | 10 +- frontend/src/store/channelsTable | 55 +--- 21 files changed, 1054 insertions(+), 355 deletions(-) create mode 100644 apps/channels/migrations/0016_channelstream_unique_channel_stream.py create mode 100644 apps/channels/migrations/0017_alter_channel_channel_number_alter_channelgroup_name.py create mode 100644 frontend/src/components/tables/ChannelTableStreams.jsx delete mode 100644 frontend/src/components/tables/ChannelsTable/ChannelsTableBody.jsx delete mode 100644 frontend/src/components/tables/ChannelsTable/ChannelsTableRow.jsx create mode 100644 frontend/src/components/tables/CustomTable/CustomTable.jsx create mode 100644 frontend/src/components/tables/CustomTable/CustomTableHeader.jsx diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b46384ad..b406fa07 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -127,6 +127,13 @@ class ChannelPagination(PageNumberPagination): page_size_query_param = 'page_size' # Allow clients to specify page size max_page_size = 10000 # Prevent excessive page sizes + + def paginate_queryset(self, queryset, request, view=None): + if not request.query_params.get(self.page_query_param): + return None # disables pagination, returns full queryset + + return super().paginate_queryset(queryset, request, view) + class ChannelFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr='icontains') channel_group_name = OrInFilter(field_name="channel_group__name", lookup_expr="icontains") @@ -148,7 +155,12 @@ class ChannelViewSet(viewsets.ModelViewSet): ordering = ['-channel_number'] def get_queryset(self): - qs = super().get_queryset() + qs = super().get_queryset().select_related( + 'channel_group', + 'logo', + 'epg_data', + 'stream_profile', + ).prefetch_related('streams') channel_group = self.request.query_params.get('channel_group') if channel_group: diff --git a/apps/channels/migrations/0016_channelstream_unique_channel_stream.py b/apps/channels/migrations/0016_channelstream_unique_channel_stream.py new file mode 100644 index 00000000..5301530a --- /dev/null +++ b/apps/channels/migrations/0016_channelstream_unique_channel_stream.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.6 on 2025-04-18 16:21 + +from django.db import migrations, models +from django.db.models import Count + +def remove_duplicate_channel_streams(apps, schema_editor): + ChannelStream = apps.get_model('dispatcharr_channels', 'ChannelStream') + # Find duplicates by (channel, stream) + duplicates = ( + ChannelStream.objects + .values('channel', 'stream') + .annotate(count=Count('id')) + .filter(count__gt=1) + ) + + for dupe in duplicates: + # Get all duplicates for this pair + dups = ChannelStream.objects.filter( + channel=dupe['channel'], + stream=dupe['stream'] + ).order_by('id') + + # Keep the first one, delete the rest + dups.exclude(id=dups.first().id).delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0015_recording_custom_properties'), + ] + + operations = [ + migrations.RunPython(remove_duplicate_channel_streams), + migrations.AddConstraint( + model_name='channelstream', + constraint=models.UniqueConstraint(fields=('channel', 'stream'), name='unique_channel_stream'), + ), + ] diff --git a/apps/channels/migrations/0017_alter_channel_channel_number_alter_channelgroup_name.py b/apps/channels/migrations/0017_alter_channel_channel_number_alter_channelgroup_name.py new file mode 100644 index 00000000..1bb7d2e7 --- /dev/null +++ b/apps/channels/migrations/0017_alter_channel_channel_number_alter_channelgroup_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-04-19 12:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0016_channelstream_unique_channel_stream'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='channel_number', + field=models.IntegerField(db_index=True), + ), + migrations.AlterField( + model_name='channelgroup', + name='name', + field=models.CharField(db_index=True, max_length=100, unique=True), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index deb66ae1..249343e9 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -27,7 +27,7 @@ def get_total_viewers(channel_id): return 0 class ChannelGroup(models.Model): - name = models.CharField(max_length=100, unique=True) + name = models.CharField(max_length=100, unique=True, db_index=True) def related_channels(self): # local import if needed to avoid cyc. Usually fine in a single file though @@ -210,7 +210,7 @@ class ChannelManager(models.Manager): class Channel(models.Model): - channel_number = models.IntegerField() + channel_number = models.IntegerField(db_index=True) name = models.CharField(max_length=255) logo = models.ForeignKey( 'Logo', @@ -426,6 +426,9 @@ class ChannelStream(models.Model): class Meta: ordering = ['order'] # Ensure streams are retrieved in order + constraints = [ + models.UniqueConstraint(fields=['channel', 'stream'], name='unique_channel_stream') + ] class ChannelGroupM3UAccount(models.Model): channel_group = models.ForeignKey( diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 505020ff..67386d4e 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -115,18 +115,14 @@ class BulkChannelProfileMembershipSerializer(serializers.Serializer): class ChannelSerializer(serializers.ModelSerializer): # Show nested group data, or ID channel_number = serializers.IntegerField(allow_null=True, required=False) - channel_group = ChannelGroupSerializer(read_only=True) channel_group_id = serializers.PrimaryKeyRelatedField( queryset=ChannelGroup.objects.all(), source="channel_group", - write_only=True, required=False ) - epg_data = EPGDataSerializer(read_only=True) epg_data_id = serializers.PrimaryKeyRelatedField( queryset=EPGData.objects.all(), source="epg_data", - write_only=True, required=False, allow_null=True, ) @@ -143,13 +139,11 @@ class ChannelSerializer(serializers.ModelSerializer): queryset=Stream.objects.all(), many=True, write_only=True, required=False ) - logo = LogoSerializer(read_only=True) logo_id = serializers.PrimaryKeyRelatedField( queryset=Logo.objects.all(), source='logo', allow_null=True, required=False, - write_only=True, ) class Meta: @@ -158,16 +152,13 @@ class ChannelSerializer(serializers.ModelSerializer): 'id', 'channel_number', 'name', - 'channel_group', 'channel_group_id', 'tvg_id', - 'epg_data', 'epg_data_id', 'streams', 'stream_ids', 'stream_profile_id', 'uuid', - 'logo', 'logo_id', ] @@ -194,34 +185,53 @@ class ChannelSerializer(serializers.ModelSerializer): ChannelStream.objects.create(channel=channel, stream_id=stream_id, order=index) return channel - def update(self, instance, validated_data): stream_ids = validated_data.pop('stream_ids', None) - # Update all fields from validated_data + # Update standard fields for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() - # Handle streams if provided if stream_ids is not None: - # Clear existing associations - instance.channelstream_set.all().delete() + # Normalize stream IDs + normalized_ids = [ + stream.id if hasattr(stream, "id") else stream + for stream in stream_ids + ] - # Create new associations with proper ordering - for index, stream in enumerate(stream_ids): - # Extract the ID from the Stream object - actual_stream_id = stream.id if hasattr(stream, "id") else stream - print(f'Setting stream {actual_stream_id} to index {index}') - ChannelStream.objects.create( - channel=instance, - stream_id=actual_stream_id, - order=index - ) + # Get current mapping of stream_id -> ChannelStream + current_links = { + cs.stream_id: cs for cs in instance.channelstream_set.all() + } + + # Track existing stream IDs + existing_ids = set(current_links.keys()) + new_ids = set(normalized_ids) + + # Delete any links not in the new list + to_remove = existing_ids - new_ids + if to_remove: + instance.channelstream_set.filter(stream_id__in=to_remove).delete() + + # Update or create with new order + for order, stream_id in enumerate(normalized_ids): + if stream_id in current_links: + cs = current_links[stream_id] + if cs.order != order: + cs.order = order + cs.save(update_fields=["order"]) + else: + ChannelStream.objects.create( + channel=instance, + stream_id=stream_id, + order=order + ) return instance + def validate_stream_profile(self, value): """Handle special case where empty/0 values mean 'use default' (null)""" if value == '0' or value == 0 or value == '' or value is None: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5184cceb..f1caa9f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "vite", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@mantine/charts": "^7.17.2", "@mantine/core": "^7.17.2", "@mantine/dates": "^7.17.2", @@ -193,6 +197,73 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index c0d6ced3..7af7ff89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@mantine/charts": "^7.17.2", "@mantine/core": "^7.17.2", "@mantine/dates": "^7.17.2", diff --git a/frontend/src/api.js b/frontend/src/api.js index 7e025d6f..f30d43dc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -8,6 +8,7 @@ import useStreamsStore from './store/streams'; import useStreamProfilesStore from './store/streamProfiles'; import useSettingsStore from './store/settings'; import { notifications } from '@mantine/notifications'; +import useChannelsTableStore from './store/channelsTable'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV @@ -91,6 +92,8 @@ const request = async (url, options = {}) => { }; export default class API { + static lastQueryParams = new URLSearchParams(); + /** * A static method so we can do: await API.getAuthToken() */ @@ -172,10 +175,30 @@ export default class API { static async queryChannels(params) { try { + API.lastQueryParams = params; + const response = await request( `${host}/api/channels/channels/?${params.toString()}` ); + useChannelsTableStore.getState().queryChannels(response, params); + + return response; + } catch (e) { + errorNotification('Failed to fetch channels', e); + } + } + + static async requeryChannels() { + try { + const response = await request( + `${host}/api/channels/channels/?${API.lastQueryParams.toString()}` + ); + + useChannelsTableStore + .getState() + .queryChannels(response, API.lastQueryParams); + return response; } catch (e) { errorNotification('Failed to fetch channels', e); @@ -258,6 +281,8 @@ export default class API { body: body, }); + API.getLogos(); + if (response.id) { useChannelsStore.getState().addChannel(response); } @@ -300,7 +325,10 @@ export default class API { const payload = { ...values }; // Handle special values - if (payload.stream_profile_id === '0' || payload.stream_profile_id === 0) { + if ( + payload.stream_profile_id === '0' || + payload.stream_profile_id === 0 + ) { payload.stream_profile_id = null; } @@ -312,15 +340,21 @@ export default class API { // Handle channel_number properly if (payload.channel_number === '') { payload.channel_number = null; - } else if (payload.channel_number !== null && payload.channel_number !== undefined) { + } else if ( + payload.channel_number !== null && + payload.channel_number !== undefined + ) { const parsedNumber = parseInt(payload.channel_number, 10); payload.channel_number = isNaN(parsedNumber) ? null : parsedNumber; } - const response = await request(`${host}/api/channels/channels/${payload.id}/`, { - method: 'PATCH', - body: payload, - }); + const response = await request( + `${host}/api/channels/channels/${payload.id}/`, + { + method: 'PATCH', + body: payload, + } + ); useChannelsStore.getState().updateChannel(response); return response; @@ -349,7 +383,7 @@ export default class API { notifications.show({ title: 'EPG Status', message: response.task_status, - color: 'blue' + color: 'blue', }); } diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 36efafb7..696143a2 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -102,7 +102,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => { const formattedValues = { ...values }; // Convert empty or "0" stream_profile_id to null for the API - if (!formattedValues.stream_profile_id || formattedValues.stream_profile_id === '0') { + if ( + !formattedValues.stream_profile_id || + formattedValues.stream_profile_id === '0' + ) { formattedValues.stream_profile_id = null; } @@ -111,9 +114,12 @@ const Channel = ({ channel = null, isOpen, onClose }) => { if (channel) { // If there's an EPG to set, use our enhanced endpoint - if (values.epg_data_id !== (channel.epg_data ? `${channel.epg_data.id}` : '')) { + if (values.epg_data_id !== (channel.epg_data_id ?? '')) { // Use the special endpoint to set EPG and trigger refresh - const epgResponse = await API.setChannelEPG(channel.id, values.epg_data_id); + const epgResponse = await API.setChannelEPG( + channel.id, + values.epg_data_id + ); // Remove epg_data_id from values since we've handled it separately const { epg_data_id, ...otherValues } = formattedValues; @@ -142,7 +148,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { }); } } catch (error) { - console.error("Error saving channel:", error); + console.error('Error saving channel:', error); } setSubmitting(false); @@ -154,8 +160,8 @@ const Channel = ({ channel = null, isOpen, onClose }) => { useEffect(() => { if (channel) { - if (channel.epg_data) { - const epgSource = epgs[channel.epg_data.epg_source]; + if (channel.epg_data_id) { + const epgSource = epgs[tvgsById[channel.epg_data_id].epg_source]; setSelectedEPG(`${epgSource.id}`); } @@ -167,8 +173,8 @@ const Channel = ({ channel = null, isOpen, onClose }) => { ? `${channel.stream_profile_id}` : '0', tvg_id: channel.tvg_id, - epg_data_id: channel.epg_data ? `${channel.epg_data?.id}` : '', - logo_id: `${channel.logo?.id}`, + epg_data_id: channel.epg_data_id ?? '', + logo_id: `${channel.logo_id}`, }); setChannelStreams(channel.streams); @@ -535,7 +541,9 @@ const Channel = ({ channel = null, isOpen, onClose }) => { name="channel_number" label="Channel # (blank to auto-assign)" value={formik.values.channel_number} - onChange={(value) => formik.setFieldValue('channel_number', value)} + onChange={(value) => + formik.setFieldValue('channel_number', value) + } error={ formik.errors.channel_number ? formik.touched.channel_number diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx new file mode 100644 index 00000000..8c29c228 --- /dev/null +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -0,0 +1,252 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import API from '../../api'; +import { GripHorizontal, SquareMinus } from 'lucide-react'; +import { + Box, + ActionIcon, + Flex, + Text, + useMantineTheme, + Center, +} from '@mantine/core'; +import { + useReactTable, + getCoreRowModel, + flexRender, +} from '@tanstack/react-table'; +import './table.css'; +import useChannelsTableStore from '../../store/channelsTable'; +import usePlaylistsStore from '../../store/playlists'; +import { + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { shallow } from 'zustand/shallow'; + +// Cell Component +const RowDragHandleCell = ({ rowId }) => { + const { attributes, listeners } = useSortable({ + id: rowId, + }); + return ( + // Alternatively, you could set these attributes on the rows themselves + + + + ); +}; + +// Row Component +const DraggableRow = ({ row }) => { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), //let dnd-kit do its thing + transition: transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1 : 0, + position: 'relative', + }; + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + + ); + })} + + ); +}; + +const ChannelStreams = ({ channel, isExpanded }) => { + const theme = useMantineTheme(); + + const channelStreams = useChannelsTableStore( + (state) => state.getChannelStreams(channel.id), + shallow + ); + + useEffect(() => { + setData(channelStreams); + }, [channelStreams]); + + const [data, setData] = useState(channelStreams || []); + + const dataIds = data?.map(({ id }) => id); + + const { playlists } = usePlaylistsStore(); + + const removeStream = async (stream) => { + const newStreamList = channelStreams.filter((s) => s.id !== stream.id); + await API.updateChannel({ + ...channel, + stream_ids: newStreamList.map((s) => s.id), + }); + await API.requeryChannels(); + }; + + const table = useReactTable({ + columns: useMemo( + () => [ + { + id: 'drag-handle', + header: 'Move', + cell: ({ row }) => , + size: 30, + }, + { + id: 'name', + header: 'Name', + accessorKey: 'name', + }, + { + id: 'm3u', + header: 'M3U', + accessorFn: (row) => + playlists.find((playlist) => playlist.id === row.m3u_account)?.name, + }, + { + id: 'actions', + header: '', + size: 30, + cell: ({ row }) => ( +
+ + removeStream(row.original)} + /> + +
+ ), + }, + ], + [playlists] + ), + data: data, + state: { + data, + }, + defaultColumn: { + size: undefined, + minSize: 0, + }, + manualPagination: true, + manualSorting: true, + manualFiltering: true, + enableRowSelection: true, + getRowId: (row) => row.id, + getCoreRowModel: getCoreRowModel(), + // getFilteredRowModel: getFilteredRowModel(), + // getSortedRowModel: getSortedRowModel(), + // getPaginationRowModel: getPaginationRowModel(), + }); + + function handleDragEnd(event) { + const { active, over } = event; + if (active && over && active.id !== over.id) { + setData((data) => { + const oldIndex = dataIds.indexOf(active.id); + const newIndex = dataIds.indexOf(over.id); + const retval = arrayMove(data, oldIndex, newIndex); + + const { streams: _, ...channelUpdate } = channel; + API.updateChannel({ + ...channelUpdate, + stream_ids: retval.map((row) => row.id), + }).then(() => { + API.requeryChannels(); + }); + + return retval; //this is just a splice util + }); + } + } + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + if (!isExpanded) { + return <>; + } + + return ( + + + {' '} + + + + {table.getRowModel().rows.map((row) => ( + + ))} + + + + + + ); +}; + +export default ChannelStreams; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index b79aae85..c6272ef1 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -15,7 +15,6 @@ import { useDebounce } from '../../utils'; import logo from '../../images/logo.png'; import useVideoStore from '../../store/useVideoStore'; import useSettingsStore from '../../store/settings'; -import usePlaylistsStore from '../../store/playlists'; import { Tv2, ScreenShare, @@ -35,6 +34,8 @@ import { ArrowUpNarrowWide, ArrowUpDown, ArrowDownWideNarrow, + ChevronDown, + ChevronRight, } from 'lucide-react'; import ghostImage from '../../images/ghost.svg'; import { @@ -70,6 +71,26 @@ import { } from '@tanstack/react-table'; import './table.css'; import useChannelsTableStore from '../../store/channelsTable'; +import usePlaylistsStore from '../../store/playlists'; +import { MantineReactTable, useMantineReactTable } from 'mantine-react-table'; +import { + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import ChannelTableStreams from './ChannelTableStreams'; const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`; const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`; @@ -189,38 +210,32 @@ const ChannelRowActions = React.memo( return (
- - - - - + + + - - - - - + + + - - - - - + + + @@ -255,16 +270,17 @@ const ChannelRowActions = React.memo( ); const ChannelsTable = ({}) => { + const data = useChannelsTableStore((s) => s.channels); + const rowCount = useChannelsTableStore((s) => s.count); + const pageCount = useChannelsTableStore((s) => s.pageCount); + const setSelectedTableIds = useChannelsTableStore( + (s) => s.setSelectedChannelIds + ); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId); const channelGroups = useChannelsStore((s) => s.channelGroups); - - const queryChannels = useChannelsTableStore((s) => s.queryChannels); - const requeryChannels = useChannelsTableStore((s) => s.requeryChannels); - const data = useChannelsTableStore((s) => s.channels); - const rowCount = useChannelsTableStore((s) => s.count); - const pageCount = useChannelsTableStore((s) => s.pageCount); + const logos = useChannelsStore((s) => s.logos); const selectedProfileChannels = useChannelsStore( (s) => s.profiles[selectedProfileId]?.channels @@ -303,34 +319,49 @@ const ChannelsTable = ({}) => { const [sorting, setSorting] = useState([ { id: 'channel_number', desc: false }, ]); + const [expandedRowId, setExpandedRowId] = useState(null); const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase); const [epgUrl, setEPGUrl] = useState(epgUrlBase); const [m3uUrl, setM3UUrl] = useState(m3uUrlBase); - useEffect(() => { + const fetchData = useCallback(async () => { + const params = new URLSearchParams(); + params.append('page', pagination.pageIndex + 1); + params.append('page_size', pagination.pageSize); + + // Apply sorting + if (sorting.length > 0) { + const sortField = sorting[0].id; + const sortDirection = sorting[0].desc ? '-' : ''; + params.append('ordering', `${sortDirection}${sortField}`); + } + + // Apply debounced filters + Object.entries(filters).forEach(([key, value]) => { + if (value) params.append(key, value); + }); + + const results = await API.queryChannels(params); + const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 const endItem = Math.min( (pagination.pageIndex + 1) * pagination.pageSize, - rowCount + results.count ); if (initialDataCount === null) { - setInitialDataCount(rowCount); + setInitialDataCount(results.count); } // Generate the string - setPaginationString(`${startItem} to ${endItem} of ${rowCount}`); - }, [data]); - - useEffect(() => { - queryChannels({ pagination, sorting, filters }); - }, []); - - useEffect(() => { - queryChannels({ pagination, sorting, filters }); + setPaginationString(`${startItem} to ${endItem} of ${results.count}`); }, [pagination, sorting, debouncedFilters]); + useEffect(() => { + fetchData(); + }, [fetchData]); + // const theme = useTheme(); const theme = useMantineTheme(); @@ -408,7 +439,9 @@ const ChannelsTable = ({}) => { updatedSelected.add(row.original.id); } }); - setSelectedChannelIds([...updatedSelected]); + const newSelection = [...updatedSelected]; + setSelectedChannelIds(newSelection); + setSelectedTableIds(newSelection); return newRowSelection; }); @@ -423,8 +456,10 @@ const ChannelsTable = ({}) => { if (value) params.append(key, value); }); const ids = await API.getAllChannelIds(params); + setSelectedTableIds(ids); setSelectedChannelIds(ids); } else { + setSelectedTableIds([]); setSelectedChannelIds([]); } @@ -488,7 +523,9 @@ const ChannelsTable = ({}) => { const deleteChannels = async () => { setIsLoading(true); await API.deleteChannels(selectedChannelIds); - requeryChannels(); + await API.requeryChannels(); + setSelectedChannelIds([]); + setRowSelection([]); setIsLoading(false); }; @@ -513,7 +550,7 @@ const ChannelsTable = ({}) => { // Refresh the channel list // await fetchChannels(); - requeryChannels(); + API.requeryChannels(); } catch (err) { console.error(err); notifications.show({ @@ -589,7 +626,6 @@ const ChannelsTable = ({}) => { }; const onSortingChange = (column) => { - console.log(sorting); const sortField = sorting[0]?.id; const sortDirection = sorting[0]?.desc; @@ -637,6 +673,12 @@ const ChannelsTable = ({}) => { const columns = useMemo( () => [ + { + id: 'expand', + size: 20, + enableSorting: false, + enableColumnFilter: false, + }, { id: 'select', size: 30, @@ -704,7 +746,8 @@ const ChannelsTable = ({}) => { ), }, { - accessorKey: 'logo', + id: 'logo', + accessorFn: (row) => logos[row.logo_id] ?? logo, size: 75, header: '', cell: ({ getValue }) => { @@ -772,6 +815,17 @@ const ChannelsTable = ({}) => { const rows = getRowModel().rows; + const onRowExpansion = (row) => { + let isExpanded = false; + setExpandedRowId((prev) => { + isExpanded = prev === row.original.id ? null : row.original.id; + return isExpanded; + }); + setRowSelection({ [row.index]: true }); + setSelectedChannelIds([row.original.id]); + setSelectedTableIds([row.original.id]); + }; + const renderHeaderCell = (header) => { let sortingIcon = ArrowUpDown; if (sorting[0]?.id == header.id) { @@ -802,12 +856,12 @@ const ChannelsTable = ({}) => { return ( # - {/*
+
{React.createElement(sortingIcon, { - onClick: () => onSortingChange('name'), + onClick: () => onSortingChange('channel_number'), size: 14, })} -
*/} +
); @@ -853,6 +907,37 @@ const ChannelsTable = ({}) => { } }; + const renderBodyCell = (cell) => { + switch (cell.column.id) { + case 'select': + return ChannelRowSelectCell({ row: cell.row }); + + case 'expand': + return ChannelExpandCell({ row: cell.row }); + + default: + return flexRender(cell.column.columnDef.cell, cell.getContext()); + } + }; + + const ChannelExpandCell = useCallback( + ({ row }) => { + const isExpanded = expandedRowId === row.original.id; + + return ( +
{ + onRowExpansion(row); + }} + > + {isExpanded ? : } +
+ ); + }, + [expandedRowId] + ); + const ChannelRowSelectCell = useCallback( ({ row }) => { return ( @@ -1031,7 +1116,7 @@ const ChannelsTable = ({}) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 60px)', + height: 'calc(100vh - 58px)', backgroundColor: '#27272A', }} > @@ -1200,7 +1285,7 @@ const ChannelsTable = ({}) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 120px)', + height: 'calc(100vh - 110px)', }} > { {getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const width = cell.column.getSize(); - return ( - - - {cell.column.id === 'select' - ? ChannelRowSelectCell({ row: cell.row }) - : flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - - ); - })} + + + {row.getVisibleCells().map((cell) => { + const width = cell.column.getSize(); + return ( + + + {renderBodyCell(cell)} + + + ); + })} + + {row.original.id === expandedRowId && ( + + + + )} ))} @@ -1318,7 +1418,10 @@ const ChannelsTable = ({}) => { Page Size { - const rowHeight = 48; - - // return ( - // - // - // {({ height }) => ( - // - // {({ index, style }) => { - // const row = rows[index]; - // return ( - // - // - // {row.getIsExpanded() && } - // - // ); - // }} - // - // )} - // - // - // ); - - return ( - - {virtualizedItems.map((virtualRow, index) => { - const row = rows[virtualRow.index] - return ( - - ); - })} - - ); -}; - -export default ChannelsTableBody; diff --git a/frontend/src/components/tables/ChannelsTable/ChannelsTableRow.jsx b/frontend/src/components/tables/ChannelsTable/ChannelsTableRow.jsx deleted file mode 100644 index 94466e13..00000000 --- a/frontend/src/components/tables/ChannelsTable/ChannelsTableRow.jsx +++ /dev/null @@ -1,61 +0,0 @@ -// HeadlessChannelsTable.jsx -import React, { useMemo, useState, useCallback } from 'react'; -import { FixedSizeList as List } from 'react-window'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { - useReactTable, - getCoreRowModel, - getSortedRowModel, - flexRender, - getExpandedRowModel, -} from '@tanstack/react-table'; -import { - Table, - Box, - Checkbox, - ActionIcon, - ScrollArea, - Center, - useMantineTheme, -} from '@mantine/core'; -import { ChevronRight, ChevronDown } from 'lucide-react'; -import useSettingsStore from '../../../store/settings'; -import useChannelsStore from '../../../store/channels'; - -const ExpandIcon = ({ row, toggle }) => ( - - {row.getIsExpanded() ? : } - -); - -const ChannelsTableRow = ({ row, virtualRow, index, style, onEdit, onDelete, onPreview, onRecord }) => { - return ( - - {row.getVisibleCells().map(cell => { - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ) - })} - - ) -}; - -export default ChannelsTableRow diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx new file mode 100644 index 00000000..c35b5bf8 --- /dev/null +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -0,0 +1,134 @@ +import { Box, Flex } from '@mantine/core'; +import CustomTableHeader from './CustomTableHeader'; +import { useCallback, useState } from 'react'; +import { flexRender } from '@tanstack/react-table'; + +const CustomTable = ({ + table, + headerCellRenderer, + rowDetailRenderer, + bodyCellRenderFns, + rowCount, +}) => { + const [expandedRowId, setExpandedRowId] = useState(null); + + const rows = table.getRowModel().rows; + + const ChannelExpandCell = useCallback( + ({ row }) => { + const isExpanded = expandedRowId === row.original.id; + + return ( +
{ + setExpandedRowId((prev) => + prev === row.original.id ? null : row.original.id + ); + }} + > + {isExpanded ? : } +
+ ); + }, + [expandedRowId] + ); + + const ChannelRowSelectCell = useCallback( + ({ row }) => { + return ( +
+ +
+ ); + }, + [rows] + ); + + const bodyCellRenderer = (cell) => { + if (bodyCellRenderFns[cell.column.id]) { + return bodyCellRenderFns(cell); + } + + switch (cell.column.id) { + case 'select': + return ChannelRowSelectCell({ row: cell.row }); + + case 'expand': + return ChannelExpandCell({ row: cell.row }); + + default: + return flexRender(cell.column.columnDef.cell, cell.getContext()); + } + }; + + return ( + + + + {table.getRowModel().rows.map((row) => ( + + + {row.getVisibleCells().map((cell) => { + return ( + + + {bodyCellRenderer(cell)} + + + ); + })} + + {row.original.id === expandedRowId && ( + + + + )} + + ))} + + + ); +}; + +export default CustomTable; diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx new file mode 100644 index 00000000..50a173d2 --- /dev/null +++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx @@ -0,0 +1,171 @@ +import { Box, Flex } from '@mantine/core'; +import { + ArrowDownWideNarrow, + ArrowUpDown, + ArrowUpNarrowWide, +} from 'lucide-react'; +import { useCallback } from 'react'; + +const CustomTableHeader = ({ + table, + headerCellRenderFns, + rowCount, + onSelectAllChange, +}) => { + const ChannelRowSelectHeader = useCallback( + ({ selectedChannelIds }) => { + return ( +
+ 0 && + selectedChannelIds.length !== rowCount + } + onChange={onSelectAllChange} + /> +
+ ); + }, + [rows, rowCount] + ); + + const onSelectAll = (e) => { + if (onSelectAllChange) { + onSelectAllChange(e); + } + }; + + const headerCellRenderer = (header) => { + let sortingIcon = ArrowUpDown; + if (sorting[0]?.id == header.id) { + if (sorting[0].desc === false) { + sortingIcon = ArrowUpNarrowWide; + } else { + sortingIcon = ArrowDownWideNarrow; + } + } + + switch (header.id) { + case 'select': + return ChannelRowSelectHeader({ + selectedChannelIds, + }); + + case 'enabled': + if (selectedProfileId !== '0' && selectedChannelIds.length > 0) { + // return EnabledHeaderSwitch(); + } + return ( +
+ +
+ ); + + // case 'channel_number': + // return ( + // + // # + // {/*
+ // {React.createElement(sortingIcon, { + // onClick: () => onSortingChange('name'), + // size: 14, + // })} + //
*/} + //
+ // ); + + // case 'name': + // return ( + // + // e.stopPropagation()} + // onChange={handleFilterChange} + // size="xs" + // variant="unstyled" + // className="table-input-header" + // /> + //
+ // {React.createElement(sortingIcon, { + // onClick: () => onSortingChange('name'), + // size: 14, + // })} + //
+ //
+ // ); + + // case 'channel_group': + // return ( + // + // ); + + default: + return flexRender(header.column.columnDef.header, header.getContext()); + } + }; + + return ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + + {headerCellRenderer(header)} + + + ); + })} + + ))} + + ); +}; + +export default CustomTableHeader; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 777d2a32..e12bb125 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -78,15 +78,14 @@ const StreamsTable = ({}) => { * Stores */ const { playlists } = usePlaylistsStore(); + const channelGroups = useChannelsStore((s) => s.channelGroups); - const channelsPageSelection = useChannelsStore( - (s) => s.channelsPageSelection - ); + const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); const fetchLogos = useChannelsStore((s) => s.fetchLogos); - const channelSelectionStreams = useChannelsStore( - (state) => state.channels[state.channelsPageSelection[0]?.id]?.streams + const channelSelectionStreams = useChannelsTableStore( + (state) => + state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams ); - const requeryChannels = useChannelsTableStore((s) => s.requeryChannels); const { environment: { env_mode }, } = useSettingsStore(); @@ -287,6 +286,7 @@ const StreamsTable = ({}) => { channel_number: null, stream_id: stream.id, }); + await API.requeryChannels(); fetchLogos(); }; @@ -298,7 +298,7 @@ const StreamsTable = ({}) => { stream_id, })) ); - requeryChannels(); + await API.requeryChannels(); fetchLogos(); setIsLoading(false); }; @@ -325,9 +325,8 @@ const StreamsTable = ({}) => { }; const addStreamsToChannel = async () => { - const { streams, ...channel } = { ...channelsPageSelection[0] }; await API.updateChannel({ - ...channel, + id: selectedChannelIds[0], stream_ids: [ ...new Set( channelSelectionStreams @@ -336,18 +335,19 @@ const StreamsTable = ({}) => { ), ], }); + await API.requeryChannels(); }; const addStreamToChannel = async (streamId) => { - const { streams, ...channel } = { ...channelsPageSelection[0] }; await API.updateChannel({ - ...channel, + id: selectedChannelIds[0], stream_ids: [ ...new Set( channelSelectionStreams.map((stream) => stream.id).concat([streamId]) ), ], }); + await API.requeryChannels(); }; const onRowSelectionChange = (updater) => { @@ -512,7 +512,7 @@ const StreamsTable = ({}) => { onClick={() => addStreamToChannel(row.original.id)} style={{ background: 'none' }} disabled={ - channelsPageSelection.length !== 1 || + selectedChannelIds.length !== 1 || (channelSelectionStreams && channelSelectionStreams .map((stream) => stream.id) diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css index 92afaaaa..c1c43f20 100644 --- a/frontend/src/components/tables/table.css +++ b/frontend/src/components/tables/table.css @@ -86,5 +86,5 @@ .table-striped .tbody .tr:nth-child(even), .table-striped .tbody .tr-even { - /* background-color: #ffffff; */ + background-color: #27272A; } diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 0b03954f..2e4de29b 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -23,6 +23,7 @@ import { } from '@mantine/core'; import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react'; import './guide.css'; +import useEPGsStore from '../store/epgs'; /** Layout constants */ const CHANNEL_WIDTH = 120; // Width of the channel/logo column @@ -33,7 +34,13 @@ const MINUTE_INCREMENT = 15; // For positioning programs every 15 min const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT); export default function TVChannelGuide({ startDate, endDate }) { - const { channels, recordings, channelGroups, profiles } = useChannelsStore(); + const channels = useChannelsStore((s) => s.channels); + const recordings = useChannelsStore((s) => s.recordings); + const channelGroups = useChannelsStore((s) => s.channelGroups); + const profiles = useChannelsStore((s) => s.profiles); + const logos = useChannelsStore((s) => s.logos); + + const tvgsById = useEPGsStore((s) => s.tvgsById); const [programs, setPrograms] = useState([]); const [guideChannels, setGuideChannels] = useState([]); @@ -79,7 +86,12 @@ export default function TVChannelGuide({ startDate, endDate }) { // Filter your Redux/Zustand channels by matching tvg_id const filteredChannels = Object.values(channels) // Include channels with matching tvg_ids OR channels with null epg_data - .filter((ch) => programIds.includes(ch.epg_data?.tvg_id) || programIds.includes(ch.uuid) || ch.epg_data === null) + .filter( + (ch) => + programIds.includes(tvgsById[ch.epg_data_id]?.tvg_id) || + programIds.includes(ch.uuid) || + ch.epg_data_id === null + ) // Add sorting by channel_number .sort( (a, b) => @@ -276,7 +288,9 @@ export default function TVChannelGuide({ startDate, endDate }) { // Helper: find channel by tvg_id function findChannelByTvgId(tvgId) { return guideChannels.find( - (ch) => ch.epg_data?.tvg_id === tvgId || (!ch.epg_data && ch.uuid === tvgId) + (ch) => + tvgsById[ch.epg_data_id]?.tvg_id === tvgId || + (!ch.epg_data_id && ch.uuid === tvgId) ); } @@ -839,10 +853,10 @@ export default function TVChannelGuide({ startDate, endDate }) { {(searchQuery !== '' || selectedGroupId !== 'all' || selectedProfileId !== 'all') && ( - - )} + + )} {filteredChannels.length}{' '} @@ -1049,8 +1063,10 @@ export default function TVChannelGuide({ startDate, endDate }) { {filteredChannels.length > 0 ? ( filteredChannels.map((channel) => { const channelPrograms = programs.filter( - (p) => (channel.epg_data && p.tvg_id === channel.epg_data.tvg_id) || - (!channel.epg_data && p.tvg_id === channel.uuid) + (p) => + (channel.epg_data_id && + p.tvg_id === tvgsById[channel.epg_data_id].tvg_id) || + (!channel.epg_data_id && p.tvg_id === channel.uuid) ); // Check if any program in this channel is expanded const hasExpandedProgram = channelPrograms.some( @@ -1149,7 +1165,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }} > {channel.name} ({ initData: async () => { await Promise.all([ - // useChannelsStore.getState().fetchChannels(), + useChannelsStore.getState().fetchChannels(), useChannelsStore.getState().fetchChannelGroups(), useChannelsStore.getState().fetchLogos(), useChannelsStore.getState().fetchChannelProfiles(), diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 5e5049be..29ba3bfc 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -113,16 +113,12 @@ const useChannelsStore = create((set, get) => ({ const channelsByID = newChannels.reduce((acc, channel) => { acc[channel.id] = channel; channelsByUUID[channel.uuid] = channel.id; - if (channel.logo) { - logos[channel.logo.id] = channel.logo; - } - profileChannels.add(channel.id); return acc; }, {}); - const newProfiles = {}; + const newProfiles = { ...defaultProfiles }; Object.entries(state.profiles).forEach(([id, profile]) => { newProfiles[id] = { ...profile, @@ -139,10 +135,6 @@ const useChannelsStore = create((set, get) => ({ ...state.channelsByUUID, ...channelsByUUID, }, - logos: { - ...state.logos, - ...logos, - }, profiles: newProfiles, }; }), diff --git a/frontend/src/store/channelsTable b/frontend/src/store/channelsTable index a5146363..2a230e84 100644 --- a/frontend/src/store/channelsTable +++ b/frontend/src/store/channelsTable @@ -3,56 +3,31 @@ import api from '../api'; import { notifications } from '@mantine/notifications'; import API from '../api'; -const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } }; - const useChannelsTableStore = create((set, get) => ({ channels: [], count: 0, pageCount: 0, - lastParams: new URLSearchParams(), + selectedChannelIds: [], - requeryChannels: async () => { - const lastParams = get().lastParams; - console.log(lastParams); - const result = await API.queryChannels(lastParams); - const pageSize = parseInt(lastParams.get?.('page_size') || '25'); - - set({ - channels: result.results, - count: result.count, - pageCount: Math.ceil(result.count / pageSize), + queryChannels: ({ results, count }, params) => { + set((state) => { + return { + channels: results, + count: count, + pageCount: Math.ceil(count / params.page_size), + }; }); }, - queryChannels: async ({ pagination, sorting, filters }) => { - const params = new URLSearchParams(); - params.append('page', pagination.pageIndex + 1); - params.append('page_size', pagination.pageSize); - - // Apply sorting - if (sorting.length > 0) { - const sortField = sorting[0].id; - const sortDirection = sorting[0].desc ? '-' : ''; - params.append('ordering', `${sortDirection}${sortField}`); - } - - // Apply debounced filters - Object.entries(filters).forEach(([key, value]) => { - if (value) params.append(key, value); + setSelectedChannelIds: (selectedChannelIds) => { + set({ + selectedChannelIds, }); + }, - try { - const result = await API.queryChannels(params); - - set((state) => ({ - channels: result.results, - count: result.count, - pageCount: Math.ceil(result.count / pagination.pageSize), - lastParams: params, - })); - } catch (error) { - console.error('Error fetching data:', error); - } + getChannelStreams: (id) => { + const channel = get().channels.find((c) => c.id === id); + return channel?.streams ?? []; }, }));