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 (
-
-
-
-
-
+
+
+
-
-
-
-
-
+
+
+
-
-
-
-
-
+
+
+