diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 8cbd70d1..4441d10e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -8,6 +8,7 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction +from django.db.models import Count from django.db.models import Q import os, json, requests, logging from urllib.parse import unquote @@ -148,7 +149,8 @@ class StreamViewSet(viewsets.ModelViewSet): unassigned = self.request.query_params.get("unassigned") if unassigned == "1": - qs = qs.filter(channels__isnull=True) + # Use annotation with Count for better performance on large datasets + qs = qs.annotate(channel_count=Count('channels')).filter(channel_count=0) channel_group = self.request.query_params.get("channel_group") if channel_group: diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 87d80953..72bbb39a 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -755,6 +755,7 @@ export const WebsocketProvider = ({ children }) => { // Refresh the channels table to show new channels try { await API.requeryChannels(); + await API.requeryStreams(); await useChannelsStore.getState().fetchChannels(); await fetchChannelProfiles(); console.log('Channels refreshed after bulk creation'); diff --git a/frontend/src/api.js b/frontend/src/api.js index ab22848c..f878c047 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -10,6 +10,7 @@ import useStreamProfilesStore from './store/streamProfiles'; import useSettingsStore from './store/settings'; import { notifications } from '@mantine/notifications'; import useChannelsTableStore from './store/channelsTable'; +import useStreamsTableStore from './store/streamsTable'; import useUsersStore from './store/users'; // If needed, you can set a base host or keep it empty if relative requests @@ -380,6 +381,7 @@ export default class API { }); useChannelsStore.getState().removeChannels([id]); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete channel', e); } @@ -394,6 +396,7 @@ export default class API { }); useChannelsStore.getState().removeChannels(channel_ids); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete channels', e); } @@ -447,6 +450,9 @@ export default class API { ); useChannelsStore.getState().updateChannel(response); + if (Object.prototype.hasOwnProperty.call(payload, 'streams')) { + await API.requeryStreams(); + } return response; } catch (e) { errorNotification('Failed to update channel', e); @@ -630,6 +636,7 @@ export default class API { useChannelsStore.getState().addChannel(response); } + await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to create channel', e); @@ -705,6 +712,46 @@ export default class API { } } + static async queryStreamsTable(params) { + try { + API.lastStreamQueryParams = params; + + const response = await request( + `${host}/api/channels/streams/?${params.toString()}` + ); + + useStreamsTableStore.getState().queryStreams(response, params); + + return response; + } catch (e) { + errorNotification('Failed to fetch streams', e); + } + } + + static async requeryStreams() { + if (!API.lastStreamQueryParams) { + return null; + } + + try { + const [response, ids] = await Promise.all([ + request( + `${host}/api/channels/streams/?${API.lastStreamQueryParams.toString()}` + ), + API.getAllStreamIds(API.lastStreamQueryParams), + ]); + + useStreamsTableStore + .getState() + .queryStreams(response, API.lastStreamQueryParams); + useStreamsTableStore.getState().setAllQueryIds(ids); + + return response; + } catch (e) { + errorNotification('Failed to fetch streams', e); + } + } + static async getAllStreamIds(params) { try { const response = await request( @@ -750,6 +797,7 @@ export default class API { useStreamsStore.getState().addStream(response); } + await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to add stream', e); @@ -768,6 +816,7 @@ export default class API { useStreamsStore.getState().updateStream(response); } + await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to update stream', e); @@ -781,6 +830,7 @@ export default class API { }); useStreamsStore.getState().removeStreams([id]); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete stream', e); } @@ -794,6 +844,7 @@ export default class API { }); useStreamsStore.getState().removeStreams(ids); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete streams', e); } diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 4fb62009..0443b3ca 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -167,6 +167,7 @@ const ChannelStreams = ({ channel, isExpanded }) => { streams: newStreamList.map((s) => s.id), }); await API.requeryChannels(); + await API.requeryStreams(); }; // Create M3U account map for quick lookup diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 02dae3d5..dcb3284e 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -20,6 +20,9 @@ import { ArrowUpNarrowWide, ArrowDownWideNarrow, Search, + Filter, + Square, + SquareCheck, } from 'lucide-react'; import { TextInput, @@ -43,12 +46,11 @@ import { MultiSelect, useMantineTheme, UnstyledButton, - LoadingOverlay, Skeleton, Modal, NumberInput, Radio, - Checkbox, + LoadingOverlay, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; import useSettingsStore from '../../store/settings'; @@ -59,6 +61,7 @@ import { CustomTable, useTable } from './CustomTable'; import useLocalStorage from '../../hooks/useLocalStorage'; import ConfirmationDialog from '../ConfirmationDialog'; import CreateChannelModal from '../modals/CreateChannelModal'; +import useStreamsTableStore from '../../store/streamsTable'; const StreamRowActions = ({ theme, @@ -178,23 +181,21 @@ const StreamRowActions = ({ const StreamsTable = ({ onReady }) => { const theme = useMantineTheme(); const hasSignaledReady = useRef(false); + const hasFetchedOnce = useRef(false); + const hasFetchedPlaylists = useRef(false); + const hasFetchedChannelGroups = useRef(false); /** * useState */ - const [allRowIds, setAllRowIds] = useState([]); const [stream, setStream] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [groupOptions, setGroupOptions] = useState([]); const [m3uOptions, setM3uOptions] = useState([]); const [initialDataCount, setInitialDataCount] = useState(null); - const [data, setData] = useState([]); // Holds fetched data - const [pageCount, setPageCount] = useState(0); const [paginationString, setPaginationString] = useState(''); const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([{ id: 'name', desc: false }]); - const [selectedStreamIds, setSelectedStreamIds] = useState([]); // Channel creation modal state (bulk) const [channelNumberingModalOpen, setChannelNumberingModalOpen] = @@ -226,14 +227,11 @@ const StreamsTable = ({ onReady }) => { 'streams-page-size', 50 ); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: storedPageSize, - }); const [filters, setFilters] = useState({ name: '', channel_group: '', m3u_account: '', + unassigned: '', }); const [columnSizing, setColumnSizing] = useLocalStorage( 'streams-table-column-sizing', @@ -241,21 +239,20 @@ const StreamsTable = ({ onReady }) => { ); const debouncedFilters = useDebounce(filters, 500, () => { // Reset to first page whenever filters change to avoid "Invalid page" errors - setPagination((prev) => ({ - ...prev, + setPagination({ + ...pagination, pageIndex: 0, - })); + }); }); - // Add state to track if stream groups are loaded - const [groupsLoaded, setGroupsLoaded] = useState(false); - const navigate = useNavigate(); /** * Stores */ const playlists = usePlaylistsStore((s) => s.playlists); + const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists); + const playlistsLoading = usePlaylistsStore((s) => s.isLoading); // Get direct access to channel groups without depending on other data const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); @@ -271,6 +268,20 @@ const StreamsTable = ({ onReady }) => { const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); + const data = useStreamsTableStore((s) => s.streams); + const pageCount = useStreamsTableStore((s) => s.pageCount); + const totalCount = useStreamsTableStore((s) => s.totalCount); + const allRowIds = useStreamsTableStore((s) => s.allQueryIds); + const setAllRowIds = useStreamsTableStore((s) => s.setAllQueryIds); + const pagination = useStreamsTableStore((s) => s.pagination); + const setPagination = useStreamsTableStore((s) => s.setPagination); + const sorting = useStreamsTableStore((s) => s.sorting); + const setSorting = useStreamsTableStore((s) => s.setSorting); + const selectedStreamIds = useStreamsTableStore((s) => s.selectedStreamIds); + const setSelectedStreamIds = useStreamsTableStore( + (s) => s.setSelectedStreamIds + ); + // Warnings store for "remember choice" functionality const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -383,95 +394,80 @@ const StreamsTable = ({ onReady }) => { })); }; - const fetchData = useCallback(async () => { - setIsLoading(true); + const toggleUnassignedOnly = () => { + setFilters((prev) => ({ + ...prev, + unassigned: prev.unassigned === '1' ? '' : '1', + })); + }; + + const fetchData = useCallback( + async ({ showLoader = true } = {}) => { + if (showLoader) { + 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 columnId = sorting[0].id; + // Map frontend column IDs to backend field names + const fieldMapping = { + name: 'name', + group: 'channel_group__name', + m3u: 'm3u_account__name', + }; + const sortField = fieldMapping[columnId] || columnId; + 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); + }); - // Ensure we have channel groups first (if not already loaded) - if (!groupsLoaded && Object.keys(channelGroups).length === 0) { try { - await fetchChannelGroups(); - setGroupsLoaded(true); + const [result, ids, filterOptions] = await Promise.all([ + API.queryStreamsTable(params), + API.getAllStreamIds(params), + API.getStreamFilterOptions(params), + ]); + + setAllRowIds(ids); + + // Set filtered options based on current filters + setGroupOptions(filterOptions.groups); + setM3uOptions( + filterOptions.m3u_accounts.map((m3u) => ({ + label: m3u.name, + value: `${m3u.id}`, + })) + ); + + if (initialDataCount === null) { + setInitialDataCount(result.count); + } + + // Signal that initial data load is complete + if (!hasSignaledReady.current && onReady) { + hasSignaledReady.current = true; + onReady(); + } } catch (error) { - console.error('Error fetching channel groups:', error); - } - } - - const params = new URLSearchParams(); - params.append('page', pagination.pageIndex + 1); - params.append('page_size', pagination.pageSize); - - // Apply sorting - if (sorting.length > 0) { - const columnId = sorting[0].id; - // Map frontend column IDs to backend field names - const fieldMapping = { - name: 'name', - group: 'channel_group__name', - m3u: 'm3u_account__name', - }; - const sortField = fieldMapping[columnId] || columnId; - 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, ids, filterOptions] = await Promise.all([ - API.queryStreams(params), - API.getAllStreamIds(params), - API.getStreamFilterOptions(params), - ]); - - setAllRowIds(ids); - setData(result.results); - setPageCount(Math.ceil(result.count / pagination.pageSize)); - - // Set filtered options based on current filters - setGroupOptions(filterOptions.groups); - setM3uOptions( - filterOptions.m3u_accounts.map((m3u) => ({ - label: m3u.name, - value: `${m3u.id}`, - })) - ); - - // Calculate the starting and ending item indexes - const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 - const endItem = Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - result.count - ); - - if (initialDataCount === null) { - setInitialDataCount(result.count); + console.error('Error fetching data:', error); } - // Generate the string - setPaginationString(`${startItem} to ${endItem} of ${result.count}`); - - // Signal that initial data load is complete - if (!hasSignaledReady.current && onReady) { - hasSignaledReady.current = true; - onReady(); + hasFetchedOnce.current = true; + if (showLoader) { + setIsLoading(false); } - } catch (error) { - console.error('Error fetching data:', error); - } - - setIsLoading(false); - }, [ - pagination, - sorting, - debouncedFilters, - groupsLoaded, - channelGroups, - fetchChannelGroups, - onReady, - ]); + }, + [pagination, sorting, debouncedFilters, onReady] + ); // Bulk creation: create channels from selected streams asynchronously const createChannelsFromStreams = async () => { @@ -544,6 +540,8 @@ const StreamsTable = ({ onReady }) => { // Clear selection since the task has started setSelectedStreamIds([]); + + // Note: This is a background task, so the update happens on WebSocket completion } catch (error) { console.error('Error starting bulk channel creation:', error); // Error notifications will be handled by WebSocket @@ -601,14 +599,15 @@ const StreamsTable = ({ onReady }) => { const executeDeleteStream = async (id) => { setDeleting(true); + setIsLoading(true); try { await API.deleteStream(id); - fetchData(); // Clear the selection for the deleted stream setSelectedStreamIds([]); table.setSelectedTableIds([]); } finally { setDeleting(false); + setIsLoading(false); setConfirmDeleteOpen(false); } }; @@ -626,11 +625,10 @@ const StreamsTable = ({ onReady }) => { }; const executeDeleteStreams = async () => { - setIsLoading(true); setDeleting(true); + setIsLoading(true); try { await API.deleteStreams(selectedStreamIds); - fetchData(); setSelectedStreamIds([]); table.setSelectedTableIds([]); } finally { @@ -640,10 +638,15 @@ const StreamsTable = ({ onReady }) => { } }; - const closeStreamForm = () => { + const closeStreamForm = async () => { setStream(null); setModalOpen(false); - fetchData(); + setIsLoading(true); + try { + await API.requeryStreams(); + } finally { + setIsLoading(false); + } }; // Single channel creation functions @@ -711,8 +714,8 @@ const StreamsTable = ({ onReady }) => { channel_profile_ids: channelProfileIds, }); await API.requeryChannels(); - const fetchLogos = useChannelsStore.getState().fetchLogos; - fetchLogos(); + // const fetchLogos = useChannelsStore.getState().fetchLogos; + // fetchLogos(); }; // Handle confirming the single channel numbering modal @@ -858,24 +861,34 @@ const StreamsTable = ({ onReady }) => { ? filters.channel_group.split(',').filter(Boolean) : []; return ( - - { - const index = selectedGroups.indexOf(value); - if (index === 0) { - return ( - + { + const index = selectedGroups.indexOf(value); + if (index === 0) { + return ( + + + {value} + + {selectedGroups.length > 1 && ( { borderRadius: '4px', }} > - {value} + +{selectedGroups.length - 1} - {selectedGroups.length > 1 && ( - - +{selectedGroups.length - 1} - - )} - - ); - } - return null; - }} - style={{ flex: 1, minWidth: 0 }} - rightSectionPointerEvents="auto" - rightSection={React.createElement(sortingIcon, { - onClick: (e) => { - e.stopPropagation(); - onSortingChange('group'); - }, - size: 14, - style: { cursor: 'pointer' }, - })} - /> - + )} + + ); + } + return null; + }} + style={{ width: '100%' }} + /> ); } @@ -1029,6 +1021,10 @@ const StreamsTable = ({ onReady }) => { manualSorting: true, manualFiltering: true, enableRowSelection: true, + state: { + pagination, + sorting, + }, headerCellRenderFns: { name: renderHeaderCell, group: renderHeaderCell, @@ -1055,6 +1051,56 @@ const StreamsTable = ({ onReady }) => { fetchData(); }, [fetchData]); + useEffect(() => { + if ( + Object.keys(channelGroups).length > 0 || + hasFetchedChannelGroups.current + ) { + return; + } + + const loadGroups = async () => { + hasFetchedChannelGroups.current = true; + try { + await fetchChannelGroups(); + } catch (error) { + console.error('Error fetching channel groups:', error); + } + }; + + loadGroups(); + }, [channelGroups, fetchChannelGroups]); + + useEffect(() => { + if ( + playlists.length > 0 || + hasFetchedPlaylists.current || + playlistsLoading + ) { + return; + } + + const loadPlaylists = async () => { + hasFetchedPlaylists.current = true; + try { + await fetchPlaylists(); + } catch (error) { + console.error('Error fetching playlists:', error); + } + }; + + loadPlaylists(); + }, [playlists, fetchPlaylists, playlistsLoading]); + + useEffect(() => { + const startItem = pagination.pageIndex * pagination.pageSize + 1; + const endItem = Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + totalCount + ); + setPaginationString(`${startItem} to ${endItem} of ${totalCount}`); + }, [pagination.pageIndex, pagination.pageSize, totalCount]); + // Clear dependent filters if selected values are no longer in filtered options useEffect(() => { // Clear group filter if the selected groups are no longer available @@ -1172,6 +1218,29 @@ const StreamsTable = ({ onReady }) => { + + + + + + + + ) : ( + + ) + } + > + Only Unassociated + + + +