mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 01:05:30 +00:00
Merge remote-tracking branch 'upstream/dev' into test/component-cleanup
This commit is contained in:
commit
7a0e396bc0
46 changed files with 2277 additions and 562 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -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 20–80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return)
|
||||
- Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out)
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
apps/channels/tests/test_dvr_port_resolution.py
Normal file
59
apps/channels/tests/test_dvr_port_resolution.py
Normal 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)
|
||||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
41
apps/m3u/tests/test_extinf_parsing.py
Normal file
41
apps/m3u/tests/test_extinf_parsing.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
12
core/apps.py
12
core/apps.py
|
|
@ -31,7 +31,6 @@ class CoreConfig(AppConfig):
|
|||
return
|
||||
|
||||
self._sync_developer_notifications()
|
||||
self._check_version_update()
|
||||
|
||||
def _sync_developer_notifications(self):
|
||||
"""Sync developer notifications from JSON file to database."""
|
||||
|
|
@ -47,14 +46,3 @@ class CoreConfig(AppConfig):
|
|||
except Exception as e:
|
||||
logger.warning(f"Failed to sync developer notifications on startup: {e}")
|
||||
|
||||
def _check_version_update(self):
|
||||
"""Check for version updates on startup."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from core.tasks import check_for_version_update
|
||||
check_for_version_update.delay()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check for version updates on startup: {e}")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
295
frontend/src/components/forms/settings/NavOrderForm.jsx
Normal file
295
frontend/src/components/forms/settings/NavOrderForm.jsx
Normal 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;
|
||||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
212
frontend/src/config/__tests__/navigation.test.js
Normal file
212
frontend/src/config/__tests__/navigation.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
frontend/src/config/navigation.js
Normal file
167
frontend/src/config/navigation.js
Normal 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);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
0
tests/__init__.py
Normal file
142
tests/test_user_preferences.py
Normal file
142
tests/test_user_preferences.py
Normal 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)
|
||||
98
tests/test_wait_for_redis.py
Normal file
98
tests/test_wait_for_redis.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue