more table bug fixes, query optimizations, re-added channel expansion stream table with reworked drag-and-drop

This commit is contained in:
dekzter 2025-04-19 08:37:43 -04:00
parent 8d1bfcb975
commit ccdb8ab00d
21 changed files with 1054 additions and 355 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
<ActionIcon {...attributes} {...listeners} variant="transparent" size="xs">
<GripHorizontal color="white" />
</ActionIcon>
);
};
// 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 (
<Box
ref={setNodeRef}
key={row.id}
className="tr"
style={{
...style,
display: 'flex',
width: '100%',
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
}}
>
{row.getVisibleCells().map((cell) => {
return (
<Box
className="td"
key={cell.id}
style={{
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex align="center" style={{ height: '100%' }}>
<Text size="xs">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Text>
</Flex>
</Box>
);
})}
</Box>
);
};
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 }) => <RowDragHandleCell rowId={row.id} />,
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 }) => (
<Center>
<ActionIcon variant="transparent" size="xs">
<SquareMinus
color={theme.tailwind.red[6]}
onClick={() => removeStream(row.original)}
/>
</ActionIcon>
</Center>
),
},
],
[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 (
<Box style={{ width: '100%', padding: 10, backgroundColor: '#163632' }}>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
{' '}
<Box
className="divTable table-striped"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Box className="tbody">
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
</Box>
</Box>
</DndContext>
</Box>
);
};
export default ChannelStreams;

View file

@ -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 (
<Box style={{ width: '100%', justifyContent: 'left' }}>
<Center>
<Tooltip label="Edit Channel">
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
>
<SquarePen size="18" />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
>
<SquarePen size="18" />
</ActionIcon>
<Tooltip label="Delete Channel">
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
>
<SquareMinus size="18" />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
>
<SquareMinus size="18" />
</ActionIcon>
<Tooltip label="Preview Channel">
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.green[5]}
onClick={onPreview}
>
<CirclePlay size="18" />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.green[5]}
onClick={onPreview}
>
<CirclePlay size="18" />
</ActionIcon>
<Menu>
<Menu.Target>
@ -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 (
<Flex gap={2}>
#
{/* <Center>
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange('name'),
onClick: () => onSortingChange('channel_number'),
size: 14,
})}
</Center> */}
</Center>
</Flex>
);
@ -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 (
<Center
style={{ width: '100%', cursor: 'pointer' }}
onClick={() => {
onRowExpansion(row);
}}
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Center>
);
},
[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)',
}}
>
<Box
@ -1269,38 +1354,53 @@ const ChannelsTable = ({}) => {
</Box>
<Box className="tbody">
{getRowModel().rows.map((row) => (
<Box
key={row.id}
className="tr"
style={{ display: 'flex', width: '100%' }}
>
{row.getVisibleCells().map((cell) => {
const width = cell.column.getSize();
return (
<Box
className="td"
key={cell.id}
style={{
flex: cell.column.columnDef.size
? '0 0 auto'
: '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex align="center" style={{ height: '100%' }}>
{cell.column.id === 'select'
? ChannelRowSelectCell({ row: cell.row })
: flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Flex>
</Box>
);
})}
<Box>
<Box
key={row.id}
className="tr"
style={{
display: 'flex',
width: '100%',
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
}}
>
{row.getVisibleCells().map((cell) => {
const width = cell.column.getSize();
return (
<Box
className="td"
key={cell.id}
style={{
flex: cell.column.columnDef.size
? '0 0 auto'
: '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex align="center" style={{ height: '100%' }}>
{renderBodyCell(cell)}
</Flex>
</Box>
);
})}
</Box>
{row.original.id === expandedRowId && (
<Box
key={row.id}
className="tr"
style={{ display: 'flex', width: '100%' }}
>
<ChannelTableStreams
channel={row.original}
isExpanded={true}
/>
</Box>
)}
</Box>
))}
</Box>
@ -1318,7 +1418,10 @@ const ChannelsTable = ({}) => {
<Group
gap={5}
justify="center"
style={{ padding: 8, borderTop: '1px solid #666' }}
style={{
padding: 8,
borderTop: '1px solid #666',
}}
>
<Text size="xs">Page Size</Text>
<NativeSelect

View file

@ -1,86 +0,0 @@
// HeadlessChannelsTable.jsx
import React, { useMemo, useState, useCallback, useRef } 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,
} from '@mantine/core';
import { ChevronRight, ChevronDown } from 'lucide-react';
import ChannelsTableRow from './ChannelsTableRow';
import { useVirtualizer } from '@tanstack/react-virtual'
const ChannelsTableBody = ({ rows, height, onEdit, onDelete, onPreview, onRecord, virtualizedItems }) => {
const rowHeight = 48;
// return (
// <tbody>
// <AutoSizer disableWidth>
// {({ height }) => (
// <List
// height={height}
// itemCount={rows.length}
// itemSize={rowHeight}
// width="100%"
// >
// {({ index, style }) => {
// const row = rows[index];
// return (
// <React.Fragment key={row.id}>
// <ChannelsTableRow
// row={row}
// onEdit={onEdit}
// onDelete={onDelete}
// onPreview={onPreview}
// onRecord={onRecord}
// />
// {row.getIsExpanded() && <ChannelsDetailPanel row={row} />}
// </React.Fragment>
// );
// }}
// </List>
// )}
// </AutoSizer>
// </tbody>
// );
return (
<Table.Tbody style={{
position: 'relative',
// display: 'block',
height: `${height}px`,
// overflowY: 'auto',
}}>
{virtualizedItems.map((virtualRow, index) => {
const row = rows[virtualRow.index]
return (
<ChannelsTableRow
row={row}
virtualRow={virtualRow}
index={index}
onEdit={onEdit}
onDelete={onDelete}
onPreview={onPreview}
onRecord={onRecord}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
/>
);
})}
</Table.Tbody>
);
};
export default ChannelsTableBody;

View file

@ -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 }) => (
<ActionIcon size="xs" onClick={toggle}>
{row.getIsExpanded() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</ActionIcon>
);
const ChannelsTableRow = ({ row, virtualRow, index, style, onEdit, onDelete, onPreview, onRecord }) => {
return (
<Table.Tr style={{
...style,
position: 'absolute',
// top: 0,
display: 'table',
tableLayout: 'fixed',
width: '100%',
}}>
{row.getVisibleCells().map(cell => {
return (
<Table.Td key={cell.id} align={cell.column.columnDef.meta?.align} style={{
padding: 0,
width: cell.column.getSize(),
minWidth: cell.column.columnDef.meta?.minWidth,
maxWidth: cell.column.columnDef.meta?.maxWidth,
// maxWidth: cell.column.getSize(),
}}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Td>
)
})}
</Table.Tr>
)
};
export default ChannelsTableRow

View file

@ -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 (
<Center
style={{ width: '100%', cursor: 'pointer' }}
onClick={() => {
setExpandedRowId((prev) =>
prev === row.original.id ? null : row.original.id
);
}}
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Center>
);
},
[expandedRowId]
);
const ChannelRowSelectCell = useCallback(
({ row }) => {
return (
<Center style={{ width: '100%' }}>
<Checkbox
size="xs"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</Center>
);
},
[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 (
<Box
className="divTable table-striped"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<CustomTableHeader
table={table}
headerCellRenderer={headerCellRenderer}
rowCount={rowCount}
onSelectAllChange={onSelectAllChange}
/>
<Box className="tbody">
{table.getRowModel().rows.map((row) => (
<Box>
<Box
key={row.id}
className="tr"
style={{
display: 'flex',
width: '100%',
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
}}
>
{row.getVisibleCells().map((cell) => {
return (
<Box
className="td"
key={cell.id}
style={{
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex align="center" style={{ height: '100%' }}>
{bodyCellRenderer(cell)}
</Flex>
</Box>
);
})}
</Box>
{row.original.id === expandedRowId && (
<Box
key={row.id}
className="tr"
style={{ display: 'flex', width: '100%' }}
>
<ChannelStreams channel={row.original} isExpanded={true} />
</Box>
)}
</Box>
))}
</Box>
</Box>
);
};
export default CustomTable;

View file

@ -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 (
<Center style={{ width: '100%' }}>
<Checkbox
size="xs"
checked={
rowCount == 0 ? false : selectedChannelIds.length == rowCount
}
indeterminate={
selectedChannelIds.length > 0 &&
selectedChannelIds.length !== rowCount
}
onChange={onSelectAllChange}
/>
</Center>
);
},
[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 (
<Center style={{ width: '100%' }}>
<ScanEye size="16" />
</Center>
);
// case 'channel_number':
// return (
// <Flex gap={2}>
// #
// {/* <Center>
// {React.createElement(sortingIcon, {
// onClick: () => onSortingChange('name'),
// size: 14,
// })}
// </Center> */}
// </Flex>
// );
// case 'name':
// return (
// <Flex gap="sm">
// <TextInput
// name="name"
// placeholder="Name"
// value={filters.name || ''}
// onClick={(e) => e.stopPropagation()}
// onChange={handleFilterChange}
// size="xs"
// variant="unstyled"
// className="table-input-header"
// />
// <Center>
// {React.createElement(sortingIcon, {
// onClick: () => onSortingChange('name'),
// size: 14,
// })}
// </Center>
// </Flex>
// );
// case 'channel_group':
// return (
// <MultiSelect
// placeholder="Group"
// variant="unstyled"
// data={groupOptions}
// size="xs"
// searchable
// clearable
// onClick={stopPropagation}
// onChange={handleGroupChange}
// style={{ width: '100%' }}
// />
// );
default:
return flexRender(header.column.columnDef.header, header.getContext());
}
};
return (
<Box
className="thead"
style={{
position: 'sticky',
top: 0,
backgroundColor: '#3E3E45',
zIndex: 10,
}}
>
{table.getHeaderGroups().map((headerGroup) => (
<Box
className="tr"
key={headerGroup.id}
style={{ display: 'flex', width: '100%' }}
>
{headerGroup.headers.map((header) => {
return (
<Box
className="th"
key={header.id}
style={{
flex: header.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: header.column.columnDef.size
? header.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex
align="center"
style={{
...(header.column.columnDef.style &&
header.column.columnDef.style),
height: '100%',
}}
>
{headerCellRenderer(header)}
</Flex>
</Box>
);
})}
</Box>
))}
</Box>
);
};
export default CustomTableHeader;

View file

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

View file

@ -86,5 +86,5 @@
.table-striped .tbody .tr:nth-child(even),
.table-striped .tbody .tr-even {
/* background-color: #ffffff; */
background-color: #27272A;
}

View file

@ -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') && (
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
Clear Filters
</Button>
)}
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
Clear Filters
</Button>
)}
<Text size="sm" color="dimmed">
{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 }) {
}}
>
<img
src={channel.logo?.cache_url || logo}
src={logos[channel.logo_id]?.cache_url || logo}
alt={channel.name}
style={{
maxWidth: '100%',

View file

@ -32,7 +32,7 @@ const useAuthStore = create((set, get) => ({
initData: async () => {
await Promise.all([
// useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useChannelsStore.getState().fetchLogos(),
useChannelsStore.getState().fetchChannelProfiles(),

View file

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

View file

@ -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 ?? [];
},
}));