Merge remote-tracking branch 'upstream/dev' into test/component-cleanup

This commit is contained in:
Nick Sandstrom 2026-03-09 14:04:35 -07:00
commit 7a0e396bc0
46 changed files with 2277 additions and 562 deletions

View file

@ -9,9 +9,19 @@ 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.
- 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)
- 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.
@ -25,22 +35,46 @@ 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.
- `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
- **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 `<account name> - <stream_id>` 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)
- 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 2080 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)
- 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.
@ -73,6 +107,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. (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.
- **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

View file

@ -278,11 +278,24 @@ class UserViewSet(viewsets.ModelViewSet):
return super().destroy(request, *args, **kwargs)
@extend_schema(
description="Get active user information",
description="Get or update active user information. PATCH updates custom_properties with merge semantics.",
methods=["GET", "PATCH"],
)
@action(detail=False, methods=["get"], url_path="me")
@action(detail=False, methods=["get", "patch"], url_path="me")
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()
return Response(serializer.data)
serializer = UserSerializer(user)
return Response(serializer.data)

View file

@ -1,9 +1,32 @@
import json
from rest_framework import serializers
from django.contrib.auth.models import Group, Permission
from .models import User
from apps.channels.models import ChannelProfile
# Valid navigation item IDs for validation
VALID_NAV_ITEM_IDS = {
'channels', 'vods', 'sources', 'guide', 'dvr',
'stats', 'plugins', 'integrations', 'system', 'settings'
}
MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit
def validate_nav_array(value, field_name):
"""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:
raise serializers.ValidationError(f"{field_name} exceeds maximum length of 50 items")
for item in value:
if not isinstance(item, str):
raise serializers.ValidationError(f"{field_name} items must be strings")
if item not in VALID_NAV_ITEM_IDS:
raise serializers.ValidationError(f"'{item}' is not a valid navigation item ID")
# 🔹 Fix for Permission serialization
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
@ -24,7 +47,7 @@ class GroupSerializer(serializers.ModelSerializer):
# 🔹 Fix for User serialization
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
password = serializers.CharField(write_only=True, required=False)
channel_profiles = serializers.PrimaryKeyRelatedField(
queryset=ChannelProfile.objects.all(), many=True, required=False
)
@ -50,6 +73,32 @@ class UserSerializer(serializers.ModelSerializer):
"last_name",
]
def validate_custom_properties(self, value):
"""Validate custom_properties structure and size."""
if value is None:
return {}
if not isinstance(value, dict):
raise serializers.ValidationError("custom_properties must be a dictionary")
# Size limit check
try:
if len(json.dumps(value)) > MAX_CUSTOM_PROPS_SIZE:
raise serializers.ValidationError(
f"custom_properties exceeds maximum size of {MAX_CUSTOM_PROPS_SIZE} bytes"
)
except (TypeError, ValueError):
raise serializers.ValidationError("custom_properties contains non-serializable data")
# Validate navOrder if present
if 'navOrder' in value:
validate_nav_array(value['navOrder'], 'navOrder')
# Validate hiddenNav if present
if 'hiddenNav' in value:
validate_nav_array(value['hiddenNav'], 'hiddenNav')
return value
def create(self, validated_data):
channel_profiles = validated_data.pop("channel_profiles", [])
@ -65,6 +114,22 @@ class UserSerializer(serializers.ModelSerializer):
password = validated_data.pop("password", None)
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 {}
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)

View file

@ -474,7 +474,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):

View file

@ -1642,6 +1642,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):
"""
@ -1820,22 +1847,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

View file

@ -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)

View file

@ -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"})

View file

@ -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.
@ -482,8 +489,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
@ -749,6 +758,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", ""))
@ -758,7 +775,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", ""),
@ -842,7 +859,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:
@ -875,7 +897,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 +1115,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,

View file

@ -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")

View file

@ -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

View file

@ -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, "

View file

@ -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}")

View file

@ -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(

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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
@ -175,23 +179,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)

View file

@ -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

View file

@ -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

View file

@ -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);
@ -2899,6 +2902,13 @@ export default class API {
return await request(`${host}/api/accounts/users/me/`);
}
static async updateMe(data) {
return await request(`${host}/api/accounts/users/me/`, {
method: 'PATCH',
body: data,
});
}
static async getUsers() {
try {
const response = await request(`${host}/api/accounts/users/`);

View file

@ -11,12 +11,12 @@ 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 = () => {
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 (
<Stack>
@ -41,14 +41,14 @@ const M3uSetupSuccess = (data) => {
</Group>
</Stack>
);
}
};
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);

View file

@ -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 (
<UnstyledButton
key={item.path}
@ -50,7 +38,7 @@ const NavLink = ({ item, isActive, collapsed }) => {
to={item.path}
className={`navlink ${isActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
>
{item.icon}
{IconComponent && <IconComponent size={20} />}
{!collapsed && (
<Text
sx={{
@ -74,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
@ -93,7 +81,7 @@ function NavGroup({ label, icon, paths, location, collapsed }) {
className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
style={{ width: '100%' }}
>
{icon}
{IconComponent && <IconComponent size={20} />}
{!collapsed && (
<Group justify="space-between" style={{ width: '100%' }}>
<Text
@ -151,6 +139,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authUser = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const getNavOrder = useAuthStore((s) => s.getNavOrder);
const getHiddenNav = useAuthStore((s) => s.getHiddenNav);
const publicIPRef = useRef(null);
@ -158,82 +148,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const closeUserForm = () => setUserFormOpen(false);
// Navigation Items
const navItems =
authUser && authUser.user_level == USER_LEVELS.ADMIN
? [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Array.isArray(channelIds) ? channelIds.length : 0})`,
},
{
label: 'VODs',
path: '/vods',
icon: <Video size={20} />,
},
{
label: 'M3U & EPG Manager',
icon: <Play size={20} />,
path: '/sources',
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'DVR', icon: <Database size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{ label: 'Plugins', icon: <PlugZap size={20} />, path: '/plugins' },
{
label: 'Integrations',
icon: <Blocks size={20} />,
paths: [
{
label: 'Connections',
icon: <Webhook size={20} />,
path: '/connect',
},
{
label: 'Logs',
icon: <Logs size={20} />,
path: '/connect/logs',
},
],
},
{
label: 'System',
icon: <MonitorCog size={20} />,
paths: [
{
label: 'Users',
icon: <User size={20} />,
path: '/users',
},
{
label: 'Logo Manager',
icon: <FileImage size={20} />,
path: '/logos',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
],
},
]
: [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Array.isArray(channelIds) ? channelIds.length : 0})`,
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
];
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, channelIds);
return orderedItems.filter((item) => !hiddenNav.includes(item.id));
}, [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

View file

@ -34,7 +34,7 @@ describe('ErrorBoundary', () => {
</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', () => {
</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', () => {

View file

@ -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(<FloatingVideo />);
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(<FloatingVideo />);
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(<FloatingVideo />);
@ -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(<FloatingVideo />);
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(<FloatingVideo />);
@ -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', () => {

View file

@ -15,9 +15,13 @@ vi.mock('../../images/logo.png', () => ({
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: (props) => <div data-testid="play-icon" {...props} />,
}));
vi.mock('lucide-react', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
Play: (props) => <div data-testid="play-icon" {...props} />,
};
});
// Mock Mantine components
vi.mock('@mantine/core', async () => {
@ -93,9 +97,7 @@ describe('GuideRow', () => {
describe('Rendering', () => {
it('should render channel row with channel information', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
expect(screen.getByAltText('Test Channel')).toBeInTheDocument();
@ -139,9 +141,7 @@ describe('GuideRow', () => {
describe('Row Height Calculation', () => {
it('should use default PROGRAM_HEIGHT when no expanded program', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${PROGRAM_HEIGHT}px` });
@ -186,7 +186,11 @@ describe('GuideRow', () => {
render(<GuideRow index={0} style={mockStyle} data={data} />);
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 +245,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 +261,7 @@ describe('GuideRow', () => {
describe('Channel Logo Interactions', () => {
it('should call handleLogoClick when logo is clicked', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.click(logo);
@ -277,9 +282,7 @@ describe('GuideRow', () => {
});
it('should not show play icon when not hovering', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
@ -328,10 +331,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 +372,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 +396,7 @@ describe('GuideRow', () => {
render(<GuideRow index={0} style={mockStyle} data={data} />);
const renderedPrograms = mockData.renderProgram.mock.calls.map(
call => call[0].id
(call) => call[0].id
);
// Only visible program should be rendered
@ -449,7 +452,9 @@ describe('GuideRow', () => {
rerender(<GuideRow index={0} style={mockStyle} data={newData} />);
// Different programs should be rendered
expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(initialCalls);
expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(
initialCalls
);
});
});
@ -499,7 +504,9 @@ describe('GuideRow', () => {
it('should show play icon on mouse enter', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
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 +518,9 @@ describe('GuideRow', () => {
it('should hide play icon on mouse leave', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
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 +532,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 +543,11 @@ describe('GuideRow', () => {
<GuideRow index={0} style={mockStyle} data={data} />
);
const { container: container2 } = render(
<GuideRow index={1} style={{ ...mockStyle, top: PROGRAM_HEIGHT }} data={data} />
<GuideRow
index={1}
style={{ ...mockStyle, top: PROGRAM_HEIGHT }}
data={data}
/>
);
// Hover over first row
@ -539,12 +555,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 +579,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 +597,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 = {

View file

@ -61,9 +61,7 @@ vi.mock('lucide-react', () => ({
}));
const renderWithProviders = (component) => {
return render(
<BrowserRouter>{component}</BrowserRouter>
);
return render(<BrowserRouter>{component}</BrowserRouter>);
};
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(
<BrowserRouter>
<M3URefreshNotification />
<M3URefreshNotification />
</BrowserRouter>
);
@ -569,7 +573,7 @@ describe('M3URefreshNotification', () => {
// Re-render with same data
rerender(
<BrowserRouter>
<M3URefreshNotification />
<M3URefreshNotification />
</BrowserRouter>
);
@ -606,7 +610,7 @@ describe('M3URefreshNotification', () => {
rerender(
<BrowserRouter>
<M3URefreshNotification />
<M3URefreshNotification />
</BrowserRouter>
);
@ -648,7 +652,7 @@ describe('M3URefreshNotification', () => {
rerender(
<BrowserRouter>
<M3URefreshNotification />
<M3URefreshNotification />
</BrowserRouter>
);
@ -687,7 +691,7 @@ describe('M3URefreshNotification', () => {
rerender(
<BrowserRouter>
<M3URefreshNotification />
<M3URefreshNotification />
</BrowserRouter>
);
@ -704,7 +708,7 @@ describe('M3URefreshNotification', () => {
rerender(
<BrowserRouter>
<M3URefreshNotification />
<M3URefreshNotification />
</BrowserRouter>
);
});

View file

@ -17,23 +17,39 @@ vi.mock('../../utils', () => ({
}));
vi.mock('../NotificationCenter', () => ({
default: () => <div data-testid="notification-center">Notification Center</div>,
default: () => (
<div data-testid="notification-center">Notification Center</div>
),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => <div data-testid="list-ordered-icon" onClick={onClick} />,
ListOrdered: ({ onClick }) => (
<div data-testid="list-ordered-icon" onClick={onClick} />
),
Play: ({ onClick }) => <div data-testid="play-icon" onClick={onClick} />,
Database: ({ onClick }) => <div data-testid="database-icon" onClick={onClick} />,
LayoutGrid: ({ onClick }) => <div data-testid="layout-grid-icon" onClick={onClick} />,
Settings: ({ onClick }) => <div data-testid="settings-icon" onClick={onClick} />,
Database: ({ onClick }) => (
<div data-testid="database-icon" onClick={onClick} />
),
LayoutGrid: ({ onClick }) => (
<div data-testid="layout-grid-icon" onClick={onClick} />
),
Settings: ({ onClick }) => (
<div data-testid="settings-icon" onClick={onClick} />
),
Copy: ({ onClick }) => <div data-testid="copy-icon" onClick={onClick} />,
ChartLine: ({ onClick }) => <div data-testid="chart-line-icon" onClick={onClick} />,
ChartLine: ({ onClick }) => (
<div data-testid="chart-line-icon" onClick={onClick} />
),
Video: ({ onClick }) => <div data-testid="video-icon" onClick={onClick} />,
PlugZap: ({ onClick }) => <div data-testid="plug-zap-icon" onClick={onClick} />,
PlugZap: ({ onClick }) => (
<div data-testid="plug-zap-icon" onClick={onClick} />
),
LogOut: ({ onClick }) => <div data-testid="logout-icon" onClick={onClick} />,
User: ({ onClick }) => <div data-testid="user-icon" onClick={onClick} />,
FileImage: ({ onClick }) => <div data-testid="file-image-icon" onClick={onClick} />,
FileImage: ({ onClick }) => (
<div data-testid="file-image-icon" onClick={onClick} />
),
Webhook: () => <div data-testid="webhook-icon" />,
Logs: () => <div data-testid="logs-icon" />,
ChevronDown: () => <div data-testid="chevron-down-icon" />,
@ -44,21 +60,22 @@ vi.mock('lucide-react', () => ({
// Mock UserForm component
vi.mock('../forms/User', () => ({
default: ({ isOpen, onClose, user }) => (
default: ({ isOpen, onClose, user }) =>
isOpen ? (
<div data-testid="user-form">
User Form for {user?.username}
<button onClick={onClose}>Close</button>
</div>
) : null
),
) : null,
}));
vi.mock('@mantine/core', async () => {
return {
Avatar: ({ children }) => <div>{children}</div>,
Group: ({ children, onClick, ...props }) => (
<div onClick={onClick} {...props}>{children}</div>
<div onClick={onClick} {...props}>
{children}
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Box: ({ children }) => <div>{children}</div>,
@ -88,7 +105,8 @@ vi.mock('@mantine/core', async () => {
<nav
style={{
...style,
width: typeof width?.base === 'number' ? `${width.base}px` : width?.base
width:
typeof width?.base === 'number' ? `${width.base}px` : width?.base,
}}
{...props}
>
@ -99,7 +117,7 @@ vi.mock('@mantine/core', async () => {
};
});
const mockChannels = [ 'channel-1', 'channel-2', 'channel-3' ];
const mockChannels = ['channel-1', 'channel-2', 'channel-3'];
const mockEnvironment = {
public_ip: '192.168.1.1',
@ -158,6 +176,8 @@ describe('Sidebar', () => {
isAuthenticated: true,
user: mockAdminUser,
logout: vi.fn(),
getNavOrder: () => null,
getHiddenNav: () => [],
};
return selector(state);
});
@ -168,7 +188,9 @@ describe('Sidebar', () => {
const { container } = renderSidebar();
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
const logo = container.querySelectorAll('img[src="/src/images/logo.png"]');
const logo = container.querySelectorAll(
'img[src="/src/images/logo.png"]'
);
expect(logo).toHaveLength(1);
});
@ -231,6 +253,8 @@ describe('Sidebar', () => {
isAuthenticated: true,
user: mockRegularUser,
logout: vi.fn(),
getNavOrder: () => null,
getHiddenNav: () => [],
};
return selector(state);
});
@ -291,6 +315,8 @@ describe('Sidebar', () => {
isAuthenticated: true,
user: { ...mockAdminUser, first_name: null },
logout: vi.fn(),
getNavOrder: () => null,
getHiddenNav: () => [],
};
return selector(state);
});
@ -330,6 +356,8 @@ describe('Sidebar', () => {
isAuthenticated: true,
user: mockAdminUser,
logout: mockLogout,
getNavOrder: () => null,
getHiddenNav: () => [],
};
return selector(state);
});
@ -357,6 +385,8 @@ describe('Sidebar', () => {
isAuthenticated: false,
user: null,
logout: vi.fn(),
getNavOrder: () => null,
getHiddenNav: () => [],
};
return selector(state);
});
@ -413,20 +443,28 @@ describe('Sidebar', () => {
renderSidebar({ collapsed: true });
const links = screen.getAllByRole('link');
links.forEach(link => {
links.forEach((link) => {
expect(link).toHaveClass('navlink-collapsed');
});
});
it('should adjust width based on collapsed state', () => {
const { rerender } = renderSidebar({ collapsed: false, drawerWidth: 250 });
const { rerender } = renderSidebar({
collapsed: false,
drawerWidth: 250,
});
const navbar = screen.getByRole('navigation');
expect(navbar).toHaveStyle({ width: '250px' });
rerender(
<BrowserRouter>
<Sidebar collapsed={true} drawerWidth={250} miniDrawerWidth={80} toggleDrawer={vi.fn()} />
<Sidebar
collapsed={true}
drawerWidth={250}
miniDrawerWidth={80}
toggleDrawer={vi.fn()}
/>
</BrowserRouter>
);
@ -446,7 +484,9 @@ describe('Sidebar', () => {
renderSidebar();
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
expect(screen.queryByRole('img', { name: /flag/i })).not.toBeInTheDocument();
expect(
screen.queryByRole('img', { name: /flag/i })
).not.toBeInTheDocument();
});
it('should use country code as alt text if country name is missing', () => {
@ -479,7 +519,9 @@ describe('Sidebar', () => {
it('should expand Integrations group when clicked', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
const integrationsGroup = screen
.getByText('Integrations')
.closest('button');
fireEvent.click(integrationsGroup);
await waitFor(() => {
@ -491,7 +533,9 @@ describe('Sidebar', () => {
it('should collapse Integrations group when clicked again', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
const integrationsGroup = screen
.getByText('Integrations')
.closest('button');
// Expand
fireEvent.click(integrationsGroup);
@ -538,7 +582,9 @@ describe('Sidebar', () => {
it('should not show multiple groups collapsed when both expanded', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
const integrationsGroup = screen
.getByText('Integrations')
.closest('button');
const systemGroup = screen.getByText('System').closest('button');
// Expand Integrations
@ -575,13 +621,17 @@ describe('Sidebar', () => {
isAuthenticated: false,
user: null,
logout: vi.fn(),
getNavOrder: () => null,
getHiddenNav: () => [],
};
return selector(state);
});
renderSidebar();
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
expect(
screen.queryByTestId('notification-center')
).not.toBeInTheDocument();
});
it('should not render NotificationCenter when not authenticated and collapsed', () => {
@ -590,13 +640,17 @@ describe('Sidebar', () => {
isAuthenticated: false,
user: null,
logout: vi.fn(),
getNavOrder: () => null,
getHiddenNav: () => [],
};
return selector(state);
});
renderSidebar({ collapsed: true });
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
expect(
screen.queryByTestId('notification-center')
).not.toBeInTheDocument();
});
});

View file

@ -16,7 +16,9 @@ vi.mock('../../utils', () => ({
}));
vi.mock('../../utils/components/SeriesModalUtils.js', () => ({
formatStreamLabel: vi.fn((provider) => `${provider.m3u_account.name} - Stream ${provider.stream_id}`),
formatStreamLabel: vi.fn(
(provider) => `${provider.m3u_account.name} - Stream ${provider.stream_id}`
),
imdbUrl: vi.fn((id) => `https://www.imdb.com/title/${id}`),
tmdbUrl: vi.fn((id, type) => `https://www.themoviedb.org/${type}/${id}`),
formatDuration: vi.fn((secs) => `${Math.floor(secs / 60)} min`),
@ -30,7 +32,9 @@ vi.mock('@mantine/core', async () => {
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>Close</button>
<button data-testid="modal-close" onClick={onClose}>
Close
</button>
{children}
</div>
) : null,
@ -56,8 +60,12 @@ vi.mock('@mantine/core', async () => {
<span {...props}>{children}</span>
),
Select: ({ data, value, onChange, placeholder, disabled }) => (
<select data-testid="provider-select" value={value}
onChange={(e) => onChange(e.target.value)} disabled={disabled}>
<select
data-testid="provider-select"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
<option value="">{placeholder}</option>
{data.map((item) => (
<option key={item.value} value={item.value}>
@ -71,10 +79,14 @@ vi.mock('@mantine/core', async () => {
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: () => <span>Play Icon</span>,
Copy: () => <span>Copy Icon</span>,
}));
vi.mock('lucide-react', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
Play: () => <span>Play Icon</span>,
Copy: () => <span>Copy Icon</span>,
};
});
describe('VODModal', () => {
const mockShowVideo = vi.fn();
@ -158,7 +170,9 @@ describe('VODModal', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Original: Original Test Movie')).toBeInTheDocument();
expect(
screen.getByText('Original: Original Test Movie')
).toBeInTheDocument();
});
expect(screen.getByText('2023')).toBeInTheDocument();
@ -174,7 +188,9 @@ describe('VODModal', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(mockVOD.id);
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(
mockVOD.id
);
});
});
@ -193,7 +209,9 @@ describe('VODModal', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
expect(screen.getByText('Loading additional details...')).toBeInTheDocument();
expect(
screen.getByText('Loading additional details...')
).toBeInTheDocument();
});
it('should handle play button click', async () => {
@ -210,7 +228,10 @@ describe('VODModal', () => {
});
it('should disable play button when multiple providers and none selected', async () => {
mockFetchMovieProviders.mockResolvedValue([mockProvider, { ...mockProvider, id: 2 }]);
mockFetchMovieProviders.mockResolvedValue([
mockProvider,
{ ...mockProvider, id: 2 },
]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
@ -255,7 +276,9 @@ describe('VODModal', () => {
});
it('should handle fetch details error gracefully', async () => {
mockFetchMovieDetailsFromProvider.mockRejectedValue(new Error('Fetch failed'));
mockFetchMovieDetailsFromProvider.mockRejectedValue(
new Error('Fetch failed')
);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
@ -310,8 +333,14 @@ describe('VODModal', () => {
const imdbLink = screen.getByText('IMDb');
const tmdbLink = screen.getByText('TMDb');
expect(imdbLink).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
expect(tmdbLink).toHaveAttribute('href', 'https://www.themoviedb.org/movie/12345');
expect(imdbLink).toHaveAttribute(
'href',
'https://www.imdb.com/title/tt1234567'
);
expect(tmdbLink).toHaveAttribute(
'href',
'https://www.themoviedb.org/movie/12345'
);
});
describe('Copy Link Functionality', () => {
@ -350,7 +379,7 @@ describe('VODModal', () => {
});
await waitFor(() => {
const copyButton = screen.getByText('Copy Link')
const copyButton = screen.getByText('Copy Link');
fireEvent.click(copyButton);
});
@ -432,7 +461,9 @@ describe('VODModal', () => {
render(<VODModal vod={minimalVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Test Movie'
);
});
});
});

View file

@ -0,0 +1,295 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
Button,
Text,
Group,
ActionIcon,
Stack,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { GripVertical, Eye, EyeOff } 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, isHidden, canHide, onToggleVisibility }) => {
const { transform, transition, setNodeRef, isDragging, attributes, listeners } = useSortable({
id: item.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : isHidden ? 0.5 : 1,
zIndex: isDragging ? 1 : 0,
position: 'relative',
};
const IconComponent = item.icon;
return (
<Box
ref={setNodeRef}
style={{
...style,
padding: '10px 12px',
border: '1px solid #444',
borderRadius: '6px',
backgroundColor: isDragging ? '#3A3A3E' : '#2A2A2E',
marginBottom: 6,
}}
>
<Group justify="space-between">
<Group gap="sm">
<ActionIcon
{...attributes}
{...listeners}
variant="transparent"
size="sm"
style={{ cursor: 'grab' }}
>
<GripVertical size={16} color="#888" />
</ActionIcon>
{IconComponent && <IconComponent size={18} color={isHidden ? '#666' : '#ccc'} />}
<Text size="sm" c={isHidden ? 'dimmed' : 'gray.3'}>
{item.label}
</Text>
</Group>
{canHide && (
<ActionIcon
variant="transparent"
size="sm"
onClick={() => onToggleVisibility(item.id)}
title={isHidden ? 'Show in navigation' : 'Hide from navigation'}
>
{isHidden ? (
<EyeOff size={16} color="#666" />
) : (
<Eye size={16} color="#888" />
)}
</ActionIcon>
)}
</Group>
</Box>
);
};
const NavOrderForm = ({ active }) => {
// 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;
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, {}),
useSensor(KeyboardSensor, {})
);
useEffect(() => {
if (active) {
const savedOrder = getNavOrder();
const orderedItems = getOrderedNavItems(savedOrder, isAdmin);
setItems(orderedItems);
}
}, [active, isAdmin, getNavOrder]);
// 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);
const newIndex = items.findIndex((item) => item.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Optimistic update
setItems(newItems);
// 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 {
await toggleNavVisibility(itemId);
notifications.show({
title: 'Navigation',
message: 'Visibility updated',
color: 'green',
autoClose: 2000,
});
} catch {
notifications.show({
title: 'Error',
message: 'Failed to update visibility',
color: 'red',
});
}
}, [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: [] });
const orderedItems = getOrderedNavItems(defaultOrder, isAdmin);
setItems(orderedItems);
notifications.show({
title: 'Navigation',
message: 'Reset to default order',
color: 'blue',
autoClose: 2000,
});
} catch {
notifications.show({
title: 'Error',
message: 'Failed to reset navigation order',
color: 'red',
});
} finally {
setIsSaving(false);
}
};
if (!active) {
return null;
}
// Cache hiddenNav before render loop to avoid calling getter N times
const hiddenNav = getHiddenNav();
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
Drag and drop to reorder the sidebar navigation items.
</Text>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{items.map((item) => (
<DraggableNavItem
key={item.id}
item={item}
isHidden={hiddenNav.includes(item.id)}
canHide={item.canHide !== false}
onToggleVisibility={handleToggleVisibility}
/>
))}
</SortableContext>
</DndContext>
<Group justify="flex-end">
<Button
variant="subtle"
color="gray"
onClick={handleReset}
disabled={isSaving}
>
Reset to Default
</Button>
</Group>
</Stack>
);
};
export default NavOrderForm;

View file

@ -0,0 +1,256 @@
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 }) => <div data-testid="dnd-context">{children}</div>,
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 }) => (
<div data-testid="sortable-context">{children}</div>
),
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 }) => <div {...props}>{children}</div>,
Button: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="reset-button">
{children}
</button>
),
Text: ({ children }) => <span>{children}</span>,
Group: ({ children }) => <div>{children}</div>,
ActionIcon: ({ children, ...props }) => (
<button {...props}>{children}</button>
),
Stack: ({ children }) => <div>{children}</div>,
useMantineTheme: () => ({}),
}));
describe('NavOrderForm', () => {
const mockSetNavOrder = vi.fn();
const mockGetNavOrder = vi.fn();
const mockGetHiddenNav = vi.fn();
const mockToggleNavVisibility = vi.fn();
const mockUpdateUserPreferences = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockSetNavOrder.mockResolvedValue({});
mockGetNavOrder.mockReturnValue(null);
mockGetHiddenNav.mockReturnValue([]);
mockToggleNavVisibility.mockResolvedValue({});
mockUpdateUserPreferences.mockResolvedValue({});
});
describe('Admin User', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
user: { user_level: USER_LEVELS.ADMIN, custom_properties: {} },
getNavOrder: mockGetNavOrder,
setNavOrder: mockSetNavOrder,
getHiddenNav: mockGetHiddenNav,
toggleNavVisibility: mockToggleNavVisibility,
updateUserPreferences: mockUpdateUserPreferences,
};
return selector(state);
});
});
it('renders all nav items for admin user', () => {
render(<NavOrderForm active={true} />);
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('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', () => {
render(<NavOrderForm active={true} />);
expect(screen.getByTestId('reset-button')).toBeInTheDocument();
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
});
it('does not render when not active', () => {
render(<NavOrderForm active={false} />);
expect(screen.queryByText('Channels')).not.toBeInTheDocument();
});
it('calls updateUserPreferences when reset button is clicked', async () => {
const user = userEvent.setup();
render(<NavOrderForm active={true} />);
const resetButton = screen.getByTestId('reset-button');
await user.click(resetButton);
await waitFor(() => {
expect(mockUpdateUserPreferences).toHaveBeenCalledWith({
navOrder: DEFAULT_ADMIN_ORDER,
hiddenNav: [],
});
});
});
it('renders visibility toggle icons for hideable items', () => {
render(<NavOrderForm active={true} />);
// All items except Settings should have visibility toggle
const toggleButtons = screen.getAllByTitle(/from navigation/i);
expect(toggleButtons.length).toBeGreaterThan(0);
});
it('calls toggleNavVisibility when eye icon is clicked', async () => {
const user = userEvent.setup();
render(<NavOrderForm active={true} />);
const toggleButtons = screen.getAllByTitle('Hide from navigation');
await user.click(toggleButtons[0]);
await waitFor(() => {
expect(mockToggleNavVisibility).toHaveBeenCalled();
});
});
it('shows hidden items with dimmed styling', () => {
mockGetHiddenNav.mockReturnValue(['channels']);
render(<NavOrderForm active={true} />);
// The component should still render the hidden item
expect(screen.getByText('Channels')).toBeInTheDocument();
});
it('uses saved order when available', () => {
const customOrder = [
'guide',
'channels',
'vods',
'sources',
'dvr',
'stats',
'plugins',
'integrations',
'system',
];
mockGetNavOrder.mockReturnValue(customOrder);
render(<NavOrderForm active={true} />);
// The component should render with custom order
expect(screen.getByText('Channels')).toBeInTheDocument();
expect(screen.getByText('System')).toBeInTheDocument();
});
});
describe('Non-Admin User', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
user: { user_level: USER_LEVELS.USER, custom_properties: {} },
getNavOrder: mockGetNavOrder,
setNavOrder: mockSetNavOrder,
getHiddenNav: mockGetHiddenNav,
toggleNavVisibility: mockToggleNavVisibility,
updateUserPreferences: mockUpdateUserPreferences,
};
return selector(state);
});
});
it('renders only non-admin nav items for regular user', () => {
render(<NavOrderForm active={true} />);
// 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 updateUserPreferences with user default order when reset', async () => {
const user = userEvent.setup();
render(<NavOrderForm active={true} />);
const resetButton = screen.getByTestId('reset-button');
await user.click(resetButton);
await waitFor(() => {
expect(mockUpdateUserPreferences).toHaveBeenCalledWith({
navOrder: DEFAULT_USER_ORDER,
hiddenNav: [],
});
});
});
});
});

View file

@ -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}`);
}
@ -936,7 +944,7 @@ const ChannelsTable = ({ onReady }) => {
/>
),
size: columnSizing.epg || 200,
minSize: 80,
minSize: 120,
},
{
id: 'channel_group',
@ -947,8 +955,8 @@ const ChannelsTable = ({ onReady }) => {
cell: (props) => (
<EditableGroupCell {...props} channelGroups={channelGroups} />
),
size: columnSizing.channel_group || 175,
minSize: 100,
size: columnSizing.channel_group || 200,
minSize: 120,
},
{
id: 'logo',
@ -1030,6 +1038,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':
@ -1041,11 +1058,16 @@ const ChannelsTable = ({ onReady }) => {
case 'channel_number':
return (
<Flex gap={2}>
<Flex gap={2} align="center">
#
<Center>
<Center
onClick={(e) => {
e.stopPropagation();
onSortingChange('channel_number');
}}
style={{ cursor: 'pointer' }}
>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange('channel_number'),
size: 14,
})}
</Center>
@ -1054,25 +1076,27 @@ const ChannelsTable = ({ onReady }) => {
case 'name':
return (
<Flex gap="sm">
<TextInput
name="name"
placeholder="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
variant="unstyled"
className="table-input-header"
leftSection={<Search size={14} opacity={0.5} />}
/>
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange('name'),
size: 14,
})}
</Center>
</Flex>
<TextInput
name="name"
placeholder="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
variant="unstyled"
className="table-input-header"
leftSection={<Search size={14} opacity={0.5} />}
style={{ width: '100%' }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('name');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
);
case 'channel_group':
@ -1095,6 +1119,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' },
})}
/>
);
}

View file

@ -18,7 +18,7 @@ const useTable = ({
expandedRowRenderer = () => <></>,
onRowSelectionChange = null,
getExpandedRowHeight = null,
state = [],
state = {},
columnSizing,
setColumnSizing,
onColumnVisibilityChange,
@ -103,6 +103,9 @@ const useTable = ({
selectedTableIds,
...(columnSizing && { columnSizing }),
},
autoResetPageIndex: false,
autoResetExpanded: false,
onStateChange: options.onStateChange,
...(setColumnSizing && { onColumnSizingChange: setColumnSizing }),
...(onColumnVisibilityChange && { onColumnVisibilityChange }),

View file

@ -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);

View file

@ -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' },
})}
/>
);
}

View file

@ -0,0 +1,212 @@
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.integrations).toBeDefined();
expect(NAV_ITEMS.system).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.integrations.adminOnly).toBe(true);
expect(NAV_ITEMS.system.adminOnly).toBe(true);
});
});
describe('DEFAULT_ADMIN_ORDER', () => {
it('includes all nav items', () => {
// settings is only for non-admin users; admins access it via the System group
const adminItems = Object.keys(NAV_ITEMS).filter(
(id) => id !== 'settings'
);
expect(DEFAULT_ADMIN_ORDER).toHaveLength(adminItems.length);
adminItems.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 = [
'integrations',
'channels',
'vods',
'sources',
'guide',
'dvr',
'stats',
'plugins',
'system',
];
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(DEFAULT_ADMIN_ORDER.length);
// Missing items should be appended at the end
const resultIds = result.map((item) => item.id);
expect(resultIds).toContain('guide');
expect(resultIds).toContain('integrations');
});
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',
'integrations',
];
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('integrations');
});
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');
// Flat items have path; group items have paths array
expect(item.path !== undefined || Array.isArray(item.paths)).toBe(true);
});
});
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);
});
});
});

View file

@ -0,0 +1,167 @@
import {
ListOrdered,
Play,
Database,
LayoutGrid,
Settings as LucideSettings,
ChartLine,
Video,
PlugZap,
User,
FileImage,
Webhook,
Logs,
Blocks,
MonitorCog,
} 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,
},
integrations: {
id: 'integrations',
label: 'Integrations',
icon: Blocks,
adminOnly: true,
paths: [
{ label: 'Connections', icon: Webhook, path: '/connect' },
{ label: 'Logs', icon: Logs, path: '/connect/logs' },
],
},
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',
label: 'Settings',
icon: LucideSettings,
path: '/settings',
adminOnly: false,
canHide: false,
},
};
export const DEFAULT_ADMIN_ORDER = [
'channels',
'vods',
'sources',
'guide',
'dvr',
'stats',
'plugins',
'integrations',
'system',
];
export const DEFAULT_USER_ORDER = [
'channels',
'guide',
'settings',
];
export const getOrderedNavItems = (userOrder, isAdmin, channelIds = []) => {
const defaultOrder = isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER;
let order;
if (userOrder && Array.isArray(userOrder) && userOrder.length > 0) {
// Filter saved order to only include allowed items
const filteredOrder = userOrder.filter((id) => defaultOrder.includes(id));
// Find any new items that aren't in the saved order and append them
const missingItems = defaultOrder.filter(
(id) => !filteredOrder.includes(id)
);
order = [...filteredOrder, ...missingItems];
} else {
order = defaultOrder;
}
return order.map((id) => {
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,
};
// Add badge for channels
if (id === 'channels') {
navItem.badge = `(${Array.isArray(channelIds) ? channelIds.length : 0})`;
}
return navItem;
}).filter(Boolean);
};

View file

@ -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

View file

@ -7,6 +7,7 @@ import {
AccordionPanel,
Box,
Center,
Divider,
Text,
Loader,
} from '@mantine/core';
@ -38,6 +39,9 @@ const DvrSettingsForm = React.lazy(
const SystemSettingsForm = React.lazy(
() => import('../components/forms/settings/SystemSettingsForm.jsx')
);
const NavOrderForm = React.lazy(
() => import('../components/forms/settings/NavOrderForm.jsx')
);
const SettingsPage = () => {
const authUser = useAuthStore((s) => s.user);
@ -66,10 +70,25 @@ const SettingsPage = () => {
<AccordionControl>UI Settings</AccordionControl>
<AccordionPanel>
<UiSettingsForm active={accordianValue === 'ui-settings'} />
<Divider my="md" />
<Accordion variant="contained">
<AccordionItem value="nav-order">
<AccordionControl>Navigation</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<NavOrderForm
active={accordianValue === 'ui-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
</Accordion>
</AccordionPanel>
</AccordionItem>
{authUser.user_level == USER_LEVELS.ADMIN && (
{authUser.user_level >= USER_LEVELS.ADMIN && (
<>
<AccordionItem value="dvr-settings">
<AccordionControl>DVR</AccordionControl>

View file

@ -71,6 +71,13 @@ vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
</div>
),
}));
vi.mock('../../components/forms/settings/NavOrderForm', () => ({
default: ({ active }) => (
<div data-testid="nav-order-form">
NavOrderForm {active ? 'active' : 'inactive'}
</div>
),
}));
vi.mock('../../components/ErrorBoundary', () => ({
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
}));
@ -96,6 +103,7 @@ vi.mock('@mantine/core', async () => {
AccordionPanel: accordionComponent.Panel,
Box: ({ children }) => <div>{children}</div>,
Center: ({ children }) => <div>{children}</div>,
Divider: () => <hr />,
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children }) => <span>{children}</span>,
};
@ -127,7 +135,7 @@ describe('SettingsPage', () => {
it('renders the settings page', () => {
renderWithRouter(<SettingsPage />);
expect(screen.getByTestId('accordion')).toBeInTheDocument();
expect(screen.getAllByTestId('accordion').length).toBeGreaterThan(0);
});
it('renders UI Settings accordion item', () => {
@ -157,6 +165,15 @@ describe('SettingsPage', () => {
expect(screen.queryByText('Proxy Settings')).not.toBeInTheDocument();
expect(screen.queryByText('Backup & Restore')).not.toBeInTheDocument();
});
it('renders Navigation accordion item for regular users', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-nav-order')
).toBeInTheDocument();
expect(screen.getByText('Navigation')).toBeInTheDocument();
});
});
describe('Rendering for Admin User', () => {
@ -181,6 +198,7 @@ describe('SettingsPage', () => {
expect(screen.getByText('Network Access')).toBeInTheDocument();
expect(screen.getByText('Proxy Settings')).toBeInTheDocument();
expect(screen.getByText('Backup & Restore')).toBeInTheDocument();
expect(screen.getByText('Navigation')).toBeInTheDocument();
});
});
@ -245,6 +263,14 @@ describe('SettingsPage', () => {
expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument();
});
it('renders Navigation accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-nav-order')
).toBeInTheDocument();
});
});
describe('Accordion Interactions', () => {

View file

@ -107,11 +107,13 @@ describe('useAuthStore', () => {
setState({
isAuthenticated: false,
isInitialized: false,
isInitializing: false,
needsSuperuser: false,
user: {
username: '',
email: '',
user_level: '',
custom_properties: {},
},
isLoading: false,
error: null,
@ -134,6 +136,7 @@ describe('useAuthStore', () => {
username: '',
email: '',
user_level: '',
custom_properties: {},
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();

View file

@ -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;
@ -30,12 +31,69 @@ 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;
// Optimistic update
set({
user: {
...currentUser,
custom_properties: {
...currentUser.custom_properties,
...preferences,
},
},
});
try {
// Send only the delta - backend merges with DB value authoritatively
const response = await API.updateMe({
custom_properties: preferences,
});
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 });
},
getHiddenNav: () => {
const user = get().user;
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) => {
const hiddenNav = get().getHiddenNav();
const newHiddenNav = hiddenNav.includes(itemId)
? hiddenNav.filter((id) => id !== itemId)
: [...hiddenNav, itemId];
return await get().updateUserPreferences({ hiddenNav: newHiddenNav });
},
initData: async () => {
// Prevent multiple simultaneous initData calls
if (get().isInitializing || get().isInitialized) {

View file

@ -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,7 +53,15 @@ 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
# 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:

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,142 @@
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 merges into existing custom_properties, preserving other keys"""
# Set initial custom_properties
self.user.custom_properties = {
"theme": "dark",
"someOtherSetting": True
}
self.user.save()
# Update only navOrder - send delta, not full object
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)
# 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"""
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)
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)

View file

@ -0,0 +1,98 @@
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.
Verifies flush behaviour: full flushdb in AIO mode, selective
(non-Celery) key deletion in modular mode.
"""
@patch('wait_for_redis.redis.Redis')
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
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.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):
"""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)