Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/JCBird1012/1062

This commit is contained in:
SergeantPanda 2026-03-08 12:00:46 -05:00
commit ea9f4f4bbd
102 changed files with 16130 additions and 2611 deletions

View file

@ -9,23 +9,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- 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 2 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)".
- 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)
- 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.
- **Inline metadata editing**: Title and description can now be edited directly in the recording details modal.
- **Refresh artwork button**: Manually re-run poster resolution on demand from the recording card.
- **Multi-source poster resolution**: Added pipeline querying EPG, VOD, TMDB, OMDb, TVMaze, and iTunes for richer recording artwork.
- **Series rules for currently-airing episodes**: Series rules now capture currently-airing episodes in addition to future scheduled ones. (Closes #473)
- **Search and filter controls**: Added search and filter controls to the recordings list.
- **Stream generator throttling**: Cached the `ProxyServer` singleton reference per client and throttled Redis resource checks (1 s) and non-owner health checks (2 s), eliminating 3+ Redis round-trips per stream loop iteration.
- **Automatic crash recovery on worker restart**: A `worker_ready` Celery signal now fires `recover_recordings_on_startup` automatically when the worker starts, so recordings stuck in "recording" status are recovered without manual intervention.
### Changed
- Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810)
- `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`).
- `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component.
- `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`).
- `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`).
- `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs.
- `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting.
- Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element.
- EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data.
### Fixed
- 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`.
- M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012)
- Celery worker memory leak during M3U/XC refresh causing 2080 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return)
- Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out)
- Added `CELERY_WORKER_MAX_MEMORY_PER_CHILD = 512 MB` as a safety net against pymalloc arena fragmentation
- EPG output was filtering programs using `start_time__gte=now` when the `days` parameter was specified, which caused currently-airing programs (started before the request time but not yet ended) to be omitted from the XML output. This produced a gap in clients' guides immediately after an EPG refresh, lasting until the next program started. Fixed by changing the filter to `end_time__gte=now` so any program that has not yet finished is included.
- TS proxy connection slot leaks and TOCTOU races in stream initialization (Fixes #947) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`→check→`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity.
- **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`→check→`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity. — Thanks [@patchy8736](https://github.com/patchy8736)
- **Leak on URL generation failure**: `generate_stream_url()` called `get_stream()` (which `INCR`s the counter) but had no cleanup path if subsequent DB lookups or URL construction failed. The post-`get_stream()` block is now wrapped in a `try/except` that calls `release_stream()` on any error.
- **Leak on retry-loop timeout**: the retry loop in `stream_ts()` called a bare `get_stream()` on the first failure to classify the error reason. If a slot was available, this `INCR`'d the counter and set Redis keys that were never released when the loop timed out. A `release_stream()` call is now issued before returning 503.
- **Leak on `initialize_channel()` failure**: when `initialize_channel()` returned `False`, the connection slot allocated by the preceding `get_stream()` was never released. A `connection_allocated` flag now tracks whether this request performed the `INCR` (fresh initialization vs. joining an existing channel), and `release_stream()` is called guarded by that flag to prevent incorrect decrements when attaching to an already-running channel.
- **Safety net for unexpected exceptions**: the outer `except` in `stream_ts()` now checks `connection_allocated` and calls `release_stream()` as a last-resort cleanup for any unhandled exception that escapes before the channel is handed off to the stream lifecycle.
- **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls.
- **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls. — Thanks [@patchy8736](https://github.com/patchy8736)
- **`update_stream_profile()` uses a Redis pipeline** for the old-profile DECR + key update + new-profile INCR sequence, preventing counter drift if the process crashes between operations.
- **`stream_generator._cleanup()`** now falls back to `Stream.objects.get()` when the channel UUID resolves to a preview flow rather than a normal channel, rather than silently skipping the slot release.
- **VOD `cleanup_persistent_connection()`** fallback DECR is now conditional: it only decrements the profile counter when the connection tracking key had already expired by TTL (i.e., `remove_connection()` would have skipped the DECR), preventing double-decrements when the key is still present.
@ -36,6 +70,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **No recovery from expired ownership**: `get_channel_owner()` called `redis.get()` twice inside a lambda (TOCTOU race — key could expire between calls); `extend_ownership()` silently returned `False` on expiry with no re-acquisition; and the non-owner cleanup path unconditionally killed streams even when the worker held the `stream_manager`. Fixed with a single `GET` in `get_channel_owner()`, re-acquisition via atomic `SET NX EX` in `extend_ownership()`, and a re-acquisition attempt with client-aware cleanup deferral in the cleanup thread.
- `get_instance()` deadlock: if `ProxyServer()` raised an exception during singleton construction, `_instance` was left permanently as the `_INITIALIZING` sentinel, causing all subsequent `get_instance()` callers to spin in an infinite `gevent.sleep()` loop. Construction is now wrapped in `try/except`; on failure `_instance` resets to `None` so the next call can retry.
- Non-atomic ownership acquisition in `try_acquire_ownership()`: replaced the separate `setnx()` + `expire()` calls with a single atomic `SET NX EX`, eliminating the race window where a process crash between the two calls could leave an ownership key with no TTL (permanent ownership lock).
- DVR bug fixes — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Duplicate recording execution**: `run_recording.apply_async(countdown=...)` exceeded Redis' default `visibility_timeout` (3600 s) for recordings scheduled more than one hour out, causing Redis to redeliver the task to multiple workers simultaneously and producing corrupted output files. Replaced `apply_async` with `ClockedSchedule` + `PeriodicTask` for database-backed one-shot scheduling that survives restarts and upgrades without the redelivery race. `run_recording` also now exits immediately if the recording is already in progress, completed, or stopped. `revoke_task()` cleans up both the `PeriodicTask` and its orphaned `ClockedSchedule` on execution. (Fixes #940, #641)
- **Stream reconnection resilience**: Recordings now survive transient network drops with automatic reconnection retrying up to 5 times and appending to the existing file. DB operations use exponential-backoff retry for transient database errors throughout the recording lifecycle.
- **Crash recovery pipeline**: On worker restart, recordings stuck in "recording" status have their segments concatenated and remuxed. Remux sanity checks reject MKV output that is less than 50% the size of a previous MKV (duplicate-task overwrite) or less than 10% of the source TS (corrupt first attempt); the source `.ts` is preserved for manual recovery on all failure paths. (Fixes #619, #624)
- **Output file collision**: Fixed collision when multiple tasks targeted the same filename.
- **WebSocket deadlock**: `send_websocket_update()` was deadlocking the gevent event loop, causing one recording's WebSocket events to block all other simultaneous recordings.
- **DVR client isolation**: Stop and Cancel operations now identify the target client by recording ID (via `User-Agent: Dispatcharr-DVR/recording-{id}`), ensuring only the correct proxy client is torn down and never affecting other recordings on the same channel.
- **Accidental stream termination on delete**: `destroy()` now only calls `_stop_dvr_clients()` for in-progress recordings, preventing stream termination when deleting a completed recording.
- **Recording card logos**: Logos were not displaying due to a channel summary API shape mismatch.
- **Logo fetch negative cache**: Added negative cache for failed remote logo fetches so dead CDNs no longer block Daphne workers on repeated requests.
- **Artwork fuzzy-match sanitisation**: Poster artwork fuzzy-matching against external APIs (TMDB, OMDb, etc.) was producing incorrect results for channels with names like "USA A&E SD\*"; channel-name strings are now sanitised before querying external sources.
- **Series modal "No upcoming episodes"**: Fixed due to a missing `_group_count` merge and an incorrect time filter.
- **Series rule cleanup**: Deleting a series rule left orphaned recordings and stale Guide indicators; rule deletion now cleans up all associated recordings. Orphaned recordings with no parent rule are also cleaned up automatically. (Fixes #1041)
- **Series rule timezone calculation**: Recurring rules silently dropped scheduled recordings for users in UTC-negative timezones after 4 pm local time. (Fixes #1042)
- **Recording modal TDZ crash**: Modal crashed on load in production bundles due to a Temporal Dead Zone error — editing state was referenced before its declaration in the minified bundle.
- **Description textarea focus loss**: The description textarea lost focus immediately when opened because the inline editing component was remounting on every render.
- **WebSocket-driven refresh**: Replaced all manual `fetchRecordings()` polling calls with debounced WebSocket-driven refresh so the recordings list stays up to date without redundant API requests.
- **comskip exit code handling**: comskip treated exit code 1 ("no commercials found") as a fatal error, causing post-processing to fail on clean recordings. Exit code 1 is now recognised as a successful no-op.
- **Differentiated WebSocket notification events**: `recording_stopped`, `recording_cancelled` (in-progress cancel), and `recording_deleted` with a `was_in_progress` flag now allow the frontend to display distinct "Recording stopped", "Recording cancelled", and "Recording deleted" toasts.
- **Duplicate series rule evaluation race**: Creating a series rule fired `evaluate_series_rules.delay()` in the API view while the frontend immediately called the synchronous evaluate endpoint, racing to create duplicate recordings for the same program. Removed the redundant async call from the API; the frontend's explicit evaluate call is now the sole evaluation path.
- **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card.
- **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently.
- Modular mode deployment hardening — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. (Fixes #1045)
- **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`.
- **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode — clearing stale stream locks, proxy metadata, and server-state keys — while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed.
- **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present.
- **Stale environment variables after container restart**: `/etc/profile.d/dispatcharr.sh` was only written on the first container run; restarts with changed env vars (e.g. a rotated `POSTGRES_PASSWORD`) retained stale values. The file is now truncated and rewritten on every startup. `/etc/environment` entries are likewise updated rather than skipped when a key already exists. All exported values are now quoted to prevent breakage from special characters.
- **Celery entrypoint startup timeouts**: The JWT key wait and migration wait loops had no timeout, leaving the Celery worker hanging indefinitely if the web container was stuck. Each loop now times out (120 s for JWT, 300 s for migrations) and exits with a clear diagnostic message. The migration readiness check is also replaced from a fragile `showmigrations | grep` pattern to `migrate --check`, which exits cleanly on both unapplied migrations and connection errors.
- **Service startup ordering**: `depends_on` entries for `db` and `redis` in `docker-compose.yml` upgraded from plain name-link ordering to `condition: service_healthy`, ensuring containers wait for actual readiness signals before starting.
- **`host.docker.internal` resolution on Linux**: Added `extra_hosts: host.docker.internal:host-gateway` to the web service in `docker-compose.yml` so Linux hosts resolve `host.docker.internal` the same way Docker Desktop does on macOS/Windows.
## [0.20.2] - 2026-03-03

View file

@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Count, F
from django.db.models import Q
import os, json, requests, logging, mimetypes
import os, json, requests, logging, mimetypes, threading, time
from datetime import timedelta
from django.utils.http import http_date
from urllib.parse import unquote
from apps.accounts.permissions import (
@ -48,7 +49,6 @@ from .serializers import (
)
from .tasks import (
match_epg_channels,
evaluate_series_rules,
evaluate_series_rules_impl,
match_single_channel_epg,
match_selected_channels_epg,
@ -72,6 +72,12 @@ from rest_framework.pagination import PageNumberPagination
logger = logging.getLogger(__name__)
# Negative cache for remote logo URLs that failed to fetch.
# Prevents repeated blocking requests to unreachable hosts (e.g., dead CDNs)
# from exhausting Daphne workers. Keyed by URL, value is expiry timestamp.
_logo_fetch_failures = {}
_LOGO_FAIL_TTL = 300 # seconds
class OrInFilter(django_filters.Filter):
"""
@ -1956,6 +1962,12 @@ class LogoViewSet(viewsets.ModelViewSet):
return response
else: # Remote image
# Skip URLs that recently failed to avoid blocking Daphne workers
# on unreachable hosts (e.g., dead CDNs referenced by old recordings).
fail_expiry = _logo_fetch_failures.get(logo_url)
if fail_expiry and time.monotonic() < fail_expiry:
raise Http404("Remote image temporarily unavailable")
try:
# Get the default user agent
try:
@ -1974,6 +1986,9 @@ class LogoViewSet(viewsets.ModelViewSet):
headers={'User-Agent': user_agent}
)
if remote_response.status_code == 200:
# Success — clear any previous failure entry
_logo_fetch_failures.pop(logo_url, None)
# Try to get content type from response headers first
content_type = remote_response.headers.get("Content-Type")
@ -1997,14 +2012,19 @@ class LogoViewSet(viewsets.ModelViewSet):
os.path.basename(logo_url)
)
return response
# Non-200 response — cache the failure and evict stale entries
now = time.monotonic()
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
if len(_logo_fetch_failures) > 256:
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
_logo_fetch_failures.pop(k, None)
raise Http404("Remote image not found")
except requests.exceptions.Timeout:
logger.warning(f"Timeout fetching logo from {logo_url}")
raise Http404("Logo request timed out")
except requests.exceptions.ConnectionError:
logger.warning(f"Connection error fetching logo from {logo_url}")
raise Http404("Unable to connect to logo server")
except requests.RequestException as e:
now = time.monotonic()
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
if len(_logo_fetch_failures) > 256:
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
_logo_fetch_failures.pop(k, None)
logger.warning(f"Error fetching logo from {logo_url}: {e}")
raise Http404("Error fetching remote image")
@ -2248,6 +2268,54 @@ class RecurringRecordingRuleViewSet(viewsets.ModelViewSet):
logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}")
def _stop_dvr_clients(channel_uuid, recording_id=None):
"""Stop DVR recording clients for a channel.
If recording_id is provided, only the client whose User-Agent contains that
recording ID is stopped (safe for simultaneous recordings on the same channel).
If recording_id is None, all Dispatcharr-DVR clients for the channel are stopped
(used by destroy() when deleting a recording whose task_id is unknown).
Returns the number of DVR clients stopped.
"""
from core.utils import RedisClient
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.services.channel_service import ChannelService
r = RedisClient.get_client()
if not r:
return 0
client_set_key = RedisKeys.clients(channel_uuid)
client_ids = r.smembers(client_set_key) or []
stopped = 0
for raw_id in client_ids:
try:
cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
ua = r.hget(meta_key, "user_agent")
ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "")
if not (ua_s and "Dispatcharr-DVR" in ua_s):
continue
# When a recording_id is specified, only stop the client for that recording.
# Each run_recording task connects with User-Agent "Dispatcharr-DVR/recording-{id}",
# so we can safely target just this recording without affecting others on the channel.
if recording_id is not None and f"recording-{recording_id}" not in ua_s:
continue
try:
ChannelService.stop_client(channel_uuid, cid)
stopped += 1
except Exception as inner_e:
logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}")
except Exception as inner:
logger.debug(f"Error while checking client metadata: {inner}")
# Do not call ChannelService.stop_channel() here.
# Stopping the channel proxy would terminate the source connection which may
# be shared with other recordings on the same channel. The TS proxy server
# already detects when client count reaches zero and tears down the channel
# cleanly on its own (with the configured shutdown delay).
return stopped
class RecordingViewSet(viewsets.ModelViewSet):
queryset = Recording.objects.all()
serializer_class = RecordingSerializer
@ -2342,72 +2410,290 @@ class RecordingViewSet(viewsets.ModelViewSet):
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
return response
@action(detail=True, methods=["post"], url_path="stop")
def stop(self, request, pk=None):
"""Stop a recording early while retaining the partial content for playback."""
instance = self.get_object()
cp = instance.custom_properties or {}
current_status = cp.get("status", "")
# Reject stop on recordings that are already in a terminal state.
# Without this guard, stop() would overwrite "completed" or
# "interrupted" with "stopped", losing the original outcome.
terminal = {"completed", "interrupted", "failed"}
if current_status in terminal:
return Response(
{"success": False, "error": f"Recording is already {current_status}"},
status=status.HTTP_409_CONFLICT,
)
# Mark as stopped in the DB first so run_recording detects it.
# This is the only operation that MUST be synchronous — run_recording reads
# the status field to decide whether the stream disconnection was deliberate.
cp["status"] = "stopped"
cp["stopped_at"] = str(timezone.now())
instance.custom_properties = cp
instance.save(update_fields=["custom_properties"])
# Send the WebSocket notification before returning the response.
# send_websocket_update is gevent-safe (offloads async_to_sync to a
# real OS thread when monkey-patching is active).
channel_uuid = str(instance.channel.uuid)
recording_id = instance.id
task_id = instance.task_id
channel_name = instance.channel.name
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_stopped",
"channel": channel_name,
})
except Exception:
pass
# DVR client teardown and task revocation are deferred to a daemon thread
# because they have occasional slow paths (Redis timeouts, Celery control
# broadcasts) that would otherwise add 5-15 s to the HTTP response time.
def _background_stop():
try:
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
if stopped:
logger.info(
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} (recording stopped early)"
)
except Exception as e:
logger.debug(f"Unable to stop DVR clients for stopped recording: {e}")
try:
from apps.channels.signals import revoke_task
revoke_task(task_id)
except Exception as e:
logger.debug(f"Unable to revoke task for stopped recording: {e}")
try:
from django.db import connection as _conn
_conn.close()
except Exception:
pass
threading.Thread(target=_background_stop, daemon=True).start()
return Response({"success": True, "status": "stopped"})
@action(detail=True, methods=["post"], url_path="extend")
def extend(self, request, pk=None):
"""Extend an in-progress recording's end_time without interrupting the stream.
The running task re-reads end_time every ~2 s and adjusts its deadline
dynamically. The pre_save signal skips task revocation while the
recording status is 'recording'.
"""
instance = self.get_object()
cp = instance.custom_properties or {}
if cp.get("status") in ("completed", "stopped", "interrupted"):
return Response(
{"success": False, "error": "Recording has already finished"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
extra_minutes = int(request.data.get("extra_minutes", 0))
except (TypeError, ValueError):
extra_minutes = 0
if extra_minutes <= 0:
return Response(
{"success": False, "error": "extra_minutes must be a positive integer"},
status=status.HTTP_400_BAD_REQUEST,
)
new_end_time = instance.end_time + timedelta(minutes=extra_minutes)
# Use queryset .update() to bypass pre_save/post_save signals.
# This avoids the pre_save signal revoking the scheduled/running
# Celery task. The running task's 2-second polling loop re-reads
# end_time from the DB and extends its deadline dynamically.
# If the task hasn't started yet (still in Beat's queue), it will
# read the updated end_time from the DB on its first poll cycle.
Recording.objects.filter(pk=instance.pk).update(end_time=new_end_time)
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_extended",
"recording_id": instance.id,
"new_end_time": new_end_time.isoformat(),
"extra_minutes": extra_minutes,
"channel": instance.channel.name,
})
except Exception:
pass
return Response({"success": True, "new_end_time": new_end_time.isoformat()})
@action(detail=True, methods=["post"], url_path="refresh-artwork")
def refresh_artwork(self, request, pk=None):
"""Re-run the poster resolution pipeline for this recording.
Useful when a recording fell back to a channel logo or default logo
because external sources were temporarily unavailable.
"""
instance = self.get_object()
def _background_refresh(rec_id):
try:
from .tasks import _resolve_poster_for_program
from .models import Recording
from core.utils import send_websocket_update
from django.db import close_old_connections
rec = Recording.objects.select_related("channel").get(id=rec_id)
cp = rec.custom_properties or {}
program = cp.get("program") or {}
poster_logo_id, poster_url = _resolve_poster_for_program(
rec.channel.name, program, channel_logo_id=rec.channel.logo_id,
)
# Refresh and merge to avoid overwriting concurrent changes.
# Only upgrade — never replace a real poster with a channel logo fallback.
rec.refresh_from_db()
fresh_cp = rec.custom_properties or {}
updated = False
is_channel_logo_fallback = (
poster_logo_id == rec.channel.logo_id
and not poster_url
)
if program and program.get("id"):
fresh_cp["program"] = program
updated = True
if not is_channel_logo_fallback:
if poster_logo_id and fresh_cp.get("poster_logo_id") != poster_logo_id:
fresh_cp["poster_logo_id"] = poster_logo_id
updated = True
if poster_url and fresh_cp.get("poster_url") != poster_url:
fresh_cp["poster_url"] = poster_url
updated = True
if updated:
rec.custom_properties = fresh_cp
rec.save(update_fields=["custom_properties"])
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_updated",
"recording_id": rec_id,
})
except Exception as e:
logger.debug(f"refresh-artwork background failed for {rec_id}: {e}")
finally:
close_old_connections()
t = threading.Thread(target=_background_refresh, args=(instance.id,), daemon=True)
t.start()
return Response({"success": True, "message": "Artwork refresh started"})
@action(detail=True, methods=["post"], url_path="update-metadata")
def update_metadata(self, request, pk=None):
"""Update user-editable recording metadata (title, description).
Sets user_edited flag to prevent EPG auto-enrichment from overwriting
the user's changes on subsequent task runs.
"""
instance = self.get_object()
title = request.data.get("title")
description = request.data.get("description")
if title is None and description is None:
return Response(
{"success": False, "error": "No fields to update"},
status=status.HTTP_400_BAD_REQUEST,
)
# Strip whitespace; treat blank strings as "no change"
clean_title = str(title).strip() if title is not None else None
clean_desc = str(description).strip() if description is not None else None
if not clean_title and not clean_desc:
return Response(
{"success": False, "error": "Title and description cannot be blank"},
status=status.HTTP_400_BAD_REQUEST,
)
cp = instance.custom_properties or {}
program = cp.get("program") or {}
if clean_title:
program["title"] = clean_title
if clean_desc:
program["description"] = clean_desc
program["user_edited"] = True
cp["program"] = program
instance.custom_properties = cp
instance.save(update_fields=["custom_properties"])
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_updated",
"recording_id": instance.id,
})
except Exception:
pass
return Response({"success": True})
def destroy(self, request, *args, **kwargs):
"""Delete the Recording and ensure any active DVR client connection is closed.
Also removes the associated file(s) from disk if present.
Operation order matters for correctness:
1. Delete the DB record first run_recording's cancellation guard
(Recording.objects.filter(id=...).exists()) will now return False,
preventing it from saving 'interrupted' status or sending
recording_ended after the stream is torn down.
2. Send recording_cancelled WebSocket immediately so the frontend
removes the card without waiting for the slow DVR client teardown.
3. Spawn a background thread to stop the DVR client and delete files.
This mirrors the stop() endpoint's approach and avoids the 5-15 s
delay that _stop_dvr_clients() can introduce.
"""
instance = self.get_object()
recording_id = instance.pk
channel_name = instance.channel.name
# Attempt to close the DVR client connection for this channel if active
try:
channel_uuid = str(instance.channel.uuid)
# Lazy imports to avoid module overhead if proxy isn't used
from core.utils import RedisClient
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.services.channel_service import ChannelService
r = RedisClient.get_client()
if r:
client_set_key = RedisKeys.clients(channel_uuid)
client_ids = r.smembers(client_set_key) or []
stopped = 0
for raw_id in client_ids:
try:
cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
ua = r.hget(meta_key, "user_agent")
ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "")
# Identify DVR recording client by its user agent
if ua_s and "Dispatcharr-DVR" in ua_s:
try:
ChannelService.stop_client(channel_uuid, cid)
stopped += 1
except Exception as inner_e:
logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}")
except Exception as inner:
logger.debug(f"Error while checking client metadata: {inner}")
if stopped:
logger.info(f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation")
# If no clients remain after stopping DVR clients, proactively stop the channel
try:
remaining = r.scard(client_set_key) or 0
except Exception:
remaining = 0
if remaining == 0:
try:
ChannelService.stop_channel(channel_uuid)
logger.info(f"Stopped channel {channel_uuid} (no clients remain)")
except Exception as sc_e:
logger.debug(f"Unable to stop channel {channel_uuid}: {sc_e}")
except Exception as e:
logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}")
# Capture paths before deletion
# Capture state before the DB row is deleted
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
file_path = cp.get("file_path")
temp_ts_path = cp.get("_temp_file_path")
channel_uuid = str(instance.channel.uuid)
# Perform DB delete first, then try to remove files
# 1. Delete the DB record (also fires post_delete → revoke_task_on_delete)
response = super().destroy(request, *args, **kwargs)
# Notify frontends to refresh recordings
# 2. Notify frontends immediately
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"})
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_cancelled",
"recording_id": recording_id,
"channel": channel_name,
"was_in_progress": rec_status == "recording",
})
except Exception:
pass
# 3. Defer slow teardown to a background thread
library_dir = '/data'
allowed_roots = ['/data/', library_dir.rstrip('/') + '/']
@ -2421,8 +2707,32 @@ class RecordingViewSet(viewsets.ModelViewSet):
except Exception as ex:
logger.warning(f"Failed to delete recording artifact {path}: {ex}")
_safe_remove(file_path)
_safe_remove(temp_ts_path)
def _background_cancel():
# Only stop the DVR client if the recording was actively streaming.
# Stopping for completed/upcoming recordings would kill an unrelated
# in-progress recording on the same channel.
if rec_status == "recording":
try:
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
if stopped:
logger.info(
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation"
)
except Exception as e:
logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}")
# Best-effort file cleanup in case run_recording already exited
# before the DB delete.
_safe_remove(file_path)
_safe_remove(temp_ts_path)
try:
from django.db import connection as _conn
_conn.close()
except Exception:
pass
threading.Thread(target=_background_cancel, daemon=True).start()
return response
@ -2535,11 +2845,9 @@ class SeriesRulesAPIView(APIView):
else:
rules.append({"tvg_id": tvg_id, "mode": mode, "title": title})
CoreSettings.set_dvr_series_rules(rules)
# Evaluate immediately for this tvg_id (async)
try:
evaluate_series_rules.delay(tvg_id)
except Exception:
pass
# Note: frontend calls the evaluate endpoint explicitly after creating
# the rule, so do NOT fire evaluate_series_rules.delay() here to
# avoid a race that creates duplicate recordings.
return Response({"success": True, "rules": rules})
@ -2552,16 +2860,44 @@ class DeleteSeriesRuleAPIView(APIView):
@extend_schema(
summary="Delete a series rule",
description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
description="Remove a series recording rule by TVG ID and clean up future scheduled recordings.",
parameters=[
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
],
)
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
CoreSettings.set_dvr_series_rules(rules)
return Response({"success": True, "rules": rules})
# Find the rule before removing to retain the title for cleanup
rules = CoreSettings.get_dvr_series_rules()
deleted_rule = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None)
remaining = [r for r in rules if str(r.get("tvg_id")) != tvg_id]
CoreSettings.set_dvr_series_rules(remaining)
# Delete only FUTURE recordings — preserve previously recorded episodes
removed = 0
if deleted_rule:
from .models import Recording
qs = Recording.objects.filter(
start_time__gte=timezone.now(),
custom_properties__program__tvg_id=tvg_id,
)
title = deleted_rule.get("title")
if title:
qs = qs.filter(custom_properties__program__title=title)
removed = qs.count()
qs.delete()
# Notify frontend to refresh recordings list
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True, "type": "recordings_refreshed", "removed": removed,
})
except Exception:
pass
return Response({"success": True, "rules": remaining, "removed": removed})
class EvaluateSeriesRulesAPIView(APIView):

View file

@ -2,14 +2,15 @@
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.timezone import now, is_aware, make_aware
from celery.result import AsyncResult
from django_celery_beat.models import ClockedSchedule, PeriodicTask
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording
from apps.m3u.models import M3UAccount
from apps.epg.tasks import parse_programs_for_tvg_id
import logging, requests, time
import json
import logging
from .tasks import run_recording, prefetch_recording_artwork
from django.utils.timezone import now, is_aware, make_aware
from datetime import timedelta
logger = logging.getLogger(__name__)
@ -85,29 +86,76 @@ def create_profile_memberships(sender, instance, created, **kwargs):
for channel in channels
])
def _dvr_task_name(recording_id):
"""Predictable PeriodicTask name for a DVR recording."""
return f"dvr-recording-{recording_id}"
def schedule_recording_task(instance, eta=None):
# Use the explicitly-passed (and timezone-aware) eta if provided;
# fall back to instance.start_time only as a last resort.
"""Schedule a recording task via ClockedSchedule + one-off PeriodicTask.
The task is stored in the database and dispatched by Celery Beat at the
scheduled time with no countdown. This avoids the Redis visibility_timeout
redelivery bug that caused duplicate recordings when using apply_async
with long countdowns.
"""
if eta is None:
eta = instance.start_time
# Ensure eta is timezone-aware before comparing against now()
if eta is not None and not is_aware(eta):
eta = make_aware(eta)
# countdown=0 fires immediately (in-progress programs whose start_time was
# clamped to now by the serializer), countdown>0 delays until start_time
# (future programs). Using an integer countdown avoids any timezone
# serialization ambiguity that can occur with an absolute eta datetime.
countdown = max(0, int((eta - now()).total_seconds()))
# Pass recording_id first so task can persist metadata to the correct row
task = run_recording.apply_async(
args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)],
countdown=countdown,
# Clamp to now so Beat dispatches immediately for past/current start times
if eta <= now():
eta = now()
task_args = [
instance.id,
instance.channel_id,
str(instance.start_time),
str(instance.end_time),
]
clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=eta)
task_name = _dvr_task_name(instance.id)
PeriodicTask.objects.update_or_create(
name=task_name,
defaults={
"task": "apps.channels.tasks.run_recording",
"clocked": clocked,
"args": json.dumps(task_args),
"one_off": True,
"enabled": True,
"interval": None,
"crontab": None,
"solar": None,
},
)
return task.id
return task_name
def revoke_task(task_id):
if task_id:
"""Cancel a pending recording task.
task_id is normally a PeriodicTask name (e.g. "dvr-recording-42").
For backwards compatibility with legacy Celery async-result UUIDs,
falls back to AsyncResult.revoke().
"""
if not task_id:
return
# Primary path: delete the PeriodicTask and clean up its ClockedSchedule
try:
pt = PeriodicTask.objects.get(name=task_id)
old_clocked = pt.clocked
pt.delete()
if old_clocked and not PeriodicTask.objects.filter(clocked=old_clocked).exists():
old_clocked.delete()
return
except PeriodicTask.DoesNotExist:
pass
# Fallback for legacy Celery task UUIDs
try:
AsyncResult(task_id).revoke()
except Exception:
pass
@receiver(pre_save, sender=Recording)
def revoke_old_task_on_update(sender, instance, **kwargs):
@ -120,6 +168,12 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
old.end_time != instance.end_time or
old.channel_id != instance.channel_id
):
# Do NOT revoke while the recording is actively streaming.
# run_recording re-reads end_time from the DB every ~2 s and extends
# its internal deadline dynamically — revoking here would kill the task.
old_status = (old.custom_properties or {}).get("status", "")
if old_status == "recording":
return
revoke_task(old.task_id)
instance.task_id = None
except Recording.DoesNotExist:
@ -128,35 +182,50 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
@receiver(post_save, sender=Recording)
def schedule_task_on_save(sender, instance, created, **kwargs):
try:
# Skip processing for internal field-only saves (metadata updates,
# task_id assignment, end_time extensions) to prevent re-entrant
# artwork dispatch and redundant recording_updated WS events.
update_fields = kwargs.get('update_fields')
if not created and update_fields is not None and set(update_fields) <= {'custom_properties', 'task_id', 'end_time'}:
return
if not instance.task_id:
start_time = instance.start_time
end_time = instance.end_time
# Make both datetimes aware (in UTC)
# Make datetimes aware (in UTC)
if not is_aware(start_time):
print("Start time was not aware, making aware")
start_time = make_aware(start_time)
if end_time and not is_aware(end_time):
end_time = make_aware(end_time)
current_time = now()
# Debug log
print(f"Start time: {start_time}, Now: {current_time}")
# Optionally allow slight fudge factor (1 second) to ensure scheduling happens
if start_time > current_time - timedelta(seconds=1):
print("Scheduling recording task!")
# Pass the corrected, timezone-aware start_time explicitly so
# schedule_recording_task uses it as the Celery ETA rather than
# re-reading instance.start_time which may still be naive.
# Future recording — schedule at start_time
logger.info(f"Recording {instance.id}: scheduling task at {start_time}")
task_id = schedule_recording_task(instance, eta=start_time)
instance.task_id = task_id
instance.save(update_fields=['task_id'])
elif end_time and end_time > current_time:
# Currently-playing — start immediately (e.g. series rule for in-progress program)
logger.info(f"Recording {instance.id}: start_time in past but end_time still future, scheduling immediately")
task_id = schedule_recording_task(instance, eta=current_time)
instance.task_id = task_id
instance.save(update_fields=['task_id'])
else:
print("Start time is in the past. Not scheduling.")
# Kick off poster/artwork prefetch to enrich Upcoming cards
try:
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
except Exception as e:
print("Error scheduling artwork prefetch:", e)
logger.info(f"Recording {instance.id}: start_time and end_time both in past, not scheduling")
# Kick off poster/artwork prefetch to enrich Upcoming cards.
# Skip when the recording is already active or finished — run_recording
# handles its own poster resolution, and scheduling artwork prefetch
# while the task is running causes a race that can overwrite status.
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
if rec_status not in ("recording", "completed", "stopped", "interrupted"):
try:
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
except Exception as e:
print("Error scheduling artwork prefetch:", e)
except Exception as e:
import traceback
print("Error in post_save signal:", e)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,278 @@
"""Tests for DVR retry logic.
Covers:
- _db_retry(): exponential backoff, max retries, connection reset
- Final metadata save retry in run_recording post-processing
- Initial TS proxy connection retry (per-base retry on retriable errors)
- recover_recordings_on_startup DB retry wrappers
"""
from datetime import timedelta
from unittest.mock import MagicMock, patch, call
from django.db import OperationalError
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel, Recording
from apps.channels.tasks import _db_retry
# ---------------------------------------------------------------------------
# _db_retry unit tests
# ---------------------------------------------------------------------------
class DbRetryTests(TestCase):
"""Tests for the _db_retry() exponential backoff helper."""
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_succeeds_on_first_attempt(self, _close, _sleep):
"""No retry needed when fn succeeds immediately."""
result = _db_retry(lambda: "ok", max_retries=3)
self.assertEqual(result, "ok")
_sleep.assert_not_called()
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_retries_on_operational_error_then_succeeds(self, mock_close, mock_sleep):
"""Retry succeeds on second attempt after OperationalError."""
call_count = {"n": 0}
def flaky():
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("connection reset")
return "recovered"
result = _db_retry(flaky, max_retries=3, base_interval=1)
self.assertEqual(result, "recovered")
self.assertEqual(call_count["n"], 2)
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_raises_after_max_retries_exhausted(self, mock_close, mock_sleep):
"""Raises OperationalError after all retries fail."""
def always_fail():
raise OperationalError("db gone")
with self.assertRaises(OperationalError):
_db_retry(always_fail, max_retries=3, base_interval=1)
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_exponential_backoff_timing(self, mock_close, mock_sleep):
"""Sleep durations follow exponential backoff: 1s, 2s, 4s."""
call_count = {"n": 0}
def fail_twice():
call_count["n"] += 1
if call_count["n"] <= 2:
raise OperationalError("retry me")
return "done"
_db_retry(fail_twice, max_retries=3, base_interval=1)
mock_sleep.assert_has_calls([call(1), call(2)])
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_close_old_connections_called_between_retries(self, mock_close, mock_sleep):
"""Stale DB connections are reset before each retry attempt."""
call_count = {"n": 0}
def fail_once():
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("stale conn")
return "ok"
_db_retry(fail_once, max_retries=3)
mock_close.assert_called_once()
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_non_operational_error_not_retried(self, mock_close, mock_sleep):
"""Non-OperationalError exceptions propagate immediately."""
def raise_value_error():
raise ValueError("not a DB error")
with self.assertRaises(ValueError):
_db_retry(raise_value_error, max_retries=3)
mock_sleep.assert_not_called()
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_returns_fn_return_value(self, mock_close, mock_sleep):
"""Return value of fn() is passed through."""
result = _db_retry(lambda: {"key": "value"}, max_retries=3)
self.assertEqual(result, {"key": "value"})
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_single_retry_allowed(self, mock_close, mock_sleep):
"""max_retries=1 means no retry — fail immediately."""
with self.assertRaises(OperationalError):
_db_retry(
lambda: (_ for _ in ()).throw(OperationalError("fail")),
max_retries=1,
)
mock_sleep.assert_not_called()
# ---------------------------------------------------------------------------
# Final metadata save retry integration tests
# ---------------------------------------------------------------------------
class FinalMetadataSaveRetryTests(TestCase):
"""The final recording metadata save must retry on transient DB errors."""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=95, name="Retry Test Channel"
)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_metadata_save_uses_db_retry(self, _ws):
"""Verify recording metadata is saved via _db_retry (retries on OperationalError)."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
# Directly call _db_retry to save metadata as run_recording does
cp = rec.custom_properties.copy()
cp["status"] = "completed"
cp["ended_at"] = str(now)
cp["bytes_written"] = 1024
def _save():
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_db_retry(_save, max_retries=3, base_interval=1, label="test save")
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], "completed")
self.assertEqual(rec.custom_properties["bytes_written"], 1024)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_metadata_survives_transient_save_failure(self, _ws):
"""Simulate OperationalError on first save, success on retry."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
cp = {"status": "completed", "bytes_written": 2048}
call_count = {"n": 0}
_real_save = rec.save
def patched_save(**kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("connection reset by peer")
return _real_save(**kwargs)
with patch.object(rec, "save", side_effect=patched_save):
with patch("apps.channels.tasks.time.sleep"):
with patch("apps.channels.tasks.close_old_connections"):
def _save():
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_db_retry(_save, max_retries=3, base_interval=1, label="test")
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], "completed")
# ---------------------------------------------------------------------------
# Initial connection retry tests
# ---------------------------------------------------------------------------
class InitialConnectionRetryTests(TestCase):
"""Verify that the DVR task's reconnection logic retries the same
base URL before falling back to the next candidate."""
def test_reconnect_max_constant_exists_in_run_recording(self):
"""run_recording must define a max-reconnect limit to prevent
infinite retries on the same broken base URL."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
# The reconnection counter pattern must be present
self.assertIn("reconnect", source.lower(),
"run_recording must contain reconnection logic")
# ---------------------------------------------------------------------------
# recover_recordings_on_startup retry tests
# ---------------------------------------------------------------------------
class RecoveryRetryTests(TestCase):
"""DB operations in recover_recordings_on_startup must use _db_retry."""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=97, name="Recovery Retry Channel"
)
@patch("apps.channels.tasks.run_recording.apply_async")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_recovery_save_retries_on_operational_error(self, _ws, mock_async):
"""Recovery status update uses _db_retry — survives one OperationalError."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=30),
end_time=now + timedelta(minutes=30),
custom_properties={},
)
# Simulate what recovery does: mark interrupted, then save with retry
cp = rec.custom_properties or {}
cp["status"] = "interrupted"
cp["interrupted_reason"] = "server_restarted"
rec.custom_properties = cp
call_count = {"n": 0}
_real_save = Recording.save
def patched_save(self_rec, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("db temporarily unavailable")
return _real_save(self_rec, **kwargs)
with patch.object(Recording, "save", patched_save):
with patch("apps.channels.tasks.time.sleep"):
with patch("apps.channels.tasks.close_old_connections"):
_db_retry(
lambda: rec.save(update_fields=["custom_properties"]),
max_retries=3,
label="test recovery",
)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("status"), "interrupted")
self.assertEqual(rec.custom_properties.get("interrupted_reason"), "server_restarted")
def test_db_retry_fetches_recording_list(self):
"""_db_retry correctly returns query results for recording list fetch."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=30),
end_time=now + timedelta(minutes=30),
custom_properties={},
)
result = _db_retry(
lambda: list(Recording.objects.filter(
start_time__lte=now, end_time__gt=now
)),
label="test query",
)
self.assertGreaterEqual(len(result), 1)
ids = [r.id for r in result]
self.assertIn(rec.id, ids)

View file

@ -0,0 +1,59 @@
import os
from django.test import SimpleTestCase
from unittest.mock import patch
from apps.channels.tasks import build_dvr_candidates
class DVRPortResolutionTests(SimpleTestCase):
"""
Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT
environment variable instead of hardcoding port 9191.
"""
@patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True)
def test_default_port_uses_9191(self):
"""Without DISPATCHARR_PORT set, candidates default to 9191."""
candidates = build_dvr_candidates()
self.assertIn('http://web:9191', candidates)
self.assertIn('http://localhost:9191', candidates)
@patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True)
def test_custom_port_reflected_in_candidates(self):
"""DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references."""
candidates = build_dvr_candidates()
self.assertIn('http://web:8080', candidates)
self.assertIn('http://localhost:8080', candidates)
self.assertNotIn('http://web:9191', candidates)
self.assertNotIn('http://localhost:9191', candidates)
@patch.dict(os.environ, {
'DISPATCHARR_PORT': '7777',
'DISPATCHARR_ENV': 'dev',
'REDIS_HOST': 'redis',
}, clear=True)
def test_dev_mode_includes_5656_and_custom_port(self):
"""Dev mode includes both uwsgi internal port (5656) and custom port."""
candidates = build_dvr_candidates()
self.assertIn('http://127.0.0.1:5656', candidates)
self.assertIn('http://127.0.0.1:7777', candidates)
@patch.dict(os.environ, {
'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234',
'REDIS_HOST': 'redis',
}, clear=True)
def test_explicit_override_is_first(self):
"""DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate."""
candidates = build_dvr_candidates()
self.assertEqual(candidates[0], 'http://custom:1234')
@patch.dict(os.environ, {
'DISPATCHARR_PORT': '3000',
'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000',
'REDIS_HOST': 'redis',
}, clear=True)
def test_internal_api_base_overrides_web_fallback(self):
"""DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default."""
candidates = build_dvr_candidates()
self.assertIn('http://myhost:4000', candidates)
self.assertNotIn('http://web:3000', candidates)

View file

@ -0,0 +1,180 @@
"""Tests for the _match_epg_program_by_timeslot() helper in tasks.py.
Covers:
- Exact time-slot match returns program dict
- 80% overlap threshold: at boundary, above, and below
- Multiple overlapping programs: dominant vs. evenly split
- Edge cases: None inputs, zero-duration recording, no EPG data
- Returned dict structure (id, title, sub_title, description)
"""
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel
from apps.epg.models import EPGSource, EPGData, ProgramData
from apps.channels.tasks import _match_epg_program_by_timeslot
class EpgMatchingSetupMixin:
"""Shared setup for EPG matching tests."""
def setUp(self):
self.source = EPGSource.objects.create(name="Test Source")
self.epg = EPGData.objects.create(
tvg_id="test.channel", name="Test Channel EPG", epg_source=self.source,
)
self.channel = Channel.objects.create(
channel_number=50, name="EPG Match Channel", epg_data=self.epg,
)
self.base = timezone.now().replace(second=0, microsecond=0)
def _prog(self, offset_min, duration_min, title="Test Show", **kwargs):
"""Create a ProgramData starting offset_min from self.base."""
start = self.base + timedelta(minutes=offset_min)
end = start + timedelta(minutes=duration_min)
return ProgramData.objects.create(
epg=self.epg, start_time=start, end_time=end, title=title, **kwargs,
)
class ExactMatchTests(EpgMatchingSetupMixin, TestCase):
"""Recording window exactly matches an EPG program."""
def test_exact_match_returns_program_dict(self):
prog = self._prog(0, 60, title="News at 9", sub_title="Top Stories",
description="Evening news broadcast")
result = _match_epg_program_by_timeslot(
self.epg, prog.start_time, prog.end_time,
)
self.assertIsNotNone(result)
self.assertEqual(result["id"], prog.id)
self.assertEqual(result["title"], "News at 9")
self.assertEqual(result["sub_title"], "Top Stories")
self.assertEqual(result["description"], "Evening news broadcast")
def test_missing_optional_fields_returned_as_empty_strings(self):
prog = self._prog(0, 30, title="Minimal Show")
result = _match_epg_program_by_timeslot(
self.epg, prog.start_time, prog.end_time,
)
self.assertIsNotNone(result)
self.assertEqual(result["sub_title"], "")
self.assertEqual(result["description"], "")
class OverlapThresholdTests(EpgMatchingSetupMixin, TestCase):
"""80% overlap threshold boundary tests."""
def test_exactly_80_percent_overlap_returns_match(self):
"""Program covers exactly 80% of the recording window."""
# Program: 0-60min, Recording: 0-75min → overlap = 60/75 = 80%
prog = self._prog(0, 60, title="Borderline Show")
rec_start = self.base
rec_end = self.base + timedelta(minutes=75)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Borderline Show")
def test_below_80_percent_returns_none(self):
"""Program covers 79% of the recording — below threshold."""
# Program: 0-60min, Recording: 0-76min → overlap = 60/76 ≈ 78.9%
prog = self._prog(0, 60, title="Too Short")
rec_start = self.base
rec_end = self.base + timedelta(minutes=76)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNone(result)
def test_above_80_percent_returns_match(self):
"""Program covers 90% of the recording."""
# Program: 0-60min, Recording: 0-66min → overlap = 60/66 ≈ 90.9%
prog = self._prog(0, 60, title="Good Match")
rec_start = self.base
rec_end = self.base + timedelta(minutes=66)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Good Match")
class MultipleProgramTests(EpgMatchingSetupMixin, TestCase):
"""Recording spans multiple EPG programs."""
def test_dominant_program_returned(self):
"""Recording spans 2 programs; one covers 85%, the other 15%."""
# Show A: 0-60min, Show B: 60-120min
# Recording: 9-69min → A overlap=51/60=85%, B overlap=9/60=15%
self._prog(0, 60, title="Show A")
self._prog(60, 60, title="Show B")
rec_start = self.base + timedelta(minutes=9)
rec_end = self.base + timedelta(minutes=69)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Show A")
def test_evenly_split_returns_none(self):
"""Recording spans 2 equal programs — neither reaches 80%."""
# Show A: 0-60min, Show B: 60-120min
# Recording: 30-90min → each covers 50%
self._prog(0, 60, title="Show A")
self._prog(60, 60, title="Show B")
rec_start = self.base + timedelta(minutes=30)
rec_end = self.base + timedelta(minutes=90)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNone(result)
def test_three_programs_one_dominant(self):
"""Recording spans 3 programs; middle one is dominant."""
# A: 0-30min, B: 30-90min, C: 90-120min
# Recording: 25-95min (70min window) → B overlap=60/70≈85.7%
self._prog(0, 30, title="Show A")
self._prog(30, 60, title="Show B")
self._prog(90, 30, title="Show C")
rec_start = self.base + timedelta(minutes=25)
rec_end = self.base + timedelta(minutes=95)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Show B")
class EdgeCaseTests(EpgMatchingSetupMixin, TestCase):
"""Edge cases and error handling."""
def test_none_epg_data_returns_none(self):
result = _match_epg_program_by_timeslot(None, self.base, self.base + timedelta(hours=1))
self.assertIsNone(result)
def test_none_start_time_returns_none(self):
result = _match_epg_program_by_timeslot(self.epg, None, self.base + timedelta(hours=1))
self.assertIsNone(result)
def test_none_end_time_returns_none(self):
result = _match_epg_program_by_timeslot(self.epg, self.base, None)
self.assertIsNone(result)
def test_zero_duration_returns_none(self):
"""Recording with start == end should return None."""
result = _match_epg_program_by_timeslot(self.epg, self.base, self.base)
self.assertIsNone(result)
def test_negative_duration_returns_none(self):
"""Recording with end before start should return None."""
result = _match_epg_program_by_timeslot(
self.epg, self.base + timedelta(hours=1), self.base,
)
self.assertIsNone(result)
def test_no_overlapping_programs_returns_none(self):
"""No EPG programs in the recording window."""
self._prog(0, 60, title="Earlier Show")
rec_start = self.base + timedelta(hours=5)
rec_end = rec_start + timedelta(hours=1)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNone(result)
def test_empty_epg_no_programs_returns_none(self):
"""EPGData exists but has no programs."""
result = _match_epg_program_by_timeslot(
self.epg, self.base, self.base + timedelta(hours=1),
)
self.assertIsNone(result)

View file

@ -0,0 +1,235 @@
"""Tests for the Extend In-Progress Recording feature.
Covers:
- extend() API endpoint (happy path and validation)
- pre_save signal guard: end_time change must NOT revoke a live recording
- pre_save signal guard: end_time change MUST still revoke an upcoming recording
- TOCTOU edge cases (extend on a completed/stopped/nonexistent recording)
"""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording
from apps.channels.api_views import RecordingViewSet
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="extend_test_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
# ---------------------------------------------------------------------------
# Extend endpoint tests
# ---------------------------------------------------------------------------
class ExtendEndpointTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/extend/"""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=88, name="Extend Test Channel"
)
self.user = _make_admin()
self.factory = APIRequestFactory()
def _extend(self, rec, extra_minutes):
request = self.factory.post(
f"/api/channels/recordings/{rec.id}/extend/",
{"extra_minutes": extra_minutes},
format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "extend"})
return view(request, pk=rec.id)
def _make_rec(self, status="recording"):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": status},
)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_updates_end_time_in_db(self, _ws):
rec = self._make_rec()
original_end = rec.end_time
response = self._extend(rec, 30)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data.get("success"))
rec.refresh_from_db()
expected = original_end + timedelta(minutes=30)
delta = abs((rec.end_time - expected).total_seconds())
self.assertLess(delta, 1, "end_time was not extended by the correct amount")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_stacks_multiple_extensions(self, _ws):
"""Calling extend() twice adds both increments."""
rec = self._make_rec()
original_end = rec.end_time
self._extend(rec, 15)
self._extend(rec, 30)
rec.refresh_from_db()
expected = original_end + timedelta(minutes=45)
delta = abs((rec.end_time - expected).total_seconds())
self.assertLess(delta, 1)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_does_not_clear_task_id(self, _ws):
"""The running Celery task must survive the DB save."""
rec = self._make_rec()
rec.task_id = "dvr-recording-999"
rec.save(update_fields=["task_id"])
self._extend(rec, 30)
rec.refresh_from_db()
self.assertEqual(rec.task_id, "dvr-recording-999")
def test_extend_returns_400_if_finished(self):
"""Cannot extend a completed, stopped, or interrupted recording."""
for bad_status in ("completed", "stopped", "interrupted"):
with self.subTest(status=bad_status):
rec = self._make_rec(status=bad_status)
response = self._extend(rec, 30)
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_succeeds_before_task_sets_status(self, _ws):
"""Extend must work when status is empty (task hasn't started yet)."""
rec = self._make_rec(status="")
response = self._extend(rec, 15)
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
expected = rec.end_time # already extended
self.assertTrue(response.data.get("success"))
@patch("apps.channels.signals.revoke_task")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_bypasses_signals_no_revoke(self, _ws, mock_revoke):
"""Extend uses .update() to bypass pre_save — revoke_task must never fire."""
rec = self._make_rec(status="")
rec.task_id = "dvr-recording-500"
rec.save(update_fields=["task_id"])
self._extend(rec, 15)
self._extend(rec, 30)
mock_revoke.assert_not_called()
rec.refresh_from_db()
self.assertEqual(rec.task_id, "dvr-recording-500")
def test_extend_returns_400_for_zero_minutes(self):
response = self._extend(self._make_rec(), 0)
self.assertEqual(response.status_code, 400)
def test_extend_returns_400_for_negative_minutes(self):
response = self._extend(self._make_rec(), -15)
self.assertEqual(response.status_code, 400)
def test_extend_returns_400_for_non_numeric_minutes(self):
rec = self._make_rec()
request = self.factory.post(
f"/api/channels/recordings/{rec.id}/extend/",
{"extra_minutes": "lots"},
format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "extend"})
response = view(request, pk=rec.id)
self.assertEqual(response.status_code, 400)
def test_extend_returns_404_for_nonexistent_recording(self):
request = self.factory.post(
"/api/channels/recordings/999999/extend/",
{"extra_minutes": 30},
format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "extend"})
response = view(request, pk=999999)
self.assertEqual(response.status_code, 404)
# ---------------------------------------------------------------------------
# pre_save signal guard tests
# ---------------------------------------------------------------------------
class PreSaveExtendGuardTests(TestCase):
"""The pre_save signal must NOT revoke a live recording when end_time changes,
but MUST still revoke a scheduled (upcoming) recording as before."""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=77, name="Signal Guard Channel"
)
def _make_rec(self, status="", task_id="dvr-recording-42"):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
task_id=task_id,
custom_properties={"status": status} if status else {},
)
@patch("apps.channels.signals.revoke_task")
def test_end_time_change_does_not_revoke_live_recording(self, mock_revoke):
"""When status='recording', extending end_time must not call revoke_task."""
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
mock_revoke.assert_not_called()
@patch("apps.channels.signals.revoke_task")
def test_task_id_preserved_after_extend_on_live_recording(self, mock_revoke):
"""task_id must not be cleared for a live recording's end_time change."""
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
original_task_id = rec.task_id
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
rec.refresh_from_db()
self.assertEqual(rec.task_id, original_task_id)
@patch("apps.channels.signals.revoke_task")
def test_end_time_change_still_revokes_upcoming_recording(self, mock_revoke):
"""The guard must NOT apply to upcoming recordings — existing behavior preserved."""
rec = self._make_rec(status="", task_id="dvr-recording-77")
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
mock_revoke.assert_called_once_with("dvr-recording-77")
@patch("apps.channels.signals.revoke_task")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_pre_save_guard_reads_db_status_not_memory_status(self, _ws, mock_revoke):
"""pre_save reads status from DB (old object), not from the instance being saved."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
task_id="dvr-recording-66",
custom_properties={"status": "recording"},
)
# Simulate: DB status changes to 'completed' behind the instance's back
Recording.objects.filter(pk=rec.pk).update(
custom_properties={"status": "completed"}
)
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
# revoke_task should be called because DB status is "completed", not "recording"
mock_revoke.assert_called_once_with("dvr-recording-66")

View file

@ -0,0 +1,379 @@
"""Tests for recording metadata endpoints and logo proxy negative cache.
Covers:
- update_metadata endpoint: title/description, user_edited flag, validation
- refresh_artwork endpoint: returns immediately, background thread behavior
- Logo proxy negative cache: cache hit/miss, expiry, eviction, success clears
"""
import time as time_mod
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording, Logo
from apps.channels.api_views import RecordingViewSet, LogoViewSet
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="metadata_test_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
# ---------------------------------------------------------------------------
# update_metadata endpoint
# ---------------------------------------------------------------------------
class UpdateMetadataTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/update-metadata/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=70, name="Meta Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _update(self, rec, data):
request = self.factory.post(
f"/api/channels/recordings/{rec.id}/update-metadata/",
data, format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "update_metadata"})
return view(request, pk=rec.id)
def _make_rec(self, custom_properties=None):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties=custom_properties or {},
)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_title_only(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": "My Show"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["title"], "My Show")
self.assertTrue(program["user_edited"])
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_description_only(self, _ws):
rec = self._make_rec({"program": {"title": "Existing Title"}})
response = self._update(rec, {"description": "A great episode"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["description"], "A great episode")
self.assertEqual(program["title"], "Existing Title")
self.assertTrue(program["user_edited"])
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_both_fields(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": "New Title", "description": "New Desc"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["title"], "New Title")
self.assertEqual(program["description"], "New Desc")
self.assertTrue(program["user_edited"])
def test_no_fields_returns_400(self):
rec = self._make_rec()
response = self._update(rec, {})
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_whitespace_trimmed(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": " Padded Title "})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["program"]["title"], "Padded Title")
def test_whitespace_only_title_returns_400(self):
"""Whitespace-only title and description should be rejected."""
rec = self._make_rec()
response = self._update(rec, {"title": " ", "description": " "})
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
def test_whitespace_only_title_with_valid_description(self):
"""Whitespace-only title is ignored; valid description is accepted."""
rec = self._make_rec({"program": {"title": "Original"}})
with patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None):
response = self._update(rec, {"title": " ", "description": "Valid desc"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
# Title should remain unchanged since the whitespace-only value is not applied
self.assertEqual(rec.custom_properties["program"]["title"], "Original")
self.assertEqual(rec.custom_properties["program"]["description"], "Valid desc")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_creates_program_dict_when_absent(self, _ws):
"""Recording with no program dict gets one created."""
rec = self._make_rec({"status": "completed"})
response = self._update(rec, {"title": "Brand New"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertIn("program", rec.custom_properties)
self.assertEqual(rec.custom_properties["program"]["title"], "Brand New")
def test_returns_404_for_nonexistent(self):
request = self.factory.post(
"/api/channels/recordings/99999/update-metadata/",
{"title": "Ghost"}, format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "update_metadata"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_sends_websocket_event(self, mock_ws):
rec = self._make_rec()
self._update(rec, {"title": "WS Test"})
mock_ws.assert_called_once()
payload = mock_ws.call_args[0][2]
self.assertEqual(payload["type"], "recording_updated")
self.assertEqual(payload["recording_id"], rec.id)
@patch("core.utils.send_websocket_update", side_effect=Exception("WS down"))
def test_ws_failure_does_not_fail_request(self, _ws):
"""WebSocket errors are silenced — the save still succeeds."""
rec = self._make_rec()
response = self._update(rec, {"title": "Resilient"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["program"]["title"], "Resilient")
# ---------------------------------------------------------------------------
# refresh_artwork endpoint
# ---------------------------------------------------------------------------
class RefreshArtworkTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/refresh-artwork/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=71, name="Artwork Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _refresh(self, rec):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
return view(request, pk=rec.id)
def _make_rec(self, custom_properties=None):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties=custom_properties or {},
)
@patch("threading.Thread")
def test_returns_200_immediately(self, mock_thread):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
response = self._refresh(rec)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data.get("success"))
@patch("threading.Thread")
def test_spawns_background_thread(self, mock_thread):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
self._refresh(rec)
mock_thread.assert_called_once()
self.assertTrue(mock_thread.call_args[1].get("daemon", False))
mock_thread.return_value.start.assert_called_once()
def test_returns_404_for_nonexistent(self):
request = self.factory.post("/api/channels/recordings/99999/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("django.db.close_old_connections")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_no_downgrade_to_channel_logo(self, _ws, _close):
"""When the pipeline returns the channel's own logo, existing poster is preserved."""
logo = Logo.objects.create(name="Channel Logo", url="https://example.com/ch.png")
self.channel.logo = logo
self.channel.save()
rec = self._make_rec({
"poster_logo_id": 999, # existing real poster
"poster_url": "https://tmdb.com/real-poster.jpg",
})
with patch("apps.channels.tasks._resolve_poster_for_program",
return_value=(logo.id, None)):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
# Run synchronously by intercepting the thread
captured_fn = None
def capture_thread(*args, **kwargs):
nonlocal captured_fn
captured_fn = kwargs.get("target") or args[0]
mock = MagicMock()
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
return mock
with patch("threading.Thread", side_effect=capture_thread):
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
view(request, pk=rec.id)
rec.refresh_from_db()
# Existing poster should be preserved — not downgraded to channel logo
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 999)
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/real-poster.jpg")
@patch("django.db.close_old_connections")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_upgrade_from_no_poster(self, _ws, _close):
"""When a recording has no poster and the pipeline finds one, it gets updated."""
rec = self._make_rec({"program": {"title": "Some Show", "id": 42}})
with patch("apps.channels.tasks._resolve_poster_for_program",
return_value=(555, "https://tmdb.com/new-poster.jpg")):
captured_fn = None
def capture_thread(*args, **kwargs):
nonlocal captured_fn
captured_fn = kwargs.get("target") or args[0]
mock = MagicMock()
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
return mock
with patch("threading.Thread", side_effect=capture_thread):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
view(request, pk=rec.id)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 555)
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/new-poster.jpg")
# ---------------------------------------------------------------------------
# Logo proxy negative cache
# ---------------------------------------------------------------------------
class LogoNegativeCacheTests(TestCase):
"""Tests for the _logo_fetch_failures negative cache in LogoViewSet.cache()."""
def setUp(self):
from apps.channels import api_views
self._failures = api_views._logo_fetch_failures
self._failures.clear()
self.factory = APIRequestFactory()
self.user = _make_admin()
def _fetch_logo(self, logo):
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
force_authenticate(request, user=self.user)
view = LogoViewSet.as_view({"get": "cache"})
return view(request, pk=logo.id)
def test_failed_url_cached_on_non_200(self):
"""Non-200 response adds URL to negative cache."""
logo = Logo.objects.create(name="Dead Logo", url="https://dead-cdn.com/logo.png")
mock_resp = MagicMock(status_code=404)
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
self.assertIn("https://dead-cdn.com/logo.png", self._failures)
def test_cached_failure_returns_404_immediately(self):
"""Subsequent request for a cached-failed URL returns 404 without making a request."""
logo = Logo.objects.create(name="Cached Fail", url="https://cached-fail.com/logo.png")
self._failures["https://cached-fail.com/logo.png"] = time_mod.monotonic() + 300
with patch("apps.channels.api_views.requests.get") as mock_get:
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
mock_get.assert_not_called()
def test_expired_cache_entry_allows_retry(self):
"""After TTL expires, a new request is made."""
logo = Logo.objects.create(name="Expired", url="https://expired.com/logo.png")
self._failures["https://expired.com/logo.png"] = time_mod.monotonic() - 1 # already expired
mock_resp = MagicMock(status_code=200)
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.iter_content = MagicMock(return_value=[b"img"])
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 200)
def test_success_clears_previous_failure(self):
"""A successful fetch removes the URL from the failure cache."""
url = "https://recovered.com/logo.png"
logo = Logo.objects.create(name="Recovered", url=url)
self._failures[url] = time_mod.monotonic() - 1 # expired
mock_resp = MagicMock(status_code=200)
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.iter_content = MagicMock(return_value=[b"img"])
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
self._fetch_logo(logo)
self.assertNotIn(url, self._failures)
def test_request_exception_cached(self):
"""Network errors are cached the same as non-200 responses."""
import requests
logo = Logo.objects.create(name="Timeout", url="https://timeout.com/logo.png")
with patch("apps.channels.api_views.requests.get", side_effect=requests.Timeout("timed out")), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
self.assertIn("https://timeout.com/logo.png", self._failures)
def test_eviction_when_cache_exceeds_256(self):
"""Stale entries are evicted when the cache grows past 256."""
now = time_mod.monotonic()
# Fill with 257 expired entries
for i in range(257):
self._failures[f"https://old-{i}.com/x.png"] = now - 1 # already expired
logo = Logo.objects.create(name="Trigger", url="https://trigger-evict.com/logo.png")
import requests
with patch("apps.channels.api_views.requests.get", side_effect=requests.ConnectionError("fail")), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
self._fetch_logo(logo)
# Expired entries should be evicted
old_entries = [k for k in self._failures if k.startswith("https://old-")]
self.assertEqual(len(old_entries), 0)
# New failure entry should exist
self.assertIn("https://trigger-evict.com/logo.png", self._failures)

View file

@ -0,0 +1,524 @@
"""Tests for recent DVR fixes.
Covers:
1. Collision avoidance: _build_output_paths checks both .mkv and .ts files
2. Logo guard: _resolve_poster_for_program skips external APIs when title channel name
3. Recording status lifecycle: status transitions visible via API
4. Concat flags: error-tolerant ffmpeg flags used for segment concatenation
5. Recovery skip-list: "recording" status NOT in terminal skip list
"""
import os
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="dvr_fixes_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
def _make_channel(name="Test Channel", number=100):
return Channel.objects.create(channel_number=number, name=name)
def _make_recording(channel, **overrides):
now = timezone.now()
defaults = {
"channel": channel,
"start_time": now - timedelta(hours=1),
"end_time": now + timedelta(hours=1),
"custom_properties": {},
}
defaults.update(overrides)
return Recording.objects.create(**defaults)
# =========================================================================
# 1. Collision avoidance — _build_output_paths
# =========================================================================
class CollisionAvoidanceTests(TestCase):
"""_build_output_paths must increment the filename counter when
EITHER the .mkv OR the .ts file already exists with size > 0."""
def _call(self, channel, program, start, end):
from apps.channels.tasks import _build_output_paths
return _build_output_paths(channel, program, start, end)
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_no_collision_when_nothing_exists(self, _tv, _fb):
"""Fresh path — no files exist, counter stays at 1."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
raise OSError("No such file")
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
# Should NOT have a _2 suffix
self.assertNotIn("_2", final)
self.assertTrue(final.endswith(".mkv"))
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb):
"""Pre-restart scenario: MKV is 0-byte placeholder, TS has real data.
The old code only checked MKV size, so it would reuse the path.
The fix also checks TS, so it must increment."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
if "_2" in path:
raise OSError("No such file")
result = MagicMock()
if path.endswith('.mkv'):
result.st_size = 0 # MKV is 0-byte placeholder
elif path.endswith('.ts'):
result.st_size = 5000000 # TS has real data from pre-restart
else:
result.st_size = 0
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
# Must have incremented to _2
self.assertIn("_2", final, "Should increment counter when TS file has data")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_collision_when_mkv_has_data(self, _tv, _fb):
"""Standard collision: MKV file has data, should increment."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
if "_2" in path:
raise OSError("No such file")
result = MagicMock()
if path.endswith('.mkv'):
result.st_size = 1000000 # MKV has data
else:
result.st_size = 0
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertIn("_2", final, "Should increment counter when MKV file has data")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_no_collision_when_both_zero_bytes(self, _tv, _fb):
"""Both MKV and TS exist but are 0 bytes — no collision."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
result = MagicMock()
result.st_size = 0 # All files empty
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertNotIn("_2", final, "Should NOT increment when all files are empty")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_collision_increments_to_3_when_2_also_occupied(self, _tv, _fb):
"""When both base and _2 are occupied, should go to _3."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
if "_3" in path:
raise OSError("No such file")
result = MagicMock()
if path.endswith('.ts'):
result.st_size = 5000000
else:
result.st_size = 0
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied")
# =========================================================================
# 2. Logo guard — _resolve_poster_for_program
# =========================================================================
class LogoGuardTests(TestCase):
"""When the program title matches the channel name, external API
searches (VOD, TMDB, OMDb, TVMaze, iTunes) must be skipped."""
def _call(self, channel_name, program, channel_logo_id=None):
from apps.channels.tasks import _resolve_poster_for_program
return _resolve_poster_for_program(channel_name, program, channel_logo_id)
@patch("apps.channels.tasks.requests.get")
def test_channel_name_as_title_skips_external_apis(self, mock_get):
"""Title = 'USA A&E SD*', channel = 'USA A&E SD*' → no external calls."""
program = {"title": "USA A&E SD*"}
logo_id, url = self._call("USA A&E SD*", program, channel_logo_id=42)
# Should NOT have called any external APIs
mock_get.assert_not_called()
# Should fall back to channel logo
self.assertEqual(logo_id, 42)
self.assertIsNone(url)
@patch("apps.channels.tasks.requests.get")
def test_channel_name_normalized_match(self, mock_get):
"""Title = 'fox news', channel = 'FOX-News*' → normalized match, skip APIs."""
program = {"title": "fox news"}
logo_id, url = self._call("FOX-News*", program, channel_logo_id=99)
mock_get.assert_not_called()
self.assertEqual(logo_id, 99)
@patch("apps.channels.tasks.requests.get")
def test_real_title_still_searched(self, mock_get):
"""Title = 'Breaking Bad' on channel 'AMC' → should try external APIs."""
# Mock TVMaze returning a result
mock_resp = MagicMock(ok=True, status_code=200)
mock_resp.json.return_value = {
"image": {"original": "https://tvmaze.com/breaking-bad.jpg"}
}
mock_get.return_value = mock_resp
program = {"title": "Breaking Bad"}
logo_id, url = self._call("AMC", program)
# Should have made at least one external API call
self.assertTrue(mock_get.called, "Should search external APIs for real titles")
self.assertIsNotNone(url)
@patch("apps.channels.tasks.requests.get")
def test_no_title_skips_to_channel_logo(self, mock_get):
"""No title at all → falls through to channel logo, no API calls."""
program = {}
logo_id, url = self._call("SomeChannel", program, channel_logo_id=55)
mock_get.assert_not_called()
self.assertEqual(logo_id, 55)
@patch("apps.channels.tasks.requests.get")
def test_epg_image_still_used_even_when_title_is_channel_name(self, mock_get):
"""Even when title = channel name, Stage 1 (EPG images) should still work."""
from apps.epg.models import ProgramData, EPGSource, EPGData
# Create an EPG source + EPGData entry + program with an icon URL
epg_source = EPGSource.objects.create(source_type="xmltv", name="Test EPG")
epg_data = EPGData.objects.create(tvg_id="test.ch", epg_source=epg_source)
prog = ProgramData.objects.create(
epg=epg_data,
title="Test Channel HD",
start_time=timezone.now() - timedelta(hours=1),
end_time=timezone.now() + timedelta(hours=1),
custom_properties={"icon": "https://epg-cdn.com/test-icon.png"},
)
program = {"title": "Test Channel HD", "id": prog.id}
# Mock _validate_url to return True for the icon URL
with patch("apps.channels.tasks._validate_url", return_value=True):
logo_id, url = self._call("Test Channel HD", program, channel_logo_id=10)
# EPG icon should still be used (Stage 1 doesn't depend on title guard)
self.assertEqual(url, "https://epg-cdn.com/test-icon.png")
mock_get.assert_not_called()
# =========================================================================
# 3. Recording status lifecycle via API
# =========================================================================
class RecordingStatusLifecycleTests(TestCase):
"""Verify recording status transitions and that terminal recordings
are properly filterable (supports the red-dot fix in guideUtils)."""
def setUp(self):
self.channel = _make_channel("Status Test Channel", 200)
self.user = _make_admin()
self.factory = APIRequestFactory()
def _list_recordings(self):
from apps.channels.api_views import RecordingViewSet
request = self.factory.get("/api/channels/recordings/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"get": "list"})
return view(request)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_stopped_recording_has_terminal_status(self, _ws):
"""After stop, custom_properties.status = 'stopped'."""
from apps.channels.api_views import RecordingViewSet
rec = _make_recording(self.channel, custom_properties={
"status": "recording",
"program": {"id": 1, "title": "Live Show"},
})
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "stop"})
with patch("apps.channels.signals.revoke_task"):
response = view(request, pk=rec.id)
self.assertIn(response.status_code, [200, 204])
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("status"), "stopped")
def test_listing_includes_status_in_custom_properties(self):
"""API listing returns custom_properties with status field."""
_make_recording(self.channel, custom_properties={
"status": "recording",
"program": {"id": 1, "title": "Recording Show"},
})
_make_recording(self.channel, custom_properties={
"status": "stopped",
"program": {"id": 2, "title": "Stopped Show"},
})
response = self._list_recordings()
self.assertEqual(response.status_code, 200)
statuses = [r["custom_properties"].get("status") for r in response.data]
self.assertIn("recording", statuses)
self.assertIn("stopped", statuses)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_delete_recording_removes_from_listing(self, _ws):
"""Deleting a recording removes it from the listing entirely."""
from apps.channels.api_views import RecordingViewSet
rec = _make_recording(self.channel, custom_properties={
"status": "stopped",
"program": {"id": 3, "title": "To Delete"},
})
rec_id = rec.id
request = self.factory.delete(f"/api/channels/recordings/{rec_id}/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"delete": "destroy"})
with patch("apps.channels.signals.revoke_task"):
response = view(request, pk=rec_id)
self.assertIn(response.status_code, [200, 204])
self.assertFalse(Recording.objects.filter(id=rec_id).exists())
# =========================================================================
# 4. Concat flags — error-tolerant ffmpeg
# =========================================================================
class ConcatFlagsTests(TestCase):
"""Verify that the finalize phase uses error-tolerant ffmpeg flags
when concatenating pre-restart segments."""
def test_concat_command_includes_error_tolerant_flags(self):
"""Inspect the source code to confirm error-tolerant flags are present.
This is a static analysis test no ffmpeg execution needed."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
# The concat subprocess.run call must include these flags
self.assertIn("+genpts+igndts+discardcorrupt", source,
"Concat must use +genpts+igndts+discardcorrupt fflags")
self.assertIn("ignore_err", source,
"Concat must use -err_detect ignore_err")
self.assertIn("-f", source)
self.assertIn("concat", source)
def test_concat_goes_directly_to_mkv(self):
"""Concat must produce MKV directly (not intermediate .ts) to
preserve timestamp boundaries and avoid playback freeze at splice."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
# Must contain reset_timestamps for proper segment boundary handling
self.assertIn("reset_timestamps", source,
"Concat must use -reset_timestamps 1 for seamless seeking")
# Must write directly to final_path (MKV), not an intermediate .ts
self.assertIn("_concat_did_remux", source,
"Concat path must set flag to skip separate remux step")
def test_segment_time_metadata_present(self):
"""Verify concat uses -segment_time_metadata for boundary awareness."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
self.assertIn("segment_time_metadata", source,
"Concat must use -segment_time_metadata 1 for segment boundary handling")
# =========================================================================
# 5. Recovery skip-list
# =========================================================================
class RecoverySkipListTests(TestCase):
"""Verify that the recovery function does NOT skip 'recording' status,
since that's the exact status recordings have when the server crashes."""
def test_recording_status_not_in_skip_list(self):
"""Inspect recover_recordings_on_startup to ensure 'recording' is
NOT treated as a terminal/skip state."""
import inspect
from apps.channels.tasks import recover_recordings_on_startup
source = inspect.getsource(recover_recordings_on_startup)
# Find the skip condition line
# It should be: if current_status in ("completed", "stopped"):
# NOT: if current_status in ("completed", "stopped", "recording"):
lines = source.split('\n')
skip_line = None
for line in lines:
if 'current_status in' in line and ('completed' in line or 'stopped' in line):
skip_line = line.strip()
break
self.assertIsNotNone(skip_line, "Should find the skip-list condition")
self.assertNotIn('"recording"', skip_line,
"Skip list must NOT contain 'recording'"
"that's the status of crashed mid-stream recordings that need recovery")
@patch("core.utils.RedisClient")
@patch("apps.channels.tasks.run_recording")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_recovery_processes_recording_status(self, _ws, mock_run, mock_redis_cls):
"""A recording with status='recording' should be recovered, not skipped."""
mock_redis_conn = MagicMock()
mock_redis_conn.set.return_value = True # Acquire lock
mock_redis_cls.get_client.return_value = mock_redis_conn
channel = _make_channel("Recovery Test", 300)
now = timezone.now()
rec = _make_recording(channel, custom_properties={
"status": "recording",
"program": {"title": "Crashed Show"},
}, end_time=now + timedelta(hours=2))
from apps.channels.tasks import recover_recordings_on_startup
with patch("apps.channels.signals.revoke_task"):
result = recover_recordings_on_startup()
# The recording should have been dispatched for recovery
self.assertTrue(mock_run.apply_async.called,
"Recording with status='recording' should be dispatched for recovery")
@patch("core.utils.RedisClient")
@patch("apps.channels.tasks.run_recording")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_recovery_skips_stopped_recordings(self, _ws, mock_run, mock_redis_cls):
"""A recording with status='stopped' should be skipped by recovery."""
mock_redis_conn = MagicMock()
mock_redis_conn.set.return_value = True
mock_redis_cls.get_client.return_value = mock_redis_conn
channel = _make_channel("Recovery Skip Test", 301)
now = timezone.now()
rec = _make_recording(channel, custom_properties={
"status": "stopped",
"program": {"title": "Finished Show"},
}, end_time=now + timedelta(hours=2))
from apps.channels.tasks import recover_recordings_on_startup
with patch("apps.channels.signals.revoke_task"):
recover_recordings_on_startup()
# Should NOT have dispatched a recovery task
mock_run.apply_async.assert_not_called()
# =========================================================================
# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId)
# =========================================================================
class MapRecordingsByProgramIdTests(TestCase):
"""These test the BACKEND side — confirming that recording status
is preserved in the API response so the frontend can filter on it.
The actual frontend filtering is covered by frontend/src/pages/__tests__/DVR.test.jsx
and the guideUtils code, but we verify the data contract here."""
def test_recording_custom_properties_status_persisted(self):
"""Recording status in custom_properties survives save/load cycle."""
channel = _make_channel("Red Dot Test", 400)
rec = _make_recording(channel, custom_properties={
"status": "stopped",
"program": {"id": 42, "title": "A Show"},
})
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], "stopped")
def test_terminal_statuses_are_well_defined(self):
"""Verify the terminal status set matches what the frontend uses."""
# These are the statuses that should NOT show a red dot in the Guide
terminal = {"stopped", "completed", "interrupted", "failed"}
channel = _make_channel("Terminal Status Test", 410)
# Verify each status is a valid recording status
for status in terminal:
rec = _make_recording(channel, custom_properties={
"status": status,
"program": {"id": 100, "title": "Test"},
})
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], status)

View file

@ -0,0 +1,585 @@
"""Tests for DVR recording scheduling with ClockedSchedule.
Uses ClockedSchedule instead of apply_async with countdown because Redis
visibility_timeout (default 3600s) causes task redelivery for long countdowns,
leading to duplicate recordings.
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from django_celery_beat.models import ClockedSchedule, PeriodicTask
from apps.channels.models import Channel, Recording
from apps.channels.signals import (
schedule_recording_task,
revoke_task,
_dvr_task_name,
)
class ScheduleRecordingTaskTests(TestCase):
"""Tests for schedule_recording_task()."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.run_recording")
def test_future_recording_creates_periodic_task(self, mock_run_recording):
"""Recordings in the future create a ClockedSchedule + PeriodicTask."""
future_time = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future_time,
end_time=future_time + timedelta(hours=1),
)
task_id = schedule_recording_task(rec, eta=future_time)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
pt = PeriodicTask.objects.get(name=expected_name)
self.assertTrue(pt.one_off)
self.assertTrue(pt.enabled)
self.assertEqual(pt.task, "apps.channels.tasks.run_recording")
self.assertIsNotNone(pt.clocked)
# apply_async should not have been called
mock_run_recording.apply_async.assert_not_called()
@patch("apps.channels.signals.run_recording")
def test_immediate_recording_creates_periodic_task(self, mock_run_recording):
"""Recordings starting now also use ClockedSchedule for consistency."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now + timedelta(hours=1),
)
task_id = schedule_recording_task(rec, eta=now)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
self.assertTrue(PeriodicTask.objects.filter(name=expected_name).exists())
@patch("apps.channels.signals.run_recording")
def test_past_start_time_clamps_to_now(self, mock_run_recording):
"""Recordings with past start_time get clamped to now."""
past_time = timezone.now() - timedelta(minutes=5)
rec = Recording.objects.create(
channel=self.channel,
start_time=past_time,
end_time=timezone.now() + timedelta(hours=1),
)
task_id = schedule_recording_task(rec, eta=past_time)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
pt = PeriodicTask.objects.get(name=expected_name)
# Clocked time should be >= now
self.assertGreaterEqual(pt.clocked.clocked_time, past_time)
@patch("apps.channels.signals.run_recording")
def test_reschedule_updates_existing_periodic_task(self, mock_run_recording):
"""Calling schedule_recording_task twice updates the existing PeriodicTask."""
future_time = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future_time,
end_time=future_time + timedelta(hours=1),
)
schedule_recording_task(rec, eta=future_time)
# Reschedule with a different time
new_eta = future_time + timedelta(hours=1)
schedule_recording_task(rec, eta=new_eta)
# Should still be exactly one PeriodicTask
task_name = f"dvr-recording-{rec.id}"
self.assertEqual(PeriodicTask.objects.filter(name=task_name).count(), 1)
@patch("apps.channels.signals.run_recording")
def test_naive_eta_is_made_aware(self, mock_run_recording):
"""A naive (timezone-unaware) eta is made timezone-aware."""
from datetime import datetime
naive_eta = datetime(2030, 6, 15, 14, 0, 0)
rec = Recording.objects.create(
channel=self.channel,
start_time=timezone.now() + timedelta(hours=1),
end_time=timezone.now() + timedelta(hours=2),
)
task_id = schedule_recording_task(rec, eta=naive_eta)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
pt = PeriodicTask.objects.get(name=expected_name)
self.assertTrue(timezone.is_aware(pt.clocked.clocked_time))
class RevokeTaskTests(TestCase):
"""Tests for revoke_task()."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
def test_revoke_deletes_periodic_task_and_clocked_schedule(self):
"""revoke_task deletes the PeriodicTask and orphaned ClockedSchedule."""
eta = timezone.now() + timedelta(hours=5)
clocked = ClockedSchedule.objects.create(clocked_time=eta)
PeriodicTask.objects.create(
name="dvr-recording-10",
task="apps.channels.tasks.run_recording",
clocked=clocked,
one_off=True,
enabled=True,
)
revoke_task("dvr-recording-10")
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
self.assertFalse(ClockedSchedule.objects.filter(id=clocked.id).exists())
def test_revoke_keeps_shared_clocked_schedule(self):
"""ClockedSchedule is kept if another PeriodicTask still references it."""
eta = timezone.now() + timedelta(hours=5)
clocked = ClockedSchedule.objects.create(clocked_time=eta)
PeriodicTask.objects.create(
name="dvr-recording-10",
task="apps.channels.tasks.run_recording",
clocked=clocked,
one_off=True,
)
PeriodicTask.objects.create(
name="dvr-recording-11",
task="apps.channels.tasks.run_recording",
clocked=clocked,
one_off=True,
)
revoke_task("dvr-recording-10")
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
self.assertTrue(ClockedSchedule.objects.filter(id=clocked.id).exists())
@patch("apps.channels.signals.AsyncResult")
def test_revoke_falls_back_to_async_result_for_legacy_ids(self, mock_async_result):
"""revoke_task falls back to AsyncResult.revoke() for old-style UUIDs."""
revoke_task("550e8400-e29b-41d4-a716-446655440000")
mock_async_result.assert_called_once_with("550e8400-e29b-41d4-a716-446655440000")
mock_async_result.return_value.revoke.assert_called_once()
def test_revoke_none_is_noop(self):
"""revoke_task(None) does nothing."""
revoke_task(None) # Should not raise
def test_revoke_empty_string_is_noop(self):
"""revoke_task('') does nothing."""
revoke_task("") # Should not raise
class DvrTaskNameTests(TestCase):
"""Tests for the naming convention helper."""
def test_task_name_format(self):
self.assertEqual(_dvr_task_name(42), "dvr-recording-42")
def test_task_name_fits_in_charfield(self):
name = _dvr_task_name(999999999)
self.assertLessEqual(len(name), 255)
class SignalIntegrationTests(TestCase):
"""Integration tests for the post_save / post_delete signal handlers."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_save_creates_periodic_task_for_future_recording(self, mock_artwork):
"""Saving a future Recording creates a PeriodicTask via post_save signal."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
)
rec.refresh_from_db()
task_name = f"dvr-recording-{rec.id}"
self.assertEqual(rec.task_id, task_name)
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_delete_removes_periodic_task(self, mock_artwork):
"""Deleting a Recording removes its PeriodicTask."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
)
rec.refresh_from_db()
task_name = rec.task_id
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
rec.delete()
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_bulk_delete_cleans_up_all_periodic_tasks(self, mock_artwork):
"""Bulk deleting recordings cleans up all their PeriodicTasks."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec_ids = []
for i in range(5):
rec = Recording.objects.create(
channel=self.channel,
start_time=future + timedelta(hours=i),
end_time=future + timedelta(hours=i + 1),
)
rec_ids.append(rec.id)
for rid in rec_ids:
self.assertTrue(
PeriodicTask.objects.filter(name=f"dvr-recording-{rid}").exists()
)
Recording.objects.filter(channel=self.channel).delete()
self.assertEqual(
PeriodicTask.objects.filter(name__startswith="dvr-recording-").count(), 0
)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_save_schedules_currently_playing_recording(self, mock_artwork):
"""A recording with past start_time but future end_time schedules immediately."""
mock_artwork.apply_async.return_value = MagicMock()
past_start = timezone.now() - timedelta(minutes=30)
future_end = timezone.now() + timedelta(minutes=30)
rec = Recording.objects.create(
channel=self.channel,
start_time=past_start,
end_time=future_end,
)
rec.refresh_from_db()
task_name = f"dvr-recording-{rec.id}"
self.assertEqual(rec.task_id, task_name)
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_save_skips_fully_past_recording(self, mock_artwork):
"""A recording with both start_time and end_time in the past is not scheduled."""
mock_artwork.apply_async.return_value = MagicMock()
past_start = timezone.now() - timedelta(hours=2)
past_end = timezone.now() - timedelta(hours=1)
rec = Recording.objects.create(
channel=self.channel,
start_time=past_start,
end_time=past_end,
)
rec.refresh_from_db()
self.assertIsNone(rec.task_id)
self.assertFalse(
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_pre_save_revokes_on_time_change(self, mock_artwork):
"""Changing a recording's start_time revokes the old task and creates a new one."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
)
rec.refresh_from_db()
old_task_name = rec.task_id
self.assertTrue(PeriodicTask.objects.filter(name=old_task_name).exists())
# Change the start time — pre_save clears task_id, post_save reschedules
new_future = future + timedelta(hours=3)
rec.start_time = new_future
rec.end_time = new_future + timedelta(hours=1)
rec.save()
rec.refresh_from_db()
# Old PeriodicTask should be deleted; new one should exist
self.assertIsNotNone(rec.task_id)
self.assertTrue(
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
)
class IdempotencyGuardTests(TestCase):
"""Tests for the idempotency guard in run_recording()."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
@patch("apps.channels.tasks.get_channel_layer")
def test_skips_if_already_recording(self, mock_layer):
"""run_recording returns early if status is already 'recording'."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording", "started_at": str(now)},
)
from apps.channels.tasks import run_recording as run_rec_task
result = run_rec_task(rec.id, self.channel.id, str(now), str(now + timedelta(hours=1)))
self.assertIsNone(result)
# get_channel_layer should not have been called (returned before)
mock_layer.assert_not_called()
@patch("apps.channels.tasks.get_channel_layer")
def test_skips_if_already_completed(self, mock_layer):
"""run_recording returns early if status is already 'completed'."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=2),
end_time=now - timedelta(hours=1),
custom_properties={"status": "completed"},
)
from apps.channels.tasks import run_recording as run_rec_task
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
self.assertIsNone(result)
mock_layer.assert_not_called()
@patch("apps.channels.tasks.get_channel_layer")
def test_skips_if_already_stopped(self, mock_layer):
"""run_recording returns early if status is already 'stopped' (user stopped it early)."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": "stopped", "stopped_at": str(now)},
)
from apps.channels.tasks import run_recording as run_rec_task
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
self.assertIsNone(result)
mock_layer.assert_not_called()
class ArtworkPrefetchSignalGuardTests(TestCase):
"""Tests that the post_save signal does not schedule artwork prefetch when
the recording is in an active or terminal state."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_not_scheduled_when_status_recording(self, mock_artwork):
"""post_save must NOT schedule artwork prefetch when status='recording'
to prevent a race that overwrites the running task's status updates."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={"status": "recording"},
)
# Simulate a save that run_recording itself might do mid-recording
rec.custom_properties = {"status": "recording", "file_path": "/data/recordings/test.mkv"}
rec.save(update_fields=["custom_properties"])
# apply_async was not called for the "recording" save
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_not_scheduled_when_status_completed(self, mock_artwork):
"""post_save must NOT schedule artwork prefetch when status='completed'."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={"status": "completed"},
)
rec.custom_properties = {"status": "completed"}
rec.save(update_fields=["custom_properties"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_not_scheduled_when_status_stopped(self, mock_artwork):
"""post_save must NOT schedule artwork prefetch when status='stopped'."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={"status": "stopped"},
)
rec.custom_properties = {"status": "stopped"}
rec.save(update_fields=["custom_properties"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_scheduled_for_new_upcoming_recording(self, mock_artwork):
"""post_save SHOULD schedule artwork prefetch for a newly created upcoming recording."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={}, # no status yet — should trigger prefetch
)
self.assertTrue(mock_artwork.apply_async.called)
class DestroyDvrClientIsolationTests(TestCase):
"""Tests that deleting a recording only stops DVR clients when the
recording is actively streaming never for completed/upcoming recordings
that could share a channel with an unrelated in-progress recording."""
def setUp(self):
from django.contrib.auth import get_user_model
from rest_framework.test import APIRequestFactory, force_authenticate
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
User = get_user_model()
self.user = User.objects.create_user(
username="dvr_test_admin", password="pass",
user_level=User.UserLevel.ADMIN,
)
self.factory = APIRequestFactory()
self.force_authenticate = force_authenticate
def _delete_recording(self, rec):
from apps.channels.api_views import RecordingViewSet
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
self.force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"delete": "destroy"})
return view(request, pk=rec.id)
@patch("apps.channels.api_views._stop_dvr_clients")
def test_destroy_completed_recording_does_not_stop_dvr_clients(self, mock_stop):
"""Deleting a completed recording must NOT call _stop_dvr_clients."""
rec = Recording.objects.create(
channel=self.channel,
start_time=timezone.now() - timedelta(hours=2),
end_time=timezone.now() - timedelta(hours=1),
custom_properties={"status": "completed", "file_path": "/data/recordings/test.mkv"},
)
self._delete_recording(rec)
mock_stop.assert_not_called()
@patch("apps.channels.api_views._stop_dvr_clients")
def test_destroy_upcoming_recording_does_not_stop_dvr_clients(self, mock_stop):
"""Deleting an upcoming (scheduled) recording must NOT call _stop_dvr_clients."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={},
)
self._delete_recording(rec)
mock_stop.assert_not_called()
@patch("apps.channels.api_views._stop_dvr_clients")
def test_destroy_active_recording_does_stop_dvr_clients(self, mock_stop):
"""Deleting an in-progress recording MUST call _stop_dvr_clients."""
mock_stop.return_value = 1
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=5),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
self._delete_recording(rec)
mock_stop.assert_called_once_with(str(self.channel.uuid), recording_id=rec.id)
class PeriodicTaskCleanupOnExecutionTests(TestCase):
"""Tests for PeriodicTask cleanup when run_recording starts."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.prefetch_recording_artwork")
@patch("apps.channels.tasks.get_channel_layer")
def test_periodic_task_cleaned_up_on_execution(self, mock_layer, mock_artwork):
"""When run_recording executes, it deletes its own PeriodicTask."""
mock_layer.return_value = MagicMock()
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={},
)
# post_save signal should have created the PeriodicTask
task_name = f"dvr-recording-{rec.id}"
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
pt = PeriodicTask.objects.get(name=task_name)
clocked_id = pt.clocked_id
from apps.channels.tasks import run_recording as run_rec_task
# This will proceed past guards, clean up the PeriodicTask, then
# eventually fail on the actual stream connection (expected)
try:
run_rec_task(rec.id, self.channel.id, str(future), str(future + timedelta(hours=1)))
except Exception:
pass
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
self.assertFalse(ClockedSchedule.objects.filter(id=clocked_id).exists())

View file

@ -0,0 +1,356 @@
"""Tests for the DVR Stop/Cancel feature set.
Covers:
- stop() endpoint
- destroy() was_in_progress field in recording_cancelled WebSocket event
- signals.py update_fields re-entrancy guard
- run_recording race guard before status write
- _stop_dvr_clients() DVR client isolation
"""
from datetime import timedelta
from unittest.mock import MagicMock, AsyncMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording
from apps.channels.api_views import RecordingViewSet, _stop_dvr_clients
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="stop_test_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
def _async_channel_layer_mock():
layer = MagicMock()
layer.group_send = AsyncMock()
return layer
class StopEndpointTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/stop/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=99, name="Stop Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _stop(self, rec):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "stop"})
return view(request, pk=rec.id)
def _make_rec(self, status="recording"):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": status},
)
@patch("core.utils.send_websocket_update")
@patch("threading.Thread")
def test_stop_writes_status_to_db_before_returning(self, mock_thread, mock_ws):
"""DB write is synchronous — run_recording polls for this."""
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
response = self._stop(rec)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data.get("success"))
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("status"), "stopped")
@patch("core.utils.send_websocket_update")
@patch("threading.Thread")
def test_stop_writes_stopped_at_timestamp(self, mock_thread, mock_ws):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
self._stop(rec)
rec.refresh_from_db()
self.assertIn("stopped_at", rec.custom_properties)
def test_stop_calls_stop_dvr_clients_in_background(self):
"""stop() spawns a background thread whose target calls _stop_dvr_clients."""
rec = self._make_rec()
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop, \
patch("core.utils.send_websocket_update"), \
patch("threading.Thread") as mock_thread:
mock_thread.return_value.start = MagicMock()
self._stop(rec)
# Verify a daemon thread was spawned
mock_thread.assert_called_once()
thread_kwargs = mock_thread.call_args[1]
self.assertTrue(thread_kwargs.get("daemon"), "Thread must be daemon")
# Execute the captured target with DB connection close patched out
target = thread_kwargs["target"]
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop2, \
patch("apps.channels.signals.revoke_task", side_effect=Exception("skip")), \
patch("django.db.connection") as mock_conn:
target()
self.assertTrue(mock_stop2.called)
args, kwargs = mock_stop2.call_args
actual_rec_id = kwargs.get("recording_id") or (args[1] if len(args) > 1 else None)
self.assertEqual(actual_rec_id, rec.id)
def test_stop_returns_404_for_nonexistent(self):
request = self.factory.post("/api/channels/recordings/99999/stop/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "stop"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("core.utils.send_websocket_update")
@patch("threading.Thread")
def test_stop_idempotent_on_already_stopped(self, mock_thread, mock_ws):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec(status="stopped")
self.assertEqual(self._stop(rec).status_code, 200)
class CancelDestroyWasInProgressTests(TestCase):
"""was_in_progress field in the recording_cancelled WebSocket event."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=98, name="Cancel Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _delete(self, rec):
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
force_authenticate(request, user=self.user)
return RecordingViewSet.as_view({"delete": "destroy"})(request, pk=rec.id)
@patch("apps.channels.api_views._stop_dvr_clients", return_value=1)
@patch("core.utils.send_websocket_update")
def test_in_progress_sends_was_in_progress_true(self, mock_ws, _):
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=10),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
self._delete(rec)
payload = mock_ws.call_args[0][2]
self.assertEqual(payload["type"], "recording_cancelled")
self.assertTrue(payload["was_in_progress"])
@patch("core.utils.send_websocket_update")
def test_completed_sends_was_in_progress_false(self, mock_ws):
rec = Recording.objects.create(
channel=self.channel,
start_time=timezone.now() - timedelta(hours=2),
end_time=timezone.now() - timedelta(hours=1),
custom_properties={"status": "completed"},
)
self._delete(rec)
self.assertFalse(mock_ws.call_args[0][2]["was_in_progress"])
class SignalUpdateFieldsReentrancyGuardTests(TestCase):
"""update_fields guard in schedule_task_on_save prevents redundant WS events."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=97, name="Signal Guard Channel")
def _create_upcoming(self):
future = timezone.now() + timedelta(hours=2)
return Recording.objects.create(
channel=self.channel, start_time=future,
end_time=future + timedelta(hours=1), custom_properties={},
)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_custom_properties_save_skips_artwork(self, mock_artwork):
rec = self._create_upcoming()
mock_artwork.reset_mock()
rec.custom_properties = {"poster_url": "https://example.com/p.jpg"}
rec.save(update_fields=["custom_properties"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_task_id_save_skips_artwork(self, mock_artwork):
rec = self._create_upcoming()
mock_artwork.reset_mock()
rec.task_id = "dvr-recording-999"
rec.save(update_fields=["task_id"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_combined_metadata_save_skips_artwork(self, mock_artwork):
rec = self._create_upcoming()
mock_artwork.reset_mock()
rec.task_id = "dvr-recording-1000"
rec.custom_properties = {"poster_url": "x"}
rec.save(update_fields=["custom_properties", "task_id"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_creation_dispatches_artwork(self, mock_artwork):
mock_artwork.apply_async.return_value = MagicMock()
self._create_upcoming()
self.assertTrue(mock_artwork.apply_async.called)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_scheduling_field_update_dispatches_artwork(self, mock_artwork):
"""save(update_fields=['start_time']) is not a metadata save — dispatch runs."""
mock_artwork.apply_async.return_value = MagicMock()
rec = self._create_upcoming()
mock_artwork.reset_mock()
future = timezone.now() + timedelta(hours=3)
rec.start_time = future
rec.end_time = future + timedelta(hours=1)
rec.save(update_fields=["start_time", "end_time"])
mock_artwork.apply_async.assert_called()
class RunRecordingRaceGuardTests(TestCase):
"""Race guard: stop() fires between idempotency check and status write."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=96, name="Race Guard Channel")
def test_race_guard_exits_when_stopped_at_db_read(self):
"""If Recording.objects.get() shows 'stopped', the task must exit
without writing 'recording' to the DB."""
from apps.channels.tasks import run_recording as run_rec
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=1),
end_time=now + timedelta(hours=1),
custom_properties={},
)
mock_layer = _async_channel_layer_mock()
original_get = Recording.objects.get
def patched_get(*args, **kwargs):
obj = original_get(*args, **kwargs)
if kwargs.get("id") == rec.id or (args and args[0] == rec.id):
obj.custom_properties = {"status": "stopped"}
return obj
with patch("apps.channels.tasks.get_channel_layer", return_value=mock_layer), \
patch("core.utils.log_system_event", side_effect=Exception("skip")), \
patch.object(Recording.objects, "get", side_effect=patched_get):
result = run_rec(
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
)
self.assertIsNone(result)
rec.refresh_from_db()
self.assertNotEqual(
rec.custom_properties.get("status"), "recording",
"Race guard failed: task overwrote 'stopped' with 'recording'",
)
def test_idempotency_guard_catches_stopped_before_channel_layer(self):
"""When status='stopped' at the idempotency check, get_channel_layer is never called."""
from apps.channels.tasks import run_recording as run_rec
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=5),
end_time=now + timedelta(hours=1),
custom_properties={"status": "stopped"},
)
with patch("apps.channels.tasks.get_channel_layer") as mock_get_layer:
result = run_rec(
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
)
self.assertIsNone(result)
mock_get_layer.assert_not_called()
class StopDvrClientsTests(TestCase):
"""_stop_dvr_clients() DVR client isolation."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=95, name="DVR Clients Channel")
self._redis = "core.utils.RedisClient"
self._sc = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_client"
self._sch = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_channel"
def _mock_redis(self, client_ids, ua_map):
r = MagicMock()
r.smembers.return_value = {c.encode() for c in client_ids}
def hget_side(key, field):
ks = key if isinstance(key, str) else key.decode("utf-8", errors="replace")
for cid, ua in ua_map.items():
if cid in ks:
return ua.encode() if isinstance(ua, str) else ua
return b""
r.hget.side_effect = hget_side
return r
def test_returns_zero_when_redis_none(self):
with patch(self._redis) as rc:
rc.get_client.return_value = None
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
def test_stops_only_matching_client_when_recording_id_given(self):
r = self._mock_redis(
["client-a", "client-b"],
{"client-a": "Dispatcharr-DVR/recording-42",
"client-b": "Dispatcharr-DVR/recording-99"},
)
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
result = _stop_dvr_clients(str(self.channel.uuid), recording_id=42)
self.assertEqual(result, 1)
stopped = [c[0][1] for c in sc.call_args_list]
self.assertIn("client-a", stopped)
self.assertNotIn("client-b", stopped)
def test_stops_all_dvr_clients_without_recording_id(self):
r = self._mock_redis(
["client-a", "client-b"],
{"client-a": "Dispatcharr-DVR/recording-42",
"client-b": "Dispatcharr-DVR/recording-99"},
)
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
result = _stop_dvr_clients(str(self.channel.uuid))
self.assertEqual(result, 2)
def test_skips_non_dvr_clients(self):
r = self._mock_redis(
["viewer", "dvr-client"],
{"viewer": "Mozilla/5.0", "dvr-client": "Dispatcharr-DVR/recording-1"},
)
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
result = _stop_dvr_clients(str(self.channel.uuid))
self.assertEqual(result, 1)
stopped = [c[0][1] for c in sc.call_args_list]
self.assertNotIn("viewer", stopped)
def test_returns_zero_for_empty_channel(self):
r = MagicMock()
r.smembers.return_value = set()
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
sc.assert_not_called()
def test_never_calls_stop_channel(self):
"""Must not stop the whole channel proxy — only individual clients."""
r = self._mock_redis(["dvr-1"], {"dvr-1": "Dispatcharr-DVR/recording-1"})
with patch(self._redis) as rc, patch(self._sc), patch(self._sch) as sch:
rc.get_client.return_value = r
_stop_dvr_clients(str(self.channel.uuid))
sch.assert_not_called()

View file

@ -0,0 +1,324 @@
"""Tests for ts_proxy keepalive and stats-update behavior.
Covers:
- stream_generator._should_send_keepalive() owner vs non-owner worker paths
- stream_generator._should_send_keepalive() Redis last_data health check
- client_manager._do_stats_update() error handling and WebSocket dispatch
- client_manager.remove_client() non-blocking stats update
- Keepalive/DVR-timeout timing invariants
"""
import threading
import time
from unittest.mock import MagicMock, patch
from django.test import TestCase
# ---------------------------------------------------------------------------
# _should_send_keepalive: owner worker path
# ---------------------------------------------------------------------------
class OwnerWorkerKeepaliveTests(TestCase):
"""Owner worker has a stream_manager; keepalive logic uses it directly."""
def _make_generator(self, healthy, at_buffer_head, consecutive_empty):
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
gen = StreamGenerator.__new__(StreamGenerator)
gen.channel_id = "00000000-0000-0000-0000-000000000001"
gen.client_id = "test-client"
buffer = MagicMock()
buffer.index = 10 if at_buffer_head else 100
gen.local_index = 10
gen.buffer = buffer
stream_manager = MagicMock()
stream_manager.healthy = healthy
gen.stream_manager = stream_manager
gen.consecutive_empty = consecutive_empty
return gen
def test_owner_healthy_returns_false(self):
"""Owner worker, healthy stream -> no keepalive."""
gen = self._make_generator(healthy=True, at_buffer_head=True, consecutive_empty=10)
self.assertFalse(gen._should_send_keepalive(gen.local_index))
def test_owner_unhealthy_at_head_returns_true(self):
"""Owner worker, unhealthy stream, at buffer head -> send keepalive."""
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=10)
self.assertTrue(gen._should_send_keepalive(gen.local_index))
def test_owner_unhealthy_not_at_head_returns_false(self):
"""Owner worker, unhealthy stream, but NOT at buffer head -> no keepalive."""
gen = self._make_generator(healthy=False, at_buffer_head=False, consecutive_empty=10)
self.assertFalse(gen._should_send_keepalive(gen.local_index))
def test_owner_insufficient_consecutive_empty_returns_false(self):
"""Owner worker, unhealthy, at head but consecutive_empty < 5 -> no keepalive."""
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=3)
self.assertFalse(gen._should_send_keepalive(gen.local_index))
def test_owner_exactly_5_consecutive_empty_returns_true(self):
"""consecutive_empty == 5 is the minimum threshold."""
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=5)
self.assertTrue(gen._should_send_keepalive(gen.local_index))
# ---------------------------------------------------------------------------
# _should_send_keepalive: non-owner worker path
# ---------------------------------------------------------------------------
class NonOwnerWorkerKeepaliveTests(TestCase):
"""Non-owner worker has stream_manager=None; health determined from Redis."""
def _make_generator(self, consecutive_empty=10):
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
gen = StreamGenerator.__new__(StreamGenerator)
gen.channel_id = "00000000-0000-0000-0000-000000000002"
gen.client_id = "test-client-nonowner"
buffer = MagicMock()
buffer.index = 10
gen.local_index = 10
gen.buffer = buffer
gen.stream_manager = None # non-owner worker
gen.consecutive_empty = consecutive_empty
return gen
def _mock_proxy_server(self, last_data_value):
"""Return a mock ProxyServer with a redis_client pre-configured."""
server = MagicMock()
redis_client = MagicMock()
server.redis_client = redis_client
redis_client.get.return_value = last_data_value
return server
def test_non_owner_fresh_data_returns_false(self):
"""Non-owner, last_data < 10s ago -> stream healthy -> no keepalive."""
gen = self._make_generator()
fresh_ts = str(time.time() - 2.0).encode()
server = self._mock_proxy_server(fresh_ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result, "Fresh data should NOT trigger keepalive")
def test_non_owner_stale_data_returns_true(self):
"""Non-owner, last_data >= 10s ago -> stream unhealthy -> send keepalive."""
gen = self._make_generator()
stale_ts = str(time.time() - 12.0).encode()
server = self._mock_proxy_server(stale_ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertTrue(result, "Stale data (12s) should trigger keepalive")
def test_non_owner_exactly_at_timeout_returns_true(self):
"""Data age exactly equal to CONNECTION_TIMEOUT (10s) -> send keepalive."""
gen = self._make_generator()
ts = str(time.time() - 10.0).encode()
server = self._mock_proxy_server(ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertTrue(result, "Data at exactly timeout threshold should trigger keepalive")
def test_non_owner_no_redis_key_returns_true(self):
"""Non-owner, last_data key missing from Redis -> assume unhealthy."""
gen = self._make_generator()
server = self._mock_proxy_server(None)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertTrue(result, "Missing last_data key should trigger keepalive")
def test_non_owner_redis_client_none_returns_false(self):
"""Non-owner, redis_client is None (disconnected) -> conservative, no keepalive."""
gen = self._make_generator()
server = MagicMock()
server.redis_client = None
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result, "No redis_client -> conservative, no keepalive")
def test_non_owner_redis_exception_returns_false(self):
"""Non-owner, Redis raises an exception -> conservative, no keepalive."""
gen = self._make_generator()
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.side_effect = Exception("Redis error")
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result, "Redis error -> conservative, no keepalive")
def test_non_owner_not_at_buffer_head_returns_false(self):
"""Non-owner, NOT at buffer head -> no keepalive regardless of Redis."""
gen = self._make_generator()
gen.buffer.index = 100 # far ahead of local_index=10
server = self._mock_proxy_server(None)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result)
def test_non_owner_insufficient_consecutive_empty_returns_false(self):
"""Non-owner, at head, but consecutive_empty < 5 -> no keepalive."""
gen = self._make_generator(consecutive_empty=2)
stale_ts = str(time.time() - 30.0).encode()
server = self._mock_proxy_server(stale_ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result)
# ---------------------------------------------------------------------------
# _do_stats_update: error handling and WebSocket dispatch
# ---------------------------------------------------------------------------
class DoStatsUpdateTests(TestCase):
"""_do_stats_update runs the actual Redis scan + WebSocket call."""
def _make_client_manager(self):
from apps.proxy.ts_proxy.client_manager import ClientManager
cm = ClientManager.__new__(ClientManager)
cm.channel_id = "00000000-0000-0000-0000-000000000004"
cm._heartbeat_running = False
return cm
def test_do_stats_update_calls_send_websocket_update(self):
"""_do_stats_update must call send_websocket_update with channel_stats."""
cm = self._make_client_manager()
mock_redis = MagicMock()
mock_redis.scan.return_value = (0, [])
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update") as mock_ws, \
patch("redis.Redis.from_url", return_value=mock_redis):
cm._do_stats_update()
mock_ws.assert_called_once()
event_type = mock_ws.call_args[0][1]
self.assertEqual(event_type, "update")
payload = mock_ws.call_args[0][2]
self.assertEqual(payload["type"], "channel_stats")
def test_do_stats_update_does_not_raise_on_redis_error(self):
"""Redis failure must be swallowed (logged), not propagated."""
cm = self._make_client_manager()
with patch("redis.Redis.from_url", side_effect=Exception("Redis down")):
try:
cm._do_stats_update()
except Exception as e:
self.fail(f"_do_stats_update raised an exception: {e}")
def test_do_stats_update_scans_channel_client_keys(self):
"""Must scan for ts_proxy:channel:*:clients pattern."""
cm = self._make_client_manager()
mock_redis = MagicMock()
mock_redis.scan.return_value = (0, [])
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update"), \
patch("redis.Redis.from_url", return_value=mock_redis):
cm._do_stats_update()
scan_call = mock_redis.scan.call_args
self.assertIn("ts_proxy:channel:*:clients", str(scan_call))
# ---------------------------------------------------------------------------
# Integration: remove_client must not block on WebSocket
# ---------------------------------------------------------------------------
class ClientRemoveIntegrationTests(TestCase):
"""When remove_client() fires, _trigger_stats_update must not block."""
def test_remove_client_does_not_block_on_websocket(self):
"""remove_client() must return quickly even if WebSocket is slow."""
from apps.proxy.ts_proxy.client_manager import ClientManager
cm = ClientManager.__new__(ClientManager)
cm.channel_id = "00000000-0000-0000-0000-000000000005"
cm._heartbeat_running = False
cm.clients = {"test-client-1"}
cm.last_heartbeat_time = {"test-client-1": time.time()}
cm.last_active_time = time.time()
cm.client_set_key = f"ts_proxy:channel:{cm.channel_id}:clients"
cm.client_ttl = 60
cm.worker_id = "worker-1"
cm.proxy_server = MagicMock()
cm.proxy_server.am_i_owner.return_value = False
cm.lock = threading.Lock()
mock_redis = MagicMock()
mock_redis.hgetall.return_value = {b"ip_address": b"127.0.0.1"}
mock_redis.scard.return_value = 1
cm.redis_client = mock_redis
slow_ws_called = threading.Event()
def slow_websocket(*args, **kwargs):
time.sleep(2.0)
slow_ws_called.set()
start = time.time()
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update", side_effect=slow_websocket):
cm.remove_client("test-client-1")
elapsed = time.time() - start
self.assertLess(elapsed, 1.0,
f"remove_client() blocked for {elapsed:.2f}s waiting for WebSocket "
f"(should dispatch to background thread and return immediately)")
# ---------------------------------------------------------------------------
# DVR timeout threshold vs keepalive timing
# ---------------------------------------------------------------------------
class KeepaliveTimingTests(TestCase):
"""Verify that keepalive threshold gives sufficient margin before DVR timeout."""
def test_keepalive_threshold_less_than_dvr_timeout(self):
"""CONNECTION_TIMEOUT (keepalive trigger) must be < DVR read timeout (15s)."""
from apps.proxy.config import TSConfig as Config
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
dvr_read_timeout = 15 # hard-coded in run_recording: timeout=(10, 15)
self.assertLess(
connection_timeout,
dvr_read_timeout,
f"CONNECTION_TIMEOUT ({connection_timeout}s) must be < DVR timeout ({dvr_read_timeout}s) "
f"so keepalives fire before DVR times out",
)
def test_keepalive_interval_is_short(self):
"""KEEPALIVE_INTERVAL must be short enough to send multiple keepalives in the gap."""
from apps.proxy.config import TSConfig as Config
interval = getattr(Config, "KEEPALIVE_INTERVAL", 0.5)
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
remaining_window = 15 - connection_timeout
self.assertGreater(
remaining_window / interval,
3,
f"KEEPALIVE_INTERVAL ({interval}s) is too long: only "
f"{remaining_window/interval:.1f} keepalives would fit in the "
f"{remaining_window}s window before DVR timeout",
)

View file

@ -0,0 +1,169 @@
"""Tests for the _validate_url() helper in tasks.py.
Covers:
- Rejection of None, empty, and non-string inputs
- Non-HTTP URLs pass through without network requests
- HTTP(S) URLs validated via HEAD request (2xx/3xx pass, 4xx/5xx fail)
- Network errors (timeout, connection) treated as failures
- Per-worker result cache: hits, expiry, eviction
"""
from unittest.mock import patch, MagicMock
from django.test import TestCase
from apps.channels.tasks import _validate_url, _url_validation_cache, _URL_CACHE_TTL
class ValidateUrlInputTests(TestCase):
"""Input validation — no network requests should be made."""
def setUp(self):
_url_validation_cache.clear()
def test_none_returns_false(self):
self.assertFalse(_validate_url(None))
def test_empty_string_returns_false(self):
self.assertFalse(_validate_url(""))
def test_non_string_returns_false(self):
self.assertFalse(_validate_url(123))
self.assertFalse(_validate_url(["http://example.com"]))
@patch("apps.channels.tasks.requests.head")
def test_non_http_url_returns_true_without_request(self, mock_head):
"""file:// and other non-HTTP schemes skip validation."""
self.assertTrue(_validate_url("file:///local/path.jpg"))
self.assertTrue(_validate_url("/data/images/poster.jpg"))
mock_head.assert_not_called()
class ValidateUrlNetworkTests(TestCase):
"""HTTP(S) URL validation via HEAD request."""
def setUp(self):
_url_validation_cache.clear()
@patch("apps.channels.tasks.requests.head")
def test_200_returns_true(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
self.assertTrue(_validate_url("https://example.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_302_redirect_returns_true(self, mock_head):
mock_head.return_value = MagicMock(status_code=302)
self.assertTrue(_validate_url("https://example.com/redirect"))
@patch("apps.channels.tasks.requests.head")
def test_404_returns_false(self, mock_head):
mock_head.return_value = MagicMock(status_code=404)
self.assertFalse(_validate_url("https://dead-cdn.com/missing.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_500_returns_false(self, mock_head):
mock_head.return_value = MagicMock(status_code=500)
self.assertFalse(_validate_url("https://broken.com/error"))
@patch("apps.channels.tasks.requests.head")
def test_timeout_returns_false(self, mock_head):
import requests
mock_head.side_effect = requests.Timeout("timed out")
self.assertFalse(_validate_url("https://slow-cdn.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_connection_error_returns_false(self, mock_head):
import requests
mock_head.side_effect = requests.ConnectionError("refused")
self.assertFalse(_validate_url("https://unreachable.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_custom_timeout_passed_to_head(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
_validate_url("https://example.com/img.jpg", timeout=10)
mock_head.assert_called_once_with(
"https://example.com/img.jpg", timeout=10, allow_redirects=True
)
@patch("apps.channels.tasks.requests.get")
@patch("apps.channels.tasks.requests.head")
def test_405_falls_back_to_get(self, mock_head, mock_get):
"""When HEAD returns 405, fall back to a ranged GET request."""
mock_head.return_value = MagicMock(status_code=405)
mock_resp = MagicMock(status_code=200)
mock_get.return_value = mock_resp
self.assertTrue(_validate_url("https://no-head.com/poster.jpg"))
mock_get.assert_called_once()
mock_resp.close.assert_called_once()
@patch("apps.channels.tasks.requests.get")
@patch("apps.channels.tasks.requests.head")
def test_405_fallback_get_also_fails(self, mock_head, mock_get):
"""When HEAD returns 405 and GET also fails, return False."""
mock_head.return_value = MagicMock(status_code=405)
mock_get.return_value = MagicMock(status_code=403)
self.assertFalse(_validate_url("https://blocked.com/poster.jpg"))
class ValidateUrlCacheTests(TestCase):
"""Per-worker result caching."""
def setUp(self):
_url_validation_cache.clear()
@patch("apps.channels.tasks.requests.head")
def test_cache_hit_avoids_second_request(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
url = "https://cached.com/poster.jpg"
self.assertTrue(_validate_url(url))
self.assertTrue(_validate_url(url))
mock_head.assert_called_once()
@patch("apps.channels.tasks.requests.head")
def test_cache_hit_returns_false_for_failed_url(self, mock_head):
mock_head.return_value = MagicMock(status_code=404)
url = "https://dead.com/missing.jpg"
self.assertFalse(_validate_url(url))
self.assertFalse(_validate_url(url))
mock_head.assert_called_once()
@patch("apps.channels.tasks.time.monotonic")
@patch("apps.channels.tasks.requests.head")
def test_cache_expiry_triggers_new_request(self, mock_head, mock_time):
"""After TTL expires, a new HEAD request is made."""
mock_head.return_value = MagicMock(status_code=200)
url = "https://expiring.com/poster.jpg"
mock_time.return_value = 1000.0
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 1)
# Within TTL — cache hit
mock_time.return_value = 1000.0 + _URL_CACHE_TTL - 1
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 1)
# Past TTL — new request
mock_time.return_value = 1000.0 + _URL_CACHE_TTL + 1
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 2)
@patch("apps.channels.tasks.time.monotonic")
@patch("apps.channels.tasks.requests.head")
def test_eviction_when_cache_exceeds_limit(self, mock_head, mock_time):
"""Expired entries are evicted when cache grows past 512 entries."""
mock_head.return_value = MagicMock(status_code=200)
# Fill cache with 513 entries at time 0
mock_time.return_value = 0.0
for i in range(513):
_url_validation_cache[f"https://fill-{i}.com/img.jpg"] = (True, 0.0)
# Advance past TTL and add one more — triggers eviction
mock_time.return_value = _URL_CACHE_TTL + 1
_validate_url("https://trigger-eviction.com/img.jpg")
# All 513 old entries expired and should be evicted
remaining = [k for k in _url_validation_cache if k.startswith("https://fill-")]
self.assertEqual(len(remaining), 0)
# The new entry should remain
self.assertIn("https://trigger-eviction.com/img.jpg", _url_validation_cache)

View file

@ -8,6 +8,7 @@ from apps.accounts.permissions import (
)
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
@ -264,38 +265,50 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
category_settings = request.data.get("category_settings", [])
try:
for setting in group_settings:
group_id = setting.get("channel_group")
enabled = setting.get("enabled", True)
auto_sync = setting.get("auto_channel_sync", False)
sync_start = setting.get("auto_sync_channel_start")
custom_properties = setting.get("custom_properties", {})
if group_id:
ChannelGroupM3UAccount.objects.update_or_create(
channel_group_id=group_id,
with transaction.atomic():
group_objects = [
ChannelGroupM3UAccount(
channel_group_id=setting["channel_group"],
m3u_account=account,
defaults={
"enabled": enabled,
"auto_channel_sync": auto_sync,
"auto_sync_channel_start": sync_start,
"custom_properties": custom_properties,
},
enabled=setting.get("enabled", True),
auto_channel_sync=setting.get("auto_channel_sync", False),
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
custom_properties=setting.get("custom_properties", {}),
)
for setting in group_settings
if setting.get("channel_group")
]
if group_objects:
ChannelGroupM3UAccount.objects.bulk_create(
group_objects,
update_conflicts=True,
unique_fields=["channel_group", "m3u_account"],
update_fields=[
"enabled",
"auto_channel_sync",
"auto_sync_channel_start",
"custom_properties",
],
)
for setting in category_settings:
category_id = setting.get("id")
enabled = setting.get("enabled", True)
custom_properties = setting.get("custom_properties", {})
if category_id:
M3UVODCategoryRelation.objects.update_or_create(
category_id=category_id,
category_objects = [
M3UVODCategoryRelation(
category_id=setting["id"],
m3u_account=account,
defaults={
"enabled": enabled,
"custom_properties": custom_properties,
},
enabled=setting.get("enabled", True),
custom_properties=setting.get("custom_properties", {}),
)
for setting in category_settings
if setting.get("id")
]
if category_objects:
M3UVODCategoryRelation.objects.bulk_create(
category_objects,
update_conflicts=True,
unique_fields=["m3u_account", "category"],
update_fields=["enabled", "custom_properties"],
)
return Response({"message": "Group settings updated successfully"})

View file

@ -489,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
@ -756,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", ""))
@ -765,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", ""),
@ -849,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:
@ -1177,14 +1192,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated."
# Aggressive garbage collection
# del streams_to_create, streams_to_update, stream_hashes, existing_streams
# from core.utils import cleanup_memory
# cleanup_memory(log_usage=True, force_collection=True)
# Clean up database connections for threading
connections.close_all()
# Free batch data structures (reference-counted deallocation)
del streams_to_create, streams_to_update, stream_hashes, existing_streams
return retval
@ -2649,6 +2662,16 @@ def refresh_single_m3u_account(account_id):
lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id)
lock_renewer.start()
try:
return _refresh_single_m3u_account_impl(account_id)
finally:
# Guaranteed cleanup on all exit paths (success, exception, early return)
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
def _refresh_single_m3u_account_impl(account_id):
"""Implementation of M3U account refresh with guaranteed memory cleanup."""
# Record start time
refresh_start_timestamp = timezone.now() # For the cleanup function
start_time = time.time() # For tracking elapsed time as float
@ -2660,8 +2683,6 @@ def refresh_single_m3u_account(account_id):
account = M3UAccount.objects.get(id=account_id, is_active=True)
if not account.is_active:
logger.debug(f"Account {account_id} is not active, skipping.")
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
return
# Set status to fetching
@ -2690,8 +2711,6 @@ def refresh_single_m3u_account(account_id):
else:
logger.debug(f"No orphaned task found for M3U account {account_id}")
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up"
# Fetch M3U lines and handle potential issues
@ -2706,6 +2725,7 @@ def refresh_single_m3u_account(account_id):
extinf_data = data["extinf_data"]
groups = data["groups"]
del data # Free top-level dict; extinf_data/groups retain their references
except json.JSONDecodeError as e:
# Handle corrupted JSON file
logger.error(
@ -2741,8 +2761,6 @@ def refresh_single_m3u_account(account_id):
logger.error(
f"Failed to refresh M3U groups for account {account_id}: {result}"
)
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
return "Failed to update m3u account - download failed or other error"
extinf_data, groups = result
@ -2775,8 +2793,6 @@ def refresh_single_m3u_account(account_id):
status="error",
error=f"Error refreshing M3U groups: {str(e)}",
)
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
return "Failed to update m3u account"
# Only proceed with parsing if we actually have data and no errors were encountered
@ -2799,8 +2815,6 @@ def refresh_single_m3u_account(account_id):
status="error",
error="No data available for processing",
)
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
return "Failed to update m3u account, no data available"
hash_keys = CoreSettings.get_m3u_hash_key().split(",")
@ -2946,6 +2960,9 @@ def refresh_single_m3u_account(account_id):
logger.info(f"Processing {len(all_xc_streams)} XC streams in {len(batches)} batches")
# Free the original list; batches hold independent sliced copies
del all_xc_streams
# Use threading for XC stream processing - now with consistent batch sizes
max_workers = min(4, len(batches))
logger.debug(f"Using {max_workers} threads for XC stream processing")
@ -3106,32 +3123,38 @@ def refresh_single_m3u_account(account_id):
except Exception as e:
logger.error(f"Error processing M3U for account {account_id}: {str(e)}")
account.status = M3UAccount.Status.ERROR
account.last_message = f"Error processing M3U: {str(e)}"
account.save(update_fields=["status", "last_message"])
try:
account.status = M3UAccount.Status.ERROR
account.last_message = f"Error processing M3U: {str(e)}"
account.save(update_fields=["status", "last_message"])
except Exception:
logger.debug(f"Failed to update account {account_id} status during error handling")
raise # Re-raise the exception for Celery to handle
finally:
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
# Free large data structures regardless of success or failure
if 'existing_groups' in locals():
del existing_groups
if 'extinf_data' in locals():
del extinf_data
if 'groups' in locals():
del groups
if 'batches' in locals():
del batches
if 'all_xc_streams' in locals():
del all_xc_streams
if 'data' in locals():
del data
if 'filtered_groups' in locals():
del filtered_groups
if 'channel_group_relationships' in locals():
del channel_group_relationships
# Aggressive garbage collection
# Only delete variables if they exist
if 'existing_groups' in locals():
del existing_groups
if 'extinf_data' in locals():
del extinf_data
if 'groups' in locals():
del groups
if 'batches' in locals():
del batches
from core.utils import cleanup_memory
cleanup_memory(log_usage=True, force_collection=True)
# Clean up cache file since we've fully processed it
if os.path.exists(cache_path):
os.remove(cache_path)
# Remove cache file after processing (success or failure)
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
try:
os.remove(cache_path)
except OSError:
pass
return f"Dispatched jobs complete."

View file

View file

@ -0,0 +1,41 @@
from django.test import SimpleTestCase
from apps.m3u.tasks import parse_extinf_line
class ParseExtinfLineTests(SimpleTestCase):
def test_preserves_equals_padding_in_tvg_logo(self):
line = (
'#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" '
'tvg-chno="1001" '
'tvg-name="UK Sky Sports Premier League" '
'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?'
'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" '
'group-title="Team Games",UK Sky Sports Premier League'
)
parsed = parse_extinf_line(line)
self.assertIsNotNone(parsed)
self.assertEqual(
parsed["attributes"]["tvg-logo"],
"https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==",
)
self.assertEqual(parsed["attributes"]["group-title"], "Team Games")
self.assertEqual(parsed["name"], "UK Sky Sports Premier League")
def test_supports_single_quoted_attributes(self):
line = (
"#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' "
"group-title='Sports',Channel One"
)
parsed = parse_extinf_line(line)
self.assertIsNotNone(parsed)
self.assertEqual(
parsed["attributes"]["tvg-logo"],
"https://example.com/logo==.png",
)
self.assertEqual(parsed["attributes"]["group-title"], "Sports")
self.assertEqual(parsed["display_name"], "Channel One")

View file

@ -0,0 +1,106 @@
"""
Tests for memory cleanup behavior in M3U refresh tasks.
Verifies that database connections are properly closed, task locks are
released on all exit paths, and garbage collection runs where expected.
"""
from unittest.mock import patch, MagicMock
from django.test import SimpleTestCase
from apps.m3u.models import M3UAccount
class ProcessM3UBatchCleanupTests(SimpleTestCase):
"""Verify process_m3u_batch_direct cleans up after processing."""
@patch("apps.m3u.tasks.Stream")
@patch("apps.m3u.tasks.M3UAccount")
def test_connections_closed_after_batch(self, mock_account_cls, mock_stream_cls):
"""Database connections must be closed after batch processing (thread safety)."""
from apps.m3u.tasks import process_m3u_batch_direct
mock_account = MagicMock()
mock_account.filters.order_by.return_value = []
mock_account_cls.objects.get.return_value = mock_account
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
[]
)
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
with patch("django.db.connections") as mock_connections:
process_m3u_batch_direct(1, [], {}, ["name", "url"])
mock_connections.close_all.assert_called()
class LockReleaseTests(SimpleTestCase):
"""Verify task lock is released on all exit paths."""
@patch("apps.m3u.tasks.delete_m3u_refresh_task_by_id", return_value=False)
def test_lock_released_on_account_not_found(self, mock_delete):
"""release_task_lock must be called when account does not exist."""
with patch(
"apps.m3u.tasks.acquire_task_lock", return_value=True
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
"apps.m3u.tasks.TaskLockRenewer"
):
with patch(
"apps.m3u.tasks.M3UAccount.objects.get",
side_effect=M3UAccount.DoesNotExist,
):
from apps.m3u.tasks import refresh_single_m3u_account
refresh_single_m3u_account(99999)
mock_release.assert_called_once_with(
"refresh_single_m3u_account", 99999
)
def test_lock_released_on_exception(self):
"""release_task_lock must be called when an exception is raised."""
mock_account = MagicMock()
mock_account.is_active = True
mock_account.account_type = "STD"
mock_account.custom_properties = {}
mock_account.filters.all.return_value = []
mock_account.status = MagicMock()
with patch(
"apps.m3u.tasks.acquire_task_lock", return_value=True
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
"apps.m3u.tasks.TaskLockRenewer"
):
with patch(
"apps.m3u.tasks.M3UAccount.objects.get", return_value=mock_account
):
with patch("os.path.exists", return_value=False):
with patch(
"apps.m3u.tasks.refresh_m3u_groups",
side_effect=RuntimeError("test"),
):
from apps.m3u.tasks import refresh_single_m3u_account
try:
refresh_single_m3u_account(1)
except RuntimeError:
pass
mock_release.assert_called_once_with("refresh_single_m3u_account", 1)
class XCCategoryCleanupTests(SimpleTestCase):
"""Regression guard: process_xc_category_direct must continue to clean up."""
@patch("apps.m3u.tasks.XCClient")
@patch("apps.m3u.tasks.M3UAccount")
def test_xc_category_calls_gc_collect(self, mock_account_cls, mock_xc_client):
"""gc.collect() must be called after XC category processing."""
from apps.m3u.tasks import process_xc_category_direct
mock_account = MagicMock()
mock_account_cls.objects.get.return_value = mock_account
with patch("gc.collect") as mock_gc, patch("django.db.connections"):
process_xc_category_direct(1, {}, {}, ["name", "url"])
mock_gc.assert_called()

View file

@ -43,7 +43,7 @@ class BaseConfig:
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"new_client_behind_seconds": 2,
"new_client_behind_seconds": 5,
}
finally:
@ -82,7 +82,7 @@ class TSConfig(BaseConfig):
# Buffer settings
INITIAL_BEHIND_CHUNKS = 4 # How many chunks behind to start a client (4 chunks = ~1MB)
CHUNK_BATCH_SIZE = 5 # How many chunks to fetch in one batch
NEW_CLIENT_BEHIND_SECONDS = 2 # Start new clients this many seconds behind live (0 = start at live)
NEW_CLIENT_BEHIND_SECONDS = 5 # Start new clients this many seconds behind live (0 = start at live)
KEEPALIVE_INTERVAL = 0.5 # Seconds between keepalive packets when at buffer head
# Chunk read timeout
CHUNK_TIMEOUT = 5 # Seconds to wait for each chunk read

View file

@ -23,7 +23,7 @@ class ClientManager:
self.channel_id = channel_id
self.redis_client = redis_client
self.clients = set()
self.lock = threading.Lock()
self.lock = threading.RLock()
self.last_active_time = time.time()
self.worker_id = worker_id # Store worker ID as instance variable
self._heartbeat_running = True # Flag to control heartbeat thread
@ -43,14 +43,20 @@ class ClientManager:
self._registered_clients = set() # Track already registered client IDs
def _trigger_stats_update(self):
"""Trigger a channel stats update via WebSocket"""
"""Trigger a channel stats update via WebSocket in a background thread.
Offloaded so the caller is not blocked. send_websocket_update is
gevent-safe (offloads async_to_sync to a native OS thread).
"""
threading.Thread(target=self._do_stats_update, daemon=True).start()
def _do_stats_update(self):
"""Perform the stats update in the background."""
try:
# Import here to avoid potential import issues
from apps.proxy.ts_proxy.channel_status import ChannelStatus
import redis
from django.conf import settings
# Get all channels from Redis using settings
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
redis_client = redis.Redis.from_url(redis_url, decode_responses=True)
all_channels = []
@ -59,7 +65,6 @@ class ClientManager:
while True:
cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100)
for key in keys:
# Extract channel ID from key
parts = key.split(':')
if len(parts) >= 4:
ch_id = parts[2]
@ -70,7 +75,6 @@ class ClientManager:
if cursor == 0:
break
# Send WebSocket update using existing infrastructure
send_websocket_update(
"updates",
"update",

View file

@ -48,7 +48,7 @@ class ConfigHelper:
Loaded from DB proxy_settings so users can change it at runtime."""
from apps.proxy.config import TSConfig
settings = TSConfig.get_proxy_settings()
return settings.get('new_client_behind_seconds', 2)
return settings.get('new_client_behind_seconds', 5)
@staticmethod
def keepalive_interval():

View file

@ -58,6 +58,19 @@ class StreamGenerator:
self.last_ttl_refresh = time.time()
self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming
# Cached proxy server reference
self.proxy_server = None
# Non-owner health check throttle: avoid Redis GET on every loop iteration
self._last_health_check_time = 0.0
self._last_health_check_result = False
self._health_check_interval = 2.0 # seconds
# Resource check throttle: Redis stop/state checks are expensive; throttle
# them while allowing cheap in-memory checks to run every iteration.
self._last_resource_check_time = 0.0
self._resource_check_interval = 1.0 # seconds
def generate(self):
"""
Generator function that produces the stream content for the client.
@ -229,6 +242,7 @@ class StreamGenerator:
)
# Store important objects as instance variables
self.proxy_server = proxy_server
self.buffer = buffer
self.stream_manager = stream_manager
self.last_yield_time = time.time()
@ -320,9 +334,7 @@ class StreamGenerator:
def _check_resources(self):
"""Check if required resources still exist."""
proxy_server = ProxyServer.get_instance()
# Enhanced resource checks
proxy_server = self.proxy_server or ProxyServer.get_instance()
if self.channel_id not in proxy_server.stream_buffers:
logger.info(f"[{self.client_id}] Channel buffer no longer exists, terminating stream")
return False
@ -331,35 +343,43 @@ class StreamGenerator:
logger.info(f"[{self.client_id}] Client manager no longer exists, terminating stream")
return False
# Check if this specific client has been stopped (Redis keys, etc.)
if proxy_server.redis_client:
# Channel stop check - with extended key set
stop_key = RedisKeys.channel_stopping(self.channel_id)
if proxy_server.redis_client.exists(stop_key):
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
client_manager = proxy_server.client_managers[self.channel_id]
if self.client_id not in client_manager.clients:
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
return False
# --- Redis checks: throttled to _resource_check_interval (default 1s) ---
# 3 Redis round-trips on every iteration is expensive at stream rates;
# stop/state signals change infrequently so a 1-second poll is sufficient.
if not proxy_server.redis_client:
return True
now = time.time()
if now - self._last_resource_check_time < self._resource_check_interval:
return True
self._last_resource_check_time = now
# Channel stop check
stop_key = RedisKeys.channel_stopping(self.channel_id)
if proxy_server.redis_client.exists(stop_key):
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
return False
# Channel state in metadata
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if state in ['error', 'stopped', 'stopping']:
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
return False
# Also check channel state in metadata
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if state in ['error', 'stopped', 'stopping']:
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
return False
# Client stop check
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
if proxy_server.redis_client.exists(client_stop_key):
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
return False
# Also check if client has been removed from client_manager
if self.channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[self.channel_id]
if self.client_id not in client_manager.clients:
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
return False
# Client stop check
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
if proxy_server.redis_client.exists(client_stop_key):
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
return False
return True
@ -368,7 +388,7 @@ class StreamGenerator:
# Process and send chunks
total_size = sum(len(c) for c in chunks)
logger.debug(f"[{self.client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {self.local_index+1} to {next_index}")
proxy_server = ProxyServer.get_instance()
proxy_server = self.proxy_server or ProxyServer.get_instance()
# Send the chunks to the client
for chunk in chunks:
@ -436,10 +456,38 @@ class StreamGenerator:
"""Determine if a keepalive packet should be sent."""
# Check if we're caught up to buffer head
at_buffer_head = local_index >= self.buffer.index
if not at_buffer_head or self.consecutive_empty < 5:
return False
# If we're at buffer head and no data is coming, send keepalive
stream_healthy = self.stream_manager.healthy if self.stream_manager else True
return at_buffer_head and not stream_healthy and self.consecutive_empty >= 5
if self.stream_manager is not None:
# Owner worker: use the in-memory health flag directly.
return not self.stream_manager.healthy
else:
# Non-owner worker: stream_manager only exists in the owner process.
# Approximate health from the Redis last_data timestamp; if stale
# beyond CONNECTION_TIMEOUT, send keepalives to prevent DVR timeout.
# Throttled: only re-query Redis every _health_check_interval seconds
# to avoid a Redis GET on every loop iteration during sustained waits.
now = time.time()
if now - self._last_health_check_time < self._health_check_interval:
return self._last_health_check_result
try:
proxy_server = self.proxy_server or ProxyServer.get_instance()
if proxy_server.redis_client:
raw = proxy_server.redis_client.get(RedisKeys.last_data(self.channel_id))
if raw:
age = now - float(raw)
timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10)
result = age >= timeout_threshold
else:
# No timestamp in Redis → key missing or expired → unhealthy
result = True
self._last_health_check_time = now
self._last_health_check_result = result
return result
except Exception:
pass
return False
def _is_ghost_client(self, local_index):
"""Check if this appears to be a ghost client (stuck but buffer advancing)."""

View file

@ -39,84 +39,44 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
# Handle direct stream preview (custom streams)
if isinstance(channel_or_stream, Stream):
from core.utils import RedisClient
stream = channel_or_stream
logger.info(f"Previewing stream directly: {stream.id} ({stream.name})")
# For custom streams, we need to get the M3U account and profile
m3u_account = stream.m3u_account
if not m3u_account:
if not stream.m3u_account:
logger.error(f"Stream {stream.id} has no M3U account")
return None, None, False, None
# Get active profiles for this M3U account
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
logger.error(f"No default active profile found for M3U account {m3u_account.id}")
# Use get_stream() to atomically reserve a slot and write the
# channel_stream / stream_profile Redis keys, matching the channel
# path so stream_name and stream_stats work correctly.
stream_id, profile_id, error_reason = stream.get_stream()
if not stream_id or not profile_id:
logger.error(f"No profile available for stream {stream.id}: {error_reason}")
return None, None, False, None
# Check profiles in order: default first, then others
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
try:
profile = M3UAccountProfile.objects.get(id=profile_id)
m3u_account = stream.m3u_account
# Try to find an available profile with connection capacity
redis_client = RedisClient.get_client()
selected_profile = None
stream_user_agent = m3u_account.get_user_agent().user_agent
if stream_user_agent is None:
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
for profile in profiles:
logger.info(profile)
stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
# Check connection availability
if redis_client:
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
stream_profile = stream.get_stream_profile()
logger.debug(f"Using stream profile: {stream_profile.name}")
# Check if profile has available slots (or unlimited connections)
if profile.max_streams == 0 or current_connections < profile.max_streams:
selected_profile = profile
logger.debug(f"Selected profile {profile.id} with {current_connections}/{profile.max_streams} connections for stream preview")
break
else:
logger.debug(f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}")
else:
# No Redis available, use first active profile
selected_profile = profile
break
transcode = not stream_profile.is_proxy()
stream_profile_id = stream_profile.id
if not selected_profile:
logger.error(f"No profiles available with connection capacity for M3U account {m3u_account.id}")
return stream_url, stream_user_agent, transcode, stream_profile_id
except Exception as e:
logger.error(f"Error generating stream URL for stream {stream.id}: {e}")
stream.release_stream()
return None, None, False, None
# Get the appropriate user agent
stream_user_agent = m3u_account.get_user_agent().user_agent
if stream_user_agent is None:
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
# Get stream URL with the selected profile's URL transformation
stream_url = transform_url(stream.url, selected_profile.search_pattern, selected_profile.replace_pattern)
# Check if the stream has its own stream_profile set, otherwise use default
if stream.stream_profile:
stream_profile = stream.stream_profile
logger.debug(f"Using stream's own stream profile: {stream_profile.name}")
else:
stream_profile = StreamProfile.objects.get(
id=CoreSettings.get_default_stream_profile_id()
)
logger.debug(f"Using default stream profile: {stream_profile.name}")
# Check if transcoding is needed
if stream_profile.is_proxy() or stream_profile is None:
transcode = False
else:
transcode = True
stream_profile_id = stream_profile.id
return stream_url, stream_user_agent, transcode, stream_profile_id
# Handle channel preview (existing logic)
channel = channel_or_stream

View file

@ -1645,14 +1645,30 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=
orphaned_movie_count = orphaned_movies.count()
if orphaned_movie_count > 0:
logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations")
orphaned_movies.delete()
try:
orphaned_movies.delete()
except IntegrityError:
# A concurrent refresh task created a new relation for one of these movies
# between our query and the DELETE. Skip and let the next cleanup run handle it.
logger.warning(
"Skipped some orphaned movie deletions due to concurrent modifications; "
"they will be retried on the next cleanup run."
)
orphaned_movie_count = 0
# Clean up series with no relations (orphaned)
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
orphaned_series_count = orphaned_series.count()
if orphaned_series_count > 0:
logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations")
orphaned_series.delete()
try:
orphaned_series.delete()
except IntegrityError:
logger.warning(
"Skipped some orphaned series deletions due to concurrent modifications; "
"they will be retried on the next cleanup run."
)
orphaned_series_count = 0
result = (f"Cleaned up {stale_movie_count} stale movie relations, "
f"{stale_series_count} stale series relations, "

View file

@ -183,7 +183,7 @@ class ProxySettingsViewSet(viewsets.ViewSet):
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"new_client_behind_seconds": 2,
"new_client_behind_seconds": 5,
}
settings_obj, created = CoreSettings.objects.get_or_create(
key=PROXY_SETTINGS_KEY,

View file

@ -329,7 +329,7 @@ class CoreSettings(models.Model):
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"new_client_behind_seconds": 2,
"new_client_behind_seconds": 5,
})
# System Settings

View file

@ -78,7 +78,7 @@ class ProxySettingsSerializer(serializers.Serializer):
redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600)
channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300)
channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60)
new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=2)
new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5)
def validate_buffering_timeout(self, value):
if value < 0 or value > 300:

View file

@ -294,30 +294,34 @@ def send_websocket_update(group_name, event_type, data, collect_garbage=False):
"""
Standardized function to send WebSocket updates with proper memory management.
Args:
group_name: The WebSocket group to send to (e.g. 'updates')
event_type: The type of message (e.g. 'update')
data: The data to send
collect_garbage: Whether to force garbage collection after sending
In uWSGI + gevent deployments, async_to_sync creates an asyncio event loop
whose native EpollSelector blocks the entire OS thread, freezing all gevent
greenlets in the worker. To avoid this, the actual send is offloaded to a
real OS thread from gevent's native threadpool when monkey-patching is active.
"""
channel_layer = get_channel_layer()
try:
async_to_sync(channel_layer.group_send)(
group_name,
{
'type': event_type,
'data': data
}
)
except Exception as e:
logger.warning(f"Failed to send WebSocket update: {e}")
finally:
# Explicitly release references to help garbage collection
channel_layer = None
message = {'type': event_type, 'data': data}
# Force garbage collection if requested
if collect_garbage:
gc.collect()
def _do_send():
try:
async_to_sync(channel_layer.group_send)(group_name, message)
except Exception as e:
logger.warning(f"Failed to send WebSocket update: {e}")
try:
import gevent.monkey
if gevent.monkey.is_module_patched('threading'):
from gevent import get_hub
get_hub().threadpool.spawn(_do_send)
return
except Exception:
pass
# Not in a gevent-patched environment — call directly
_do_send()
if collect_garbage:
gc.collect()
def send_websocket_event(event, success, data):
"""Acquire a lock to prevent concurrent task execution."""

View file

@ -12,9 +12,20 @@ fi
trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR
##############################################################################
# 0) Warning / Disclaimer
# 0) Locales & Warning / Disclaimer
##############################################################################
setup_locales() {
echo ">>> Setting up locales..."
apt-get update
apt-get install -y locales
sed -i '/en_US.UTF-8 UTF-8/s/^# //g' /etc/locale.gen
locale-gen
update-locale LANG=en_US.UTF-8
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
}
show_disclaimer() {
echo "**************************************************************"
echo "WARNING: While we do not anticipate any problems, we disclaim all"
@ -106,8 +117,8 @@ setup_postgresql() {
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
if [[ "$db_exists" != "1" ]]; then
echo ">>> Creating database '${POSTGRES_DB}'..."
sudo -u postgres createdb "$POSTGRES_DB"
echo ">>> Creating database '${POSTGRES_DB}' with UTF8 encoding..."
sudo -u postgres createdb -E UTF8 "$POSTGRES_DB"
else
echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation."
fi
@ -326,7 +337,7 @@ User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin"
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
@ -354,7 +365,7 @@ User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin"
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
@ -382,7 +393,7 @@ User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
Environment="PATH=${APP_DIR}/env/bin"
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
@ -474,6 +485,7 @@ EOF
##############################################################################
main() {
setup_locales
show_disclaimer
configure_variables
install_packages

View file

@ -2,7 +2,7 @@
import os
from celery import Celery
import logging
from celery.signals import task_postrun # Add import for signals
from celery.signals import task_postrun, worker_ready
# Initialize with defaults before Django settings are loaded
DEFAULT_LOG_LEVEL = 'DEBUG'
@ -149,3 +149,10 @@ def setup_celery_logging(**kwargs):
except (AttributeError, TypeError):
# If the log level string is invalid, default to DEBUG
logger.setLevel(logging.DEBUG)
@worker_ready.connect
def on_worker_ready(**kwargs):
"""Resume or finalize interrupted DVR recordings after a worker restart."""
from apps.channels.tasks import recover_recordings_on_startup
recover_recordings_on_startup.delay()

View file

@ -240,6 +240,10 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
# Worker memory safety net: recycle prefork workers exceeding 512MB RSS.
# Prevents unbounded growth from memory fragmentation or unexpected leaks.
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 524_288 # 512 MB in KB
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
CELERY_BEAT_SCHEDULE = {
# Explicitly disable the old fetch-channel-statuses task

View file

@ -15,8 +15,12 @@ services:
volumes:
- ./data:/data
depends_on:
- db
- redis
db:
condition: service_healthy
redis:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
# --- Environment Configuration ---
environment:
@ -83,9 +87,12 @@ services:
container_name: dispatcharr_celery
restart: unless-stopped
depends_on:
- db
- redis
- web
db:
condition: service_healthy
redis:
condition: service_healthy
web:
condition: service_started
volumes:
- ./data:/data
extra_hosts:
@ -97,6 +104,10 @@ services:
# Deployment Mode
- DISPATCHARR_ENV=modular
# Internal Service Communication
# Must match the web service port for DVR recording and internal API calls
- DISPATCHARR_PORT=9191
# PostgreSQL Connection
- POSTGRES_HOST=db
- POSTGRES_PORT=5432

View file

@ -9,9 +9,19 @@ echo_with_timestamp() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# Wait for Django secret key
# Wait for Django secret key (generated by the web container on startup)
JWT_TIMEOUT=120
JWT_WAITED=0
echo 'Waiting for Django secret key...'
while [ ! -f /data/jwt ]; do sleep 1; done
while [ ! -f /data/jwt ]; do
if [ $JWT_WAITED -ge $JWT_TIMEOUT ]; then
echo "❌ ERROR: Timed out waiting for /data/jwt after ${JWT_TIMEOUT}s."
echo " Is the web container running? Does it have the /data volume mounted?"
exit 1
fi
sleep 1
JWT_WAITED=$((JWT_WAITED + 1))
done
export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)"
# --- NumPy version switching for legacy hardware ---
@ -26,11 +36,21 @@ if [ "$USE_LEGACY_NUMPY" = "true" ]; then
fi
fi
# Wait for migrations to complete (check that NO unapplied migrations remain)
# Wait for migrations to complete
# Uses 'migrate --check' which exits 0 only when all migrations are applied,
# and exits 1 on unapplied migrations OR connection errors (safe either way)
MIG_TIMEOUT=300
MIG_WAITED=0
echo 'Waiting for migrations to complete...'
until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do
until python manage.py migrate --check >/dev/null 2>&1; do
if [ $MIG_WAITED -ge $MIG_TIMEOUT ]; then
echo "❌ ERROR: Timed out waiting for migrations after ${MIG_TIMEOUT}s."
echo " Check web container logs for migration errors."
exit 1
fi
echo_with_timestamp 'Migrations not ready yet, waiting...'
sleep 2
MIG_WAITED=$((MIG_WAITED + 2))
done
# Start Celery

View file

@ -99,31 +99,35 @@ echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'"
# READ-ONLY - don't let users change these
export POSTGRES_DIR=/data/db
# Global variables, stored so other users inherit them
if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
# Define all variables to process
variables=(
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
)
# Global variables, stored so other users inherit them.
# Rewritten every startup so that container restarts with changed env vars
# pick up the new values (not stale ones from a previous run).
# Define all variables to process
variables=(
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
)
# Process each variable for both profile.d and environment
for var in "${variables[@]}"; do
# Check if the variable is set in the environment
if [ -n "${!var+x}" ]; then
# Add to profile.d
echo "export ${var}=${!var}" >> /etc/profile.d/dispatcharr.sh
# Add to /etc/environment if not already there
grep -q "^${var}=" /etc/environment || echo "${var}=${!var}" >> /etc/environment
else
echo "Warning: Environment variable $var is not set"
fi
done
fi
# Truncate files before rewriting
> /etc/profile.d/dispatcharr.sh
# Process each variable for both profile.d and environment
for var in "${variables[@]}"; do
# Check if the variable is set in the environment
if [ -n "${!var+x}" ]; then
# Add to profile.d (quoted to handle special characters in values)
echo "export ${var}='${!var}'" >> /etc/profile.d/dispatcharr.sh
# Add/update in /etc/environment
sed -i "/^${var}=/d" /etc/environment
echo "${var}='${!var}'" >> /etc/environment
else
echo "Warning: Environment variable $var is not set"
fi
done
chmod +x /etc/profile.d/dispatcharr.sh
@ -175,23 +179,17 @@ else
check_external_postgres_version || exit 1
fi
# Wait for Redis to be ready (modular mode uses external Redis)
# Wait for Redis to be ready and flush stale state.
# In modular mode Redis is external — call wait_for_redis.py here
# because uWSGI's exec-pre runs under 'su -' which strips env vars
# (DISPATCHARR_ENV, REDIS_HOST, etc.).
# In AIO mode Redis is started by uWSGI (attach-daemon), so the
# exec-pre in uwsgi.ini handles the wait + flush there instead.
if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}"
echo_with_timestamp "Waiting for external Redis to be ready..."
until python3 -c "
import socket, sys
try:
s = socket.create_connection(('${REDIS_HOST}', ${REDIS_PORT}), timeout=2)
s.close()
sys.exit(0)
except Exception:
sys.exit(1)
" 2>/dev/null; do
echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
sleep 1
done
echo "✅ External Redis is ready"
echo_with_timestamp "Waiting for Redis to be ready..."
python3 /app/scripts/wait_for_redis.py
echo "✅ Redis is ready"
fi
# Ensure database encoding is UTF8 (handles both internal and external databases)

View file

@ -204,14 +204,19 @@ check_external_postgres_version() {
MIN_REQUIRED_VERSION=$PG_VERSION
# Query external PostgreSQL version
EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "postgres" -tAc "SHOW server_version;" 2>/dev/null | grep -oE '^[0-9]+')
# Use $POSTGRES_DB — restricted users may not have access to the default 'postgres' database
PG_VERSION_ERR=$(mktemp)
EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SHOW server_version;" 2>"$PG_VERSION_ERR" | grep -oE '^[0-9]+')
if [ -z "$EXTERNAL_VERSION" ]; then
echo "❌ ERROR: Unable to determine external PostgreSQL version"
echo " Could not connect to database at ${POSTGRES_HOST}:${POSTGRES_PORT}"
echo " Could not connect to database '$POSTGRES_DB' at ${POSTGRES_HOST}:${POSTGRES_PORT} as user '$POSTGRES_USER'"
echo " Error: $(cat "$PG_VERSION_ERR")"
echo " Please verify your database connection settings."
rm -f "$PG_VERSION_ERR"
return 1
fi
rm -f "$PG_VERSION_ERR"
# Compare versions
if [[ "$EXTERNAL_VERSION" -lt "$MIN_REQUIRED_VERSION" ]]; then

View file

@ -5,8 +5,8 @@
; exec-pre = touch /data/logs/uwsgi.log
; exec-pre = chmod 666 /data/logs/uwsgi.log
; First run Redis availability check script once
exec-pre = python /app/scripts/wait_for_redis.py
; Redis wait + flush is handled by the entrypoint in modular mode
; (uWSGI exec-pre runs under 'su -' which strips Docker env vars)
; Start Daphne for WebSocket support (required for real-time features)
; Redis and Celery run in separate containers in modular mode

View file

@ -5794,24 +5794,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -19,6 +19,21 @@ import useAuthStore from './store/auth';
export const WebsocketContext = createContext([false, () => {}, null]);
// Debounce: coalesces rapid recording WS events into a single fetchRecordings()
// call (400 ms window) to prevent redundant re-renders in the TV Guide.
let _recordingFetchTimer = null;
function scheduleRecordingFetch() {
if (_recordingFetchTimer) clearTimeout(_recordingFetchTimer);
_recordingFetchTimer = setTimeout(async () => {
_recordingFetchTimer = null;
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings:', e);
}
}, 400);
}
export const WebsocketProvider = ({ children }) => {
const [isReady, setIsReady] = useState(false);
const [val, setVal] = useState(null);
@ -193,21 +208,21 @@ export const WebsocketProvider = ({ children }) => {
loading: false,
autoClose: 4000,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch {}
scheduleRecordingFetch();
} else if (status === 'skipped') {
const reasonMap = {
no_commercials_detected: 'No commercials were detected in this recording',
no_commercials: 'No commercials were detected in this recording',
};
notifications.update({
id,
title: 'No commercials to remove',
message: parsedEvent.data.reason || '',
message: reasonMap[parsedEvent.data.reason] || parsedEvent.data.reason || '',
color: 'teal',
loading: false,
autoClose: 3000,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch {}
scheduleRecordingFetch();
} else if (status === 'error') {
notifications.update({
id,
@ -217,9 +232,7 @@ export const WebsocketProvider = ({ children }) => {
loading: false,
autoClose: 6000,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch {}
scheduleRecordingFetch();
}
break;
}
@ -508,19 +521,11 @@ export const WebsocketProvider = ({ children }) => {
break;
case 'recording_updated':
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on update:', e);
}
scheduleRecordingFetch();
break;
case 'recordings_refreshed':
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on refreshed:', e);
}
scheduleRecordingFetch();
break;
case 'recording_started':
@ -528,11 +533,7 @@ export const WebsocketProvider = ({ children }) => {
title: 'Recording started!',
message: `Started recording channel ${parsedEvent.data.channel}`,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on start:', e);
}
scheduleRecordingFetch();
break;
case 'recording_ended':
@ -540,10 +541,36 @@ export const WebsocketProvider = ({ children }) => {
title: 'Recording finished!',
message: `Stopped recording channel ${parsedEvent.data.channel}`,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on end:', e);
scheduleRecordingFetch();
break;
case 'recording_stopped':
notifications.show({
title: 'Recording stopped',
message: `Recording stopped early for ${parsedEvent.data.channel || 'channel'}. Partial content has been saved.`,
color: 'yellow',
});
scheduleRecordingFetch();
break;
case 'recording_extended':
scheduleRecordingFetch();
break;
case 'recording_cancelled':
notifications.show({
title: parsedEvent.data.was_in_progress ? 'Recording cancelled' : 'Recording deleted',
message: parsedEvent.data.was_in_progress
? 'Recording cancelled and content removed.'
: 'Recording deleted.',
color: 'red',
});
// Surgical removal by ID avoids a full fetchRecordings() re-render.
// Fall back to a full refresh if the ID is missing (e.g. older server).
if (parsedEvent.data.recording_id != null) {
useChannelsStore.getState().removeRecording(parsedEvent.data.recording_id);
} else {
scheduleRecordingFetch();
}
break;

View file

@ -2662,6 +2662,55 @@ export default class API {
}
}
static async stopRecording(id) {
try {
await request(`${host}/api/channels/recordings/${id}/stop/`, {
method: 'POST',
});
} catch (e) {
errorNotification(`Failed to stop recording ${id}`, e);
throw e;
}
}
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' },
});
return resp;
} catch (e) {
errorNotification(`Failed to extend recording ${id}`, e);
throw e;
}
}
static async refreshArtwork(id) {
try {
await request(`${host}/api/channels/recordings/${id}/refresh-artwork/`, {
method: 'POST',
});
} catch (e) {
errorNotification(`Failed to refresh artwork for recording ${id}`, e);
throw e;
}
}
static async updateRecordingMetadata(id, { title, description }) {
try {
await request(`${host}/api/channels/recordings/${id}/update-metadata/`, {
method: 'POST',
body: JSON.stringify({ title, description }),
headers: { 'Content-Type': 'application/json' },
});
} catch (e) {
errorNotification(`Failed to update recording metadata`, e);
throw e;
}
}
static async runComskip(recordingId) {
try {
const resp = await request(

View file

@ -1,4 +1,4 @@
import { Modal, Group, Text, Button, Checkbox, Box } from '@mantine/core';
import { Modal, Group, Button, Checkbox, Box } from '@mantine/core';
import React, { useState } from 'react';
import useWarningsStore from '../store/warnings';

View file

@ -4,12 +4,16 @@ class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <div>Something went wrong</div>;
return <div>Something went wrong: {this.state.error?.message}</div>;
}
return this.props.children;
}

View file

@ -12,6 +12,7 @@ export const Field = ({ field, value, onChange }) => {
const description = field.help_text ?? field.description ?? field.value;
const common = { label: field.label, description };
const effective = value ?? field.default;
switch (field.type) {
case 'info':
return (

View file

@ -4,36 +4,18 @@ import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import mpegts from 'mpegts.js';
import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
import {
applyConstraints,
calculateNewDimensions,
getClientCoordinates,
getLivePlayerErrorMessage,
getVODPlayerErrorMessage,
} from '../utils/components/FloatingVideoUtils.js';
export default function FloatingVideo() {
const isVisible = useVideoStore((s) => s.isVisible);
const streamUrl = useVideoStore((s) => s.streamUrl);
const contentType = useVideoStore((s) => s.contentType);
const metadata = useVideoStore((s) => s.metadata);
const hideVideo = useVideoStore((s) => s.hideVideo);
const videoRef = useRef(null);
const playerRef = useRef(null);
const videoContainerRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
const [showOverlay, setShowOverlay] = useState(true);
const [videoSize, setVideoSize] = useState({ width: 320, height: 180 });
const [isResizing, setIsResizing] = useState(false);
const resizeStateRef = useRef(null);
const overlayTimeoutRef = useRef(null);
const aspectRatioRef = useRef(320 / 180);
const [dragPosition, setDragPosition] = useState(null);
const dragPositionRef = useRef(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const initialPositionRef = useRef(null);
const MIN_WIDTH = 220;
const MIN_HEIGHT = 124;
const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
const HEADER_HEIGHT = 38; // height of the close button header area
const ERROR_HEIGHT = 45; // approximate height of error message area when displayed
const ResizeHandles = ({ startResize }) => {
const HANDLE_SIZE = 18;
const HANDLE_OFFSET = 0;
const resizeHandleBaseStyle = {
position: 'absolute',
width: HANDLE_SIZE,
@ -43,6 +25,7 @@ export default function FloatingVideo() {
zIndex: 8,
touchAction: 'none',
};
const resizeHandles = [
{
id: 'bottom-right',
@ -106,6 +89,56 @@ export default function FloatingVideo() {
},
];
return (
<>
{/* Resize handles */}
{resizeHandles.map((handle) => (
<Box
key={handle.id}
className="floating-video-no-drag"
onMouseDown={(event) => startResize(event, handle)}
onTouchStart={(event) => startResize(event, handle)}
style={{
...resizeHandleBaseStyle,
...handle.style,
cursor: handle.cursor,
}}
/>
))}
</>
);
}
export default function FloatingVideo() {
const isVisible = useVideoStore((s) => s.isVisible);
const streamUrl = useVideoStore((s) => s.streamUrl);
const contentType = useVideoStore((s) => s.contentType);
const metadata = useVideoStore((s) => s.metadata);
const hideVideo = useVideoStore((s) => s.hideVideo);
const videoRef = useRef(null);
const playerRef = useRef(null);
const videoContainerRef = useRef(null);
const resizeStateRef = useRef(null);
const overlayTimeoutRef = useRef(null);
const aspectRatioRef = useRef(320 / 180);
const dragPositionRef = useRef(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const initialPositionRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
const [showOverlay, setShowOverlay] = useState(true);
const [videoSize, setVideoSize] = useState({ width: 320, height: 180 });
const [isResizing, setIsResizing] = useState(false);
const [dragPosition, setDragPosition] = useState(null);
const MIN_WIDTH = 220;
const MIN_HEIGHT = 124;
const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
const HEADER_HEIGHT = 38; // height of the close button header area
const ERROR_HEIGHT = 45; // approximate height of error message area when displayed
// Safely destroy the mpegts player to prevent errors
const safeDestroyPlayer = () => {
try {
@ -190,41 +223,20 @@ export default function FloatingVideo() {
};
const handleError = (e) => {
setIsLoading(false);
const error = e.target.error;
let errorMessage = 'Video playback error';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Video playback was aborted';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Network error while loading video';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Video codec not supported by your browser';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Video format not supported by your browser';
break;
default:
errorMessage = error.message || 'Unknown video error';
}
}
setLoadError(errorMessage);
setLoadError(getVODPlayerErrorMessage(e.target.error));
};
// Enhanced progress tracking for VOD
const handleProgress = () => {
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
const duration = video.duration;
if (duration > 0) {
const bufferedPercent = (bufferedEnd / duration) * 100;
// You could emit this to a store for UI feedback
}
}
// if (video.buffered.length > 0) {
// const bufferedEnd = video.buffered.end(video.buffered.length - 1);
// const duration = video.duration;
// if (duration > 0) {
// const bufferedPercent = (bufferedEnd / duration) * 100;
// // You could emit this to a store for UI feedback
// }
// }
};
// Add event listeners
@ -301,37 +313,7 @@ export default function FloatingVideo() {
if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) {
console.error('Player error:', errorType, errorDetail);
let errorMessage = `Error: ${errorType}`;
if (errorType === 'MediaError') {
const errorString = errorDetail?.toLowerCase() || '';
if (
errorString.includes('audio') ||
errorString.includes('ac3') ||
errorString.includes('ac-3')
) {
errorMessage =
'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.';
} else if (
errorString.includes('video') ||
errorString.includes('h264') ||
errorString.includes('h.264')
) {
errorMessage =
'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.';
} else if (errorString.includes('mse')) {
errorMessage =
"Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
} else {
errorMessage =
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.';
}
} else if (errorDetail) {
errorMessage += ` - ${errorDetail}`;
}
setLoadError(errorMessage);
setLoadError(getLivePlayerErrorMessage(errorType, errorDetail));
}
});
@ -448,15 +430,7 @@ export default function FloatingVideo() {
(event) => {
if (!resizeStateRef.current) return;
const clientX =
event.touches && event.touches.length
? event.touches[0].clientX
: event.clientX;
const clientY =
event.touches && event.touches.length
? event.touches[0].clientY
: event.clientY;
const { clientX, clientY } = getClientCoordinates(event);
const {
startX,
startY,
@ -466,104 +440,62 @@ export default function FloatingVideo() {
handle,
aspectRatio,
} = resizeStateRef.current;
const deltaX = clientX - startX;
const deltaY = clientY - startY;
const widthDelta = deltaX * handle.xDir;
const heightDelta = deltaY * handle.yDir;
const ratio = aspectRatio || aspectRatioRef.current;
const { width: nextWidth, height: nextHeight } = calculateNewDimensions(
clientX - startX,
clientY - startY,
startWidth,
startHeight,
handle,
ratio
);
// Derive width/height while keeping the original aspect ratio
let nextWidth = startWidth + widthDelta;
let nextHeight = nextWidth / ratio;
// Allow vertical-driven resize if the user drags mostly vertically
if (Math.abs(deltaY) > Math.abs(deltaX)) {
nextHeight = startHeight + heightDelta;
nextWidth = nextHeight * ratio;
}
// Respect minimums while keeping the ratio
if (nextWidth < MIN_WIDTH) {
nextWidth = MIN_WIDTH;
nextHeight = nextWidth / ratio;
}
if (nextHeight < MIN_HEIGHT) {
nextHeight = MIN_HEIGHT;
nextWidth = nextHeight * ratio;
}
// Keep within viewport with a margin based on current position
const posX = startPos?.x ?? 0;
const posY = startPos?.y ?? 0;
const margin = VISIBLE_MARGIN;
let maxWidth = null;
let maxHeight = null;
if (!handle.isLeft) {
maxWidth = Math.max(MIN_WIDTH, window.innerWidth - posX - margin);
}
if (!handle.isTop) {
maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - posY - margin);
}
if (maxWidth != null && nextWidth > maxWidth) {
nextWidth = maxWidth;
nextHeight = nextWidth / ratio;
}
if (maxHeight != null && nextHeight > maxHeight) {
nextHeight = maxHeight;
nextWidth = nextHeight * ratio;
}
// Final pass to honor both bounds while keeping the ratio
if (maxWidth != null && nextWidth > maxWidth) {
nextWidth = maxWidth;
nextHeight = nextWidth / ratio;
}
const constrainedSize = applyConstraints(
nextWidth,
nextHeight,
ratio,
startPos,
handle,
MIN_WIDTH,
MIN_HEIGHT,
VISIBLE_MARGIN
);
setVideoSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
width: Math.round(constrainedSize.width),
height: Math.round(constrainedSize.height),
});
if (handle.isLeft || handle.isTop) {
let nextX = posX;
let nextY = posY;
if (handle.isLeft) {
nextX = posX + (startWidth - nextWidth);
}
if (handle.isTop) {
nextY = posY + (startHeight - nextHeight);
}
const clamped = clampToVisibleWithSize(
nextX,
nextY,
nextWidth,
nextHeight
);
if (handle.isLeft) {
nextX = clamped.x;
}
if (handle.isTop) {
nextY = clamped.y;
}
const nextPos = { x: nextX, y: nextY };
setDragPosition(nextPos);
dragPositionRef.current = nextPos;
}
updatePositionIfNeeded(
handle,
startPos,
startWidth,
startHeight,
constrainedSize
);
},
[MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize]
);
const updatePositionIfNeeded = (handle, startPos, startWidth, startHeight, newSize) => {
if (!handle.isLeft && !handle.isTop) return;
const posX = startPos?.x ?? 0;
const posY = startPos?.y ?? 0;
let nextX = handle.isLeft ? posX + (startWidth - newSize.width) : posX;
let nextY = handle.isTop ? posY + (startHeight - newSize.height) : posY;
const clamped = clampToVisibleWithSize(nextX, nextY, newSize.width, newSize.height);
const nextPos = {
x: handle.isLeft ? clamped.x : nextX,
y: handle.isTop ? clamped.y : nextY,
};
setDragPosition(nextPos);
dragPositionRef.current = nextPos;
};
const endResize = useCallback(() => {
setIsResizing(false);
resizeStateRef.current = null;
@ -577,14 +509,7 @@ export default function FloatingVideo() {
event.stopPropagation();
event.preventDefault();
const clientX =
event.touches && event.touches.length
? event.touches[0].clientX
: event.clientX;
const clientY =
event.touches && event.touches.length
? event.touches[0].clientY
: event.clientY;
const { clientX, clientY } = getClientCoordinates(event);
const aspectRatio =
videoSize.height > 0
@ -718,13 +643,35 @@ export default function FloatingVideo() {
boxShadow: '0 2px 10px rgba(0,0,0,0.7)',
}}
>
{/* Simple header row with a close button */}
{/* Header row with optional title and close button */}
<Flex
justify="flex-end"
justify="space-between"
align="center"
style={{
padding: 3,
padding: '3px 3px 3px 8px',
minHeight: '38px',
}}
>
{metadata?.name ? (
<Text
size="xs"
fw={600}
style={{
color: 'rgba(255,255,255,0.9)',
flex: 1,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
paddingRight: 4,
textShadow: '0px 1px 3px rgba(0,0,0,0.8)',
userSelect: 'none',
}}
>
{metadata.name}
</Text>
) : (
<Box style={{ flex: 1 }} />
)}
<CloseButton
onClick={handleClose}
onTouchEnd={handleClose}
@ -735,6 +682,7 @@ export default function FloatingVideo() {
minWidth: '32px',
cursor: 'pointer',
touchAction: 'manipulation',
flexShrink: 0,
}}
/>
</Flex>
@ -853,20 +801,7 @@ export default function FloatingVideo() {
</Box>
)}
{/* Resize handles */}
{resizeHandles.map((handle) => (
<Box
key={handle.id}
className="floating-video-no-drag"
onMouseDown={(event) => startResize(event, handle)}
onTouchStart={(event) => startResize(event, handle)}
style={{
...resizeHandleBaseStyle,
...handle.style,
cursor: handle.cursor,
}}
/>
))}
<ResizeHandles startResize={startResize}/>
</div>
</Draggable>
);

View file

@ -1,7 +1,6 @@
// frontend/src/components/FloatingVideo.js
import React, { useEffect, useState } from 'react';
import usePlaylistsStore from '../store/playlists';
import { notifications } from '@mantine/notifications';
import useStreamsStore from '../store/streams';
import useChannelsStore from '../store/channels';
import useEPGsStore from '../store/epgs';
@ -10,6 +9,39 @@ import { Stack, Button, Group } from '@mantine/core';
import API from '../api';
import { useNavigate } from 'react-router-dom';
import { CircleCheck } from 'lucide-react';
import { showNotification } from '../utils/notificationUtils.js';
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
usePlaylistsStore.getState().setEditPlaylistId(data.account);
// Then navigate to the content sources page
// Using the exact path that matches your app's routing structure
navigate('/sources');
};
return (
<Stack>
{data.message ||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
<Group grow>
<Button size="xs" variant="default" onClick={onClickRefresh}>
Refresh Now
</Button>
<Button size="xs" variant="outline" onClick={onClickConfigure}>
Configure Groups
</Button>
</Group>
</Stack>
);
};
export default function M3URefreshNotification() {
const playlists = usePlaylistsStore((s) => s.playlists);
@ -22,9 +54,9 @@ export default function M3URefreshNotification() {
const fetchCategories = useVODStore((s) => s.fetchCategories);
const [notificationStatus, setNotificationStatus] = useState({});
const navigate = useNavigate();
const handleM3UUpdate = (data) => {
// Skip if status hasn't changed
if (
JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)
) {
@ -36,132 +68,101 @@ export default function M3URefreshNotification() {
return;
}
// Store the updated status first
setNotificationStatus({
...notificationStatus,
// Update notification status
setNotificationStatus((prev) => ({
...prev,
[data.account]: data,
});
}));
// Special handling for pending setup status
// Handle different status types
if (data.status === 'pending_setup') {
fetchChannelGroups();
fetchPlaylists();
notifications.show({
title: `M3U Setup: ${playlist.name}`,
message: (
<Stack>
{data.message ||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
<Group grow>
<Button
size="xs"
variant="default"
onClick={() => {
API.refreshPlaylist(data.account);
}}
>
Refresh Now
</Button>
<Button
size="xs"
variant="outline"
onClick={() => {
// Store the ID we want to edit in the store first
usePlaylistsStore.getState().setEditPlaylistId(data.account);
// Then navigate to the content sources page
// Using the exact path that matches your app's routing structure
navigate('/sources');
}}
>
Configure Groups
</Button>
</Group>
</Stack>
),
color: 'orange.5',
autoClose: 5000, // Keep visible a bit longer
});
handlePendingSetup(playlist, data);
return;
}
// Check for error status FIRST before doing anything else
if (data.status === 'error') {
// Only show the error notification if we have a complete task (progress=100)
// or if it's explicitly flagged as an error
if (data.progress === 100) {
notifications.show({
title: `M3U Processing: ${playlist.name}`,
message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`,
color: 'red',
autoClose: 5000, // Keep error visible a bit longer
});
}
return; // Exit early for any error status
handleError(playlist, data);
return;
}
// Check if we already have an error stored for this account, and if so, don't show further notifications
// Skip if already errored
const currentStatus = notificationStatus[data.account];
if (currentStatus && currentStatus.status === 'error') {
// Don't show any other notifications once we've hit an error
return;
}
const taskProgress = data.progress;
// Handle normal progress updates (0% start, 100% completion)
if (data.progress === 0 || data.progress === 100) {
handleProgressNotification(playlist, data);
}
};
// Only show start and completion notifications for normal operation
if (data.progress != 0 && data.progress != 100) {
return;
const handlePendingSetup = (playlist, data) => {
fetchChannelGroups();
fetchPlaylists();
showNotification({
title: `M3U Setup: ${playlist.name}`,
message: <M3uSetupSuccess data={data} />,
color: 'orange.5',
autoClose: 5000,
});
};
const handleError = (playlist, data) => {
if (data.progress === 100) {
showNotification({
title: `M3U Processing: ${playlist.name}`,
message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`,
color: 'red',
autoClose: 5000,
});
}
};
const getActionMessage = (action) => {
const messages = {
downloading: 'Downloading',
parsing: 'Stream parsing',
processing_groups: 'Group parsing',
vod_refresh: 'VOD content refresh',
};
return messages[action] || 'Processing';
};
const triggerPostCompletionFetches = (action) => {
if (action == 'parsing') {
fetchStreams();
API.requeryChannels();
fetchChannelIds();
} else if (action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();
fetchEPGData();
fetchPlaylists();
} else if (action == 'vod_refresh') {
fetchPlaylists();
fetchCategories();
}
};
const handleProgressNotification = (playlist, data) => {
const baseMessage = getActionMessage(data.action);
const message =
data.progress == 0
? `${baseMessage} starting...`
: `${baseMessage} complete!`;
if (data.progress == 100) {
triggerPostCompletionFetches(data.action);
}
let message = '';
switch (data.action) {
case 'downloading':
message = 'Downloading';
break;
case 'parsing':
message = 'Stream parsing';
break;
case 'processing_groups':
message = 'Group parsing';
break;
case 'vod_refresh':
message = 'VOD content refresh';
break;
}
if (taskProgress == 0) {
message = `${message} starting...`;
} else if (taskProgress == 100) {
message = `${message} complete!`;
// Only trigger additional fetches on successful completion
if (data.action == 'parsing') {
fetchStreams();
API.requeryChannels();
fetchChannelIds();
} else if (data.action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();
fetchEPGData();
fetchPlaylists();
} else if (data.action == 'vod_refresh') {
// VOD refresh completed, trigger VOD categories refresh
fetchPlaylists(); // Refresh playlist data to show updated VOD info
fetchCategories(); // Refresh VOD categories to make them visible
}
}
notifications.show({
showNotification({
title: `M3U Processing: ${playlist.name}`,
message,
loading: taskProgress == 0,
loading: data.progress == 0,
autoClose: 2000,
icon: taskProgress == 100 ? <CircleCheck /> : null,
icon: data.progress == 100 ? <CircleCheck /> : null,
});
};

View file

@ -10,7 +10,9 @@ import {
Group,
Indicator,
Popover,
ScrollArea,
PopoverDropdown,
PopoverTarget,
ScrollAreaAutosize,
Stack,
Text,
ThemeIcon,
@ -33,7 +35,11 @@ import {
ArrowRight,
} from 'lucide-react';
import useNotificationsStore from '../store/notifications';
import API from '../api';
import {
dismissAllNotifications,
dismissNotification,
getNotifications,
} from '../utils/components/NotificationCenterUtils.js';
// Get icon for notification type
const getNotificationIcon = (type) => {
@ -249,7 +255,7 @@ const NotificationCenter = ({ onSettingAction }) => {
// Fetch notifications on mount and periodically
const fetchNotifications = useCallback(async () => {
try {
await API.getNotifications(showDismissed);
await getNotifications(showDismissed);
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
@ -265,7 +271,7 @@ const NotificationCenter = ({ onSettingAction }) => {
const handleDismiss = async (notificationId, actionTaken = null) => {
try {
await API.dismissNotification(notificationId, actionTaken);
await dismissNotification(notificationId, actionTaken);
} catch (error) {
console.error('Failed to dismiss notification:', error);
}
@ -273,7 +279,7 @@ const NotificationCenter = ({ onSettingAction }) => {
const handleDismissAll = async () => {
try {
await API.dismissAllNotifications();
await dismissAllNotifications();
} catch (error) {
console.error('Failed to dismiss all notifications:', error);
}
@ -302,7 +308,7 @@ const NotificationCenter = ({ onSettingAction }) => {
shadow="lg"
withArrow
>
<Popover.Target>
<PopoverTarget>
<Indicator
color="red"
size={16}
@ -321,9 +327,9 @@ const NotificationCenter = ({ onSettingAction }) => {
<Bell size={20} />
</ActionIcon>
</Indicator>
</Popover.Target>
</PopoverTarget>
<Popover.Dropdown p={0}>
<PopoverDropdown p={0}>
{/* Header */}
<Group justify="space-between" p="sm" pb="xs">
<Group gap="xs">
@ -367,7 +373,7 @@ const NotificationCenter = ({ onSettingAction }) => {
<Divider />
{/* Notification list */}
<ScrollArea.Autosize mah={400} type="auto" offsetScrollbars>
<ScrollAreaAutosize mah={400} type="auto" offsetScrollbars>
{displayedNotifications.length === 0 ? (
<Box p="lg" ta="center">
<ThemeIcon
@ -403,7 +409,7 @@ const NotificationCenter = ({ onSettingAction }) => {
))}
</Stack>
)}
</ScrollArea.Autosize>
</ScrollAreaAutosize>
{/* Footer with info text */}
{!showDismissed &&
@ -421,7 +427,7 @@ const NotificationCenter = ({ onSettingAction }) => {
</Box>
</>
)}
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
);
};

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,6 @@ import {
} from 'lucide-react';
import {
Avatar,
AppShell,
Group,
Stack,
Box,
@ -31,6 +30,7 @@ import {
UnstyledButton,
TextInput,
ActionIcon,
AppShellNavbar,
ScrollArea,
} from '@mantine/core';
import logo from '../images/logo.png';
@ -246,7 +246,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
};
return (
<AppShell.Navbar
<AppShellNavbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
p="xs"
style={{
@ -427,7 +427,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
</AppShell.Navbar>
</AppShellNavbar>
);
};

View file

@ -19,11 +19,9 @@ import {
Download,
Gauge,
HardDriveDownload,
List,
LogIn,
LogOut,
RefreshCw,
Shield,
ShieldAlert,
SquareX,
Timer,
@ -31,9 +29,143 @@ import {
Video,
XCircle,
} from 'lucide-react';
import dayjs from 'dayjs';
import API from '../api';
import useLocalStorage from '../hooks/useLocalStorage';
import { format } from '../utils/dateTimeUtils.js';
const getEventIcon = (eventType) => {
switch (eventType) {
case 'channel_start':
return <CirclePlay size={16} />;
case 'channel_stop':
return <SquareX size={16} />;
case 'channel_reconnect':
return <RefreshCw size={16} />;
case 'channel_buffering':
return <Timer size={16} />;
case 'channel_failover':
return <HardDriveDownload size={16} />;
case 'client_connect':
return <Users size={16} />;
case 'client_disconnect':
return <Users size={16} />;
case 'recording_start':
return <Video size={16} />;
case 'recording_end':
return <Video size={16} />;
case 'stream_switch':
return <HardDriveDownload size={16} />;
case 'm3u_refresh':
return <RefreshCw size={16} />;
case 'm3u_download':
return <Download size={16} />;
case 'epg_refresh':
return <RefreshCw size={16} />;
case 'epg_download':
return <Download size={16} />;
case 'login_success':
return <LogIn size={16} />;
case 'login_failed':
return <ShieldAlert size={16} />;
case 'logout':
return <LogOut size={16} />;
case 'm3u_blocked':
return <XCircle size={16} />;
case 'epg_blocked':
return <XCircle size={16} />;
default:
return <Gauge size={16} />;
}
};
const getEventColor = (eventType) => {
switch (eventType) {
case 'channel_start':
case 'client_connect':
case 'recording_start':
case 'login_success':
return 'green';
case 'channel_reconnect':
return 'yellow';
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
case 'logout':
return 'gray';
case 'channel_buffering':
return 'yellow';
case 'channel_failover':
case 'channel_error':
return 'orange';
case 'stream_switch':
return 'blue';
case 'm3u_refresh':
case 'epg_refresh':
return 'cyan';
case 'm3u_download':
case 'epg_download':
return 'teal';
case 'login_failed':
case 'm3u_blocked':
case 'epg_blocked':
return 'red';
default:
return 'gray';
}
};
const getSystemEvents = (eventsLimit, offset) => {
return API.getSystemEvents(eventsLimit, offset);
}
const Event = ({ event }) => {
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
return (
<Box
p="xs"
bdrs={4}
style={{
backgroundColor: '#1A1B1E',
borderLeft: `3px solid var(--mantine-color-${getEventColor(event.event_type)}-6)`,
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" flex={1} miw={0}>
<Box c={`${getEventColor(event.event_type)}.6`}>
{getEventIcon(event.event_type)}
</Box>
<Stack gap={2} flex={1} miw={0}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{event.event_type_display || event.event_type}
</Text>
{event.channel_name && (
<Text size="sm" c="dimmed" truncate maw={300}>
{event.channel_name}
</Text>
)}
</Group>
{event.details &&
Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(([key]) =>
!['stream_url', 'new_url'].includes(key))
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
</Stack>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{format(event.timestamp, `${dateFormat} HH:mm:ss`)}
</Text>
</Group>
</Box>
);
};
const SystemEvents = () => {
const [events, setEvents] = useState([]);
@ -42,8 +174,7 @@ const SystemEvents = () => {
const { ref: cardRef, width: cardWidth } = useElementSize();
const isNarrow = cardWidth < 650;
const [isLoading, setIsLoading] = useState(false);
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
const [eventsRefreshInterval, setEventsRefreshInterval] = useLocalStorage(
'events-refresh-interval',
0
@ -58,7 +189,7 @@ const SystemEvents = () => {
const fetchEvents = useCallback(async () => {
try {
setIsLoading(true);
const response = await API.getSystemEvents(eventsLimit, offset);
const response = await getSystemEvents(eventsLimit, offset);
if (response && response.events) {
setEvents(response.events);
setTotalEvents(response.total || 0);
@ -86,87 +217,6 @@ const SystemEvents = () => {
setCurrentPage(1);
}, [eventsLimit]);
const getEventIcon = (eventType) => {
switch (eventType) {
case 'channel_start':
return <CirclePlay size={16} />;
case 'channel_stop':
return <SquareX size={16} />;
case 'channel_reconnect':
return <RefreshCw size={16} />;
case 'channel_buffering':
return <Timer size={16} />;
case 'channel_failover':
return <HardDriveDownload size={16} />;
case 'client_connect':
return <Users size={16} />;
case 'client_disconnect':
return <Users size={16} />;
case 'recording_start':
return <Video size={16} />;
case 'recording_end':
return <Video size={16} />;
case 'stream_switch':
return <HardDriveDownload size={16} />;
case 'm3u_refresh':
return <RefreshCw size={16} />;
case 'm3u_download':
return <Download size={16} />;
case 'epg_refresh':
return <RefreshCw size={16} />;
case 'epg_download':
return <Download size={16} />;
case 'login_success':
return <LogIn size={16} />;
case 'login_failed':
return <ShieldAlert size={16} />;
case 'logout':
return <LogOut size={16} />;
case 'm3u_blocked':
return <XCircle size={16} />;
case 'epg_blocked':
return <XCircle size={16} />;
default:
return <Gauge size={16} />;
}
};
const getEventColor = (eventType) => {
switch (eventType) {
case 'channel_start':
case 'client_connect':
case 'recording_start':
case 'login_success':
return 'green';
case 'channel_reconnect':
return 'yellow';
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
case 'logout':
return 'gray';
case 'channel_buffering':
return 'yellow';
case 'channel_failover':
case 'channel_error':
return 'orange';
case 'stream_switch':
return 'blue';
case 'm3u_refresh':
case 'epg_refresh':
return 'cyan';
case 'm3u_download':
case 'epg_download':
return 'teal';
case 'login_failed':
case 'm3u_blocked':
case 'epg_blocked':
return 'red';
default:
return 'gray';
}
};
return (
<Card
ref={cardRef}
@ -272,55 +322,7 @@ const SystemEvents = () => {
</Text>
) : (
events.map((event) => (
<Box
key={event.id}
p="xs"
style={{
backgroundColor: '#1A1B1E',
borderRadius: '4px',
borderLeft: `3px solid var(--mantine-color-${getEventColor(event.event_type)}-6)`,
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Box c={`${getEventColor(event.event_type)}.6`}>
{getEventIcon(event.event_type)}
</Box>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{event.event_type_display || event.event_type}
</Text>
{event.channel_name && (
<Text
size="sm"
c="dimmed"
truncate
style={{ maxWidth: '300px' }}
>
{event.channel_name}
</Text>
)}
</Group>
{event.details &&
Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(
([key]) =>
!['stream_url', 'new_url'].includes(key)
)
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
</Stack>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{dayjs(event.timestamp).format(`${dateFormat} HH:mm:ss`)}
</Text>
</Group>
</Box>
<Event key={event.id} event={event} />
))
)}
</Stack>

View file

@ -14,152 +14,233 @@ import {
Modal,
} from '@mantine/core';
import { Play, Copy } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import { copyToClipboard } from '../utils';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import {
formatDuration,
formatStreamLabel,
getYouTubeEmbedUrl,
imdbUrl,
tmdbUrl
} from '../utils/components/SeriesModalUtils.js';
import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
import {
formatAudioDetails,
formatVideoDetails,
getMovieStreamUrl,
getTechnicalDetails,
} from '../utils/components/VODModalUtils.js';
const imdbUrl = (imdb_id) =>
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
const tmdbUrl = (tmdb_id, type = 'movie') =>
tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
const formatDuration = (seconds) => {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
};
const Movie = ({
onClickYouTubeTrailer,
hasMultipleProviders,
selectedProvider,
detailedVOD,
vod
}) => {
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const formatStreamLabel = (relation) => {
// Create a label for the stream that includes provider name and stream-specific info
const provider = relation.m3u_account.name;
const streamId = relation.stream_id;
const displayVOD = detailedVOD || vod;
// Try to extract quality info - prioritizing the new quality_info field from backend
let qualityInfo = '';
const getStreamUrl = () => {
if (!displayVOD) return null;
// 1. Check the new quality_info field from backend (PRIMARY)
if (relation.quality_info) {
if (relation.quality_info.quality) {
qualityInfo = ` - ${relation.quality_info.quality}`;
} else if (relation.quality_info.resolution) {
qualityInfo = ` - ${relation.quality_info.resolution}`;
} else if (relation.quality_info.bitrate) {
qualityInfo = ` - ${relation.quality_info.bitrate}`;
}
}
// 2. Fallback: Check custom_properties detailed info structure
if (qualityInfo === '' && relation.custom_properties) {
const props = relation.custom_properties;
// Check detailed_info structure (where the real data is!)
if (qualityInfo === '' && props.detailed_info) {
const detailedInfo = props.detailed_info;
// Extract from video resolution
if (
detailedInfo.video &&
detailedInfo.video.width &&
detailedInfo.video.height
) {
const width = detailedInfo.video.width;
const height = detailedInfo.video.height;
// Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
if (width >= 3840) {
qualityInfo = ' - 4K';
} else if (width >= 1920) {
qualityInfo = ' - 1080p';
} else if (width >= 1280) {
qualityInfo = ' - 720p';
} else if (width >= 854) {
qualityInfo = ' - 480p';
} else {
qualityInfo = ` - ${width}x${height}`;
}
}
// Extract from movie name in detailed_info
if (qualityInfo === '' && detailedInfo.name) {
const name = detailedInfo.name;
if (name.includes('4K') || name.includes('2160p')) {
qualityInfo = ' - 4K';
} else if (name.includes('1080p') || name.includes('FHD')) {
qualityInfo = ' - 1080p';
} else if (name.includes('720p') || name.includes('HD')) {
qualityInfo = ' - 720p';
} else if (name.includes('480p')) {
qualityInfo = ' - 480p';
}
}
}
}
// 3. Final fallback: Check stream name for quality markers
if (qualityInfo === '' && relation.stream_name) {
const streamName = relation.stream_name;
if (streamName.includes('4K') || streamName.includes('2160p')) {
qualityInfo = ' - 4K';
} else if (streamName.includes('1080p') || streamName.includes('FHD')) {
qualityInfo = ' - 1080p';
} else if (streamName.includes('720p') || streamName.includes('HD')) {
qualityInfo = ' - 720p';
} else if (streamName.includes('480p')) {
qualityInfo = ' - 480p';
}
}
return `${provider}${qualityInfo}${streamId ? ` (Stream ${streamId})` : ''}`;
};
const getTechnicalDetails = (selectedProvider, defaultVOD) => {
let source = defaultVOD; // Default fallback
// If a provider is selected, try to get technical details from various locations
if (selectedProvider) {
// 1. First try the movie/episode relation content
const content = selectedProvider.movie || selectedProvider.episode;
if (content && (content.bitrate || content.video || content.audio)) {
source = content;
}
// 2. Try technical details directly on the relation object
else if (
selectedProvider.bitrate ||
selectedProvider.video ||
selectedProvider.audio
) {
source = selectedProvider;
}
// 3. Try to extract from custom_properties detailed_info (where quality data is stored)
else if (selectedProvider.custom_properties?.detailed_info) {
const detailedInfo = selectedProvider.custom_properties.detailed_info;
// Create a synthetic source from detailed_info
const syntheticSource = {
bitrate: detailedInfo.bitrate || null,
video: detailedInfo.video || null,
audio: detailedInfo.audio || null,
};
if (
syntheticSource.bitrate ||
syntheticSource.video ||
syntheticSource.audio
) {
source = syntheticSource;
}
}
}
return {
bitrate: source?.bitrate,
video: source?.video,
audio: source?.audio,
return getMovieStreamUrl(vod, selectedProvider, env_mode);
};
const handlePlayVOD = () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
showVideo(streamUrl, 'vod', displayVOD);
};
const handleCopyLink = async () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Stream link copied to clipboard',
});
};
return (
<Stack spacing="md" flex={1}>
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name &&
displayVOD.o_name !== displayVOD.name && (
<Text size="sm" c="dimmed" fs="italic">
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && (
<Badge color="blue">{displayVOD.year}</Badge>
)}
{displayVOD.duration_secs && (
<Badge color="gray">
{formatDuration(displayVOD.duration_secs)}
</Badge>
)}
{displayVOD.rating && (
<Badge color="yellow">{displayVOD.rating}</Badge>
)}
{displayVOD.age && (
<Badge color="orange">{displayVOD.age}</Badge>
)}
<Badge color="green">Movie</Badge>
{/* imdb_id and tmdb_id badges */}
{displayVOD.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(displayVOD.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displayVOD.tmdb_id && (
<Badge
color="cyan"
component="a"
href={tmdbUrl(displayVOD.tmdb_id, 'movie')}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Group>
{/* Release date */}
{displayVOD.release_date && (
<Text size="sm" c="dimmed">
<strong>Release Date:</strong> {displayVOD.release_date}
</Text>
)}
{displayVOD.genre && (
<Text size="sm" c="dimmed">
<strong>Genre:</strong> {displayVOD.genre}
</Text>
)}
{displayVOD.director && (
<Text size="sm" c="dimmed">
<strong>Director:</strong> {displayVOD.director}
</Text>
)}
{displayVOD.actors && (
<Text size="sm" c="dimmed">
<strong>Cast:</strong> {displayVOD.actors}
</Text>
)}
{displayVOD.country && (
<Text size="sm" c="dimmed">
<strong>Country:</strong> {displayVOD.country}
</Text>
)}
{/* Description */}
{displayVOD.description && (
<Box>
<Text size="sm" weight={500} mb={8}>
Description
</Text>
<Text size="sm">{displayVOD.description}</Text>
</Box>
)}
{/* Play and Watch Trailer buttons */}
<Group spacing="xs" mt="sm">
<Button
leftSection={<Play size={16} />}
variant="filled"
color="blue"
size="sm"
onClick={handlePlayVOD}
disabled={hasMultipleProviders && !selectedProvider}
style={{ alignSelf: 'flex-start' }}
>
Play Movie
</Button>
{displayVOD.youtube_trailer && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onClickYouTubeTrailer}
style={{ alignSelf: 'flex-start' }}
>
Watch Trailer
</Button>
)}
<Button
leftSection={<Copy size={16} />}
variant="outline"
color="gray"
size="sm"
onClick={handleCopyLink}
style={{ alignSelf: 'flex-start' }}
>
Copy Link
</Button>
</Group>
</Stack>
);
};
const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
if (!hasDetails) return null;
const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0;
const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0;
return (
<Stack spacing={4} mt="xs">
<Text size="sm" weight={500}>
Technical Details:
{selectedProvider && (
<Text size="xs" c="dimmed" weight="normal" span ml={8}>
(from {selectedProvider.m3u_account.name}
{selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
</Text>
)}
</Text>
{techDetails.bitrate && techDetails.bitrate > 0 && (
<Text size="xs" c="dimmed">
<strong>Bitrate:</strong> {techDetails.bitrate} kbps
</Text>
)}
{hasVideo && (
<Text size="xs" c="dimmed">
<strong>Video:</strong> {formatVideoDetails(techDetails.video)}
</Text>
)}
{hasAudio && (
<Text size="xs" c="dimmed">
<strong>Audio:</strong> {formatAudioDetails(techDetails.audio)}
</Text>
)}
</Stack>
);
};
const VODModal = ({ vod, opened, onClose }) => {
@ -170,9 +251,8 @@ const VODModal = ({ vod, opened, onClose }) => {
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState(null);
const [loadingProviders, setLoadingProviders] = useState(false);
const { fetchMovieDetailsFromProvider, fetchMovieProviders } = useVODStore();
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
useEffect(() => {
if (opened && vod) {
@ -234,54 +314,17 @@ const VODModal = ({ vod, opened, onClose }) => {
}
}, [opened]);
const getStreamUrl = () => {
const vodToPlay = detailedVOD || vod;
if (!vodToPlay) return null;
const onClickYouTubeTrailer = () => {
setTrailerUrl(
getYouTubeEmbedUrl(displayVOD.youtube_trailer)
);
setTrailerModalOpened(true);
}
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
// Add selected provider as query parameter if available
if (selectedProvider) {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
if (env_mode === 'dev') {
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
} else {
streamUrl = `${window.location.origin}${streamUrl}`;
}
return streamUrl;
};
const handlePlayVOD = () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
const vodToPlay = detailedVOD || vod;
showVideo(streamUrl, 'vod', vodToPlay);
};
const handleCopyLink = async () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Stream link copied to clipboard',
});
};
// Helper to get embeddable YouTube URL
const getEmbedUrl = (url) => {
if (!url) return '';
// Accepts full YouTube URLs or just IDs
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
const videoId = match ? match[1] : url;
return `https://www.youtube.com/embed/${videoId}`;
};
const onChangeSelectedProvider = (value) => {
const provider = providers.find((p) => p.id.toString() === value);
setSelectedProvider(provider);
}
if (!vod) return null;
@ -376,146 +419,13 @@ const VODModal = ({ vod, opened, onClose }) => {
</Box>
)}
<Stack spacing="md" style={{ flex: 1 }}>
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name &&
displayVOD.o_name !== displayVOD.name && (
<Text
size="sm"
color="dimmed"
style={{ fontStyle: 'italic' }}
>
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && (
<Badge color="blue">{displayVOD.year}</Badge>
)}
{displayVOD.duration_secs && (
<Badge color="gray">
{formatDuration(displayVOD.duration_secs)}
</Badge>
)}
{displayVOD.rating && (
<Badge color="yellow">{displayVOD.rating}</Badge>
)}
{displayVOD.age && (
<Badge color="orange">{displayVOD.age}</Badge>
)}
<Badge color="green">Movie</Badge>
{/* imdb_id and tmdb_id badges */}
{displayVOD.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(displayVOD.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displayVOD.tmdb_id && (
<Badge
color="cyan"
component="a"
href={tmdbUrl(displayVOD.tmdb_id, 'movie')}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Group>
{/* Release date */}
{displayVOD.release_date && (
<Text size="sm" color="dimmed">
<strong>Release Date:</strong> {displayVOD.release_date}
</Text>
)}
{displayVOD.genre && (
<Text size="sm" color="dimmed">
<strong>Genre:</strong> {displayVOD.genre}
</Text>
)}
{displayVOD.director && (
<Text size="sm" color="dimmed">
<strong>Director:</strong> {displayVOD.director}
</Text>
)}
{displayVOD.actors && (
<Text size="sm" color="dimmed">
<strong>Cast:</strong> {displayVOD.actors}
</Text>
)}
{displayVOD.country && (
<Text size="sm" color="dimmed">
<strong>Country:</strong> {displayVOD.country}
</Text>
)}
{/* Description */}
{displayVOD.description && (
<Box>
<Text size="sm" weight={500} mb={8}>
Description
</Text>
<Text size="sm">{displayVOD.description}</Text>
</Box>
)}
{/* Play and Watch Trailer buttons */}
<Group spacing="xs" mt="sm">
<Button
leftSection={<Play size={16} />}
variant="filled"
color="blue"
size="sm"
onClick={handlePlayVOD}
disabled={providers.length > 0 && !selectedProvider}
style={{ alignSelf: 'flex-start' }}
>
Play Movie
</Button>
{displayVOD.youtube_trailer && (
<Button
variant="outline"
color="red"
size="sm"
onClick={() => {
setTrailerUrl(
getEmbedUrl(displayVOD.youtube_trailer)
);
setTrailerModalOpened(true);
}}
style={{ alignSelf: 'flex-start' }}
>
Watch Trailer
</Button>
)}
<Button
leftSection={<Copy size={16} />}
variant="outline"
color="gray"
size="sm"
onClick={handleCopyLink}
style={{ alignSelf: 'flex-start' }}
>
Copy Link
</Button>
</Group>
</Stack>
<Movie
detailedVOD={detailedVOD}
vod={vod}
hasMultipleProviders={providers.length > 0}
selectedProvider={selectedProvider}
onClickYouTubeTrailer={onClickYouTubeTrailer}
/>
</Flex>
{/* Provider Information & Play Button Row */}
@ -542,12 +452,7 @@ const VODModal = ({ vod, opened, onClose }) => {
label: formatStreamLabel(provider),
}))}
value={selectedProvider?.id?.toString() || ''}
onChange={(value) => {
const provider = providers.find(
(p) => p.id.toString() === value
);
setSelectedProvider(provider);
}}
onChange={(value) => onChangeSelectedProvider(value)}
placeholder="Select stream..."
style={{ minWidth: 250 }}
disabled={loadingProviders}
@ -576,135 +481,21 @@ const VODModal = ({ vod, opened, onClose }) => {
</Group>
{/* Technical Details */}
{(() => {
const techDetails = getTechnicalDetails(
selectedProvider,
displayVOD
);
const hasDetails =
techDetails.bitrate || techDetails.video || techDetails.audio;
return (
hasDetails && (
<Stack spacing={4} mt="xs">
<Text size="sm" weight={500}>
Technical Details:
{selectedProvider && (
<Text
size="xs"
color="dimmed"
weight="normal"
span
style={{ marginLeft: 8 }}
>
(from {selectedProvider.m3u_account.name}
{selectedProvider.stream_id &&
` - Stream ${selectedProvider.stream_id}`}
)
</Text>
)}
</Text>
{techDetails.bitrate && techDetails.bitrate > 0 && (
<Text size="xs" color="dimmed">
<strong>Bitrate:</strong> {techDetails.bitrate} kbps
</Text>
)}
{techDetails.video &&
Object.keys(techDetails.video).length > 0 && (
<Text size="xs" color="dimmed">
<strong>Video:</strong>{' '}
{techDetails.video.codec_long_name &&
techDetails.video.codec_long_name !== 'unknown'
? techDetails.video.codec_long_name
: techDetails.video.codec_name}
{techDetails.video.profile
? ` (${techDetails.video.profile})`
: ''}
{techDetails.video.width && techDetails.video.height
? `, ${techDetails.video.width}x${techDetails.video.height}`
: ''}
{techDetails.video.display_aspect_ratio
? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}`
: ''}
{techDetails.video.bit_rate
? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps`
: ''}
{techDetails.video.r_frame_rate
? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps`
: ''}
{techDetails.video.tags?.encoder
? `, Encoder: ${techDetails.video.tags.encoder}`
: ''}
</Text>
)}
{techDetails.audio &&
Object.keys(techDetails.audio).length > 0 && (
<Text size="xs" color="dimmed">
<strong>Audio:</strong>{' '}
{techDetails.audio.codec_long_name &&
techDetails.audio.codec_long_name !== 'unknown'
? techDetails.audio.codec_long_name
: techDetails.audio.codec_name}
{techDetails.audio.profile
? ` (${techDetails.audio.profile})`
: ''}
{techDetails.audio.channel_layout
? `, Channels: ${techDetails.audio.channel_layout}`
: techDetails.audio.channels
? `, Channels: ${techDetails.audio.channels}`
: ''}
{techDetails.audio.sample_rate
? `, Sample Rate: ${techDetails.audio.sample_rate} Hz`
: ''}
{techDetails.audio.bit_rate
? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps`
: ''}
{techDetails.audio.tags?.handler_name
? `, Handler: ${techDetails.audio.tags.handler_name}`
: ''}
</Text>
)}
</Stack>
)
);
})()}
{/* YouTube trailer if available */}
<MovieTechnicalDetails
selectedProvider={selectedProvider}
displayVOD={displayVOD}
/>
</Stack>
</Box>
</Box>
</Modal>
{/* YouTube Trailer Modal */}
<Modal
<YouTubeTrailerModal
opened={trailerModalOpened}
onClose={() => setTrailerModalOpened(false)}
title="Trailer"
size="xl"
centered
withCloseButton
>
<Box
style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}
>
{trailerUrl && (
<iframe
src={trailerUrl}
title="YouTube Trailer"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
}}
/>
)}
</Box>
</Modal>
trailerUrl={trailerUrl}
/>
</>
);
};

View file

@ -0,0 +1,275 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
// Mock the warnings store
vi.mock('../../store/warnings');
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
</div>
) : null,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled }) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={onChange}
/>
{label}
</label>
),
Box: ({ children }) => <div>{children}</div>,
};
});
describe('ConfirmationDialog', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
const mockOnSuppressChange = vi.fn();
const mockSuppressWarning = vi.fn();
const mockIsWarningSuppressed = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
useWarningsStore.mockImplementation((selector) => {
const state = {
suppressWarning: mockSuppressWarning,
isWarningSuppressed: mockIsWarningSuppressed,
};
return selector ? selector(state) : state;
});
});
it('should render when opened', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
});
it('should not render when closed', () => {
render(
<ConfirmationDialog
opened={false}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should display custom title and message', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="Delete Item"
message="This action cannot be undone"
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Item');
expect(screen.getByText('This action cannot be undone')).toBeInTheDocument();
});
it('should call onConfirm when confirm button is clicked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
confirmLabel="Delete"
/>
);
fireEvent.click(screen.getByText('Delete'));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('should call onClose when cancel button is clicked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
cancelLabel="Cancel"
/>
);
fireEvent.click(screen.getByText('Cancel'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should show suppress checkbox when actionKey is provided', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
expect(screen.getByLabelText("Don't ask me again")).toBeInTheDocument();
});
it('should not show suppress checkbox when actionKey is not provided', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument();
});
it('should call suppressWarning when suppress is checked and confirmed', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
fireEvent.click(screen.getByLabelText("Don't ask me again"));
fireEvent.click(screen.getByText('Confirm'));
expect(mockSuppressWarning).toHaveBeenCalledWith('delete-action');
});
it('should call onSuppressChange when suppress checkbox is toggled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
onSuppressChange={mockOnSuppressChange}
/>
);
fireEvent.click(screen.getByLabelText("Don't ask me again"));
expect(mockOnSuppressChange).toHaveBeenCalledWith(true);
});
it('should show delete file option when enabled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument();
});
it('should pass deleteFiles state to onConfirm when delete option is checked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
fireEvent.click(screen.getByLabelText('Also delete files from disk'));
fireEvent.click(screen.getByText('Confirm'));
expect(mockOnConfirm).toHaveBeenCalledWith(true);
});
it('should reset deleteFiles state after confirmation', () => {
const { rerender } = render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
fireEvent.click(screen.getByLabelText('Also delete files from disk'));
fireEvent.click(screen.getByText('Confirm'));
rerender(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked();
});
it('should show loading state on confirm button', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
loading={true}
/>
);
expect(screen.getByText('Confirm')).toBeDisabled();
});
it('should disable cancel button when loading', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
loading={true}
/>
);
expect(screen.getByText('Cancel')).toBeDisabled();
});
it('should initialize suppress checkbox based on store state', () => {
mockIsWarningSuppressed.mockReturnValue(true);
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
expect(screen.getByLabelText("Don't ask me again")).toBeChecked();
});
});

View file

@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ErrorBoundary from '../ErrorBoundary';
// Component that throws an error for testing
const ThrowError = ({ shouldThrow }) => {
if (shouldThrow) {
throw new Error('Test error');
}
return <div>Child component</div>;
};
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
// Suppress console.error for cleaner test output
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('should render children when there is no error', () => {
render(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('should render error message when child component throws error', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
expect(screen.queryByText('Child component')).not.toBeInTheDocument();
});
it('should not render error message when child component does not throw', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText('Child component')).toBeInTheDocument();
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
});
it('should handle multiple children', () => {
render(
<ErrorBoundary>
<div>First child</div>
<div>Second child</div>
</ErrorBoundary>
);
expect(screen.getByText('First child')).toBeInTheDocument();
expect(screen.getByText('Second child')).toBeInTheDocument();
});
it('should catch errors from nested children', () => {
render(
<ErrorBoundary>
<div>
<div>
<ThrowError shouldThrow={true} />
</div>
</div>
</ErrorBoundary>
);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
it('should have hasError state set to true after catching error', () => {
const { container } = render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
// Verify error boundary rendered fallback UI
expect(container.querySelector('div')).toHaveTextContent(
'Something went wrong'
);
});
it('should have hasError state set to false initially', () => {
const { container } = render(
<ErrorBoundary>
<div data-testid="child">Normal content</div>
</ErrorBoundary>
);
// Verify children are rendered (not error state)
expect(screen.getByTestId('child')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,802 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Field } from '../Field';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
TextInput: ({ label, description, value, onChange, type, placeholder }) => (
<div>
<label htmlFor="text-input">{label}</label>
<input
id="text-input"
type={type || 'text'}
value={value}
onChange={onChange}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
NumberInput: ({ label, description, value, onChange, placeholder }) => (
<div>
<label htmlFor="number-input">{label}</label>
<input
id="number-input"
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Textarea: ({ label, description, value, onChange, placeholder }) => (
<div>
<label htmlFor="textarea-input">{label}</label>
<textarea
id="textarea-input"
value={value}
onChange={onChange}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Switch: ({ label, description, checked, onChange }) => (
<div>
<label htmlFor="switch-input">{label}</label>
<input
id="switch-input"
type="checkbox"
checked={checked}
onChange={onChange}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Select: ({ label, description, value, data, onChange, placeholder }) => (
<div>
<label htmlFor="select-input">{label}</label>
<select
id="select-input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-describedby={description}
>
{data.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{description && <div>{description}</div>}
</div>
),
Text: ({ children, fw, size, c }) => (
<div data-fw={fw} data-size={size} data-color={c}>
{children}
</div>
),
};
});
describe('Field', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('TextInput (string type)', () => {
it('should render TextInput for string type', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
help_text: 'Enter your name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.getByText('Enter your name')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: '',
};
render(<Field field={field} value="John" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveValue('John');
});
it('should use default value when value is null', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: 'Default Name',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveValue('Default Name');
});
it('should call onChange with field id and value', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'New Value' },
});
expect(mockOnChange).toHaveBeenCalledWith('name', 'New Value');
});
});
describe('NumberInput (number type)', () => {
it('should render NumberInput for number type', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
help_text: 'Enter your age',
default: 0,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toBeInTheDocument();
expect(screen.getByText('Enter your age')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: 0,
};
render(<Field field={field} value={25} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveValue(25);
});
it('should default to 0 when value and default are null', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: null,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveValue(0);
});
it('should call onChange with field id and numeric value', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: 0,
};
render(<Field field={field} value={0} onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Age'), {
target: { value: '30' },
});
expect(mockOnChange).toHaveBeenCalledWith('age', 30);
});
});
describe('Switch (boolean type)', () => {
it('should render Switch for boolean type', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
help_text: 'Toggle active state',
default: false,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeInTheDocument();
expect(screen.getByText('Toggle active state')).toBeInTheDocument();
});
it('should be checked when value is true', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={true} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeChecked();
});
it('should be unchecked when value is false', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).not.toBeChecked();
});
it('should use default value when value is null', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: true,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeChecked();
});
it('should call onChange with field id and checked state', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
fireEvent.click(screen.getByLabelText('Active'));
expect(mockOnChange).toHaveBeenCalledWith('active', true);
});
});
describe('Select (select type)', () => {
it('should render Select for select type', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
help_text: 'Select your country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toBeInTheDocument();
expect(screen.getByText('Select your country')).toBeInTheDocument();
});
it('should render options correctly', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('United States')).toBeInTheDocument();
expect(screen.getByText('Canada')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="ca" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toHaveValue('ca');
});
it('should convert value to string', () => {
const field = {
id: 'status',
type: 'select',
label: 'Status',
default: 1,
options: [
{ value: 1, label: 'Active' },
{ value: 2, label: 'Inactive' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Status')).toHaveValue('1');
});
it('should handle empty options array', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: null,
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toBeInTheDocument();
});
it('should call onChange with field id and selected value', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="us" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Country'), {
target: { value: 'ca' },
});
expect(mockOnChange).toHaveBeenCalledWith('country', 'ca');
});
});
describe('Textarea (text type)', () => {
it('should render Textarea for text type', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
help_text: 'Enter your biography',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toBeInTheDocument();
expect(screen.getByText('Enter your biography')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: '',
};
render(<Field field={field} value="My bio" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveValue('My bio');
});
it('should use default value when value is null', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: 'Default bio',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveValue('Default bio');
});
it('should call onChange with field id and value', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Biography'), {
target: { value: 'New bio text' },
});
expect(mockOnChange).toHaveBeenCalledWith('bio', 'New bio text');
});
it('should render with placeholder', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
placeholder: 'Enter your bio here...',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveAttribute(
'placeholder',
'Enter your bio here...'
);
});
});
describe('Info type', () => {
it('should render info with label and description', () => {
const field = {
id: 'info1',
type: 'info',
label: 'Important Information',
description: 'This is important info',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Important Information')).toBeInTheDocument();
expect(screen.getByText('This is important info')).toBeInTheDocument();
});
it('should render info with only description', () => {
const field = {
id: 'info2',
type: 'info',
description: 'Just a description',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Just a description')).toBeInTheDocument();
});
it('should render info with only label', () => {
const field = {
id: 'info3',
type: 'info',
label: 'Just a label',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Just a label')).toBeInTheDocument();
});
it('should prioritize help_text over description', () => {
const field = {
id: 'info4',
type: 'info',
label: 'Title',
help_text: 'Help text takes priority',
description: 'This should not appear',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Help text takes priority')).toBeInTheDocument();
expect(screen.queryByText('This should not appear')).not.toBeInTheDocument();
});
it('should use field.value if no help_text or description', () => {
const field = {
id: 'info5',
type: 'info',
label: 'Title',
value: 'Value text',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Value text')).toBeInTheDocument();
});
it('should not call onChange for info type', () => {
const field = {
id: 'info6',
type: 'info',
label: 'Read-only Info',
description: 'Cannot be changed',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(mockOnChange).not.toHaveBeenCalled();
});
});
describe('Password input type', () => {
it('should render password input when input_type is password', () => {
const field = {
id: 'password',
type: 'string',
label: 'Password',
input_type: 'password',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
});
it('should render text input when input_type is not password', () => {
const field = {
id: 'username',
type: 'string',
label: 'Username',
input_type: 'text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Username')).toHaveAttribute('type', 'text');
});
it('should default to text input when input_type is undefined', () => {
const field = {
id: 'email',
type: 'string',
label: 'Email',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'text');
});
});
describe('Description priority', () => {
it('should prioritize help_text over description', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
help_text: 'Help text',
description: 'Description text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Help text')).toBeInTheDocument();
expect(screen.queryByText('Description text')).not.toBeInTheDocument();
});
it('should use description when help_text is not provided', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
description: 'Description text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Description text')).toBeInTheDocument();
});
it('should use field.value when neither help_text nor description provided', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
value: 'Value text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Value text')).toBeInTheDocument();
});
it('should not show description when all are undefined', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Test')).toBeInTheDocument();
// No description should be present
});
});
describe('Placeholder handling', () => {
it('should render placeholder for TextInput', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
placeholder: 'Enter your name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveAttribute(
'placeholder',
'Enter your name'
);
});
it('should render placeholder for NumberInput', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
placeholder: 'Enter your age',
default: 0,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveAttribute(
'placeholder',
'Enter your age'
);
});
it('should render placeholder for Select', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
placeholder: 'Select a country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toHaveAttribute(
'placeholder',
'Select a country'
);
});
it('should render placeholder for Textarea', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
placeholder: 'Tell us about yourself',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveAttribute(
'placeholder',
'Tell us about yourself'
);
});
});
describe('Edge cases', () => {
it('should handle empty string default value', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
default: '',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Test')).toHaveValue('');
});
it('should handle 0 as valid number value', () => {
const field = {
id: 'count',
type: 'number',
label: 'Count',
default: 10,
};
render(<Field field={field} value={0} onChange={mockOnChange} />);
expect(screen.getByLabelText('Count')).toHaveValue(0);
});
it('should handle false as valid boolean value', () => {
const field = {
id: 'enabled',
type: 'boolean',
label: 'Enabled',
default: true,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
expect(screen.getByLabelText('Enabled')).not.toBeChecked();
});
it('should handle empty string as valid select value', () => {
const field = {
id: 'status',
type: 'select',
label: 'Status',
default: 'active',
options: [
{ value: '', label: 'None' },
{ value: 'active', label: 'Active' },
],
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Status')).toHaveValue('');
});
});
describe('Default fallback', () => {
it('should render TextInput for unknown type', () => {
const field = {
id: 'custom',
type: 'unknown',
label: 'Custom Field',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Custom Field')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,477 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import FloatingVideo from '../FloatingVideo';
import useVideoStore from '../../store/useVideoStore';
// Mock the video store
vi.mock('../../store/useVideoStore');
// Mock mpegts.js
vi.mock('mpegts.js', () => ({
default: {
createPlayer: vi.fn(),
getFeatureList: vi.fn(),
Events: {
LOADING_COMPLETE: 'loading_complete',
METADATA_ARRIVED: 'metadata_arrived',
ERROR: 'error',
MEDIA_INFO: 'media_info',
},
},
}));
// Import the mocked module after mocking
const mpegts = (await import('mpegts.js')).default;
// Mock react-draggable
vi.mock('react-draggable', () => ({
default: ({ children, nodeRef }) => <div ref={nodeRef}>{children}</div>,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
CloseButton: ({ onClick, onTouchEnd }) => (
<button
data-testid="close-button"
onClick={onClick}
onTouchEnd={onTouchEnd}
>
Close
</button>
),
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children, ...props }) => <div {...props}>{children}</div>,
};
});
describe('FloatingVideo', () => {
const mockHideVideo = vi.fn();
let mockPlayer;
beforeEach(async () => {
vi.clearAllMocks();
// Mock HTMLVideoElement methods
HTMLVideoElement.prototype.load = vi.fn();
HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
HTMLVideoElement.prototype.pause = vi.fn();
mockPlayer = {
attachMediaElement: vi.fn(),
load: vi.fn(),
play: vi.fn(() => Promise.resolve()),
pause: vi.fn(),
destroy: vi.fn(),
on: vi.fn(),
};
mpegts.createPlayer.mockReturnValue(mockPlayer);
mpegts.getFeatureList.mockReturnValue({ mseLivePlayback: true });
useVideoStore.mockImplementation((selector) => {
const state = {
isVisible: false,
streamUrl: null,
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
});
});
describe('Visibility', () => {
it('should not render when isVisible is false', () => {
const { container } = render(<FloatingVideo />);
expect(container.firstChild).toBeNull();
});
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;
}
});
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;
}
});
render(<FloatingVideo />);
expect(screen.getByTestId('close-button')).toBeInTheDocument();
});
});
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;
}
});
});
it('should initialize mpegts player for live streams', () => {
render(<FloatingVideo />);
expect(mpegts.createPlayer).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mpegts',
url: 'http://example.com/stream.ts',
isLive: true,
})
);
});
it('should show loading state initially', () => {
render(<FloatingVideo />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText('Loading stream...')).toBeInTheDocument();
});
it('should attach player to video element', () => {
render(<FloatingVideo />);
expect(mockPlayer.attachMediaElement).toHaveBeenCalled();
});
it('should handle player errors', async () => {
render(<FloatingVideo />);
const errorCallback = mockPlayer.on.mock.calls.find(
(call) => call[0] === mpegts.Events.ERROR
)?.[1];
errorCallback('MediaError', 'AC3 codec not supported');
await screen.findByText(/Audio codec not supported/i);
});
it('should handle unsupported browser', () => {
mpegts.getFeatureList.mockReturnValue({
mseLivePlayback: false,
});
render(<FloatingVideo />);
expect(
screen.getByText(/browser doesn't support live video streaming/i)
).toBeInTheDocument();
});
it('should play video on MEDIA_INFO event', async () => {
render(<FloatingVideo />);
const mediaInfoCallback = mockPlayer.on.mock.calls.find(
(call) => call[0] === mpegts.Events.MEDIA_INFO
)?.[1];
await mediaInfoCallback();
expect(mockPlayer.play).toHaveBeenCalled();
});
});
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;
}
});
});
it('should use native video player for VOD', () => {
render(<FloatingVideo />);
expect(mpegts.createPlayer).not.toHaveBeenCalled();
});
it('should set video source for VOD', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
expect(video).toBeInTheDocument();
expect(video.src).toBe('http://example.com/video.mp4');
expect(video.poster).toBe('http://example.com/poster.jpg');
});
it('should show metadata overlay', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
// Simulate video loaded event to clear loading state
fireEvent.loadedData(video);
expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
1
);
expect(screen.getByText('2024')).toBeInTheDocument();
});
it('should hide overlay after 4 seconds', () => {
vi.useFakeTimers();
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
1
);
vi.advanceTimersByTime(4000);
waitFor(() => {
// After overlay hides, only the header title remains
expect(screen.getAllByText('Test Movie').length).toBe(1);
});
vi.useRealTimers();
});
it('should show overlay on mouse enter', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
const videoContainer = video.parentElement;
fireEvent.mouseEnter(videoContainer);
expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
1
);
});
it('should hide overlay on mouse leave', () => {
vi.useFakeTimers();
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
const videoContainer = video.parentElement;
fireEvent.mouseEnter(videoContainer);
fireEvent.mouseLeave(videoContainer);
vi.advanceTimersByTime(4000);
waitFor(() => {
// After overlay hides, only the header title remains
expect(screen.getAllByText('Test Movie').length).toBe(1);
});
vi.useRealTimers();
});
});
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;
}
});
});
it('should call hideVideo when close button is clicked', () => {
vi.useFakeTimers();
render(<FloatingVideo />);
fireEvent.click(screen.getByTestId('close-button'));
vi.advanceTimersByTime(50);
waitFor(() => {
expect(mockHideVideo).toHaveBeenCalled();
expect(mockPlayer.destroy).toHaveBeenCalled();
});
vi.useRealTimers();
});
});
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;
}
});
});
it('should display video error messages', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
Object.defineProperty(video, 'error', {
value: { code: 3, message: 'MEDIA_ERR_DECODE' },
writable: true,
});
fireEvent.error(video);
expect(screen.getByText(/MEDIA_ERR_DECODE/i)).toBeInTheDocument();
});
it('should handle network errors', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
Object.defineProperty(video, 'error', {
value: { code: 2, message: 'MEDIA_ERR_NETWORK' },
writable: true,
});
fireEvent.error(video);
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;
}
});
const { unmount } = render(<FloatingVideo />);
unmount();
expect(mockPlayer.destroy).toHaveBeenCalled();
});
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;
}
});
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;
}
});
rerender(<FloatingVideo />);
expect(mockPlayer.destroy).toHaveBeenCalled();
});
});
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;
}
});
});
it('should render resize handles', () => {
const { container } = render(<FloatingVideo />);
const handles = container.querySelectorAll(
'[class*="floating-video-no-drag"]'
);
// Should have 4 resize handles plus video element
expect(handles.length).toBeGreaterThanOrEqual(4);
});
});
});

View file

@ -0,0 +1,715 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import GuideRow from '../GuideRow';
import {
CHANNEL_WIDTH,
EXPANDED_PROGRAM_HEIGHT,
HOUR_WIDTH,
PROGRAM_HEIGHT,
} from '../../pages/guideUtils';
// Mock logo import
vi.mock('../../images/logo.png', () => ({
default: 'mocked-logo.png',
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: (props) => <div data-testid="play-icon" {...props} />,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Text: ({ children, ...props }) => <div {...props}>{children}</div>,
};
});
// Helper function to create programs at specific times
const createProgramAtTime = (id, startHour, durationMinutes) => {
const timelineStart = new Date('2024-01-01T00:00:00Z');
const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
return {
id,
title: `Program ${id}`,
startMs,
endMs: startMs + durationMinutes * 60 * 1000,
};
};
describe('GuideRow', () => {
const mockChannel = {
id: 'channel-1',
name: 'Test Channel',
channel_number: '101',
logo_id: 'logo-1',
};
const mockProgram = {
id: 'program-1',
title: 'Test Program',
start_time: '2024-01-01T10:00:00Z',
end_time: '2024-01-01T11:00:00Z',
};
const mockLogos = {
'logo-1': {
cache_url: 'https://example.com/logo.png',
},
};
const mockData = {
filteredChannels: [mockChannel],
programsByChannelId: new Map([[mockChannel.id, [mockProgram]]]),
expandedProgramId: null,
rowHeights: {},
logos: mockLogos,
hoveredChannelId: null,
setHoveredChannelId: vi.fn(),
renderProgram: vi.fn((program) => (
<div key={program.id} data-testid={`program-${program.id}`}>
{program.title}
</div>
)),
handleLogoClick: vi.fn(),
contentWidth: 1920,
guideScrollLeftRef: { current: 0 },
viewportWidth: 1920,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const mockStyle = {
position: 'absolute',
left: 0,
top: 0,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render channel row with channel information', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
expect(screen.getByAltText('Test Channel')).toBeInTheDocument();
expect(screen.getByText('101')).toBeInTheDocument();
});
it('should return null when channel does not exist', () => {
const data = { ...mockData, filteredChannels: [] };
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
expect(container.firstChild).toBeNull();
});
it('should use default logo when channel logo is not available', () => {
const channelWithoutLogo = { ...mockChannel, logo_id: 'missing-logo' };
const data = {
...mockData,
filteredChannels: [channelWithoutLogo],
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const img = screen.getByAltText('Test Channel');
expect(img).toHaveAttribute('src', 'mocked-logo.png');
});
it('should display channel number or dash if missing', () => {
const channelWithoutNumber = { ...mockChannel, channel_number: null };
const data = {
...mockData,
filteredChannels: [channelWithoutNumber],
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
});
describe('Row Height Calculation', () => {
it('should use default PROGRAM_HEIGHT when no expanded program', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${PROGRAM_HEIGHT}px` });
});
it('should use EXPANDED_PROGRAM_HEIGHT when program is expanded', () => {
const data = {
...mockData,
expandedProgramId: mockProgram.id,
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${EXPANDED_PROGRAM_HEIGHT}px` });
});
it('should use pre-calculated row height from rowHeights array', () => {
const customHeight = 150;
const data = {
...mockData,
rowHeights: { 0: customHeight },
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${customHeight}px` });
});
});
describe('Programs Rendering', () => {
it('should render programs when channel has programs', () => {
// Create program at hour 0 (definitely within viewport at scrollLeft 0)
const visibleProgram = createProgramAtTime('program-1', 0, 60);
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, [visibleProgram]]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-program-1')).toBeInTheDocument();
expect(mockData.renderProgram).toHaveBeenCalledWith(visibleProgram, undefined, mockChannel);
});
it('should render multiple programs', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 1, 30),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-prog-1')).toBeInTheDocument();
expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
});
it('should render placeholder when channel has no programs', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBeGreaterThan(0);
});
it('should render placeholder when programsByChannelId does not contain channel', () => {
const data = {
...mockData,
programsByChannelId: new Map(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBeGreaterThan(0);
});
it('should position placeholder programs correctly', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const placeholders = container.querySelectorAll('[pos*="absolute"]');
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('w', `${HOUR_WIDTH * 2}`);
});
});
});
describe('Channel Logo Interactions', () => {
it('should call handleLogoClick when logo is clicked', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.click(logo);
expect(mockData.handleLogoClick).toHaveBeenCalledWith(
mockChannel,
expect.any(Object)
);
});
it('should show play icon on hover', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseEnter(logo);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
it('should not show play icon when not hovering', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
});
describe('Layout and Styling', () => {
it('should set correct channel logo width', () => {
const { container } = render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logoContainer = container.querySelector('.channel-logo');
expect(logoContainer).toHaveAttribute('w', `${CHANNEL_WIDTH}`);
expect(logoContainer).toHaveAttribute('miw', `${CHANNEL_WIDTH}`);
});
it('should apply content width to row', () => {
const customWidth = 2400;
const data = {
...mockData,
contentWidth: customWidth,
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ width: `${customWidth}px` });
});
it('should adjust logo image container height based on row height', () => {
const customHeight = 200;
const data = {
...mockData,
rowHeights: { 0: customHeight },
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const imageContainer = container.querySelector('img').parentElement;
expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`);
});
});
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
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 6 }, // Scroll to hour 6
viewportWidth: HOUR_WIDTH * 4, // Show 4 hours
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Program at hour 6 should be visible
expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
// Programs outside viewport + buffer should not be rendered
expect(mockData.renderProgram).toHaveBeenCalledTimes(1);
});
it('should render programs within buffer zone', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 1, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 2 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Programs within H_BUFFER (600px) should be rendered
const renderedPrograms = mockData.renderProgram.mock.calls.map(
call => call[0]
);
expect(renderedPrograms.length).toBeGreaterThan(0);
});
it('should not render programs completely outside viewport and buffer', () => {
const programs = [
createProgramAtTime('prog-far-left', 0, 60),
createProgramAtTime('prog-visible', 10, 60),
createProgramAtTime('prog-far-right', 22, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const renderedPrograms = mockData.renderProgram.mock.calls.map(
call => call[0].id
);
// Only visible program should be rendered
expect(renderedPrograms).toContain('prog-visible');
expect(renderedPrograms).not.toContain('prog-far-left');
expect(renderedPrograms).not.toContain('prog-far-right');
});
it('should handle edge case where program spans viewport boundary', () => {
const programs = [
createProgramAtTime('prog-spanning', 5, 180), // 3-hour program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 6 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Program spanning viewport should be visible
expect(screen.getByTestId('program-prog-spanning')).toBeInTheDocument();
});
it('should update visible programs when scroll position changes', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 12, 60),
];
const scrollRef = { current: 0 };
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: scrollRef,
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { rerender } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const initialCalls = mockData.renderProgram.mock.calls.length;
// Scroll to different position
scrollRef.current = HOUR_WIDTH * 12;
const newData = { ...data, guideScrollLeftRef: scrollRef };
rerender(<GuideRow index={0} style={mockStyle} data={newData} />);
// Different programs should be rendered
expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(initialCalls);
});
});
describe('Placeholder Culling', () => {
it('should only render placeholders visible in viewport', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const visiblePlaceholders = screen.getAllByText('No program data');
// Should render fewer than total placeholders due to culling
expect(visiblePlaceholders.length).toBeLessThan(Math.ceil(24 / 2));
expect(visiblePlaceholders.length).toBeGreaterThan(0);
});
it('should not render placeholders outside viewport', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 20 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const visiblePlaceholders = screen.getAllByText('No program data');
// Near the end of timeline, should show fewer placeholders
expect(visiblePlaceholders.length).toBeGreaterThan(0);
expect(visiblePlaceholders.length).toBeLessThanOrEqual(3);
});
});
describe('Hover State Management', () => {
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');
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
fireEvent.mouseEnter(logoContainer);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
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');
fireEvent.mouseEnter(logoContainer);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
fireEvent.mouseLeave(logoContainer);
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
it('should maintain hover state independently per row', () => {
const data = {
...mockData,
filteredChannels: [mockChannel, { ...mockChannel, id: 'channel-2', name: 'Channel 2' }],
};
// Render both rows separately
const { container: container1 } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const { container: container2 } = render(
<GuideRow index={1} style={{ ...mockStyle, top: PROGRAM_HEIGHT }} data={data} />
);
// Hover over first row
const logo1 = container1.querySelector('.channel-logo');
fireEvent.mouseEnter(logo1);
// First row should show play icon
expect(container1.querySelector('[data-testid="play-icon"]')).toBeInTheDocument();
// Second row should not show play icon
expect(container2.querySelector('[data-testid="play-icon"]')).not.toBeInTheDocument();
});
});
describe('Program Time Positioning', () => {
const createTimedProgram = (startHour, durationMinutes) => {
const timelineStart = new Date('2024-01-01T00:00:00Z');
const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
return {
id: `program-${startHour}`,
title: `Program at ${startHour}h`,
startMs,
endMs: startMs + durationMinutes * 60 * 1000,
};
};
it('should calculate correct viewport boundaries', () => {
const programs = [
createTimedProgram(6, 60),
createTimedProgram(12, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Both programs should be rendered since they're within viewport range
expect(mockData.renderProgram).toHaveBeenCalled();
});
it('should handle programs at timeline boundaries', () => {
const programs = [
createTimedProgram(0, 60), // Start of timeline
createTimedProgram(23, 60), // End of timeline
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: 0 },
viewportWidth: HOUR_WIDTH * 24,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('Program at 0h')).toBeInTheDocument();
expect(screen.getByText('Program at 23h')).toBeInTheDocument();
});
it('should handle very short programs', () => {
const programs = [
createTimedProgram(12, 5), // 5-minute program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 12 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-program-12')).toBeInTheDocument();
});
it('should handle very long programs', () => {
const programs = [
createTimedProgram(6, 360), // 6-hour program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Long program spanning viewport should be visible
expect(screen.getByTestId('program-program-6')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty programsByChannelId gracefully', () => {
const data = {
...mockData,
programsByChannelId: new Map(),
guideScrollLeftRef: { current: 0 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getAllByText('No program data').length).toBeGreaterThan(0);
});
it('should handle zero viewport width', () => {
const programs = [mockProgram];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: 0 },
viewportWidth: 0,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Should still render due to buffer
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
});
it('should handle negative scroll position', () => {
const programs = [createProgramAtTime('prog-1', 0, 60)];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: -100 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
});
it('should handle row index out of bounds', () => {
const data = {
...mockData,
filteredChannels: [mockChannel],
};
const { container } = render(
<GuideRow index={999} style={mockStyle} data={data} />
);
expect(container.firstChild).toBeNull();
});
it('should memoize component to prevent unnecessary re-renders', () => {
const { rerender } = render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const renderCount = mockData.renderProgram.mock.calls.length;
// Re-render with same props
rerender(<GuideRow index={0} style={mockStyle} data={mockData} />);
// Should not cause additional renders due to React.memo
expect(mockData.renderProgram.mock.calls.length).toBe(renderCount);
});
});
});

View file

@ -0,0 +1,318 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import HourTimeline from '../HourTimeline';
import { format } from '../../utils/dateTimeUtils';
import { HOUR_WIDTH } from '../../pages/guideUtils';
// Mock date utilities
vi.mock('../../utils/dateTimeUtils', () => ({
format: vi.fn((date, formatStr) => {
if (!formatStr) return date.toISOString();
return formatStr === 'h:mm a' ? '12:00 PM' : 'Jan 1';
}),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
};
});
describe('HourTimeline', () => {
const mockTime1 = new Date('2024-01-01T10:00:00Z');
const mockTime2 = new Date('2024-01-01T11:00:00Z');
const mockTime3 = new Date('2024-01-02T00:00:00Z');
const mockHourTimeline = [
{ time: mockTime1, isNewDay: false },
{ time: mockTime2, isNewDay: false },
];
const mockTimeFormat = 'h:mm a';
const mockFormatDayLabel = vi.fn((time) => 'Mon');
const mockHandleTimeClick = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render all hour blocks', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(mockFormatDayLabel).toHaveBeenCalledTimes(2);
expect(format).toHaveBeenCalledWith(mockTime1, mockTimeFormat);
expect(format).toHaveBeenCalledWith(mockTime2, mockTimeFormat);
});
it('should render empty when hourTimeline is empty', () => {
const { container } = render(
<HourTimeline
hourTimeline={[]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(container.firstChild).toBeNull();
});
it('should render formatted time labels', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const timeLabels = screen.getAllByText('12:00 PM');
expect(timeLabels.length).toBe(2);
});
it('should render day labels', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabels = screen.getAllByText('Mon');
expect(dayLabels.length).toBe(2);
});
});
describe('HourBlock Styling', () => {
it('should apply correct width and height to hour blocks', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
hourBlocks.forEach((block) => {
expect(block).toHaveAttribute('w', `${HOUR_WIDTH}`);
expect(block).toHaveAttribute('h', '40px');
expect(block).toHaveAttribute('pos', 'relative');
});
});
it('should apply default styling for non-new-day blocks', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
expect(hourBlocks[0]).toHaveStyle({
backgroundColor: '#1B2421',
});
});
it('should apply special styling for new day blocks', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const { container } = render(
<HourTimeline
hourTimeline={newDayTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlock = container.querySelector('[style*="cursor: pointer"]');
expect(hourBlock).toHaveStyle({
borderLeft: '2px solid #3BA882',
backgroundColor: '#1E2A27',
});
});
it('should apply bold font weight to day label on new day', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const { container } = render(
<HourTimeline
hourTimeline={newDayTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabel = screen.getByText('Mon');
expect(dayLabel).toHaveAttribute('fw', '600');
expect(dayLabel).toHaveAttribute('c', '#3BA882');
});
it('should apply normal font weight to day label on regular day', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabels = screen.getAllByText('Mon');
expect(dayLabels[0]).toHaveAttribute('fw', '400');
});
});
describe('Quarter Hour Markers', () => {
it('should render quarter hour markers', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const markers = container.querySelectorAll('[style*="background-color: rgb(113, 128, 150);"]');
expect(markers.length).toBe(3); // 15, 30, 45 minute markers
});
it('should position quarter hour markers correctly', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const markers = container.querySelectorAll('[style*="backgroundColor: #718096"]');
const positions = ['25%', '50%', '75%'];
markers.forEach((marker, index) => {
expect(marker).toHaveStyle({
left: positions[index],
width: '1px',
height: '8px',
position: 'absolute',
bottom: '0px',
});
});
});
});
describe('Click Interactions', () => {
it('should call handleTimeClick when hour block is clicked', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
});
it('should call handleTimeClick with correct time for each block', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
fireEvent.click(hourBlocks[1]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime2, expect.any(Object));
});
});
describe('Component Keys', () => {
it('should use formatted time as key for each hour block', () => {
format.mockImplementation((date) => date.toISOString());
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(format).toHaveBeenCalledWith(mockTime1);
expect(format).toHaveBeenCalledWith(mockTime2);
});
});
describe('Time Label Positioning', () => {
it('should position time label correctly', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const timeLabel = container.querySelector('[pos*="absolute"][top*="8"]');
expect(timeLabel).toHaveAttribute('left', '4');
});
});
describe('Visual Separators', () => {
it('should render left separator box', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const separator = container.querySelector('[w*="1px"][left*="0"]');
expect(separator).toHaveAttribute('pos', 'absolute');
expect(separator).toHaveAttribute('top', '0');
expect(separator).toHaveAttribute('bottom', '0');
});
});
});

View file

@ -0,0 +1,398 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import LazyLogo from '../LazyLogo';
import useLogosStore from '../../store/logos';
// Mock the logos store
vi.mock('../../store/logos', () => ({
default: vi.fn(),
}));
// Mock the logo import
vi.mock('../../images/logo.png', () => ({
default: 'mocked-default-logo.png',
}));
// Mock Mantine Skeleton component
vi.mock('@mantine/core', async () => {
return {
Skeleton: ({ height, width, style, ...props }) => {
return <div data-testid="skeleton" style={{ height, width, ...style }} {...props} />;
},
};
});
describe('LazyLogo', () => {
let mockFetchLogosByIds;
let mockStore;
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useFakeTimers();
mockFetchLogosByIds = vi.fn().mockResolvedValue(undefined);
mockStore = {
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
allowLogoRendering: true,
};
useLogosStore.mockImplementation((selector) => selector(mockStore));
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
describe('Rendering', () => {
it('should render image with logo data', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" alt="Test Logo" />);
const img = screen.getByAltText('Test Logo');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'https://example.com/logo.png');
});
it('should render with default style', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
expect(img).toHaveStyle({
maxHeight: '18px',
maxWidth: '55px',
});
});
it('should render with custom style', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
const customStyle = { maxHeight: 30, maxWidth: 100 };
render(<LazyLogo logoId="logo-1" style={customStyle} />);
const img = screen.getByAltText('logo');
expect(img).toHaveStyle({
maxHeight: '30px',
maxWidth: '100px',
});
});
it('should render fallback logo when no logoId provided', () => {
render(<LazyLogo />);
const img = screen.getByAltText('logo');
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should pass through additional props to img element', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(
<LazyLogo logoId="logo-1" className="test-class" data-testid="custom-logo" />
);
const img = screen.getByTestId('custom-logo');
expect(img).toHaveClass('test-class');
});
});
describe('Skeleton Loading State', () => {
it('should show skeleton when logo rendering is not allowed', () => {
mockStore.allowLogoRendering = false;
render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
expect(screen.queryByAltText('logo')).not.toBeInTheDocument();
});
it('should show skeleton when logo data is not available', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
});
it('should render skeleton with default dimensions', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
height: '18px',
width: '55px',
});
});
it('should render skeleton with custom dimensions', () => {
mockStore.logos = {};
const customStyle = { maxHeight: 40, maxWidth: 120 };
render(<LazyLogo logoId="logo-1" style={customStyle} />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
height: '40px',
width: '120px',
});
});
it('should apply border radius to skeleton', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
borderRadius: '4px',
});
});
});
describe('Logo Fetching', () => {
it('should fetch logo when logoId is provided and logo data is missing', async () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
});
it('should not fetch logo when logo data already exists', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should not fetch logo when allowLogoRendering is false', () => {
mockStore.allowLogoRendering = false;
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should not fetch logo when no logoId is provided', () => {
render(<LazyLogo />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should batch multiple logo requests', async () => {
mockStore.logos = {};
render(
<>
<LazyLogo logoId="logo-1" />
<LazyLogo logoId="logo-2" />
<LazyLogo logoId="logo-3" />
</>
);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(
expect.arrayContaining(['logo-1', 'logo-2', 'logo-3'])
);
});
it('should debounce fetch requests with 100ms delay', async () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(50);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(mockFetchLogosByIds).toHaveBeenCalled();
});
it('should handle fetch errors gracefully', async () => {
mockFetchLogosByIds.mockRejectedValueOnce(new Error('Fetch failed'));
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
});
it('should not fetch same logo twice', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
rerender(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
});
});
describe('Error Handling', () => {
it('should fallback to default logo on image load error', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
// Simulate image load error
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should use custom fallback source on error', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
const customFallback = 'custom-fallback.png';
render(<LazyLogo logoId="logo-1" fallbackSrc={customFallback} />);
const img = screen.getByAltText('logo');
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', customFallback);
});
it('should only set fallback once to prevent infinite error loop', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
// First error - should set fallback
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
// Reset src to test second error
img.src = 'https://example.com/another-invalid.png';
// Second error - should not change src again
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should reset error state when logoId changes', async () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo1.png' },
'logo-2': { cache_url: 'https://example.com/logo2.png' },
};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
img.dispatchEvent(new Event('error'));
rerender(<LazyLogo logoId="logo-2" />);
const newImg = screen.getByAltText('logo');
expect(newImg).toHaveAttribute('src', 'https://example.com/logo2.png');
});
});
describe('Component Lifecycle', () => {
it('should cleanup on unmount', () => {
const { unmount } = render(<LazyLogo logoId="logo-1" />);
unmount();
// Should not throw errors after unmount
vi.advanceTimersByTime(100);
});
it('should handle rapid logoId changes', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
rerender(<LazyLogo logoId="logo-2" />);
rerender(<LazyLogo logoId="logo-3" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalled();
});
});
describe('Store Integration', () => {
it('should react to store updates', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
// Update store with logo data
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
rerender(<LazyLogo logoId="logo-1" />);
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
expect(screen.getByAltText('logo')).toBeInTheDocument();
});
it('should handle allowLogoRendering becoming true', async () => {
mockStore.allowLogoRendering = false;
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
mockStore.allowLogoRendering = true;
rerender(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
});
});
});

View file

@ -0,0 +1,716 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BrowserRouter } from 'react-router-dom';
import M3URefreshNotification from '../M3URefreshNotification';
import usePlaylistsStore from '../../store/playlists';
import useStreamsStore from '../../store/streams';
import useChannelsStore from '../../store/channels';
import useEPGsStore from '../../store/epgs';
import useVODStore from '../../store/useVODStore';
import API from '../../api';
import { showNotification } from '../../utils/notificationUtils';
// Mock all stores
vi.mock('../../store/playlists', () => ({
default: vi.fn(),
}));
vi.mock('../../store/streams', () => ({
default: vi.fn(),
}));
vi.mock('../../store/channels', () => ({
default: vi.fn(),
}));
vi.mock('../../store/epgs', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVODStore', () => ({
default: vi.fn(),
}));
// Mock API
vi.mock('../../api', () => ({
default: {
refreshPlaylist: vi.fn(),
requeryChannels: vi.fn(),
},
}));
// Mock notification utility
vi.mock('../../utils/notificationUtils', () => ({
showNotification: vi.fn(),
}));
vi.mock('@mantine/core', async () => {
return {
Stack: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
CircleCheck: () => <div data-testid="circle-check-icon" />,
}));
const renderWithProviders = (component) => {
return render(<BrowserRouter>{component}</BrowserRouter>);
};
describe('M3URefreshNotification', () => {
let mockPlaylistsStore;
let mockStreamsStore;
let mockChannelsStore;
let mockEPGsStore;
let mockVODStore;
const mockPlaylist = {
id: 1,
name: 'Test Playlist',
url: 'https://example.com/playlist.m3u',
};
beforeEach(() => {
vi.clearAllMocks();
// Setup default store mocks
mockPlaylistsStore = {
playlists: [mockPlaylist],
refreshProgress: {},
fetchPlaylists: vi.fn(),
setEditPlaylistId: vi.fn(),
};
mockStreamsStore = {
fetchStreams: vi.fn(),
};
mockChannelsStore = {
fetchChannelGroups: vi.fn(),
fetchChannelIds: vi.fn(),
};
mockEPGsStore = {
fetchEPGData: vi.fn(),
};
mockVODStore = {
fetchCategories: vi.fn(),
};
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));
});
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderWithProviders(<M3URefreshNotification />);
expect(container).toBeInTheDocument();
});
it('should render empty fragment', () => {
const { container } = renderWithProviders(<M3URefreshNotification />);
expect(container.firstChild).toBeNull();
});
});
describe('Download Progress Notifications', () => {
it('should show notification when download starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Downloading starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should show notification when download completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Downloading complete!',
loading: false,
autoClose: 2000,
icon: expect.anything(),
});
});
});
it('should not show notification for intermediate progress', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 50,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
describe('Parsing Progress Notifications', () => {
it('should show notification when parsing starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Stream parsing starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should show notification and trigger fetches when parsing completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
expect(API.requeryChannels).toHaveBeenCalled();
expect(mockChannelsStore.fetchChannelIds).toHaveBeenCalled();
});
});
});
describe('Group Processing Notifications', () => {
it('should show notification when processing groups starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'processing_groups',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Group parsing starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should trigger multiple fetches when processing groups completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'processing_groups',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
expect(mockEPGsStore.fetchEPGData).toHaveBeenCalled();
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
});
});
});
describe('VOD Refresh Notifications', () => {
it('should show notification when VOD refresh starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'vod_refresh',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'VOD content refresh starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should trigger VOD-specific fetches when VOD refresh completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'vod_refresh',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
expect(mockVODStore.fetchCategories).toHaveBeenCalled();
});
});
});
describe('Pending Setup Status', () => {
it('should show setup notification and trigger fetches for pending_setup status', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'pending_setup',
message: 'Test setup message',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Setup: Test Playlist',
message: expect.anything(),
color: 'orange.5',
autoClose: 5000,
});
expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
});
});
it('should use default message when no message provided in pending_setup', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'pending_setup',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
});
});
describe('Error Handling', () => {
it('should show error notification when status is error and progress is 100', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'error',
progress: 100,
error: 'Connection timeout',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'parsing failed: Connection timeout',
color: 'red',
autoClose: 5000,
});
});
});
it('should not show error notification when progress is not 100', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'error',
progress: 50,
error: 'Connection timeout',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('should use default error message when error field is missing', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
status: 'error',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'downloading failed: Unknown error',
color: 'red',
autoClose: 5000,
});
});
});
it('should use default action when action field is missing in error', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'error',
progress: 100,
error: 'Test error',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Processing failed: Test error',
color: 'red',
autoClose: 5000,
});
});
});
it('should not show further notifications after error status', async () => {
// First update with error
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'error',
progress: 100,
error: 'Test error',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Second update with success
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'completed',
progress: 100,
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
// Should not show notification due to previous error
expect(showNotification).not.toHaveBeenCalled();
});
});
describe('Playlist Validation', () => {
it('should not show notification if playlist not found', async () => {
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {
999: {
account: 999,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('should handle multiple playlists correctly', async () => {
const secondPlaylist = { id: 2, name: 'Second Playlist' };
mockPlaylistsStore.playlists = [mockPlaylist, secondPlaylist];
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
2: {
account: 2,
action: 'downloading',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(2);
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'M3U Processing: Test Playlist',
})
);
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'M3U Processing: Second Playlist',
})
);
});
});
});
describe('Notification Deduplication', () => {
it('should not show duplicate notification for same status', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Re-render with same data
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
expect(showNotification).not.toHaveBeenCalled();
});
it('should show notification when status changes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Update with different progress
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 100,
status: 'completed',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
});
});
describe('State Cleanup', () => {
it('should reset notification status when playlists change', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
vi.clearAllMocks();
// Change playlists - remove existing playlist
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {
2: {
account: 2,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
// Should not show notification because playlist doesn't exist
expect(showNotification).not.toHaveBeenCalled();
});
it('should handle empty playlists array', async () => {
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
describe('Effect Dependencies', () => {
it('should re-run effect when refreshProgress changes', async () => {
mockPlaylistsStore.refreshProgress = {};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
expect(showNotification).not.toHaveBeenCalled();
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
});
it('should re-run effect when playlists change', async () => {
const { rerender } = renderWithProviders(<M3URefreshNotification />);
const newPlaylist = { id: 2, name: 'New Playlist' };
mockPlaylistsStore.playlists = [mockPlaylist, newPlaylist];
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
});
});
});

View file

@ -0,0 +1,662 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserRouter } from 'react-router-dom';
import NotificationCenter from '../NotificationCenter';
import useNotificationsStore from '../../store/notifications';
import * as NotificationUtils from '../../utils/components/NotificationCenterUtils';
// Mock the notifications store
vi.mock('../../store/notifications');
// Mock the notification utils
vi.mock('../../utils/components/NotificationCenterUtils', () => ({
getNotifications: vi.fn(),
dismissNotification: vi.fn(),
dismissAllNotifications: vi.fn(),
}));
// Mock react-router-dom
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Popover: ({ children, opened }) => (
<div data-testid="popover" data-opened={opened}>
{children}
</div>
),
PopoverTarget: ({ children }) => <div data-testid="popover-target">{children}</div>,
PopoverDropdown: ({ children }) => <div data-testid="popover-dropdown">{children}</div>,
Indicator: ({ children, label, disabled, processing }) => (
<div
data-testid="indicator"
data-label={label}
data-disabled={disabled}
data-processing={processing}
>
{children}
</div>
),
ActionIcon: ({ children, onClick, 'aria-label': ariaLabel, ...props }) => (
<button onClick={onClick} aria-label={ariaLabel} data-testid={`action-icon-${ariaLabel}`} {...props}>
{children}
</button>
),
ScrollAreaAutosize: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Badge: ({ children, ...props }) => <span data-testid="badge" {...props}>{children}</span>,
Card: ({ children, ...props }) => <div data-testid="notification-card" {...props}>{children}</div>,
ThemeIcon: ({ children, ...props }) => <div data-testid="theme-icon" {...props}>{children}</div>,
Group: ({ children, ...props }) => <div data-testid="group" {...props}>{children}</div>,
Stack: ({ children, ...props }) => <div data-testid="stack" {...props}>{children}</div>,
Box: ({ children, ...props }) => <div data-testid="box" {...props}>{children}</div>,
Text: ({ children, ...props }) => <span data-testid="text" {...props}>{children}</span>,
Button: ({ children, onClick, ...props }) => (
<button onClick={onClick} data-testid="button" {...props}>
{children}
</button>
),
Divider: () => <hr data-testid="divider" />,
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" title={label}>
{children}
</div>
),
useMantineTheme: () => ({
colors: {
red: ['', '', '', '', '', '#fa5252', '', '', '', '#c92a2a'],
orange: ['', '', '', '', '', '#fd7e14'],
blue: ['', '', '', '', '', '#228be6'],
gray: ['', '', '', '', '', '#adb5bd'],
green: ['', '', '', '', '', '#51cf66'],
violet: ['', '', '', '', '', '#7950f2'],
},
}),
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Bell: () => <span data-testid="bell-icon">Bell</span>,
Check: () => <span data-testid="check-icon">Check</span>,
CheckCheck: () => <span data-testid="checkcheck-icon">CheckCheck</span>,
Download: () => <span data-testid="download-icon">Download</span>,
ExternalLink: () => <span data-testid="external-link-icon">ExternalLink</span>,
Info: () => <span data-testid="info-icon">Info</span>,
Settings: () => <span data-testid="settings-icon">Settings</span>,
AlertTriangle: () => <span data-testid="alert-triangle-icon">AlertTriangle</span>,
Megaphone: () => <span data-testid="megaphone-icon">Megaphone</span>,
X: () => <span data-testid="x-icon">X</span>,
Eye: () => <span data-testid="eye-icon">Eye</span>,
EyeOff: () => <span data-testid="eyeoff-icon">EyeOff</span>,
ArrowRight: () => <span data-testid="arrow-right-icon">ArrowRight</span>,
}));
const mockNotifications = [
{
id: 1,
title: 'Version Update Available',
message: 'Version 2.0 is available',
notification_type: 'version_update',
priority: 'high',
is_dismissed: false,
created_at: '2024-01-01T10:00:00Z',
action_data: {
release_url: 'https://github.com/releases/v2.0',
},
},
{
id: 2,
title: 'Setting Recommendation',
message: 'Enable dark mode for better experience',
notification_type: 'setting_recommendation',
priority: 'normal',
is_dismissed: false,
created_at: '2024-01-02T10:00:00Z',
action_data: {},
},
{
id: 3,
title: 'System Announcement',
message: 'Maintenance scheduled for tomorrow',
notification_type: 'announcement',
priority: 'normal',
is_dismissed: true,
created_at: '2024-01-03T10:00:00Z',
action_data: {},
},
{
id: 4,
title: 'Critical Warning',
message: 'System issue detected',
notification_type: 'warning',
priority: 'critical',
is_dismissed: false,
created_at: '2024-01-04T10:00:00Z',
action_data: {},
},
];
describe('NotificationCenter', () => {
beforeEach(() => {
vi.clearAllMocks();
// vi.useFakeTimers();
// Default store mock
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: mockNotifications,
unreadCount: 3,
getUnreadNotifications: vi.fn(() =>
mockNotifications.filter((n) => !n.is_dismissed)
),
};
return selector ? selector(state) : state;
});
NotificationUtils.getNotifications.mockResolvedValue();
NotificationUtils.dismissNotification.mockResolvedValue();
NotificationUtils.dismissAllNotifications.mockResolvedValue();
});
afterEach(() => {
vi.clearAllTimers();
});
const renderComponent = (props = {}) => {
return render(
<BrowserRouter>
<NotificationCenter {...props} />
</BrowserRouter>
);
};
describe('Bell Icon and Indicator', () => {
it('should render bell icon', () => {
renderComponent();
expect(screen.getByLabelText('Notifications')).toBeInTheDocument();
expect(screen.getByTestId('bell-icon')).toBeInTheDocument();
});
it('should show unread count indicator when there are unread notifications', () => {
renderComponent();
const indicator = screen.getByTestId('indicator');
expect(indicator).toHaveAttribute('data-label', '3');
expect(indicator).toHaveAttribute('data-disabled', 'false');
expect(indicator).toHaveAttribute('data-processing', 'true');
});
it('should not show indicator when unread count is zero', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [],
unreadCount: 0,
getUnreadNotifications: vi.fn(() => []),
};
return selector ? selector(state) : state;
});
renderComponent();
const indicator = screen.getByTestId('indicator');
expect(indicator).toHaveAttribute('data-disabled', 'true');
});
it('should show "9+" when unread count exceeds 9', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: mockNotifications,
unreadCount: 15,
getUnreadNotifications: vi.fn(() => mockNotifications),
};
return selector ? selector(state) : state;
});
renderComponent();
const indicator = screen.getByTestId('indicator');
expect(indicator).toHaveAttribute('data-label', '9+');
});
});
describe('Popover Toggle', () => {
it('should open popover when bell icon is clicked', () => {
renderComponent();
const bellButton = screen.getByLabelText('Notifications');
fireEvent.click(bellButton);
const popover = screen.getByTestId('popover');
expect(popover).toHaveAttribute('data-opened', 'true');
});
it('should close popover when bell icon is clicked again', () => {
renderComponent();
const bellButton = screen.getByLabelText('Notifications');
fireEvent.click(bellButton);
fireEvent.click(bellButton);
const popover = screen.getByTestId('popover');
expect(popover).toHaveAttribute('data-opened', 'false');
});
it('should display notification header with count', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('Notifications')).toBeInTheDocument();
expect(screen.getByText('3 new')).toBeInTheDocument();
});
});
describe('API Calls', () => {
it('should fetch notifications on mount', async () => {
renderComponent();
await waitFor(() => {
expect(NotificationUtils.getNotifications).toHaveBeenCalledWith(false);
});
});
it('should fetch notifications every 5 minutes', () => {
vi.useFakeTimers();
renderComponent();
expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(5 * 60 * 1000);
expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('should handle API errors gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.getNotifications.mockRejectedValue(new Error('Network error'));
renderComponent();
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to fetch notifications:',
expect.any(Error)
);
});
consoleError.mockRestore();
});
});
describe('Notification Display', () => {
it('should display unread notifications by default', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('Version Update Available')).toBeInTheDocument();
expect(screen.getByText('Setting Recommendation')).toBeInTheDocument();
expect(screen.getByText('Critical Warning')).toBeInTheDocument();
expect(screen.queryByText('System Announcement')).not.toBeInTheDocument();
});
it('should display all notifications when show dismissed is toggled', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
expect(screen.getByText('System Announcement')).toBeInTheDocument();
});
it('should show empty state when no unread notifications', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [],
unreadCount: 0,
getUnreadNotifications: vi.fn(() => []),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('All caught up!')).toBeInTheDocument();
expect(screen.getByText('No new notifications')).toBeInTheDocument();
});
it('should show dismissed count in footer', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('1 dismissed notification')).toBeInTheDocument();
});
it('should show correct dismissed count with multiple dismissed notifications', () => {
const notifications = [
...mockNotifications,
{ ...mockNotifications[2], id: 5, is_dismissed: true },
];
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications,
unreadCount: 3,
getUnreadNotifications: vi.fn(() =>
notifications.filter((n) => !n.is_dismissed)
),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('2 dismissed notifications')).toBeInTheDocument();
});
});
describe('Notification Actions', () => {
it('should dismiss notification when X button is clicked', async () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const xIcons = screen.getAllByTestId('x-icon');
fireEvent.click(xIcons[0].closest('button'));
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
});
});
it('should dismiss all notifications when CheckCheck button is clicked', async () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const checkCheckIcon = screen.getByTestId('checkcheck-icon');
fireEvent.click(checkCheckIcon.closest('button'));
await waitFor(() => {
expect(NotificationUtils.dismissAllNotifications).toHaveBeenCalled();
});
});
it('should open release URL for version update notification', () => {
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => {});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const viewReleaseButton = screen.getByText('View Release');
fireEvent.click(viewReleaseButton);
expect(windowOpen).toHaveBeenCalledWith(
'https://github.com/releases/v2.0',
'_blank'
);
windowOpen.mockRestore();
});
it('should navigate for notifications with action_url', () => {
const notificationWithUrl = {
...mockNotifications[0],
action_data: {
action_url: '/settings',
action_text: 'Go to Settings',
},
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [notificationWithUrl],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const actionButton = screen.getByText('Go to Settings');
fireEvent.click(actionButton);
expect(mockNavigate).toHaveBeenCalledWith('/settings');
});
it('should call onSettingAction when applying setting recommendation', async () => {
const onSettingAction = vi.fn();
renderComponent({ onSettingAction });
fireEvent.click(screen.getByLabelText('Notifications'));
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'applied');
expect(onSettingAction).toHaveBeenCalledWith(mockNotifications[1]);
});
});
it('should dismiss setting recommendation when Ignore is clicked', async () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const ignoreButton = screen.getByText('Ignore');
fireEvent.click(ignoreButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'dismissed');
});
});
it('should close popover when navigating with action_url', () => {
const notificationWithUrl = {
...mockNotifications[0],
action_data: {
action_url: '/settings',
action_text: 'Go to Settings',
},
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [notificationWithUrl],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const actionButton = screen.getByText('Go to Settings');
fireEvent.click(actionButton);
const popover = screen.getByTestId('popover');
expect(popover).toHaveAttribute('data-opened', 'false');
});
});
describe('Notification Types and Icons', () => {
it('should render correct icon for version_update', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('download-icon')).toBeInTheDocument();
});
it('should render correct icon for setting_recommendation', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('settings-icon')).toBeInTheDocument();
});
it('should render correct icon for announcement', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [mockNotifications[2]],
unreadCount: 0,
getUnreadNotifications: vi.fn(() => []),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
expect(screen.getByTestId('megaphone-icon')).toBeInTheDocument();
});
it('should render correct icon for warning', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument();
});
it('should render info icon for unknown type', () => {
const unknownTypeNotification = {
...mockNotifications[0],
notification_type: 'unknown',
is_dismissed: false,
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [unknownTypeNotification],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [unknownTypeNotification]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('info-icon')).toBeInTheDocument();
});
});
describe('Priority Badges', () => {
it('should show priority badge for high priority notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('high')).toBeInTheDocument();
});
it('should show priority badge for critical priority notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('critical')).toBeInTheDocument();
});
it('should not show priority badge for normal priority', () => {
const normalNotification = {
...mockNotifications[1],
priority: 'normal',
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [normalNotification],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [normalNotification]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.queryByText('normal')).not.toBeInTheDocument();
});
it('should show dismissed badge for dismissed notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
expect(screen.getByText('Dismissed')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should handle dismiss notification errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissNotification.mockRejectedValue(new Error('API error'));
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const xIcons = screen.getAllByTestId('x-icon');
fireEvent.click(xIcons[0].closest('button'));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to dismiss notification:',
expect.any(Error)
);
});
consoleError.mockRestore();
});
it('should handle dismiss all notifications errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissAllNotifications.mockRejectedValue(new Error('API error'));
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const checkCheckIcon = screen.getByTestId('checkcheck-icon');
fireEvent.click(checkCheckIcon.closest('button'));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to dismiss all notifications:',
expect.any(Error)
);
});
consoleError.mockRestore();
});
});
describe('Date Display', () => {
it('should display formatted date for notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const expectedDate = new Date('2024-01-01T10:00:00Z').toLocaleDateString();
expect(screen.getByText(expectedDate)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import RecordingSynopsis from '../RecordingSynopsis';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Text: ({ children, size, c, lineClamp, onClick, title, style, ...props }) => {
return (
<div
data-testid="text"
data-size={size}
data-color={c}
data-line-clamp={lineClamp}
title={title}
onClick={onClick}
style={style}
{...props}
>
{children}
</div>
);
},
};
});
describe('RecordingSynopsis', () => {
let mockOnOpen;
beforeEach(() => {
vi.clearAllMocks();
mockOnOpen = vi.fn();
});
describe('Rendering', () => {
it('should render with short description', () => {
const shortDescription = 'This is a short description.';
render(
<RecordingSynopsis description={shortDescription} onOpen={mockOnOpen} />
);
const text = screen.getByText(shortDescription);
expect(text).toBeInTheDocument();
});
it('should return null when description is undefined', () => {
const { container } = render(
<RecordingSynopsis description={undefined} onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should return null when description is null', () => {
const { container } = render(
<RecordingSynopsis description={null} onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should return null when description is empty string', () => {
const { container } = render(
<RecordingSynopsis description="" onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should render without onOpen callback', () => {
const description = 'Test description';
render(<RecordingSynopsis description={description} />);
expect(screen.getByText(description)).toBeInTheDocument();
});
});
describe('Text Truncation', () => {
it('should not truncate description with exactly 140 characters', () => {
const exactLength = 'A'.repeat(140);
render(
<RecordingSynopsis description={exactLength} onOpen={mockOnOpen} />
);
expect(screen.getByText(exactLength)).toBeInTheDocument();
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument();
});
it('should truncate description with 141 characters', () => {
const overLength = 'A'.repeat(141);
render(
<RecordingSynopsis description={overLength} onOpen={mockOnOpen} />
);
const expectedPreview = `${overLength.slice(0, 140).trim()}...`;
expect(screen.getByText(expectedPreview)).toBeInTheDocument();
});
it('should trim whitespace before adding ellipsis', () => {
const description = 'A'.repeat(135) + ' B'.repeat(10);
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const preview = screen.getByTestId('text').textContent;
expect(preview).toMatch(/\.\.\./);
expect(preview).not.toMatch(/\s+\.\.\./);
});
});
describe('Click Interactions', () => {
it('should call onOpen when clicked', () => {
const description = 'Test description';
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const text = screen.getByText(description);
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(1);
});
it('should not throw error when clicked without onOpen callback', () => {
const description = 'Test description';
render(<RecordingSynopsis description={description} />);
const text = screen.getByText(description);
expect(() => fireEvent.click(text)).not.toThrow();
});
it('should handle multiple clicks', () => {
const description = 'Test description';
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const text = screen.getByText(description);
fireEvent.click(text);
fireEvent.click(text);
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(3);
});
it('should be clickable with truncated text', () => {
const longDescription = 'A'.repeat(200);
render(
<RecordingSynopsis description={longDescription} onOpen={mockOnOpen} />
);
const text = screen.getByTestId('text');
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,864 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SeriesModal from '../SeriesModal';
import useVODStore from '../../store/useVODStore';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import { copyToClipboard } from '../../utils';
// Mock stores
vi.mock('../../store/useVODStore', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVideoStore', () => ({
default: vi.fn(),
}));
vi.mock('../../store/settings', () => ({
default: vi.fn(),
}));
// Mock utils
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: () => <div data-testid="play-icon" />,
Copy: () => <div data-testid="copy-icon" />,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
const actual = await vi.importActual('@mantine/core');
return {
...actual,
Modal: ({ opened, onClose, title, children, size, ...props }) => {
if (!opened) return null;
return (
<div data-testid="modal" data-title={title} data-size={size}>
<button onClick={onClose} data-testid="modal-close">Close</button>
<div>{children}</div>
</div>
);
},
Box: ({ children, ...props }) => <div data-testid="box" {...props}>{children}</div>,
Button: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="button" {...props}>
{children}
</button>
),
Flex: ({ children, ...props }) => <div data-testid="flex" {...props}>{children}</div>,
Group: ({ children, ...props }) => <div data-testid="group" {...props}>{children}</div>,
Image: ({ src, alt, ...props }) => (
<img src={src} alt={alt} data-testid="image" {...props} />
),
Text: ({ children, ...props }) => <div data-testid="text" {...props}>{children}</div>,
Title: ({ children, order, ...props }) => (
<div data-testid="title" data-order={order} {...props}>{children}</div>
),
Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => (
<div data-testid="select" data-label={label}>
<select
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
{...props}
>
<option value="">{placeholder}</option>
{data?.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
),
Badge: ({ children, ...props }) => <a data-testid="badge" {...props}>{children}</a>,
Loader: (props) => <div data-testid="loader" {...props} />,
Stack: ({ children, ...props }) => <div data-testid="stack" {...props}>{children}</div>,
ActionIcon: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="action-icon" {...props}>
{children}
</button>
),
Tabs: ({ children, value, onChange, ...props }) => (
<div data-testid="tabs" data-value={value} {...props}>
<div onClick={(e) => {
const tab = e.target.closest('[data-tab-value]');
if (tab) onChange?.(tab.dataset.tabValue);
}}>
{children}
</div>
</div>
),
TabsList: ({ children }) => <div data-testid="tabs-list">{children}</div>,
TabsTab: ({ children, value }) => (
<button data-testid="tabs-tab" data-tab-value={value}>{children}</button>
),
TabsPanel: ({ children, value }) => (
<div data-testid="tabs-panel" data-value={value}>{children}</div>
),
Table: ({ children, ...props }) => <table data-testid="table" {...props}>{children}</table>,
TableThead: ({ children }) => <thead data-testid="table-thead">{children}</thead>,
TableTbody: ({ children }) => <tbody data-testid="table-tbody">{children}</tbody>,
TableTr: ({ children, onClick, ...props }) => (
<tr onClick={onClick} data-testid="table-tr" {...props}>{children}</tr>
),
TableTh: ({ children, ...props }) => <th data-testid="table-th" {...props}>{children}</th>,
TableTd: ({ children, ...props }) => <td data-testid="table-td" {...props}>{children}</td>,
Divider: (props) => <hr data-testid="divider" {...props} />,
};
});
describe('SeriesModal', () => {
let mockVODStore;
let mockVideoStore;
let mockSettingsStore;
const mockSeries = {
id: 1,
name: 'Test Series',
series_image: 'https://example.com/cover.jpg',
genre: 'Drama, Action',
cast: 'Actor 1, Actor 2',
director: 'Director Name',
m3u_account: { name: 'Test Account' },
rating: '8.5',
release_date: '2020-01-01',
youtube_trailer: 'dQw4w9WgXcQ',
};
const mockEpisode = {
id: 1,
uuid: 'episode-uuid-1',
series_id: 1,
season_number: 1,
episode_number: 1,
name: 'Pilot',
duration_secs: 3600,
rating: '8.0',
container_extension: 'mkv',
added: '2024-01-01T00:00:00Z',
};
const mockDetailedSeries = {
...mockSeries,
episodesList: [mockEpisode],
tmdb_id: '12345',
imdb_id: 'tt1234567',
};
const mockProviders = [
{
id: 1,
stream_id: 100,
account_id: 5,
m3u_account: { name: 'Provider 1' },
stream_name: 'Test Series 1080p',
quality_info: { quality: '1080p' },
},
{
id: 2,
stream_id: 101,
account_id: 5,
m3u_account: { name: 'Provider 2' },
stream_name: 'Test Series 720p',
quality_info: null,
}
];
beforeEach(() => {
vi.clearAllMocks();
mockVODStore = {
fetchSeriesInfo: vi.fn().mockResolvedValue(mockDetailedSeries),
fetchSeriesProviders: vi.fn().mockResolvedValue(mockProviders),
};
mockVideoStore = {
showVideo: vi.fn(),
};
mockSettingsStore = {
environment: { env_mode: 'prod' },
};
useVODStore.mockImplementation((selector) => selector ? selector(mockVODStore) : mockVODStore);
useVideoStore.mockImplementation((selector) => selector ? selector(mockVideoStore) : mockVideoStore);
useSettingsStore.mockImplementation((selector) => selector ? selector(mockSettingsStore) : mockSettingsStore);
copyToClipboard.mockResolvedValue(undefined);
});
describe('Rendering', () => {
it('should render nothing when series is null', () => {
const { container } = render(
<SeriesModal series={null} opened={true} onClose={vi.fn()} />
);
expect(container.firstChild).toBeNull();
});
it('should render nothing when modal is closed', () => {
render(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened with series', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
it('should display series name as title', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText('Test Series')).toBeInTheDocument();
});
});
it('should display cover image', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const image = screen.getByAltText('Test Series');
expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg');
});
});
it('should show loader while fetching details', () => {
mockVODStore.fetchSeriesInfo = vi.fn(() => new Promise(() => {}));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
//expect multiple loader instances since we have one for details and one for providers
const loaders = screen.getAllByTestId('loader');
expect(loaders.length).toBeGreaterThan(0);
});
});
describe('Data Fetching', () => {
it('should fetch series info when opened', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalledWith(1);
});
});
it('should fetch series providers when opened', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesProviders).toHaveBeenCalledWith(1);
});
});
it('should not fetch data when modal is closed', () => {
render(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(mockVODStore.fetchSeriesInfo).not.toHaveBeenCalled();
expect(mockVODStore.fetchSeriesProviders).not.toHaveBeenCalled();
});
it('should reset state when modal closes', async () => {
const { rerender } = render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
});
rerender(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
describe('Series Information Display', () => {
it('should display genre', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Drama, Action/)).toBeInTheDocument();
});
});
it('should display rating', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/8\.5/)).toBeInTheDocument();
});
});
it('should display IMDB link when imdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const link = screen.getByText(/IMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
});
});
it('should display TMDB link when tmdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const link = screen.getByText(/TMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.themoviedb.org/tv/12345');
});
});
});
describe('Provider Selection', () => {
it('should display provider select with fetched providers', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const select = screen.getByTestId('select');
expect(select).toBeInTheDocument();
});
});
it('should format provider label correctly with quality info', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should handle provider selection', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
let select;
await waitFor(() => {
select = screen.getByTestId('select').querySelector('select');
fireEvent.change(select, { target: { value: '1' } });
});
await waitFor(() => {
expect(select.value).toBe('1');
});
});
it('should show loader while fetching providers', () => {
mockVODStore.fetchSeriesProviders = vi.fn(() => new Promise(() => {}));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
// Should show loading text and loader
expect(screen.getByText('Stream Selection')).toBeInTheDocument();
const loaders = screen.getAllByTestId('loader');
expect(loaders.length).toBeGreaterThan(0);
});
});
describe('Episodes Display', () => {
it('should display episodes grouped by season', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
});
});
it('should display episode information', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Pilot/)).toBeInTheDocument();
});
});
it('should format episode duration correctly', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/1h 0m/)).toBeInTheDocument();
});
});
it('should handle episodes with no duration', async () => {
const episodeNoDuration = { ...mockEpisode, duration_secs: null };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episodeNoDuration],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Pilot/)).toBeInTheDocument();
});
});
it('should sort episodes by episode number', async () => {
const episode2 = { ...mockEpisode, id: 2, episode_number: 2, name: 'Second Episode' };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode2, mockEpisode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const episodes = screen.getAllByTestId('table-tr');
expect(episodes[1]).toHaveTextContent('Pilot');
expect(episodes[2]).toHaveTextContent('Second Episode');
});
});
});
describe('Episode Actions', () => {
it('should play episode when play button clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
'vod',
mockEpisode
);
});
it('should include provider stream_id in URL when provider selected', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const select = screen.getByTestId('select').querySelector('select');
fireEvent.change(select, { target: { value: '1' } });
});
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('stream_id=100'),
'vod',
mockEpisode
);
});
it('should use dev mode URL in development', async () => {
mockSettingsStore.environment.env_mode = 'dev';
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('localhost:5656'),
'vod',
mockEpisode
);
});
it('should copy episode link when copy button clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const copyButtons = screen.getAllByTestId('action-icon');
fireEvent.click(copyButtons[1]);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
expect.objectContaining({
successTitle: 'Link Copied!',
successMessage: 'Episode link copied to clipboard',
})
);
});
it('should expand episode details when row clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.getByText(/8\.0/)).toBeInTheDocument();
});
});
it('should collapse episode details when clicked again', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.getByText(/8\.0/)).toBeInTheDocument();
});
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.queryByText(/8\.0/)).not.toBeInTheDocument();
});
});
});
it('should show trailer button when youtube_trailer exists', async () => {
render(<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Watch Trailer')).toBeInTheDocument();
});
});
describe('Season Tabs', () => {
it('should create tabs for each season', async () => {
const season2Episode = { ...mockEpisode, id: 2, season_number: 2, episode_num: 1 };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [mockEpisode, season2Episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
expect(screen.getByText(/Season 2/i)).toBeInTheDocument();
});
});
it('should sort seasons in ascending order', async () => {
const season3Episode = { ...mockEpisode, id: 3, season_number: 3 };
const season2Episode = { ...mockEpisode, id: 2, season_number: 2 };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [season3Episode, mockEpisode, season2Episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const tabs = screen.getAllByTestId('tabs-tab');
expect(tabs[0]).toHaveTextContent('Season 1');
expect(tabs[1]).toHaveTextContent('Season 2');
expect(tabs[2]).toHaveTextContent('Season 3');
});
});
it('should activate first season tab by default', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const tabs = screen.getByTestId('tabs');
expect(tabs).toHaveAttribute('data-value', 'season-1');
});
});
});
describe('Quality Info Extraction', () => {
it('should extract quality from quality_info field', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should extract quality from custom_properties.detailed_info.video', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should extract quality from stream_name as fallback', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
stream_name: 'Test Series 720p',
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 720p/)).toBeInTheDocument();
});
});
it('should detect 4K quality', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 3840, height: 2160 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 4K/)).toBeInTheDocument();
});
});
it('should show resolution when standard quality not detected', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 720, height: 480 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/720x480/)).toBeInTheDocument();
});
});
});
describe('Edge Cases', () => {
it('should handle series with no episodes', async () => {
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.queryByTestId('table')).not.toBeInTheDocument();
});
});
it('should handle fetch errors gracefully', async () => {
mockVODStore.fetchSeriesInfo.mockRejectedValue(new Error('Fetch failed'));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
});
});
it('should handle episodes with null season_number', async () => {
const episodeNoSeason = { ...mockEpisode, season_number: null };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episodeNoSeason],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
});
});
it('should handle empty provider list', async () => {
mockVODStore.fetchSeriesProviders.mockResolvedValue([]);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText('Test Account')).toBeInTheDocument()
});
});
});
describe('Modal Close', () => {
it('should call onClose when close button clicked', async () => {
const onClose = vi.fn();
render(
<SeriesModal series={mockSeries} opened={true} onClose={onClose} />
);
await waitFor(() => {
const closeButton = screen.getByTestId('modal-close');
fireEvent.click(closeButton);
});
expect(onClose).toHaveBeenCalled();
});
});
describe('Helper Functions', () => {
it('should format duration with hours and minutes', () => {
const duration = 7265; // 2h 1m 5s
// This tests the formatDuration function indirectly through episode display
const episode = {
...mockEpisode,
info: { ...mockEpisode.info, duration },
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
waitFor(() => {
expect(screen.getByText(/2h 1m/)).toBeInTheDocument();
});
});
it('should format duration with minutes and seconds when under an hour', () => {
const duration = 125; // 2m 5s
const episode = {
...mockEpisode,
info: { ...mockEpisode.info, duration },
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
waitFor(() => {
expect(screen.getByText(/2m 5s/)).toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,628 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Sidebar from '../Sidebar';
import useChannelsStore from '../../store/channels';
import useSettingsStore from '../../store/settings';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import { USER_LEVELS } from '../../constants';
// Mock stores
vi.mock('../../store/channels');
vi.mock('../../store/settings');
vi.mock('../../store/auth');
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
vi.mock('../NotificationCenter', () => ({
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} />,
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} />,
Copy: ({ onClick }) => <div data-testid="copy-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} />,
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} />,
Webhook: () => <div data-testid="webhook-icon" />,
Logs: () => <div data-testid="logs-icon" />,
ChevronDown: () => <div data-testid="chevron-down-icon" />,
ChevronRight: () => <div data-testid="chevron-right-icon" />,
MonitorCog: () => <div data-testid="monitor-cog-icon" />,
Blocks: () => <div data-testid="blocks-icon" />,
}));
// Mock UserForm component
vi.mock('../forms/User', () => ({
default: ({ isOpen, onClose, user }) => (
isOpen ? (
<div data-testid="user-form">
User Form for {user?.username}
<button onClick={onClose}>Close</button>
</div>
) : null
),
}));
vi.mock('@mantine/core', async () => {
return {
Avatar: ({ children }) => <div>{children}</div>,
Group: ({ children, onClick, ...props }) => (
<div onClick={onClick} {...props}>{children}</div>
),
Stack: ({ children }) => <div>{children}</div>,
Box: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <div>{children}</div>,
UnstyledButton: ({ children, onClick, component, to, className }) => {
const Component = component || 'button';
return (
<Component onClick={onClick} to={to} className={className}>
{children}
</Component>
);
},
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
<div>
{label && <label>{label}</label>}
{leftSection}
<input value={value} onChange={onChange} />
{rightSection}
</div>
),
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
AppShellNavbar: ({ children, style, width, ...props }) => (
<nav
style={{
...style,
width: typeof width?.base === 'number' ? `${width.base}px` : width?.base
}}
{...props}
>
{children}
</nav>
),
ScrollArea: ({ children }) => <div>{children}</div>,
};
});
const mockChannels = [ 'channel-1', 'channel-2', 'channel-3' ];
const mockEnvironment = {
public_ip: '192.168.1.1',
country_code: 'US',
country_name: 'United States',
};
const mockVersion = {
version: '1.2.3',
timestamp: '20240115',
};
const mockAdminUser = {
id: 1,
username: 'admin',
first_name: 'Admin',
user_level: USER_LEVELS.ADMIN,
};
const mockRegularUser = {
id: 2,
username: 'user',
first_name: 'John',
user_level: USER_LEVELS.USER,
};
const renderSidebar = (props = {}) => {
const defaultProps = {
collapsed: false,
toggleDrawer: vi.fn(),
drawerWidth: 250,
miniDrawerWidth: 80,
};
return render(
<BrowserRouter>
<Sidebar {...defaultProps} {...props} />
</BrowserRouter>
);
};
describe('Sidebar', () => {
beforeEach(() => {
vi.clearAllMocks();
useChannelsStore.mockReturnValue(mockChannels);
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: mockVersion,
};
return selector(state);
});
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockAdminUser,
logout: vi.fn(),
};
return selector(state);
});
});
describe('Brand Section', () => {
it('should render logo and brand name when expanded', () => {
const { container } = renderSidebar();
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
const logo = container.querySelectorAll('img[src="/src/images/logo.png"]');
expect(logo).toHaveLength(1);
});
it('should hide brand name when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Dispatcharr')).not.toBeInTheDocument();
});
it('should toggle drawer when brand is clicked', () => {
const toggleDrawer = vi.fn();
renderSidebar({ toggleDrawer });
const brand = screen.getByText('Dispatcharr').closest('div');
fireEvent.click(brand);
expect(toggleDrawer).toHaveBeenCalledTimes(1);
});
});
describe('Navigation Links - Admin User', () => {
it('should render all admin navigation items', async () => {
renderSidebar();
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();
// Expand System group to access Users
const systemButton = screen.getByText('System');
fireEvent.click(systemButton);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
it('should display channel count badge', () => {
renderSidebar();
expect(screen.getByText('(3)')).toBeInTheDocument();
});
it('should hide labels and badges when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Channels')).not.toBeInTheDocument();
expect(screen.queryByText('(3)')).not.toBeInTheDocument();
});
});
describe('Navigation Links - Regular User', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockRegularUser,
logout: vi.fn(),
};
return selector(state);
});
});
it('should render limited navigation items for regular user', () => {
renderSidebar();
expect(screen.getByText('Channels')).toBeInTheDocument();
expect(screen.getByText('TV Guide')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
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();
});
});
describe('Profile Section - Authenticated', () => {
it('should render public IP with country flag', () => {
renderSidebar();
const ipInput = screen.getByDisplayValue('192.168.1.1');
expect(ipInput).toBeInTheDocument();
const flag = screen.getByAltText('United States');
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
});
it('should copy public IP to clipboard when copy button is clicked', async () => {
copyToClipboard.mockResolvedValue();
renderSidebar();
const copyButton = screen.getByTestId('copy-icon').closest('button');
fireEvent.click(copyButton);
await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith('192.168.1.1', {
successTitle: 'Success',
successMessage: 'Public IP copied to clipboard',
});
});
});
it('should render user avatar and name', () => {
renderSidebar();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('should fallback to username if first_name is not set', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: { ...mockAdminUser, first_name: null },
logout: vi.fn(),
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('admin')).toBeInTheDocument();
});
it('should open user form when username is clicked', () => {
renderSidebar();
const nameButton = screen.getByText('Admin');
fireEvent.click(nameButton);
expect(screen.getByTestId('user-form')).toBeInTheDocument();
expect(screen.getByText('User Form for admin')).toBeInTheDocument();
});
it('should close user form when close button is clicked', () => {
renderSidebar();
const nameButton = screen.getByText('Admin');
fireEvent.click(nameButton);
expect(screen.getByTestId('user-form')).toBeInTheDocument();
const closeButton = screen.getByText('Close');
fireEvent.click(closeButton);
expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
});
it('should logout when logout button is clicked', () => {
const mockLogout = vi.fn();
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockAdminUser,
logout: mockLogout,
};
return selector(state);
});
renderSidebar();
const logoutIcon = screen.getByTestId('logout-icon');
fireEvent.click(logoutIcon);
expect(mockLogout).toHaveBeenCalledTimes(1);
});
it('should hide profile details when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
});
describe('Profile Section - Not Authenticated', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
});
it('should not render profile section when not authenticated', () => {
renderSidebar();
expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
});
describe('Version Display', () => {
it('should render version when expanded', () => {
renderSidebar();
expect(screen.getByText('v1.2.3-20240115')).toBeInTheDocument();
});
it('should render version without timestamp if not available', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: { version: '1.2.3' },
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
it('should render default version if not loaded', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: null,
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
});
it('should hide version when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText(/v1.2.3-20240115/)).not.toBeInTheDocument();
});
});
describe('Collapsed State', () => {
it('should apply collapsed class to navigation links', () => {
renderSidebar({ collapsed: true });
const links = screen.getAllByRole('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 navbar = screen.getByRole('navigation');
expect(navbar).toHaveStyle({ width: '250px' });
rerender(
<BrowserRouter>
<Sidebar collapsed={true} drawerWidth={250} miniDrawerWidth={80} toggleDrawer={vi.fn()} />
</BrowserRouter>
);
expect(navbar).toHaveStyle({ width: '80px' });
});
});
describe('Environment Edge Cases', () => {
it('should handle missing country code gracefully', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: { public_ip: '192.168.1.1' },
version: mockVersion,
};
return selector(state);
});
renderSidebar();
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
expect(screen.queryByRole('img', { name: /flag/i })).not.toBeInTheDocument();
});
it('should use country code as alt text if country name is missing', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: {
public_ip: '192.168.1.1',
country_code: 'US',
},
version: mockVersion,
};
return selector(state);
});
renderSidebar();
const flag = screen.getByAltText('US');
expect(flag).toBeInTheDocument();
});
});
describe('NavGroup Component', () => {
it('should render Integrations group with children collapsed by default', () => {
renderSidebar();
expect(screen.getByText('Integrations')).toBeInTheDocument();
expect(screen.queryByText('Connections')).not.toBeInTheDocument();
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
});
it('should expand Integrations group when clicked', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
});
});
it('should collapse Integrations group when clicked again', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
// Expand
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
});
// Collapse
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.queryByText('Connections')).not.toBeInTheDocument();
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
});
});
it('should render System group with children collapsed by default', () => {
renderSidebar();
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.queryByText('Users')).not.toBeInTheDocument();
expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
});
it('should expand System group when clicked', async () => {
renderSidebar();
const systemGroup = screen.getByText('System').closest('button');
fireEvent.click(systemGroup);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
it('should hide group label when collapsed sidebar', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Integrations')).not.toBeInTheDocument();
expect(screen.queryByText('System')).not.toBeInTheDocument();
});
it('should not show multiple groups collapsed when both expanded', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
const systemGroup = screen.getByText('System').closest('button');
// Expand Integrations
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
});
// Expand System (Integrations should remain expanded)
fireEvent.click(systemGroup);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Connections')).toBeInTheDocument();
});
});
});
describe('NotificationCenter Integration', () => {
it('should render NotificationCenter when authenticated and expanded', () => {
renderSidebar();
expect(screen.getByTestId('notification-center')).toBeInTheDocument();
});
it('should render NotificationCenter when authenticated and collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.getByTestId('notification-center')).toBeInTheDocument();
});
it('should not render NotificationCenter when not authenticated', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
renderSidebar();
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
});
it('should not render NotificationCenter when not authenticated and collapsed', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
renderSidebar({ collapsed: true });
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
});
});
describe('Channel Count Badge', () => {
it('should display 0 when no channels exist', () => {
useChannelsStore.mockReturnValue({});
renderSidebar();
expect(screen.getByText('(0)')).toBeInTheDocument();
});
it('should handle null channelIds gracefully', () => {
useChannelsStore.mockReturnValue(null);
renderSidebar();
expect(screen.getByText('(0)')).toBeInTheDocument();
});
it('should handle array of channel IDs', () => {
useChannelsStore.mockReturnValue(['channel-1', 'channel-2', 'channel-3']);
renderSidebar();
expect(screen.getByText('(3)')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import SystemEvents from '../SystemEvents';
import API from '../../api';
import useLocalStorage from "../../hooks/useLocalStorage";
// Mock the API module
vi.mock('../../api', () => ({
default: {
getSystemEvents: vi.fn(),
},
}));
// Mock the useLocalStorage hook
vi.mock('../../hooks/useLocalStorage', () => ({
default: vi.fn((key, defaultValue) => {
const mockSetters = {
'events-refresh-interval': vi.fn(),
'events-limit': vi.fn(),
'date-format': vi.fn(),
};
return [defaultValue, mockSetters[key] || vi.fn()];
}),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
ActionIcon: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
Card: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
NumberInput: ({ value, onChange, label }) => (
<input
type="number"
value={value}
aria-label={label}
onChange={(e) => onChange(Number(e.target.value))}
/>
),
Pagination: ({ page, onChange, total }) => (
<div>
{Array.from({ length: Math.ceil(total / 100) }, (_, i) => (
<button key={i} onClick={() => onChange(i + 1)}>
{i + 1}
</button>
))}
</div>
),
Select: ({ value, onChange, data }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{data.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <div>{children}</div>,
Title: ({ children }) => <h1>{children}</h1>,
};
});
// Mock the dateTimeUtils
vi.mock('../../utils/dateTimeUtils.js', () => ({
format: vi.fn((timestamp, format) => '01/15 10:30:45'),
}));
const mockEventsResponse = {
events: [
{
id: 1,
event_type: 'channel_start',
event_type_display: 'Channel Started',
channel_name: 'Test Channel',
timestamp: '2024-01-15T10:30:45Z',
details: { bitrate: '5000kbps' },
},
{
id: 2,
event_type: 'login_success',
event_type_display: 'Login Successful',
timestamp: '2024-01-15T10:25:30Z',
details: {},
},
],
total: 2,
};
describe('SystemEvents', () => {
beforeEach(() => {
vi.clearAllMocks();
API.getSystemEvents.mockResolvedValue(mockEventsResponse);
});
afterEach(() => {
vi.clearAllTimers();
});
it('should render component with title', async () => {
render(<SystemEvents />);
await waitFor(() => {
expect(screen.getByText('System Events')).toBeInTheDocument();
});
});
it('should fetch and display events on mount', async () => {
render(<SystemEvents />);
await waitFor(() => {
expect(API.getSystemEvents).toHaveBeenCalledWith(100, 0);
});
});
it('should expand and show events when chevron is clicked', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Channel Started')).toBeInTheDocument();
expect(screen.getByText('Login Successful')).toBeInTheDocument();
});
});
it('should display "No events recorded yet" when events array is empty', async () => {
API.getSystemEvents.mockResolvedValue({ events: [], total: 0 });
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('No events recorded yet')).toBeInTheDocument();
});
});
it('should call fetchEvents when refresh button is clicked', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Refresh')).toBeInTheDocument();
});
const refreshButton = screen.getByText('Refresh');
fireEvent.click(refreshButton);
await waitFor(() => {
expect(API.getSystemEvents).toHaveBeenCalledTimes(3)
});
});
it('should update events limit when changed', async () => {
const mockSetEventsLimit = vi.fn();
// Update the mock to return the setter for this test
useLocalStorage.mockImplementation((key, defaultValue) => {
if (key === 'events-limit') {
return [100, mockSetEventsLimit];
}
return [defaultValue, vi.fn()];
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
const input = screen.getByLabelText('Events Per Page');
fireEvent.change(input, { target: { value: '50' } });
});
expect(mockSetEventsLimit).toHaveBeenCalled();
});
it('should show pagination when total events exceed limit', async () => {
API.getSystemEvents.mockResolvedValue({
events: mockEventsResponse.events,
total: 150,
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText(/Showing 1-100 of 150/)).toBeInTheDocument();
});
});
it('should handle API errors gracefully', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
API.getSystemEvents.mockRejectedValue(new Error('API Error'));
render(<SystemEvents />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error fetching system events:',
expect.any(Error)
);
});
consoleErrorSpy.mockRestore();
});
it('should display event details correctly', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Test Channel')).toBeInTheDocument();
expect(screen.getByText('bitrate: 5000kbps')).toBeInTheDocument();
});
});
it('should change page when pagination is clicked', async () => {
API.getSystemEvents.mockResolvedValue({
events: mockEventsResponse.events,
total: 250,
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText(/Showing 1-100 of 250/)).toBeInTheDocument();
});
// Note: Pagination interaction would require more specific selectors
// based on Mantine's Pagination component implementation
});
});

View file

@ -0,0 +1,439 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import VODModal from '../VODModal';
import useVODStore from '../../store/useVODStore';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
// Mock stores
vi.mock('../../store/useVODStore');
vi.mock('../../store/useVideoStore');
vi.mock('../../store/settings');
// Mock utils
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(() => Promise.resolve()),
}));
vi.mock('../../utils/components/SeriesModalUtils.js', () => ({
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`),
getYouTubeEmbedUrl: vi.fn((url) => `https://www.youtube.com/embed/${url}`),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>Close</button>
{children}
</div>
) : null,
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
Title: ({ children }) => <h3>{children}</h3>,
Button: ({ children, onClick, disabled, leftSection }) => (
<button onClick={onClick} disabled={disabled}>
{leftSection}
{children}
</button>
),
Badge: ({ children, component, href, ...props }) =>
component === 'a' ? (
<a href={href} {...props}>
{children}
</a>
) : (
<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}>
<option value="">{placeholder}</option>
{data.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
),
Loader: () => <div data-testid="loader">Loading...</div>,
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: () => <span>Play Icon</span>,
Copy: () => <span>Copy Icon</span>,
}));
describe('VODModal', () => {
const mockShowVideo = vi.fn();
const mockFetchMovieDetailsFromProvider = vi.fn();
const mockFetchMovieProviders = vi.fn();
const mockOnClose = vi.fn();
const mockVOD = {
id: 1,
uuid: 'test-uuid',
name: 'Test Movie',
o_name: 'Original Test Movie',
year: 2023,
duration_secs: 7200,
rating: '8.5',
age: 'PG-13',
imdb_id: 'tt1234567',
tmdb_id: '12345',
release_date: '2023-01-15',
genre: 'Action, Sci-Fi',
director: 'Test Director',
actors: 'Actor 1, Actor 2',
country: 'USA',
description: 'A test movie description',
youtube_trailer: 'test-trailer-id',
movie_image: 'https://example.com/poster.jpg',
backdrop_path: ['https://example.com/backdrop.jpg'],
bitrate: 5000,
m3u_account: { name: 'Test Account', id: 1 },
};
const mockProvider = {
id: 1,
stream_id: 'stream-123',
m3u_account: { name: 'Test Provider', id: 1 },
bitrate: 6000,
};
beforeEach(() => {
vi.clearAllMocks();
useVideoStore.mockImplementation((selector) => {
const state = { showVideo: mockShowVideo };
return selector ? selector(state) : state;
});
useVODStore.mockImplementation((selector) => {
const state = {
fetchMovieDetailsFromProvider: mockFetchMovieDetailsFromProvider,
fetchMovieProviders: mockFetchMovieProviders,
};
return selector ? selector(state) : state;
});
useSettingsStore.mockImplementation((selector) => {
const state = { environment: { env_mode: 'prod' } };
return selector ? selector(state) : state;
});
mockFetchMovieDetailsFromProvider.mockResolvedValue(mockVOD);
mockFetchMovieProviders.mockResolvedValue([mockProvider]);
});
it('should not render when vod is null', () => {
render(<VODModal vod={null} opened={true} onClose={mockOnClose} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened with vod', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
it('should not render when closed', () => {
render(<VODModal vod={mockVOD} opened={false} onClose={mockOnClose} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should display movie details correctly', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Original: Original Test Movie')).toBeInTheDocument();
});
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.getByText('8.5')).toBeInTheDocument();
expect(screen.getByText('PG-13')).toBeInTheDocument();
expect(screen.getByText('Action, Sci-Fi')).toBeInTheDocument();
expect(screen.getByText(/Test Director/)).toBeInTheDocument();
expect(screen.getByText(/Actor 1, Actor 2/)).toBeInTheDocument();
expect(screen.getByText(/A test movie description/)).toBeInTheDocument();
});
it('should fetch movie details on mount', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(mockVOD.id);
});
});
it('should fetch movie providers on mount', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalledWith(mockVOD.id);
});
});
it('should show loading state while fetching details', () => {
mockFetchMovieDetailsFromProvider.mockImplementation(
() => new Promise(() => {})
);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
expect(screen.getByText('Loading additional details...')).toBeInTheDocument();
});
it('should handle play button click', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalled();
});
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
expect(mockShowVideo).toHaveBeenCalled();
});
it('should disable play button when multiple providers and none selected', async () => {
mockFetchMovieProviders.mockResolvedValue([mockProvider, { ...mockProvider, id: 2 }]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Play Movie')).toBeInTheDocument();
});
// Note: Testing for disabled state would require checking the button's disabled attribute
});
it('should handle provider selection', async () => {
const providers = [
mockProvider,
{ ...mockProvider, id: 2, stream_id: 'stream-456' },
];
mockFetchMovieProviders.mockResolvedValue(providers);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const select = screen.getByTestId('provider-select');
expect(select).toBeInTheDocument();
});
const select = screen.getByTestId('provider-select');
fireEvent.change(select, { target: { value: '2' } });
await waitFor(() => {
const select = screen.getByTestId('provider-select');
expect(select).toHaveValue('2');
});
});
it('should display single provider as badge', async () => {
mockFetchMovieProviders.mockResolvedValue([mockProvider]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Test Provider')).toBeInTheDocument();
});
});
it('should handle fetch details error gracefully', async () => {
mockFetchMovieDetailsFromProvider.mockRejectedValue(new Error('Fetch failed'));
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalled();
});
// Should still display basic VOD info
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
it('should handle fetch providers error gracefully', async () => {
mockFetchMovieProviders.mockRejectedValue(new Error('Fetch failed'));
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalled();
});
// Should still render without providers
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should display technical details when available', async () => {
const vodWithTech = {
...mockVOD,
bitrate: 5000,
video: {
codec_name: 'h264',
width: 1920,
height: 1080,
},
audio: {
codec_name: 'aac',
channels: 2,
},
};
mockFetchMovieDetailsFromProvider.mockResolvedValue(vodWithTech);
render(<VODModal vod={vodWithTech} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText(/Technical Details:/)).toBeInTheDocument();
});
});
it('should render IMDb and TMDb badges with correct links', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
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');
});
describe('Copy Link Functionality', () => {
it('should copy movie link when copy button clicked', async () => {
const { copyToClipboard } = await import('../../utils');
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const copyButton = screen.getByText('Copy Link');
fireEvent.click(copyButton);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/movie/test-uuid'),
expect.objectContaining({
successTitle: expect.any(String),
successMessage: expect.any(String),
})
);
});
it('should include provider stream_id in copied URL when provider selected', async () => {
const { copyToClipboard } = await import('../../utils');
mockFetchMovieProviders.mockResolvedValue([
{ ...mockProvider, id: 1 },
{ ...mockProvider, id: 2, stream_id: 'stream-456' },
]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const select = screen.getByTestId('provider-select');
fireEvent.change(select, { target: { value: '2' } });
});
await waitFor(() => {
const copyButton = screen.getByText('Copy Link')
fireEvent.click(copyButton);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('stream_id=stream-456'),
expect.any(Object)
);
});
});
describe('URL Generation', () => {
it('should use dev mode URL in development', async () => {
useSettingsStore.mockImplementation((selector) => {
const state = { environment: { env_mode: 'dev' } };
return selector ? selector(state) : state;
});
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
});
expect(mockShowVideo).toHaveBeenCalledWith(
expect.stringContaining('localhost:5656'),
'vod',
expect.any(Object)
);
});
it('should use production URL in production', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
});
expect(mockShowVideo).toHaveBeenCalledWith(
expect.not.stringContaining('localhost:5656'),
'vod',
expect.any(Object)
);
});
});
describe('Modal Interaction', () => {
it('should call onClose when close button clicked', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const closeButton = screen.getByTestId('modal-close');
fireEvent.click(closeButton);
});
expect(mockOnClose).toHaveBeenCalled();
});
});
describe('Edge Cases', () => {
it('should handle VOD with no image', async () => {
const vodNoImage = { ...mockVOD, movie_image: null };
render(<VODModal vod={vodNoImage} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
it('should handle VOD with missing optional fields', async () => {
const minimalVOD = {
id: 1,
uuid: 'test-uuid',
name: 'Test Movie',
m3u_account: { name: 'Test Account', id: 1 },
};
render(<VODModal vod={minimalVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
});
});
});

View file

@ -13,20 +13,23 @@ import {
Box,
Button,
Card,
Center,
Flex,
Group,
Image,
Menu,
Modal,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, SquareX } from 'lucide-react';
import { AlertTriangle, Plus, Square, SquareX } from 'lucide-react';
import defaultLogo from '../../images/logo.png';
import RecordingSynopsis from '../RecordingSynopsis';
import {
deleteRecordingById,
deleteSeriesAndRule,
extendRecordingById,
getChannelLogoUrl,
getPosterUrl,
getRecordingUrl,
getSeasonLabel,
@ -34,15 +37,39 @@ import {
getShowVideoUrl,
removeRecording,
runComSkip,
stopRecordingById,
} from './../../utils/cards/RecordingCardUtils.js';
const areRecordingPropsEqual = (prev, next) => {
const pr = prev.recording;
const nr = next.recording;
if (!pr || !nr) return pr === nr;
const pcp = pr.custom_properties || {};
const ncp = nr.custom_properties || {};
return (
pr.id === nr.id &&
pr.start_time === nr.start_time &&
pr.end_time === nr.end_time &&
pr._group_count === nr._group_count &&
pcp.status === ncp.status &&
pcp.poster_logo_id === ncp.poster_logo_id &&
pcp.poster_url === ncp.poster_url &&
pcp.file_url === ncp.file_url &&
pcp.output_file_url === ncp.output_file_url &&
pcp.comskip?.status === ncp.comskip?.status &&
pcp.program?.title === ncp.program?.title &&
prev.channel?.id === next.channel?.id
);
};
const RecordingCard = ({
recording,
onOpenDetails,
onOpenRecurring,
channel: channelProp = null,
}) => {
const channels = useChannelsStore((s) => s.channels);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
@ -59,12 +86,11 @@ const RecordingCard = ({
const description = program.description || customProps.description || '';
const isRecurringRule = customProps?.rule?.type === 'recurring';
// Poster or channel logo
// Poster or channel logo (getPosterUrl falls back to Dispatcharr default logo)
const posterUrl = getPosterUrl(
customProps.poster_logo_id,
customProps,
channel?.logo?.cache_url,
env_mode
getChannelLogoUrl(channel)
);
const start = toUserTime(recording.start_time);
@ -73,7 +99,11 @@ const RecordingCard = ({
const status = customProps.status;
const isTimeActive = now.isAfter(start) && now.isBefore(end);
const isInterrupted = status === 'interrupted';
const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
const isInProgress =
isTimeActive &&
!isInterrupted &&
status !== 'completed' &&
status !== 'stopped';
const isUpcoming = now.isBefore(start);
const isSeriesGroup = Boolean(
recording._group_count && recording._group_count > 1
@ -88,7 +118,9 @@ const RecordingCard = ({
const handleWatchLive = () => {
if (!channel) return;
showVideo(getShowVideoUrl(channel, env_mode), 'live');
showVideo(getShowVideoUrl(channel, env_mode), 'live', {
name: channel.name,
});
};
const handleWatchRecording = () => {
@ -117,10 +149,39 @@ const RecordingCard = ({
}
};
// Cancel handling for series groups
const handleExtend = async (minutes, e) => {
e?.stopPropagation?.();
try {
await extendRecordingById(recording.id, minutes);
notifications.show({
title: 'Recording extended',
message: `Added ${minutes} minutes to this recording`,
color: 'teal',
autoClose: 2000,
});
} catch (error) {
console.error('Failed to extend recording', error);
notifications.show({
title: 'Extension failed',
message: 'Could not extend the recording',
color: 'red',
autoClose: 3000,
});
}
};
// Stop / Cancel / Delete state and handlers
const [cancelOpen, setCancelOpen] = React.useState(false);
const [stopConfirmOpen, setStopConfirmOpen] = React.useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const handleCancelClick = (e) => {
const handleStopClick = (e) => {
e.stopPropagation();
setStopConfirmOpen(true);
};
const handleDeleteClick = (e) => {
e.stopPropagation();
if (isRecurringRule) {
onOpenRecurring?.(recording, true);
@ -129,7 +190,32 @@ const RecordingCard = ({
if (isSeriesGroup) {
setCancelOpen(true);
} else {
setDeleteConfirmOpen(true);
}
};
const confirmStop = async () => {
try {
setBusy(true);
await stopRecordingById(recording.id);
} catch (error) {
console.error('Failed to stop recording', error);
} finally {
setBusy(false);
setStopConfirmOpen(false);
try {
await fetchRecordings();
} catch {}
}
};
const confirmDelete = async () => {
try {
setBusy(true);
removeRecording(recording.id);
} finally {
setBusy(false);
setDeleteConfirmOpen(false);
}
};
@ -229,6 +315,8 @@ const RecordingCard = ({
backgroundColor: isInterrupted ? '#2b1f20' : '#27272A',
borderColor: isInterrupted ? '#a33' : undefined,
height: '100%',
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
}}
onClick={handleOnMainCardClick}
@ -270,30 +358,75 @@ const RecordingCard = ({
Recurring
</Badge>
)}
{seLabel && !isSeriesGroup && (
<Badge color="gray" variant="light">
{seLabel}
</Badge>
)}
</Group>
</Stack>
</Group>
<Center>
<Tooltip label={isUpcoming || isInProgress ? 'Cancel' : 'Delete'}>
<Group gap={4}>
{isInProgress && (
<Tooltip label="Extend recording">
<Box display="inline-flex">
<Menu withinPortal position="bottom-end" shadow="md">
<Menu.Target>
<ActionIcon
variant="transparent"
color="teal.5"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Plus size={20} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Label>Extend recording by</Menu.Label>
<Menu.Item onClick={(e) => handleExtend(15, e)}>
+15 minutes
</Menu.Item>
<Menu.Item onClick={(e) => handleExtend(30, e)}>
+30 minutes
</Menu.Item>
<Menu.Item onClick={(e) => handleExtend(60, e)}>
+1 hour
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Box>
</Tooltip>
)}
{isInProgress && (
<Tooltip label="Stop recording (keep partial content)">
<ActionIcon
variant="transparent"
color="yellow.6"
onMouseDown={(e) => e.stopPropagation()}
onClick={handleStopClick}
>
<Square size="20" fill="currentColor" />
</ActionIcon>
</Tooltip>
)}
<Tooltip
label={
isInProgress
? 'Cancel & delete'
: isUpcoming
? 'Cancel'
: 'Delete'
}
>
<ActionIcon
variant="transparent"
color="red.9"
onMouseDown={(e) => e.stopPropagation()}
onClick={handleCancelClick}
onClick={handleDeleteClick}
>
<SquareX size="20" />
</ActionIcon>
</Tooltip>
</Center>
</Group>
</Flex>
<Flex gap="sm" align="center">
<Flex gap="sm" align="flex-start" style={{ flex: 1 }}>
<Image
src={posterUrl}
w={64}
@ -301,10 +434,10 @@ const RecordingCard = ({
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc="/logo.png"
fallbackSrc={getChannelLogoUrl(channel) || defaultLogo}
/>
<Stack gap={6} flex={1}>
{!isSeriesGroup && subTitle && (
<Stack gap={6} flex={1} style={{ alignSelf: 'stretch' }}>
{subTitle && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Episode
@ -314,6 +447,16 @@ const RecordingCard = ({
</Text>
</Group>
)}
{seLabel && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Season/Episode
</Text>
<Text size="sm" fw={700}>
{seLabel}
</Text>
</Group>
)}
<Group justify="space-between">
<Text size="sm" c="dimmed">
Channel
@ -346,12 +489,19 @@ const RecordingCard = ({
</Text>
)}
<Group justify="flex-end" gap="xs" pt={4}>
<Group
justify="flex-end"
gap="xs"
pt={4}
style={{ marginTop: 'auto' }}
>
{isInProgress && <WatchLive />}
{!isUpcoming && <WatchRecording />}
{!isUpcoming &&
customProps?.status === 'completed' &&
(customProps?.status === 'completed' ||
customProps?.status === 'stopped' ||
customProps?.status === 'interrupted') &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
<Button
@ -378,11 +528,89 @@ const RecordingCard = ({
)}
</Card>
);
if (!isSeriesGroup) return MainCard;
// Confirmation modals for stop and cancel/delete
const ConfirmModals = (
<>
<Modal
opened={stopConfirmOpen}
onClose={() => setStopConfirmOpen(false)}
title="Stop Recording"
centered
size="md"
zIndex={9999}
>
<Stack gap="sm">
<Text>
The recording will be stopped early. The portion already recorded
will be saved and available for playback.
</Text>
<Group justify="flex-end">
<Button
variant="default"
onClick={() => setStopConfirmOpen(false)}
disabled={busy}
>
Go Back
</Button>
<Button color="yellow" loading={busy} onClick={confirmStop}>
Stop Recording
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
title={
isInProgress || isUpcoming ? 'Cancel Recording' : 'Delete Recording'
}
centered
size="md"
zIndex={9999}
>
<Stack gap="sm">
<Text>
{isInProgress
? 'The recording will be cancelled and all recorded content will be permanently deleted.'
: isUpcoming
? 'This scheduled recording will be cancelled.'
: 'This recording and all associated files will be permanently deleted.'}
</Text>
<Group justify="flex-end">
<Button
variant="default"
onClick={() => setDeleteConfirmOpen(false)}
disabled={busy}
>
Go Back
</Button>
<Button color="red" loading={busy} onClick={confirmDelete}>
{isInProgress
? 'Cancel & Delete'
: isUpcoming
? 'Cancel'
: 'Delete'}
</Button>
</Group>
</Stack>
</Modal>
</>
);
if (!isSeriesGroup)
return (
<>
{ConfirmModals}
{MainCard}
</>
);
// Stacked look for series groups: render two shadow layers behind the main card
return (
<Box style={{ position: 'relative' }}>
{ConfirmModals}
<Modal
opened={cancelOpen}
onClose={() => setCancelOpen(false)}
@ -438,4 +666,4 @@ const RecordingCard = ({
);
};
export default RecordingCard;
export default React.memo(RecordingCard, areRecordingPropsEqual);

View file

@ -431,7 +431,7 @@ const StreamConnectionCard = ({
url = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
}
showVideo(url);
showVideo(url, 'live', { name: actualChannel.name });
};
if (location.pathname !== '/stats') {

View file

@ -1,6 +1,5 @@
import React from 'react';
import { Modal, Flex, Button } from '@mantine/core';
import useChannelsStore from '../../store/channels.jsx';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesRuleByTvgId } from '../../pages/guideUtils.js';
@ -22,11 +21,7 @@ export default function ProgramRecordingModal({
} catch (error) {
console.warn('Failed to delete recording', error);
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after delete', error);
}
// recording_cancelled WS event triggers the debounced fetchRecordings()
onClose();
};
@ -35,11 +30,7 @@ export default function ProgramRecordingModal({
tvg_id: program.tvg_id,
title: program.title,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after series delete', error);
}
// recordings_refreshed WS event triggers the debounced fetchRecordings()
onClose();
};

View file

@ -5,7 +5,9 @@ import {
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React from 'react';
import { Pencil, RefreshCcw, Check, X } from 'lucide-react';
import {
ActionIcon,
Badge,
Button,
Card,
@ -15,11 +17,15 @@ import {
Modal,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import useVideoStore from '../../store/useVideoStore.jsx';
import { notifications } from '@mantine/notifications';
import defaultLogo from '../../images/logo.png';
import {
deleteRecordingById,
getChannelLogoUrl,
getPosterUrl,
getRecordingUrl,
getSeasonLabel,
@ -52,11 +58,56 @@ const RecordingDetailsModal = ({
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const safeRecording = recording || {};
const [editing, setEditing] = React.useState(false);
// Prefer the store version of the recording for live updates
// (e.g., after artwork refresh or metadata edit via WebSocket).
// Preserve _group_count from the categorized prop the store version
// doesn't carry this client-side field, so without merging it back
// isSeriesGroup would always be false and the episode list hidden.
const safeRecording = React.useMemo(() => {
if (recording?.id && Array.isArray(allRecordings)) {
const found = allRecordings.find((r) => r.id === recording.id);
if (found) {
if (recording._group_count != null) {
return { ...found, _group_count: recording._group_count };
}
return found;
}
}
return recording || {};
}, [allRecordings, recording]);
const customProps = safeRecording.custom_properties || {};
const program = customProps.program || {};
const recordingName = program.title || 'Custom Recording';
const description = program.description || customProps.description || '';
// Derive poster URL from live store data instead of the stale prop snapshot.
const livePosterUrl = React.useMemo(
() =>
getPosterUrl(
customProps.poster_logo_id,
customProps,
getChannelLogoUrl(channel)
),
[customProps.poster_logo_id, customProps, channel]
);
// Optimistic overrides show saved values immediately without waiting
// for the WebSocket round-trip to refresh the store.
const [savedTitle, setSavedTitle] = React.useState(null);
const [savedDescription, setSavedDescription] = React.useState(null);
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
const description =
savedDescription ?? (program.description || customProps.description || '');
const [editTitle, setEditTitle] = React.useState('');
const [editDescription, setEditDescription] = React.useState('');
// Reset optimistic state when the recording changes
React.useEffect(() => {
setSavedTitle(null);
setSavedDescription(null);
setEditing(false);
}, [recording?.id]);
const start = toUserTime(safeRecording.start_time);
const end = toUserTime(safeRecording.end_time);
const stats = customProps.stream_info || {};
@ -70,6 +121,7 @@ const RecordingDetailsModal = ({
const fileUrl = customProps.file_url || customProps.output_file_url;
const canWatchRecording =
(customProps.status === 'completed' ||
customProps.status === 'stopped' ||
customProps.status === 'interrupted') &&
Boolean(fileUrl);
@ -140,7 +192,9 @@ const RecordingDetailsModal = ({
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
if (!ch) return;
useVideoStore.getState().showVideo(getShowVideoUrl(ch, env_mode), 'live');
useVideoStore
.getState()
.showVideo(getShowVideoUrl(ch, env_mode), 'live', { name: ch.name });
}
};
@ -157,12 +211,55 @@ const RecordingDetailsModal = ({
url: getPosterUrl(
childRec.custom_properties?.poster_logo_id,
undefined,
ch?.logo?.cache_url
getChannelLogoUrl(ch)
),
},
});
};
const startEditing = () => {
setEditTitle(recordingName === 'Custom Recording' ? '' : recordingName);
setEditDescription(description);
setEditing(true);
};
const cancelEditing = () => setEditing(false);
const saveMetadata = async () => {
try {
await API.updateRecordingMetadata(recording.id, {
title: editTitle || 'Custom Recording',
description: editDescription,
});
setSavedTitle(editTitle || 'Custom Recording');
setSavedDescription(editDescription);
setEditing(false);
notifications.show({
title: 'Saved',
message: 'Recording metadata updated',
color: 'green',
autoClose: 2000,
});
} catch (error) {
console.error('Failed to save metadata', error);
}
};
const handleRefreshArtwork = async (e) => {
e.stopPropagation?.();
try {
await API.refreshArtwork(recording.id);
notifications.show({
title: 'Refreshing artwork',
message: 'Poster resolution started',
color: 'blue.5',
autoClose: 2000,
});
} catch (error) {
console.error('Failed to refresh artwork', error);
}
};
const handleRunComskip = async (e) => {
e.stopPropagation?.();
try {
@ -191,7 +288,10 @@ const RecordingDetailsModal = ({
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
const purl = getPosterUrl(posterLogoId, cp, posterUrl);
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
const epChannel =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
const onRemove = async (e) => {
e?.stopPropagation?.();
@ -200,11 +300,7 @@ const RecordingDetailsModal = ({
} catch (error) {
console.error('Failed to delete upcoming recording', error);
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.error('Failed to refresh recordings after delete', error);
}
// recording_cancelled WS event triggers the debounced fetchRecordings()
};
const handleOnMainCardClick = () => {
@ -228,7 +324,7 @@ const RecordingDetailsModal = ({
fit="contain"
radius="sm"
alt={pr.title || recordingName}
fallbackSrc="/logo.png"
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
/>
<Stack gap={4} flex={1}>
<Group justify="space-between">
@ -328,7 +424,7 @@ const RecordingDetailsModal = ({
posterUrl={getPosterUrl(
childRec.custom_properties?.poster_logo_id,
childRec.custom_properties,
channelsById[childRec.channel]?.logo?.cache_url
getChannelLogoUrl(channelsById[childRec.channel])
)}
env_mode={env_mode}
onWatchLive={handleOnWatchLive}
@ -342,15 +438,27 @@ const RecordingDetailsModal = ({
const Movie = () => {
return (
<Flex gap="lg" align="flex-start">
<Image
src={posterUrl}
w={180}
h={240}
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc="/logo.png"
/>
<Stack gap={4} align="center">
<Image
src={livePosterUrl}
w={180}
h={240}
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc={getChannelLogoUrl(channel) || defaultLogo}
/>
<Button
size="compact-xs"
variant="subtle"
color="dimmed"
leftSection={<RefreshCcw size={12} />}
onClick={handleRefreshArtwork}
styles={{ root: { fontWeight: 400 } }}
>
Refresh artwork
</Button>
</Stack>
<Stack gap={8} style={{ flex: 1 }}>
<Group justify="space-between" align="center">
<Text c="dimmed" size="sm">
@ -360,7 +468,9 @@ const RecordingDetailsModal = ({
{onWatchLive && <WatchLive />}
{onWatchRecording && <WatchRecording />}
{onEdit && start.isAfter(userNow()) && <Edit />}
{customProps.status === 'completed' &&
{(customProps.status === 'completed' ||
customProps.status === 'stopped' ||
customProps.status === 'interrupted') &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
<Button
@ -385,11 +495,20 @@ const RecordingDetailsModal = ({
</Badge>
</Group>
)}
{description && (
{editing ? (
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.currentTarget.value)}
placeholder="Description (optional)"
size="sm"
minRows={2}
autosize
/>
) : description ? (
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{description}
</Text>
)}
) : null}
{statRows.length > 0 && (
<Stack gap={4} pt={6}>
<Text fw={600} size="sm">
@ -415,9 +534,58 @@ const RecordingDetailsModal = ({
opened={opened}
onClose={onClose}
title={
isSeriesGroup
? `Series: ${recordingName}`
: `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`
editing ? (
<Group gap={8} align="center" wrap="nowrap" style={{ flex: 1 }}>
<TextInput
value={editTitle}
onChange={(e) => setEditTitle(e.currentTarget.value)}
placeholder="Recording title"
size="sm"
style={{ flex: 1 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') saveMetadata();
if (e.key === 'Escape') cancelEditing();
}}
/>
<ActionIcon
size="sm"
variant="subtle"
color="green"
onClick={saveMetadata}
>
<Check size={14} />
</ActionIcon>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={cancelEditing}
>
<X size={14} />
</ActionIcon>
</Group>
) : (
<Group gap={8} align="center">
<span>
{isSeriesGroup
? `Series: ${recordingName}`
: savedTitle
? recordingName
: `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`}
</span>
{!isSeriesGroup && (
<ActionIcon
size="sm"
variant="subtle"
color="dimmed"
onClick={startEditing}
>
<Pencil size={14} />
</ActionIcon>
)}
</Group>
)
}
size="lg"
centered
@ -430,7 +598,7 @@ const RecordingDetailsModal = ({
title: { color: 'white' },
}}
>
{isSeriesGroup ? <Series /> : <Movie />}
{isSeriesGroup ? Series() : Movie()}
</Modal>
);
};

View file

@ -33,11 +33,10 @@ import {
updateRecurringRuleEnabled,
} from '../../utils/forms/RecurringRuleModalUtils.js';
const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
const RecurringRuleModal = ({ opened, onClose, ruleId, recording: sourceRecording, onEditOccurrence }) => {
const channels = useChannelsStore((s) => s.channels);
const recurringRules = useChannelsStore((s) => s.recurringRules);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const recordings = useChannelsStore((s) => s.recordings);
const { toUserTime, userNow } = useTimeHelpers();
const { timeFormat: timeformat, dateFormat: dateformat } =
@ -125,7 +124,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
setSaving(true);
try {
await updateRecurringRule(ruleId, values);
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
title: 'Recurring rule updated',
message: 'Schedule adjustments saved',
@ -145,7 +144,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
setDeleting(true);
try {
await deleteRecurringRuleById(ruleId);
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
title: 'Recurring rule removed',
message: 'All future occurrences were cancelled',
@ -165,7 +164,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
setSaving(true);
try {
await updateRecurringRuleEnabled(ruleId, checked);
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
message: checked
@ -186,7 +185,7 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
setBusyOccurrence(occurrence.id);
try {
await deleteRecordingById(occurrence.id);
await fetchRecordings();
// recording_cancelled WS event handles recording list update
notifications.show({
title: 'Occurrence cancelled',
message: 'The selected airing was removed',
@ -203,7 +202,45 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
if (!rule) {
return (
<Modal opened={opened} onClose={onClose} title="Recurring Rule" centered>
<Text size="sm">Recurring rule not found.</Text>
<Stack gap="md">
<Text size="sm">
The recurring rule for this recording no longer exists.
</Text>
{sourceRecording && (
<>
<Text size="sm" c="dimmed">
Would you like to delete this recording?
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="red"
loading={deleting}
onClick={async () => {
setDeleting(true);
try {
await deleteRecordingById(sourceRecording.id);
notifications.show({
title: 'Recording deleted',
color: 'green',
autoClose: 2500,
});
onClose();
} catch (e) {
console.error('Failed to delete orphaned recording', e);
} finally {
setDeleting(false);
}
}}
>
Delete Recording
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
}

View file

@ -0,0 +1,29 @@
import { Box, Modal } from '@mantine/core';
import React from 'react';
export const YouTubeTrailerModal = ({ opened, onClose, trailerUrl }) => {
return (
<Modal opened={opened} onClose={onClose} title="Trailer" size="xl" centered>
<Box pos="relative" pb={'56.25%'} h={0}>
{trailerUrl && (
<iframe
src={trailerUrl}
title="YouTube Trailer"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
}}
/>
)}
</Box>
</Modal>
);
};

View file

@ -0,0 +1,109 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { YouTubeTrailerModal } from '../YouTubeTrailerModal';
// Mock Mantine components
vi.mock('@mantine/core', () => ({
Modal: ({ opened, onClose, title, children, size, centered }) => {
if (!opened) return null;
return (
<div
data-testid="modal"
data-title={title}
data-size={size}
data-centered={centered}
>
<button onClick={onClose} data-testid="modal-close">Close</button>
<div>{children}</div>
</div>
);
},
Box: ({ children, ...props }) => (
<div data-testid="box" {...props}>{children}</div>
),
}));
describe('YouTubeTrailerModal', () => {
const mockTrailerUrl = 'https://www.youtube.com/embed/dQw4w9WgXcQ';
const mockOnClose = vi.fn();
it('should not render when opened is false', () => {
render(
<YouTubeTrailerModal
opened={false}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened is true', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should display correct modal title', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
const modal = screen.getByTestId('modal');
expect(modal).toHaveAttribute('data-title', 'Trailer');
});
it('should render iframe with correct trailerUrl', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
const iframe = screen.getByTitle('YouTube Trailer');
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('src', mockTrailerUrl);
});
it('should not render iframe when trailerUrl is null', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={null}
/>
);
expect(screen.queryByTitle('YouTube Trailer')).not.toBeInTheDocument();
});
it('should call onClose when close button clicked', () => {
const mockClose = vi.fn();
render(
<YouTubeTrailerModal
opened={true}
onClose={mockClose}
trailerUrl={mockTrailerUrl}
/>
);
const closeButton = screen.getByTestId('modal-close');
closeButton.click();
expect(mockClose).toHaveBeenCalledOnce();
});
});

View file

@ -140,12 +140,12 @@ const ChannelStreams = ({ channel, isExpanded }) => {
const authUser = useAuthStore((s) => s.user);
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
function handleWatchStream(streamHash) {
function handleWatchStream(streamHash, streamName) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
showVideo(vidUrl, 'live', streamName ? { name: streamName } : null);
}
const [data, setData] = useState(channelStreams || []);
@ -390,7 +390,10 @@ const ChannelStreams = ({ channel, isExpanded }) => {
color="blue"
variant="light"
onClick={() =>
handleWatchStream(stream.stream_hash || stream.id)
handleWatchStream(
stream.stream_hash || stream.id,
stream.name
)
}
style={{ marginLeft: 2 }}
>

View file

@ -693,7 +693,7 @@ const ChannelsTable = ({ onReady }) => {
);
const url = getChannelURL(channel);
console.log(`Stream URL: ${url}`);
showVideo(url);
showVideo(url, 'live', { name: channel.name });
};
const onRowSelectionChange = (newSelection) => {

View file

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

View file

@ -139,6 +139,21 @@ const M3UTable = () => {
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
const editPlaylistId = usePlaylistsStore((s) => s.editPlaylistId);
const setEditPlaylistId = usePlaylistsStore((s) => s.setEditPlaylistId);
// Memoize data to prevent unnecessary re-renders during progress updates
const processedData = useMemo(() => {
return playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
// First sort by active status (active items first)
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1;
}
// Then sort by name (case-insensitive)
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}, [playlists]);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@ -640,18 +655,7 @@ const M3UTable = () => {
// Listen for edit playlist requests from notifications
useEffect(() => {
setData(
playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
// First sort by active status (active items first)
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1;
}
// Then sort by name (case-insensitive)
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
})
);
setData(processedData);
if (editPlaylistId) {
const playlistToEdit = playlists.find((p) => p.id === editPlaylistId);
@ -661,7 +665,7 @@ const M3UTable = () => {
setEditPlaylistId(null);
}
}
}, [editPlaylistId, playlists]);
}, [editPlaylistId, processedData, playlists, setEditPlaylistId]);
const onSortingChange = (column) => {
console.log(column);

View file

@ -112,7 +112,7 @@ const StreamRowActions = ({
'Hash:',
row.original.stream_hash
);
handleWatchStream(row.original.stream_hash);
handleWatchStream(row.original.stream_hash, row.original.name);
}, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream
const iconSize =
@ -1012,12 +1012,12 @@ const StreamsTable = ({ onReady }) => {
});
};
function handleWatchStream(streamHash) {
function handleWatchStream(streamHash, streamName) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode == 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
showVideo(vidUrl, 'live', streamName ? { name: streamName } : null);
}
const onSortingChange = (column) => {

View file

@ -1,16 +1,20 @@
import React, { useMemo, useState, useEffect, lazy, Suspense } from 'react';
import {
ActionIcon,
Box,
Button,
Badge,
Flex,
Group,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
useMantineTheme,
} from '@mantine/core';
import { SquarePlus } from 'lucide-react';
import { Search, SquarePlus, X } from 'lucide-react';
import useChannelsStore from '../store/channels';
import API from '../api';
import useSettingsStore from '../store/settings';
@ -22,14 +26,26 @@ const RecordingDetailsModal = lazy(
);
import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
import RecordingCard from '../components/cards/RecordingCard.jsx';
import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
import {
categorizeRecordings,
filterRecordings,
buildChannelOptions,
} from '../utils/pages/DVRUtils.js';
import {
getChannelLogoUrl,
getPosterUrl,
getRecordingUrl,
getShowVideoUrl,
} from '../utils/cards/RecordingCardUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const STATUS_OPTIONS = [
{ value: 'recording', label: 'Recording' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'completed', label: 'Completed' },
{ value: 'interrupted', label: 'Interrupted' },
];
const RecordingList = ({
list,
onOpenDetails,
@ -61,6 +77,11 @@ const DVRPage = () => {
const [ruleModal, setRuleModal] = useState({ open: false, ruleId: null });
const [editRecording, setEditRecording] = useState(null);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [selectedChannelId, setSelectedChannelId] = useState(null);
const [selectedStatus, setSelectedStatus] = useState(null);
const openRecordingModal = () => {
setRecordingModalOpen(true);
};
@ -75,7 +96,7 @@ const DVRPage = () => {
};
const closeDetails = () => setDetailsOpen(false);
const openRuleModal = (recording) => {
const openRuleModal = (recording, isDelete) => {
const ruleId = recording?.custom_properties?.rule?.id;
if (!ruleId) {
openDetails(recording);
@ -84,7 +105,12 @@ const DVRPage = () => {
setDetailsOpen(false);
setDetailsRecording(null);
setEditRecording(null);
setRuleModal({ open: true, ruleId });
setRuleModal({
open: true,
ruleId,
recording,
isDelete: isDelete || false,
});
};
const closeRuleModal = () => setRuleModal({ open: false, ruleId: null });
@ -129,6 +155,52 @@ const DVRPage = () => {
return categorizeRecordings(recordings, toUserTime, now);
}, [recordings, now, toUserTime]);
// Channel options for filter dropdown (from raw unfiltered data)
const channelOptions = useMemo(() => {
return buildChannelOptions(channelsById, inProgress, upcoming, completed);
}, [channelsById, inProgress, upcoming, completed]);
// Filtered buckets
const hasActiveFilters =
searchQuery !== '' || selectedChannelId !== null || selectedStatus !== null;
const filteredInProgress = useMemo(() => {
if (selectedStatus && selectedStatus !== 'recording') return [];
return filterRecordings(inProgress, searchQuery, selectedChannelId);
}, [inProgress, searchQuery, selectedChannelId, selectedStatus]);
const filteredUpcoming = useMemo(() => {
if (selectedStatus && selectedStatus !== 'scheduled') return [];
return filterRecordings(upcoming, searchQuery, selectedChannelId);
}, [upcoming, searchQuery, selectedChannelId, selectedStatus]);
const filteredCompleted = useMemo(() => {
if (
selectedStatus &&
!['completed', 'interrupted'].includes(selectedStatus)
)
return [];
let filtered = filterRecordings(completed, searchQuery, selectedChannelId);
if (selectedStatus === 'interrupted') {
filtered = filtered.filter(
(rec) => rec.custom_properties?.status === 'interrupted'
);
} else if (selectedStatus === 'completed') {
// "Completed" includes both completed and stopped recordings
filtered = filtered.filter(
(rec) => rec.custom_properties?.status !== 'interrupted'
);
}
return filtered;
}, [completed, searchQuery, selectedChannelId, selectedStatus]);
// Filter handlers
const clearFilters = () => {
setSearchQuery('');
setSelectedChannelId(null);
setSelectedStatus(null);
};
const handleOnWatchLive = () => {
const rec = detailsRecording;
const now = userNow();
@ -142,7 +214,7 @@ const DVRPage = () => {
channel,
useSettingsStore.getState().environment.env_mode
);
useVideoStore.getState().showVideo(url, 'live');
useVideoStore.getState().showVideo(url, 'live', { name: channel.name });
}
};
@ -158,33 +230,84 @@ const DVRPage = () => {
url: getPosterUrl(
detailsRecording.custom_properties?.poster_logo_id,
undefined,
channelsById[detailsRecording.channel]?.logo?.cache_url
getChannelLogoUrl(channelsById[detailsRecording.channel])
),
},
});
};
return (
<Box p={10}>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="sm"
onClick={openRecordingModal}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
New Recording
</Button>
<Stack gap="lg" pt={12}>
<Flex gap="md" align="center" wrap="wrap" mb={12}>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="sm"
onClick={openRecordingModal}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
New Recording
</Button>
<TextInput
placeholder="Search recordings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
w={'250px'}
leftSection={<Search size={16} />}
rightSection={
searchQuery ? (
<ActionIcon
onClick={() => setSearchQuery('')}
variant="subtle"
color="gray"
size="sm"
>
<X size={14} />
</ActionIcon>
) : null
}
/>
<Select
placeholder="Filter by channel"
data={channelOptions}
value={selectedChannelId}
onChange={setSelectedChannelId}
w={'220px'}
clearable
searchable
/>
<Select
placeholder="Filter by status"
data={STATUS_OPTIONS}
value={selectedStatus}
onChange={setSelectedStatus}
w={'180px'}
clearable
/>
{hasActiveFilters && (
<Button variant="subtle" onClick={clearFilters} size="sm">
Clear Filters
</Button>
)}
</Flex>
<Stack gap="lg">
<div>
<Group justify="space-between" mb={8}>
<Title order={4}>Currently Recording</Title>
<Badge color="red.6">{inProgress.length}</Badge>
<Badge color="red.6">
{hasActiveFilters
? `${filteredInProgress.length} / ${inProgress.length}`
: inProgress.length}
</Badge>
</Group>
<SimpleGrid
cols={3}
@ -196,15 +319,17 @@ const DVRPage = () => {
>
{
<RecordingList
list={inProgress}
list={filteredInProgress}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
channelsById={channelsById}
/>
}
{inProgress.length === 0 && (
{filteredInProgress.length === 0 && (
<Text size="sm" c="dimmed">
Nothing recording right now.
{hasActiveFilters
? 'No recordings match your filters.'
: 'Nothing recording right now.'}
</Text>
)}
</SimpleGrid>
@ -213,7 +338,11 @@ const DVRPage = () => {
<div>
<Group justify="space-between" mb={8}>
<Title order={4}>Upcoming Recordings</Title>
<Badge color="yellow.6">{upcoming.length}</Badge>
<Badge color="yellow.6">
{hasActiveFilters
? `${filteredUpcoming.length} / ${upcoming.length}`
: upcoming.length}
</Badge>
</Group>
<SimpleGrid
cols={3}
@ -225,15 +354,17 @@ const DVRPage = () => {
>
{
<RecordingList
list={upcoming}
list={filteredUpcoming}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
channelsById={channelsById}
/>
}
{upcoming.length === 0 && (
{filteredUpcoming.length === 0 && (
<Text size="sm" c="dimmed">
No upcoming recordings.
{hasActiveFilters
? 'No recordings match your filters.'
: 'No upcoming recordings.'}
</Text>
)}
</SimpleGrid>
@ -242,7 +373,11 @@ const DVRPage = () => {
<div>
<Group justify="space-between" mb={8}>
<Title order={4}>Previously Recorded</Title>
<Badge color="gray.6">{completed.length}</Badge>
<Badge color="gray.6">
{hasActiveFilters
? `${filteredCompleted.length} / ${completed.length}`
: completed.length}
</Badge>
</Group>
<SimpleGrid
cols={3}
@ -254,15 +389,17 @@ const DVRPage = () => {
>
{
<RecordingList
list={completed}
list={filteredCompleted}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
channelsById={channelsById}
/>
}
{completed.length === 0 && (
{filteredCompleted.length === 0 && (
<Text size="sm" c="dimmed">
No completed recordings yet.
{hasActiveFilters
? 'No recordings match your filters.'
: 'No completed recordings yet.'}
</Text>
)}
</SimpleGrid>
@ -284,6 +421,7 @@ const DVRPage = () => {
opened={ruleModal.open}
onClose={closeRuleModal}
ruleId={ruleModal.ruleId}
recording={ruleModal.recording}
onEditOccurrence={(occ) => {
setRuleModal({ open: false, ruleId: null });
setEditRecording(occ);
@ -302,7 +440,7 @@ const DVRPage = () => {
posterUrl={getPosterUrl(
detailsRecording.custom_properties?.poster_logo_id,
detailsRecording.custom_properties,
channelsById[detailsRecording.channel]?.logo?.cache_url
getChannelLogoUrl(channelsById[detailsRecording.channel])
)}
env_mode={useSettingsStore.getState().environment.env_mode}
onWatchLive={handleOnWatchLive}

View file

@ -93,7 +93,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
const recordings = useChannelsStore((s) => s.recordings);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const profiles = useChannelsStore((s) => s.profiles);
const isLoading = useChannelsStore((s) => s.isLoading);
const [isProgramsLoading, setIsProgramsLoading] = useState(true);
const logos = useLogosStore((s) => s.logos);
@ -699,14 +698,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const saveSeriesRule = useCallback(async (program, mode) => {
await createSeriesRule(program, mode);
await evaluateSeriesRule(program);
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn(
'Failed to refresh recordings after saving series rule',
error
);
}
// recordings_refreshed WS event triggers the debounced fetchRecordings()
showNotification({
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
});
@ -732,7 +724,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
showVideo(getShowVideoUrl(matched, env_mode));
showVideo(getShowVideoUrl(matched, env_mode), 'live', {
name: matched.name,
});
},
[env_mode, findChannelByTvgId, showVideo]
);
@ -741,7 +735,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
(channel, event) => {
event.stopPropagation();
showVideo(getShowVideoUrl(channel, env_mode));
showVideo(getShowVideoUrl(channel, env_mode), 'live', {
name: channel.name,
});
},
[env_mode, showVideo]
);
@ -1350,9 +1346,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
pos="relative"
>
<LoadingOverlay
visible={isLoading || isProgramsLoading || isChannelsLoading}
/>
<LoadingOverlay visible={isProgramsLoading || isChannelsLoading} />
{nowPosition >= 0 && (
<Box
ref={nowLineRef}

View file

@ -33,10 +33,39 @@ vi.mock('../../api');
// Mock Mantine components
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick }) => (
<button data-testid="action-icon" onClick={onClick}>
{children}
</button>
),
Box: ({ children }) => <div data-testid="box">{children}</div>,
Container: ({ children }) => <div data-testid="container">{children}</div>,
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
Title: ({ children, order }) => <h1 data-order={order}>{children}</h1>,
Text: ({ children }) => <p>{children}</p>,
TextInput: ({ placeholder, value, onChange, ...props }) => (
<input
data-testid="text-input"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
),
Select: ({ placeholder, data, value, onChange, ...props }) => (
<select
data-testid="select"
value={value || ''}
onChange={(e) => onChange(e.target.value || null)}
aria-label={placeholder}
>
<option value="">{placeholder}</option>
{(data || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
Button: ({ children, onClick, leftSection, loading, ...props }) => (
<button onClick={onClick} disabled={loading} {...props}>
{leftSection}
@ -128,11 +157,15 @@ vi.mock('../../utils/dateTimeUtils.js', async (importActual) => {
useTimeHelpers: vi.fn(),
};
});
vi.mock('../../utils/cards/RecordingCardUtils.js', () => ({
getPosterUrl: vi.fn(),
getRecordingUrl: vi.fn(),
getShowVideoUrl: vi.fn(),
}));
vi.mock('../../utils/cards/RecordingCardUtils.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getPosterUrl: vi.fn(),
getRecordingUrl: vi.fn(),
getShowVideoUrl: vi.fn(),
};
});
vi.mock('../../utils/pages/DVRUtils.js', async (importActual) => {
const actual = await importActual();
return {
@ -513,7 +546,8 @@ describe('DVRPage', () => {
expect(mockShowVideo).toHaveBeenCalledWith(
expect.stringContaining('stream.url'),
'live'
'live',
{ name: 'Channel 1' }
);
});

View file

@ -21,7 +21,7 @@ describe('UsersPage', () => {
const { container } = render(<UsersPage />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
expect(screen.queryByTestId('users-table')).not.toBeInTheDocument();
});
@ -39,7 +39,7 @@ describe('UsersPage', () => {
const { container } = render(<UsersPage />);
// id: 0 is falsy, so should render empty
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
it('switches from unauthenticated to authenticated state', () => {
@ -47,7 +47,7 @@ describe('UsersPage', () => {
render(<UsersPage />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
useAuthStore.mockReturnValue({ id: 1 });

View file

@ -705,6 +705,23 @@ describe('guideUtils', () => {
expect(result.size).toBe(0);
});
it('should exclude terminal status recordings', () => {
const recordings = [
{ id: 1, custom_properties: { program: { id: 'p1' }, status: 'completed' } },
{ id: 2, custom_properties: { program: { id: 'p2' }, status: 'stopped' } },
{ id: 3, custom_properties: { program: { id: 'p3' }, status: 'interrupted' } },
{ id: 4, custom_properties: { program: { id: 'p4' }, status: 'failed' } },
{ id: 5, custom_properties: { program: { id: 'p5' }, status: 'recording' } },
{ id: 6, custom_properties: { program: { id: 'p6' } } },
];
const result = guideUtils.mapRecordingsByProgramId(recordings);
expect(result.size).toBe(2);
expect(result.get('p5').id).toBe(5);
expect(result.get('p6').id).toBe(6);
});
});
describe('formatTime', () => {

View file

@ -214,11 +214,15 @@ export const mapChannelsById = (guideChannels) => {
return map;
};
const _terminalStatuses = new Set(['stopped', 'completed', 'interrupted', 'failed']);
export const mapRecordingsByProgramId = (recordings) => {
const map = new Map();
(recordings || []).forEach((recording) => {
const programId = recording?.custom_properties?.program?.id;
if (programId != null) {
const status = recording?.custom_properties?.status;
// Only show indicator for pending/active recordings, not terminal ones
if (programId != null && !_terminalStatuses.has(status)) {
map.set(programId, recording);
}
});

View file

@ -459,15 +459,10 @@ const useChannelsStore = create((set, get) => ({
},
fetchRecordings: async () => {
set({ isLoading: true, error: null });
try {
set({
recordings: await api.getRecordings(),
isLoading: false,
});
set({ recordings: await api.getRecordings() });
} catch (error) {
console.error('Failed to fetch recordings:', error);
set({ error: 'Failed to load recordings.', isLoading: false });
}
},
@ -494,11 +489,16 @@ const useChannelsStore = create((set, get) => ({
const target = String(id);
const current = state.recordings;
if (Array.isArray(current)) {
// Early return if item doesn't exist avoids a new array reference
// (and thus a needless re-render) when called redundantly, e.g. both
// the optimistic API delete and the WS recording_cancelled handler.
if (!current.some((r) => String(r?.id) === target)) return {};
return {
recordings: current.filter((r) => String(r?.id) !== target),
};
}
if (current && typeof current === 'object') {
if (!Object.values(current).some((r) => String(r?.id) === target)) return {};
const next = { ...current };
for (const k of Object.keys(next)) {
try {

View file

@ -1,5 +1,6 @@
import API from '../../api.js';
import useChannelsStore from '../../store/channels.jsx';
import defaultLogo from '../../images/logo.png';
export const removeRecording = (id) => {
// Optimistically remove immediately from UI
@ -19,20 +20,30 @@ export const removeRecording = (id) => {
});
};
/**
* Resolve the channel logo cache URL from either a full channel object
* (has logo.cache_url) or a summary object (has logo_id integer).
*/
export const getChannelLogoUrl = (channel) => {
if (!channel) return null;
if (channel.logo_id) return `/api/channels/logos/${channel.logo_id}/cache/`;
return channel.logo?.cache_url || null;
};
export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => {
let purl = posterLogoId
? `/api/channels/logos/${posterLogoId}/cache/`
: customProperties?.poster_url || posterUrl || '/logo.png';
: customProperties?.poster_url || posterUrl || null;
if (
purl &&
typeof import.meta !== 'undefined' &&
import.meta.env &&
import.meta.env.DEV &&
purl &&
purl.startsWith('/')
) {
purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
}
return purl;
return purl || defaultLogo;
};
export const getShowVideoUrl = (channel, env_mode) => {
@ -51,6 +62,14 @@ export const deleteRecordingById = async (recordingId) => {
await API.deleteRecording(recordingId);
};
export const stopRecordingById = async (recordingId) => {
await API.stopRecording(recordingId);
};
export const extendRecordingById = async (recordingId, extraMinutes) => {
await API.extendRecording(recordingId, extraMinutes);
};
export const deleteSeriesAndRule = async (seriesInfo) => {
const { tvg_id, title } = seriesInfo;
if (tvg_id) {

View file

@ -126,7 +126,9 @@ describe('RecordingCardUtils', () => {
vi.stubEnv('DEV', false);
const result = getPosterUrl(null, {}, '');
expect(result).toBe('/logo.png');
// Falls back to the imported default Dispatcharr logo asset
expect(result).toBeTruthy();
expect(result).toContain('logo');
});
it('prepends dev server URL in dev mode for relative paths', () => {

View file

@ -0,0 +1,124 @@
export const getLivePlayerErrorMessage = (errorType, errorDetail) => {
if (errorType !== 'MediaError') {
return errorDetail
? `Error: ${errorType} - ${errorDetail}`
: `Error: ${errorType}`;
}
const errorString = errorDetail?.toLowerCase() || '';
if (
errorString.includes('audio') ||
errorString.includes('ac3') ||
errorString.includes('ac-3')
) {
return 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.';
}
if (
errorString.includes('video') ||
errorString.includes('h264') ||
errorString.includes('h.264')
) {
return 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.';
}
if (errorString.includes('mse')) {
return "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
}
return 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.';
};
export const getVODPlayerErrorMessage = (error) => {
if (!error) return 'Video playback error';
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
return 'Video playback was aborted';
case error.MEDIA_ERR_NETWORK:
return 'Network error while loading video';
case error.MEDIA_ERR_DECODE:
return 'Video codec not supported by your browser';
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
return 'Video format not supported by your browser';
default:
return error.message || 'Unknown video error';
}
};
export const getClientCoordinates = (event) => ({
clientX: event.touches?.[0]?.clientX ?? event.clientX,
clientY: event.touches?.[0]?.clientY ?? event.clientY,
});
export const calculateNewDimensions = (
deltaX,
deltaY,
startWidth,
startHeight,
handle,
ratio
) => {
const widthDelta = deltaX * handle.xDir;
const heightDelta = deltaY * handle.yDir;
let width = startWidth + widthDelta;
let height = width / ratio;
// Use vertical-driven resize if user drags mostly vertically
if (Math.abs(deltaY) > Math.abs(deltaX)) {
height = startHeight + heightDelta;
width = height * ratio;
}
return { width, height };
};
export const applyConstraints = (
width,
height,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
) => {
// Apply minimum constraints
if (width < minWidth) {
width = minWidth;
height = width / ratio;
}
if (height < minHeight) {
height = minHeight;
width = height * ratio;
}
// Apply viewport constraints
const posX = startPos?.x ?? 0;
const posY = startPos?.y ?? 0;
const maxWidth = !handle.isLeft
? Math.max(minWidth, window.innerWidth - posX - visibleMargin)
: null;
const maxHeight = !handle.isTop
? Math.max(minHeight, window.innerHeight - posY - visibleMargin)
: null;
if (maxWidth && width > maxWidth) {
width = maxWidth;
height = width / ratio;
}
if (maxHeight && height > maxHeight) {
height = maxHeight;
width = height * ratio;
}
// Final adjustment to maintain aspect ratio
if (maxWidth && width > maxWidth) {
width = maxWidth;
height = width / ratio;
}
return { width, height };
};

View file

@ -0,0 +1,11 @@
import API from '../../api.js';
export const getNotifications = (showDismissed) => {
return API.getNotifications(showDismissed);
};
export const dismissNotification = (notificationId, actionTaken) => {
return API.dismissNotification(notificationId, actionTaken);
};
export const dismissAllNotifications = () => {
return API.dismissAllNotifications();
};

View file

@ -0,0 +1,166 @@
export const imdbUrl = (imdb_id) =>
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
export const tmdbUrl = (tmdb_id, type = 'movie') =>
tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
export const formatDuration = (seconds) => {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
};
export const formatStreamLabel = (relation) => {
const provider = relation.m3u_account.name;
const streamId = relation.stream_id;
const quality = extractQuality(relation);
return `${provider}${quality ?? ''}${streamId ? ` (Stream ${streamId})` : ''}`;
};
const extractQuality = (relation) => {
// 1. Primary: Backend quality_info field
const fromQualityInfo = getQualityFromBackend(relation.quality_info);
if (fromQualityInfo) return fromQualityInfo;
// 2. Secondary: Custom properties detailed_info
if (relation.custom_properties?.detailed_info) {
const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info);
if (fromDetailedInfo) return fromDetailedInfo;
}
// 3. Fallback: Stream name
if (relation.stream_name) {
return getQualityFromStreamName(relation.stream_name);
}
return '';
};
const getQualityFromBackend = (qualityInfo) => {
if (!qualityInfo) return '';
if (qualityInfo.quality) {
return ` - ${qualityInfo.quality}`;
} else if (qualityInfo.resolution) {
return ` - ${qualityInfo.resolution}`;
} else if (qualityInfo.bitrate) {
return ` - ${qualityInfo.bitrate}`;
}
return '';
};
const getQualityFromDetailedInfo = (detailedInfo) => {
// Check video dimensions first
if (detailedInfo.video?.width && detailedInfo.video?.height) {
return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height);
}
// Check name field
if (detailedInfo.name) {
return parseQualityFromText(detailedInfo.name);
}
return '';
};
const getQualityFromStreamName = (streamName) => {
return parseQualityFromText(streamName);
};
const parseQualityFromText = (text) => {
if (text.includes('4K') || text.includes('2160p')) return ' - 4K';
if (text.includes('1080p') || text.includes('FHD')) return ' - 1080p';
if (text.includes('720p') || text.includes('HD')) return ' - 720p';
if (text.includes('480p')) return ' - 480p';
return '';
};
const getQualityInfoFromDimensions = (width, height) => {
// Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
if (width >= 3840) {
return ' - 4K';
} else if (width >= 1920) {
return ' - 1080p';
} else if (width >= 1280) {
return ' - 720p';
} else if (width >= 854) {
return ' - 480p';
} else {
return ` - ${width}x${height}`;
}
}
export const sortEpisodesList = (episodesList) => {
return episodesList.sort((a, b) => {
if (a.season_number !== b.season_number) {
return (a.season_number || 0) - (b.season_number || 0);
}
return (a.episode_number || 0) - (b.episode_number || 0);
});
};
export const groupEpisodesBySeason = (seriesEpisodes) => {
const grouped = {};
seriesEpisodes.forEach((episode) => {
const season = episode.season_number || 1;
if (!grouped[season]) {
grouped[season] = [];
}
grouped[season].push(episode);
});
return grouped;
};
export const sortBySeasonNumber = (episodesBySeason) => {
return Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b);
};
export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => {
let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
// Add selected provider as query parameter if available
if (selectedProvider) {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
if (env_mode === 'dev') {
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
} else {
streamUrl = `${window.location.origin}${streamUrl}`;
}
return streamUrl;
};
// Helper to get embeddable YouTube URL
export const getYouTubeEmbedUrl = (url) => {
if (!url) return '';
// Accepts full YouTube URLs or just IDs
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
const videoId = match ? match[1] : url;
return `https://www.youtube.com/embed/${videoId}`;
};
export const getEpisodeAirdate = (episode) => {
return episode.air_date
? new Date(episode.air_date).toLocaleDateString()
: 'N/A';
};
export const getTmdbUrlLink = (displaySeries, episode) => {
return (
tmdbUrl(displaySeries.tmdb_id, 'tv') +
(episode.season_number && episode.episode_number
? `/season/${episode.season_number}/episode/${episode.episode_number}`
: '')
);
};

View file

@ -0,0 +1,124 @@
const hasValidTechnicalDetails = (obj) => {
return obj?.bitrate || obj?.video || obj?.audio;
};
const extractFromDetailedInfo = (customProperties) => {
const detailedInfo = customProperties?.detailed_info;
if (!detailedInfo) return null;
return {
bitrate: detailedInfo.bitrate || null,
video: detailedInfo.video || null,
audio: detailedInfo.audio || null,
};
};
export const getTechnicalDetails = (selectedProvider, defaultVOD) => {
if (!selectedProvider) {
return {
bitrate: defaultVOD?.bitrate,
video: defaultVOD?.video,
audio: defaultVOD?.audio,
};
}
// Try movie/episode content first
const content = selectedProvider.movie || selectedProvider.episode;
if (content && hasValidTechnicalDetails(content)) {
return {
bitrate: content.bitrate,
video: content.video,
audio: content.audio,
};
}
// Try provider object directly
if (hasValidTechnicalDetails(selectedProvider)) {
return {
bitrate: selectedProvider.bitrate,
video: selectedProvider.video,
audio: selectedProvider.audio,
};
}
// Try custom_properties.detailed_info
const detailedInfo = extractFromDetailedInfo(
selectedProvider.custom_properties
);
if (detailedInfo && hasValidTechnicalDetails(detailedInfo)) {
return detailedInfo;
}
// Fallback to defaultVOD
return {
bitrate: defaultVOD?.bitrate,
video: defaultVOD?.video,
audio: defaultVOD?.audio,
};
};
export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => {
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
// Add selected provider as query parameter if available
if (selectedProvider) {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
if (env_mode === 'dev') {
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
} else {
streamUrl = `${window.location.origin}${streamUrl}`;
}
return streamUrl;
};
export const formatVideoDetails = (video) => {
const parts = [];
const codec =
video.codec_long_name && video.codec_long_name !== 'unknown'
? video.codec_long_name
: video.codec_name;
parts.push(codec);
if (video.profile) parts.push(`(${video.profile})`);
if (video.width && video.height) parts.push(`${video.width}x${video.height}`);
if (video.display_aspect_ratio)
parts.push(`Aspect Ratio: ${video.display_aspect_ratio}`);
if (video.bit_rate)
parts.push(`Bitrate: ${Math.round(Number(video.bit_rate) / 1000)} kbps`);
if (video.r_frame_rate) parts.push(`Frame Rate: ${video.r_frame_rate} fps`);
if (video.tags?.encoder) parts.push(`Encoder: ${video.tags.encoder}`);
return parts.join(', ');
};
export const formatAudioDetails = (audio) => {
const parts = [];
const codec =
audio.codec_long_name && audio.codec_long_name !== 'unknown'
? audio.codec_long_name
: audio.codec_name;
parts.push(codec);
if (audio.profile) parts.push(`(${audio.profile})`);
const channels =
audio.channel_layout || (audio.channels ? `${audio.channels}` : null);
if (channels) parts.push(`Channels: ${channels}`);
if (audio.sample_rate) parts.push(`Sample Rate: ${audio.sample_rate} Hz`);
if (audio.bit_rate)
parts.push(`Bitrate: ${Math.round(Number(audio.bit_rate) / 1000)} kbps`);
if (audio.tags?.handler_name)
parts.push(`Handler: ${audio.tags.handler_name}`);
return parts.join(', ');
};

View file

@ -0,0 +1,235 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
getLivePlayerErrorMessage,
getVODPlayerErrorMessage,
getClientCoordinates,
calculateNewDimensions,
applyConstraints,
} from '../FloatingVideoUtils';
describe('FloatingVideoUtils', () => {
describe('getLivePlayerErrorMessage', () => {
it('should return formatted error for non-MediaError types', () => {
expect(getLivePlayerErrorMessage('NetworkError', 'Connection failed')).toBe(
'Error: NetworkError - Connection failed'
);
});
it('should return error type only when no detail provided', () => {
expect(getLivePlayerErrorMessage('NetworkError')).toBe('Error: NetworkError');
});
it('should return audio codec message for audio-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'audio codec not supported');
expect(result).toBe(
'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'
);
});
it('should return audio codec message for AC3 errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'AC3 codec issue');
expect(result).toContain('Audio codec not supported');
});
it('should return video codec message for video-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'video codec h264 failed');
expect(result).toBe(
'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'
);
});
it('should return MSE message for MSE-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'MSE not supported');
expect(result).toBe(
"Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."
);
});
it('should return generic codec message for other MediaError cases', () => {
const result = getLivePlayerErrorMessage('MediaError', 'unknown codec issue');
expect(result).toBe(
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
);
});
it('should handle null errorDetail for MediaError', () => {
const result = getLivePlayerErrorMessage('MediaError', null);
expect(result).toBe(
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
);
});
});
describe('getVODPlayerErrorMessage', () => {
it('should return default message when error is null', () => {
expect(getVODPlayerErrorMessage(null)).toBe('Video playback error');
});
it('should return aborted message for MEDIA_ERR_ABORTED', () => {
const error = { code: 1, MEDIA_ERR_ABORTED: 1 };
expect(getVODPlayerErrorMessage(error)).toBe('Video playback was aborted');
});
it('should return network message for MEDIA_ERR_NETWORK', () => {
const error = { code: 2, MEDIA_ERR_NETWORK: 2 };
expect(getVODPlayerErrorMessage(error)).toBe('Network error while loading video');
});
it('should return codec message for MEDIA_ERR_DECODE', () => {
const error = { code: 3, MEDIA_ERR_DECODE: 3 };
expect(getVODPlayerErrorMessage(error)).toBe('Video codec not supported by your browser');
});
it('should return format message for MEDIA_ERR_SRC_NOT_SUPPORTED', () => {
const error = { code: 4, MEDIA_ERR_SRC_NOT_SUPPORTED: 4 };
expect(getVODPlayerErrorMessage(error)).toBe('Video format not supported by your browser');
});
it('should return error message for unknown error codes', () => {
const error = { code: 99, message: 'Custom error message' };
expect(getVODPlayerErrorMessage(error)).toBe('Custom error message');
});
it('should return default message for unknown error without message', () => {
const error = { code: 99 };
expect(getVODPlayerErrorMessage(error)).toBe('Unknown video error');
});
});
describe('getClientCoordinates', () => {
it('should extract coordinates from mouse event', () => {
const event = { clientX: 100, clientY: 200 };
expect(getClientCoordinates(event)).toEqual({ clientX: 100, clientY: 200 });
});
it('should extract coordinates from touch event', () => {
const event = { touches: [{ clientX: 150, clientY: 250 }] };
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
});
it('should prioritize touch coordinates over mouse coordinates', () => {
const event = {
touches: [{ clientX: 150, clientY: 250 }],
clientX: 100,
clientY: 200,
};
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
});
it('should handle undefined coordinates', () => {
const event = {};
expect(getClientCoordinates(event)).toEqual({ clientX: undefined, clientY: undefined });
});
});
describe('calculateNewDimensions', () => {
const ratio = 16 / 9;
it('should calculate dimensions based on horizontal drag', () => {
const handle = { xDir: 1, yDir: 0, isLeft: false, isTop: false };
const result = calculateNewDimensions(100, 10, 400, 225, handle, ratio);
expect(result.width).toBe(500);
expect(result.height).toBeCloseTo(281.25, 1);
});
it('should calculate dimensions based on vertical drag', () => {
const handle = { xDir: 0, yDir: 1, isLeft: false, isTop: false };
const result = calculateNewDimensions(10, 100, 400, 225, handle, ratio);
expect(result.height).toBe(325);
expect(result.width).toBeCloseTo(577.78, 1);
});
it('should use vertical-driven resize when vertical delta is larger', () => {
const handle = { xDir: 1, yDir: 1, isLeft: false, isTop: false };
const result = calculateNewDimensions(20, 100, 400, 225, handle, ratio);
expect(result.height).toBe(325);
expect(result.width).toBeCloseTo(577.78, 1);
});
it('should handle negative deltas', () => {
const handle = { xDir: -1, yDir: 0, isLeft: true, isTop: false };
const result = calculateNewDimensions(-50, 0, 400, 225, handle, ratio);
expect(result.width).toBe(450);
expect(result.height).toBeCloseTo(253.13, 1);
});
});
describe('applyConstraints', () => {
const ratio = 16 / 9;
const minWidth = 200;
const minHeight = 112.5;
const visibleMargin = 50;
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
});
it('should apply minimum width constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(100, 50, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(minWidth);
expect(result.height).toBeCloseTo(minWidth / ratio, 1);
});
it('should apply minimum height constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 100, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
expect(result.height).toBe(minHeight);
expect(result.width).toBeCloseTo(minHeight * ratio, 1);
});
it('should apply maximum width constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 1200, y: 100 };
const result = applyConstraints(800, 450, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const maxWidth = 1920 - 1200 - 50; // 670
expect(result.width).toBe(maxWidth);
expect(result.height).toBeCloseTo(maxWidth / ratio, 1);
});
it('should apply maximum height constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 100, y: 700 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const maxHeight = 1080 - 700 - 50; // 330
expect(result.height).toBe(maxHeight);
expect(result.width).toBeCloseTo(maxHeight * ratio, 1);
});
it('should not apply max width constraint for left handle', () => {
const handle = { isLeft: true, isTop: false };
const startPos = { x: 1800, y: 100 };
const result = applyConstraints(500, 281.25, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(500);
expect(result.height).toBeCloseTo(281.25, 1);
});
it('should not apply max height constraint for top handle', () => {
const handle = { isLeft: false, isTop: true };
const startPos = { x: 100, y: 1000 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(500);
expect(result.height).toBe(400);
});
it('should handle null startPos', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 168.75, ratio, null, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(300);
expect(result.height).toBeCloseTo(168.75, 1);
});
});
});

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as NotificationUtils from '../NotificationCenterUtils';
import API from '../../../api';
vi.mock('../../../api');
describe('NotificationCenterUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getNotifications', () => {
it('should call API.getNotifications with showDismissed parameter', async () => {
const mockNotifications = [
{ id: 1, message: 'Test notification' }
];
API.getNotifications.mockResolvedValue(mockNotifications);
const result = await NotificationUtils.getNotifications(false);
expect(API.getNotifications).toHaveBeenCalledWith(false);
expect(result).toEqual(mockNotifications);
});
it('should call API.getNotifications with showDismissed=true', async () => {
const mockNotifications = [
{ id: 2, message: 'Dismissed notification', is_dismissed: true }
];
API.getNotifications.mockResolvedValue(mockNotifications);
const result = await NotificationUtils.getNotifications(true);
expect(API.getNotifications).toHaveBeenCalledWith(true);
expect(result).toEqual(mockNotifications);
});
it('should handle API errors', async () => {
const error = new Error('API error');
API.getNotifications.mockRejectedValue(error);
await expect(NotificationUtils.getNotifications(false)).rejects.toThrow('API error');
expect(API.getNotifications).toHaveBeenCalledWith(false);
});
});
describe('dismissNotification', () => {
it('should call API.dismissNotification with notificationId and actionTaken', async () => {
API.dismissNotification.mockResolvedValue({ success: true });
const result = await NotificationUtils.dismissNotification(1, 'dismissed');
expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
expect(result).toEqual({ success: true });
});
it('should handle API errors when dismissing', async () => {
const error = new Error('Dismiss failed');
API.dismissNotification.mockRejectedValue(error);
await expect(NotificationUtils.dismissNotification(1, 'dismissed')).rejects.toThrow('Dismiss failed');
expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
});
});
describe('dismissAllNotifications', () => {
it('should call API.dismissAllNotifications', async () => {
API.dismissAllNotifications.mockResolvedValue({ success: true, count: 5 });
const result = await NotificationUtils.dismissAllNotifications();
expect(API.dismissAllNotifications).toHaveBeenCalled();
expect(result).toEqual({ success: true, count: 5 });
});
it('should handle API errors when dismissing all', async () => {
const error = new Error('Dismiss all failed');
API.dismissAllNotifications.mockRejectedValue(error);
await expect(NotificationUtils.dismissAllNotifications()).rejects.toThrow('Dismiss all failed');
expect(API.dismissAllNotifications).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,397 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
imdbUrl,
tmdbUrl,
formatDuration,
formatStreamLabel,
sortEpisodesList,
groupEpisodesBySeason,
sortBySeasonNumber,
getEpisodeStreamUrl,
getYouTubeEmbedUrl,
getEpisodeAirdate,
getTmdbUrlLink
} from '../SeriesModalUtils';
describe('SeriesModalUtils', () => {
describe('imdbUrl', () => {
it('should return IMDb URL with valid ID', () => {
expect(imdbUrl('tt1234567')).toBe('https://www.imdb.com/title/tt1234567');
});
it('should return empty string when ID is null', () => {
expect(imdbUrl(null)).toBe('');
});
it('should return empty string when ID is undefined', () => {
expect(imdbUrl(undefined)).toBe('');
});
});
describe('tmdbUrl', () => {
it('should return TMDB movie URL by default', () => {
expect(tmdbUrl('12345')).toBe('https://www.themoviedb.org/movie/12345');
});
it('should return TMDB TV URL when type is tv', () => {
expect(tmdbUrl('12345', 'tv')).toBe('https://www.themoviedb.org/tv/12345');
});
it('should return empty string when ID is null', () => {
expect(tmdbUrl(null)).toBe('');
});
});
describe('formatDuration', () => {
it('should format hours and minutes', () => {
expect(formatDuration(7200)).toBe('2h 0m');
});
it('should format hours, minutes and seconds', () => {
expect(formatDuration(3665)).toBe('1h 1m');
});
it('should format minutes and seconds when under an hour', () => {
expect(formatDuration(125)).toBe('2m 5s');
});
it('should return empty string for 0 seconds', () => {
expect(formatDuration(0)).toBe('');
});
it('should return empty string for null', () => {
expect(formatDuration(null)).toBe('');
});
});
describe('formatStreamLabel', () => {
const baseRelation = {
m3u_account: { name: 'Provider 1' },
stream_id: 100
};
it('should format label with quality from quality_info', () => {
const relation = {
...baseRelation,
quality_info: { quality: '1080p' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should format label with quality from resolution', () => {
const relation = {
...baseRelation,
quality_info: { resolution: '4K' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should format label with quality from bitrate', () => {
const relation = {
...baseRelation,
quality_info: { bitrate: '8000kbps' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 8000kbps (Stream 100)');
});
it('should detect 4K from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 3840, height: 2160 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect 1080p from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect 720p from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 1280, height: 720 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
});
it('should show custom dimensions for non-standard resolutions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 640, height: 480 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 640x480 (Stream 100)');
});
it('should detect quality from detailed_info name field', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
name: 'Stream 4K HDR'
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect quality from stream_name', () => {
const relation = {
...baseRelation,
stream_name: 'Channel 1080p FHD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect 2160p as 4K', () => {
const relation = {
...baseRelation,
stream_name: 'Stream 2160p'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect FHD as 1080p', () => {
const relation = {
...baseRelation,
stream_name: 'Stream FHD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect HD as 720p', () => {
const relation = {
...baseRelation,
stream_name: 'Stream HD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
});
it('should format without stream ID when not provided', () => {
const relation = {
m3u_account: { name: 'Provider 1' },
quality_info: { quality: '1080p' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p');
});
it('should format without quality when not detected', () => {
const relation = {
m3u_account: { name: 'Provider 1' },
stream_id: 100
};
expect(formatStreamLabel(relation)).toBe('Provider 1 (Stream 100)');
});
it('should prioritize quality_info over detailed_info', () => {
const relation = {
...baseRelation,
quality_info: { quality: '4K' },
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
});
describe('sortEpisodesList', () => {
it('should sort episodes by season then episode number', () => {
const episodes = [
{ season_number: 2, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 1, episode_number: 1 }
];
const sorted = sortEpisodesList(episodes);
expect(sorted).toEqual([
{ season_number: 1, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 2, episode_number: 1 }
]);
});
it('should handle missing season numbers as 0', () => {
const episodes = [
{ season_number: 1, episode_number: 1 },
{ episode_number: 1 }
];
const sorted = sortEpisodesList(episodes);
expect(sorted[0].season_number).toBeUndefined();
});
});
describe('groupEpisodesBySeason', () => {
it('should group episodes by season number', () => {
const episodes = [
{ season_number: 1, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 2, episode_number: 1 }
];
const grouped = groupEpisodesBySeason(episodes);
expect(grouped[1]).toHaveLength(2);
expect(grouped[2]).toHaveLength(1);
});
it('should default missing season numbers to 1', () => {
const episodes = [{ episode_number: 1 }];
const grouped = groupEpisodesBySeason(episodes);
expect(grouped[1]).toHaveLength(1);
});
});
describe('sortBySeasonNumber', () => {
it('should return season numbers in ascending order', () => {
const episodesBySeason = {
3: [],
1: [],
2: []
};
const sorted = sortBySeasonNumber(episodesBySeason);
expect(sorted).toEqual([1, 2, 3]);
});
});
describe('getEpisodeStreamUrl', () => {
beforeEach(() => {
delete window.location;
window.location = {
protocol: 'https:',
hostname: 'example.com',
origin: 'https://example.com'
};
});
it('should generate stream URL without provider in production', () => {
const episode = { uuid: 'episode-123' };
const url = getEpisodeStreamUrl(episode, null, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123');
});
it('should generate stream URL with stream_id parameter', () => {
const episode = { uuid: 'episode-123' };
const provider = {
stream_id: 'stream-456',
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?stream_id=stream-456');
});
it('should generate stream URL with m3u_account_id when no stream_id', () => {
const episode = { uuid: 'episode-123' };
const provider = {
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?m3u_account_id=1');
});
it('should use dev port in dev mode', () => {
const episode = { uuid: 'episode-123' };
const url = getEpisodeStreamUrl(episode, null, 'dev');
expect(url).toBe('https://example.com:5656/proxy/vod/episode/episode-123');
});
it('should encode special characters in stream_id', () => {
const episode = { uuid: 'episode-123' };
const provider = {
stream_id: 'stream with spaces',
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toContain('stream%20with%20spaces');
});
});
describe('getYouTubeEmbedUrl', () => {
it('should convert youtube.com watch URL to embed URL', () => {
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should convert youtu.be URL to embed URL', () => {
const url = 'https://youtu.be/dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should handle bare video ID', () => {
const videoId = 'dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(videoId)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should return empty string for null', () => {
expect(getYouTubeEmbedUrl(null)).toBe('');
});
it('should return empty string for empty string', () => {
expect(getYouTubeEmbedUrl('')).toBe('');
});
});
describe('getEpisodeAirdate', () => {
it('should format valid air date', () => {
const episode = { air_date: '2024-01-15' };
const formatted = getEpisodeAirdate(episode);
expect(formatted).toMatch(/1\/1[4|5]\/2024/);
});
it('should return N/A for missing air date', () => {
const episode = {};
expect(getEpisodeAirdate(episode)).toBe('N/A');
});
it('should return N/A for null air date', () => {
const episode = { air_date: null };
expect(getEpisodeAirdate(episode)).toBe('N/A');
});
});
describe('getTmdbUrlLink', () => {
const displaySeries = { tmdb_id: '12345' };
it('should return TV show URL without episode details', () => {
const episode = {};
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
it('should return TV show URL with season and episode', () => {
const episode = { season_number: 2, episode_number: 5 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345/season/2/episode/5');
});
it('should not append episode details if only season is present', () => {
const episode = { season_number: 2 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
it('should not append episode details if only episode is present', () => {
const episode = { episode_number: 5 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
});
});

View file

@ -0,0 +1,344 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getTechnicalDetails,
getMovieStreamUrl,
formatVideoDetails,
formatAudioDetails,
} from '../VODModalUtils';
describe('VODModalUtils', () => {
describe('getTechnicalDetails', () => {
const defaultVOD = {
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
};
it('should return defaultVOD details when no provider selected', () => {
const result = getTechnicalDetails(null, defaultVOD);
expect(result).toEqual({
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
});
});
it('should extract details from movie content', () => {
const provider = {
movie: {
bitrate: '8000 kbps',
video: 'H.265',
audio: 'AC3',
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '8000 kbps',
video: 'H.265',
audio: 'AC3',
});
});
it('should extract details from episode content', () => {
const provider = {
episode: {
bitrate: '6000 kbps',
video: 'AVC',
audio: 'DTS',
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '6000 kbps',
video: 'AVC',
audio: 'DTS',
});
});
it('should extract details from provider object directly', () => {
const provider = {
bitrate: '7000 kbps',
video: 'VP9',
audio: 'Opus',
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '7000 kbps',
video: 'VP9',
audio: 'Opus',
});
});
it('should extract details from custom_properties.detailed_info', () => {
const provider = {
custom_properties: {
detailed_info: {
bitrate: '9000 kbps',
video: 'AV1',
audio: 'EAC3',
},
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '9000 kbps',
video: 'AV1',
audio: 'EAC3',
});
});
it('should fallback to defaultVOD when provider has no valid details', () => {
const provider = {
custom_properties: {},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
});
});
it('should prioritize movie content over provider object', () => {
const provider = {
movie: {
bitrate: '8000 kbps',
},
bitrate: '7000 kbps',
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result.bitrate).toBe('8000 kbps');
});
it('should handle undefined defaultVOD', () => {
const result = getTechnicalDetails(null, undefined);
expect(result).toEqual({
bitrate: undefined,
video: undefined,
audio: undefined,
});
});
});
describe('getMovieStreamUrl', () => {
const vod = { uuid: 'test-uuid-123' };
const originalLocation = window.location;
beforeEach(() => {
delete window.location;
window.location = {
protocol: 'https:',
hostname: 'example.com',
origin: 'https://example.com',
};
});
afterEach(() => {
window.location = originalLocation;
});
it('should generate basic stream URL without provider', () => {
const result = getMovieStreamUrl(vod, null, 'production');
expect(result).toBe('https://example.com/proxy/vod/movie/test-uuid-123');
});
it('should include stream_id when available', () => {
const provider = {
stream_id: 'stream-123',
m3u_account: { id: 'account-456' },
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toBe(
'https://example.com/proxy/vod/movie/test-uuid-123?stream_id=stream-123'
);
});
it('should include m3u_account_id when stream_id not available', () => {
const provider = {
m3u_account: { id: 'account-456' },
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toBe(
'https://example.com/proxy/vod/movie/test-uuid-123?m3u_account_id=account-456'
);
});
it('should use dev port in dev mode', () => {
const result = getMovieStreamUrl(vod, null, 'dev');
expect(result).toBe(
'https://example.com:5656/proxy/vod/movie/test-uuid-123'
);
});
it('should encode stream_id', () => {
const provider = {
stream_id: 'stream with spaces',
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toContain('stream_id=stream%20with%20spaces');
});
});
describe('formatVideoDetails', () => {
it('should format complete video details', () => {
const video = {
codec_name: 'h264',
codec_long_name: 'H.264 / AVC / MPEG-4 AVC',
profile: 'High',
width: 1920,
height: 1080,
display_aspect_ratio: '16:9',
bit_rate: '8000000',
r_frame_rate: '23.98',
tags: { encoder: 'x264' },
};
const result = formatVideoDetails(video);
expect(result).toBe(
'H.264 / AVC / MPEG-4 AVC, (High), 1920x1080, Aspect Ratio: ' +
'16:9, Bitrate: 8000 kbps, Frame Rate: 23.98 fps, Encoder: x264'
);
});
it('should use codec_name when codec_long_name is unknown', () => {
const video = {
codec_name: 'h264',
codec_long_name: 'unknown',
};
const result = formatVideoDetails(video);
expect(result).toBe('h264');
});
it('should use codec_name when codec_long_name is not available', () => {
const video = {
codec_name: 'h265',
};
const result = formatVideoDetails(video);
expect(result).toBe('h265');
});
it('should handle minimal video details', () => {
const video = {
codec_name: 'vp9',
};
const result = formatVideoDetails(video);
expect(result).toBe('vp9');
});
it('should format bitrate correctly', () => {
const video = {
codec_name: 'h264',
bit_rate: '5500000',
};
const result = formatVideoDetails(video);
expect(result).toContain('Bitrate: 5500 kbps');
});
});
describe('formatAudioDetails', () => {
it('should format complete audio details', () => {
const audio = {
codec_name: 'aac',
codec_long_name: 'AAC (Advanced Audio Coding)',
profile: 'LC',
channel_layout: '5.1',
sample_rate: '48000',
bit_rate: '256000',
tags: { handler_name: 'SoundHandler' },
};
const result = formatAudioDetails(audio);
expect(result).toBe(
'AAC (Advanced Audio Coding), (LC), Channels: 5.1, ' +
'Sample Rate: 48000 Hz, Bitrate: 256 kbps, Handler: SoundHandler'
);
});
it('should use codec_name when codec_long_name is unknown', () => {
const audio = {
codec_name: 'ac3',
codec_long_name: 'unknown',
};
const result = formatAudioDetails(audio);
expect(result).toBe('ac3');
});
it('should use channels count when channel_layout not available', () => {
const audio = {
codec_name: 'aac',
channels: '2',
};
const result = formatAudioDetails(audio);
expect(result).toBe('aac, Channels: 2');
});
it('should handle minimal audio details', () => {
const audio = {
codec_name: 'opus',
};
const result = formatAudioDetails(audio);
expect(result).toBe('opus');
});
it('should format bitrate correctly', () => {
const audio = {
codec_name: 'aac',
bit_rate: '192000',
};
const result = formatAudioDetails(audio);
expect(result).toContain('Bitrate: 192 kbps');
});
it('should prefer channel_layout over channels', () => {
const audio = {
codec_name: 'dts',
channel_layout: '7.1',
channels: '8',
};
const result = formatAudioDetails(audio);
expect(result).toContain('Channels: 7.1');
});
});
});

View file

@ -30,8 +30,9 @@ const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
if ((pr.tvg_id || '') !== tvid) return false;
if ((pr.title || '').toLowerCase() !== titleKey) return false;
const st = toUserTime(r.start_time);
return st.isAfter(userNow());
// Include episodes that haven't ended yet (currently-airing + future)
const et = toUserTime(r.end_time);
return et.isAfter(userNow());
});
};

View file

@ -282,6 +282,7 @@ describe('RecordingDetailsModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
end_time: '2024-01-02T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' },
@ -289,6 +290,7 @@ describe('RecordingDetailsModalUtils', () => {
},
{
start_time: '2024-01-02T13:00:00',
end_time: '2024-01-02T14:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show2', title: 'Other Show' },
@ -313,6 +315,7 @@ describe('RecordingDetailsModalUtils', () => {
const recordings = [
{
start_time: '2023-12-31T12:00:00',
end_time: '2023-12-31T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' },
@ -320,6 +323,7 @@ describe('RecordingDetailsModalUtils', () => {
},
{
start_time: '2024-01-02T12:00:00',
end_time: '2024-01-02T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' },

View file

@ -14,6 +14,6 @@ export const getProxySettingDefaults = () => {
redis_chunk_ttl: 60,
channel_shutdown_delay: 0,
channel_init_grace_period: 5,
new_client_behind_seconds: 2,
new_client_behind_seconds: 5,
};
};

View file

@ -61,7 +61,7 @@ describe('ProxySettingsFormUtils', () => {
redis_chunk_ttl: 60,
channel_shutdown_delay: 0,
channel_init_grace_period: 5,
new_client_behind_seconds: 2,
new_client_behind_seconds: 5,
});
});

View file

@ -32,7 +32,7 @@ const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
const e = toUserTime(rec.end_time);
const status = rec.custom_properties?.status;
if (status === 'interrupted' || status === 'completed') {
if (status === 'interrupted' || status === 'completed' || status === 'stopped') {
completed.push(rec);
} else {
if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
@ -88,3 +88,55 @@ export const categorizeRecordings = (recordings, toUserTime, now) => {
completed,
};
};
export const filterRecordings = (recordings, searchQuery, selectedChannelId) => {
return recordings.filter((rec) => {
if (searchQuery) {
const q = searchQuery.toLowerCase();
const cp = rec.custom_properties || {};
const program = cp.program || {};
const title = (program.title || '').toLowerCase();
const subTitle = (program.sub_title || '').toLowerCase();
const description = (program.description || cp.description || '').toLowerCase();
if (!title.includes(q) && !subTitle.includes(q) && !description.includes(q)) {
return false;
}
}
if (selectedChannelId) {
if (String(rec.channel) !== String(selectedChannelId)) return false;
}
return true;
});
};
export const buildChannelOptions = (channelsById, ...buckets) => {
const channelIds = new Set();
for (const bucket of buckets) {
for (const rec of bucket) {
if (rec.channel != null) channelIds.add(rec.channel);
}
}
const options = [];
for (const id of channelIds) {
const ch = channelsById[id];
if (ch) {
options.push({
value: String(ch.id),
label: ch.channel_number ? `${ch.channel_number} - ${ch.name}` : ch.name,
});
}
}
options.sort((a, b) => {
const aNum = parseInt(a.label, 10);
const bNum = parseInt(b.label, 10);
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
return a.label.localeCompare(b.label);
});
return options;
};

View file

@ -541,4 +541,162 @@ describe('DVRUtils', () => {
expect(result.upcoming[0]._group_count).toBe(1);
});
});
describe('filterRecordings', () => {
const makeRec = (id, title, subTitle, description, channel) => ({
id,
channel,
custom_properties: {
program: { title, sub_title: subTitle, description },
},
});
it('returns all recordings when no filters active', () => {
const recs = [makeRec(1, 'News', 'Episode 1', 'Daily news', 5)];
expect(DVRUtils.filterRecordings(recs, '', null)).toEqual(recs);
});
it('filters by title case-insensitively', () => {
const recs = [
makeRec(1, 'Morning News', '', '', 1),
makeRec(2, 'Sports Center', '', '', 1),
];
const result = DVRUtils.filterRecordings(recs, 'news', null);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('filters by sub_title', () => {
const recs = [
makeRec(1, 'Show A', 'Pilot Episode', '', 1),
makeRec(2, 'Show B', 'Finale', '', 1),
];
const result = DVRUtils.filterRecordings(recs, 'pilot', null);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('filters by description', () => {
const recs = [
makeRec(1, 'Show A', '', 'A thriller about detectives', 1),
makeRec(2, 'Show B', '', 'A comedy special', 1),
];
const result = DVRUtils.filterRecordings(recs, 'detective', null);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('filters by channel id', () => {
const recs = [
makeRec(1, 'Show A', '', '', 5),
makeRec(2, 'Show B', '', '', 10),
];
const result = DVRUtils.filterRecordings(recs, '', '5');
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('combines search and channel filter with AND logic', () => {
const recs = [
makeRec(1, 'News', '', '', 5),
makeRec(2, 'News', '', '', 10),
makeRec(3, 'Sports', '', '', 5),
];
const result = DVRUtils.filterRecordings(recs, 'news', '5');
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('handles recordings without custom_properties', () => {
const recs = [{ id: 1, channel: 5 }];
const result = DVRUtils.filterRecordings(recs, 'anything', null);
expect(result).toHaveLength(0);
});
it('handles recordings without custom_properties and no search', () => {
const recs = [{ id: 1, channel: 5 }];
const result = DVRUtils.filterRecordings(recs, '', null);
expect(result).toHaveLength(1);
});
it('handles empty array', () => {
expect(DVRUtils.filterRecordings([], 'test', '5')).toEqual([]);
});
it('falls back to custom_properties.description', () => {
const recs = [{
id: 1,
channel: 1,
custom_properties: {
description: 'A fallback description',
program: { title: 'Show' },
},
}];
const result = DVRUtils.filterRecordings(recs, 'fallback', null);
expect(result).toHaveLength(1);
});
});
describe('buildChannelOptions', () => {
const channelsById = {
5: { id: 5, name: 'CNN', channel_number: '5' },
10: { id: 10, name: 'ESPN', channel_number: '10' },
3: { id: 3, name: 'NBC', channel_number: '3' },
20: { id: 20, name: 'Local Access' },
};
it('returns only channels present in recordings', () => {
const bucket = [{ channel: 5 }, { channel: 10 }];
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
expect(result).toHaveLength(2);
expect(result.map((o) => o.value)).toEqual(['5', '10']);
});
it('sorts by channel number numerically', () => {
const bucket = [{ channel: 10 }, { channel: 3 }, { channel: 5 }];
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
expect(result[0].label).toBe('3 - NBC');
expect(result[1].label).toBe('5 - CNN');
expect(result[2].label).toBe('10 - ESPN');
});
it('handles multiple buckets', () => {
const b1 = [{ channel: 5 }];
const b2 = [{ channel: 10 }];
const b3 = [{ channel: 3 }];
const result = DVRUtils.buildChannelOptions(channelsById, b1, b2, b3);
expect(result).toHaveLength(3);
});
it('deduplicates channels across buckets', () => {
const b1 = [{ channel: 5 }, { channel: 10 }];
const b2 = [{ channel: 5 }, { channel: 3 }];
const result = DVRUtils.buildChannelOptions(channelsById, b1, b2);
expect(result).toHaveLength(3);
});
it('skips channels not in channelsById', () => {
const bucket = [{ channel: 5 }, { channel: 999 }];
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
expect(result).toHaveLength(1);
expect(result[0].value).toBe('5');
});
it('formats label with channel number when available', () => {
const bucket = [{ channel: 5 }];
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
expect(result[0].label).toBe('5 - CNN');
});
it('formats label without channel number when missing', () => {
const bucket = [{ channel: 20 }];
const result = DVRUtils.buildChannelOptions(channelsById, bucket);
expect(result[0].label).toBe('Local Access');
});
it('returns empty array for empty buckets', () => {
const result = DVRUtils.buildChannelOptions(channelsById, [], []);
expect(result).toEqual([]);
});
});
});

View file

@ -12,6 +12,28 @@ import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Key prefixes used by Celery's broker (Kombu) and result backend.
# These must be preserved in modular mode where Celery runs independently.
_CELERY_KEY_PREFIXES = ('celery', '_kombu', 'unacked')
def _flush_non_celery_keys(client):
"""Delete all Redis keys except those belonging to Celery."""
cursor = '0'
deleted = 0
while True:
cursor, keys = client.scan(cursor=cursor, count=500)
to_delete = [
k for k in keys
if not k.decode('utf-8', errors='replace').startswith(_CELERY_KEY_PREFIXES)
]
if to_delete:
deleted += client.delete(*to_delete)
if cursor == 0:
break
logger.info(f"Modular mode: selectively cleared {deleted} non-Celery Redis key(s)")
def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2):
"""Wait for Redis to become available"""
redis_client = None
@ -31,7 +53,15 @@ def wait_for_redis(host='localhost', port=6379, db=0, password='', username='',
socket_connect_timeout=2
)
redis_client.ping()
redis_client.flushdb() # Flush the database to ensure it's clean
# Clear stale state on startup. In AIO mode, every service restarts
# together so a full flush is safe. In modular mode, Celery has its
# own lifecycle — preserve its broker/result keys and only wipe
# application state (stream locks, proxy metadata, etc.).
if os.environ.get('DISPATCHARR_ENV') == 'modular':
_flush_non_celery_keys(redis_client)
else:
redis_client.flushdb()
logger.info(f"Flushed Redis database")
logger.info(f"✅ Redis at {host}:{port}/{db} is now available!")
return True
except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e:

Some files were not shown because too many files have changed in this diff Show more