From 9ac73cf9902ff274b34e06387ae49c256a2fc674 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 8 Mar 2025 09:17:20 -0500 Subject: [PATCH] initial push for pagination with streams tables - still need to fix the channels form tables --- apps/channels/api_views.py | 27 +++ apps/channels/serializers.py | 32 ++- dispatcharr/settings.py | 2 + frontend/src/api.js | 15 ++ .../src/components/tables/ChannelsTable.js | 37 +-- frontend/src/components/tables/M3UsTable.js | 3 - .../src/components/tables/StreamsTable.js | 216 +++++++++++------- frontend/src/store/auth.js | 2 - frontend/src/utils.js | 17 ++ requirements.txt | 1 + 10 files changed, 228 insertions(+), 124 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index d1858165..de2312bb 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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() diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index c4af1ebb..d58c7bcb 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -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 diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index aad7b7c6..975fbe6e 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -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 = { diff --git a/frontend/src/api.js b/frontend/src/api.js index 0d813bcf..67708977 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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', diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js index 05d3bae0..e65c7514 100644 --- a/frontend/src/components/tables/ChannelsTable.js +++ b/frontend/src/components/tables/ChannelsTable.js @@ -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, diff --git a/frontend/src/components/tables/M3UsTable.js b/frontend/src/components/tables/M3UsTable.js index 82e16ad3..eb1ce5e6 100644 --- a/frontend/src/components/tables/M3UsTable.js +++ b/frontend/src/components/tables/M3UsTable.js @@ -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 = () => { diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js index 062b606a..cbfbe942 100644 --- a/frontend/src/components/tables/StreamsTable.js +++ b/frontend/src/components/tables/StreamsTable.js @@ -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 }) => ( 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: ( - - handleFilterChange(column.id, '')} // Clear text on click - edge="end" - size="small" - sx={{ p: 0 }} - > - - - - ), - }, - }} + // slotProps={{ + // input: { + // endAdornment: ( + // + // handleFilterChange(column.id, '')} // Clear text on click + // edge="end" + // size="small" + // sx={{ p: 0 }} + // > + // + // + // + // ), + // }, + // }} /> ), - 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)) } > @@ -352,9 +394,14 @@ const StreamsTable = ({}) => { ), + 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 ); }, }); + /** + * useEffects + */ + useEffect(() => { + fetchData(); + }, [fetchData]); + + useEffect(() => { + if (typeof window !== 'undefined') { + setIsLoading(false); + } + }, []); + return ( diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js index 8904e489..d72f9f0f 100644 --- a/frontend/src/store/auth.js +++ b/frontend/src/store/auth.js @@ -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(), diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 668dec43..b765c405 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -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; +} diff --git a/requirements.txt b/requirements.txt index 7a00b5e3..0c1de6cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,4 @@ uwsgi channels channels-redis daphne +django-filter