mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge branch 'Dispatcharr:main' into Feature/771-Auto-Matching-Improvments
This commit is contained in:
commit
52b0c7ca26
15 changed files with 701 additions and 372 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue