From a170663407b688507e17ace3e9d809d53cf6d626 Mon Sep 17 00:00:00 2001 From: None Date: Wed, 4 Feb 2026 21:09:35 -0600 Subject: [PATCH 01/53] feat: Add sorting by Group and EPG columns to Channels and Streams tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Backend: Add epg_data__name to ChannelViewSet ordering_fields - ChannelsTable: Add sort icons to EPG and Group column headers using rightSection prop - ChannelsTable: Add field mapping for channel_group → channel_group__name and epg → epg_data__name - StreamsTable: Add sort icon to Group column header for consistency Adds the ability to sort channels by Group and EPG columns, and streams by Group column. Closes #854 --- apps/channels/api_views.py | 2 +- .../src/components/tables/ChannelsTable.jsx | 85 +++++++++++++------ .../src/components/tables/StreamsTable.jsx | 9 ++ 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ad5a270f..b1ea9a45 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -461,7 +461,7 @@ class ChannelViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = ChannelFilter search_fields = ["name", "channel_group__name"] - ordering_fields = ["channel_number", "name", "channel_group__name"] + ordering_fields = ["channel_number", "name", "channel_group__name", "epg_data__name"] ordering = ["-channel_number"] def create(self, request, *args, **kwargs): diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 371cb77f..8cc33904 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -443,7 +443,15 @@ const ChannelsTable = ({ onReady }) => { // Apply sorting if (sorting.length > 0) { - const sortField = sorting[0].id; + let sortField = sorting[0].id; + // Map frontend column ids to backend ordering field names + const fieldMapping = { + channel_group: 'channel_group__name', + epg: 'epg_data__name', + }; + if (fieldMapping[sortField]) { + sortField = fieldMapping[sortField]; + } const sortDirection = sorting[0].desc ? '-' : ''; params.append('ordering', `${sortDirection}${sortField}`); } @@ -931,7 +939,7 @@ const ChannelsTable = ({ onReady }) => { /> ), size: columnSizing.epg || 200, - minSize: 80, + minSize: 120, }, { id: 'channel_group', @@ -942,8 +950,8 @@ const ChannelsTable = ({ onReady }) => { cell: (props) => ( ), - size: columnSizing.channel_group || 175, - minSize: 100, + size: columnSizing.channel_group || 200, + minSize: 120, }, { id: 'logo', @@ -1025,6 +1033,15 @@ const ChannelsTable = ({ onReady }) => { : [] } style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('epg'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} /> ); case 'enabled': @@ -1036,11 +1053,16 @@ const ChannelsTable = ({ onReady }) => { case 'channel_number': return ( - + # -
+
{ + e.stopPropagation(); + onSortingChange('channel_number'); + }} + style={{ cursor: 'pointer' }} + > {React.createElement(sortingIcon, { - onClick: () => onSortingChange('channel_number'), size: 14, })}
@@ -1049,25 +1071,27 @@ const ChannelsTable = ({ onReady }) => { case 'name': return ( - - e.stopPropagation()} - onChange={handleFilterChange} - size="xs" - variant="unstyled" - className="table-input-header" - leftSection={} - /> -
- {React.createElement(sortingIcon, { - onClick: () => onSortingChange('name'), - size: 14, - })} -
-
+ e.stopPropagation()} + onChange={handleFilterChange} + size="xs" + variant="unstyled" + className="table-input-header" + leftSection={} + style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('name'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} + /> ); case 'channel_group': @@ -1090,6 +1114,15 @@ const ChannelsTable = ({ onReady }) => { : [] } style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('channel_group'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} /> ); } diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index d7c1b059..cd66f74f 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -1102,6 +1102,15 @@ const StreamsTable = ({ onReady }) => { className="table-input-header custom-multiselect" clearable style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('group'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} /> ); } From 6f5491098697d0fb94de948c873da41b1507bd54 Mon Sep 17 00:00:00 2001 From: Nicholas Gerrer Date: Wed, 25 Feb 2026 23:34:56 -0500 Subject: [PATCH 02/53] fix: add configurable uwsgi_read_timeout to fix 504 timeout on large M3U imports (fixes #745) --- docker/entrypoint.sh | 1 + docker/init/03-init-dispatcharr.sh | 1 + docker/nginx.conf | 1 + 3 files changed, 3 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b79af952..dc36421a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -41,6 +41,7 @@ export REDIS_DB=${REDIS_DB:-0} export REDIS_PASSWORD=${REDIS_PASSWORD:-} export REDIS_USER=${REDIS_USER:-} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} +export NGINX_UWSGI_TIMEOUT=${NGINX_UWSGI_TIMEOUT:-300} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' export LD_LIBRARY_PATH='/usr/local/lib' export SECRET_FILE="/data/jwt" diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 0c317017..186831ea 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -36,6 +36,7 @@ if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then DISPATCHARR_PORT=9191 fi sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default +sed -i "s/NGINX_UWSGI_TIMEOUT/${NGINX_UWSGI_TIMEOUT}/g" /etc/nginx/sites-enabled/default # Configure nginx based on IPv6 availability if ip -6 addr show | grep -q "inet6"; then diff --git a/docker/nginx.conf b/docker/nginx.conf index e08d08f2..c8ee3bc1 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,6 +21,7 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; + uwsgi_read_timeout NGINX_UWSGI_TIMEOUT; } location /assets/ { From 858c81f826e0c8cce944adaffe63e73f57070546 Mon Sep 17 00:00:00 2001 From: Nicholas Gerrer Date: Thu, 26 Feb 2026 01:21:26 -0500 Subject: [PATCH 03/53] perf: use bulk_create with update_conflicts for group settings save Replace N+1 update_or_create() loop with bulk_create(update_conflicts=True) wrapped in transaction.atomic(). On large M3U accounts with many groups, the previous approach issued individual DB queries per group/category, which could take minutes on slower hardware like Synology NAS devices. --- apps/m3u/api_views.py | 69 +++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 26e182d9..34f3cd77 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -8,6 +8,7 @@ from apps.accounts.permissions import ( ) from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes +from django.db import transaction from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache @@ -264,38 +265,50 @@ class M3UAccountViewSet(viewsets.ModelViewSet): category_settings = request.data.get("category_settings", []) try: - for setting in group_settings: - group_id = setting.get("channel_group") - enabled = setting.get("enabled", True) - auto_sync = setting.get("auto_channel_sync", False) - sync_start = setting.get("auto_sync_channel_start") - custom_properties = setting.get("custom_properties", {}) - - if group_id: - ChannelGroupM3UAccount.objects.update_or_create( - channel_group_id=group_id, + with transaction.atomic(): + group_objects = [ + ChannelGroupM3UAccount( + channel_group_id=setting["channel_group"], m3u_account=account, - defaults={ - "enabled": enabled, - "auto_channel_sync": auto_sync, - "auto_sync_channel_start": sync_start, - "custom_properties": custom_properties, - }, + enabled=setting.get("enabled", True), + auto_channel_sync=setting.get("auto_channel_sync", False), + auto_sync_channel_start=setting.get("auto_sync_channel_start"), + custom_properties=setting.get("custom_properties", {}), + ) + for setting in group_settings + if setting.get("channel_group") + ] + + if group_objects: + ChannelGroupM3UAccount.objects.bulk_create( + group_objects, + update_conflicts=True, + unique_fields=["channel_group", "m3u_account"], + update_fields=[ + "enabled", + "auto_channel_sync", + "auto_sync_channel_start", + "custom_properties", + ], ) - for setting in category_settings: - category_id = setting.get("id") - enabled = setting.get("enabled", True) - custom_properties = setting.get("custom_properties", {}) - - if category_id: - M3UVODCategoryRelation.objects.update_or_create( - category_id=category_id, + category_objects = [ + M3UVODCategoryRelation( + category_id=setting["id"], m3u_account=account, - defaults={ - "enabled": enabled, - "custom_properties": custom_properties, - }, + enabled=setting.get("enabled", True), + custom_properties=setting.get("custom_properties", {}), + ) + for setting in category_settings + if setting.get("id") + ] + + if category_objects: + M3UVODCategoryRelation.objects.bulk_create( + category_objects, + update_conflicts=True, + unique_fields=["m3u_account", "category"], + update_fields=["enabled", "custom_properties"], ) return Response({"message": "Group settings updated successfully"}) From 6ff81e628763861565ea8386fa77df884548ab5b Mon Sep 17 00:00:00 2001 From: None Date: Tue, 3 Mar 2026 17:18:16 -0600 Subject: [PATCH 04/53] Fix modular mode deployment issues (#1045) - Fix Postgres version check failing with restricted DB users (use $POSTGRES_DB instead of hardcoded 'postgres') - Fix DVR recording broken in modular mode (respect DISPATCHARR_PORT instead of hardcoding 9191) - Remove flushdb() from wait_for_redis.py to prevent Redis data loss on container restart - Add DISPATCHARR_PORT to celery environment in docker-compose.yml - Add depends_on health conditions for proper service startup ordering - Add extra_hosts for host.docker.internal resolution on Linux - Harden celery entrypoint with timeouts for JWT wait (120s) and migration wait (300s) - Replace fragile showmigrations grep with migrate --check - Add unit tests for DVR port resolution and flushdb removal regression --- apps/channels/tasks.py | 44 ++++++---- .../tests/test_dvr_port_resolution.py | 59 ++++++++++++++ docker/docker-compose.yml | 21 +++-- docker/entrypoint.celery.sh | 28 ++++++- docker/init/02-postgres.sh | 9 ++- scripts/wait_for_redis.py | 1 - tests/__init__.py | 0 tests/test_wait_for_redis.py | 81 +++++++++++++++++++ 8 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 apps/channels/tests/test_dvr_port_resolution.py create mode 100644 tests/__init__.py create mode 100644 tests/test_wait_for_redis.py diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 8d49287b..a95e9ad6 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1486,6 +1486,33 @@ def _build_output_paths(channel, program, start_time, end_time): return final_path, temp_ts_path, os.path.basename(final_path) +def build_dvr_candidates(): + """Build ordered list of candidate base URLs for DVR TS streaming. + + Reads environment variables to determine which URLs to try: + - DISPATCHARR_INTERNAL_TS_BASE_URL: explicit override (first priority) + - DISPATCHARR_PORT: the external port (default 9191) + - DISPATCHARR_ENV/DISPATCHARR_DEBUG/REDIS_HOST: dev-mode detection + - DISPATCHARR_INTERNAL_API_BASE: override for the docker service URL + """ + explicit = os.environ.get('DISPATCHARR_INTERNAL_TS_BASE_URL') + dispatcharr_port = os.environ.get('DISPATCHARR_PORT', '9191') + is_dev = (os.environ.get('DISPATCHARR_ENV', '').lower() == 'dev') or \ + (os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true') or \ + (os.environ.get('REDIS_HOST', 'redis') in ('localhost', '127.0.0.1')) + candidates = [] + if explicit: + candidates.append(explicit) + if is_dev: + # Debug container typically exposes API on 5656 (uwsgi internal port) + candidates.extend(['http://127.0.0.1:5656', f'http://127.0.0.1:{dispatcharr_port}']) + # Docker service name fallback — use DISPATCHARR_PORT so modular mode works with custom ports + candidates.append(os.environ.get('DISPATCHARR_INTERNAL_API_BASE', f'http://web:{dispatcharr_port}')) + # Last-resort localhost ports + candidates.extend(['http://localhost:5656', f'http://localhost:{dispatcharr_port}']) + return candidates + + @shared_task def run_recording(recording_id, channel_id, start_time_str, end_time_str): """ @@ -1790,22 +1817,7 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): from requests.exceptions import ReadTimeout, ConnectionError as ReqConnectionError, ChunkedEncodingError - # Determine internal base URL(s) for TS streaming - # Prefer explicit override, then try common ports for debug and docker - explicit = os.environ.get('DISPATCHARR_INTERNAL_TS_BASE_URL') - is_dev = (os.environ.get('DISPATCHARR_ENV', '').lower() == 'dev') or \ - (os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true') or \ - (os.environ.get('REDIS_HOST', 'redis') in ('localhost', '127.0.0.1')) - candidates = [] - if explicit: - candidates.append(explicit) - if is_dev: - # Debug container typically exposes API on 5656 - candidates.extend(['http://127.0.0.1:5656', 'http://127.0.0.1:9191']) - # Docker service name fallback - candidates.append(os.environ.get('DISPATCHARR_INTERNAL_API_BASE', 'http://web:9191')) - # Last-resort localhost ports - candidates.extend(['http://localhost:5656', 'http://localhost:9191']) + candidates = build_dvr_candidates() chosen_base = None last_error = None diff --git a/apps/channels/tests/test_dvr_port_resolution.py b/apps/channels/tests/test_dvr_port_resolution.py new file mode 100644 index 00000000..c7373959 --- /dev/null +++ b/apps/channels/tests/test_dvr_port_resolution.py @@ -0,0 +1,59 @@ +import os +from django.test import SimpleTestCase +from unittest.mock import patch + +from apps.channels.tasks import build_dvr_candidates + + +class DVRPortResolutionTests(SimpleTestCase): + """ + Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT + environment variable instead of hardcoding port 9191. + """ + + @patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True) + def test_default_port_uses_9191(self): + """Without DISPATCHARR_PORT set, candidates default to 9191.""" + candidates = build_dvr_candidates() + self.assertIn('http://web:9191', candidates) + self.assertIn('http://localhost:9191', candidates) + + @patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True) + def test_custom_port_reflected_in_candidates(self): + """DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references.""" + candidates = build_dvr_candidates() + self.assertIn('http://web:8080', candidates) + self.assertIn('http://localhost:8080', candidates) + self.assertNotIn('http://web:9191', candidates) + self.assertNotIn('http://localhost:9191', candidates) + + @patch.dict(os.environ, { + 'DISPATCHARR_PORT': '7777', + 'DISPATCHARR_ENV': 'dev', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_dev_mode_includes_5656_and_custom_port(self): + """Dev mode includes both uwsgi internal port (5656) and custom port.""" + candidates = build_dvr_candidates() + self.assertIn('http://127.0.0.1:5656', candidates) + self.assertIn('http://127.0.0.1:7777', candidates) + + @patch.dict(os.environ, { + 'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_explicit_override_is_first(self): + """DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate.""" + candidates = build_dvr_candidates() + self.assertEqual(candidates[0], 'http://custom:1234') + + @patch.dict(os.environ, { + 'DISPATCHARR_PORT': '3000', + 'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_internal_api_base_overrides_web_fallback(self): + """DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default.""" + candidates = build_dvr_candidates() + self.assertIn('http://myhost:4000', candidates) + self.assertNotIn('http://web:3000', candidates) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8d086500..d674460a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,8 +15,12 @@ services: volumes: - ./data:/data depends_on: - - db - - redis + db: + condition: service_healthy + redis: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" # --- Environment Configuration --- environment: @@ -83,9 +87,12 @@ services: container_name: dispatcharr_celery restart: unless-stopped depends_on: - - db - - redis - - web + db: + condition: service_healthy + redis: + condition: service_healthy + web: + condition: service_started volumes: - ./data:/data extra_hosts: @@ -97,6 +104,10 @@ services: # Deployment Mode - DISPATCHARR_ENV=modular + # Internal Service Communication + # Must match the web service port for DVR recording and internal API calls + - DISPATCHARR_PORT=9191 + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh index e5c2f340..1c14f9b6 100644 --- a/docker/entrypoint.celery.sh +++ b/docker/entrypoint.celery.sh @@ -9,9 +9,19 @@ echo_with_timestamp() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" } -# Wait for Django secret key +# Wait for Django secret key (generated by the web container on startup) +JWT_TIMEOUT=120 +JWT_WAITED=0 echo 'Waiting for Django secret key...' -while [ ! -f /data/jwt ]; do sleep 1; done +while [ ! -f /data/jwt ]; do + if [ $JWT_WAITED -ge $JWT_TIMEOUT ]; then + echo "❌ ERROR: Timed out waiting for /data/jwt after ${JWT_TIMEOUT}s." + echo " Is the web container running? Does it have the /data volume mounted?" + exit 1 + fi + sleep 1 + JWT_WAITED=$((JWT_WAITED + 1)) +done export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)" # --- NumPy version switching for legacy hardware --- @@ -26,11 +36,21 @@ if [ "$USE_LEGACY_NUMPY" = "true" ]; then fi fi -# Wait for migrations to complete (check that NO unapplied migrations remain) +# Wait for migrations to complete +# Uses 'migrate --check' which exits 0 only when all migrations are applied, +# and exits 1 on unapplied migrations OR connection errors (safe either way) +MIG_TIMEOUT=300 +MIG_WAITED=0 echo 'Waiting for migrations to complete...' -until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do +until python manage.py migrate --check >/dev/null 2>&1; do + if [ $MIG_WAITED -ge $MIG_TIMEOUT ]; then + echo "❌ ERROR: Timed out waiting for migrations after ${MIG_TIMEOUT}s." + echo " Check web container logs for migration errors." + exit 1 + fi echo_with_timestamp 'Migrations not ready yet, waiting...' sleep 2 + MIG_WAITED=$((MIG_WAITED + 2)) done # Start Celery diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 87aa94ef..2ece526f 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -204,14 +204,19 @@ check_external_postgres_version() { MIN_REQUIRED_VERSION=$PG_VERSION # Query external PostgreSQL version - EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "postgres" -tAc "SHOW server_version;" 2>/dev/null | grep -oE '^[0-9]+') + # Use $POSTGRES_DB — restricted users may not have access to the default 'postgres' database + PG_VERSION_ERR=$(mktemp) + EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SHOW server_version;" 2>"$PG_VERSION_ERR" | grep -oE '^[0-9]+') if [ -z "$EXTERNAL_VERSION" ]; then echo "❌ ERROR: Unable to determine external PostgreSQL version" - echo " Could not connect to database at ${POSTGRES_HOST}:${POSTGRES_PORT}" + echo " Could not connect to database '$POSTGRES_DB' at ${POSTGRES_HOST}:${POSTGRES_PORT} as user '$POSTGRES_USER'" + echo " Error: $(cat "$PG_VERSION_ERR")" echo " Please verify your database connection settings." + rm -f "$PG_VERSION_ERR" return 1 fi + rm -f "$PG_VERSION_ERR" # Compare versions if [[ "$EXTERNAL_VERSION" -lt "$MIN_REQUIRED_VERSION" ]]; then diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py index 0d278150..41b66757 100644 --- a/scripts/wait_for_redis.py +++ b/scripts/wait_for_redis.py @@ -31,7 +31,6 @@ def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', socket_connect_timeout=2 ) redis_client.ping() - redis_client.flushdb() # Flush the database to ensure it's clean logger.info(f"✅ Redis at {host}:{port}/{db} is now available!") return True except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_wait_for_redis.py b/tests/test_wait_for_redis.py new file mode 100644 index 00000000..55e623eb --- /dev/null +++ b/tests/test_wait_for_redis.py @@ -0,0 +1,81 @@ +import sys +import os +import importlib +from django.test import SimpleTestCase +from unittest.mock import patch, MagicMock + +import redis as redis_module + +# Ensure the scripts directory is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) + + +def _import_wait_for_redis(): + """Import (or reimport) the wait_for_redis function from scripts/.""" + import wait_for_redis as module + importlib.reload(module) + return module.wait_for_redis + + +class WaitForRedisTests(SimpleTestCase): + """ + Tests for scripts/wait_for_redis.py. + + The critical regression test is test_successful_connection_does_not_call_flushdb + which ensures the removed flushdb() call is never re-added. + """ + + @patch('wait_for_redis.redis.Redis') + def test_successful_connection_does_not_call_flushdb(self, mock_redis_cls): + """After connecting successfully, flushdb must NOT be called.""" + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_cls.return_value = mock_client + + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=1, retry_interval=0) + + self.assertTrue(result) + mock_client.ping.assert_called_once() + mock_client.flushdb.assert_not_called() + + @patch('wait_for_redis.redis.Redis') + def test_retries_on_connection_error(self, mock_redis_cls): + """Should retry on ConnectionError and eventually succeed.""" + mock_client = MagicMock() + mock_client.ping.side_effect = [ + redis_module.exceptions.ConnectionError("refused"), + redis_module.exceptions.ConnectionError("refused"), + True, + ] + mock_redis_cls.return_value = mock_client + + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=5, retry_interval=0) + + self.assertTrue(result) + self.assertEqual(mock_client.ping.call_count, 3) + + @patch('wait_for_redis.redis.Redis') + def test_returns_false_after_max_retries(self, mock_redis_cls): + """Should return False when max retries are exhausted.""" + mock_client = MagicMock() + mock_client.ping.side_effect = redis_module.exceptions.ConnectionError("refused") + mock_redis_cls.return_value = mock_client + + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=2, retry_interval=0) + + self.assertFalse(result) + + @patch('wait_for_redis.redis.Redis') + def test_unexpected_error_returns_false(self, mock_redis_cls): + """Generic exceptions should return False immediately.""" + mock_client = MagicMock() + mock_client.ping.side_effect = RuntimeError("unexpected") + mock_redis_cls.return_value = mock_client + + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=5, retry_interval=0) + + self.assertFalse(result) From c6cf9443d252f6ea616e6ebcb9eed4965a57ed71 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Thu, 5 Mar 2026 14:16:21 -0500 Subject: [PATCH 05/53] fix: guard is_adult parsing against invalid values or whatever other fun things providers might shove down the wire... --- apps/m3u/tasks.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index a9413143..3f4f1c92 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -462,6 +462,13 @@ def get_case_insensitive_attr(attributes, key, default=""): return default +def parse_is_adult(value): + try: + return int(value) == 1 + except (TypeError, ValueError): + return False + + def parse_extinf_line(line: str) -> dict: """ Parse an EXTINF line from an M3U file. @@ -875,7 +882,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(group_id), "stream_hash": stream_hash, "custom_properties": stream, - "is_adult": int(stream.get("is_adult", 0)) == 1, + "is_adult": parse_is_adult(stream.get("is_adult", 0)), "is_stale": False, "stream_id": provider_stream_id, "stream_chno": stream_chno, @@ -1093,7 +1100,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, "custom_properties": stream_info["attributes"], - "is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1, + "is_adult": parse_is_adult(stream_info["attributes"].get("is_adult", 0)), "is_stale": False, "stream_id": provider_stream_id, "stream_chno": channel_num, From a09915438cc56955a9436994634fd8087075db54 Mon Sep 17 00:00:00 2001 From: Marcin Olek Date: Fri, 6 Mar 2026 00:48:22 +0100 Subject: [PATCH 06/53] fix(frontend): prevent infinite re-renders in tables - disable autoResetPageIndex, autoResetExpanded, and autoResetRowSelection in CustomTable - memoize M3U table data to prevent re-renders on playlist progress updates - fix default table state initialization from array to object Made-with: Cursor --- .../components/tables/CustomTable/index.jsx | 7 ++++- frontend/src/components/tables/M3UsTable.jsx | 30 +++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 883e01d7..7050ff1f 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -18,7 +18,7 @@ const useTable = ({ expandedRowRenderer = () => <>, onRowSelectionChange = null, getExpandedRowHeight = null, - state = [], + state = {}, columnSizing, setColumnSizing, onColumnVisibilityChange, @@ -103,6 +103,11 @@ const useTable = ({ selectedTableIds, ...(columnSizing && { columnSizing }), }, + // Add these lines to prevent infinite loops during data updates + autoResetPageIndex: false, + autoResetExpanded: false, + autoResetRowSelection: false, + onStateChange: options.onStateChange, ...(setColumnSizing && { onColumnSizingChange: setColumnSizing }), ...(onColumnVisibilityChange && { onColumnVisibilityChange }), diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 6ae42e51..e2e5e844 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -139,6 +139,21 @@ const M3UTable = () => { const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress); const editPlaylistId = usePlaylistsStore((s) => s.editPlaylistId); const setEditPlaylistId = usePlaylistsStore((s) => s.setEditPlaylistId); + + // Memoize data to prevent unnecessary re-renders during progress updates + const processedData = useMemo(() => { + return playlists + .filter((playlist) => playlist.locked === false) + .sort((a, b) => { + // First sort by active status (active items first) + if (a.is_active !== b.is_active) { + return a.is_active ? -1 : 1; + } + // Then sort by name (case-insensitive) + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + }, [playlists]); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -640,18 +655,7 @@ const M3UTable = () => { // Listen for edit playlist requests from notifications useEffect(() => { - setData( - playlists - .filter((playlist) => playlist.locked === false) - .sort((a, b) => { - // First sort by active status (active items first) - if (a.is_active !== b.is_active) { - return a.is_active ? -1 : 1; - } - // Then sort by name (case-insensitive) - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - }) - ); + setData(processedData); if (editPlaylistId) { const playlistToEdit = playlists.find((p) => p.id === editPlaylistId); @@ -661,7 +665,7 @@ const M3UTable = () => { setEditPlaylistId(null); } } - }, [editPlaylistId, playlists]); + }, [editPlaylistId, processedData, playlists, setEditPlaylistId]); const onSortingChange = (column) => { console.log(column); From b43618dbe9253c237781947adbb906519b71f4f3 Mon Sep 17 00:00:00 2001 From: Marcin Olek Date: Fri, 6 Mar 2026 00:49:23 +0100 Subject: [PATCH 07/53] fix(install): harden debian_install.sh for non-UTF8 environments - ensure en_US.UTF-8 locales are generated and set as default - force UTF8 encoding during PostgreSQL database creation - add standard system paths to systemd services to ensure tools like ffmpeg are found Made-with: Cursor --- debian_install.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/debian_install.sh b/debian_install.sh index 3630161d..305424f8 100755 --- a/debian_install.sh +++ b/debian_install.sh @@ -12,9 +12,20 @@ fi trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR ############################################################################## -# 0) Warning / Disclaimer +# 0) Locales & Warning / Disclaimer ############################################################################## +setup_locales() { + echo ">>> Setting up locales..." + apt-get update + apt-get install -y locales + sed -i '/en_US.UTF-8 UTF-8/s/^# //g' /etc/locale.gen + locale-gen + update-locale LANG=en_US.UTF-8 + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 +} + show_disclaimer() { echo "**************************************************************" echo "WARNING: While we do not anticipate any problems, we disclaim all" @@ -106,8 +117,8 @@ setup_postgresql() { db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'") if [[ "$db_exists" != "1" ]]; then - echo ">>> Creating database '${POSTGRES_DB}'..." - sudo -u postgres createdb "$POSTGRES_DB" + echo ">>> Creating database '${POSTGRES_DB}' with UTF8 encoding..." + sudo -u postgres createdb -E UTF8 "$POSTGRES_DB" else echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation." fi @@ -326,7 +337,7 @@ User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} EnvironmentFile=/opt/dispatcharr/.env -Environment="PATH=${APP_DIR}/env/bin" +Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" Environment="POSTGRES_DB=${POSTGRES_DB}" Environment="POSTGRES_USER=${POSTGRES_USER}" Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" @@ -354,7 +365,7 @@ User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} EnvironmentFile=/opt/dispatcharr/.env -Environment="PATH=${APP_DIR}/env/bin" +Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" Environment="POSTGRES_DB=${POSTGRES_DB}" Environment="POSTGRES_USER=${POSTGRES_USER}" Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" @@ -382,7 +393,7 @@ User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} EnvironmentFile=/opt/dispatcharr/.env -Environment="PATH=${APP_DIR}/env/bin" +Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" Environment="POSTGRES_DB=${POSTGRES_DB}" Environment="POSTGRES_USER=${POSTGRES_USER}" Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" @@ -474,6 +485,7 @@ EOF ############################################################################## main() { + setup_locales show_disclaimer configure_variables install_packages From a51eb37075cb80163e08c8d795fd2a5b1c343ca8 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Fri, 6 Mar 2026 08:10:30 -0500 Subject: [PATCH 08/53] fix: M3U EXTINF parsing for attributes ending in double equals fixes: #1055 --- apps/m3u/tasks.py | 6 ++-- apps/m3u/tests/test_extinf_parsing.py | 41 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 apps/m3u/tests/test_extinf_parsing.py diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index c0c90c57..162359e9 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -482,8 +482,10 @@ def parse_extinf_line(line: str) -> dict: attrs = {} last_attr_end = 0 - # Use a single regex that handles both quote types - for match in re.finditer(r'([^\s]+)=(["\'])([^\2]*?)\2', content): + # Use a single regex that handles both quote types. + # Keys must stop at '=' so values like base64-padded URLs ending with '==' + # don't get folded into the preceding attribute name. + for match in re.finditer(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2', content): key = match.group(1) value = match.group(3) attrs[key] = value diff --git a/apps/m3u/tests/test_extinf_parsing.py b/apps/m3u/tests/test_extinf_parsing.py new file mode 100644 index 00000000..367f569b --- /dev/null +++ b/apps/m3u/tests/test_extinf_parsing.py @@ -0,0 +1,41 @@ +from django.test import SimpleTestCase + +from apps.m3u.tasks import parse_extinf_line + + +class ParseExtinfLineTests(SimpleTestCase): + def test_preserves_equals_padding_in_tvg_logo(self): + line = ( + '#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" ' + 'tvg-chno="1001" ' + 'tvg-name="UK Sky Sports Premier League" ' + 'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?' + 'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" ' + 'group-title="Team Games",UK Sky Sports Premier League' + ) + + parsed = parse_extinf_line(line) + + self.assertIsNotNone(parsed) + self.assertEqual( + parsed["attributes"]["tvg-logo"], + "https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==", + ) + self.assertEqual(parsed["attributes"]["group-title"], "Team Games") + self.assertEqual(parsed["name"], "UK Sky Sports Premier League") + + def test_supports_single_quoted_attributes(self): + line = ( + "#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' " + "group-title='Sports',Channel One" + ) + + parsed = parse_extinf_line(line) + + self.assertIsNotNone(parsed) + self.assertEqual( + parsed["attributes"]["tvg-logo"], + "https://example.com/logo==.png", + ) + self.assertEqual(parsed["attributes"]["group-title"], "Sports") + self.assertEqual(parsed["display_name"], "Channel One") From 4209794cfafa7fee7e53a6be79ab051488c6e105 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 09:05:30 -0600 Subject: [PATCH 09/53] changelog: Update changelog for extinf attribute parsing. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8bd0551..7826d7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012) - Celery worker memory leak during M3U/XC refresh causing 20–80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return) - Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out) From 03ed8c0a1c862a5a3be1175540e52da13b0a6691 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 11:38:12 -0600 Subject: [PATCH 10/53] changelog: Update changelog for debian_install script pr. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7826d7e9..1e7776f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek) + - Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding. + - PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`. + - `PATH` in the Celery worker, Celery Beat, and Daphne systemd service files extended to include `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`, fixing failures where background tasks could not locate `ffmpeg` or `ffprobe`. - M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012) - Celery worker memory leak during M3U/XC refresh causing 20–80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return) From 758a79a4f6e05761f991fe10ebe036fb7b0010a0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 13:16:49 -0600 Subject: [PATCH 11/53] fix(frontend): remove non-existent autoResetRowSelection option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autoResetRowSelection does not exist in TanStack Table v8 — it was a v7 option. The remaining autoResetPageIndex and autoResetExpanded options are valid v8 API and retained. --- frontend/src/components/tables/CustomTable/index.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 7050ff1f..c87b57bb 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -103,10 +103,8 @@ const useTable = ({ selectedTableIds, ...(columnSizing && { columnSizing }), }, - // Add these lines to prevent infinite loops during data updates autoResetPageIndex: false, autoResetExpanded: false, - autoResetRowSelection: false, onStateChange: options.onStateChange, ...(setColumnSizing && { onColumnSizingChange: setColumnSizing }), From 375dd57ae5a8ae268276c8649a30e3caa7dbc695 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 13:21:18 -0600 Subject: [PATCH 12/53] changelog: Update changelog for m3u import pr. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7776f9..04ca4ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` → `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek) - `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek) - Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding. - PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`. From d39b0777155a086ced02964efe4dd199f4534733 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 14:51:34 -0600 Subject: [PATCH 13/53] Rename fetchChannels to fetchChannelIds in M3URefreshNotification component and tests --- .../src/components/M3URefreshNotification.jsx | 19 +++++------ .../__tests__/M3URefreshNotification.test.jsx | 32 +++++++++++-------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 8fd461b4..263cb46c 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -16,7 +16,7 @@ const M3uSetupSuccess = (data) => { const onClickRefresh = () => { API.refreshPlaylist(data.account); - } + }; const onClickConfigure = () => { // Store the ID we want to edit in the store first @@ -25,7 +25,7 @@ const M3uSetupSuccess = (data) => { // Then navigate to the content sources page // Using the exact path that matches your app's routing structure navigate('/sources'); - } + }; return ( @@ -41,14 +41,14 @@ const M3uSetupSuccess = (data) => { ); -} +}; export default function M3URefreshNotification() { const playlists = usePlaylistsStore((s) => s.playlists); const refreshProgress = usePlaylistsStore((s) => s.refreshProgress); const fetchStreams = useStreamsStore((s) => s.fetchStreams); const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); - const fetchChannels = useChannelsStore((s) => s.fetchChannels); + const fetchChannelIds = useChannelsStore((s) => s.fetchChannelIds); const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists); const fetchEPGData = useEPGsStore((s) => s.fetchEPGData); const fetchCategories = useVODStore((s) => s.fetchCategories); @@ -69,7 +69,7 @@ export default function M3URefreshNotification() { } // Update notification status - setNotificationStatus(prev => ({ + setNotificationStatus((prev) => ({ ...prev, [data.account]: data, })); @@ -134,7 +134,7 @@ export default function M3URefreshNotification() { if (action == 'parsing') { fetchStreams(); API.requeryChannels(); - fetchChannels(); + fetchChannelIds(); } else if (action == 'processing_groups') { fetchStreams(); fetchChannelGroups(); @@ -148,9 +148,10 @@ export default function M3URefreshNotification() { const handleProgressNotification = (playlist, data) => { const baseMessage = getActionMessage(data.action); - const message = data.progress == 0 - ? `${baseMessage} starting...` - : `${baseMessage} complete!`; + const message = + data.progress == 0 + ? `${baseMessage} starting...` + : `${baseMessage} complete!`; if (data.progress == 100) { triggerPostCompletionFetches(data.action); diff --git a/frontend/src/components/__tests__/M3URefreshNotification.test.jsx b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx index 14be3a7f..2c5a5572 100644 --- a/frontend/src/components/__tests__/M3URefreshNotification.test.jsx +++ b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx @@ -61,9 +61,7 @@ vi.mock('lucide-react', () => ({ })); const renderWithProviders = (component) => { - return render( - {component} - ); + return render({component}); }; describe('M3URefreshNotification', () => { @@ -96,7 +94,7 @@ describe('M3URefreshNotification', () => { mockChannelsStore = { fetchChannelGroups: vi.fn(), - fetchChannels: vi.fn(), + fetchChannelIds: vi.fn(), }; mockEPGsStore = { @@ -107,9 +105,15 @@ describe('M3URefreshNotification', () => { fetchCategories: vi.fn(), }; - usePlaylistsStore.mockImplementation((selector) => selector(mockPlaylistsStore)); - useStreamsStore.mockImplementation((selector) => selector(mockStreamsStore)); - useChannelsStore.mockImplementation((selector) => selector(mockChannelsStore)); + usePlaylistsStore.mockImplementation((selector) => + selector(mockPlaylistsStore) + ); + useStreamsStore.mockImplementation((selector) => + selector(mockStreamsStore) + ); + useChannelsStore.mockImplementation((selector) => + selector(mockChannelsStore) + ); useEPGsStore.mockImplementation((selector) => selector(mockEPGsStore)); useVODStore.mockImplementation((selector) => selector(mockVODStore)); }); @@ -231,7 +235,7 @@ describe('M3URefreshNotification', () => { expect(showNotification).toHaveBeenCalled(); expect(mockStreamsStore.fetchStreams).toHaveBeenCalled(); expect(API.requeryChannels).toHaveBeenCalled(); - expect(mockChannelsStore.fetchChannels).toHaveBeenCalled(); + expect(mockChannelsStore.fetchChannelIds).toHaveBeenCalled(); }); }); }); @@ -483,7 +487,7 @@ describe('M3URefreshNotification', () => { rerender( - + ); @@ -569,7 +573,7 @@ describe('M3URefreshNotification', () => { // Re-render with same data rerender( - + ); @@ -606,7 +610,7 @@ describe('M3URefreshNotification', () => { rerender( - + ); @@ -648,7 +652,7 @@ describe('M3URefreshNotification', () => { rerender( - + ); @@ -687,7 +691,7 @@ describe('M3URefreshNotification', () => { rerender( - + ); @@ -704,7 +708,7 @@ describe('M3URefreshNotification', () => { rerender( - + ); }); From 8b332c426b0020dbdde85f5cad4e44fa0806e7ec Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 15:08:53 -0600 Subject: [PATCH 14/53] changelog: Update changelog for refactor PR. --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ca4ead..6e956bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. — Thanks [@nick4810](https://github.com/nick4810) - Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. - New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". - Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) @@ -25,6 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810) + - `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`). + - `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component. + - `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`). + - `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`). + - `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs. + - `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting. + - Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element. + - EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data. ### Fixed From ba38dc1cd4f7e7ace933498e5f5fbcb75671b0a1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 15:12:28 -0600 Subject: [PATCH 15/53] changelog: Removed accidental line break. --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e956bd3..42f2f89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs. - `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting. - Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element. - - EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data. ### Fixed From 4f41c287aca6f401ff508f37e873a0222e964d72 Mon Sep 17 00:00:00 2001 From: None Date: Fri, 6 Mar 2026 15:17:32 -0600 Subject: [PATCH 16/53] Fix Redis flush and wait_for_redis in modular mode - Move modular Redis wait from uWSGI exec-pre to entrypoint (exec-pre runs under 'su -' which strips Docker env vars, so DISPATCHARR_ENV and REDIS_HOST were never available) - Selective flush in modular mode: clears stale app state (stream locks, proxy metadata) while preserving Celery broker/result keys - AIO mode unchanged: full flushdb via uWSGI exec-pre - Update unit tests for both flush paths --- docker/entrypoint.sh | 24 +++++++++--------------- docker/uwsgi.modular.ini | 4 ++-- scripts/wait_for_redis.py | 31 +++++++++++++++++++++++++++++++ tests/test_wait_for_redis.py | 31 ++++++++++++++++++++++++------- 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b79af952..693c25d0 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -175,23 +175,17 @@ else check_external_postgres_version || exit 1 fi -# Wait for Redis to be ready (modular mode uses external Redis) +# Wait for Redis to be ready and flush stale state. +# In modular mode Redis is external — call wait_for_redis.py here +# because uWSGI's exec-pre runs under 'su -' which strips env vars +# (DISPATCHARR_ENV, REDIS_HOST, etc.). +# In AIO mode Redis is started by uWSGI (attach-daemon), so the +# exec-pre in uwsgi.ini handles the wait + flush there instead. if [[ "$DISPATCHARR_ENV" == "modular" ]]; then echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}" - echo_with_timestamp "Waiting for external Redis to be ready..." - until python3 -c " -import socket, sys -try: - s = socket.create_connection(('${REDIS_HOST}', ${REDIS_PORT}), timeout=2) - s.close() - sys.exit(0) -except Exception: - sys.exit(1) -" 2>/dev/null; do - echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..." - sleep 1 - done - echo "✅ External Redis is ready" + echo_with_timestamp "Waiting for Redis to be ready..." + python3 /app/scripts/wait_for_redis.py + echo "✅ Redis is ready" fi # Ensure database encoding is UTF8 (handles both internal and external databases) diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini index 3220a8d8..57ecaea8 100644 --- a/docker/uwsgi.modular.ini +++ b/docker/uwsgi.modular.ini @@ -5,8 +5,8 @@ ; exec-pre = touch /data/logs/uwsgi.log ; exec-pre = chmod 666 /data/logs/uwsgi.log -; First run Redis availability check script once -exec-pre = python /app/scripts/wait_for_redis.py +; Redis wait + flush is handled by the entrypoint in modular mode +; (uWSGI exec-pre runs under 'su -' which strips Docker env vars) ; Start Daphne for WebSocket support (required for real-time features) ; Redis and Celery run in separate containers in modular mode diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py index 41b66757..e1814411 100644 --- a/scripts/wait_for_redis.py +++ b/scripts/wait_for_redis.py @@ -12,6 +12,28 @@ import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +# Key prefixes used by Celery's broker (Kombu) and result backend. +# These must be preserved in modular mode where Celery runs independently. +_CELERY_KEY_PREFIXES = ('celery', '_kombu', 'unacked') + + +def _flush_non_celery_keys(client): + """Delete all Redis keys except those belonging to Celery.""" + cursor = '0' + deleted = 0 + while True: + cursor, keys = client.scan(cursor=cursor, count=500) + to_delete = [ + k for k in keys + if not k.decode('utf-8', errors='replace').startswith(_CELERY_KEY_PREFIXES) + ] + if to_delete: + deleted += client.delete(*to_delete) + if cursor == 0: + break + logger.info(f"Modular mode: selectively cleared {deleted} non-Celery Redis key(s)") + + def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2): """Wait for Redis to become available""" redis_client = None @@ -31,6 +53,15 @@ def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', socket_connect_timeout=2 ) redis_client.ping() + # Clear stale state on startup. In AIO mode, every service restarts + # together so a full flush is safe. In modular mode, Celery has its + # own lifecycle — preserve its broker/result keys and only wipe + # application state (stream locks, proxy metadata, etc.). + if os.environ.get('DISPATCHARR_ENV') == 'modular': + _flush_non_celery_keys(redis_client) + else: + redis_client.flushdb() + logger.info(f"Flushed Redis database") logger.info(f"✅ Redis at {host}:{port}/{db} is now available!") return True except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e: diff --git a/tests/test_wait_for_redis.py b/tests/test_wait_for_redis.py index 55e623eb..0dd02ba1 100644 --- a/tests/test_wait_for_redis.py +++ b/tests/test_wait_for_redis.py @@ -21,23 +21,40 @@ class WaitForRedisTests(SimpleTestCase): """ Tests for scripts/wait_for_redis.py. - The critical regression test is test_successful_connection_does_not_call_flushdb - which ensures the removed flushdb() call is never re-added. + Verifies flush behaviour: full flushdb in AIO mode, selective + (non-Celery) key deletion in modular mode. """ @patch('wait_for_redis.redis.Redis') - def test_successful_connection_does_not_call_flushdb(self, mock_redis_cls): - """After connecting successfully, flushdb must NOT be called.""" + def test_aio_mode_calls_flushdb(self, mock_redis_cls): + """In AIO mode (default), flushdb is called after successful ping.""" mock_client = MagicMock() mock_client.ping.return_value = True mock_redis_cls.return_value = mock_client - wait_for_redis = _import_wait_for_redis() - result = wait_for_redis(max_retries=1, retry_interval=0) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop('DISPATCHARR_ENV', None) + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=1, retry_interval=0) + + self.assertTrue(result) + mock_client.flushdb.assert_called_once() + + @patch('wait_for_redis._flush_non_celery_keys') + @patch('wait_for_redis.redis.Redis') + def test_modular_mode_does_not_call_flushdb(self, mock_redis_cls, mock_selective): + """In modular mode, flushdb must NOT be called — selective flush instead.""" + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_cls.return_value = mock_client + + with patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular'}): + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=1, retry_interval=0) self.assertTrue(result) - mock_client.ping.assert_called_once() mock_client.flushdb.assert_not_called() + mock_selective.assert_called_once_with(mock_client) @patch('wait_for_redis.redis.Redis') def test_retries_on_connection_error(self, mock_redis_cls): From 846ae598fc675b8246f61aaffa6852093574a7de Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 15:21:23 -0600 Subject: [PATCH 17/53] Tests: Fix errorboundary and floatingvideo tests. --- .../__tests__/ErrorBoundary.test.jsx | 8 +- .../__tests__/FloatingVideo.test.jsx | 259 ++++++++++-------- 2 files changed, 146 insertions(+), 121 deletions(-) diff --git a/frontend/src/components/__tests__/ErrorBoundary.test.jsx b/frontend/src/components/__tests__/ErrorBoundary.test.jsx index bab0778d..76c51110 100644 --- a/frontend/src/components/__tests__/ErrorBoundary.test.jsx +++ b/frontend/src/components/__tests__/ErrorBoundary.test.jsx @@ -34,7 +34,7 @@ describe('ErrorBoundary', () => { ); - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText(/Something went wrong/)).toBeInTheDocument(); expect(screen.queryByText('Child component')).not.toBeInTheDocument(); }); @@ -72,7 +72,7 @@ describe('ErrorBoundary', () => { ); - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText(/Something went wrong/)).toBeInTheDocument(); }); it('should have hasError state set to true after catching error', () => { @@ -83,7 +83,9 @@ describe('ErrorBoundary', () => { ); // Verify error boundary rendered fallback UI - expect(container.querySelector('div')).toHaveTextContent('Something went wrong'); + expect(container.querySelector('div')).toHaveTextContent( + 'Something went wrong' + ); }); it('should have hasError state set to false initially', () => { diff --git a/frontend/src/components/__tests__/FloatingVideo.test.jsx b/frontend/src/components/__tests__/FloatingVideo.test.jsx index 9c537d2f..63f3ee3d 100644 --- a/frontend/src/components/__tests__/FloatingVideo.test.jsx +++ b/frontend/src/components/__tests__/FloatingVideo.test.jsx @@ -90,32 +90,36 @@ describe('FloatingVideo', () => { }); it('should not render when streamUrl is null', () => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: null, - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: null, + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); const { container } = render(); expect(container.firstChild).toBeNull(); }); it('should render when isVisible is true and streamUrl is provided', () => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/stream', - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); render(); expect(screen.getByTestId('close-button')).toBeInTheDocument(); @@ -124,16 +128,18 @@ describe('FloatingVideo', () => { describe('Live Stream Player', () => { beforeEach(() => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/stream.ts', - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); }); it('should initialize mpegts player for live streams', () => { @@ -198,20 +204,22 @@ describe('FloatingVideo', () => { describe('VOD Player', () => { beforeEach(() => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/video.mp4', - contentType: 'vod', - metadata: { - name: 'Test Movie', - year: '2024', - logo: { url: 'http://example.com/poster.jpg' }, - }, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/video.mp4', + contentType: 'vod', + metadata: { + name: 'Test Movie', + year: '2024', + logo: { url: 'http://example.com/poster.jpg' }, + }, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); }); it('should use native video player for VOD', () => { @@ -234,7 +242,9 @@ describe('FloatingVideo', () => { // Simulate video loaded event to clear loading state fireEvent.loadedData(video); - expect(screen.getByText('Test Movie')).toBeInTheDocument(); + expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual( + 1 + ); expect(screen.getByText('2024')).toBeInTheDocument(); }); @@ -247,13 +257,15 @@ describe('FloatingVideo', () => { fireEvent.loadedData(video); fireEvent.canPlay(video); - expect(screen.getByText('Test Movie')).toBeInTheDocument(); - + expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual( + 1 + ); vi.advanceTimersByTime(4000); waitFor(() => { - expect(screen.queryByText('Test Movie')).not.toBeInTheDocument(); + // After overlay hides, only the header title remains + expect(screen.getAllByText('Test Movie').length).toBe(1); }); vi.useRealTimers(); @@ -266,11 +278,13 @@ describe('FloatingVideo', () => { fireEvent.loadedData(video); fireEvent.canPlay(video); - const videoContainer = screen.getByText('Test Movie').closest('div'); + const videoContainer = video.parentElement; fireEvent.mouseEnter(videoContainer); - expect(screen.getByText('Test Movie')).toBeInTheDocument(); + expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual( + 1 + ); }); it('should hide overlay on mouse leave', () => { @@ -282,7 +296,7 @@ describe('FloatingVideo', () => { fireEvent.loadedData(video); fireEvent.canPlay(video); - const videoContainer = screen.getByText('Test Movie').closest('div'); + const videoContainer = video.parentElement; fireEvent.mouseEnter(videoContainer); fireEvent.mouseLeave(videoContainer); @@ -290,7 +304,8 @@ describe('FloatingVideo', () => { vi.advanceTimersByTime(4000); waitFor(() => { - expect(screen.queryByText('Test Movie')).not.toBeInTheDocument(); + // After overlay hides, only the header title remains + expect(screen.getAllByText('Test Movie').length).toBe(1); }); vi.useRealTimers(); @@ -299,16 +314,18 @@ describe('FloatingVideo', () => { describe('Close functionality', () => { beforeEach(() => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/stream.ts', - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); }); it('should call hideVideo when close button is clicked', () => { @@ -331,16 +348,18 @@ describe('FloatingVideo', () => { describe('Error handling', () => { beforeEach(() => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/video.mp4', - contentType: 'vod', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/video.mp4', + contentType: 'vod', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); }); it('should display video error messages', () => { @@ -354,9 +373,7 @@ describe('FloatingVideo', () => { fireEvent.error(video); - expect( - screen.getByText(/MEDIA_ERR_DECODE/i) - ).toBeInTheDocument(); + expect(screen.getByText(/MEDIA_ERR_DECODE/i)).toBeInTheDocument(); }); it('should handle network errors', () => { @@ -370,24 +387,24 @@ describe('FloatingVideo', () => { fireEvent.error(video); - expect( - screen.getByText(/MEDIA_ERR_NETWORK/i) - ).toBeInTheDocument(); + expect(screen.getByText(/MEDIA_ERR_NETWORK/i)).toBeInTheDocument(); }); }); describe('Player cleanup', () => { it('should cleanup player on unmount', () => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/stream.ts', - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); const { unmount } = render(); @@ -397,29 +414,33 @@ describe('FloatingVideo', () => { }); it('should cleanup player when streamUrl changes', () => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/stream1.ts', - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream1.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); const { rerender } = render(); - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/stream2.ts', - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream2.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); rerender(); @@ -429,16 +450,18 @@ describe('FloatingVideo', () => { describe('Resize functionality', () => { beforeEach(() => { - useVideoStore.mockImplementation((selector) => { { - const state = { - isVisible: true, - streamUrl: 'http://example.com/stream.ts', - contentType: 'live', - metadata: null, - hideVideo: mockHideVideo, - }; - return selector ? selector(state) : state; - }}); + useVideoStore.mockImplementation((selector) => { + { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + } + }); }); it('should render resize handles', () => { From bb2c3c228d0b14924f26a8bbe14bddafa6b81a8f Mon Sep 17 00:00:00 2001 From: None Date: Fri, 6 Mar 2026 15:27:19 -0600 Subject: [PATCH 18/53] Fix stale env vars in profile.d on container restart - Rewrite /etc/profile.d/dispatcharr.sh on every startup instead of only on first run, so container restarts with changed env vars pick up new values - Quote exported values to prevent breakage from special characters in POSTGRES_PASSWORD or other vars - Update /etc/environment entries instead of skipping if already present --- docker/entrypoint.sh | 52 ++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 693c25d0..7d2a1bf6 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -99,31 +99,35 @@ echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'" # READ-ONLY - don't let users change these export POSTGRES_DIR=/data/db -# Global variables, stored so other users inherit them -if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then - # Define all variables to process - variables=( - PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE - POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT - DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT - DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH - CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY - ) +# Global variables, stored so other users inherit them. +# Rewritten every startup so that container restarts with changed env vars +# pick up the new values (not stale ones from a previous run). +# Define all variables to process +variables=( + PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE + POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT + DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT + DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH + CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY +) - # Process each variable for both profile.d and environment - for var in "${variables[@]}"; do - # Check if the variable is set in the environment - if [ -n "${!var+x}" ]; then - # Add to profile.d - echo "export ${var}=${!var}" >> /etc/profile.d/dispatcharr.sh - # Add to /etc/environment if not already there - grep -q "^${var}=" /etc/environment || echo "${var}=${!var}" >> /etc/environment - else - echo "Warning: Environment variable $var is not set" - fi - done -fi +# Truncate files before rewriting +> /etc/profile.d/dispatcharr.sh + +# Process each variable for both profile.d and environment +for var in "${variables[@]}"; do + # Check if the variable is set in the environment + if [ -n "${!var+x}" ]; then + # Add to profile.d (quoted to handle special characters in values) + echo "export ${var}='${!var}'" >> /etc/profile.d/dispatcharr.sh + # Add/update in /etc/environment + sed -i "/^${var}=/d" /etc/environment + echo "${var}='${!var}'" >> /etc/environment + else + echo "Warning: Environment variable $var is not set" + fi +done chmod +x /etc/profile.d/dispatcharr.sh From cf3f562cabc6b0c0bb8504b9329f197fb52a2c73 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 15:47:37 -0600 Subject: [PATCH 19/53] fix: destructure props in M3uSetupSuccess component --- frontend/src/components/M3URefreshNotification.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 263cb46c..bd6319bf 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom'; import { CircleCheck } from 'lucide-react'; import { showNotification } from '../utils/notificationUtils.js'; -const M3uSetupSuccess = (data) => { +const M3uSetupSuccess = ({ data }) => { const navigate = useNavigate(); const onClickRefresh = () => { From 658c7744f7148cc88a017fcaab338a39831fd6bb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 16:21:24 -0600 Subject: [PATCH 20/53] changelog: Update changelog for modular hardening PR. --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f2f89b..d6d432d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. — Thanks [@nick4810](https://github.com/nick4810) +- Unit tests for DVR port resolution (`build_dvr_candidates`) and selective Redis flush behavior in modular mode. — Thanks [@CodeBormen](https://github.com/CodeBormen) - Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. - New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". - Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) @@ -88,6 +89,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Duplicate series rule evaluation race**: Creating a series rule fired `evaluate_series_rules.delay()` in the API view while the frontend immediately called the synchronous evaluate endpoint, racing to create duplicate recordings for the same program. Removed the redundant async call from the API; the frontend's explicit evaluate call is now the sole evaluation path. - **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card. - **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently. +- Modular mode deployment hardening — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. + - **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`. + - **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode — clearing stale stream locks, proxy metadata, and server-state keys — while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed. + - **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present. + - **Stale environment variables after container restart**: `/etc/profile.d/dispatcharr.sh` was only written on the first container run; restarts with changed env vars (e.g. a rotated `POSTGRES_PASSWORD`) retained stale values. The file is now truncated and rewritten on every startup. `/etc/environment` entries are likewise updated rather than skipped when a key already exists. All exported values are now quoted to prevent breakage from special characters. + - **Celery entrypoint startup timeouts**: The JWT key wait and migration wait loops had no timeout, leaving the Celery worker hanging indefinitely if the web container was stuck. Each loop now times out (120 s for JWT, 300 s for migrations) and exits with a clear diagnostic message. The migration readiness check is also replaced from a fragile `showmigrations | grep` pattern to `migrate --check`, which exits cleanly on both unapplied migrations and connection errors. + - **Service startup ordering**: `depends_on` entries for `db` and `redis` in `docker-compose.yml` upgraded from plain name-link ordering to `condition: service_healthy`, ensuring containers wait for actual readiness signals before starting. + - **`host.docker.internal` resolution on Linux**: Added `extra_hosts: host.docker.internal:host-gateway` to the web service in `docker-compose.yml` so Linux hosts resolve `host.docker.internal` the same way Docker Desktop does on macOS/Windows. ## [0.20.2] - 2026-03-03 From ebd112eeb2ac583f4b825d6e56b82c1e3120f057 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 16:29:55 -0600 Subject: [PATCH 21/53] changelog: Added reference to issue for PG version check. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6d432d3..17836b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card. - **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently. - Modular mode deployment hardening — Thanks [@CodeBormen](https://github.com/CodeBormen) - - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. + - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. (Fixes #1045) - **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`. - **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode — clearing stale stream locks, proxy metadata, and server-state keys — while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed. - **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present. From f53dd7e01655ee0963ad9518399e266a90ce20f1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 17:21:07 -0600 Subject: [PATCH 22/53] Revert "fix: add configurable uwsgi_read_timeout to fix 504 timeout on large M3U imports (fixes #745)" This reverts commit 6f5491098697d0fb94de948c873da41b1507bd54. --- docker/entrypoint.sh | 1 - docker/init/03-init-dispatcharr.sh | 1 - docker/nginx.conf | 1 - 3 files changed, 3 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index dc36421a..b79af952 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -41,7 +41,6 @@ export REDIS_DB=${REDIS_DB:-0} export REDIS_PASSWORD=${REDIS_PASSWORD:-} export REDIS_USER=${REDIS_USER:-} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} -export NGINX_UWSGI_TIMEOUT=${NGINX_UWSGI_TIMEOUT:-300} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' export LD_LIBRARY_PATH='/usr/local/lib' export SECRET_FILE="/data/jwt" diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 186831ea..0c317017 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -36,7 +36,6 @@ if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then DISPATCHARR_PORT=9191 fi sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default -sed -i "s/NGINX_UWSGI_TIMEOUT/${NGINX_UWSGI_TIMEOUT}/g" /etc/nginx/sites-enabled/default # Configure nginx based on IPv6 availability if ip -6 addr show | grep -q "inet6"; then diff --git a/docker/nginx.conf b/docker/nginx.conf index c8ee3bc1..e08d08f2 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,7 +21,6 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; - uwsgi_read_timeout NGINX_UWSGI_TIMEOUT; } location /assets/ { From 7e5e539aac612f5e5e4af5fbed067f988970ce92 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 6 Mar 2026 17:24:54 -0600 Subject: [PATCH 23/53] changelog: add entry for bulk M3U group settings upsert fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17836b5d..4d78f782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) — Thanks [@nickgerrer](https://github.com/nickgerrer) - Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` → `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek) - `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek) - Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding. From 9876a25e628453cd88195a0feb280ac4e99b84bc Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 7 Mar 2026 18:33:17 -0600 Subject: [PATCH 24/53] Bug Fix: XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream. --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d78f782..ed606809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream. - 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) — Thanks [@nickgerrer](https://github.com/nickgerrer) - Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` → `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek) - `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 162359e9..fc8dc756 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -751,6 +751,14 @@ def collect_xc_streams(account_id, enabled_groups): # Filter streams based on enabled categories filtered_count = 0 for stream in all_xc_streams: + # Fall back to a generated name if the provider returns null/empty + stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + if not stream.get("name"): + logger.warning( + f"XC stream has null/empty name; using generated name '{stream_name}' " + f"(stream_id={stream.get('stream_id', 'unknown')})" + ) + # Get the category_id for this stream category_id = str(stream.get("category_id", "")) @@ -760,7 +768,7 @@ def collect_xc_streams(account_id, enabled_groups): # Convert XC stream to our standard format with all properties preserved stream_data = { - "name": stream["name"], + "name": stream_name, "url": xc_client.get_stream_url(stream["stream_id"]), "attributes": { "tvg-id": stream.get("epg_channel_id", ""), @@ -844,7 +852,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): ) for stream in streams: - name = stream["name"] + name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + if not stream.get("name"): + logger.warning( + f"XC stream has null/empty name in category {group_name}; " + f"using generated name '{name}' (stream_id={stream.get('stream_id', 'unknown')})" + ) raw_stream_id = stream.get("stream_id", "") provider_stream_id = None if raw_stream_id: From c0d70767e67b13b7db5077487b80b74770684ffa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 7 Mar 2026 18:39:41 -0600 Subject: [PATCH 25/53] changelog: Add credit to @patchy8736 for contributions that came from his closed PR. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed606809..7e46e6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,12 +53,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `CELERY_WORKER_MAX_MEMORY_PER_CHILD = 512 MB` as a safety net against pymalloc arena fragmentation - EPG output was filtering programs using `start_time__gte=now` when the `days` parameter was specified, which caused currently-airing programs (started before the request time but not yet ended) to be omitted from the XML output. This produced a gap in clients' guides immediately after an EPG refresh, lasting until the next program started. Fixed by changing the filter to `end_time__gte=now` so any program that has not yet finished is included. - TS proxy connection slot leaks and TOCTOU races in stream initialization (Fixes #947) - Thanks [@CodeBormen](https://github.com/CodeBormen) - - **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`→check→`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity. + - **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`→check→`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity. — Thanks [@patchy8736](https://github.com/patchy8736) - **Leak on URL generation failure**: `generate_stream_url()` called `get_stream()` (which `INCR`s the counter) but had no cleanup path if subsequent DB lookups or URL construction failed. The post-`get_stream()` block is now wrapped in a `try/except` that calls `release_stream()` on any error. - **Leak on retry-loop timeout**: the retry loop in `stream_ts()` called a bare `get_stream()` on the first failure to classify the error reason. If a slot was available, this `INCR`'d the counter and set Redis keys that were never released when the loop timed out. A `release_stream()` call is now issued before returning 503. - **Leak on `initialize_channel()` failure**: when `initialize_channel()` returned `False`, the connection slot allocated by the preceding `get_stream()` was never released. A `connection_allocated` flag now tracks whether this request performed the `INCR` (fresh initialization vs. joining an existing channel), and `release_stream()` is called guarded by that flag to prevent incorrect decrements when attaching to an already-running channel. - **Safety net for unexpected exceptions**: the outer `except` in `stream_ts()` now checks `connection_allocated` and calls `release_stream()` as a last-resort cleanup for any unhandled exception that escapes before the channel is handed off to the stream lifecycle. - - **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls. + - **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls. — Thanks [@patchy8736](https://github.com/patchy8736) - **`update_stream_profile()` uses a Redis pipeline** for the old-profile DECR + key update + new-profile INCR sequence, preventing counter drift if the process crashes between operations. - **`stream_generator._cleanup()`** now falls back to `Stream.objects.get()` when the channel UUID resolves to a preview flow rather than a normal channel, rather than silently skipping the slot release. - **VOD `cleanup_persistent_connection()`** fallback DECR is now conditional: it only decrements the profile counter when the connection tracking key had already expired by TTL (i.e., `remove_connection()` would have skipped the DECR), preventing double-decrements when the key is still present. From 753bc1a8934737b295fcca6ce5a1412929ee00c8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 11:46:31 -0500 Subject: [PATCH 26/53] fix(stream-preview): restore stream_name and stream_stats for stream previews Stream.get_stream() was never called in the preview path of generate_stream_url(), so the channel_stream and stream_profile Redis keys were never written. This caused the stream_id to be missing from channel metadata, which silently broke two things: - Stats page showed no stream name or logo - stream_stats were never saved to the database Fix: replace the manual profile-selection loop in the preview path with a direct call to Stream.get_stream(), matching the channel path exactly. This also fixes a pre-existing non-atomic slot reservation (read-then-check vs INCR-first) that could over-allocate connections on concurrent previews. Regression introduced in 49b7b9e2. --- apps/proxy/ts_proxy/url_utils.py | 86 +++++++++----------------------- 1 file changed, 23 insertions(+), 63 deletions(-) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index fe06ad48..14a714ea 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -39,84 +39,44 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] # Handle direct stream preview (custom streams) if isinstance(channel_or_stream, Stream): - from core.utils import RedisClient - stream = channel_or_stream logger.info(f"Previewing stream directly: {stream.id} ({stream.name})") - # For custom streams, we need to get the M3U account and profile - m3u_account = stream.m3u_account - if not m3u_account: + if not stream.m3u_account: logger.error(f"Stream {stream.id} has no M3U account") return None, None, False, None - # Get active profiles for this M3U account - m3u_profiles = m3u_account.profiles.filter(is_active=True) - default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) - - if not default_profile: - logger.error(f"No default active profile found for M3U account {m3u_account.id}") + # Use get_stream() to atomically reserve a slot and write the + # channel_stream / stream_profile Redis keys, matching the channel + # path so stream_name and stream_stats work correctly. + stream_id, profile_id, error_reason = stream.get_stream() + if not stream_id or not profile_id: + logger.error(f"No profile available for stream {stream.id}: {error_reason}") return None, None, False, None - # Check profiles in order: default first, then others - profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] + try: + profile = M3UAccountProfile.objects.get(id=profile_id) + m3u_account = stream.m3u_account - # Try to find an available profile with connection capacity - redis_client = RedisClient.get_client() - selected_profile = None + stream_user_agent = m3u_account.get_user_agent().user_agent + if stream_user_agent is None: + stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) + logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - for profile in profiles: - logger.info(profile) + stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern) - # Check connection availability - if redis_client: - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) + stream_profile = stream.get_stream_profile() + logger.debug(f"Using stream profile: {stream_profile.name}") - # Check if profile has available slots (or unlimited connections) - if profile.max_streams == 0 or current_connections < profile.max_streams: - selected_profile = profile - logger.debug(f"Selected profile {profile.id} with {current_connections}/{profile.max_streams} connections for stream preview") - break - else: - logger.debug(f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}") - else: - # No Redis available, use first active profile - selected_profile = profile - break + transcode = not stream_profile.is_proxy() + stream_profile_id = stream_profile.id - if not selected_profile: - logger.error(f"No profiles available with connection capacity for M3U account {m3u_account.id}") + return stream_url, stream_user_agent, transcode, stream_profile_id + except Exception as e: + logger.error(f"Error generating stream URL for stream {stream.id}: {e}") + stream.release_stream() return None, None, False, None - # Get the appropriate user agent - stream_user_agent = m3u_account.get_user_agent().user_agent - if stream_user_agent is None: - stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) - logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - - # Get stream URL with the selected profile's URL transformation - stream_url = transform_url(stream.url, selected_profile.search_pattern, selected_profile.replace_pattern) - - # Check if the stream has its own stream_profile set, otherwise use default - if stream.stream_profile: - stream_profile = stream.stream_profile - logger.debug(f"Using stream's own stream profile: {stream_profile.name}") - else: - stream_profile = StreamProfile.objects.get( - id=CoreSettings.get_default_stream_profile_id() - ) - logger.debug(f"Using default stream profile: {stream_profile.name}") - - # Check if transcoding is needed - if stream_profile.is_proxy() or stream_profile is None: - transcode = False - else: - transcode = True - - stream_profile_id = stream_profile.id - - return stream_url, stream_user_agent, transcode, stream_profile_id # Handle channel preview (existing logic) channel = channel_or_stream From dfb91db98753fc225f9fa663ecb25d47bb23e041 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 11:56:52 -0500 Subject: [PATCH 27/53] Bug Fix: VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run. --- CHANGELOG.md | 1 + apps/vod/tasks.py | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e46e6e1..842da260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run. - XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream. - 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) — Thanks [@nickgerrer](https://github.com/nickgerrer) - Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` → `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek) diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 1b12c7b7..ff195fc9 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1645,14 +1645,30 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= orphaned_movie_count = orphaned_movies.count() if orphaned_movie_count > 0: logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations") - orphaned_movies.delete() + try: + orphaned_movies.delete() + except IntegrityError: + # A concurrent refresh task created a new relation for one of these movies + # between our query and the DELETE. Skip and let the next cleanup run handle it. + logger.warning( + "Skipped some orphaned movie deletions due to concurrent modifications; " + "they will be retried on the next cleanup run." + ) + orphaned_movie_count = 0 # Clean up series with no relations (orphaned) orphaned_series = Series.objects.filter(m3u_relations__isnull=True) orphaned_series_count = orphaned_series.count() if orphaned_series_count > 0: logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations") - orphaned_series.delete() + try: + orphaned_series.delete() + except IntegrityError: + logger.warning( + "Skipped some orphaned series deletions due to concurrent modifications; " + "they will be retried on the next cleanup run." + ) + orphaned_series_count = 0 result = (f"Cleaned up {stale_movie_count} stale movie relations, " f"{stale_series_count} stale series relations, " From b61f3f970d23725013f3a48dd5ddeb80f2692ed8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 12:02:28 -0500 Subject: [PATCH 28/53] changelog: Update changelog for parse_is_adult PR. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 842da260..24203d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding. - PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`. - `PATH` in the Celery worker, Celery Beat, and Daphne systemd service files extended to include `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`, fixing failures where background tasks could not locate `ffmpeg` or `ffprobe`. +- `is_adult` field parsing now guards against invalid values (e.g. the string `"None"`) that providers may send instead of a valid integer, preventing a `ValueError` crash during M3U/XC stream refresh. A new `parse_is_adult()` helper wraps the cast in a `try/except`, returning `False` for anything that cannot be interpreted as `1`. (Fixes #1061) — Thanks [@JCBird1012](https://github.com/JCBird1012) - M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012) - Celery worker memory leak during M3U/XC refresh causing 20–80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return) From 76271fc9fddd3fb74a26ddcb7bcfc73737d8865c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 13:38:18 -0500 Subject: [PATCH 29/53] Bug Fix: Version update notification persisting after upgrading to the notified version (e.g. "v0.20.2 available" shown while already running v0.20.2). Root cause: `check_for_version_update.delay()` was called from `AppConfig.ready()`, which fires inside Celery prefork pool subprocesses before the broker connection is established, causing the dispatch to fail silently with no log output. Fixed by moving the startup dispatch to the `worker_ready` signal in `celery.py` (consistent with the existing `recover_recordings_on_startup` pattern), and deleting the stale `version-{current_version}` notification at the top of the production check path so it is cleared even when GitHub is unreachable. A WebSocket update is sent immediately on deletion so the frontend badge clears without waiting for the API response. --- CHANGELOG.md | 1 + core/apps.py | 12 ------------ core/tasks.py | 17 ++++++++++++++++- dispatcharr/celery.py | 7 +++++-- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24203d49..0daba010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Version update notification persisting after upgrading to the notified version (e.g. "v0.20.2 available" shown while already running v0.20.2). Root cause: `check_for_version_update.delay()` was called from `AppConfig.ready()`, which fires inside Celery prefork pool subprocesses before the broker connection is established, causing the dispatch to fail silently with no log output. Fixed by moving the startup dispatch to the `worker_ready` signal in `celery.py` (consistent with the existing `recover_recordings_on_startup` pattern), and deleting the stale `version-{current_version}` notification at the top of the production check path so it is cleared even when GitHub is unreachable. A WebSocket update is sent immediately on deletion so the frontend badge clears without waiting for the API response. - VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run. - XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream. - 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) — Thanks [@nickgerrer](https://github.com/nickgerrer) diff --git a/core/apps.py b/core/apps.py index f2780bd1..ee182e89 100644 --- a/core/apps.py +++ b/core/apps.py @@ -31,7 +31,6 @@ class CoreConfig(AppConfig): return self._sync_developer_notifications() - self._check_version_update() def _sync_developer_notifications(self): """Sync developer notifications from JSON file to database.""" @@ -47,14 +46,3 @@ class CoreConfig(AppConfig): except Exception as e: logger.warning(f"Failed to sync developer notifications on startup: {e}") - def _check_version_update(self): - """Check for version updates on startup.""" - import logging - - logger = logging.getLogger(__name__) - - try: - from core.tasks import check_for_version_update - check_for_version_update.delay() - except Exception as e: - logger.warning(f"Failed to check for version updates on startup: {e}") diff --git a/core/tasks.py b/core/tasks.py index 44ddc68a..a18c2f74 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -789,6 +789,7 @@ def check_for_version_update(): try: is_dev_build = __timestamp__ is not None DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'} + if is_dev_build: # Check Docker Hub for newer dev builds docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev" @@ -878,7 +879,21 @@ def check_for_version_update(): } ) else: - # Production build - check GitHub for stable releases + # Production build - check GitHub for stable releases. + # Delete any stale notification for the currently running version upfront; + # a "vX is available" notification is meaningless once the user is already on vX. + # Notify the frontend immediately so the badge clears without waiting for the API call. + deleted_count = SystemNotification.objects.filter( + notification_key=f"version-{__version__}", + notification_type='version_update', + ).delete()[0] + if deleted_count > 0: + send_websocket_update( + 'updates', + 'update', + {'success': True, 'type': 'notifications_cleared', 'count': deleted_count} + ) + github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest" headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS} response = requests.get( diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 436ffc35..8ccb3c33 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -54,7 +54,7 @@ app.conf.update( def cleanup_task_memory(**kwargs): """Clean up memory and database connections after each task completes""" from django.db import connection - + # Get task name from kwargs task_name = kwargs.get('task').name if kwargs.get('task') else '' @@ -153,6 +153,9 @@ def setup_celery_logging(**kwargs): @worker_ready.connect def on_worker_ready(**kwargs): - """Resume or finalize interrupted DVR recordings after a worker restart.""" + """Tasks to run once the worker is fully connected and ready.""" from apps.channels.tasks import recover_recordings_on_startup recover_recordings_on_startup.delay() + + from core.tasks import check_for_version_update + check_for_version_update.delay() From edf81ef6c6aefec5f31a631861b05a04b53a4a36 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sat, 31 Jan 2026 13:44:57 -0700 Subject: [PATCH 30/53] Add PATCH method to /me/ endpoint for user preferences - Allow users to update their own profile via PATCH /api/accounts/users/me/ - Supports partial updates to custom_properties (including navOrder) - Add comprehensive test coverage for the endpoint Co-Authored-By: Claude Opus 4.5 --- apps/accounts/api_views.py | 11 ++- apps/accounts/test_user_preferences.py | 127 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 apps/accounts/test_user_preferences.py diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index cf2d9225..fc542dd7 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -280,9 +280,18 @@ class UserViewSet(viewsets.ModelViewSet): @extend_schema( description="Get active user information", ) - @action(detail=False, methods=["get"], url_path="me") + @swagger_auto_schema( + method="patch", + operation_description="Update active user preferences", + ) + @action(detail=False, methods=["get", "patch"], url_path="me") def me(self, request): user = request.user + if request.method == "PATCH": + serializer = UserSerializer(user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) serializer = UserSerializer(user) return Response(serializer.data) diff --git a/apps/accounts/test_user_preferences.py b/apps/accounts/test_user_preferences.py new file mode 100644 index 00000000..55f2a94a --- /dev/null +++ b/apps/accounts/test_user_preferences.py @@ -0,0 +1,127 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +User = get_user_model() + + +class UserPreferencesAPITests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="testuser", + password="testpass123", + user_level=10 + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.me_url = "/api/accounts/users/me/" + + def test_get_me_returns_user_data(self): + """Test GET /me/ returns current user data""" + response = self.client.get(self.me_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["username"], "testuser") + + def test_patch_me_updates_custom_properties(self): + """Test PATCH /me/ updates custom_properties""" + nav_order = ["channels", "vods", "sources", "guide", "dvr", "stats"] + data = { + "custom_properties": { + "navOrder": nav_order + } + } + + response = self.client.patch(self.me_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["custom_properties"]["navOrder"], nav_order) + + # Verify database was updated + self.user.refresh_from_db() + self.assertEqual(self.user.custom_properties["navOrder"], nav_order) + + def test_patch_me_nav_order_persists(self): + """Test navOrder persists and returns correctly""" + nav_order = ["settings", "channels", "vods"] + data = { + "custom_properties": { + "navOrder": nav_order + } + } + + # Update + self.client.patch(self.me_url, data, format="json") + + # Fetch again + response = self.client.get(self.me_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["custom_properties"]["navOrder"], nav_order) + + def test_patch_me_partial_update_preserves_other_properties(self): + """Test partial update doesn't overwrite other custom_properties""" + # Set initial custom_properties + self.user.custom_properties = { + "theme": "dark", + "someOtherSetting": True + } + self.user.save() + + # Update only navOrder + nav_order = ["channels", "vods"] + data = { + "custom_properties": { + "navOrder": nav_order + } + } + + response = self.client.patch(self.me_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Note: JSONField replacement behavior - the entire custom_properties is replaced + # This is expected Django behavior for JSONField + self.assertEqual(response.data["custom_properties"]["navOrder"], nav_order) + + def test_patch_me_with_empty_nav_order(self): + """Test PATCH with empty navOrder array""" + data = { + "custom_properties": { + "navOrder": [] + } + } + + response = self.client.patch(self.me_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["custom_properties"]["navOrder"], []) + + def test_patch_me_updates_first_name(self): + """Test PATCH /me/ can update other fields like first_name""" + data = { + "first_name": "Test" + } + + response = self.client.patch(self.me_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["first_name"], "Test") + + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "Test") + + def test_patch_me_unauthenticated_fails(self): + """Test PATCH /me/ fails for unauthenticated users""" + self.client.logout() + unauthenticated_client = APIClient() + + data = { + "custom_properties": { + "navOrder": ["channels"] + } + } + + response = unauthenticated_client.patch(self.me_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) From 0e34415493ff33352b67d938d44479ce1a38f102 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sat, 31 Jan 2026 13:45:03 -0700 Subject: [PATCH 31/53] Add centralized navigation configuration - Extract nav item definitions into config/navigation.js - Define NAV_ITEMS with id, label, icon, path, adminOnly for each item - Add DEFAULT_ADMIN_ORDER and DEFAULT_USER_ORDER arrays - Create getOrderedNavItems() helper for custom ordering - Handle missing items by appending to end of saved order - Add comprehensive test coverage Co-Authored-By: Claude Opus 4.5 --- .../src/config/__tests__/navigation.test.js | 173 ++++++++++++++++++ frontend/src/config/navigation.js | 144 +++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 frontend/src/config/__tests__/navigation.test.js create mode 100644 frontend/src/config/navigation.js diff --git a/frontend/src/config/__tests__/navigation.test.js b/frontend/src/config/__tests__/navigation.test.js new file mode 100644 index 00000000..9eb11d9f --- /dev/null +++ b/frontend/src/config/__tests__/navigation.test.js @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { + NAV_ITEMS, + DEFAULT_ADMIN_ORDER, + DEFAULT_USER_ORDER, + getOrderedNavItems, +} from '../navigation'; + +describe('navigation config', () => { + describe('NAV_ITEMS', () => { + it('has all expected nav items', () => { + expect(NAV_ITEMS.channels).toBeDefined(); + expect(NAV_ITEMS.vods).toBeDefined(); + expect(NAV_ITEMS.sources).toBeDefined(); + expect(NAV_ITEMS.guide).toBeDefined(); + expect(NAV_ITEMS.dvr).toBeDefined(); + expect(NAV_ITEMS.stats).toBeDefined(); + expect(NAV_ITEMS.plugins).toBeDefined(); + expect(NAV_ITEMS.users).toBeDefined(); + expect(NAV_ITEMS.logos).toBeDefined(); + expect(NAV_ITEMS.settings).toBeDefined(); + }); + + it('has correct adminOnly flags', () => { + expect(NAV_ITEMS.channels.adminOnly).toBe(false); + expect(NAV_ITEMS.guide.adminOnly).toBe(false); + expect(NAV_ITEMS.settings.adminOnly).toBe(false); + + expect(NAV_ITEMS.vods.adminOnly).toBe(true); + expect(NAV_ITEMS.sources.adminOnly).toBe(true); + expect(NAV_ITEMS.dvr.adminOnly).toBe(true); + expect(NAV_ITEMS.stats.adminOnly).toBe(true); + expect(NAV_ITEMS.plugins.adminOnly).toBe(true); + expect(NAV_ITEMS.users.adminOnly).toBe(true); + expect(NAV_ITEMS.logos.adminOnly).toBe(true); + }); + }); + + describe('DEFAULT_ADMIN_ORDER', () => { + it('includes all nav items', () => { + expect(DEFAULT_ADMIN_ORDER).toHaveLength(Object.keys(NAV_ITEMS).length); + Object.keys(NAV_ITEMS).forEach((id) => { + expect(DEFAULT_ADMIN_ORDER).toContain(id); + }); + }); + }); + + describe('DEFAULT_USER_ORDER', () => { + it('only includes non-admin items', () => { + DEFAULT_USER_ORDER.forEach((id) => { + expect(NAV_ITEMS[id].adminOnly).toBe(false); + }); + }); + + it('includes channels, guide, and settings', () => { + expect(DEFAULT_USER_ORDER).toContain('channels'); + expect(DEFAULT_USER_ORDER).toContain('guide'); + expect(DEFAULT_USER_ORDER).toContain('settings'); + }); + }); + + describe('getOrderedNavItems', () => { + it('returns default order when no saved order exists for admin', () => { + const result = getOrderedNavItems(null, true); + + expect(result.map((item) => item.id)).toEqual(DEFAULT_ADMIN_ORDER); + }); + + it('returns default order when no saved order exists for non-admin', () => { + const result = getOrderedNavItems(null, false); + + expect(result.map((item) => item.id)).toEqual(DEFAULT_USER_ORDER); + }); + + it('returns default order when saved order is empty array', () => { + const result = getOrderedNavItems([], true); + + expect(result.map((item) => item.id)).toEqual(DEFAULT_ADMIN_ORDER); + }); + + it('uses custom order when provided', () => { + const customOrder = ['settings', 'channels', 'vods', 'sources', 'guide', 'dvr', 'stats', 'plugins', 'users', 'logos']; + const result = getOrderedNavItems(customOrder, true); + + expect(result.map((item) => item.id)).toEqual(customOrder); + }); + + it('appends missing items to end of saved order', () => { + // Simulate a saved order that is missing some newer items + const savedOrder = ['channels', 'vods', 'sources']; + const result = getOrderedNavItems(savedOrder, true); + + // First items should be in saved order + expect(result[0].id).toBe('channels'); + expect(result[1].id).toBe('vods'); + expect(result[2].id).toBe('sources'); + + // All items should be present + expect(result).toHaveLength(Object.keys(NAV_ITEMS).length); + + // Missing items should be appended at the end + const resultIds = result.map((item) => item.id); + expect(resultIds).toContain('guide'); + expect(resultIds).toContain('settings'); + }); + + it('filters out admin-only items for non-admin users', () => { + const customOrder = ['channels', 'vods', 'sources', 'guide', 'dvr', 'settings']; + const result = getOrderedNavItems(customOrder, false); + + const resultIds = result.map((item) => item.id); + + // Should only include non-admin items + expect(resultIds).toContain('channels'); + expect(resultIds).toContain('guide'); + expect(resultIds).toContain('settings'); + + // Should not include admin-only items + expect(resultIds).not.toContain('vods'); + expect(resultIds).not.toContain('sources'); + expect(resultIds).not.toContain('dvr'); + }); + + it('filters out unknown items from saved order', () => { + const savedOrder = ['channels', 'unknown_item', 'vods', 'invalid', 'settings']; + const result = getOrderedNavItems(savedOrder, true); + + const resultIds = result.map((item) => item.id); + + expect(resultIds).not.toContain('unknown_item'); + expect(resultIds).not.toContain('invalid'); + expect(resultIds).toContain('channels'); + expect(resultIds).toContain('vods'); + expect(resultIds).toContain('settings'); + }); + + it('adds channel badge with correct count', () => { + const channels = { 1: {}, 2: {}, 3: {} }; + const result = getOrderedNavItems(null, true, channels); + + const channelItem = result.find((item) => item.id === 'channels'); + expect(channelItem.badge).toBe('(3)'); + }); + + it('returns items with correct structure', () => { + const result = getOrderedNavItems(null, true); + + result.forEach((item) => { + expect(item).toHaveProperty('id'); + expect(item).toHaveProperty('label'); + expect(item).toHaveProperty('icon'); + expect(item).toHaveProperty('path'); + }); + }); + + it('preserves order when user changes role from admin to non-admin', () => { + // Admin saved a custom order + const adminSavedOrder = ['settings', 'vods', 'channels', 'sources', 'guide', 'dvr', 'stats', 'plugins', 'users', 'logos']; + + // When user is demoted to non-admin, only allowed items should show + const result = getOrderedNavItems(adminSavedOrder, false); + const resultIds = result.map((item) => item.id); + + // Order should be preserved for allowed items + expect(resultIds[0]).toBe('settings'); + expect(resultIds[1]).toBe('channels'); + expect(resultIds[2]).toBe('guide'); + + // Should only have non-admin items + expect(resultIds).toHaveLength(3); + }); + }); +}); diff --git a/frontend/src/config/navigation.js b/frontend/src/config/navigation.js new file mode 100644 index 00000000..003514ee --- /dev/null +++ b/frontend/src/config/navigation.js @@ -0,0 +1,144 @@ +import { + ListOrdered, + Play, + Database, + LayoutGrid, + Settings as LucideSettings, + ChartLine, + Video, + PlugZap, + User, + FileImage, +} from 'lucide-react'; + +export const NAV_ITEMS = { + channels: { + id: 'channels', + label: 'Channels', + icon: ListOrdered, + path: '/channels', + adminOnly: false, + hasBadge: true, + }, + vods: { + id: 'vods', + label: 'VODs', + icon: Video, + path: '/vods', + adminOnly: true, + }, + sources: { + id: 'sources', + label: 'M3U & EPG Manager', + icon: Play, + path: '/sources', + adminOnly: true, + }, + guide: { + id: 'guide', + label: 'TV Guide', + icon: LayoutGrid, + path: '/guide', + adminOnly: false, + }, + dvr: { + id: 'dvr', + label: 'DVR', + icon: Database, + path: '/dvr', + adminOnly: true, + }, + stats: { + id: 'stats', + label: 'Stats', + icon: ChartLine, + path: '/stats', + adminOnly: true, + }, + plugins: { + id: 'plugins', + label: 'Plugins', + icon: PlugZap, + path: '/plugins', + adminOnly: true, + }, + users: { + id: 'users', + label: 'Users', + icon: User, + path: '/users', + adminOnly: true, + }, + logos: { + id: 'logos', + label: 'Logo Manager', + icon: FileImage, + path: '/logos', + adminOnly: true, + }, + settings: { + id: 'settings', + label: 'Settings', + icon: LucideSettings, + path: '/settings', + adminOnly: false, + }, +}; + +export const DEFAULT_ADMIN_ORDER = [ + 'channels', + 'vods', + 'sources', + 'guide', + 'dvr', + 'stats', + 'plugins', + 'users', + 'logos', + 'settings', +]; + +export const DEFAULT_USER_ORDER = [ + 'channels', + 'guide', + 'settings', +]; + +export const getOrderedNavItems = (userOrder, isAdmin, channels = {}) => { + const defaultOrder = isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER; + const allowedItems = isAdmin + ? Object.keys(NAV_ITEMS) + : Object.keys(NAV_ITEMS).filter((id) => !NAV_ITEMS[id].adminOnly); + + let order; + if (userOrder && Array.isArray(userOrder) && userOrder.length > 0) { + // Filter saved order to only include allowed items + const filteredOrder = userOrder.filter((id) => allowedItems.includes(id)); + + // Find any new items that aren't in the saved order and append them + const missingItems = allowedItems.filter((id) => !filteredOrder.includes(id)); + + order = [...filteredOrder, ...missingItems]; + } else { + order = defaultOrder; + } + + return order.map((id) => { + const item = NAV_ITEMS[id]; + if (!item) return null; + + const navItem = { + id: item.id, + label: item.label, + icon: item.icon, + path: item.path, + }; + + // Add badge for channels + if (item.hasBadge && id === 'channels') { + navItem.badge = `(${Object.keys(channels).length})`; + } + + return navItem; + }).filter(Boolean); +}; From e876d6e926d9a4f2ee53a06db2573fccc95ac5ee Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sat, 31 Jan 2026 13:45:07 -0700 Subject: [PATCH 32/53] Add navigation preference methods to auth store - Add updateMe() API method for PATCH requests to /me/ - Add getNavOrder() to retrieve saved navigation order - Add setNavOrder() to persist navigation order changes - Add updateUserPreferences() with optimistic updates and rollback Co-Authored-By: Claude Opus 4.5 --- frontend/src/api.js | 13 +++++++++++++ frontend/src/store/auth.jsx | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/frontend/src/api.js b/frontend/src/api.js index 35233204..369336e8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2899,6 +2899,19 @@ export default class API { return await request(`${host}/api/accounts/users/me/`); } + static async updateMe(data) { + try { + const response = await request(`${host}/api/accounts/users/me/`, { + method: 'PATCH', + body: data, + }); + return response; + } catch (e) { + errorNotification('Failed to update user preferences', e); + throw e; + } + } + static async getUsers() { try { const response = await request(`${host}/api/accounts/users/`); diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index fb002db7..4486fae8 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -30,12 +30,50 @@ const useAuthStore = create((set, get) => ({ username: '', email: '', user_level: '', + custom_properties: {}, }, isLoading: false, error: null, setUser: (user) => set({ user }), + updateUserPreferences: async (preferences) => { + const currentUser = get().user; + const updatedCustomProperties = { + ...currentUser.custom_properties, + ...preferences, + }; + + // Optimistic update + set({ + user: { + ...currentUser, + custom_properties: updatedCustomProperties, + }, + }); + + try { + const response = await API.updateMe({ + custom_properties: updatedCustomProperties, + }); + set({ user: response }); + return response; + } catch (error) { + // Revert on failure + set({ user: currentUser }); + throw error; + } + }, + + getNavOrder: () => { + const user = get().user; + return user?.custom_properties?.navOrder || null; + }, + + setNavOrder: async (navOrder) => { + return await get().updateUserPreferences({ navOrder }); + }, + initData: async () => { // Prevent multiple simultaneous initData calls if (get().isInitializing || get().isInitialized) { From e2205fb454e0d86fa78d55fbc9470cae3b36daf8 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sat, 31 Jan 2026 13:45:13 -0700 Subject: [PATCH 33/53] Add NavOrderForm component for drag-and-drop nav ordering - Drag-and-drop reorderable list using dnd-kit - Auto-saves on drop with optimistic update - Reset to Default button restores role-based defaults - Shows only items available to user's role - Add test coverage for admin and non-admin users Co-Authored-By: Claude Opus 4.5 --- .../forms/settings/NavOrderForm.jsx | 213 ++++++++++++++++++ .../settings/__tests__/NavOrderForm.test.jsx | 190 ++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 frontend/src/components/forms/settings/NavOrderForm.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx diff --git a/frontend/src/components/forms/settings/NavOrderForm.jsx b/frontend/src/components/forms/settings/NavOrderForm.jsx new file mode 100644 index 00000000..9bd56fe2 --- /dev/null +++ b/frontend/src/components/forms/settings/NavOrderForm.jsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Text, + Group, + ActionIcon, + Stack, + useMantineTheme, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { GripVertical } from 'lucide-react'; +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import useAuthStore from '../../../store/auth'; +import { + NAV_ITEMS, + DEFAULT_ADMIN_ORDER, + DEFAULT_USER_ORDER, + getOrderedNavItems, +} from '../../../config/navigation'; +import { USER_LEVELS } from '../../../constants'; + +const DraggableNavItem = ({ item }) => { + const theme = useMantineTheme(); + const { transform, transition, setNodeRef, isDragging, attributes, listeners } = useSortable({ + id: item.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition: transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1 : 0, + position: 'relative', + }; + + const IconComponent = item.icon; + + return ( + + + + + + + {IconComponent && } + + {item.label} + + + + + ); +}; + +const NavOrderForm = ({ active }) => { + const theme = useMantineTheme(); + const user = useAuthStore((s) => s.user); + const getNavOrder = useAuthStore((s) => s.getNavOrder); + const setNavOrder = useAuthStore((s) => s.setNavOrder); + + const isAdmin = user?.user_level >= USER_LEVELS.ADMIN; + const defaultOrder = isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER; + + const [items, setItems] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + useEffect(() => { + if (active) { + const savedOrder = getNavOrder(); + const orderedItems = getOrderedNavItems(savedOrder, isAdmin); + setItems(orderedItems); + } + }, [active, isAdmin, getNavOrder]); + + const handleDragEnd = async ({ active, over }) => { + if (!over || active.id === over.id) return; + + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + const newItems = arrayMove(items, oldIndex, newIndex); + + // Optimistic update + setItems(newItems); + + // Save to backend + setIsSaving(true); + try { + const newOrder = newItems.map((item) => item.id); + await setNavOrder(newOrder); + notifications.show({ + title: 'Navigation', + message: 'Order saved successfully', + color: 'green', + autoClose: 2000, + }); + } catch (error) { + // Revert on failure + const savedOrder = getNavOrder(); + const orderedItems = getOrderedNavItems(savedOrder, isAdmin); + setItems(orderedItems); + notifications.show({ + title: 'Error', + message: 'Failed to save navigation order', + color: 'red', + }); + } finally { + setIsSaving(false); + } + }; + + const handleReset = async () => { + setIsSaving(true); + try { + await setNavOrder(defaultOrder); + const orderedItems = getOrderedNavItems(defaultOrder, isAdmin); + setItems(orderedItems); + notifications.show({ + title: 'Navigation', + message: 'Reset to default order', + color: 'blue', + autoClose: 2000, + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to reset navigation order', + color: 'red', + }); + } finally { + setIsSaving(false); + } + }; + + if (!active) { + return null; + } + + return ( + + + Drag and drop to reorder the sidebar navigation items. + + + + item.id)} + strategy={verticalListSortingStrategy} + > + {items.map((item) => ( + + ))} + + + + + + + + ); +}; + +export default NavOrderForm; diff --git a/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx new file mode 100644 index 00000000..3d60e11c --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx @@ -0,0 +1,190 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import NavOrderForm from '../NavOrderForm'; +import useAuthStore from '../../../../store/auth'; +import { USER_LEVELS } from '../../../../constants'; +import { DEFAULT_ADMIN_ORDER, DEFAULT_USER_ORDER } from '../../../../config/navigation'; + +// Mock dependencies +vi.mock('../../../../store/auth'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock dnd-kit +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }) =>
{children}
, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + useSensor: vi.fn(), + useSensors: vi.fn(() => []), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }) =>
{children}
, + useSortable: () => ({ + transform: null, + transition: null, + setNodeRef: vi.fn(), + isDragging: false, + attributes: {}, + listeners: {}, + }), + arrayMove: vi.fn((arr, from, to) => { + const result = [...arr]; + const [removed] = result.splice(from, 1); + result.splice(to, 0, removed); + return result; + }), + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: vi.fn(() => ''), + }, + }, +})); + +vi.mock('@dnd-kit/modifiers', () => ({ + restrictToVerticalAxis: vi.fn(), +})); + +// Mock Mantine components +vi.mock('@mantine/core', () => ({ + Box: ({ children, ...props }) =>
{children}
, + Button: ({ children, onClick, disabled, ...props }) => ( + + ), + Text: ({ children }) => {children}, + Group: ({ children }) =>
{children}
, + ActionIcon: ({ children, ...props }) => , + Stack: ({ children }) =>
{children}
, + useMantineTheme: () => ({}), +})); + +describe('NavOrderForm', () => { + const mockSetNavOrder = vi.fn(); + const mockGetNavOrder = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSetNavOrder.mockResolvedValue({}); + mockGetNavOrder.mockReturnValue(null); + }); + + describe('Admin User', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + user: { user_level: USER_LEVELS.ADMIN, custom_properties: {} }, + getNavOrder: mockGetNavOrder, + setNavOrder: mockSetNavOrder, + }; + return selector(state); + }); + }); + + it('renders all nav items for admin user', () => { + render(); + + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('VODs')).toBeInTheDocument(); + expect(screen.getByText('M3U & EPG Manager')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('DVR')).toBeInTheDocument(); + expect(screen.getByText('Stats')).toBeInTheDocument(); + expect(screen.getByText('Plugins')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Logo Manager')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('renders reset to default button', () => { + render(); + + expect(screen.getByTestId('reset-button')).toBeInTheDocument(); + expect(screen.getByText('Reset to Default')).toBeInTheDocument(); + }); + + it('does not render when not active', () => { + render(); + + expect(screen.queryByText('Channels')).not.toBeInTheDocument(); + }); + + it('calls setNavOrder when reset button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const resetButton = screen.getByTestId('reset-button'); + await user.click(resetButton); + + await waitFor(() => { + expect(mockSetNavOrder).toHaveBeenCalledWith(DEFAULT_ADMIN_ORDER); + }); + }); + + it('uses saved order when available', () => { + const customOrder = ['settings', 'channels', 'vods', 'sources', 'guide', 'dvr', 'stats', 'plugins', 'users', 'logos']; + mockGetNavOrder.mockReturnValue(customOrder); + + render(); + + // The component should render with custom order + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + }); + + describe('Non-Admin User', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + user: { user_level: USER_LEVELS.USER, custom_properties: {} }, + getNavOrder: mockGetNavOrder, + setNavOrder: mockSetNavOrder, + }; + return selector(state); + }); + }); + + it('renders only non-admin nav items for regular user', () => { + render(); + + // Non-admin items should be visible + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + + // Admin-only items should not be visible + expect(screen.queryByText('VODs')).not.toBeInTheDocument(); + expect(screen.queryByText('M3U & EPG Manager')).not.toBeInTheDocument(); + expect(screen.queryByText('DVR')).not.toBeInTheDocument(); + expect(screen.queryByText('Stats')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugins')).not.toBeInTheDocument(); + expect(screen.queryByText('Users')).not.toBeInTheDocument(); + expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument(); + }); + + it('calls setNavOrder with user default order when reset', async () => { + const user = userEvent.setup(); + render(); + + const resetButton = screen.getByTestId('reset-button'); + await user.click(resetButton); + + await waitFor(() => { + expect(mockSetNavOrder).toHaveBeenCalledWith(DEFAULT_USER_ORDER); + }); + }); + }); +}); From adc28551a6d3b3ae5826faddf2bed2b8810df438 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sat, 31 Jan 2026 13:45:18 -0700 Subject: [PATCH 34/53] Integrate configurable navigation into Settings and Sidebar - Add Navigation accordion section to Settings page - Update Sidebar to use getOrderedNavItems() with user's saved order - Sidebar updates immediately when order changes - Add test coverage for Navigation settings section Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/Sidebar.jsx | 104 ++---------------- frontend/src/pages/Settings.jsx | 15 +++ .../src/pages/__tests__/Settings.test.jsx | 17 +++ 3 files changed, 44 insertions(+), 92 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 0b016e7d..f7484645 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,26 +1,13 @@ -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useMemo } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { copyToClipboard } from '../utils'; import { - ListOrdered, - Play, - Database, - LayoutGrid, - Settings as LucideSettings, Copy, - ChartLine, - Video, - PlugZap, LogOut, - User, - FileImage, - Webhook, - Logs, ChevronDown, ChevronRight, - MonitorCog, - Blocks, } from 'lucide-react'; +import { getOrderedNavItems } from '../config/navigation'; import { Avatar, Group, @@ -43,6 +30,7 @@ import UserForm from './forms/User'; import NotificationCenter from './NotificationCenter'; const NavLink = ({ item, isActive, collapsed }) => { + const IconComponent = item.icon; return ( { to={item.path} className={`navlink ${isActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`} > - {item.icon} + {IconComponent && } {!collapsed && ( { const closeUserForm = () => setUserFormOpen(false); - // Navigation Items - const navItems = - authUser && authUser.user_level == USER_LEVELS.ADMIN - ? [ - { - label: 'Channels', - icon: , - path: '/channels', - badge: `(${Array.isArray(channelIds) ? channelIds.length : 0})`, - }, - { - label: 'VODs', - path: '/vods', - icon: - + )} diff --git a/frontend/src/components/forms/settings/NavOrderForm.jsx b/frontend/src/components/forms/settings/NavOrderForm.jsx index 36f29b7f..beff9e02 100644 --- a/frontend/src/components/forms/settings/NavOrderForm.jsx +++ b/frontend/src/components/forms/settings/NavOrderForm.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Box, Button, @@ -6,7 +6,6 @@ import { Group, ActionIcon, Stack, - useMantineTheme, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { GripVertical, Eye, EyeOff } from 'lucide-react'; @@ -37,7 +36,6 @@ import { import { USER_LEVELS } from '../../../constants'; const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => { - const theme = useMantineTheme(); const { transform, transition, setNodeRef, isDragging, attributes, listeners } = useSortable({ id: item.id, }); @@ -100,12 +98,13 @@ const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => { }; const NavOrderForm = ({ active }) => { - const theme = useMantineTheme(); + // All store selectors grouped together const user = useAuthStore((s) => s.user); const getNavOrder = useAuthStore((s) => s.getNavOrder); const setNavOrder = useAuthStore((s) => s.setNavOrder); const getHiddenNav = useAuthStore((s) => s.getHiddenNav); const toggleNavVisibility = useAuthStore((s) => s.toggleNavVisibility); + const updateUserPreferences = useAuthStore((s) => s.updateUserPreferences); const isAdmin = user?.user_level >= USER_LEVELS.ADMIN; const defaultOrder = isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER; @@ -113,6 +112,10 @@ const NavOrderForm = ({ active }) => { const [items, setItems] = useState([]); const [isSaving, setIsSaving] = useState(false); + // Refs for debouncing + const saveTimeoutRef = useRef(null); + const pendingOrderRef = useRef(null); + const sensors = useSensors( useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), @@ -127,7 +130,57 @@ const NavOrderForm = ({ active }) => { } }, [active, isAdmin, getNavOrder]); - const handleDragEnd = async ({ active, over }) => { + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, []); + + // Debounced save function + const debouncedSave = useCallback(async (newOrder) => { + // Clear any pending save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Store the pending order + pendingOrderRef.current = newOrder; + + // Schedule save after 800ms of inactivity + saveTimeoutRef.current = setTimeout(async () => { + const orderToSave = pendingOrderRef.current; + if (!orderToSave) return; + + setIsSaving(true); + try { + await setNavOrder(orderToSave); + notifications.show({ + title: 'Navigation', + message: 'Order saved successfully', + color: 'green', + autoClose: 2000, + }); + } catch { + // Revert on failure + const savedOrder = getNavOrder(); + const orderedItems = getOrderedNavItems(savedOrder, isAdmin); + setItems(orderedItems); + notifications.show({ + title: 'Error', + message: 'Failed to save navigation order', + color: 'red', + }); + } finally { + setIsSaving(false); + pendingOrderRef.current = null; + } + }, 800); + }, [setNavOrder, getNavOrder, isAdmin]); + + const handleDragEnd = ({ active, over }) => { if (!over || active.id === over.id) return; const oldIndex = items.findIndex((item) => item.id === active.id); @@ -137,35 +190,37 @@ const NavOrderForm = ({ active }) => { // Optimistic update setItems(newItems); - // Save to backend - setIsSaving(true); + // Debounced save to backend + const newOrder = newItems.map((item) => item.id); + debouncedSave(newOrder); + }; + + // Wrapped visibility toggle with error handling + const handleToggleVisibility = useCallback(async (itemId) => { try { - const newOrder = newItems.map((item) => item.id); - await setNavOrder(newOrder); + await toggleNavVisibility(itemId); notifications.show({ title: 'Navigation', - message: 'Order saved successfully', + message: 'Visibility updated', color: 'green', autoClose: 2000, }); - } catch (error) { - // Revert on failure - const savedOrder = getNavOrder(); - const orderedItems = getOrderedNavItems(savedOrder, isAdmin); - setItems(orderedItems); + } catch { notifications.show({ title: 'Error', - message: 'Failed to save navigation order', + message: 'Failed to update visibility', color: 'red', }); - } finally { - setIsSaving(false); } - }; - - const updateUserPreferences = useAuthStore((s) => s.updateUserPreferences); + }, [toggleNavVisibility]); const handleReset = async () => { + // Cancel any pending debounced save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + pendingOrderRef.current = null; + } + setIsSaving(true); try { await updateUserPreferences({ navOrder: defaultOrder, hiddenNav: [] }); @@ -177,7 +232,7 @@ const NavOrderForm = ({ active }) => { color: 'blue', autoClose: 2000, }); - } catch (error) { + } catch { notifications.show({ title: 'Error', message: 'Failed to reset navigation order', @@ -192,6 +247,9 @@ const NavOrderForm = ({ active }) => { return null; } + // Cache hiddenNav before render loop to avoid calling getter N times + const hiddenNav = getHiddenNav(); + return ( @@ -212,9 +270,9 @@ const NavOrderForm = ({ active }) => { ))} diff --git a/frontend/src/config/navigation.js b/frontend/src/config/navigation.js index 003514ee..469e146f 100644 --- a/frontend/src/config/navigation.js +++ b/frontend/src/config/navigation.js @@ -82,6 +82,7 @@ export const NAV_ITEMS = { icon: LucideSettings, path: '/settings', adminOnly: false, + canHide: false, // Settings can never be hidden }, }; @@ -132,10 +133,11 @@ export const getOrderedNavItems = (userOrder, isAdmin, channels = {}) => { label: item.label, icon: item.icon, path: item.path, + canHide: item.canHide, // Include canHide property }; // Add badge for channels - if (item.hasBadge && id === 'channels') { + if (id === 'channels') { navItem.badge = `(${Object.keys(channels).length})`; } diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index be1dbce2..12202b3f 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -84,7 +84,7 @@ const SettingsPage = () => { - {authUser.user_level == USER_LEVELS.ADMIN && ( + {authUser.user_level >= USER_LEVELS.ADMIN && ( <> DVR From 678240da6e3723058f33d53e10c4621c52c3e724 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 15:31:29 -0500 Subject: [PATCH 37/53] Move navigation settings under ui settings --- frontend/src/pages/Settings.jsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 12202b3f..6332493d 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -7,6 +7,7 @@ import { AccordionPanel, Box, Center, + Divider, Text, Loader, } from '@mantine/core'; @@ -69,18 +70,19 @@ const SettingsPage = () => { UI Settings - - - - - Navigation - - - }> - - - + + + + Navigation + + + }> + + + + + + From cde39c0e13825f8c746a748d679eecf2a2f5fb1b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 15:44:04 -0500 Subject: [PATCH 38/53] Fixed logic with default orders. --- apps/accounts/serializers.py | 6 +++--- frontend/src/config/navigation.js | 9 ++++----- frontend/src/pages/Settings.jsx | 4 +++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index f2fb3eed..1f970026 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -15,7 +15,7 @@ MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit def validate_nav_array(value, field_name): - """Validate that a value is an array of strings.""" + """Validate that a value is an array of valid nav item ID strings.""" if not isinstance(value, list): raise serializers.ValidationError(f"{field_name} must be an array") if len(value) > 50: @@ -23,8 +23,8 @@ def validate_nav_array(value, field_name): for item in value: if not isinstance(item, str): raise serializers.ValidationError(f"{field_name} items must be strings") - if len(item) > 50: - raise serializers.ValidationError(f"{field_name} item exceeds maximum length of 50 characters") + if item not in VALID_NAV_ITEM_IDS: + raise serializers.ValidationError(f"'{item}' is not a valid navigation item ID") # 🔹 Fix for Permission serialization diff --git a/frontend/src/config/navigation.js b/frontend/src/config/navigation.js index 469e146f..940afae0 100644 --- a/frontend/src/config/navigation.js +++ b/frontend/src/config/navigation.js @@ -107,17 +107,16 @@ export const DEFAULT_USER_ORDER = [ export const getOrderedNavItems = (userOrder, isAdmin, channels = {}) => { const defaultOrder = isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER; - const allowedItems = isAdmin - ? Object.keys(NAV_ITEMS) - : Object.keys(NAV_ITEMS).filter((id) => !NAV_ITEMS[id].adminOnly); let order; if (userOrder && Array.isArray(userOrder) && userOrder.length > 0) { // Filter saved order to only include allowed items - const filteredOrder = userOrder.filter((id) => allowedItems.includes(id)); + const filteredOrder = userOrder.filter((id) => defaultOrder.includes(id)); // Find any new items that aren't in the saved order and append them - const missingItems = allowedItems.filter((id) => !filteredOrder.includes(id)); + const missingItems = defaultOrder.filter( + (id) => !filteredOrder.includes(id) + ); order = [...filteredOrder, ...missingItems]; } else { diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 6332493d..8bef5b47 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -77,7 +77,9 @@ const SettingsPage = () => { }> - + From a4ad9a9135f1cef8ce98c74fe42860262adb6b6a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 15:48:46 -0500 Subject: [PATCH 39/53] Move python test into tests folder. Fix frontend test after merge changes. --- .../settings/__tests__/NavOrderForm.test.jsx | 36 ++++++++++++++----- .../test_user_preferences.py | 0 2 files changed, 28 insertions(+), 8 deletions(-) rename {apps/accounts => tests}/test_user_preferences.py (100%) diff --git a/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx index 6768e3ad..5cbf603e 100644 --- a/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx @@ -4,7 +4,10 @@ import userEvent from '@testing-library/user-event'; import NavOrderForm from '../NavOrderForm'; import useAuthStore from '../../../../store/auth'; import { USER_LEVELS } from '../../../../constants'; -import { DEFAULT_ADMIN_ORDER, DEFAULT_USER_ORDER } from '../../../../config/navigation'; +import { + DEFAULT_ADMIN_ORDER, + DEFAULT_USER_ORDER, +} from '../../../../config/navigation'; // Mock dependencies vi.mock('../../../../store/auth'); @@ -26,7 +29,9 @@ vi.mock('@dnd-kit/core', () => ({ })); vi.mock('@dnd-kit/sortable', () => ({ - SortableContext: ({ children }) =>
{children}
, + SortableContext: ({ children }) => ( +
{children}
+ ), useSortable: () => ({ transform: null, transition: null, @@ -66,7 +71,9 @@ vi.mock('@mantine/core', () => ({ ), Text: ({ children }) => {children}, Group: ({ children }) =>
{children}
, - ActionIcon: ({ children, ...props }) => , + ActionIcon: ({ children, ...props }) => ( + + ), Stack: ({ children }) =>
{children}
, useMantineTheme: () => ({}), })); @@ -112,9 +119,12 @@ describe('NavOrderForm', () => { expect(screen.getByText('DVR')).toBeInTheDocument(); expect(screen.getByText('Stats')).toBeInTheDocument(); expect(screen.getByText('Plugins')).toBeInTheDocument(); - expect(screen.getByText('Users')).toBeInTheDocument(); - expect(screen.getByText('Logo Manager')).toBeInTheDocument(); - expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('Integrations')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + // Users, Logo Manager, Settings are children of System group, not top-level + expect(screen.queryByText('Users')).not.toBeInTheDocument(); + expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument(); + expect(screen.queryByText('Settings')).not.toBeInTheDocument(); }); it('renders reset to default button', () => { @@ -174,14 +184,24 @@ describe('NavOrderForm', () => { }); it('uses saved order when available', () => { - const customOrder = ['settings', 'channels', 'vods', 'sources', 'guide', 'dvr', 'stats', 'plugins', 'users', 'logos']; + const customOrder = [ + 'guide', + 'channels', + 'vods', + 'sources', + 'dvr', + 'stats', + 'plugins', + 'integrations', + 'system', + ]; mockGetNavOrder.mockReturnValue(customOrder); render(); // The component should render with custom order expect(screen.getByText('Channels')).toBeInTheDocument(); - expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); }); }); diff --git a/apps/accounts/test_user_preferences.py b/tests/test_user_preferences.py similarity index 100% rename from apps/accounts/test_user_preferences.py rename to tests/test_user_preferences.py From 1a9d2e27773e992c1e4e1c303c806476fab56561 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 16:06:24 -0500 Subject: [PATCH 40/53] Remove double error notifications. --- frontend/src/api.js | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index 369336e8..9e4ac982 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2675,11 +2675,14 @@ export default class API { static async extendRecording(id, extraMinutes) { try { - const resp = await request(`${host}/api/channels/recordings/${id}/extend/`, { - method: 'POST', - body: JSON.stringify({ extra_minutes: extraMinutes }), - headers: { 'Content-Type': 'application/json' }, - }); + const resp = await request( + `${host}/api/channels/recordings/${id}/extend/`, + { + method: 'POST', + body: JSON.stringify({ extra_minutes: extraMinutes }), + headers: { 'Content-Type': 'application/json' }, + } + ); return resp; } catch (e) { errorNotification(`Failed to extend recording ${id}`, e); @@ -2900,16 +2903,10 @@ export default class API { } static async updateMe(data) { - try { - const response = await request(`${host}/api/accounts/users/me/`, { - method: 'PATCH', - body: data, - }); - return response; - } catch (e) { - errorNotification('Failed to update user preferences', e); - throw e; - } + return await request(`${host}/api/accounts/users/me/`, { + method: 'PATCH', + body: data, + }); } static async getUsers() { From d5aaa0277c4e1e4b42d77f73e0ec5173d2677309 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 16:08:42 -0500 Subject: [PATCH 41/53] security: Do not allow user to give themselves admin or super_user privileges. --- apps/accounts/api_views.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 66d0fc75..76892929 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -285,6 +285,13 @@ class UserViewSet(viewsets.ModelViewSet): def me(self, request): user = request.user if request.method == "PATCH": + ALLOWED_FIELDS = {"custom_properties", "first_name", "last_name", "email", "password"} + disallowed = set(request.data.keys()) - ALLOWED_FIELDS + if disallowed: + return Response( + {"detail": f"Fields not allowed for self-update: {', '.join(disallowed)}"}, + status=400, + ) serializer = UserSerializer(user, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() From 0070e66dd1afea77a9a7a2b21694ae144567a672 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 16:09:23 -0500 Subject: [PATCH 42/53] Bug Fix: Fix double merge race condition in auth.jsx --- frontend/src/store/auth.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 4beef15e..8f0362c7 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -39,22 +39,22 @@ const useAuthStore = create((set, get) => ({ updateUserPreferences: async (preferences) => { const currentUser = get().user; - const updatedCustomProperties = { - ...currentUser.custom_properties, - ...preferences, - }; // Optimistic update set({ user: { ...currentUser, - custom_properties: updatedCustomProperties, + custom_properties: { + ...currentUser.custom_properties, + ...preferences, + }, }, }); try { + // Send only the delta - backend merges with DB value authoritatively const response = await API.updateMe({ - custom_properties: updatedCustomProperties, + custom_properties: preferences, }); set({ user: response }); return response; From 72e4ce0813c4b88bfae556535c3e6209c030ad34 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 16:10:07 -0500 Subject: [PATCH 43/53] tests: Add a check that a user can not self elevate. --- frontend/src/components/Sidebar.jsx | 5 ++--- tests/test_user_preferences.py | 23 +++++++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index eff8be82..91830574 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -148,12 +148,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const closeUserForm = () => setUserFormOpen(false); - // Get user's saved navigation order and hidden items using store getters - const navOrder = getNavOrder(); - const hiddenNav = getHiddenNav(); const isAdmin = authUser && authUser.user_level >= USER_LEVELS.ADMIN; // Navigation Items - computed from user's saved order, filtered by visibility + const navOrder = getNavOrder(); + const hiddenNav = getHiddenNav(); const navItems = useMemo(() => { const orderedItems = getOrderedNavItems(navOrder, isAdmin, channels); return orderedItems.filter((item) => !hiddenNav.includes(item.id)); diff --git a/tests/test_user_preferences.py b/tests/test_user_preferences.py index 55f2a94a..5749011a 100644 --- a/tests/test_user_preferences.py +++ b/tests/test_user_preferences.py @@ -61,7 +61,7 @@ class UserPreferencesAPITests(TestCase): self.assertEqual(response.data["custom_properties"]["navOrder"], nav_order) def test_patch_me_partial_update_preserves_other_properties(self): - """Test partial update doesn't overwrite other custom_properties""" + """Test partial update merges into existing custom_properties, preserving other keys""" # Set initial custom_properties self.user.custom_properties = { "theme": "dark", @@ -69,7 +69,7 @@ class UserPreferencesAPITests(TestCase): } self.user.save() - # Update only navOrder + # Update only navOrder - send delta, not full object nav_order = ["channels", "vods"] data = { "custom_properties": { @@ -80,9 +80,10 @@ class UserPreferencesAPITests(TestCase): response = self.client.patch(self.me_url, data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - # Note: JSONField replacement behavior - the entire custom_properties is replaced - # This is expected Django behavior for JSONField + # Backend merge semantics: existing keys are preserved self.assertEqual(response.data["custom_properties"]["navOrder"], nav_order) + self.assertEqual(response.data["custom_properties"]["theme"], "dark") + self.assertEqual(response.data["custom_properties"]["someOtherSetting"], True) def test_patch_me_with_empty_nav_order(self): """Test PATCH with empty navOrder array""" @@ -125,3 +126,17 @@ class UserPreferencesAPITests(TestCase): response = unauthenticated_client.patch(self.me_url, data, format="json") self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_patch_me_cannot_escalate_privileges(self): + """Test PATCH /me/ rejects attempts to change user_level or is_staff""" + original_level = self.user.user_level + + data = {"user_level": 99, "is_staff": True, "is_superuser": True} + response = self.client.patch(self.me_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.user.refresh_from_db() + self.assertEqual(self.user.user_level, original_level) + self.assertFalse(self.user.is_staff) + self.assertFalse(self.user.is_superuser) From 63234a4a466c8e6dc5baa3437d08b8c7b4b6eac3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 16:35:34 -0500 Subject: [PATCH 44/53] Bug Fix: Restore integrations/system groups and fix channels reference after rebase --- apps/accounts/serializers.py | 2 +- frontend/src/components/Sidebar.jsx | 10 +++--- frontend/src/config/navigation.js | 56 ++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 1f970026..66c665fc 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -9,7 +9,7 @@ from apps.channels.models import ChannelProfile # Valid navigation item IDs for validation VALID_NAV_ITEM_IDS = { 'channels', 'vods', 'sources', 'guide', 'dvr', - 'stats', 'plugins', 'users', 'logos', 'settings' + 'stats', 'plugins', 'integrations', 'system', 'settings' } MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 91830574..0035581b 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -62,9 +62,9 @@ const NavLink = ({ item, isActive, collapsed }) => { ); }; -function NavGroup({ label, icon, paths, location, collapsed }) { +function NavGroup({ label, icon: IconComponent, paths, location, collapsed }) { const [open, setOpen] = useState(() => - location.pathname.startsWith('/connect') + paths.some((p) => location.pathname.startsWith(p.path)) ); const parentActive = paths @@ -81,7 +81,7 @@ function NavGroup({ label, icon, paths, location, collapsed }) { className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`} style={{ width: '100%' }} > - {icon} + {IconComponent && } {!collapsed && ( { const navOrder = getNavOrder(); const hiddenNav = getHiddenNav(); const navItems = useMemo(() => { - const orderedItems = getOrderedNavItems(navOrder, isAdmin, channels); + const orderedItems = getOrderedNavItems(navOrder, isAdmin, channelIds); return orderedItems.filter((item) => !hiddenNav.includes(item.id)); - }, [navOrder, hiddenNav, isAdmin, channels]); + }, [navOrder, hiddenNav, isAdmin, channelIds]); // Environment settings and version are loaded by the settings store during initData() // No need to fetch them again here - just use the store values diff --git a/frontend/src/config/navigation.js b/frontend/src/config/navigation.js index 940afae0..093b1927 100644 --- a/frontend/src/config/navigation.js +++ b/frontend/src/config/navigation.js @@ -9,6 +9,10 @@ import { PlugZap, User, FileImage, + Webhook, + Logs, + Blocks, + MonitorCog, } from 'lucide-react'; export const NAV_ITEMS = { @@ -62,19 +66,27 @@ export const NAV_ITEMS = { path: '/plugins', adminOnly: true, }, - users: { - id: 'users', - label: 'Users', - icon: User, - path: '/users', + integrations: { + id: 'integrations', + label: 'Integrations', + icon: Blocks, adminOnly: true, + paths: [ + { label: 'Connections', icon: Webhook, path: '/connect' }, + { label: 'Logs', icon: Logs, path: '/connect/logs' }, + ], }, - logos: { - id: 'logos', - label: 'Logo Manager', - icon: FileImage, - path: '/logos', + system: { + id: 'system', + label: 'System', + icon: MonitorCog, adminOnly: true, + canHide: false, + paths: [ + { label: 'Users', icon: User, path: '/users' }, + { label: 'Logo Manager', icon: FileImage, path: '/logos' }, + { label: 'Settings', icon: LucideSettings, path: '/settings' }, + ], }, settings: { id: 'settings', @@ -82,7 +94,7 @@ export const NAV_ITEMS = { icon: LucideSettings, path: '/settings', adminOnly: false, - canHide: false, // Settings can never be hidden + canHide: false, }, }; @@ -94,9 +106,8 @@ export const DEFAULT_ADMIN_ORDER = [ 'dvr', 'stats', 'plugins', - 'users', - 'logos', - 'settings', + 'integrations', + 'system', ]; export const DEFAULT_USER_ORDER = [ @@ -105,7 +116,7 @@ export const DEFAULT_USER_ORDER = [ 'settings', ]; -export const getOrderedNavItems = (userOrder, isAdmin, channels = {}) => { +export const getOrderedNavItems = (userOrder, isAdmin, channelIds = []) => { const defaultOrder = isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER; let order; @@ -127,17 +138,28 @@ export const getOrderedNavItems = (userOrder, isAdmin, channels = {}) => { const item = NAV_ITEMS[id]; if (!item) return null; + // Group item (has paths array) + if (item.paths) { + return { + id: item.id, + label: item.label, + icon: item.icon, + paths: item.paths, + canHide: item.canHide, + }; + } + const navItem = { id: item.id, label: item.label, icon: item.icon, path: item.path, - canHide: item.canHide, // Include canHide property + canHide: item.canHide, }; // Add badge for channels if (id === 'channels') { - navItem.badge = `(${Object.keys(channels).length})`; + navItem.badge = `(${Array.isArray(channelIds) ? channelIds.length : 0})`; } return navItem; From 76d980dceedea2a6c4815113ef6874c3bd7929ec Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 16:36:36 -0500 Subject: [PATCH 45/53] Bug Fix: onLogout is not defined, use logout from auth store --- frontend/src/components/Sidebar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 0035581b..73ee13c3 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -311,7 +311,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { - + )} From 70a0b43c8aa388bf4cd458be8ad82a0cebecc5cc Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 17:05:26 -0500 Subject: [PATCH 46/53] Cleanup frontend and backend if valid item ids change. --- apps/accounts/serializers.py | 12 +++++++++++- frontend/src/store/auth.jsx | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 66c665fc..c90ffef4 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -115,10 +115,20 @@ class UserSerializer(serializers.ModelSerializer): channel_profiles = validated_data.pop("channel_profiles", None) # Merge custom_properties instead of replacing (prevents data loss) + # Strip null values — sending null for a key omits it rather than overwriting with null custom_properties = validated_data.pop("custom_properties", None) if custom_properties is not None: existing = instance.custom_properties or {} - instance.custom_properties = {**existing, **custom_properties} + cleaned = {k: v for k, v in custom_properties.items() if v is not None} + merged = {**existing, **cleaned} + # Scrub stale nav IDs so the DB self-heals on next save + for nav_field in ('navOrder', 'hiddenNav'): + if nav_field in merged and isinstance(merged[nav_field], list): + merged[nav_field] = [ + item for item in merged[nav_field] + if item in VALID_NAV_ITEM_IDS + ] + instance.custom_properties = merged for attr, value in validated_data.items(): setattr(instance, attr, value) diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 8f0362c7..6d36e88c 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -8,6 +8,7 @@ import useUserAgentsStore from './userAgents'; import useUsersStore from './users'; import API from '../api'; import { USER_LEVELS } from '../constants'; +import { DEFAULT_ADMIN_ORDER, DEFAULT_USER_ORDER } from '../config/navigation'; const decodeToken = (token) => { if (!token) return null; @@ -76,7 +77,13 @@ const useAuthStore = create((set, get) => ({ getHiddenNav: () => { const user = get().user; - return user?.custom_properties?.hiddenNav || []; + const hiddenNav = user?.custom_properties?.hiddenNav || []; + // Filter out stale IDs that are no longer valid for this user's role + const isAdmin = user?.user_level >= USER_LEVELS.ADMIN; + const validIds = new Set( + isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER + ); + return hiddenNav.filter((id) => validIds.has(id)); }, toggleNavVisibility: async (itemId) => { From ac714587293e0dcf55e4b0fd8f5886c75e8da989 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 17:08:52 -0500 Subject: [PATCH 47/53] changelog: Update changelog for navigation ordering PR --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0daba010..47808842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Configurable sidebar navigation ordering and visibility — Thanks [@jcasimir](https://github.com/jcasimir) + - Sidebar nav items can be reordered via drag-and-drop in Settings → UI Settings → Navigation. + - Individual nav items can be hidden from the sidebar using the eye toggle. Hiding an item preserves its position in the order. + - A "Reset to Default" button restores the role-appropriate default order and clears all hidden items. + - Order and visibility are saved per-user with optimistic updates and automatic rollback on failure. Changes appear in the sidebar immediately without a page reload. + - Admin users see a grouped navigation: flat items (`Channels`, `VODs`, `M3U & EPG Manager`, `TV Guide`, `DVR`, `Stats`, `Plugins`) plus collapsible `Integrations` (Connections, Logs) and `System` (Users, Logo Manager, Settings) groups. The `System` group cannot be hidden. + - Non-admin users see `Channels`, `TV Guide`, and `Settings`, with the `Settings` item not hideable. - Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. — Thanks [@nick4810](https://github.com/nick4810) - Unit tests for DVR port resolution (`build_dvr_candidates`) and selective Redis flush behavior in modular mode. — Thanks [@CodeBormen](https://github.com/CodeBormen) - Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. @@ -39,6 +46,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Security**: Any authenticated user could self-escalate privileges by sending `user_level`, `is_staff`, or `is_superuser` in a `PATCH /api/accounts/users/me/` request. The `/me/` endpoint now enforces an allowlist (`custom_properties`, `first_name`, `last_name`, `email`, `password`); any other fields return HTTP 400. Privilege-sensitive fields can only be changed via the admin-only `PATCH /api/accounts/users/{id}/` endpoint. +- Double error notification when saving user preferences: `API.updateMe` was catching errors internally and displaying a notification before re-throwing, causing callers to display a second notification for the same failure. +- Navigation preference saves from concurrent sessions could overwrite each other due to a double-merge race: the frontend was pre-merging `custom_properties` before sending, then the backend merged again against the DB value, causing the second session's write to silently drop keys set by the first. The frontend now sends only the delta; the backend merges authoritatively against the stored value. +- Stale nav item IDs (e.g. from a previous nav structure) are now scrubbed from `navOrder` and `hiddenNav` on the next preference save, preventing unbounded growth of the `custom_properties` JSON field. - Version update notification persisting after upgrading to the notified version (e.g. "v0.20.2 available" shown while already running v0.20.2). Root cause: `check_for_version_update.delay()` was called from `AppConfig.ready()`, which fires inside Celery prefork pool subprocesses before the broker connection is established, causing the dispatch to fail silently with no log output. Fixed by moving the startup dispatch to the `worker_ready` signal in `celery.py` (consistent with the existing `recover_recordings_on_startup` pattern), and deleting the stale `version-{current_version}` notification at the top of the production check path so it is cleared even when GitHub is unreachable. A WebSocket update is sent immediately on deletion so the frontend badge clears without waiting for the API response. - VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run. - XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream. From 7899901936f224d03b4f0aee84bb95d4315b0712 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 18:48:20 -0500 Subject: [PATCH 48/53] Enhancement: Channels page default splitter ratio changed from 50/50 to 60/40 (channels/streams) so all channel action buttons are visible without scrolling on 1080p displays. --- CHANGELOG.md | 1 + frontend/src/pages/Channels.jsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47808842..ae7addeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Channels page default splitter ratio changed from 50/50 to 60/40 (channels/streams) so all channel action buttons are visible without scrolling on 1080p displays. - Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810) - `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`). - `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component. diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index ca8438bb..7b746b45 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -22,7 +22,7 @@ const PageContent = () => { const [allotmentSizes, setAllotmentSizes] = useLocalStorage( 'channels-splitter-sizes', - [50, 50] + [60, 40] ); // Only load logos when BOTH tables are ready From 5f80ab08b65ac75c4ddefe268443c412b3525e91 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 18:54:28 -0500 Subject: [PATCH 49/53] changelog: Update changelog for table sorting pr. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7addeb..3ec4834e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. - New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". - Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) +- Sorting by Group and EPG columns in the Channels table, and sorting by Group in the Streams table. Column headers now display a sort icon and clicking them cycles through ascending/descending/unsorted states. (Closes #854) — Thanks [@CodeBormen](https://github.com/CodeBormen) - DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen) - **Stop Recording**: A new Stop button (distinct from Cancel) cleanly ends an in-progress recording early and keeps the partial file available for playback. The API returns immediately; stream teardown and task revocation happen in a background thread to prevent 504 timeouts. When multiple recordings run simultaneously, stopping one only terminates that recording's proxy client by ID, leaving all others unaffected. (Closes #454) - **Extend Recording**: In-progress recordings can be extended by 15, 30, or 60 minutes without interrupting the stream. From 1c6e9bbf04081f7a8417ad6685dfc8050820995f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 19:00:44 -0500 Subject: [PATCH 50/53] changelog: Added clarification about api abilities prior to PR. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec4834e..414a6850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. - New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". - Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) -- Sorting by Group and EPG columns in the Channels table, and sorting by Group in the Streams table. Column headers now display a sort icon and clicking them cycles through ascending/descending/unsorted states. (Closes #854) — Thanks [@CodeBormen](https://github.com/CodeBormen) +- Sort icons added to the Group and EPG column headers in the Channels table, and to the Group column header in the Streams table. Clicking a sort icon cycles through ascending/descending/unsorted states. EPG sorting required a backend change (`epg_data__name` added to `ChannelViewSet.ordering_fields`); Group sorting was already supported by the API in both tables. (Closes #854) — Thanks [@CodeBormen](https://github.com/CodeBormen) - DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen) - **Stop Recording**: A new Stop button (distinct from Cancel) cleanly ends an in-progress recording early and keeps the partial file available for playback. The API returns immediately; stream teardown and task revocation happen in a background thread to prevent 504 timeouts. When multiple recordings run simultaneously, stopping one only terminates that recording's proxy client by ID, leaving all others unaffected. (Closes #454) - **Extend Recording**: In-progress recordings can be extended by 15, 30, or 60 minutes without interrupting the stream. From 4e663635654879e1ddaebd34fa0623d859a9c1f7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 8 Mar 2026 21:10:53 -0500 Subject: [PATCH 51/53] tests: Update frontend tests after sidebar changes. --- .../components/__tests__/GuideRow.test.jsx | 82 +++++++++-------- .../src/components/__tests__/Sidebar.test.jsx | 88 ++++++++++++++----- 2 files changed, 111 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/__tests__/GuideRow.test.jsx b/frontend/src/components/__tests__/GuideRow.test.jsx index 8dc18eaf..db57def5 100644 --- a/frontend/src/components/__tests__/GuideRow.test.jsx +++ b/frontend/src/components/__tests__/GuideRow.test.jsx @@ -93,9 +93,7 @@ describe('GuideRow', () => { describe('Rendering', () => { it('should render channel row with channel information', () => { - render( - - ); + render(); expect(screen.getByTestId('guide-row')).toBeInTheDocument(); expect(screen.getByAltText('Test Channel')).toBeInTheDocument(); @@ -139,9 +137,7 @@ describe('GuideRow', () => { describe('Row Height Calculation', () => { it('should use default PROGRAM_HEIGHT when no expanded program', () => { - render( - - ); + render(); const row = screen.getByTestId('guide-row'); expect(row).toHaveStyle({ height: `${PROGRAM_HEIGHT}px` }); @@ -186,7 +182,11 @@ describe('GuideRow', () => { render(); expect(screen.getByTestId('program-program-1')).toBeInTheDocument(); - expect(mockData.renderProgram).toHaveBeenCalledWith(visibleProgram, undefined, mockChannel); + expect(mockData.renderProgram).toHaveBeenCalledWith( + visibleProgram, + undefined, + mockChannel + ); }); it('should render multiple programs', () => { @@ -241,12 +241,15 @@ describe('GuideRow', () => { ); const placeholders = container.querySelectorAll('[pos*="absolute"]'); - const filteredPlaceholders = Array.from(placeholders).filter(el => + const filteredPlaceholders = Array.from(placeholders).filter((el) => el.textContent.includes('No program data') ); filteredPlaceholders.forEach((placeholder, index) => { - expect(placeholder).toHaveAttribute('left', `${index * (HOUR_WIDTH * 2)}`); + expect(placeholder).toHaveAttribute( + 'left', + `${index * (HOUR_WIDTH * 2)}` + ); expect(placeholder).toHaveAttribute('w', `${HOUR_WIDTH * 2}`); }); }); @@ -254,9 +257,7 @@ describe('GuideRow', () => { describe('Channel Logo Interactions', () => { it('should call handleLogoClick when logo is clicked', () => { - render( - - ); + render(); const logo = screen.getByAltText('Test Channel').closest('.channel-logo'); fireEvent.click(logo); @@ -277,9 +278,7 @@ describe('GuideRow', () => { }); it('should not show play icon when not hovering', () => { - render( - - ); + render(); expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument(); }); @@ -328,10 +327,10 @@ describe('GuideRow', () => { describe('Horizontal Viewport Culling', () => { it('should only render programs visible in viewport', () => { const programs = [ - createProgramAtTime('prog-1', 0, 60), // Hour 0 - createProgramAtTime('prog-2', 6, 60), // Hour 6 - createProgramAtTime('prog-3', 12, 60), // Hour 12 - createProgramAtTime('prog-4', 18, 60), // Hour 18 + createProgramAtTime('prog-1', 0, 60), // Hour 0 + createProgramAtTime('prog-2', 6, 60), // Hour 6 + createProgramAtTime('prog-3', 12, 60), // Hour 12 + createProgramAtTime('prog-4', 18, 60), // Hour 18 ]; const data = { @@ -369,7 +368,7 @@ describe('GuideRow', () => { // Programs within H_BUFFER (600px) should be rendered const renderedPrograms = mockData.renderProgram.mock.calls.map( - call => call[0] + (call) => call[0] ); expect(renderedPrograms.length).toBeGreaterThan(0); @@ -393,7 +392,7 @@ describe('GuideRow', () => { render(); const renderedPrograms = mockData.renderProgram.mock.calls.map( - call => call[0].id + (call) => call[0].id ); // Only visible program should be rendered @@ -449,7 +448,9 @@ describe('GuideRow', () => { rerender(); // Different programs should be rendered - expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(initialCalls); + expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan( + initialCalls + ); }); }); @@ -499,7 +500,9 @@ describe('GuideRow', () => { it('should show play icon on mouse enter', () => { render(); - const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo'); + const logoContainer = screen + .getByAltText('Test Channel') + .closest('.channel-logo'); expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument(); @@ -511,7 +514,9 @@ describe('GuideRow', () => { it('should hide play icon on mouse leave', () => { render(); - const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo'); + const logoContainer = screen + .getByAltText('Test Channel') + .closest('.channel-logo'); fireEvent.mouseEnter(logoContainer); expect(screen.getByTestId('play-icon')).toBeInTheDocument(); @@ -523,7 +528,10 @@ describe('GuideRow', () => { it('should maintain hover state independently per row', () => { const data = { ...mockData, - filteredChannels: [mockChannel, { ...mockChannel, id: 'channel-2', name: 'Channel 2' }], + filteredChannels: [ + mockChannel, + { ...mockChannel, id: 'channel-2', name: 'Channel 2' }, + ], }; // Render both rows separately @@ -531,7 +539,11 @@ describe('GuideRow', () => { ); const { container: container2 } = render( - + ); // Hover over first row @@ -539,12 +551,15 @@ describe('GuideRow', () => { fireEvent.mouseEnter(logo1); // First row should show play icon - expect(container1.querySelector('[data-testid="play-icon"]')).toBeInTheDocument(); + expect( + container1.querySelector('[data-testid="play-icon"]') + ).toBeInTheDocument(); // Second row should not show play icon - expect(container2.querySelector('[data-testid="play-icon"]')).not.toBeInTheDocument(); + expect( + container2.querySelector('[data-testid="play-icon"]') + ).not.toBeInTheDocument(); }); - }); describe('Program Time Positioning', () => { @@ -560,10 +575,7 @@ describe('GuideRow', () => { }; it('should calculate correct viewport boundaries', () => { - const programs = [ - createTimedProgram(6, 60), - createTimedProgram(12, 60), - ]; + const programs = [createTimedProgram(6, 60), createTimedProgram(12, 60)]; const data = { ...mockData, @@ -581,8 +593,8 @@ describe('GuideRow', () => { it('should handle programs at timeline boundaries', () => { const programs = [ - createTimedProgram(0, 60), // Start of timeline - createTimedProgram(23, 60), // End of timeline + createTimedProgram(0, 60), // Start of timeline + createTimedProgram(23, 60), // End of timeline ]; const data = { diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx index d3163b1d..693f9ca1 100644 --- a/frontend/src/components/__tests__/Sidebar.test.jsx +++ b/frontend/src/components/__tests__/Sidebar.test.jsx @@ -17,23 +17,39 @@ vi.mock('../../utils', () => ({ })); vi.mock('../NotificationCenter', () => ({ - default: () =>
Notification Center
, + default: () => ( +
Notification Center
+ ), })); // Mock lucide-react icons vi.mock('lucide-react', () => ({ - ListOrdered: ({ onClick }) =>
, + ListOrdered: ({ onClick }) => ( +
+ ), Play: ({ onClick }) =>
, - Database: ({ onClick }) =>
, - LayoutGrid: ({ onClick }) =>
, - Settings: ({ onClick }) =>
, + Database: ({ onClick }) => ( +
+ ), + LayoutGrid: ({ onClick }) => ( +
+ ), + Settings: ({ onClick }) => ( +
+ ), Copy: ({ onClick }) =>
, - ChartLine: ({ onClick }) =>
, + ChartLine: ({ onClick }) => ( +
+ ), Video: ({ onClick }) =>
, - PlugZap: ({ onClick }) =>
, + PlugZap: ({ onClick }) => ( +
+ ), LogOut: ({ onClick }) =>
, User: ({ onClick }) =>
, - FileImage: ({ onClick }) =>
, + FileImage: ({ onClick }) => ( +
+ ), Webhook: () =>
, Logs: () =>
, ChevronDown: () =>
, @@ -44,21 +60,22 @@ vi.mock('lucide-react', () => ({ // Mock UserForm component vi.mock('../forms/User', () => ({ - default: ({ isOpen, onClose, user }) => ( + default: ({ isOpen, onClose, user }) => isOpen ? (
User Form for {user?.username}
- ) : null - ), + ) : null, })); vi.mock('@mantine/core', async () => { return { Avatar: ({ children }) =>
{children}
, Group: ({ children, onClick, ...props }) => ( -
{children}
+
+ {children} +
), Stack: ({ children }) =>
{children}
, Box: ({ children }) =>
{children}
, @@ -88,7 +105,8 @@ vi.mock('@mantine/core', async () => {