diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91500d87..27a5608e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Performance
+- Reduced unnecessary re-renders in the channels table. Removed three Zustand store subscriptions from `ChannelsTable` that caused a full component re-render on every unrelated store update: the entire `channels` map, `selectedProfileChannels`, and `selectedProfileChannelIds` (these were superseded when `ChannelEnabledSwitch` was refactored to subscribe to the store directly). Narrowed the `channelIds` subscription to a stable boolean `hasChannels` so only the empty↔non-empty transition triggers a re-render instead of every channel add/remove. Added a custom `React.memo` comparator to `ChannelRowActions` that compares `row.original.id` rather than the full TanStack Table row object (which is recreated each render cycle), preventing every action cell from re-rendering on table updates. Memoized `getChannelURL`, `createRecording`, and `handleWatchStream` with `useCallback` for stable column cell references.
- Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138)
## [0.23.0] - 2026-04-17
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index c3a3e363..b531cff6 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -17,13 +17,11 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import useChannelsStore from '../../store/channels';
-import { notifications } from '@mantine/notifications';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import ChannelBatchForm from '../forms/ChannelBatch';
import RecordingForm from '../forms/Recording';
import { useDebounce, copyToClipboard } from '../../utils';
-import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import {
@@ -65,7 +63,6 @@ import {
Tooltip,
Skeleton,
} from '@mantine/core';
-import { getCoreRowModel, flexRender } from '@tanstack/react-table';
import './table.css';
import useChannelsTableStore from '../../store/channelsTable';
import ChannelTableStreams from './ChannelTableStreams';
@@ -243,7 +240,12 @@ const ChannelRowActions = React.memo(
);
- }
+ },
+ // Custom comparator: only re-render when the actual channel changes.
+ // The row object is a new TanStack Table reference on each render, but
+ // row.original.id is stable. Callbacks read fresh data at call time.
+ (prevProps, nextProps) =>
+ prevProps.row.original.id === nextProps.row.original.id
);
const ChannelsTable = ({ onReady }) => {
@@ -291,22 +293,16 @@ const ChannelsTable = ({ onReady }) => {
const sorting = useChannelsTableStore((s) => s.sorting);
const setSorting = useChannelsTableStore((s) => s.setSorting);
const totalCount = useChannelsTableStore((s) => s.totalCount);
- const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams);
const allRowIds = useChannelsTableStore((s) => s.allQueryIds);
const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds);
- const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
// store/channels
- const channels = useChannelsStore((s) => s.channels);
- const channelIds = useChannelsStore((s) => s.channelIds);
+ const hasChannels = useChannelsStore((s) => s.channelIds.length > 0);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
- const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
+ const [, setTablePrefs] = useLocalStorage('channel-table-prefs', {
pageSize: 50,
});
- const selectedProfileChannels = useChannelsStore(
- (s) => s.profiles[selectedProfileId]?.channels
- );
// store/settings
const env_mode = useSettingsStore((s) => s.environment.env_mode);
@@ -316,14 +312,6 @@ const ChannelsTable = ({ onReady }) => {
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
- /**
- * useMemo
- */
- const selectedProfileChannelIds = useMemo(
- () => new Set(selectedProfileChannels),
- [selectedProfileChannels]
- );
-
/**
* useState
*/
@@ -331,9 +319,6 @@ const ChannelsTable = ({ onReady }) => {
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [channelBatchModalOpen, setChannelBatchModalOpen] = useState(false);
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
- const [selectedProfile, setSelectedProfile] = useState(
- profiles[selectedProfileId]
- );
const [showDisabled, setShowDisabled] = useState(true);
const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] =
useState(false);
@@ -345,7 +330,7 @@ const ChannelsTable = ({ onReady }) => {
channel_group: '',
epg: '',
});
- const [isLoading, setIsLoading] = useState(true);
+ const [, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
@@ -490,7 +475,7 @@ const ChannelsTable = ({ onReady }) => {
setIsLoading(true);
try {
- const [results, ids] = await Promise.all([
+ const [, ids] = await Promise.all([
API.queryChannels(params),
API.getAllChannelIds(params),
]);
@@ -673,37 +658,38 @@ const ChannelsTable = ({ onReady }) => {
}
};
- const createRecording = (channel) => {
+ const createRecording = useCallback((channel) => {
console.log(`Recording channel ID: ${channel.id}`);
setChannel(channel);
setRecordingModalOpen(true);
- };
+ }, []);
- const getChannelURL = (channel) => {
- // Make sure we're using the channel UUID consistently
- if (!channel || !channel.uuid) {
- console.error('Invalid channel object or missing UUID:', channel);
- return '';
- }
+ const getChannelURL = useCallback(
+ (channel) => {
+ // Make sure we're using the channel UUID consistently
+ if (!channel || !channel.uuid) {
+ console.error('Invalid channel object or missing UUID:', channel);
+ return '';
+ }
- const uri = `/proxy/ts/stream/${channel.uuid}`;
- let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`;
- if (env_mode == 'dev') {
- channelUrl = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
- }
+ const uri = `/proxy/ts/stream/${channel.uuid}`;
+ let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`;
+ if (env_mode == 'dev') {
+ channelUrl = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
+ }
- return channelUrl;
- };
+ return channelUrl;
+ },
+ [env_mode]
+ );
- const handleWatchStream = (channel) => {
- // Add additional logging to help debug issues
- console.log(
- `Watching stream for channel: ${channel.name} (${channel.id}), UUID: ${channel.uuid}`
- );
- const url = getChannelURL(channel);
- console.log(`Stream URL: ${url}`);
- showVideo(url, 'live', { name: channel.name });
- };
+ const handleWatchStream = useCallback(
+ (channel) => {
+ const url = getChannelURL(channel);
+ showVideo(url, 'live', { name: channel.name });
+ },
+ [getChannelURL, showVideo]
+ );
const onRowSelectionChange = (newSelection) => {
setSelectedChannelIds(newSelection);
@@ -861,8 +847,6 @@ const ChannelsTable = ({ onReady }) => {
}, [fetchData]);
useEffect(() => {
- setSelectedProfile(profiles[selectedProfileId]);
-
const profileString =
selectedProfileId != '0' ? `/${profiles[selectedProfileId].name}` : '';
setHDHRUrl(`${hdhrUrlBase}${profileString}`);
@@ -1524,12 +1508,12 @@ const ChannelsTable = ({ onReady }) => {
{/* Table or ghost empty state inside Paper */}
- {channelsTableLength === 0 && channelIds.length === 0 && (
+ {channelsTableLength === 0 && !hasChannels && (
)}
- {(channelsTableLength > 0 || channelIds.length > 0) && (
+ {(channelsTableLength > 0 || hasChannels) && (
{
- const virtualized = false;
-
- if (virtualized) {
- return (
-
-
- {({ height }) => {
- const getItemSize = (index) => {
- const row = rows[index];
- const isExpanded = expandedRowIds.includes(row.original.id);
- console.log(isExpanded);
-
- // Default row height
- let rowHeight = 28;
-
- if (isExpanded && getExpandedRowHeight) {
- // If row is expanded, adjust the height to be larger (based on your logic)
- // You can get this height from your state, or calculate based on number of items in the expanded row
- rowHeight += getExpandedRowHeight(row); // This function would calculate the expanded row's height
- }
-
- return rowHeight;
- };
-
- return (
-
- {({ index, style }) => {
- const row = rows[index];
- return renderTableBodyRow(row, index, style);
- }}
-
- );
- }}
-
-
- );
- }
-
return (
{rows.map((row, index) => renderTableBodyRow(row, index))}