initial push for pagination with streams tables - still need to fix the channels form tables

This commit is contained in:
dekzter 2025-03-08 09:17:20 -05:00
parent f57e15bd00
commit 9ac73cf990
10 changed files with 228 additions and 124 deletions

View file

@ -10,7 +10,27 @@ from django.shortcuts import get_object_or_404
from .models import Stream, Channel, ChannelGroup
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer
from .tasks import match_epg_channels
import django_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.pagination import PageNumberPagination
class StreamPagination(PageNumberPagination):
page_size = 25 # Default page size
page_size_query_param = 'page_size' # Allow clients to specify page size
max_page_size = 1000 # Prevent excessive page sizes
class StreamFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='icontains')
group_name = django_filters.CharFilter(lookup_expr='icontains')
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
m3u_account_name = django_filters.CharFilter(field_name="m3u_account__name", lookup_expr="icontains")
m3u_account_is_active = django_filters.BooleanFilter(field_name="m3u_account__is_active")
class Meta:
model = Stream
fields = ['name', 'group_name', 'm3u_account', 'm3u_account_name', 'm3u_account_is_active']
# ─────────────────────────────────────────────────────────
# 1) Stream API (CRUD)
@ -19,6 +39,13 @@ class StreamViewSet(viewsets.ModelViewSet):
queryset = Stream.objects.all()
serializer_class = StreamSerializer
permission_classes = [IsAuthenticated]
pagination_class = StreamPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = StreamFilter
search_fields = ['name', 'group_name']
ordering_fields = ['name', 'group_name']
ordering = ['-name']
def get_queryset(self):
qs = super().get_queryset()

View file

@ -74,11 +74,10 @@ class ChannelSerializer(serializers.ModelSerializer):
required=False
)
streams = serializers.ListField(
child=serializers.IntegerField(),
write_only=True
streams = serializers.SerializerMethodField()
stream_ids = serializers.PrimaryKeyRelatedField(
queryset=Stream.objects.all(), many=True, write_only=True, required=False
)
stream_ids = serializers.SerializerMethodField()
class Meta:
model = Channel
@ -97,9 +96,17 @@ class ChannelSerializer(serializers.ModelSerializer):
'stream_profile_id',
]
def get_stream_ids(self, obj):
"""Retrieve ordered stream IDs for GET requests."""
return list(obj.streams.all().order_by('channelstream__order').values_list('id', flat=True))
def get_streams(self, obj):
"""Retrieve ordered stream objects for GET requests."""
ordered_streams = obj.streams.all().order_by('channelstream__order')
print(f'Retrieving streams in order')
for index, stream in enumerate(ordered_streams):
print(f'Stream {stream.id}, index {index}')
return StreamSerializer(ordered_streams, many=True).data
# def get_stream_ids(self, obj):
# """Retrieve ordered stream IDs for GET requests."""
# return list(obj.streams.all().order_by('channelstream__order').values_list('id', flat=True))
def create(self, validated_data):
stream_ids = validated_data.pop('streams', [])
@ -113,8 +120,8 @@ class ChannelSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
print("Validated Data:", validated_data)
stream_ids = validated_data.pop('streams', None)
print(f'stream ids: {stream_ids}')
streams = validated_data.pop('stream_ids', None)
print(f'stream ids: {streams}')
# Update the actual Channel fields
instance.channel_number = validated_data.get('channel_number', instance.channel_number)
@ -132,11 +139,12 @@ class ChannelSerializer(serializers.ModelSerializer):
instance.save()
# Handle the many-to-many 'streams'
if stream_ids is not None:
if streams is not None:
# Clear existing relationships
instance.channelstream_set.all().delete()
# Add new streams in order
for index, stream_id in enumerate(stream_ids):
ChannelStream.objects.create(channel=instance, stream_id=stream_id, order=index)
for index, stream in enumerate(streams):
print(f'Setting stream {stream.id} to index {index}')
ChannelStream.objects.create(channel=instance, stream_id=stream.id, order=index)
return instance

View file

@ -32,6 +32,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'django_filters',
]
@ -115,6 +116,7 @@ REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
}
SWAGGER_SETTINGS = {

View file

@ -266,6 +266,21 @@ export default class API {
return retval;
}
static async queryStreams(params) {
const response = await fetch(
`${host}/api/channels/streams/?${params.toString()}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
}
);
const retval = await response.json();
return retval;
}
static async addStream(values) {
const response = await fetch(`${host}/api/channels/streams/`, {
method: 'POST',

View file

@ -36,29 +36,13 @@ import utils from '../../utils';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import useStreamsStore from '../../store/streams';
import usePlaylistsStore from '../../store/playlists';
const ChannelStreams = ({ channel, isExpanded }) => {
const [channelStreams, setChannelStreams] = useState([]);
const channelStreamIds = useChannelsStore(
(state) => state.channels[channel.id]?.stream_ids
const channelStreams = useChannelsStore(
(state) => state.channels[channel.id]?.streams
);
const { playlists } = usePlaylistsStore();
const { streams } = useStreamsStore();
useEffect(
() =>
setChannelStreams(
streams
.filter((stream) => channelStreamIds.includes(stream.id))
.sort(
(a, b) =>
channelStreamIds.indexOf(a.id) - channelStreamIds.indexOf(b.id)
)
),
[streams, channelStreamIds]
);
const removeStream = async (stream) => {
let streamSet = new Set(channelStreams);
@ -66,7 +50,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
streamSet = Array.from(streamSet);
await API.updateChannel({
...channel,
streams: streamSet.map((stream) => stream.id),
stream_ids: streamSet.map((stream) => stream.id),
});
};
@ -113,10 +97,11 @@ const ChannelStreams = ({ channel, isExpanded }) => {
channelStreams.splice(draggingRow.index, 1)[0]
);
// setChannelStreams([...channelStreams]);
const { streams: oldStreams, ...channelUpdate } = channel;
API.updateChannel({
...channel,
streams: channelStreams.map((stream) => stream.id),
...channelUpdate,
stream_ids: channelStreams.map((stream) => stream.id),
});
}
},
@ -460,12 +445,6 @@ const ChannelsTable = ({}) => {
);
};
const onRowSelectionChange = (e, test) => {
console.log(e());
console.log(test);
setRowSelection(e);
};
useEffect(() => {
const selectedRows = table
.getSelectedRowModel()
@ -489,7 +468,7 @@ const ChannelsTable = ({}) => {
enableColumnActions: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: onRowSelectionChange,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || channelsLoading,

View file

@ -25,7 +25,6 @@ import {
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
import { TableHelper } from '../../helpers';
import useStreamsStore from '../../store/streams';
const Example = () => {
const [playlist, setPlaylist] = useState(null);
@ -34,7 +33,6 @@ const Example = () => {
const [activeFilterValue, setActiveFilterValue] = useState('all');
const playlists = usePlaylistsStore((state) => state.playlists);
const { fetchStreams } = useStreamsStore();
const columns = useMemo(
//column definitions...
@ -116,7 +114,6 @@ const Example = () => {
const deletePlaylist = async (id) => {
await API.deletePlaylist(id);
fetchStreams();
};
const closeModal = () => {

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useCallback, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
@ -16,7 +16,6 @@ import {
Autocomplete,
InputAdornment,
} from '@mui/material';
import useStreamsStore from '../../store/streams';
import API from '../../api';
import {
Delete as DeleteIcon,
@ -24,14 +23,17 @@ import {
Add as AddIcon,
MoreVert as MoreVertIcon,
PlaylistAdd as PlaylistAddIcon,
Clear as ClearIcon,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { useDebounce } from '../../utils';
const StreamsTable = ({}) => {
/**
* useState
*/
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
@ -41,26 +43,39 @@ const StreamsTable = ({}) => {
const [m3uOptions, setM3uOptions] = useState([]);
const [actionsOpenRow, setActionsOpenRow] = useState(null);
const { streams, isLoading: streamsLoading } = useStreamsStore();
const [data, setData] = useState([]); // Holds fetched data
const [rowCount, setRowCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const [selectedRows, setSelectedRows] = useState({});
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 25,
});
const [filters, setFilters] = useState({
name: '',
group_name: '',
m3u_account: '',
});
const debouncedFilters = useDebounce(filters, 500);
/**
* Stores
*/
const { playlists } = usePlaylistsStore();
const { channelsPageSelection } = useChannelsStore();
const channelSelectionStreams = useChannelsStore(
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
);
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
const handleFilterChange = (columnId, value) => {
setFilterValues((prev) => {
return {
...prev,
[columnId]: value ? value.toLowerCase() : '',
};
});
};
useEffect(() => {
setGroupOptions([...new Set(streams.map((stream) => stream.group_name))]);
setM3uOptions([...new Set(playlists.map((playlist) => playlist.name))]);
}, [streams, playlists]);
/**
* useMemos
*/
/**
* useMemo
*/
const columns = useMemo(
() => [
{
@ -72,10 +87,11 @@ const StreamsTable = ({}) => {
Header: ({ column }) => (
<TextField
variant="standard"
name="name"
label="Name"
value={filterValues[column.id]}
value={filters[column.id]}
onClick={(e) => e.stopPropagation()}
onChange={(e) => handleFilterChange(column.id, e.target.value)}
onChange={handleFilterChange}
size="small"
margin="none"
fullWidth
@ -86,27 +102,24 @@ const StreamsTable = ({}) => {
// width: '200px', // Optional: Adjust width
}
}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => handleFilterChange(column.id, '')} // Clear text on click
edge="end"
size="small"
sx={{ p: 0 }}
>
<ClearIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</InputAdornment>
),
},
}}
// slotProps={{
// input: {
// endAdornment: (
// <InputAdornment position="end">
// <IconButton
// onClick={() => handleFilterChange(column.id, '')} // Clear text on click
// edge="end"
// size="small"
// sx={{ p: 0 }}
// >
// <ClearIcon sx={{ fontSize: '1rem' }} />
// </IconButton>
// </InputAdornment>
// ),
// },
// }}
/>
),
meta: {
filterVariant: null,
},
},
{
header: 'Group',
@ -173,12 +186,49 @@ const StreamsTable = ({}) => {
),
},
],
[playlists, groupOptions, m3uOptions]
[playlists, groupOptions, m3uOptions, filters]
);
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
/**
* Functions
*/
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const fetchData = useCallback(async () => {
setIsLoading(true);
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(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
try {
const result = await API.queryStreams(params);
setData(result.results);
setRowCount(result.count);
} catch (error) {
console.error('Error fetching data:', error);
}
setIsLoading(false);
}, [pagination, sorting, debouncedFilters]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
@ -229,20 +279,6 @@ const StreamsTable = ({}) => {
setModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const addStreamsToChannel = async () => {
const channel = channelsPageSelection[0];
const selectedRows = table.getSelectedRowModel().rows;
@ -250,17 +286,23 @@ const StreamsTable = ({}) => {
...channel,
streams: [
...new Set(
channel.stream_ids.concat(selectedRows.map((row) => row.original.id))
channel.streams
.map((stream) => stream.id)
.concat(selectedRows.map((row) => row.original.id))
),
],
});
};
const addStreamToChannel = async (streamId) => {
const channel = channelsPageSelection[0];
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
streams: [...new Set(channel.stream_ids.concat([streamId]))],
stream_ids: [
...new Set(
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
),
],
});
};
@ -274,30 +316,27 @@ const StreamsTable = ({}) => {
setActionsOpenRow(null);
};
const filteredData = streams.filter((row) =>
columns.every(({ accessorKey }) =>
filterValues[accessorKey]
? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey])
: true
)
);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: filteredData,
enablePagination: false,
enableRowVirtualization: true,
data,
enablePagination: true,
manualPagination: true,
manualSorting: true,
enableBottomToolbar: true,
enableStickyHeader: true,
onPaginationChange: setPagination,
onSortingChange: setSorting,
rowCount: rowCount,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || streamsLoading,
isLoading: isLoading,
sorting,
pagination,
rowSelection,
},
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
enableRowActions: true,
positionActionsColumn: 'first',
renderRowActions: ({ row }) => (
@ -310,7 +349,10 @@ const StreamsTable = ({}) => {
sx={{ py: 0, px: 0.5 }}
disabled={
channelsPageSelection.length != 1 ||
channelsPageSelection[0]?.stream_ids.includes(row.original.id)
(channelSelectionStreams &&
channelSelectionStreams
.map((stream) => stream.id)
.includes(row.original.id))
}
>
<PlaylistAddIcon fontSize="small" />
@ -352,9 +394,14 @@ const StreamsTable = ({}) => {
</Menu>
</>
),
muiPaginationProps: {
size: 'small',
rowsPerPageOptions: [25, 50, 100, 250, 500],
labelRowsPerPage: 'Rows per page',
},
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)',
height: 'calc(100vh - 145px)',
overflowY: 'auto',
},
},
@ -400,7 +447,7 @@ const StreamsTable = ({}) => {
sx={{ marginLeft: 1 }}
disabled={selectedRowCount == 0}
>
Create Channels
CREATE CHANNELS
</Button>
<Button
variant="contained"
@ -411,13 +458,26 @@ const StreamsTable = ({}) => {
channelsPageSelection.length != 1 || selectedRowCount == 0
}
>
Add to Channel
ADD TO CHANNEL
</Button>
</Stack>
);
},
});
/**
* useEffects
*/
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
return (
<Box>
<MaterialReactTable table={table} />

View file

@ -1,7 +1,6 @@
import { create } from 'zustand';
import API from '../api';
import useChannelsStore from './channels';
import useStreamsStore from './streams';
import useUserAgentsStore from './userAgents';
import usePlaylistsStore from './playlists';
import useEPGsStore from './epgs';
@ -32,7 +31,6 @@ const useAuthStore = create((set, get) => ({
await Promise.all([
useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useStreamsStore.getState().fetchStreams(),
useUserAgentsStore.getState().fetchUserAgents(),
usePlaylistsStore.getState().fetchPlaylists(),
useEPGsStore.getState().fetchEPGs(),

View file

@ -1,3 +1,5 @@
import React, { useState, useEffect } from 'react';
export default {
Limiter: (n, list) => {
if (!list || !list.length) {
@ -34,3 +36,18 @@ export default {
});
},
};
// Custom debounce hook
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change
}, [value, delay]);
return debouncedValue;
}

View file

@ -25,3 +25,4 @@ uwsgi
channels
channels-redis
daphne
django-filter