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 }) => {
+
+
}
variant="light"
diff --git a/frontend/src/store/streamsTable.jsx b/frontend/src/store/streamsTable.jsx
new file mode 100644
index 00000000..f353acf7
--- /dev/null
+++ b/frontend/src/store/streamsTable.jsx
@@ -0,0 +1,49 @@
+import { create } from 'zustand';
+
+const useStreamsTableStore = create((set) => ({
+ streams: [],
+ pageCount: 0,
+ totalCount: 0,
+ sorting: [{ id: 'name', desc: false }],
+ pagination: {
+ pageIndex: 0,
+ pageSize:
+ JSON.parse(localStorage.getItem('streams-page-size')) || 50,
+ },
+ selectedStreamIds: [],
+ allQueryIds: [],
+
+ queryStreams: ({ results, count }, params) => {
+ set(() => ({
+ streams: results,
+ totalCount: count,
+ pageCount: Math.ceil(count / params.get('page_size')),
+ }));
+ },
+
+ setAllQueryIds: (allQueryIds) => {
+ set(() => ({
+ allQueryIds,
+ }));
+ },
+
+ setSelectedStreamIds: (selectedStreamIds) => {
+ set(() => ({
+ selectedStreamIds,
+ }));
+ },
+
+ setPagination: (pagination) => {
+ set(() => ({
+ pagination,
+ }));
+ },
+
+ setSorting: (sorting) => {
+ set(() => ({
+ sorting,
+ }));
+ },
+}));
+
+export default useStreamsTableStore;