Merge branch 'Dispatcharr:main' into Feature/771-Auto-Matching-Improvments

This commit is contained in:
Matt Grutza 2026-01-29 18:22:09 -06:00 committed by GitHub
commit 52b0c7ca26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 701 additions and 372 deletions

View file

@ -7,15 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.18.1] - 2026-01-27
### Fixed
- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure
## [0.18.0] - 2026-01-27
### Security
- Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities:
- **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx))
- **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7))
- Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router)
- Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details.
### Added
- Series Rules API Swagger Documentation: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints (`GET /series-rules/`, `POST /series-rules/`, `DELETE /series-rules/{tvg_id}/`, `POST /series-rules/evaluate/`, `POST /series-rules/bulk-remove/`), including detailed descriptions, request/response schemas, and error handling information for improved API discoverability
- Editable Channel Table Mode:
- Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal.
- EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment.
@ -49,6 +59,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering:
- Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely
- Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()`
- Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls
- Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses
- Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches
- Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development
- Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store
- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence.
- Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology.
- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage.

View file

@ -2406,9 +2406,72 @@ class SeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="List all series rules",
operation_description="Retrieve all configured DVR series recording rules.",
responses={
200: openapi.Response(
description="List of series rules",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'rules': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'),
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
},
),
description='List of series recording rules'
),
},
),
),
},
)
def get(self, request):
return Response({"rules": CoreSettings.get_dvr_series_rules()})
@swagger_auto_schema(
operation_summary="Create or update a series rule",
operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['tvg_id'],
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'),
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
},
),
responses={
200: openapi.Response(
description="Series rule created/updated successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'rules': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
'mode': openapi.Schema(type=openapi.TYPE_STRING),
'title': openapi.Schema(type=openapi.TYPE_STRING),
},
),
description='Updated list of all rules'
),
},
),
),
400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"),
},
)
def post(self, request):
data = request.data or {}
tvg_id = str(data.get("tvg_id") or "").strip()
@ -2441,6 +2504,36 @@ class DeleteSeriesRuleAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="Delete a series rule",
operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
manual_parameters=[
openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'),
],
responses={
200: openapi.Response(
description="Series rule deleted successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'rules': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
'mode': openapi.Schema(type=openapi.TYPE_STRING),
'title': openapi.Schema(type=openapi.TYPE_STRING),
},
),
description='Updated list of all rules'
),
},
),
),
},
)
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
@ -2455,6 +2548,27 @@ class EvaluateSeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="Evaluate series rules",
operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'),
},
),
responses={
200: openapi.Response(
description="Evaluation completed successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
},
),
),
},
)
def post(self, request):
tvg_id = request.data.get("tvg_id")
# Run synchronously so UI sees results immediately
@ -2476,6 +2590,32 @@ class BulkRemoveSeriesRecordingsAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="Bulk remove scheduled recordings for a series",
operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['tvg_id'],
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'),
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'),
'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'),
},
),
responses={
200: openapi.Response(
description="Recordings removed successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'),
},
),
),
400: openapi.Response(description="Bad request (missing tvg_id)"),
},
)
def post(self, request):
from django.utils import timezone
tvg_id = str(request.data.get("tvg_id") or "").strip()

View file

@ -3720,9 +3720,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.clamp": {

View file

@ -1,5 +1,4 @@
// frontend/src/App.js
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
BrowserRouter as Router,
Route,
@ -40,18 +39,25 @@ const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isInitialized = useAuthStore((s) => s.isInitialized);
const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated);
const logout = useAuthStore((s) => s.logout);
const initData = useAuthStore((s) => s.initData);
const initializeAuth = useAuthStore((s) => s.initializeAuth);
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
const authCheckStarted = useRef(false);
const superuserCheckStarted = useRef(false);
const toggleDrawer = () => {
setOpen(!open);
};
// Check if a superuser exists on first load.
useEffect(() => {
if (superuserCheckStarted.current) return;
superuserCheckStarted.current = true;
async function checkSuperuser() {
try {
const response = await API.fetchSuperUser();
@ -69,10 +75,13 @@ const App = () => {
}
}
checkSuperuser();
}, []);
}, [setSuperuserExists]);
// Authentication check
useEffect(() => {
if (authCheckStarted.current) return;
authCheckStarted.current = true;
const checkAuth = async () => {
try {
const loggedIn = await initializeAuth();
@ -105,14 +114,15 @@ const App = () => {
height: 0,
}}
navbar={{
width: isAuthenticated
? open
? drawerWidth
: miniDrawerWidth
: 0,
width:
isAuthenticated && isInitialized
? open
? drawerWidth
: miniDrawerWidth
: 0,
}}
>
{isAuthenticated && (
{isAuthenticated && isInitialized && (
<Sidebar
drawerWidth={drawerWidth}
miniDrawerWidth={miniDrawerWidth}
@ -134,7 +144,7 @@ const App = () => {
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
<Routes>
{isAuthenticated ? (
{isAuthenticated && isInitialized ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/sources" element={<ContentSources />} />
@ -154,7 +164,11 @@ const App = () => {
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
to={
isAuthenticated && isInitialized
? defaultRoute
: '/login'
}
replace
/>
}

View file

@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
@ -33,8 +33,7 @@ import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
import useSettingsStore from '../store/settings';
import useAuthStore from '../store/auth'; // Add this import
import API from '../api';
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
@ -75,16 +74,13 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const channels = useChannelsStore((s) => s.channels);
const environment = useSettingsStore((s) => s.environment);
const appVersion = useSettingsStore((s) => s.version);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authUser = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const publicIPRef = useRef(null);
const [appVersion, setAppVersion] = useState({
version: '',
timestamp: null,
});
const [userFormOpen, setUserFormOpen] = useState(false);
const closeUserForm = () => setUserFormOpen(false);
@ -144,36 +140,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
},
];
// Fetch environment settings including version on component mount
useEffect(() => {
if (!isAuthenticated) {
return;
}
const fetchEnvironment = async () => {
API.getEnvironmentSettings();
};
fetchEnvironment();
}, [isAuthenticated]);
// Fetch version information on component mount (regardless of authentication)
useEffect(() => {
const fetchVersion = async () => {
try {
const versionData = await API.getVersion();
setAppVersion({
version: versionData.version || '',
timestamp: versionData.timestamp || null,
});
} catch (error) {
console.error('Failed to fetch version information:', error);
// Keep using default values from useState initialization
}
};
fetchVersion();
}, []);
// Environment settings and version are loaded by the settings store during initData()
// No need to fetch them again here - just use the store values
const copyPublicIP = async () => {
const success = await copyToClipboard(environment.public_ip);

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import API from '../../api';
import useSettingsStore from '../../store/settings';
import {
Paper,
Title,
@ -25,13 +25,14 @@ const LoginForm = () => {
const logout = useAuthStore((s) => s.logout);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const initData = useAuthStore((s) => s.initData);
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
const storedVersion = useSettingsStore((s) => s.version);
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
const [rememberMe, setRememberMe] = useState(false);
const [savePassword, setSavePassword] = useState(false);
const [forgotPasswordOpened, setForgotPasswordOpened] = useState(false);
const [version, setVersion] = useState(null);
const [isLoading, setIsLoading] = useState(false);
// Simple base64 encoding/decoding for localStorage
@ -55,11 +56,9 @@ const LoginForm = () => {
};
useEffect(() => {
// Fetch version info
API.getVersion().then((data) => {
setVersion(data?.version);
});
}, []);
// Fetch version info using the settings store (will skip if already loaded)
fetchVersion();
}, [fetchVersion]);
useEffect(() => {
// Load saved username if it exists
@ -234,8 +233,8 @@ const LoginForm = () => {
lineHeight: '1.2',
}}
>
Password will be stored locally without encryption. Only
use on trusted devices.
Password will be stored locally without encryption. Only use
on trusted devices.
</Text>
)}
</div>
@ -252,7 +251,7 @@ const LoginForm = () => {
</Stack>
</form>
{version && (
{storedVersion.version && (
<Text
size="xs"
color="dimmed"
@ -262,7 +261,7 @@ const LoginForm = () => {
right: 30,
}}
>
v{version}
v{storedVersion.version}
</Text>
)}
</Paper>

View file

@ -13,6 +13,7 @@ import {
} from '@mantine/core';
import API from '../../api';
import useAuthStore from '../../store/auth';
import useSettingsStore from '../../store/settings';
import logo from '../../assets/logo.png';
function SuperuserForm() {
@ -22,15 +23,14 @@ function SuperuserForm() {
email: '',
});
const [error, setError] = useState('');
const [version, setVersion] = useState(null);
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
const storedVersion = useSettingsStore((s) => s.version);
useEffect(() => {
// Fetch version info
API.getVersion().then((data) => {
setVersion(data?.version);
});
}, []);
// Fetch version info using the settings store (will skip if already loaded)
fetchVersion();
}, [fetchVersion]);
const handleChange = (e) => {
setFormData((prev) => ({
@ -120,7 +120,7 @@ function SuperuserForm() {
</Stack>
</form>
{version && (
{storedVersion.version && (
<Text
size="xs"
color="dimmed"
@ -130,7 +130,7 @@ function SuperuserForm() {
right: 30,
}}
>
v{version}
v{storedVersion.version}
</Text>
)}
</Paper>

View file

@ -253,7 +253,7 @@ const ChannelsTable = ({ onReady }) => {
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
// Get channel logos for logo selection
const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection();
const { ensureLogosLoaded } = useChannelLogoSelection();
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
@ -344,6 +344,9 @@ const ChannelsTable = ({ onReady }) => {
const [deleting, setDeleting] = useState(false);
const hasFetchedData = useRef(false);
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
// Drag-and-drop sensors
const sensors = useSensors(
@ -423,8 +426,7 @@ const ChannelsTable = ({ onReady }) => {
* Functions
*/
const fetchData = useCallback(async () => {
setIsLoading(true);
// Build params first to check for duplicates
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
@ -447,7 +449,7 @@ const ChannelsTable = ({ onReady }) => {
}
// Apply debounced filters
Object.entries(filters).forEach(([key, value]) => {
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) {
if (Array.isArray(value)) {
// Convert null values to "null" string for URL parameter
@ -461,12 +463,36 @@ const ChannelsTable = ({ onReady }) => {
}
});
const paramsString = params.toString();
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
if (
fetchInProgressRef.current &&
lastFetchParamsRef.current === paramsString
) {
return;
}
// Increment fetch version to track this specific fetch request
const currentFetchVersion = ++fetchVersionRef.current;
lastFetchParamsRef.current = paramsString;
fetchInProgressRef.current = true;
setIsLoading(true);
try {
const [results, ids] = await Promise.all([
await API.queryChannels(params),
await API.getAllChannelIds(params),
API.queryChannels(params),
API.getAllChannelIds(params),
]);
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
setIsLoading(false);
hasFetchedData.current = true;
@ -483,6 +509,12 @@ const ChannelsTable = ({ onReady }) => {
onReady();
}
} catch (error) {
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
setIsLoading(false);
// API layer handles "Invalid page" errors by resetting and retrying
// Just re-throw to show notification for actual errors
@ -492,11 +524,9 @@ const ChannelsTable = ({ onReady }) => {
pagination,
sorting,
debouncedFilters,
onReady,
showDisabled,
selectedProfileId,
showOnlyStreamlessChannels,
tvgsLoaded,
]);
const stopPropagation = useCallback((e) => {
@ -947,19 +977,11 @@ const ChannelsTable = ({ onReady }) => {
enableResizing: false,
header: '',
cell: (props) => (
<Box
onClick={() => {
// Ensure logos are loaded when user tries to edit
ensureLogosLoaded();
}}
style={{ width: '100%', height: '100%' }}
>
<EditableLogoCell
{...props}
channelLogos={channelLogos}
LazyLogo={LazyLogo}
/>
</Box>
<EditableLogoCell
{...props}
LazyLogo={LazyLogo}
ensureLogosLoaded={ensureLogosLoaded}
/>
),
},
{
@ -987,8 +1009,9 @@ const ChannelsTable = ({ onReady }) => {
// the actual sizes through its own state after initialization.
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
// from the store, so we don't need to recreate columns when logos load.
// Note: tvgsLoaded is intentionally excluded - EditableEPGCell handles loading state internally
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded]
[selectedProfileId, channelGroups, theme, tvgsById, epgs]
);
const renderHeaderCell = (header) => {

View file

@ -4,6 +4,7 @@ import React, {
useEffect,
useRef,
useMemo,
memo,
} from 'react';
import {
Box,
@ -16,33 +17,88 @@ import {
} from '@mantine/core';
import API from '../../../api';
import useChannelsTableStore from '../../../store/channelsTable';
import useLogosStore from '../../../store/logos';
// Lightweight wrapper that only renders full editable cell when unlocked
// This prevents 250+ heavy component instances when table is locked
const EditableCellWrapper = memo(
({ children, getValue, isUnlocked, renderLocked }) => {
if (!isUnlocked) {
// Render lightweight locked view
return renderLocked ? (
renderLocked(getValue())
) : (
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0 4px',
}}
>
{getValue() ?? ''}
</Box>
);
}
// Only render heavy component when unlocked
return children;
}
);
// Editable text cell
export const EditableTextCell = ({ row, column, getValue }) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const [isFocused, setIsFocused] = useState(false);
// When locked or not focused, show simple display
if (!isUnlocked || !isFocused) {
return (
<Box
onClick={() => isUnlocked && setIsFocused(true)}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: isUnlocked ? 'text' : 'default',
padding: '0 4px',
}}
>
{getValue() ?? ''}
</Box>
);
}
// Only mount heavy component when actually editing
return (
<EditableTextCellInner
row={row}
column={column}
getValue={getValue}
onBlur={() => setIsFocused(false)}
/>
);
};
// Inner component with all the editing logic - only rendered when focused
const EditableTextCellInner = ({ row, column, getValue, onBlur }) => {
const initialValue = getValue() || '';
const [value, setValue] = useState(initialValue);
const [isFocused, setIsFocused] = useState(false);
const previousValue = useRef(initialValue);
const isMounted = useRef(false);
const debounceTimer = useRef(null);
useEffect(() => {
const currentValue = getValue() || '';
if (!isFocused && currentValue !== previousValue.current) {
if (currentValue !== previousValue.current) {
setValue(currentValue);
previousValue.current = currentValue;
}
}, [getValue, isFocused]);
}, [getValue]);
const saveValue = useCallback(
async (newValue) => {
// Don't save if not mounted, not unlocked, or value hasn't changed
if (
!isMounted.current ||
!isUnlocked ||
newValue === previousValue.current
) {
// Don't save if not mounted or value hasn't changed
if (!isMounted.current || newValue === previousValue.current) {
return;
}
@ -62,7 +118,7 @@ export const EditableTextCell = ({ row, column, getValue }) => {
setValue(previousValue.current || '');
}
},
[row.original.id, column.id, isUnlocked]
[row.original.id, column.id]
);
useEffect(() => {
@ -77,7 +133,6 @@ export const EditableTextCell = ({ row, column, getValue }) => {
}, []);
const handleChange = (e) => {
if (!isUnlocked) return;
const newValue = e.currentTarget.value;
setValue(newValue);
@ -93,40 +148,10 @@ export const EditableTextCell = ({ row, column, getValue }) => {
};
const handleBlur = () => {
setIsFocused(false);
if (isUnlocked) {
saveValue(value);
}
saveValue(value);
onBlur();
};
const handleClick = () => {
if (isUnlocked) {
setIsFocused(true);
}
};
if (!isUnlocked || !isFocused) {
return (
<Box
onClick={handleClick}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: isUnlocked ? 'text' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{value}
</Box>
);
}
return (
<TextInput
value={value}
@ -154,28 +179,62 @@ export const EditableTextCell = ({ row, column, getValue }) => {
// Editable number cell
export const EditableNumberCell = ({ row, column, getValue }) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const [isFocused, setIsFocused] = useState(false);
const value = getValue();
const formattedValue =
value !== null && value !== undefined
? value === Math.floor(value)
? Math.floor(value)
: value
: '';
// When locked or not focused, show simple display
if (!isUnlocked || !isFocused) {
return (
<Box
onClick={() => isUnlocked && setIsFocused(true)}
style={{
textAlign: 'right',
width: '100%',
cursor: isUnlocked ? 'text' : 'default',
padding: '0 4px',
}}
>
{formattedValue}
</Box>
);
}
return (
<EditableNumberCellInner
row={row}
column={column}
getValue={getValue}
onBlur={() => setIsFocused(false)}
/>
);
};
// Inner component with all the editing logic - only rendered when focused
const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => {
const initialValue = getValue();
const [value, setValue] = useState(initialValue);
const [isFocused, setIsFocused] = useState(false);
const previousValue = useRef(initialValue);
const isMounted = useRef(false);
useEffect(() => {
const currentValue = getValue();
if (!isFocused && currentValue !== previousValue.current) {
if (currentValue !== previousValue.current) {
setValue(currentValue);
previousValue.current = currentValue;
}
}, [getValue, isFocused]);
}, [getValue]);
const saveValue = useCallback(
async (newValue) => {
// Don't save if not mounted, not unlocked, or value hasn't changed
if (
!isMounted.current ||
!isUnlocked ||
newValue === previousValue.current
) {
// Don't save if not mounted or value hasn't changed
if (!isMounted.current || newValue === previousValue.current) {
return;
}
@ -203,8 +262,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => {
// If channel_number was changed, refetch to reorder the table
if (column.id === 'channel_number') {
await API.requeryChannels();
// Exit edit mode after resorting to avoid confusion
setIsFocused(false);
onBlur();
}
}
} catch (error) {
@ -212,7 +270,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => {
setValue(previousValue.current);
}
},
[row.original.id, column.id, isUnlocked]
[row.original.id, column.id, onBlur]
);
useEffect(() => {
@ -223,51 +281,14 @@ export const EditableNumberCell = ({ row, column, getValue }) => {
}, []);
const handleChange = (newValue) => {
if (!isUnlocked) return;
setValue(newValue);
};
const handleBlur = () => {
setIsFocused(false);
if (isUnlocked) {
saveValue(value);
}
saveValue(value);
onBlur();
};
const handleClick = () => {
if (isUnlocked) {
setIsFocused(true);
}
};
const formattedValue =
value !== null && value !== undefined
? value === Math.floor(value)
? Math.floor(value)
: value
: '';
if (!isUnlocked || !isFocused) {
return (
<Box
onClick={handleClick}
style={{
textAlign: 'right',
width: '100%',
cursor: isUnlocked ? 'text' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{formattedValue}
</Box>
);
}
return (
<NumberInput
value={value}
@ -291,21 +312,56 @@ export const EditableNumberCell = ({ row, column, getValue }) => {
};
// Editable select cell for groups
export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
export const EditableGroupCell = ({ row, channelGroups }) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const [isFocused, setIsFocused] = useState(false);
const groupId = row.original.channel_group_id;
const groupName = channelGroups[groupId]?.name || '';
// Show simple display when locked OR when unlocked but not focused
if (!isUnlocked || !isFocused) {
return (
<Box
onClick={() => isUnlocked && setIsFocused(true)}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0 4px',
cursor: isUnlocked ? 'pointer' : 'default',
}}
>
{groupName}
</Box>
);
}
return (
<EditableGroupCellInner
row={row}
channelGroups={channelGroups}
groupName={groupName}
groupId={groupId}
onBlur={() => setIsFocused(false)}
/>
);
};
// Inner component with all the editing logic - only rendered when focused
const EditableGroupCellInner = ({
row,
channelGroups,
groupName,
groupId,
onBlur,
}) => {
const previousGroupId = useRef(groupId);
const [isFocused, setIsFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const saveValue = useCallback(
async (newGroupId) => {
// Don't save if not unlocked or value hasn't changed
if (
!isUnlocked ||
String(newGroupId) === String(previousGroupId.current)
) {
// Don't save if value hasn't changed
if (String(newGroupId) === String(previousGroupId.current)) {
return;
}
@ -324,18 +380,12 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
console.error('Failed to update channel group:', error);
}
},
[row.original.id, isUnlocked]
[row.original.id]
);
const handleClick = () => {
if (isUnlocked) {
setIsFocused(true);
}
};
const handleChange = (newGroupId) => {
saveValue(newGroupId);
setIsFocused(false);
onBlur();
setSearchValue('');
};
@ -344,33 +394,11 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
label: group.name,
}));
if (!isUnlocked || !isFocused) {
return (
<Box
onClick={handleClick}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: isUnlocked ? 'pointer' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{groupName}
</Box>
);
}
return (
<Select
value={null}
onChange={handleChange}
onBlur={() => setIsFocused(false)}
onBlur={onBlur}
data={groupOptions}
size="xs"
variant="unstyled"
@ -401,12 +429,10 @@ export const EditableEPGCell = ({
tvgsLoaded,
}) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const epgDataId = getValue();
const previousEpgDataId = useRef(epgDataId);
const [isFocused, setIsFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const epgDataId = getValue();
// Format display text
// Format display text - needed for both locked and unlocked states
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
const tvgId = epgObj?.tvg_id;
const epgName =
@ -423,13 +449,90 @@ export const EditableEPGCell = ({
// Show skeleton while EPG data is loading (only if channel has an EPG assignment)
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
// Build tooltip content
const tooltip = epgObj
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
: '';
// Show simple display when locked OR when unlocked but not focused
if (!isUnlocked || !isFocused) {
// If loading EPG data, show skeleton
if (isEpgDataPending) {
return (
<Box
onClick={() => isUnlocked && setIsFocused(true)}
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
cursor: isUnlocked ? 'pointer' : 'default',
}}
>
<Skeleton
height={18}
width="70%"
visible={true}
animate={true}
style={{ borderRadius: 4 }}
/>
</Box>
);
}
return (
<Tooltip
label={<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>}
withArrow
position="top"
disabled={!epgObj}
openDelay={500}
>
<Box
onClick={() => isUnlocked && setIsFocused(true)}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0 4px',
cursor: isUnlocked ? 'pointer' : 'default',
}}
>
{displayText}
</Box>
</Tooltip>
);
}
return (
<EditableEPGCellInner
row={row}
tvgsById={tvgsById}
epgs={epgs}
epgDataId={epgDataId}
epgObj={epgObj}
displayText={displayText}
onBlur={() => setIsFocused(false)}
/>
);
};
// Inner component with all the editing logic - only rendered when focused
const EditableEPGCellInner = ({
row,
tvgsById,
epgs,
epgDataId,
displayText,
onBlur,
}) => {
const previousEpgDataId = useRef(epgDataId);
const [searchValue, setSearchValue] = useState('');
const saveValue = useCallback(
async (newEpgDataId) => {
// Don't save if not unlocked or value hasn't changed
if (
!isUnlocked ||
String(newEpgDataId) === String(previousEpgDataId.current)
) {
// Don't save if value hasn't changed
if (String(newEpgDataId) === String(previousEpgDataId.current)) {
return;
}
@ -449,20 +552,13 @@ export const EditableEPGCell = ({
console.error('Failed to update EPG:', error);
}
},
[row.original.id, isUnlocked]
[row.original.id]
);
const handleClick = () => {
if (isUnlocked) {
setSearchValue(''); // Start with empty search
setIsFocused(true);
}
};
const handleChange = (newEpgDataId) => {
saveValue(newEpgDataId);
setSearchValue('');
setIsFocused(false);
onBlur();
};
// Build EPG options
@ -514,69 +610,11 @@ export const EditableEPGCell = ({
return options;
}, [tvgsById, epgs]);
// Build tooltip content
const tooltip = epgObj
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
: '';
if (!isUnlocked || !isFocused) {
// If loading EPG data, show skeleton
if (isEpgDataPending) {
return (
<Box
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
}}
>
<Skeleton
height={18}
width="70%"
visible={true}
animate={true}
style={{ borderRadius: 4 }}
/>
</Box>
);
}
// Otherwise, show the normal EPG assignment cell
return (
<Tooltip
label={<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>}
withArrow
position="top"
disabled={!epgObj}
openDelay={500}
>
<Box
onClick={handleClick}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: isUnlocked ? 'pointer' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{displayText}
</Box>
</Tooltip>
);
}
return (
<Select
value={null}
onChange={handleChange}
onBlur={() => setIsFocused(false)}
onBlur={onBlur}
data={epgOptions}
size="xs"
variant="unstyled"
@ -599,17 +637,69 @@ export const EditableEPGCell = ({
};
// Editable cell for Logo selection
export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
export const EditableLogoCell = ({
row,
getValue,
LazyLogo,
ensureLogosLoaded,
}) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const logoId = getValue();
const previousLogoId = useRef(logoId);
const [isFocused, setIsFocused] = useState(false);
const logoId = getValue();
const handleClick = () => {
if (isUnlocked) {
// Ensure logos are loaded when user tries to edit
ensureLogosLoaded?.();
setIsFocused(true);
}
};
// Show simple display when locked OR when unlocked but not focused
if (!isUnlocked || !isFocused) {
return (
<Box
onClick={handleClick}
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: isUnlocked ? 'pointer' : 'default',
}}
>
{LazyLogo && (
<LazyLogo
logoId={logoId}
alt="logo"
style={{ maxHeight: 18, maxWidth: 55 }}
/>
)}
</Box>
);
}
return (
<EditableLogoCellInner
row={row}
logoId={logoId}
onBlur={() => setIsFocused(false)}
/>
);
};
// Inner component with all the editing logic - only rendered when focused
const EditableLogoCellInner = ({ row, logoId, onBlur }) => {
// Subscribe directly to the logos store so we get updates when logos load
const channelLogos = useLogosStore((s) => s.channelLogos);
const previousLogoId = useRef(logoId);
const [searchValue, setSearchValue] = useState('');
const saveValue = useCallback(
async (newLogoId) => {
// Don't save if not unlocked or value hasn't changed
if (!isUnlocked || String(newLogoId) === String(previousLogoId.current)) {
// Don't save if value hasn't changed
if (String(newLogoId) === String(previousLogoId.current)) {
return;
}
@ -628,20 +718,13 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
console.error('Failed to update logo:', error);
}
},
[row.original.id, isUnlocked]
[row.original.id]
);
const handleClick = () => {
if (isUnlocked) {
setSearchValue('');
setIsFocused(true);
}
};
const handleChange = (newLogoId) => {
saveValue(newLogoId);
setSearchValue('');
setIsFocused(false);
onBlur();
};
// Build logo options with logo data
@ -706,36 +789,6 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
);
};
if (!isUnlocked || !isFocused) {
// When not editing, show the logo image
return (
<Box
onClick={handleClick}
style={{
cursor: isUnlocked ? 'pointer' : 'default',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{LazyLogo && (
<LazyLogo
logoId={logoId}
alt="logo"
style={{ maxHeight: 18, maxWidth: 55 }}
/>
)}
</Box>
);
}
return (
<Box
style={{
@ -748,7 +801,7 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
<Select
value={null}
onChange={handleChange}
onBlur={() => setIsFocused(false)}
onBlur={onBlur}
data={logoOptions}
size="xs"
variant="unstyled"

View file

@ -197,6 +197,9 @@ const StreamsTable = ({ onReady }) => {
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
// Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
@ -412,10 +415,6 @@ const StreamsTable = ({ onReady }) => {
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);
@ -443,6 +442,25 @@ const StreamsTable = ({ onReady }) => {
}
});
const paramsString = params.toString();
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
if (
fetchInProgressRef.current &&
lastFetchParamsRef.current === paramsString
) {
return;
}
// Increment fetch version to track this specific fetch request
const currentFetchVersion = ++fetchVersionRef.current;
lastFetchParamsRef.current = paramsString;
fetchInProgressRef.current = true;
if (showLoader) {
setIsLoading(true);
}
try {
const [result, ids, filterOptions] = await Promise.all([
API.queryStreamsTable(params),
@ -450,6 +468,13 @@ const StreamsTable = ({ onReady }) => {
API.getStreamFilterOptions(params),
]);
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
setAllRowIds(ids);
// Set filtered options based on current filters
@ -481,9 +506,20 @@ const StreamsTable = ({ onReady }) => {
onReady();
}
} catch (error) {
fetchInProgressRef.current = false;
// Skip logging if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
console.error('Error fetching data:', error);
}
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
hasFetchedOnce.current = true;
if (showLoader) {
setIsLoading(false);

View file

@ -25,6 +25,7 @@ const isTokenExpired = (expirationTime) => {
const useAuthStore = create((set, get) => ({
isAuthenticated: false,
isInitialized: false,
isInitializing: false,
needsSuperuser: false,
user: {
username: '',
@ -37,20 +38,28 @@ const useAuthStore = create((set, get) => ({
setUser: (user) => set({ user }),
initData: async () => {
const user = await API.me();
if (user.user_level <= USER_LEVELS.STREAMER) {
throw new Error('Unauthorized');
// Prevent multiple simultaneous initData calls
if (get().isInitializing || get().isInitialized) {
return;
}
set({ user, isAuthenticated: true });
// Ensure settings are loaded first
await useSettingsStore.getState().fetchSettings();
set({ isInitializing: true });
try {
// Only after settings are loaded, fetch the essential data
const user = await API.me();
if (user.user_level <= USER_LEVELS.STREAMER) {
throw new Error('Unauthorized');
}
set({ user });
// Ensure settings are loaded first
await useSettingsStore.getState().fetchSettings();
// Fetch essential data needed for initial render
// Note: fetchChannels() is intentionally NOT awaited here - it's slow (~3s)
// and only needed for delete modal details. It loads in background after UI renders.
await Promise.all([
useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useChannelsStore.getState().fetchChannelProfiles(),
usePlaylistsStore.getState().fetchPlaylists(),
@ -64,10 +73,23 @@ const useAuthStore = create((set, get) => ({
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
// Only set isAuthenticated and isInitialized AFTER essential data is loaded
// This prevents routes from rendering before data is ready
set({
isAuthenticated: true,
isInitialized: true,
isInitializing: false,
});
// Load channels data in background (not blocking) - needed for delete modal details
useChannelsStore.getState().fetchChannels();
// Note: Logos are loaded after the Channels page tables finish loading
// This is handled by the tables themselves signaling completion
} catch (error) {
console.error('Error initializing data:', error);
set({ isInitializing: false });
throw error;
}
},
@ -118,7 +140,7 @@ const useAuthStore = create((set, get) => ({
// Action to refresh the token
getRefreshToken: async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) return false; // Add explicit return here
if (!refreshToken) return false;
try {
const data = await api.refreshToken(refreshToken);
@ -126,18 +148,17 @@ const useAuthStore = create((set, get) => ({
set({
accessToken: data.access,
tokenExpiration: decodeToken(data.access),
isAuthenticated: true,
});
localStorage.setItem('accessToken', data.access);
localStorage.setItem('tokenExpiration', decodeToken(data.access));
return data.access;
}
return false; // Add explicit return for when data.access is not available
return false;
} catch (error) {
console.error('Token refresh failed:', error);
await get().logout();
return false; // Add explicit return after error
return false;
}
},
@ -156,6 +177,8 @@ const useAuthStore = create((set, get) => ({
refreshToken: null,
tokenExpiration: null,
isAuthenticated: false,
isInitialized: false,
isInitializing: false,
user: null,
});
localStorage.removeItem('accessToken');

View file

@ -1,7 +1,7 @@
import { create } from 'zustand';
import api from '../api';
const useSettingsStore = create((set) => ({
const useSettingsStore = create((set, get) => ({
settings: {},
environment: {
// Add default values for environment settings
@ -10,15 +10,27 @@ const useSettingsStore = create((set) => ({
country_name: '',
env_mode: 'prod',
},
version: {
version: '',
timestamp: null,
},
isLoading: false,
error: null,
fetchSettings: async () => {
set({ isLoading: true, error: null });
try {
const settings = await api.getSettings();
const env = await api.getEnvironmentSettings();
set({
// Only fetch version if not already loaded (may have been fetched by Login/Superuser form)
const currentVersion = get().version;
const needsVersion = !currentVersion.version;
const [settings, env, versionData] = await Promise.all([
api.getSettings(),
api.getEnvironmentSettings(),
needsVersion ? api.getVersion() : Promise.resolve(null),
]);
const newState = {
settings: settings.reduce((acc, setting) => {
acc[setting.key] = setting;
return acc;
@ -30,12 +42,42 @@ const useSettingsStore = create((set) => ({
country_name: '',
env_mode: 'prod',
},
});
};
// Only update version if we fetched it
if (versionData) {
newState.version = {
version: versionData?.version || '',
timestamp: versionData?.timestamp || null,
};
}
set(newState);
} catch (error) {
set({ error: 'Failed to load settings.', isLoading: false });
}
},
// Fetch version independently (for unauthenticated pages like Login)
fetchVersion: async () => {
// Skip if already loaded
if (get().version.version) {
return get().version;
}
try {
const versionData = await api.getVersion();
const version = {
version: versionData?.version || '',
timestamp: versionData?.timestamp || null,
};
set({ version });
return version;
} catch (error) {
console.error('Failed to fetch version:', error);
return get().version;
}
},
updateSetting: (setting) =>
set((state) => ({
settings: { ...state.settings, [setting.key]: setting },

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
export default {
Limiter: (n, list) => {
@ -40,13 +40,25 @@ export default {
// Custom debounce hook
export function useDebounce(value, delay = 500, callback = null) {
const [debouncedValue, setDebouncedValue] = useState(value);
const isFirstRender = useRef(true);
const previousValueRef = useRef(JSON.stringify(value));
useEffect(() => {
const currentValueStr = JSON.stringify(value);
// Skip if value hasn't actually changed (prevents unnecessary state updates)
if (previousValueRef.current === currentValueStr) {
return;
}
const handler = setTimeout(() => {
setDebouncedValue(value);
if (callback) {
// Only fire callback if not the first render
if (callback && !isFirstRender.current) {
callback();
}
isFirstRender.current = false;
previousValueRef.current = currentValueStr;
}, delay);
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change

View file

@ -5,7 +5,7 @@ djangorestframework==3.16.1
requests==2.32.5
psutil==7.1.3
pillow
drf-yasg>=1.21.11
drf-spectacular>=0.29.0
streamlink
python-vlc
yt-dlp
@ -18,6 +18,7 @@ m3u8
rapidfuzz==3.14.3
regex # Required by transformers but also used for advanced regex features
tzlocal
pytz
# PyTorch dependencies (CPU only)
--extra-index-url https://download.pytorch.org/whl/cpu/

View file

@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process