From bd202638ef0d4a8a7ab1b184e73a64b2beb1b799 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 14 May 2026 14:50:58 -0500 Subject: [PATCH 001/125] fix: Add psycogreen to dependencies for improved concurrency support --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 81878424..8519b755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "python-vlc", "yt-dlp", "gevent==26.4.0", + "psycogreen", "daphne", "uwsgi", "django-cors-headers", From d448561767504cffa86a5c0da3819569ccf2fd2c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 10:03:44 -0500 Subject: [PATCH 002/125] Bug Fix: Refactored channel teardown to kill ffmpeg earlier in the process. Removed redundant cleanup task. Added guard to prevent multiple cleanups for the same channel. --- CHANGELOG.md | 3 ++ apps/proxy/live_proxy/input/manager.py | 50 ++++++++++++-------- apps/proxy/live_proxy/output/ts/generator.py | 37 --------------- apps/proxy/live_proxy/server.py | 12 ++++- 4 files changed, 43 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60eceeae..c7d3d062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Plugin monkey-patches and module-level hooks were never applied in uWSGI workers.** Both uWSGI configs use `lazy-apps=true`, meaning each worker boots independently and never inherits state from the master. `should_skip_initialization()` correctly skipped one-shot startup tasks in workers, but also blocked `discover_plugins`, so plugin modules were never imported in any of the 4 request-serving workers. Plugins relying on patching request handling (monkey-patches, signal registrations) were silently inactive until a Connect event lazily triggered discovery in that specific worker. Discovery is now run in every process that serves requests, gated only for Celery processes (which use `worker_ready`) and management commands that don't serve requests. - **PostgreSQL connection pool exhausted under load in gevent workers.** uWSGI's gevent pool runs many greenlets concurrently on a single OS thread. With `CONN_MAX_AGE = 60`, each greenlet that touched the database retained its own open connection for the full 60 seconds (gevent thread-locals are greenlet-locals), rapidly exhausting PostgreSQL's `max_connections` limit under moderate concurrency. `DATABASE_CONN_MAX_AGE` is now `0` so connections are closed after each request. `close_old_connections()` is also called at the top of the long-running stream manager and cleanup watchdog loops so stale handles accumulated across greenlet switches are released promptly. - **Stream proxy race: cleanup watchdog could stop a channel still in the connecting phase.** When the first viewer triggered channel initialization, the client was registered only after the connect-wait loop completed. The cleanup watchdog runs concurrently and stops channels with zero connected clients after a grace period; if the grace period elapsed during the connect-wait, the watchdog killed the channel and left the viewer stuck. The client is now registered before the connect-wait loop begins so the watchdog always sees at least one client. +- **2-second "stream thread did not terminate within timeout" warning on every channel stop.** `_close_socket` in `input/manager.py` was closing the relay pipe read-end (`self.socket`) before killing the ffmpeg process. The stream OS thread blocks in `select()` on that fd; on Linux, closing an fd from another thread while a `select()` is in progress on it does not reliably interrupt the call (POSIX allows this to be undefined). The thread stayed blocked for the full chunk-timeout (5 s), and `stream_thread.join(timeout=2.0)` always expired first. Fixed by killing ffmpeg first: when ffmpeg dies its copy of the relay write-end closes, delivering EOF to `select()` immediately. `self.socket` is closed afterward as cleanup only. +- **Duplicate `stop_channel` calls on every channel shutdown.** `StreamGenerator._cleanup` triggered channel shutdown via two parallel paths: `client_manager.remove_client()` (which fires `handle_client_disconnect` on the owning worker, the correct path) and `_schedule_channel_shutdown_if_needed` (which independently spawned a delayed `stop_channel` greenlet). The duplicate call was suppressed by the `_stopping_channels` guard but produced a redundant log entry and unnecessary greenlet on every shutdown. `_schedule_channel_shutdown_if_needed` has been removed; `handle_client_disconnect` is the sole shutdown trigger. +- **Concurrent greenlets could re-enter `_close_socket` during `proc.wait()`.** `self.transcode_process` was set to `None` at the end of the `if proc:` block rather than immediately after capturing the reference. Under gevent, `proc.wait(timeout=0.5)` yields the hub, allowing a second greenlet to enter `_close_socket`, find `self.transcode_process` still set, and attempt a second kill+close. `self.transcode_process = None` is now assigned immediately after `proc = self.transcode_process` so concurrent callers see `None` and skip the block. ### Performance diff --git a/apps/proxy/live_proxy/input/manager.py b/apps/proxy/live_proxy/input/manager.py index 581c6fb7..ccc52318 100644 --- a/apps/proxy/live_proxy/input/manager.py +++ b/apps/proxy/live_proxy/input/manager.py @@ -1440,23 +1440,20 @@ class StreamManager: except Exception as e: logger.debug(f"Error stopping HTTP reader for channel {self.channel_id}: {e}") - # Otherwise handle socket and transcode resources - if self.socket: - try: - self.socket.close() - except Exception as e: - logger.debug(f"Error closing socket for channel {self.channel_id}: {e}") - pass - - # Enhanced transcode process cleanup with immediate termination - if self.transcode_process: + # Kill proc before closing self.socket. Closing relay_read while the stream + # OS thread is blocked in select() on it does not reliably wake that select() + # on Linux. Killing ffmpeg closes its relay_write, sending EOF to the stream + # thread naturally. We close self.socket afterward as cleanup only. + proc = self.transcode_process + self.transcode_process = None # claim early so concurrent greenlets skip this block + if proc: try: logger.debug(f"Killing transcode process for channel {self.channel_id}") - self.transcode_process.kill() + proc.kill() # Give it a very short time to die try: - self.transcode_process.wait(timeout=0.5) + proc.wait(timeout=0.5) except subprocess.TimeoutExpired: logger.error(f"Failed to kill transcode process even with force for channel {self.channel_id}") except Exception as e: @@ -1464,18 +1461,26 @@ class StreamManager: # Final attempt: try to kill directly try: - self.transcode_process.kill() + proc.kill() except Exception as e: logger.error(f"Final kill attempt failed for channel {self.channel_id}: {e}") + # Close relay socket after proc death; stream thread has already unblocked via EOF. + if self.socket: + try: + self.socket.close() + except Exception as e: + logger.debug(f"Error closing socket for channel {self.channel_id}: {e}") + + if proc: # Explicitly close all subprocess pipes to prevent file descriptor leaks try: - if self.transcode_process.stdin: - self.transcode_process.stdin.close() - if self.transcode_process.stdout: - self.transcode_process.stdout.close() - if self.transcode_process.stderr: - self.transcode_process.stderr.close() + if proc.stdin: + proc.stdin.close() + if proc.stdout: + proc.stdout.close() + if proc.stderr: + proc.stderr.close() logger.debug(f"Closed all subprocess pipes for channel {self.channel_id}") except Exception as e: logger.debug(f"Error closing subprocess pipes for channel {self.channel_id}: {e}") @@ -1492,8 +1497,7 @@ class StreamManager: finally: self.stderr_reader_thread = None - self.transcode_process = None - self.transcode_process_active = False # Reset the flag + self.transcode_process_active = False # Clear transcode active key in Redis if available if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: @@ -1558,6 +1562,10 @@ class StreamManager: try: chunk = _os.read(fd, Config.CHUNK_SIZE) except OSError as e: + import errno as _errno + if e.errno == _errno.EAGAIN and (self.stop_requested or not self.running): + self.connected = False + return False logger.warning(f"Read error for channel {self.channel_id}: {e}") self.connected = False return False diff --git a/apps/proxy/live_proxy/output/ts/generator.py b/apps/proxy/live_proxy/output/ts/generator.py index 59ec8ab2..68ddacca 100644 --- a/apps/proxy/live_proxy/output/ts/generator.py +++ b/apps/proxy/live_proxy/output/ts/generator.py @@ -597,43 +597,6 @@ class StreamGenerator: except Exception as e: logger.error(f"Could not log client disconnect event: {e}") - # Schedule channel shutdown if no clients left - self._schedule_channel_shutdown_if_needed(local_clients) - - def _schedule_channel_shutdown_if_needed(self, local_clients): - """ - Schedule channel shutdown if there are no clients left and we're the owner. - """ - proxy_server = ProxyServer.get_instance() - - # If no clients left and we're the owner, schedule shutdown using the config value - if local_clients == 0 and proxy_server.am_i_owner(self.channel_id): - # When output/profile managers are present, remove_client already spawned - # handle_client_disconnect which will stop them and then stop the channel. - # Spawning a second shutdown greenlet here causes a redundant concurrent - # stop_channel call that can race against the first. - if (proxy_server.output_managers.get(self.channel_id) or - proxy_server.profile_managers.get(self.channel_id)): - return - - logger.info(f"No local clients left for channel {self.channel_id}, scheduling shutdown") - - def delayed_shutdown(): - # Use the config setting instead of hardcoded value - shutdown_delay = ConfigHelper.channel_shutdown_delay() # Use ConfigHelper - logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped") - gevent.sleep(shutdown_delay) # Replace time.sleep - - # After delay, check global client count - if self.channel_id in proxy_server.client_managers: - total = proxy_server.client_managers[self.channel_id].get_total_client_count() - if total == 0: - logger.info(f"Shutting down channel {self.channel_id} as no clients connected") - proxy_server.stop_channel(self.channel_id) - else: - logger.info(f"Not shutting down channel {self.channel_id}, {total} clients still connected") - - gevent.spawn(delayed_shutdown) def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None, buffer=None): """ diff --git a/apps/proxy/live_proxy/server.py b/apps/proxy/live_proxy/server.py index b4be63b2..f07ec06f 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -70,6 +70,7 @@ class ProxyServer: self.profile_managers = {} # {channel_id: {profile_id: OutputProfileManager}} self.profile_buffers = {} # {channel_id: {profile_id: StreamBuffer}} self._channel_names = {} + self._stopping_channels = set() # channels with an active stop_channel call in progress # Generate a unique worker ID pid = os.getpid() @@ -1267,6 +1268,10 @@ class ProxyServer: def stop_channel(self, channel_id): """Stop a channel with proper ownership handling""" + if channel_id in self._stopping_channels: + logger.debug(f"stop_channel already in progress for {channel_id}, ignoring duplicate call") + return + self._stopping_channels.add(channel_id) try: logger.info(f"Stopping channel {channel_id}") @@ -1316,7 +1321,6 @@ class ProxyServer: # Release ownership self.release_ownership(channel_id) - logger.info(f"Released ownership of channel {channel_id}") # Log channel stop event (after cleanup, before releasing ownership section ends) try: @@ -1404,6 +1408,8 @@ class ProxyServer: except Exception as e: logger.error(f"Error stopping channel {channel_id}: {e}") return False + finally: + self._stopping_channels.discard(channel_id) def check_inactive_channels(self): """Check for inactive channels (no clients) and stop them""" @@ -1615,6 +1621,10 @@ class ProxyServer: # Safety: if we have a stream_manager, we ARE the real owner # but the Redis key may have expired. Try to re-acquire. if channel_id in self.stream_managers: + # Ownership was explicitly released by an active stop_channel call - + # don't fight the shutdown by trying to re-acquire. + if channel_id in self._stopping_channels: + continue logger.warning( f"Ownership gap for {channel_id}: this worker has stream_manager " f"but am_i_owner returned False. Attempting re-acquisition." From 8ac0aa4f5f78d73345b3ae157df409fb69860bbd Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 12:33:37 -0500 Subject: [PATCH 003/125] fix: update default output profiles to include additional ffmpeg parameters for improved processing speed. --- core/migrations/0024_outputprofile.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/migrations/0024_outputprofile.py b/core/migrations/0024_outputprofile.py index 772ea038..4048e47a 100644 --- a/core/migrations/0024_outputprofile.py +++ b/core/migrations/0024_outputprofile.py @@ -11,12 +11,15 @@ def create_default_output_profiles(apps, schema_editor): 'command': 'ffmpeg', 'parameters': ( '-fflags +discardcorrupt+genpts+nobuffer ' + '-probesize 512K ' + '-analyzeduration 0 ' '-i pipe:0 ' '-map 0 ' '-c:v copy ' '-c:a ac3 ' '-b:a 384k ' '-max_muxing_queue_size 4096 ' + '-flush_packets 1 ' '-mpegts_flags +pat_pmt_at_frames+resend_headers+initial_discontinuity ' '-f mpegts pipe:1' ), @@ -30,6 +33,8 @@ def create_default_output_profiles(apps, schema_editor): 'command': 'ffmpeg', 'parameters': ( '-fflags +discardcorrupt+genpts+nobuffer ' + '-probesize 512K ' + '-analyzeduration 0 ' '-i pipe:0 ' '-map 0 ' '-c:v copy ' @@ -37,6 +42,7 @@ def create_default_output_profiles(apps, schema_editor): '-b:a 192k ' '-ac 2 ' '-max_muxing_queue_size 4096 ' + '-flush_packets 1 ' '-mpegts_flags +pat_pmt_at_frames+resend_headers+initial_discontinuity ' '-f mpegts pipe:1' ), From a3511d636382c60a639a440cc0e80851a6c48354 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 17:43:15 -0500 Subject: [PATCH 004/125] fix: update profile save method to include expiration date in custom properties --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d3d062..651d9492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **XC profile `exp_date` not updating on account refresh.** `refresh_account_profiles` saved the freshly-fetched `custom_properties` with `update_fields=['custom_properties']`, which excluded `exp_date` from the SQL `UPDATE`. The model's `save()` method parses the new expiry from `custom_properties` and assigns it to `self.exp_date`, but that value was silently dropped because the column was not listed in `update_fields`. Added `'exp_date'` to the `update_fields` list so both columns are written together. - **~25-second transcode startup delay and worker freeze when starting ffmpeg under gevent+uWSGI.** Enabling gevent cooperative multitasking (see Changed above) exposed a deadlock: `fork()` hangs indefinitely in gevent's `_before_fork` pthread_atfork handler when called from any thread while gevent is running - including from real OS threads. `subprocess.Popen`, which all three ffmpeg spawn sites used, calls `fork()` internally, stalling the uWSGI worker for ~25 seconds or freezing it entirely and blocking all other clients on that worker. - `input/manager.py`: replaced `subprocess.Popen` with `os.posix_spawn` + a minimal `_SpawnedProcess` wrapper. `os.posix_spawn` is POSIX-specified to skip pthread_atfork handlers entirely. - `output/fmp4/manager.py`, `output/profile/manager.py`: replaced `subprocess.Popen` with a new shared `posix_spawn_proc()` helper in `live_proxy/utils.py`. The helper also sets `O_NONBLOCK` on the stdin pipe write-end: under gevent, `threading.Thread` is monkey-patched to greenlets, so a blocking write to a full 64 KB pipe would stall the entire hub. `_write_all()` in both output managers now treats a `None` return (EAGAIN on the non-blocking FD) as a cooperative wait via `select.select()` rather than a fatal error. diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 55ea7ff7..db6b1b6e 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -2955,7 +2955,7 @@ def refresh_account_profiles(account_id): existing_props = profile.custom_properties or {} existing_props.update(profile_account_info) profile.custom_properties = existing_props - profile.save(update_fields=['custom_properties']) + profile.save(update_fields=['custom_properties', 'exp_date']) profiles_updated += 1 logger.info(f"Updated account information for profile '{profile.name}' ({profiles_updated}/{profiles.count()})") From a71dcb31a0563f2310632cedf28a51fd58fbaafe Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 19:05:44 -0500 Subject: [PATCH 005/125] fix: ensure proper cleanup of client managers on channel disconnect --- apps/proxy/live_proxy/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/proxy/live_proxy/server.py b/apps/proxy/live_proxy/server.py index f07ec06f..879de2ec 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -1990,7 +1990,9 @@ class ProxyServer: logger.info(f"Non-owner cleanup: Removed stream buffer for channel {channel_id}") if channel_id in self.client_managers: - del self.client_managers[channel_id] + client_manager = self.client_managers.pop(channel_id) + if hasattr(client_manager, 'stop'): + client_manager.stop() logger.info(f"Non-owner cleanup: Removed client manager for channel {channel_id}") # Stop profile managers owned by this worker, but only for profiles From 28347a239b23a6409b63b87c12648cb8ccdf8eb4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 20:17:59 -0500 Subject: [PATCH 006/125] Enhancement: add django-redis to dependencies for improved caching support --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8519b755..c8c958cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "channels", "channels-redis==4.3.0", "django-filter", + "django-redis", "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", From 108bd52e7fe982bdda5f070c674521b0845fb0ff Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 20:23:57 -0500 Subject: [PATCH 007/125] perf: replace local cache with django-redis for improved EPG caching --- CHANGELOG.md | 2 ++ dispatcharr/settings.py | 32 +++++++++++++++++++------------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 651d9492..11121f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`_cleanup_local_resources` skipped `ClientManager.stop()` on channel removal.** `ProxyServer._cleanup_local_resources` deleted each channel's `ClientManager` entry with `del`, which removed it from the dict but gave the object no signal to terminate. The per-channel heartbeat greenlet inside the manager continued running until it next checked its running flag and found the channel absent from Redis. The entry is now removed with `pop()` and `stop()` is called on the captured manager before it is discarded, terminating the heartbeat immediately on channel cleanup. - **XC profile `exp_date` not updating on account refresh.** `refresh_account_profiles` saved the freshly-fetched `custom_properties` with `update_fields=['custom_properties']`, which excluded `exp_date` from the SQL `UPDATE`. The model's `save()` method parses the new expiry from `custom_properties` and assigns it to `self.exp_date`, but that value was silently dropped because the column was not listed in `update_fields`. Added `'exp_date'` to the `update_fields` list so both columns are written together. - **~25-second transcode startup delay and worker freeze when starting ffmpeg under gevent+uWSGI.** Enabling gevent cooperative multitasking (see Changed above) exposed a deadlock: `fork()` hangs indefinitely in gevent's `_before_fork` pthread_atfork handler when called from any thread while gevent is running - including from real OS threads. `subprocess.Popen`, which all three ffmpeg spawn sites used, calls `fork()` internally, stalling the uWSGI worker for ~25 seconds or freezing it entirely and blocking all other clients on that worker. - `input/manager.py`: replaced `subprocess.Popen` with `os.posix_spawn` + a minimal `_SpawnedProcess` wrapper. `os.posix_spawn` is POSIX-specified to skip pthread_atfork handlers entirely. @@ -107,6 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Performance +- **EPG HTTP response cache replaced with `django-redis`.** The default Django cache backend was `LocMemCache`, an in-process memory store. With multiple uWSGI workers, each worker independently generated and cached the full EPG XML in its own heap; with 4 workers the peak memory cost was up to 4 times the size of the EPG document. The cache backend is now `django_redis.cache.RedisCache`, backed by the same Redis instance used by `channels_redis`, so a single cached EPG copy is shared across all workers. - **`AutoSyncAdvanced` and `LogoForm` are now lazy-loaded in the M3U group filter.** Both components are large and only needed when the user opens the gear modal or logo upload modal. Wrapping them in `React.lazy` + `Suspense` removes them from the initial bundle and defers their parse/execute cost until first use. — Thanks [@nick4810](https://github.com/nick4810) - **Auto-sync at scale**: the new override-aware sync flow is more capable than the prior path but the implementation choices below keep it viable on libraries with thousands of channels. — Thanks [@CodeBormen](https://github.com/CodeBormen) - **Bulk writes throughout `sync_auto_channels`.** Per-row `Channel.objects.create()` and `.save()` calls were replaced with `bulk_create()` and `bulk_update()` paths that batch the entire group's create + update sets into single round-trips. The renumber pass collects all dirty channels into one list and flushes with a single `bulk_update` at the end of the loop. `ChannelStream.order` writes were similarly consolidated into a single `bulk_update`. diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 530b56c7..e8818108 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -116,19 +116,6 @@ DATABASE_CONN_MAX_AGE = 0 # Close after each request; gevent makes per-greenlet # Disable atomic requests for performance-sensitive views ATOMIC_REQUESTS = False -# Cache settings - add caching for EPG operations -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - "LOCATION": "dispatcharr-epg-cache", - "TIMEOUT": 3600, # 1 hour cache timeout - "OPTIONS": { - "MAX_ENTRIES": 10000, - "CULL_FREQUENCY": 3, # Purge 1/3 of entries when max is reached - }, - } -} - # Timeouts for external connections REQUESTS_TIMEOUT = 30 # Seconds for external API requests @@ -198,6 +185,25 @@ CHANNEL_LAYERS = { }, } +_django_redis_opts = { + "CLIENT_CLASS": "django_redis.client.DefaultClient", +} +if REDIS_SSL: + # rediss:// in the URL already enables SSL; pass cert paths and verify + # settings separately via CONNECTION_POOL_KWARGS. + _django_redis_opts["CONNECTION_POOL_KWARGS"] = { + k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl" + } + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": _channels_redis_url, + "TIMEOUT": 3600, + "OPTIONS": _django_redis_opts, + } +} + # PostgreSQL TLS configuration (defined before DATABASES for module-level access) POSTGRES_SSL = os.environ.get("POSTGRES_SSL", "false").lower() == "true" POSTGRES_SSL_MODE = os.environ.get("POSTGRES_SSL_MODE", "verify-full") From 50e23cbfd77d61dcb05dc4a977c0cf16a40e09a0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 20:17:59 -0500 Subject: [PATCH 008/125] Enhancement: add django-redis to dependencies for improved caching support --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8519b755..c8c958cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "channels", "channels-redis==4.3.0", "django-filter", + "django-redis", "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", From 322c7c9323e466467e7284589263de11ea43e54d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 19 May 2026 09:05:18 -0500 Subject: [PATCH 009/125] Security: Update Django dependency to version 6.0.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c8c958cb..8542216c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ license = "AGPL-3.0-only" requires-python = ">=3.13" dynamic = ["version"] dependencies = [ - "Django==6.0.4", + "Django==6.0.5", "psycopg2-binary==2.9.12", "celery[redis]==5.6.3", "djangorestframework==3.17.1", From 1f11f80e1ee81a9a1a5340f5ee0a066a553aa4f2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 19 May 2026 09:05:18 -0500 Subject: [PATCH 010/125] Security: Update Django dependency to version 6.0.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c8c958cb..8542216c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ license = "AGPL-3.0-only" requires-python = ">=3.13" dynamic = ["version"] dependencies = [ - "Django==6.0.4", + "Django==6.0.5", "psycopg2-binary==2.9.12", "celery[redis]==5.6.3", "djangorestframework==3.17.1", From 1d9741a0195d3d8c2f5ed9a55668024f5463e0e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 19 May 2026 09:11:44 -0500 Subject: [PATCH 011/125] changelog: Update changelog for django update. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11121f3d..0757ccca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Updated `Django` 6.0.4 → 6.0.5, resolving the following CVEs: + - **CVE-2026-6907**: Cache leak exposing sensitive information via `cache.get_or_set()` race condition. + - **CVE-2026-35192**: Persistent session cookies retaining sensitive information after logout. + - **CVE-2026-5766**: Improper handling of length parameter inconsistency in multipart form parsing. + ### Added - **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted: @@ -79,6 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Channel-number duplicates are explicitly allowed.** Sync's `used_numbers` seed still avoids assigning the same number to two newly-created auto channels in the same run, so accidental duplication during sync remains impossible. Manual or override-driven duplication is permitted; downstream client behavior on duplicates varies by client. - **gevent cooperative multitasking enabled in all uWSGI workers.** `gevent-early-monkey-patch = true` and `import = dispatcharr.gevent_patch` are now set in all four uWSGI configuration files (`uwsgi.ini`, `uwsgi.modular.ini`, `uwsgi.dev.ini`, `uwsgi.debug.ini`). The new `dispatcharr/gevent_patch.py` module ensures gevent's stdlib monkey-patching is applied before application code loads (replacing blocking socket, threading, and OS primitives with cooperative gevent equivalents) and installs psycogreen's wait-callback so psycopg2 database I/O yields to the gevent hub instead of blocking the OS thread. Without these settings, any blocking psycopg2, `requests`, or DNS call froze every greenlet on the affected worker for the duration of the call. - **WebSocket group sends rewritten to bypass asyncio in gevent workers.** `gevent.monkey.patch_all()` removes `select.epoll` from the stdlib `select` module, which breaks asyncio event loop creation in threadpool threads. The previous `send_websocket_update` and `_send_async` paths dispatched via `async_to_sync(channel_layer.group_send)()` from a threadpool thread, which failed with `AttributeError: module 'select' has no attribute 'epoll'`; the exception was caught at WARNING level, so every WebSocket push from REST views was silently discarded. A new `_gevent_ws_send()` in `core/utils.py` replicates the channels_redis 4.x `group_send` wire format directly using the synchronous Redis client - group membership lookup from the `asgi:group:{name}` sorted set, msgpack serialization with a 12-byte random prefix, and `ZADD` to per-channel sorted sets. Both `send_websocket_update()` and `_send_async()` detect gevent patching at call time and dispatch via `gevent.spawn(_gevent_ws_send)` instead. Celery workers, which are not gevent-patched, continue to use the `async_to_sync` path unchanged. +- Dependency updates: + - `Django` 6.0.4 → 6.0.5 (security patch; see Security section) ### Fixed From 594596d4f586c4947fbc1f7772e20015f990471e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 19 May 2026 14:37:33 -0500 Subject: [PATCH 012/125] Enhancement: update channel card tooltip to use stable 'started_at' timestamp and refactor getStartDate utility --- CHANGELOG.md | 1 + apps/proxy/live_proxy/channel_status.py | 3 ++- .../components/cards/StreamConnectionCard.jsx | 2 +- .../utils/cards/StreamConnectionCardUtils.js | 19 +++---------------- 4 files changed, 7 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0757ccca..c6841390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **2-second "stream thread did not terminate within timeout" warning on every channel stop.** `_close_socket` in `input/manager.py` was closing the relay pipe read-end (`self.socket`) before killing the ffmpeg process. The stream OS thread blocks in `select()` on that fd; on Linux, closing an fd from another thread while a `select()` is in progress on it does not reliably interrupt the call (POSIX allows this to be undefined). The thread stayed blocked for the full chunk-timeout (5 s), and `stream_thread.join(timeout=2.0)` always expired first. Fixed by killing ffmpeg first: when ffmpeg dies its copy of the relay write-end closes, delivering EOF to `select()` immediately. `self.socket` is closed afterward as cleanup only. - **Duplicate `stop_channel` calls on every channel shutdown.** `StreamGenerator._cleanup` triggered channel shutdown via two parallel paths: `client_manager.remove_client()` (which fires `handle_client_disconnect` on the owning worker, the correct path) and `_schedule_channel_shutdown_if_needed` (which independently spawned a delayed `stop_channel` greenlet). The duplicate call was suppressed by the `_stopping_channels` guard but produced a redundant log entry and unnecessary greenlet on every shutdown. `_schedule_channel_shutdown_if_needed` has been removed; `handle_client_disconnect` is the sole shutdown trigger. - **Concurrent greenlets could re-enter `_close_socket` during `proc.wait()`.** `self.transcode_process` was set to `None` at the end of the `if proc:` block rather than immediately after capturing the reference. Under gevent, `proc.wait(timeout=0.5)` yields the hub, allowing a second greenlet to enter `_close_socket`, find `self.transcode_process` still set, and attempt a second kill+close. `self.transcode_process = None` is now assigned immediately after `proc = self.transcode_process` so concurrent callers see `None` and skip the block. +- **Channel card "started at" tooltip jumping by 1 second on every stats poll.** The Stats page channel card tooltip showed the channel start time by computing `Date.now() - uptime * 1000` on every render. Because `uptime` is a server-side elapsed-seconds value recomputed each response, this reconstruction drifted by up to 1 second per tick. The basic channel info path now emits `started_at` (the raw Unix timestamp from Redis) alongside `uptime`, matching what the detailed stats path already sent. The frontend `getStartDate` helper now accepts the stable `started_at` timestamp directly, so the displayed wall-clock time is fixed from the first poll and never changes. ### Performance diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index fd82d4f7..3f75693e 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -399,7 +399,8 @@ class ChannelStatus: 'owner': metadata.get(ChannelMetadataField.OWNER), 'buffer_index': int(buffer_index_value) if buffer_index_value else 0, 'client_count': client_count, - 'uptime': uptime + 'uptime': uptime, + 'started_at': created_at if created_at > 0 else None, } channel_name = metadata.get(ChannelMetadataField.CHANNEL_NAME) diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 57df1cfc..30509a86 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -615,7 +615,7 @@ const StreamConnectionCard = ({ - +
{toFriendlyDuration(uptime, 'seconds')} diff --git a/frontend/src/utils/cards/StreamConnectionCardUtils.js b/frontend/src/utils/cards/StreamConnectionCardUtils.js index e3c5720e..574c3b2f 100644 --- a/frontend/src/utils/cards/StreamConnectionCardUtils.js +++ b/frontend/src/utils/cards/StreamConnectionCardUtils.js @@ -17,22 +17,9 @@ export const getBufferingSpeedThreshold = (proxySetting) => { return 1.0; // Default fallback }; -export const getStartDate = (uptime) => { - // Get the current date and time - const currentDate = new Date(); - // Calculate the start date by subtracting uptime (in milliseconds) - const startDate = new Date(currentDate.getTime() - uptime * 1000); - // Format the date as a string (you can adjust the format as needed) - return startDate.toLocaleString({ - weekday: 'short', // optional, adds day of the week - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: true, // 12-hour format with AM/PM - }); +export const getStartDate = (startedAt) => { + if (!startedAt) return 'Unknown'; + return new Date(startedAt * 1000).toLocaleString(); }; export const getM3uAccountsMap = (m3uAccounts) => { From f84265f2a5ec1564bbc5076b729e1691b413b734 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 19 May 2026 21:14:06 -0500 Subject: [PATCH 013/125] Enhancement: add Comskip mode and hardware acceleration settings to DVR configuration. Update default comskip config. --- CHANGELOG.md | 10 ++ apps/channels/tasks.py | 21 ++++ core/models.py | 12 +++ docker/comskip.ini | 101 +++++++++++++++++- .../forms/settings/DvrSettingsForm.jsx | 52 +++++++-- .../__tests__/DvrSettingsForm.test.jsx | 32 ++++++ .../forms/settings/DvrSettingsFormUtils.js | 2 + frontend/src/utils/pages/SettingsUtils.js | 4 + 8 files changed, 224 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6841390..c8058317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option: + - **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut. + - **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this. +- **Comskip hardware acceleration setting.** DVR Settings now includes a "Hardware acceleration" option that passes a hardware-decode flag to the comskip binary, reducing CPU load during commercial detection on capable hosts: + - **None** (default): software decode. + - **NVIDIA NVDEC (`--cuvid`)**: requires the NVIDIA container toolkit and a supported GPU inside the container. + - **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container. - **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted: - `/hdhr/output_profile//lineup.json` - output profile only - `/hdhr//output_profile//lineup.json` - channel profile + output profile @@ -37,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations. +- **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected. - **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs. - **Stream Settings descriptions added.** Default User Agent, Default Stream Profile, Default Output Format, M3U Hash Key, and HDHR Default Output Profile all now have inline description text below their labels explaining their purpose and effect. - **System Settings descriptions added.** Maximum System Events, Preferred Region, and Auto-Import Mapped Files now have inline description text. The redundant description paragraph that duplicated the accordion header text has been removed. @@ -91,6 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead. - **`_cleanup_local_resources` skipped `ClientManager.stop()` on channel removal.** `ProxyServer._cleanup_local_resources` deleted each channel's `ClientManager` entry with `del`, which removed it from the dict but gave the object no signal to terminate. The per-channel heartbeat greenlet inside the manager continued running until it next checked its running flag and found the channel absent from Redis. The entry is now removed with `pop()` and `stop()` is called on the captured manager before it is discarded, terminating the heartbeat immediately on channel cleanup. - **XC profile `exp_date` not updating on account refresh.** `refresh_account_profiles` saved the freshly-fetched `custom_properties` with `update_fields=['custom_properties']`, which excluded `exp_date` from the SQL `UPDATE`. The model's `save()` method parses the new expiry from `custom_properties` and assigns it to `self.exp_date`, but that value was silently dropped because the column was not listed in `update_fields`. Added `'exp_date'` to the `update_fields` list so both columns are written together. - **~25-second transcode startup delay and worker freeze when starting ffmpeg under gevent+uWSGI.** Enabling gevent cooperative multitasking (see Changed above) exposed a deadlock: `fork()` hangs indefinitely in gevent's `_before_fork` pthread_atfork handler when called from any thread while gevent is running - including from real OS threads. `subprocess.Popen`, which all three ffmpeg spawn sites used, calls `fork()` internally, stalling the uWSGI worker for ~25 seconds or freezing it entirely and blocking all other clients on that worker. diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index a6a1902e..91748652 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -3180,7 +3180,11 @@ def comskip_process_recording(recording_id: int): _ws('started', {"title": (cp.get('program') or {}).get('title') or os.path.basename(file_path)}) try: + comskip_mode = CoreSettings.get_dvr_comskip_mode() + hw_accel = CoreSettings.get_dvr_comskip_hw_accel() cmd = [comskip_bin, "--output", os.path.dirname(file_path)] + if hw_accel != "none": + cmd.insert(1, f"--{hw_accel}") # Prefer user-specified INI, fall back to known defaults ini_candidates = [] try: @@ -3300,6 +3304,19 @@ def comskip_process_recording(recording_id: int): _ws('skipped', {"reason": "no_commercials", "commercials": 0}) return "no_commercials" + if comskip_mode == "mark": + cp["comskip"] = { + "status": "completed", + "mode": "mark", + "edl": os.path.basename(edl_path), + "commercials": len(commercials), + } + if selected_ini: + cp["comskip"]["ini_path"] = selected_ini + _persist_custom_properties() + _ws('completed', {"commercials": len(commercials), "mode": "mark"}) + return "ok" + workdir = os.path.dirname(file_path) parts = [] try: @@ -3340,6 +3357,10 @@ def comskip_process_recording(recording_id: int): for pth in parts: try: os.remove(pth) except Exception: pass + try: + os.remove(edl_path) + except Exception: + pass cp["comskip"] = { "status": "completed", diff --git a/core/models.py b/core/models.py index 293a40f3..0ec4efda 100644 --- a/core/models.py +++ b/core/models.py @@ -313,6 +313,8 @@ class CoreSettings(models.Model): "movie_fallback_template": "Movies/{start}.mkv", "comskip_enabled": False, "comskip_custom_path": "", + "comskip_mode": "cut", + "comskip_hw_accel": "none", "pre_offset_minutes": 0, "post_offset_minutes": 0, "series_rules": [], @@ -342,6 +344,16 @@ class CoreSettings(models.Model): def get_dvr_comskip_enabled(cls): return bool(cls.get_dvr_settings().get("comskip_enabled", False)) + @classmethod + def get_dvr_comskip_mode(cls): + mode = cls.get_dvr_settings().get("comskip_mode", "cut") + return mode if mode in ("cut", "mark") else "cut" + + @classmethod + def get_dvr_comskip_hw_accel(cls): + hw = cls.get_dvr_settings().get("comskip_hw_accel", "none") + return hw if hw in ("none", "cuvid", "qsv") else "none" + @classmethod def get_dvr_comskip_custom_path(cls): return cls.get_dvr_settings().get("comskip_custom_path", "") diff --git a/docker/comskip.ini b/docker/comskip.ini index 5dc94fd0..e963eae9 100644 --- a/docker/comskip.ini +++ b/docker/comskip.ini @@ -1,6 +1,101 @@ -; Minimal default comskip config -edl_out=1 -output_edl=1 +; comskip.ini - tuned for recorded live TV +; Source reference: https://github.com/erikkaashoek/Comskip/blob/master/comskip.c + +[Main Settings] +; Detection method bitmask - add the values together for the methods you want: +; 1 = black/uniform frame 2 = logo detection +; 4 = scene change 8 = resolution change +; 16 = closed captions 32 = aspect ratio change +; 64 = silence 255 = all methods +; Comskip default (no ini): 107 (1+2+8+32+64, no scene change or CC) +; Recommended for recorded TV: 127 (all seven methods) +detect_method=127 + +; Verbosity level. 0=silent, 5=moderate, 10=maximum. Use 10 temporarily to diagnose misses. verbose=0 + +; Number of CPU threads to use. 0 = use all available cores. thread_count=0 +[Output] +; Write an EDL cut-list file. Dispatcharr reads this to drive FFmpeg, which +; permanently cuts the commercials out of the recording file and replaces it in place. +output_edl=1 + +; Action code written in the third column of each EDL line. +; The format originates from MPlayer (values 0-1); Kodi adopted it and extended it (values 2-3). +; 0 = Cut - player removes the section entirely; total duration shrinks (MPlayer + Kodi) +; 1 = Mute - audio muted, video keeps playing (MPlayer + Kodi) +; 2 = Scene Marker - marks a point of interest; used for chapter-style navigation (Kodi only) +; 3 = Commercial Break - auto-skipped once on first playthrough; user can rewind into it (Kodi only) +; Dispatcharr's cut mode ignores this value (only timestamps matter for FFmpeg cutting). +; For mark mode the file is kept and handed to the player, so 3 is correct for Kodi. +; Default in comskip is 0; without this override Kodi would treat ranges as hard cuts. +edl_skip_field=3 + +; Suppress the default .txt stats file comskip writes alongside the EDL. +output_default=0 + +[Commercial Break Timing] +; Maximum total length in seconds for one commercial break (source default: 600) +max_commercialbreak=600 + +; Minimum total length in seconds for a break to be marked (source default: 20) +; 25s is a safe floor for US broadcast TV - avoids marking short interstitials +min_commercialbreak=25 + +; Maximum length in seconds for a single spot within a break (source default: 120) +max_commercial_size=120 + +; Minimum length in seconds for a single spot (source default: 4) +min_commercial_size=5 + +; Minimum show segment in seconds; blocks shorter than this are suspect (source default: 120) +min_show_segment_length=120 + +[Black Frame Detection] +; A frame is NOT black if any sampled pixel exceeds this brightness (0-255, source default: 60) +max_brightness=60 + +; Secondary check: if any pixel exceeds this value, comskip also checks the average (source default: 40) +test_brightness=40 + +; Maximum average brightness for a frame to be classified as black/dim (0-255, source default: 19) +max_avg_brightness=19 + +; Uniform-color (non-black slate) detection sensitivity. Lower = stricter. (source default: 500) +; Solid-color slates at break boundaries are also used as cut points. +non_uniformity=500 + +[Logo Detection] +; Minimum fraction of frames in the show that must carry the logo to enable logo detection (source default: 0.40) +; Lower this if the station watermark disappears often during the program itself. +logo_fraction=0.40 + +; Upper bound: if more than this fraction of frames has a logo, detection is disabled (source default: 0.92) +logo_percentile=0.92 + +; Edge-match similarity threshold for confirming the logo is present (0.0-1.0, source default: 0.80) +; Lower slightly (e.g. 0.75) if comskip fails to track the logo through compression artifacts. +logo_threshold=0.75 + +; Minimum fraction of a block that must contain the logo for it to score as show content (source default: 0.25) +logo_percentage_threshold=0.25 + +; Maximum fraction of screen area the logo may occupy - larger regions are rejected (source default: 0.12) +logo_max_percentage_of_screen=0.12 + +; Temporal smoothing filter for logo presence. 0 = off. (source default: 0) +; Set to 4 if brief compression artifacts cause the logo to flicker in/out. +logo_filter=0 + +[Silence Detection] +; Frames with audio volume below this value are treated as silence (source default: 100) +; Raise this if quiet dialogue or music beds are triggering false cut points. +max_silence=100 + +[Live TV] +; Set to 1 only if comskip is processing the file WHILE it is still being recorded. +; For post-recording analysis (the normal Dispatcharr use case) keep this at 0. +live_tv=0 + diff --git a/frontend/src/components/forms/settings/DvrSettingsForm.jsx b/frontend/src/components/forms/settings/DvrSettingsForm.jsx index ee79db57..959304ff 100644 --- a/frontend/src/components/forms/settings/DvrSettingsForm.jsx +++ b/frontend/src/components/forms/settings/DvrSettingsForm.jsx @@ -1,5 +1,5 @@ import useSettingsStore from '../../../store/settings.jsx'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { getChangedSettings, parseSettings, @@ -13,6 +13,7 @@ import { Flex, Group, NumberInput, + Select, Stack, Switch, Text, @@ -34,6 +35,7 @@ const DvrSettingsForm = React.memo(({ active }) => { path: '', exists: false, }); + const isSavingRef = useRef(false); const form = useForm({ mode: 'controlled', @@ -45,7 +47,7 @@ const DvrSettingsForm = React.memo(({ active }) => { }, [active]); useEffect(() => { - if (settings) { + if (settings && !isSavingRef.current) { const formValues = parseSettings(settings); form.setValues(formValues); @@ -114,17 +116,27 @@ const DvrSettingsForm = React.memo(({ active }) => { const onSubmit = async () => { setSaved(false); + isSavingRef.current = true; const changedSettings = getChangedSettings(form.getValues(), settings); - // Update each changed setting in the backend (create if missing) try { await saveChangedSettings(settings, changedSettings); - + isSavingRef.current = false; + const latestSettings = useSettingsStore.getState().settings; + if (latestSettings) { + const formValues = parseSettings(latestSettings); + form.setValues(formValues); + if (formValues['comskip_custom_path']) { + setComskipConfig((prev) => ({ + path: formValues['comskip_custom_path'], + exists: prev.exists, + })); + } + } setSaved(true); } catch (error) { - // Error notifications are already shown by API functions - // Just don't show the success message + isSavingRef.current = false; console.error('Error saving settings:', error); } }; @@ -136,13 +148,39 @@ const DvrSettingsForm = React.memo(({ active }) => { )} + ({ onChange={onChange} /> ), + Select: ({ label, id, name, data, ...rest }) => ( + + ), Text: ({ children }) => {children}, TextInput: ({ label, id, name, placeholder, ...rest }) => ( { }); }); + it('renders the comskip_mode select', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('comskip_mode')).toBeInTheDocument(); + }); + }); + + it('renders the comskip_hw_accel select', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('comskip_hw_accel')).toBeInTheDocument(); + }); + }); + it('renders the file input for comskip.ini upload', async () => { render(); await waitFor(() => { diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js index fcc5a4c0..8357b047 100644 --- a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js @@ -16,6 +16,8 @@ export const getDvrSettingsFormInitialValues = () => { movie_fallback_template: '', comskip_enabled: false, comskip_custom_path: '', + comskip_mode: 'cut', + comskip_hw_accel: 'none', pre_offset_minutes: 0, post_offset_minutes: 0, }; diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index a88bbd01..e3efd3bc 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -48,6 +48,8 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'movie_fallback_template', 'comskip_enabled', 'comskip_custom_path', + 'comskip_mode', + 'comskip_hw_accel', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules', @@ -306,6 +308,8 @@ export const parseSettings = (settings) => { ? dvrSettings.comskip_enabled : Boolean(dvrSettings.comskip_enabled); parsed.comskip_custom_path = dvrSettings.comskip_custom_path; + parsed.comskip_mode = dvrSettings.comskip_mode || 'cut'; + parsed.comskip_hw_accel = dvrSettings.comskip_hw_accel || 'none'; parsed.pre_offset_minutes = typeof dvrSettings.pre_offset_minutes === 'number' ? dvrSettings.pre_offset_minutes From fb89a7c97753eaf25a03d9935bdc6c23506ff255 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 16:46:32 -0500 Subject: [PATCH 014/125] Enhancement: refactor ChannelsTable and CustomTable components for improved selection handling and performance Bug Fix: Shift select on channel table. --- CHANGELOG.md | 7 +++ .../src/components/tables/ChannelsTable.jsx | 5 +- .../tables/CustomTable/CustomTableBody.jsx | 5 +- .../components/tables/CustomTable/index.jsx | 46 +++++-------------- 4 files changed, 25 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8058317..f0ee26a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Duplicate `stop_channel` calls on every channel shutdown.** `StreamGenerator._cleanup` triggered channel shutdown via two parallel paths: `client_manager.remove_client()` (which fires `handle_client_disconnect` on the owning worker, the correct path) and `_schedule_channel_shutdown_if_needed` (which independently spawned a delayed `stop_channel` greenlet). The duplicate call was suppressed by the `_stopping_channels` guard but produced a redundant log entry and unnecessary greenlet on every shutdown. `_schedule_channel_shutdown_if_needed` has been removed; `handle_client_disconnect` is the sole shutdown trigger. - **Concurrent greenlets could re-enter `_close_socket` during `proc.wait()`.** `self.transcode_process` was set to `None` at the end of the `if proc:` block rather than immediately after capturing the reference. Under gevent, `proc.wait(timeout=0.5)` yields the hub, allowing a second greenlet to enter `_close_socket`, find `self.transcode_process` still set, and attempt a second kill+close. `self.transcode_process = None` is now assigned immediately after `proc = self.transcode_process` so concurrent callers see `None` and skip the block. - **Channel card "started at" tooltip jumping by 1 second on every stats poll.** The Stats page channel card tooltip showed the channel start time by computing `Date.now() - uptime * 1000` on every render. Because `uptime` is a server-side elapsed-seconds value recomputed each response, this reconstruction drifted by up to 1 second per tick. The basic channel info path now emits `started_at` (the raw Unix timestamp from Redis) alongside `uptime`, matching what the detailed stats path already sent. The frontend `getStartDate` helper now accepts the stable `started_at` timestamp directly, so the displayed wall-clock time is fixed from the first poll and never changes. +- **Shift+click range selection in the channels table broken after row memoization.** After `MemoizedTableRow` was introduced, `handleShiftSelect` captured `lastClickedId` from its render-time closure. Because the memo comparator intentionally excludes callback function references, unselected rows retained the stale closure where `lastClickedId === null`, so every shift+click from a previously unchecked row fell through to a plain toggle instead of selecting the range. Added `lastClickedIdRef` and `allRowIdsRef` alongside the existing `selectedTableIdsRef`; `handleShiftSelect` now reads from those refs so every row uses the current anchor ID and full ID list regardless of which render produced the closure. +- **Selected rows in the channels table did not show the teal highlight.** `MemoizedTableRow` applied `backgroundColor: '#163632'` based on `row.getIsSelected()` from TanStack Table's API. Because `state.rowSelection` was never wired into `useReactTable`, `row.getIsSelected()` always returned `false` and selected rows remained unstyled. Changed to use the `isSelected` prop, which is correctly derived from `selectedTableIdsSet` and already tracked by the memo comparator. +- **`blur` event listener in `useTable` leaked on component unmount.** The `useEffect` cleanup function called `window.removeEventListener('blur', ...)` with a newly created anonymous function literal that never matched the handler registered at setup time, so the listener was never removed. Extracted to a named `handleBlur` constant so setup and cleanup reference the same function. ### Performance @@ -143,6 +146,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`JsonResponse` for ID list and channel summary endpoints.** The `stream_ids`, `channel_ids`, and channel `summary` endpoints now use `django.http.JsonResponse` instead of DRF's `Response`, bypassing the DRF renderer pipeline (content-type negotiation, serializer dispatch) for responses that are already plain Python structures. Removes overhead on every channel-table load and stream-table load. - **Logo queryset annotates `channel_count` to eliminate N+1 in `LogoSerializer`.** `LogoViewSet.get_queryset()` now annotates each row with `Count('channels')`. `LogoSerializer.get_channel_count()` and `get_is_used()` read the annotation directly instead of issuing a separate `COUNT(*)` per logo. The `used=true` and `used=false` list filters use the annotation for their conditions, removing the `DISTINCT` that was previously required. - **`ChannelProfileSerializer` reads prefetched memberships.** `ChannelProfileViewSet.get_queryset()` now prefetches enabled `ChannelProfileMembership` rows into `enabled_memberships`. `ChannelProfileSerializer.get_channels()` uses the prefetched set when available, eliminating one query per profile in any response that lists multiple profiles. +- **Channel table selection re-render reduction.** + - Removed `isShiftKeyDown` React state from `useTable`. Setting it on every `keydown`/`keyup` event triggered a re-render of any table consumer (`ChannelsTable`, `CustomTableBody`, ~50 memoized row comparisons) each time shift was pressed or released. The visual shift-key effect is handled entirely by `document.body.classList` manipulation, so the React state served no purpose. + - Removed the `selectedChannelIds` reactive Zustand store subscription from `ChannelsTable`. Each checkbox click wrote to both local `selectedTableIds` state and the store via `onRowSelectionChange`, causing two sequential re-renders of `ChannelsTable` per click. The two consumers of this subscription (`deleteChannel` and `ChannelBatchForm`) now read from `table.selectedTableIds` directly. + - Removed the dead `rowSelection` useMemo that built a TanStack row-selection map from `selectedTableIds`. The map was placed in `tableInstance` but `state.rowSelection` was never passed to `useReactTable`, so TanStack never consumed it. Eliminated a full page-row iteration on every selection change. ## [0.24.0] - 2026-05-03 diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index ae0e9e69..797471ae 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -291,7 +291,6 @@ const ChannelsTable = ({ onReady }) => { const setSelectedChannelIds = useChannelsTableStore( (s) => s.setSelectedChannelIds ); - const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); const setExpandedChannelId = useChannelsTableStore( (s) => s.setExpandedChannelId ); @@ -613,7 +612,7 @@ const ChannelsTable = ({ onReady }) => { table.setSelectedTableIds([]); - if (selectedChannelIds.length > 0) { + if (table.selectedTableIds.length > 0) { // Use bulk delete for multiple selections setIsBulkDelete(true); setChannelToDelete(null); @@ -1726,7 +1725,7 @@ const ChannelsTable = ({ onReady }) => { /> diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index cae17e67..02aa08e7 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -35,11 +35,14 @@ const MemoizedTableRow = React.memo( { + if (e.shiftKey) e.preventDefault(); + }} style={{ display: 'flex', width: '100%', minWidth: '100%', - ...(row.getIsSelected() && { + ...(isSelected && { backgroundColor: '#163632', }), ...customRowStyles, diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 0c081721..3df90747 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -30,7 +30,10 @@ const useTable = ({ selectedTableIdsRef.current = selectedTableIds; const [expandedRowIds, setExpandedRowIds] = useState([]); const [lastClickedId, setLastClickedId] = useState(null); - const [isShiftKeyDown, setIsShiftKeyDown] = useState(false); + const lastClickedIdRef = useRef(lastClickedId); + lastClickedIdRef.current = lastClickedId; + const allRowIdsRef = useRef(allRowIds); + allRowIdsRef.current = allRowIds; // Use shared table preferences hook const { headerPinned, setHeaderPinned, tableSize, setTableSize } = @@ -39,8 +42,6 @@ const useTable = ({ // Event handlers for shift key detection with improved handling const handleKeyDown = useCallback((e) => { if (e.key === 'Shift') { - setIsShiftKeyDown(true); - // Apply the class to disable text selection immediately document.body.classList.add('shift-key-active'); // Set a style attribute directly on body for extra assurance document.body.style.userSelect = 'none'; @@ -52,8 +53,6 @@ const useTable = ({ const handleKeyUp = useCallback((e) => { if (e.key === 'Shift') { - setIsShiftKeyDown(false); - // Remove the class when shift is released document.body.classList.remove('shift-key-active'); // Reset the style attributes document.body.style.removeProperty('user-select'); @@ -68,27 +67,19 @@ const useTable = ({ window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); - // Also detect blur/focus events to handle cases where shift is held and window loses focus - window.addEventListener('blur', () => { - setIsShiftKeyDown(false); + const handleBlur = () => { document.body.classList.remove('shift-key-active'); document.body.style.removeProperty('user-select'); document.body.style.removeProperty('-webkit-user-select'); document.body.style.removeProperty('-ms-user-select'); document.body.style.removeProperty('cursor'); - }); + }; + window.addEventListener('blur', handleBlur); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); - window.removeEventListener('blur', () => { - setIsShiftKeyDown(false); - document.body.classList.remove('shift-key-active'); - document.body.style.removeProperty('user-select'); - document.body.style.removeProperty('-webkit-user-select'); - document.body.style.removeProperty('-ms-user-select'); - document.body.style.removeProperty('cursor'); - }); + window.removeEventListener('blur', handleBlur); }; }, [handleKeyDown, handleKeyUp]); @@ -129,16 +120,6 @@ const useTable = ({ } }; - const rowSelection = useMemo(() => { - const selection = {}; - table.getRowModel().rows.forEach((row) => { - if (selectedTableIdsSet.has(row.original.id)) { - selection[row.id] = true; - } - }); - return selection; - }, [selectedTableIdsSet, table.getRowModel().rows]); - const onSelectAllChange = async (e) => { const selectAll = e.target.checked; if (selectAll) { @@ -162,22 +143,22 @@ const useTable = ({ // Handle the shift+click selection const handleShiftSelect = (rowId, isShiftKey) => { - if (!isShiftKey || lastClickedId === null) { + if (!isShiftKey || lastClickedIdRef.current === null) { // Normal selection behavior setLastClickedId(rowId); return false; // Return false to indicate we're not handling it } // Handle shift-click range selection - const currentIndex = allRowIds.indexOf(rowId); - const lastIndex = allRowIds.indexOf(lastClickedId); + const currentIndex = allRowIdsRef.current.indexOf(rowId); + const lastIndex = allRowIdsRef.current.indexOf(lastClickedIdRef.current); if (currentIndex === -1 || lastIndex === -1) return false; // Determine range const startIndex = Math.min(currentIndex, lastIndex); const endIndex = Math.max(currentIndex, lastIndex); - const rangeIds = allRowIds.slice(startIndex, endIndex + 1); + const rangeIds = allRowIdsRef.current.slice(startIndex, endIndex + 1); // Preserve existing selections outside the range const idsOutsideRange = selectedTableIdsRef.current.filter( @@ -252,14 +233,12 @@ const useTable = ({ ...options, selectedTableIds, updateSelectedTableIds, - rowSelection, allRowIds, onSelectAllChange, selectedTableIdsSet, expandedRowIds, expandedRowRenderer, setSelectedTableIds, - isShiftKeyDown, // Include shift key state in the table instance headerPinned, setHeaderPinned, tableSize, @@ -269,7 +248,6 @@ const useTable = ({ selectedTableIdsSet, expandedRowIds, allRowIds, - isShiftKeyDown, options, headerPinned, setHeaderPinned, From 429a6a6bdc16d03f0c225274f19dbade4ca4adc4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 16:52:18 -0500 Subject: [PATCH 015/125] tests: Fixed DVR settings form test. --- .../utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js index 35bb6501..227df50d 100644 --- a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js @@ -71,6 +71,8 @@ describe('DvrSettingsFormUtils', () => { movie_fallback_template: '', comskip_enabled: false, comskip_custom_path: '', + comskip_mode: 'cut', + comskip_hw_accel: 'none', pre_offset_minutes: 0, post_offset_minutes: 0, }); From 821dccc380eb23cb3bfab344bde5c916afe3dc30 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 17:22:42 -0500 Subject: [PATCH 016/125] Enhancement: update cursor styles for shift key active state to improve user experience --- .../components/tables/CustomTable/index.jsx | 2 +- frontend/src/components/tables/table.css | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 3df90747..0065b4c2 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -47,7 +47,7 @@ const useTable = ({ document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; document.body.style.msUserSelect = 'none'; - document.body.style.cursor = 'pointer'; + document.body.style.cursor = 'default'; } }, []); diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css index 1e21540a..ea3fb017 100644 --- a/frontend/src/components/tables/table.css +++ b/frontend/src/components/tables/table.css @@ -152,8 +152,8 @@ html { } /* Prevent text selection when shift key is pressed */ -.shift-key-active { - cursor: pointer !important; +body.shift-key-active { + cursor: default !important; } .shift-key-active *, @@ -167,10 +167,10 @@ html { } /* Always allow text selection in editable elements */ -.shift-key-active input, +.shift-key-active input:not([type='checkbox']):not([type='radio']), .shift-key-active textarea, .shift-key-active [contenteditable='true'], -.shift-key-active .table-input-header input { +.shift-key-active .table-input-header input:not([type='checkbox']):not([type='radio']) { user-select: text !important; -webkit-user-select: text !important; -moz-user-select: text !important; @@ -179,13 +179,13 @@ html { } /* Improve specificity and ensure text selection is disabled when shift is pressed */ -.shift-key-active, -.shift-key-active * { +body.shift-key-active, +body.shift-key-active * { user-select: none !important; -webkit-user-select: none !important; -moz-user-select: none !important; -ms-user-select: none !important; - cursor: pointer !important; + cursor: default !important; } /* Add a visual indicator when shift is pressed */ @@ -198,13 +198,13 @@ html { } /* Always allow text selection in inputs even when shift is pressed */ -.shift-key-active input, -.shift-key-active textarea, -.shift-key-active [contenteditable='true'], -.shift-key-active select, -.shift-key-active .mantine-Select-input, -.shift-key-active .mantine-MultiSelect-input, -.shift-key-active .table-input-header input { +body.shift-key-active input:not([type='checkbox']):not([type='radio']), +body.shift-key-active textarea, +body.shift-key-active [contenteditable='true'], +body.shift-key-active select, +body.shift-key-active .mantine-Select-input, +body.shift-key-active .mantine-MultiSelect-input, +body.shift-key-active .table-input-header input:not([type='checkbox']):not([type='radio']) { user-select: text !important; -webkit-user-select: text !important; -moz-user-select: text !important; @@ -212,6 +212,12 @@ html { cursor: text !important; } +/* Keep pointer cursor on checkboxes/radios when shift is pressed */ +body.shift-key-active input[type='checkbox'], +body.shift-key-active input[type='radio'] { + cursor: pointer !important; +} + /* Column resize handle styles */ .resizer { position: absolute; From e0bb2d925ed51ee9b900e64de23532607a0e2a23 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 17:27:32 -0500 Subject: [PATCH 017/125] Enhancement: implement Shift+click and Ctrl+click row selection in tables for improved user interaction --- CHANGELOG.md | 4 +++ .../tables/CustomTable/CustomTable.jsx | 1 + .../tables/CustomTable/CustomTableBody.jsx | 4 +++ .../components/tables/CustomTable/index.jsx | 28 ++++++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ee26a9..054d160b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`POST /api/channels/series-rules/preview/`** returns up to 25 (configurable, max 100) upcoming programs that a candidate rule would match within the standard 7-day evaluation horizon, without persisting anything. Used by the new rule editor to give live feedback as the user types. - **`GET /api/epg/programs/search/?tvg_id=`** filter parameter for exact `epg.tvg_id` matches. - **Series rule editor modal** with form (title + match mode, description + match mode, episodes mode, pinned channel) and a debounced (500ms) preview pane backed by an `AbortController` so per-keystroke calls don't pile up. A "Customize rule..." link in the program record-choice modal opens the editor pre-filled with the program's `tvg_id` and title; the series rules modal gains an "Add rule" button and an "Edit" button per existing rule. +- **Shift+click and Ctrl+click row selection in tables.** Clicking anywhere on a non-interactive area of a row now participates in selection: + - **Shift+click**: extends selection from the last-clicked row to the current row (range select), identical to shift+clicking the checkbox. + - **Ctrl+click** (Cmd+click on Mac): toggles the clicked row in or out of the current selection without disturbing other selected rows. + - Plain clicks on action buttons, checkboxes, inputs, links, and menus are unaffected. The expand chevron uses `stopPropagation` so expanding a row does not also trigger selection. ### Changed diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index 8b0a9f0c..d0dcdbd8 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -76,6 +76,7 @@ const CustomTable = ({ table }) => { tableCellProps={table.tableCellProps} enableDragDrop={table.enableDragDrop} selectedTableIdsSet={table.selectedTableIdsSet} + handleRowClickRef={table.handleRowClickRef} /> ); diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index 02aa08e7..170d51e6 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -17,6 +17,7 @@ const MemoizedTableRow = React.memo( isSelected, renderBodyCellRef, expandedRowRendererRef, + handleRowClickRef, getRowStyles, tableCellProps, enableDragDrop, @@ -38,6 +39,7 @@ const MemoizedTableRow = React.memo( onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }} + onClick={(e) => handleRowClickRef?.current?.(row.original.id, e)} style={{ display: 'flex', width: '100%', @@ -101,6 +103,7 @@ const CustomTableBody = ({ tableCellProps, enableDragDrop = false, selectedTableIdsSet, + handleRowClickRef, }) => { // Store callbacks in refs so memoized rows always access the latest versions // without the function references themselves triggering re-renders. @@ -127,6 +130,7 @@ const CustomTableBody = ({ } renderBodyCellRef={renderBodyCellRef} expandedRowRendererRef={expandedRowRendererRef} + handleRowClickRef={handleRowClickRef} getRowStyles={getRowStyles} tableCellProps={tableCellProps} enableDragDrop={enableDragDrop} diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 0065b4c2..e691dc6e 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -34,6 +34,7 @@ const useTable = ({ lastClickedIdRef.current = lastClickedId; const allRowIdsRef = useRef(allRowIds); allRowIdsRef.current = allRowIds; + const handleRowClickRef = useRef(null); // Use shared table preferences hook const { headerPinned, setHeaderPinned, tableSize, setTableSize } = @@ -171,6 +172,29 @@ const useTable = ({ return true; // Return true to indicate we've handled it }; + const handleRowClick = (rowId, e) => { + if ( + e.target.closest( + 'button, a, input, select, textarea, [role="menuitem"], [role="option"], [role="button"]' + ) + ) { + return; + } + if (e.shiftKey) { + handleShiftSelect(rowId, true); + } else if (e.ctrlKey || e.metaKey) { + const newSet = new Set(selectedTableIdsRef.current); + if (newSet.has(rowId)) { + newSet.delete(rowId); + } else { + newSet.add(rowId); + setLastClickedId(rowId); + } + updateSelectedTableIds([...newSet]); + } + }; + handleRowClickRef.current = handleRowClick; + const renderBodyCell = ({ row, cell }) => { if (bodyCellRenderFns[cell.column.id]) { return bodyCellRenderFns[cell.column.id]({ row, cell }); @@ -209,7 +233,8 @@ const useTable = ({ return (
{ + onClick={(e) => { + e.stopPropagation(); onRowExpansion(row); }} > @@ -239,6 +264,7 @@ const useTable = ({ expandedRowIds, expandedRowRenderer, setSelectedTableIds, + handleRowClickRef, headerPinned, setHeaderPinned, tableSize, From e2074c192116a6274249c047e84a7216c827d862 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 21:52:28 -0400 Subject: [PATCH 018/125] fix: reduce intermittent 503s on stats page and API requests --- apps/proxy/live_proxy/channel_status.py | 28 +++++++++++++------------ docker/nginx.conf | 2 ++ frontend/src/pages/Stats.jsx | 6 +----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 3f75693e..2bf93ae8 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -419,7 +419,8 @@ class ChannelStatus: info['stream_name'] = stream_name # Add data throughput information to basic info - total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES) + # TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip + total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES) if total_bytes_bytes: total_bytes = int(total_bytes_bytes) info['total_bytes'] = total_bytes @@ -460,24 +461,25 @@ class ChannelStatus: client_key = RedisKeys.client_metadata(channel_id, client_id) + # Fetch only the fields we need in one round-trip (hmget returns a list + # in the same order as the requested keys; values are None if absent) + ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( + client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ) + client_info = { 'client_id': client_id, + 'user_agent': ua, } - user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent') - client_info['user_agent'] = user_agent_bytes + if ip: + client_info['ip_address'] = ip - ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address') - if ip_address_bytes: - client_info['ip_address'] = ip_address_bytes + if connected_at: + client_info['connected_at'] = float(connected_at) - connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') - if connected_at_bytes: - client_info['connected_at'] = float(connected_at_bytes) - - user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id') - if user_id_bytes: - client_info['user_id'] = user_id_bytes + if user_id: + client_info['user_id'] = user_id output_format = proxy_server.redis_client.hget(client_key, 'output_format') client_info['output_format'] = output_format or 'mpegts' diff --git a/docker/nginx.conf b/docker/nginx.conf index c5cb429e..bacfb655 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,6 +21,8 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; + uwsgi_read_timeout 300s; + uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ec5f7042..ea4ef5f2 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -239,11 +239,7 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Fetch initial stats on component mount (for immediate data when navigating to page) - useEffect(() => { - fetchChannelStats(); - fetchVODStats(); - }, [fetchChannelStats, fetchVODStats]); + // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) useEffect(() => { console.log('Processing channel stats:', channelStats); From 6787e4912fc8ff9f5a93db62e4cfb930e783ac01 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:12:09 -0400 Subject: [PATCH 019/125] refactor: replace multiple hget calls with hmget where fields are known - channel_status.py: fold output_format and output_profile_id into the existing hmget, reducing per-client Redis calls from 3 to 1 - utils.py: collapse 2x and 3x hget per key in scan_iter loops (get_user_active_connections) to single hmget calls - views.py: replace 3x hget on channel metadata in the worker-join path of stream_ts with a single hmget --- apps/proxy/live_proxy/channel_status.py | 13 +++++++------ apps/proxy/live_proxy/views.py | 13 +++++-------- apps/proxy/utils.py | 9 ++++----- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 2bf93ae8..9bd1d284 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -463,13 +463,18 @@ class ChannelStatus: # Fetch only the fields we need in one round-trip (hmget returns a list # in the same order as the requested keys; values are None if absent) - ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( - client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ua, ip, connected_at, user_id, output_format, raw_profile_id = ( + proxy_server.redis_client.hmget( + client_key, + 'user_agent', 'ip_address', 'connected_at', 'user_id', + 'output_format', 'output_profile_id', + ) ) client_info = { 'client_id': client_id, 'user_agent': ua, + 'output_format': output_format or 'mpegts', } if ip: @@ -481,10 +486,6 @@ class ChannelStatus: if user_id: client_info['user_id'] = user_id - output_format = proxy_server.redis_client.hget(client_key, 'output_format') - client_info['output_format'] = output_format or 'mpegts' - - raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id') if raw_profile_id and raw_profile_id not in ('None', '0', ''): client_info['output_profile_id'] = int(raw_profile_id) else: diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 1369a364..bf39f6a9 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -468,14 +468,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): if proxy_server.redis_client: metadata_key = RedisKeys.channel_metadata(channel_id) - url_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.URL - ) - ua_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.USER_AGENT - ) - profile_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.STREAM_PROFILE + url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget( + metadata_key, + ChannelMetadataField.URL, + ChannelMetadataField.USER_AGENT, + ChannelMetadataField.STREAM_PROFILE, ) if url_bytes: diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index db2339ed..69ee15e4 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -98,8 +98,7 @@ def get_user_active_connections(user_id): channel_id = parts[2] client_id = parts[4] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'connected_at') + client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at') logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] channel_id = {channel_id}") @@ -124,9 +123,9 @@ def get_user_active_connections(user_id): if len(parts) >= 2: client_id = parts[1] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'created_at') - content_uuid = redis_client.hget(key, 'content_uuid') + client_user_id, connected_at, content_uuid = redis_client.hmget( + key, 'user_id', 'created_at', 'content_uuid' + ) logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] client_id = {client_id}") From a2c69be861d38711e40f0dad1976500b33423f12 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:22:48 -0400 Subject: [PATCH 020/125] fix: update Stats tests to expect single mount fetch after removing duplicate useEffect Tests were asserting fetchActiveChannelStats/getVODStats were called twice on mount, encoding React strict mode's double-invoke behavior rather than testing actual application logic. Update all affected call count assertions to reflect the correct single-fetch behavior. --- frontend/src/pages/__tests__/Stats.test.jsx | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index d809a47a..21cfebf7 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -220,8 +220,8 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); }); }); @@ -283,16 +283,16 @@ describe('StatsPage', () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); // Advance timers by 5 seconds await act(async () => { vi.advanceTimersByTime(5000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); @@ -303,13 +303,13 @@ describe('StatsPage', () => { useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); vi.useRealTimers(); }); @@ -319,7 +319,7 @@ describe('StatsPage', () => { const { unmount } = render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); unmount(); @@ -328,7 +328,7 @@ describe('StatsPage', () => { }); // Should not fetch again after unmount - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); @@ -338,14 +338,14 @@ describe('StatsPage', () => { it('refreshes stats when Refresh Now button is clicked', async () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); const refreshButton = screen.getByText('Refresh Now'); fireEvent.click(refreshButton); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); @@ -406,14 +406,14 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(1); }); const stopButton = await screen.findByTestId('stop-vod-client-client-1'); fireEvent.click(stopButton); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); From 17e6e8c2381b8a9ef6aabfb9e6ce7808775bacc8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 10:13:28 -0500 Subject: [PATCH 021/125] chore: Cleanup unused code. --- apps/proxy/live_proxy/output/ts/generator.py | 3 +- apps/proxy/live_proxy/utils.py | 48 -------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/apps/proxy/live_proxy/output/ts/generator.py b/apps/proxy/live_proxy/output/ts/generator.py index 68ddacca..250a9b3a 100644 --- a/apps/proxy/live_proxy/output/ts/generator.py +++ b/apps/proxy/live_proxy/output/ts/generator.py @@ -169,8 +169,7 @@ class StreamGenerator: yield create_ts_packet('error', "Error: Channel is stopping") return False - # Send PAT+PMT+null so clients recognise a valid TS program and keep - # buffering instead of timing out from missing program info. + # Send null packets to keep the connection alive during initialization. if time.time() - last_keepalive >= keepalive_interval: logger.debug(f"[{self.client_id}] Sending keepalive during initialization") keepalive_data = create_ts_packet('null') diff --git a/apps/proxy/live_proxy/utils.py b/apps/proxy/live_proxy/utils.py index 5177f1ce..c83ce3ba 100644 --- a/apps/proxy/live_proxy/utils.py +++ b/apps/proxy/live_proxy/utils.py @@ -1,6 +1,5 @@ import logging import re -import struct from urllib.parse import urlparse import inspect @@ -58,53 +57,6 @@ def get_client_ip(request): ip = request.META.get('REMOTE_ADDR') return ip -def _mpeg_crc32(data): - crc = 0xFFFFFFFF - for b in data: - crc ^= b << 24 - for _ in range(8): - if crc & 0x80000000: - crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF - else: - crc = (crc << 1) & 0xFFFFFFFF - return crc - - -def create_ts_pat_pmt_packets(): - """ - Return two valid TS packets: PAT (PID 0x0000) and PMT (PID 0x0100). - - Declares program 1 with an H.264 video track at PID 0x0101. - TS clients like VLC need PAT/PMT to recognise a stream as valid; without - them they time out waiting for program info even while receiving null packets. - Returns exactly 376 bytes (2 x 188-byte TS packets). - """ - # PAT section: program 1 mapped to PMT at PID 0x0100 - pat_body = bytes([ - 0x00, 0xB0, 0x0D, # table_id=PAT, section_length=13 - 0x00, 0x01, # transport_stream_id=1 - 0xC1, 0x00, 0x00, # version=0, current=1, section 0/0 - 0x00, 0x01, # program_number=1 - 0xE1, 0x00, # PMT PID=0x0100 (reserved 0b111 | PID) - ]) - pat_body += struct.pack('>I', _mpeg_crc32(pat_body)) - pat_packet = bytes([0x47, 0x40, 0x00, 0x10, 0x00]) + pat_body + bytes([0xFF] * (183 - len(pat_body))) - - # PMT section: program 1, H.264 video at PID 0x0101 - pmt_body = bytes([ - 0x02, 0xB0, 0x12, # table_id=PMT, section_length=18 - 0x00, 0x01, # program_number=1 - 0xC1, 0x00, 0x00, # version=0, current=1, section 0/0 - 0xE1, 0x01, # PCR_PID=0x0101 - 0xF0, 0x00, # program_info_length=0 - 0x1B, 0xE1, 0x01, 0xF0, 0x00, # stream_type=H.264, PID=0x0101 - ]) - pmt_body += struct.pack('>I', _mpeg_crc32(pmt_body)) - pmt_packet = bytes([0x47, 0x41, 0x00, 0x10, 0x00]) + pmt_body + bytes([0xFF] * (183 - len(pmt_body))) - - return pat_packet + pmt_packet - - def create_ts_packet(packet_type='null', message=None): """ Create a Transport Stream (TS) packet for various purposes. From f416c7c25112f795c650bc7b2923443fac6a06e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:00:52 -0500 Subject: [PATCH 022/125] Enhancement: add About modal with version info and community links; refactor SVG icons into separate module --- CHANGELOG.md | 3 + frontend/src/components/AboutModal.jsx | 144 ++++++++++++++++++ frontend/src/components/PluginDetailPanel.jsx | 13 +- frontend/src/components/Sidebar.jsx | 30 +++- frontend/src/components/icons.jsx | 13 ++ 5 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/AboutModal.jsx create mode 100644 frontend/src/components/icons.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 054d160b..69e709f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **About modal.** A `?` button in the sidebar footer opens an About dialog showing the current version, links to Documentation, Discord, GitHub, and Open Collective, a contributors acknowledgment, and a memorial note for Jesse Mann. The button is visible in both expanded and collapsed sidebar states. - **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option: - **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut. - **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this. @@ -48,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Custom SVG icons extracted to `icons.jsx`.** `DiscordIcon` and `GitHubIcon` were moved from `PluginDetailPanel.jsx` into a new shared `frontend/src/components/icons.jsx` module so they can be reused across components without cross-importing from an unrelated file. + - **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations. - **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected. - **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs. diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx new file mode 100644 index 00000000..55342cb3 --- /dev/null +++ b/frontend/src/components/AboutModal.jsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { + Box, + Button, + Divider, + Group, + Modal, + SimpleGrid, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { BookOpen, Github, Heart, Users } from 'lucide-react'; +import { DiscordIcon } from './icons.jsx'; +import logo from '../images/logo.png'; +import useSettingsStore from '../store/settings'; + +const AboutModal = ({ isOpen, onClose }) => { + const appVersion = useSettingsStore((s) => s.version); + const versionString = `v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`; + + return ( + + + + Dispatcharr + + + Dispatcharr + + + {versionString} + + + + + + + + + + + + + + + + + + + + + + + + + + + + Contributors + + + + Dispatcharr is built by the community, for the community. Thank you + to every contributor, tester, and supporter who has helped make this + project what it is. + + + + + + + In memory of{' '} + + Jesse Mann + + . + + + + + + ); +}; + +export default AboutModal; diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx index 602e0852..27d553a9 100644 --- a/frontend/src/components/PluginDetailPanel.jsx +++ b/frontend/src/components/PluginDetailPanel.jsx @@ -23,18 +23,7 @@ import { } from 'lucide-react'; import { compareVersions } from './pluginUtils.js'; import { formatKB } from '../utils/networkUtils.js'; - -export const GitHubIcon = ({ size = 16 }) => ( - - - -); - -export const DiscordIcon = ({ size = 16 }) => ( - - - -); +import { DiscordIcon, GitHubIcon } from './icons.jsx'; /** * Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals. diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index f5332d3a..c17f02e4 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,7 +1,15 @@ import React, { useRef, useState, useMemo } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { copyToClipboard } from '../utils'; -import { Copy, LogOut, ChevronDown, ChevronRight, Heart } from 'lucide-react'; +import { + Copy, + LogOut, + ChevronDown, + ChevronRight, + Heart, + HelpCircle, +} from 'lucide-react'; +import AboutModal from './AboutModal'; import { getOrderedNavItems } from '../config/navigation'; import { Avatar, @@ -156,6 +164,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const publicIPRef = useRef(null); const [userFormOpen, setUserFormOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); const closeUserForm = () => setUserFormOpen(false); @@ -372,6 +381,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { + + setAboutOpen(true)} + > + + + {isAuthenticated && } @@ -389,10 +407,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { > {isAuthenticated && } + + setAboutOpen(true)} + > + + + )} + setAboutOpen(false)} /> ); }; diff --git a/frontend/src/components/icons.jsx b/frontend/src/components/icons.jsx new file mode 100644 index 00000000..540f4e68 --- /dev/null +++ b/frontend/src/components/icons.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export const GitHubIcon = ({ size = 16 }) => ( + + + +); + +export const DiscordIcon = ({ size = 16 }) => ( + + + +); From 2f66b7c183f849e2daf1dd294b88df95a25f275f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:01:10 -0500 Subject: [PATCH 023/125] chore: remove python-gnupg dependency from project --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8542216c..db7efc78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", - "python-gnupg", ] [build-system] From 7e0ea5eae0b3dc0b26014b01236902faa9e08a1a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:12:31 -0500 Subject: [PATCH 024/125] Enhancement: add caching for plugin detail responses and implement cache invalidation on repo refresh. Bug Fix: The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. --- CHANGELOG.md | 5 ++ Plugin_repo.md | 126 ++++++++++++++++------------- apps/plugins/api_views.py | 163 ++++++++++++++++++++++++++++++++++---- 3 files changed, 222 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e709f1..8d12736e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dependency updates: - `Django` 6.0.4 → 6.0.5 (security patch; see Security section) +### Removed + +- **`python-gnupg` dependency dropped.** GPG manifest signature verification now calls the `gpg` binary directly via `os.posix_spawn` (see Fixed below). The `python-gnupg` Python library was the only consumer and has been removed from `pyproject.toml`. The `gpg` binary itself is still required on the host (it was always required since `python-gnupg` is just a wrapper around it). + ### Fixed - **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead. @@ -116,6 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `input/http_streamer.py`: set `O_NONBLOCK` on the HTTP-to-pipe relay write-end with an EAGAIN retry loop for the same reason. - `core/views.py` (`stream_view`): replaced `subprocess.Popen` with `os.posix_spawn`; also fixed a pre-existing indentation bug where the `return StreamingHttpResponse(...)` was accidentally nested inside `stream_generator` (making every successful response return `None` and raise a Django error). Also corrected two `NameError` references to the undefined `stream_id` variable in log messages. - `apps/connect/handlers/script.py` (`ScriptHandler`): replaced `subprocess.run` with a `_posix_run` helper that uses `os.posix_spawn` + cooperative `select.select` reads + non-blocking `waitpid` polling. Without this fix, any script-type connect integration configured for events fired from a uWSGI worker (e.g. `client_connect`) would deadlock the serving greenlet. Note: `cwd` is no longer set to the script's directory during execution (it inherits the worker's cwd); this was a minor convenience, not a documented guarantee. +- **`POST /api/plugins/repos/plugin-detail/` hung for up to 105 seconds under gevent+uWSGI.** The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures, triggering the same `fork()` atfork deadlock described above. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. `select.select()` drains stdout/stderr cooperatively (gevent-patched) and `os.waitpid(WNOHANG)` with `time.sleep(0.01)` reaps the child without blocking the hub. Results are cached in Redis for 5 minutes per manifest URL so repeat detail fetches skip GPG entirely. The cache is also invalidated per-plugin when the owning repo's hub manifest is refreshed, so a newly released version is visible immediately after a manual hub refresh. - **XC server sub-path URLs now work correctly.** When a provider serves its XC API from a sub-path (e.g. `http://server/Pluto/gb/player_api.php`), Dispatcharr was stripping the path entirely and hitting the root (`/player_api.php`) instead. `_normalize_url` now preserves sub-path components and only strips any trailing `.php` segment (covering `player_api.php`, `get.php`, `xmltv.php`, and any future endpoint without a maintained list). The same fix is applied to `get_transformed_credentials` in the M3U profile transformation path. (Fixes #1218) - **M3U filter delete confirmation showed wrong field name and had a typo.** The confirmation dialog for deleting an M3U filter read `filter.type` (always `undefined`) instead of `filter.filter_type`, leaving the "Type:" line blank, and displayed "Patter:" instead of "Pattern:". Both are corrected. — Thanks [@nick4810](https://github.com/nick4810) - **M3U form FileInput expanded the modal width on long filenames.** Uploading a local M3U file with a long name caused the `FileInput` to expand beyond the modal's layout bounds. The input now clips overflow with `textOverflow: ellipsis`. — Thanks [@nick4810](https://github.com/nick4810) diff --git a/Plugin_repo.md b/Plugin_repo.md index 6df4dec5..1e02efb7 100644 --- a/Plugin_repo.md +++ b/Plugin_repo.md @@ -81,6 +81,7 @@ This is the simplest valid repo manifest - one plugin with enough info to show i Dispatcharr accepts two top-level shapes: **Wrapped (supports signing):** + ```json { "manifest": { "plugins": [...], ... }, @@ -89,6 +90,7 @@ Dispatcharr accepts two top-level shapes: ``` **Flat (no signing):** + ```json { "plugins": [...], @@ -118,36 +120,36 @@ If the name contains any of these, the repo will be rejected on add and skipped ### Top-Level Metadata -| Field | Required | Description | -|-------|----------|-------------| -| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). | -| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. | -| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. | -| `plugins` | **Yes** | Array of plugin entry objects. | +| Field | Required | Description | +| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). | +| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. | +| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. | +| `plugins` | **Yes** | Array of plugin entry objects. | ### Plugin Entry Fields -| Field | Required | Description | -|-------|----------|-------------| -| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). | -| `name` | **Yes** | Human-readable display name. | -| `description` | No | Short description shown on the plugin card. | -| `author` | No | Author or organization name. | -| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. | -| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | -| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. | -| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). | -| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. | -| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | -| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | -| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | -| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | -| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | -| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | -| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. | -| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | -| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | -| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | +| Field | Required | Description | +| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). | +| `name` | **Yes** | Human-readable display name. | +| `description` | No | Short description shown on the plugin card. | +| `author` | No | Author or organization name. | +| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. | +| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | +| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. | +| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). | +| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. | +| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | +| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | +| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | +| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | +| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | +| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | +| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. | +| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | +| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | +| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything. @@ -160,6 +162,7 @@ If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) ``` This lets you keep plugin entries compact: + ```json { "root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases", @@ -175,6 +178,7 @@ This lets you keep plugin entries compact: ``` **Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL: + ``` {registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png ``` @@ -186,6 +190,7 @@ This lets you keep plugin entries compact: The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead. Include a per-plugin manifest if you want to: + - Offer multiple downloadable versions - Show per-version compatibility ranges - Display build timestamps and commit links for each version @@ -196,6 +201,7 @@ Include a per-plugin manifest if you want to: Same as the root manifest - both flat and wrapped formats are accepted: **Flat (no signing):** + ```json { "slug": "...", @@ -204,6 +210,7 @@ Same as the root manifest - both flat and wrapped formats are accepted: ``` **Wrapped (supports signing):** + ```json { "manifest": { @@ -272,33 +279,33 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. ### Per-Plugin Manifest Fields -| Field | Required | Description | -|-------|----------|-------------| -| `slug` | No | Plugin identifier (should match the repo entry). | -| `name` | No | Display name. | -| `description` | No | Full description shown in the detail modal. | -| `author` | No | Author/org name shown in the detail modal. | -| `license` | No | SPDX license identifier. | -| `latest_version` | No | Latest version string. | -| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. | -| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. | -| `versions` | No | Array of version objects (newest first recommended). | -| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. | +| Field | Required | Description | +| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slug` | No | Plugin identifier (should match the repo entry). | +| `name` | No | Display name. | +| `description` | No | Full description shown in the detail modal. | +| `author` | No | Author/org name shown in the detail modal. | +| `license` | No | SPDX license identifier. | +| `latest_version` | No | Latest version string. | +| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. | +| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. | +| `versions` | No | Array of version objects (newest first recommended). | +| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. | ### Version Object Fields -| Field | Required | Description | -|-------|----------|-------------| -| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). | -| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. | -| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. | -| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. | -| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | -| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | -| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | -| `size` | No | Size of this version's zip in kilobytes. Informational only. | -| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | -| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | +| Field | Required | Description | +| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). | +| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. | +| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. | +| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. | +| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | +| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | +| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | +| `size` | No | Size of this version's zip in kilobytes. Informational only. | +| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | +| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`. @@ -348,6 +355,7 @@ jq -c '.manifest' manifest.json | gpg --armor --detach-sign ``` In code terms: + ```python import json canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n" @@ -371,11 +379,11 @@ Use the wrapped format so the signature sits alongside the manifest: ### Verification Results -| Result | Meaning | UI Badge | -|--------|---------|----------| -| `true` | Valid signature | Green checkmark | -| `false` | Invalid signature or verification error | Red X | -| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral | +| Result | Meaning | UI Badge | +| ------- | ------------------------------------------------------------------- | --------------- | +| `true` | Valid signature | Green checkmark | +| `false` | Invalid signature or verification error | Red X | +| `null` | Not attempted (no signature, no key, or `gpg` binary not installed) | Gray/neutral | ### Signing Workflow Example @@ -446,6 +454,7 @@ my_plugin-1.0.0.zip ``` Or with a subdirectory: + ``` my_plugin-1.0.0.zip my_plugin/ @@ -477,6 +486,7 @@ The plugin is installed **disabled** by default. The user can enable it from the Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection. A plugin shows "Update Available" when: + - It is managed (installed from a repo) - Its `installed_version` differs from `latest_version` - It was installed from the same repo @@ -488,7 +498,9 @@ A plugin shows "Update Available" when: A plugin repo manifest is just a JSON file served over HTTPS. Some options: ### GitHub Pages / Raw Content + Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs: + ``` https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json ``` @@ -496,9 +508,11 @@ https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json Use `root_url` pointing to your releases branch/path so version URLs stay relative. ### Static File Server + Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed. ### GitHub Releases + You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch. --- diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 8f7a2c71..80ab591a 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -1,7 +1,6 @@ import hashlib import ipaddress import logging -import io import json import re import socket @@ -9,6 +8,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status, serializers from drf_spectacular.utils import extend_schema, inline_serializer +from django.core.cache import cache from django.conf import settings from django.core.files.uploadedfile import UploadedFile from django.http import FileResponse @@ -335,6 +335,21 @@ def _save_fetched_manifest_to_repo(repo, data, verified): return None +def _invalidate_plugin_detail_cache(repo_id, manifest_data): + manifest = manifest_data.get("manifest", manifest_data) + root_url = manifest.get("root_url", "").rstrip("/") + keys = [] + for p in manifest.get("plugins", []): + url = p.get("manifest_url", "") + if not url: + continue + if root_url and not url.startswith(("http://", "https://")): + url = f"{root_url}/{url}" + keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}") + if keys: + cache.delete_many(keys) + + def _unmanage_dropped_slugs(repo, new_manifest_data): """After a manifest refresh, clear source_repo on any installed plugins whose slug is no longer listed in the repo's manifest. Also syncs the @@ -571,6 +586,7 @@ class PluginDeleteAPIView(PluginAuthMixin, APIView): # --------------------------------------------------------------------------- MANIFEST_FETCH_TIMEOUT = 15 +PLUGIN_DETAIL_CACHE_TTL = 300 # seconds OFFICIAL_KEY_PATH = os.path.join( os.path.dirname(__file__), "keys", "dispatcharr-plugins.pub" @@ -589,6 +605,101 @@ def _normalize_pgp_key(text): return text +def _gpg_run(cmd, input_data=None, timeout=30): + """ + Run a GPG command using os.posix_spawn. + + os.posix_spawn skips pthread_atfork handlers, avoiding the indefinite hang + that fork()-based approaches suffer under gevent+uWSGI. select.select() + and time.sleep() are gevent-patched so reads and the waitpid poll yield to + the hub cooperatively. + + Returns (returncode, stdout_bytes, stderr_bytes). + """ + import select as _select + import signal as _signal + import time as _time + + stdin_r, stdin_w = os.pipe() + stdout_r, stdout_w = os.pipe() + stderr_r, stderr_w = os.pipe() + + try: + executable = shutil.which(cmd[0]) or cmd[0] + pid = os.posix_spawn( + executable, cmd, os.environ, + file_actions=[ + (os.POSIX_SPAWN_DUP2, stdin_r, 0), + (os.POSIX_SPAWN_DUP2, stdout_w, 1), + (os.POSIX_SPAWN_DUP2, stderr_w, 2), + (os.POSIX_SPAWN_CLOSE, stdin_r), + (os.POSIX_SPAWN_CLOSE, stdin_w), + (os.POSIX_SPAWN_CLOSE, stdout_w), + (os.POSIX_SPAWN_CLOSE, stderr_w), + (os.POSIX_SPAWN_CLOSE, stdout_r), + (os.POSIX_SPAWN_CLOSE, stderr_r), + ], + ) + except Exception: + for fd in (stdin_r, stdin_w, stdout_r, stdout_w, stderr_r, stderr_w): + try: + os.close(fd) + except OSError: + pass + raise + + for fd in (stdin_r, stdout_w, stderr_w): + os.close(fd) + + try: + if input_data: + os.write(stdin_w, input_data) + finally: + os.close(stdin_w) + + out, err = [], [] + done = set() + deadline = _time.monotonic() + timeout + try: + while len(done) < 2: + remaining = deadline - _time.monotonic() + if remaining <= 0: + try: + os.kill(pid, _signal.SIGKILL) + except ProcessLookupError: + pass + break + fds = [fd for fd in (stdout_r, stderr_r) if fd not in done] + readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5)) + for fd in readable: + data = os.read(fd, 8192) + if data: + (out if fd == stdout_r else err).append(data) + else: + done.add(fd) + finally: + for fd in (stdout_r, stderr_r): + try: + os.close(fd) + except OSError: + pass + + deadline = _time.monotonic() + 5.0 + while _time.monotonic() < deadline: + try: + wpid, st = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return -1, b"".join(out), b"".join(err) + if wpid == pid: + if os.WIFEXITED(st): + return os.WEXITSTATUS(st), b"".join(out), b"".join(err) + if os.WIFSIGNALED(st): + return -os.WTERMSIG(st), b"".join(out), b"".join(err) + return -1, b"".join(out), b"".join(err) + _time.sleep(0.01) + return -1, b"".join(out), b"".join(err) + + def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=None): """Verify a detached GPG signature over the canonical manifest JSON. @@ -597,7 +708,7 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text= repos). When *None* the bundled official key is used instead. Returns True if valid, False if invalid/error, None if verification - could not be attempted (no signature, no key, gnupg missing, etc.). + could not be attempted (no signature, no key, gpg binary missing, etc.). """ if not signature_armored: return None @@ -614,18 +725,19 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text= logger.debug("No GPG public key available; skipping verification") return None - try: - import gnupg - except ImportError: - logger.debug("python-gnupg not installed; skipping signature verification") - return None - tmp_home = tempfile.mkdtemp(prefix="gpg_verify_") try: - gpg = gnupg.GPG(gnupghome=tmp_home) - import_result = gpg.import_keys(key_text) - if not import_result.fingerprints: - logger.warning("Failed to import GPG public key") + key_bytes = key_text.encode("utf-8") if isinstance(key_text, str) else key_text + rc, _, import_stderr = _gpg_run( + ["gpg", "--batch", "--no-tty", "--status-fd", "2", + "--homedir", tmp_home, "--import"], + input_data=key_bytes, + ) + if logger.isEnabledFor(logging.DEBUG): + for line in import_stderr.decode("utf-8", errors="replace").splitlines(): + logger.debug("gpg import: %s", line) + if rc != 0: + logger.warning("GPG key import failed (rc=%d)", rc) return None # Must match what the signing script produces: jq -c '.manifest' @@ -639,8 +751,18 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text= with open(sig_path, "w") as sf: sf.write(signature_armored) - verified = gpg.verify_data(sig_path, manifest_bytes) - return bool(verified) + rc, _, verify_stderr = _gpg_run( + ["gpg", "--batch", "--no-tty", "--status-fd", "2", + "--homedir", tmp_home, "--verify", sig_path, "-"], + input_data=manifest_bytes, + ) + if logger.isEnabledFor(logging.DEBUG): + for line in verify_stderr.decode("utf-8", errors="replace").splitlines(): + logger.debug("gpg verify: %s", line) + return rc == 0 and b"VALIDSIG" in verify_stderr + except FileNotFoundError: + logger.debug("gpg binary not found; skipping signature verification") + return None except Exception: logger.exception("GPG signature verification error") return False @@ -881,6 +1003,7 @@ class PluginRepoRefreshAPIView(PluginAuthMixin, APIView): if err: return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) _unmanage_dropped_slugs(repo, data) + _invalidate_plugin_detail_cache(repo.id, data) return Response(PluginRepoSerializer(repo).data) @@ -1017,6 +1140,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView): _validate_fetch_url(manifest_url) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + cache_key = f"plugin_detail:{repo_id}:{hashlib.md5(manifest_url.encode()).hexdigest()}" + cached = cache.get(cache_key) + if cached is not None: + return Response(cached) + try: resp = http_requests.get(manifest_url, timeout=MANIFEST_FETCH_TIMEOUT) resp.raise_for_status() @@ -1045,10 +1174,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView): if url_val and not url_val.startswith(("http://", "https://")): manifest_obj["latest"][url_field] = f"{root_url}/{url_val}" - return Response({ + result = { "manifest": manifest_obj, "signature_verified": verified, - }) + } + cache.set(cache_key, result, PLUGIN_DETAIL_CACHE_TTL) + return Response(result) except Exception as e: logger.exception("Failed to fetch plugin manifest from %s", manifest_url) return Response( From 9c22a52c145d766a593c8fb20e3a096905043f27 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:01:10 -0500 Subject: [PATCH 025/125] chore: remove python-gnupg dependency from project --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8542216c..db7efc78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", - "python-gnupg", ] [build-system] From 1ab1f638a3fcf834643df5e788b61fbb7351d136 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 17:08:01 -0500 Subject: [PATCH 026/125] tests: Fix frontend test for sidebar. --- frontend/src/components/__tests__/Sidebar.test.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx index 478078c7..d46010a7 100644 --- a/frontend/src/components/__tests__/Sidebar.test.jsx +++ b/frontend/src/components/__tests__/Sidebar.test.jsx @@ -22,6 +22,10 @@ vi.mock('../NotificationCenter', () => ({ ), })); +vi.mock('../AboutModal', () => ({ + default: () => null, +})); + // Mock lucide-react icons vi.mock('lucide-react', () => ({ ListOrdered: ({ onClick }) => ( @@ -59,6 +63,7 @@ vi.mock('lucide-react', () => ({ Heart: () =>
, Package: () =>
, Download: () =>
, + HelpCircle: () =>
, })); // Mock UserForm component From e3269a21ce56e1d75e2de567744551a165b63576 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 21 May 2026 22:33:35 +0000 Subject: [PATCH 027/125] Release v0.25.0 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d12736e..f753e9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.0] - 2026-05-21 + ### Security - Updated `Django` 6.0.4 → 6.0.5, resolving the following CVEs: diff --git a/version.py b/version.py index ae571bbd..c6dd8a87 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.24.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.25.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From 59587cbe663e0a1543a0497e6063aa774b008b9f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 22 May 2026 11:51:16 -0500 Subject: [PATCH 028/125] Fix XC stream URL format output handling and improve M3U playlist generation performance. Removed unused imports in apps\output --- CHANGELOG.md | 9 +++++++++ apps/output/views.py | 33 +++++++++++++++++---------------- apps/proxy/live_proxy/views.py | 7 ++++++- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f753e9a5..07233ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly. +- **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback. + +### Performance + +- **M3U playlist URL building moved outside the channel loop.** `generate_m3u` previously rebuilt the query-string suffix (and for XC requests, called `build_absolute_uri_with_port`) on every channel iteration even though both inputs are request-level constants. The XC base URL and both query-string suffixes (`xc_qs_suffix`, `proxy_qs_suffix`) are now computed once before the channel loop; per-channel URL assembly is a single f-string interpolation. + ## [0.25.0] - 2026-05-21 ### Security diff --git a/apps/output/views.py b/apps/output/views.py index 089df38a..1a73923d 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1,6 +1,5 @@ from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse import json -from rest_framework.response import Response from django.urls import reverse from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream from apps.channels.utils import format_channel_number @@ -16,7 +15,7 @@ from datetime import datetime, timedelta import html import time from tzlocal import get_localzone -from urllib.parse import urlparse +from urllib.parse import urlencode import base64 import logging from django.db.models.functions import Lower @@ -211,7 +210,21 @@ def generate_m3u(request, profile_name=None, user=None): # This is an XC API request - use XC-style EPG URL base_url = build_absolute_uri_with_port(request, '') epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" + # Build the query-string suffix for stream URLs once - it's the same for every channel + xc_qs = {} + if output_profile_id: + xc_qs['output_profile'] = output_profile_id + if output_format_param: + xc_qs['output_format'] = output_format_param + xc_qs_suffix = f"?{urlencode(xc_qs)}" if xc_qs else "" else: + # Pre-compute proxy query-string suffix (same for every channel in this request) + proxy_qs = {} + if output_profile_id: + proxy_qs['output_profile'] = output_profile_id + if output_format_param: + proxy_qs['output_format'] = output_format_param + proxy_qs_suffix = f"?{urlencode(proxy_qs)}" if proxy_qs else "" # Regular request - use standard EPG endpoint epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint')) @@ -219,7 +232,6 @@ def generate_m3u(request, profile_name=None, user=None): preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days'] query_params = {k: v for k, v in request.GET.items() if k in preserved_params} if query_params: - from urllib.parse import urlencode epg_url = f"{epg_base_url}?{urlencode(query_params)}" else: epg_url = epg_base_url @@ -281,9 +293,7 @@ def generate_m3u(request, profile_name=None, user=None): # Determine the stream URL based on request type if is_xc_request: - # XC API request - use XC-style stream URL format - base_url = build_absolute_uri_with_port(request, '') - stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}" + stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}{xc_qs_suffix}" elif use_direct_urls: # Try to get the first stream's direct URL all_streams = channel.streams.all() @@ -297,16 +307,7 @@ def generate_m3u(request, profile_name=None, user=None): else: # Standard behavior - use proxy URL base_stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") - qs_parts = {} - if output_profile_id: - qs_parts['output_profile'] = output_profile_id - if output_format_param: - qs_parts['output_format'] = output_format_param - if qs_parts: - from urllib.parse import urlencode - stream_url = f"{base_stream_url}?{urlencode(qs_parts)}" - else: - stream_url = base_stream_url + stream_url = f"{base_stream_url}{proxy_qs_suffix}" m3u_content += extinf_line + stream_url + "\n" diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 1369a364..a2610c93 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -630,7 +630,12 @@ def stream_xc(request, username, password, channel_id): else: channel = get_object_or_404(Channel, id=channel_id) - force_format = 'fmp4' if extension.lower() == '.mp4' else 'mpegts' + if extension.lower() == '.mp4': + force_format = 'fmp4' + elif extension.lower() == '.ts': + force_format = 'mpegts' + else: + force_format = None return stream_ts(request._request, str(channel.uuid), user, force_output_format=force_format) From 137241f873ca9a57cad006753f6f23d1ec149347 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 22 May 2026 13:53:00 -0500 Subject: [PATCH 029/125] Bug Fix: Fix auto sync migration. (Fixes #1259) --- CHANGELOG.md | 1 + apps/channels/migrations/0037_auto_sync_overhaul.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07233ffc..7ae64497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259) - **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly. - **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback. diff --git a/apps/channels/migrations/0037_auto_sync_overhaul.py b/apps/channels/migrations/0037_auto_sync_overhaul.py index d93a1789..86f34762 100644 --- a/apps/channels/migrations/0037_auto_sync_overhaul.py +++ b/apps/channels/migrations/0037_auto_sync_overhaul.py @@ -62,6 +62,9 @@ def backfill_auto_created_by_null(apps, schema_editor): f"(ambiguous/no streams): {demoted}" ) + with schema_editor.connection.cursor() as cursor: + cursor.execute("SET CONSTRAINTS ALL IMMEDIATE") + def reverse_auto_created_by_null(apps, schema_editor): # Forward decisions cannot be cleanly reverted (no record of the @@ -101,6 +104,9 @@ def reverse_backfill_channel_number_nulls(apps, schema_editor): ch.save(update_fields=["channel_number"]) next_num += 1.0 + with schema_editor.connection.cursor() as cursor: + cursor.execute("SET CONSTRAINTS ALL IMMEDIATE") + class Migration(migrations.Migration): From 0d1d1f27221d883fbc675edc0f78ccbbf26115e0 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Sat, 23 May 2026 14:20:13 -0400 Subject: [PATCH 030/125] fix: revert nginx timeouts; fire stats fetch on mount regardless of interval --- docker/nginx.conf | 2 -- frontend/src/pages/Stats.jsx | 16 +++++++--------- frontend/src/pages/__tests__/Stats.test.jsx | 10 +++++++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index bacfb655..c5cb429e 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,8 +21,6 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; - uwsgi_read_timeout 300s; - uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ea4ef5f2..86c7f1c3 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -212,19 +212,19 @@ const StatsPage = () => { } }, [setVodStats]); + // Always fetch once on mount, regardless of polling interval setting + useEffect(() => { + fetchChannelStats(); + fetchVODStats(); + }, [fetchChannelStats, fetchVODStats]); + // Set up polling for stats when on stats page useEffect(() => { - const location = window.location; - const isOnStatsPage = location.pathname === '/stats'; + const isOnStatsPage = window.location.pathname === '/stats'; if (isOnStatsPage && refreshInterval > 0) { setIsPollingActive(true); - // Initial fetch - fetchChannelStats(); - fetchVODStats(); - - // Set up interval const interval = setInterval(() => { fetchChannelStats(); fetchVODStats(); @@ -239,8 +239,6 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) - useEffect(() => { console.log('Processing channel stats:', channelStats); if ( diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index 21cfebf7..92cf8560 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -297,19 +297,23 @@ describe('StatsPage', () => { vi.useRealTimers(); }); - it('does not poll when interval is 0', async () => { + it('does not poll when interval is 0 but still fetches once on mount', async () => { vi.useFakeTimers(); useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should still fetch once on mount even with interval = 0 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should not have polled — count stays at 1 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); From 812d25b987e33dcaf8e8680b26d6391634db88fa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 13:54:41 -0500 Subject: [PATCH 031/125] fix: resolve SynchronousOnlyOperation in ORM calls by allowing async unsafe operations --- CHANGELOG.md | 1 + apps/channels/tasks.py | 14 ++++---------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae64497..5e3896f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260) - **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259) - **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly. - **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback. diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 91748652..598ca297 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1361,11 +1361,8 @@ def _evaluate_series_rules_locked(tvg_id, result): # Notify frontend to refresh try: - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - 'updates', - {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}}, - ) + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}) except Exception: pass @@ -1440,11 +1437,8 @@ def reschedule_upcoming_recordings_for_offset_change_impl(): # Notify frontend to refresh try: - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - 'updates', - {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "rescheduled": changed}}, - ) + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "rescheduled": changed}) except Exception: pass From 5ea194059be5e247bf5df350d1309c10699c97c4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 23 May 2026 19:17:36 +0000 Subject: [PATCH 032/125] Release v0.25.1 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3896f7..7e998abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.1] - 2026-05-23 + ### Fixed - **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260) diff --git a/version.py b/version.py index c6dd8a87..5f17fe8a 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.25.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.25.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From 92d3068be0e964478ec2cb18f41183cf916a490a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:25:44 -0500 Subject: [PATCH 033/125] fix: ensure migrations run automatically after restoring backups to prevent missing schema issues --- CHANGELOG.md | 4 ++++ apps/backups/tasks.py | 3 +++ frontend/src/components/backups/BackupManager.jsx | 6 +++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e998abd..3464dd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state. + ## [0.25.1] - 2026-05-23 ### Fixed diff --git a/apps/backups/tasks.py b/apps/backups/tasks.py index f531fef8..a169b7db 100644 --- a/apps/backups/tasks.py +++ b/apps/backups/tasks.py @@ -1,6 +1,7 @@ import logging import traceback from celery import shared_task +from django.core.management import call_command from . import services @@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str): backup_file = backup_dir / filename logger.info(f"[RESTORE] Backup file path: {backup_file}") services.restore_backup(backup_file) + logger.info(f"[RESTORE] Running migrations after restore...") + call_command('migrate', '--noinput', verbosity=1) logger.info(f"[RESTORE] Task {self.request.id} completed successfully") return { "status": "completed", diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index 02b7d15d..c58755b2 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -439,12 +439,12 @@ export default function BackupManager() { try { await API.restoreBackup(selectedBackup.name); notifications.show({ - title: 'Success', + title: 'Restore Complete', message: - 'Backup restored successfully. You may need to refresh the page.', + 'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.', color: 'green', }); - setTimeout(() => window.location.reload(), 2000); + setTimeout(() => window.location.reload(), 4000); } catch (error) { notifications.show({ title: 'Error', From fb260356aad4ff8b44035fe88cc769742df361a7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:45:49 -0500 Subject: [PATCH 034/125] changelog: Update changelog for stats PR. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3464dd31..7cef64db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Performance + +- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012) + ### Fixed - **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state. From 0db557384a7219d7d14426eba7ee2b053e5550a0 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sun, 24 May 2026 10:59:53 -0400 Subject: [PATCH 035/125] configurable per-page, sticky pagination footer, fix update sort order and disabled button badge color --- .../components/theme/SizedInstallButton.jsx | 2 +- frontend/src/pages/PluginBrowse.jsx | 122 ++++++++++++------ 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/theme/SizedInstallButton.jsx b/frontend/src/components/theme/SizedInstallButton.jsx index 780aedb5..7bcb1819 100644 --- a/frontend/src/components/theme/SizedInstallButton.jsx +++ b/frontend/src/components/theme/SizedInstallButton.jsx @@ -71,7 +71,7 @@ const SizedInstallButton = ({ : 'var(--mantine-primary-color-filled-hover)' : colorVar, filter: isDisabled - ? 'brightness(0.65) saturate(0.7)' + ? 'grayscale(1) brightness(0.55)' : hovered ? 'brightness(0.86)' : 'brightness(0.82)', diff --git a/frontend/src/pages/PluginBrowse.jsx b/frontend/src/pages/PluginBrowse.jsx index ccc81b28..dd4a3a6d 100644 --- a/frontend/src/pages/PluginBrowse.jsx +++ b/frontend/src/pages/PluginBrowse.jsx @@ -8,6 +8,7 @@ import { Group, Loader, Modal, + NativeSelect, NumberInput, Pagination, Select, @@ -68,6 +69,7 @@ export default function PluginBrowsePage() { const saveIntervalTimer = useRef(null); const recentlyInstalledSlugs = useRef(new Set()); + const recentlyUpdatedSlugs = useRef(new Set()); const recentlyUninstalledSlugs = useRef(new Set()); const [searchQuery, setSearchQuery] = useState(''); @@ -75,7 +77,14 @@ export default function PluginBrowsePage() { const [filterRepo, setFilterRepo] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [page, setPage] = useState(1); - const perPage = 9; + const [perPage, setPerPage] = useState(() => { + const stored = localStorage.getItem('pluginBrowsePerPage'); + return stored && !isNaN(Number(stored)) ? Number(stored) : 9; + }); + const handlePerPageChange = (value) => { + setPerPage(Number(value)); + localStorage.setItem('pluginBrowsePerPage', value); + }; const hasFetched = useRef(false); @@ -294,6 +303,8 @@ export default function PluginBrowsePage() { // Pre-sort weights: deprecated → installed → incompatible sink to bottom (in that order) // Recently installed plugins are exempt so they don't jump away after install const weight = (p) => { + if (p.install_status === 'update_available') return -1; + if (recentlyUpdatedSlugs.current.has(p.slug)) return -1; if (recentlyInstalledSlugs.current.has(p.slug)) return 0; if (recentlyUninstalledSlugs.current.has(p.slug)) return 2; const meetsMin = @@ -335,10 +346,10 @@ export default function PluginBrowsePage() { appVersion, ]); - // Reset to page 1 when filters/search change + // Reset to page 1 when filters/search/page-size change React.useEffect(() => { setPage(1); - }, [searchQuery, filterRepo, filterStatus, sortBy]); + }, [searchQuery, filterRepo, filterStatus, sortBy, perPage]); const totalPages = Math.ceil(filteredPlugins.length / perPage); const paginatedPlugins = filteredPlugins.slice( @@ -347,7 +358,15 @@ export default function PluginBrowsePage() { ); return ( - + + @@ -467,38 +486,69 @@ export default function PluginBrowsePage() { )} {!loading && filteredPlugins.length > 0 && ( - <> - - {paginatedPlugins.map((p) => ( - 1} - onBeforeInstall={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - }} - onInstalled={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - fetchAvailablePlugins(); - }} - onUninstalled={(slug) => { - if (slug) recentlyUninstalledSlugs.current.add(slug); - }} - /> - ))} - - {totalPages > 1 && ( - - - - )} - + + {paginatedPlugins.map((p) => ( + 1} + onBeforeInstall={(slug) => { + if (slug) { + if (p.install_status === 'update_available') { + recentlyUpdatedSlugs.current.add(slug); + } else { + recentlyInstalledSlugs.current.add(slug); + } + } + }} + onInstalled={(slug) => { + if (slug) recentlyInstalledSlugs.current.add(slug); + fetchAvailablePlugins(); + }} + onUninstalled={(slug) => { + if (slug) recentlyUninstalledSlugs.current.add(slug); + }} + /> + ))} + + )} + + + + {!loading && filteredPlugins.length > 0 && ( + + + Page Size + handlePerPageChange(e.target.value)} + styles={{ input: { textAlignLast: 'center' } }} + /> + + + {`${(page - 1) * perPage + 1} to ${Math.min(page * perPage, filteredPlugins.length)} of ${filteredPlugins.length}`} + + + )} {/* Manage Repos Modal */} From 2070f9e281f762c62d2a37289740ac059a184745 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Mon, 25 May 2026 04:31:42 -0400 Subject: [PATCH 036/125] docker: split base image into builder and runtime stages Keeps compilers in the builder; runtime stage copies only the venv and NumPy wheel. --- docker/DispatcharrBase | 62 +++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 3040e189..61c460ce 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -1,4 +1,10 @@ -FROM lscr.io/linuxserver/ffmpeg:latest +# ============================================================================== +# Stage 1: builder +# Installs the Python toolchain + compilers, creates the virtual environment and +# builds the legacy (CPU-baseline=none) NumPy wheel. None of these compilers end +# up in the final image — only the artifacts below are copied forward. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS builder ENV DEBIAN_FRONTEND=noninteractive ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy @@ -15,10 +21,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && apt-get update \ && apt-get install --no-install-recommends -y \ python3.13 python3.13-dev python3.13-venv libpython3.13 \ - libpcre3 libpcre3-dev libpq-dev procps pciutils \ - nginx comskip \ - vlc-bin vlc-plugin-base \ - build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build + libpcre3 libpcre3-dev libpq-dev \ + build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build \ + && rm -rf /var/lib/apt/lists/* # --- Install UV --- COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ @@ -28,11 +33,12 @@ WORKDIR /tmp/build COPY pyproject.toml /tmp/build/ COPY version.py /tmp/build/ COPY README.md /tmp/build/ -RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ - rm -rf /tmp/build +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- +# build/pip are installed into the venv only long enough to produce the wheel, +# then uninstalled so they are not carried into the final image's venv. RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ cd /tmp && \ $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ @@ -40,14 +46,44 @@ RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build cd numpy-*/ && \ $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ mv dist/*.whl /opt/ && \ - cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ + cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz /tmp/build && \ uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip -# --- Clean up build dependencies to reduce image size --- -RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ - apt-get autoremove -y --purge && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /root/.cache /tmp/* +# ============================================================================== +# Stage 2: final runtime image +# Same ffmpeg base, but installs only the runtime system libraries (no compilers) +# and copies the prebuilt virtual environment + NumPy wheel from the builder. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS final + +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy +ENV VIRTUAL_ENV=/dispatcharrpy +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +# --- Install runtime system dependencies (no build toolchain) --- +# python3.13 + libpython3.13 back the copied venv; libpcre3 backs uWSGI; +# libopenblas0 backs the legacy NumPy wheel; ca-certificates/gnupg2/curl/wget +# and software-properties-common are kept for TLS and the AIO runtime apt step. +RUN apt-get update && apt-get install --no-install-recommends -y \ + ca-certificates software-properties-common gnupg2 curl wget \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update \ + && apt-get install --no-install-recommends -y \ + python3.13 python3.13-venv libpython3.13 \ + libpcre3 libpq-dev libopenblas0 procps pciutils \ + nginx comskip \ + vlc-bin vlc-plugin-base \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Install UV (used at runtime to swap in the legacy NumPy wheel) --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# --- Copy prebuilt virtual environment and legacy NumPy wheel from the builder --- +COPY --from=builder /dispatcharrpy /dispatcharrpy +COPY --from=builder /opt/numpy-*.whl /opt/ # --- Set up Redis 7.x --- RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ From 94c8bd36bfffc6d361ff6f252037ccda9c1854c7 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Mon, 25 May 2026 11:12:46 -0400 Subject: [PATCH 037/125] perf: replace per-request DB connections with geventpool --- dispatcharr/settings.py | 12 ++++++++---- pyproject.toml | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index e8818108..1b103ccf 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -111,7 +111,7 @@ XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5 # Database optimization settings DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries -DATABASE_CONN_MAX_AGE = 0 # Close after each request; gevent makes per-greenlet connections +DATABASE_CONN_MAX_AGE = None # Managed by django-db-geventpool; None = persistent pool connections # Disable atomic requests for performance-sensitive views ATOMIC_REQUESTS = False @@ -221,13 +221,17 @@ if os.getenv("DB_ENGINE", None) == "sqlite": else: DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql", + "ENGINE": "django_db_geventpool.backends.postgresql", "NAME": os.environ.get("POSTGRES_DB", "dispatcharr"), "USER": os.environ.get("POSTGRES_USER", "dispatch"), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"), "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": int(os.environ.get("POSTGRES_PORT", 5432)), "CONN_MAX_AGE": DATABASE_CONN_MAX_AGE, + "OPTIONS": { + "MAX_CONNS": 20, # Per-worker pool size; 4 workers × 20 = 80 total < pg max_connections=100 + "REUSE_CONNS": 10, # Connections to keep warm between requests + }, } } @@ -238,9 +242,9 @@ else: ("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY), ], "PostgreSQL") - DATABASES["default"]["OPTIONS"] = { + DATABASES["default"]["OPTIONS"].update({ "sslmode": POSTGRES_SSL_MODE, - } + }) if POSTGRES_SSL_CA_CERT: DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT if POSTGRES_SSL_CERT: diff --git a/pyproject.toml b/pyproject.toml index db7efc78..fe7112f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "yt-dlp", "gevent==26.4.0", "psycogreen", + "django-db-geventpool", "daphne", "uwsgi", "django-cors-headers", From 6021bdf741a89164e568212f97b8365824a742cd Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:02:01 -0700 Subject: [PATCH 038/125] Extracted utils --- .../utils/forms/RecordingDetailsModalUtils.js | 21 ++ frontend/src/utils/forms/RecordingUtils.js | 181 ++++++++++++++++++ .../utils/forms/RecurringRuleModalUtils.js | 56 +++--- .../utils/forms/SeriesRuleEditorModalUtils.js | 84 ++++++++ .../src/utils/forms/StreamProfileUtils.js | 44 +++++ frontend/src/utils/forms/StreamUtils.js | 19 ++ frontend/src/utils/forms/UserAgentUtils.js | 17 ++ frontend/src/utils/forms/UserUtils.js | 150 +++++++++++++++ frontend/src/utils/guideUtils.js | 23 +-- 9 files changed, 544 insertions(+), 51 deletions(-) create mode 100644 frontend/src/utils/forms/RecordingUtils.js create mode 100644 frontend/src/utils/forms/SeriesRuleEditorModalUtils.js create mode 100644 frontend/src/utils/forms/StreamProfileUtils.js create mode 100644 frontend/src/utils/forms/StreamUtils.js create mode 100644 frontend/src/utils/forms/UserAgentUtils.js create mode 100644 frontend/src/utils/forms/UserUtils.js diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js index c81c3b53..2d9ea0ab 100644 --- a/frontend/src/utils/forms/RecordingDetailsModalUtils.js +++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js @@ -1,3 +1,5 @@ +import API from '../../api.js'; + export const getStatRows = (stats) => { return [ ['Video Codec', stats.video_codec], @@ -86,3 +88,22 @@ export const getUpcomingEpisodes = ( (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time) ); }; + +export const getChannel = (id) => { + return API.getChannel(id); +}; + +export const updateRecordingMetadata = ( + recording, + editTitle, + editDescription +) => { + return API.updateRecordingMetadata(recording.id, { + title: editTitle || 'Custom Recording', + description: editDescription, + }); +}; + +export const refreshArtwork = (id) => { + return API.refreshArtwork(id); +}; diff --git a/frontend/src/utils/forms/RecordingUtils.js b/frontend/src/utils/forms/RecordingUtils.js new file mode 100644 index 00000000..fde3b6c5 --- /dev/null +++ b/frontend/src/utils/forms/RecordingUtils.js @@ -0,0 +1,181 @@ +import API from '../../api.js'; +import { + add, + diff, + format, + getNow, + initializeTime, + isValid, + roundToNearest, + setMillisecond, + setSecond, + toDate, + toTimeString, +} from '../dateTimeUtils.js'; +import { isNotEmpty } from '@mantine/form'; + +export const asDate = (value) => { + if (!value) return null; + if (value instanceof Date) return value; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const toIsoIfDate = (value) => { + const dt = asDate(value); + return dt ? dt.toISOString() : value; +}; + +export const toDateString = (value) => { + const dt = asDate(value); + return dt ? format(dt, 'YYYY-MM-DD') : null; +}; + +export const createRoundedDate = (minutesAhead = 0) => { + let rounded = roundToNearest(getNow(), 30); + rounded = setSecond(rounded, 0); + rounded = setMillisecond(rounded, 0); + return toDate(minutesAhead ? add(rounded, minutesAhead, 'minute') : rounded); +}; + +// robust onChange for TimeInput (string or event) +export const timeChange = (setter) => (valOrEvent) => { + if (typeof valOrEvent === 'string') setter(valOrEvent); + else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); +}; + +export const getChannelsSummary = () => { + return API.getChannelsSummary(); +}; +export const updateRecording = (id, data) => { + return API.updateRecording(id, data); +}; +export const createRecording = (data) => { + return API.createRecording(data); +}; +export const createRecurringRule = (data) => { + return API.createRecurringRule(data); +}; + +/** + * Sorts a channels array or object by channel_number (then name) and maps + * each entry to a { value, label } select option. + * + * @param {Array|Object} channels + * @param {(item: object) => string} [labelFn] defaults to `name || "Channel id"` + */ +export const sortedChannelOptions = (channels, labelFn) => { + const list = Array.isArray(channels) + ? channels + : Object.values(channels || {}); + + const defaultLabel = (item) => item.name || `Channel ${item.id}`; + + return [...list] + .sort((a, b) => { + const aNum = Number(a.channel_number) || 0; + const bNum = Number(b.channel_number) || 0; + if (aNum !== bNum) return aNum - bNum; + return (a.name || '').localeCompare(b.name || ''); + }) + .map((item) => ({ + value: String(item.id), + label: (labelFn ?? defaultLabel)(item), + })); +}; + +/** Label that includes the channel number prefix used in Recording/SeriesRule UIs. */ +export const numberedChannelLabel = (item) => + item.channel_number + ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` + : item.name || `Channel ${item.id}`; + +export const getSingleFormDefaults = (recording = null, channel = null) => { + const defaultStart = createRoundedDate(); + const defaultEnd = createRoundedDate(60); + return { + channel_id: recording + ? `${recording.channel}` + : channel + ? `${channel.id}` + : '', + start_time: recording + ? asDate(recording.start_time) || defaultStart + : defaultStart, + end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd, + }; +}; + +export const getRecurringFormDefaults = (channel = null) => { + const defaultStart = createRoundedDate(); + const defaultEnd = createRoundedDate(60); + const defaultDate = new Date(); + return { + channel_id: channel ? `${channel.id}` : '', + days_of_week: [], + start_time: format(defaultStart, 'HH:mm'), + end_time: format(defaultEnd, 'HH:mm'), + rule_name: channel?.name || '', + start_date: defaultDate, + end_date: defaultDate, + }; +}; + +export const buildSinglePayload = (values) => ({ + channel: values.channel_id, + start_time: toIsoIfDate(values.start_time), + end_time: toIsoIfDate(values.end_time), +}); + +export const buildRecurringPayload = (values) => ({ + channel: values.channel_id, + days_of_week: (values.days_of_week || []).map(Number), + start_time: toTimeString(values.start_time), + end_time: toTimeString(values.end_time), + start_date: toDateString(values.start_date), + end_date: toDateString(values.end_date), + name: values.rule_name?.trim() || '', +}); + +export const singleFormValidators = { + channel_id: isNotEmpty('Select a channel'), + start_time: isNotEmpty('Select a start time'), + end_time: (value, values) => { + const start = asDate(values.start_time); + const end = asDate(value); + if (!end) return 'Select an end time'; + if (start && end <= start) return 'End time must be after start time'; + return null; + }, +}; + +export const recurringFormValidators = { + channel_id: isNotEmpty('Select a channel'), + days_of_week: (value) => (value?.length ? null : 'Pick at least one day'), + start_time: (value) => (value ? null : 'Select a start time'), + end_time: (value, values) => { + if (!value) return 'Select an end time'; + const start = initializeTime( + values.start_time, + ['HH:mm', 'hh:mm A', 'h:mm A'], + null, + true + ); + const end = initializeTime( + value, + ['HH:mm', 'hh:mm A', 'h:mm A'], + null, + true + ); + if (isValid(start) && isValid(end) && diff(start, end, 'minute') === 0) + return 'End time must differ from start time'; + return null; + }, + end_date: (value, values) => { + const end = asDate(value); + const start = asDate(values.start_date); + if (!end) return 'Select an end date'; + if (start && end < start) return 'End date cannot be before start date'; + return null; + }, +}; diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js index db8a5dd8..5a747bf5 100644 --- a/frontend/src/utils/forms/RecurringRuleModalUtils.js +++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js @@ -1,25 +1,12 @@ import API from '../../api.js'; -import { toTimeString } from '../dateTimeUtils.js'; -import dayjs from 'dayjs'; - -export const getChannelOptions = (channels) => { - const list = Array.isArray(channels) - ? channels - : Object.values(channels || {}); - return list - .sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) { - return (a.name || '').localeCompare(b.name || ''); - } - return aNum - bNum; - }) - .map((item) => ({ - value: `${item.id}`, - label: item.name || `Channel ${item.id}`, - })); -}; +import { + getNow, + isAfter, + parseDate, + toDate, + toTimeString, +} from '../dateTimeUtils.js'; +import { buildRecurringPayload } from './RecordingUtils.js'; export const getUpcomingOccurrences = ( recordings, @@ -35,7 +22,7 @@ export const getUpcomingOccurrences = ( .filter( (rec) => rec?.custom_properties?.rule?.id === ruleId && - toUserTime(rec.start_time).isAfter(now) + isAfter(toUserTime(rec.start_time), now) ) .sort( (a, b) => @@ -45,17 +32,7 @@ export const getUpcomingOccurrences = ( export const updateRecurringRule = async (ruleId, values) => { await API.updateRecurringRule(ruleId, { - channel: values.channel_id, - days_of_week: (values.days_of_week || []).map((d) => Number(d)), - start_time: toTimeString(values.start_time), - end_time: toTimeString(values.end_time), - start_date: values.start_date - ? dayjs(values.start_date).format('YYYY-MM-DD') - : null, - end_date: values.end_date - ? dayjs(values.end_date).format('YYYY-MM-DD') - : null, - name: values.rule_name?.trim() || '', + ...buildRecurringPayload(values), enabled: Boolean(values.enabled), }); }; @@ -67,3 +44,16 @@ export const deleteRecurringRuleById = async (ruleId) => { export const updateRecurringRuleEnabled = async (ruleId, checked) => { await API.updateRecurringRule(ruleId, { enabled: checked }); }; + +export const getFormDefaults = (rule) => { + return { + channel_id: `${rule.channel}`, + days_of_week: (rule.days_of_week || []).map((d) => String(d)), + rule_name: rule.name || '', + start_time: toTimeString(rule.start_time), + end_time: toTimeString(rule.end_time), + start_date: parseDate(rule.start_date) || toDate(getNow()), + end_date: parseDate(rule.end_date), + enabled: Boolean(rule.enabled), + }; +}; diff --git a/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js new file mode 100644 index 00000000..dbcd545e --- /dev/null +++ b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js @@ -0,0 +1,84 @@ +import API from '../../api.js'; +import { + numberedChannelLabel, + sortedChannelOptions, +} from './RecordingUtils.js'; + +export const TITLE_MODES = [ + { label: 'Exact', value: 'exact' }, + { label: 'Contains', value: 'contains' }, + { label: 'Whole word', value: 'search' }, + { label: 'Regex', value: 'regex' }, +]; +export const DESCRIPTION_MODES = [ + { label: 'Contains', value: 'contains' }, + { label: 'Whole word', value: 'search' }, + { label: 'Regex', value: 'regex' }, +]; +export const EPISODE_MODES = [ + { label: 'All episodes', value: 'all' }, + { label: 'New only', value: 'new' }, +]; + +export function formatRange(start, end) { + try { + const s = new Date(start); + const e = new Date(end); + + if(isNaN(s) || isNaN(e)) throw new Error('Invalid date'); + + const sameDay = s.toDateString() === e.toDateString(); + const dateStr = s.toLocaleDateString(); + const startStr = s.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + const endStr = e.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + return sameDay + ? `${dateStr} ${startStr} - ${endStr}` + : `${dateStr} ${startStr} -> ${e.toLocaleString()}`; + } catch { + return `${start} - ${end}`; + } +} + +export const previewSeriesRule = (debouncedPreviewKey, controller) => { + return API.previewSeriesRule(debouncedPreviewKey, { + signal: controller.signal, + }); +}; + +export const getTvgOptions = (tvgs) => { + const seen = new Set(); + const options = []; + for (const t of tvgs || []) { + if (!t.tvg_id || seen.has(t.tvg_id)) continue; + seen.add(t.tvg_id); + options.push({ + value: t.tvg_id, + label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id, + }); + } + return options.sort((a, b) => a.label.localeCompare(b.label)); +}; + +export const getChannelOptions = (allChannels, tvgsById, tvgId) => { + const sorted = sortedChannelOptions(allChannels, numberedChannelLabel); + const matching = []; + const others = []; + for (const item of sorted) { + const channel = allChannels.find((c) => String(c.id) === item.value); + const cTvg = channel?.epg_data_id + ? tvgsById?.[channel.epg_data_id]?.tvg_id + : null; + if (tvgId && cTvg && String(cTvg) === String(tvgId)) { + matching.push(item); + } else { + others.push(item); + } + } + return [...matching, ...others]; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/StreamProfileUtils.js b/frontend/src/utils/forms/StreamProfileUtils.js new file mode 100644 index 00000000..edcc7ba6 --- /dev/null +++ b/frontend/src/utils/forms/StreamProfileUtils.js @@ -0,0 +1,44 @@ +// Built-in commands supported by Dispatcharr out of the box. +import { yupResolver } from '@hookform/resolvers/yup'; +import API from '../../api.js'; +import * as Yup from 'yup'; + +export const BUILT_IN_COMMANDS = [ + { value: 'ffmpeg', label: 'FFmpeg' }, + { value: 'streamlink', label: 'Streamlink' }, + { value: 'cvlc', label: 'VLC' }, + { value: 'yt-dlp', label: 'yt-dlp' }, + { value: '__custom__', label: 'Custom…' }, +]; + +// Default parameter examples for each built-in command. +export const COMMAND_EXAMPLES = { + ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1', + streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout', + cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}', + 'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}', +}; + +// Returns '__custom__' when the command isn't one of the built-ins, +// otherwise returns the command value itself. +export const toCommandSelection = (command) => + BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__') + ? command + : '__custom__'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + command: Yup.string().required('Command is required'), + parameters: Yup.string(), +}); + +export const getResolver = () => { + return yupResolver(schema); +}; + +export const updateStreamProfile = (profileId, values) => { + return API.updateStreamProfile({ id: profileId, ...values }); +}; +export const addStreamProfile = (values) => { + return API.addStreamProfile(values); +}; diff --git a/frontend/src/utils/forms/StreamUtils.js b/frontend/src/utils/forms/StreamUtils.js new file mode 100644 index 00000000..59998233 --- /dev/null +++ b/frontend/src/utils/forms/StreamUtils.js @@ -0,0 +1,19 @@ +import * as Yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import API from '../../api.js'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + url: Yup.string().required('URL is required').min(0), +}); + +export const getResolver = () => { + return yupResolver(schema); +}; + +export const updateStream = (streamId, payload) => { + return API.updateStream({ id: streamId, ...payload }); +}; +export const addStream = (payload) => { + return API.addStream(payload); +}; diff --git a/frontend/src/utils/forms/UserAgentUtils.js b/frontend/src/utils/forms/UserAgentUtils.js new file mode 100644 index 00000000..c05c7681 --- /dev/null +++ b/frontend/src/utils/forms/UserAgentUtils.js @@ -0,0 +1,17 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import API from '../../api.js'; +import * as Yup from 'yup'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + user_agent: Yup.string().required('User-Agent is required'), +}); +export const getResolver = () => { + return yupResolver(schema); +}; +export const updateUserAgent = (userAgentId, values) => { + return API.updateUserAgent({ id: userAgentId, ...values }); +}; +export const addUserAgent = (values) => { + return API.addUserAgent(values); +}; diff --git a/frontend/src/utils/forms/UserUtils.js b/frontend/src/utils/forms/UserUtils.js new file mode 100644 index 00000000..256d84bb --- /dev/null +++ b/frontend/src/utils/forms/UserUtils.js @@ -0,0 +1,150 @@ +import {NETWORK_ACCESS_OPTIONS, USER_LEVELS} from "../../constants.js"; +import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../networkUtils.js'; +import API from "../../api.js"; + +const isValidNetworkEntry = (entry) => + entry.match(IPV4_CIDR_REGEX) || + entry.match(IPV6_CIDR_REGEX) || + (entry + '/32').match(IPV4_CIDR_REGEX) || + (entry + '/128').match(IPV6_CIDR_REGEX); +const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS); + +export const createUser = (values) => { + return API.createUser(values); +}; + +export const updateUser = (userId, values, isAdmin, authUser) => { + return API.updateUser( + userId, + values, + isAdmin ? false : authUser.id === userId + ); +}; + +export const generateApiKey = (payload) => { + return API.generateApiKey(payload); +}; + +export const revokeApiKey = (payload) => { + return API.revokeApiKey(payload); +}; + +export const userToFormValues = (user) => { + const customProps = user.custom_properties || {}; + const networks = customProps.allowed_networks || {}; + + return { + username: user.username, + first_name: user.first_name || '', + last_name: user.last_name || '', + email: user.email, + user_level: `${user.user_level}`, + stream_limit: user.stream_limit || 0, + channel_profiles: + user.channel_profiles.length > 0 + ? user.channel_profiles.map((id) => `${id}`) + : ['0'], + xc_password: customProps.xc_password || '', + output_format: customProps.output_format || '', + output_profile: customProps.output_profile + ? `${customProps.output_profile}` + : '', + hide_adult_content: customProps.hide_adult_content || false, + epg_days: customProps.epg_days || 0, + epg_prev_days: customProps.epg_prev_days || 0, + allowed_ips: [ + ...new Set( + NETWORK_KEYS.flatMap((key) => + networks[key] ? networks[key].split(',').filter(Boolean) : [] + ) + ), + ], + }; +}; + +export const formValuesToPayload = (values, existingUser) => { + const customProps = { ...(existingUser?.custom_properties || {}) }; + const payload = { ...values }; + + customProps.xc_password = payload.xc_password || ''; + delete payload.xc_password; + + customProps.output_format = payload.output_format || null; + delete payload.output_format; + + customProps.output_profile = payload.output_profile + ? parseInt(payload.output_profile, 10) + : null; + delete payload.output_profile; + + customProps.hide_adult_content = payload.hide_adult_content || false; + delete payload.hide_adult_content; + + customProps.epg_days = payload.epg_days || 0; + delete payload.epg_days; + + customProps.epg_prev_days = payload.epg_prev_days || 0; + delete payload.epg_prev_days; + + const joined = (payload.allowed_ips || []).join(','); + delete payload.allowed_ips; + const allowed_networks = {}; + if (joined) { + NETWORK_KEYS.forEach((key) => { + allowed_networks[key] = joined; + }); + } + customProps.allowed_networks = allowed_networks; + + payload.custom_properties = customProps; + + if (payload.channel_profiles?.includes('0')) { + payload.channel_profiles = []; + } + + return payload; +}; + +export const getFormInitialValues = () => { + return { + username: '', + first_name: '', + last_name: '', + email: '', + user_level: '0', + stream_limit: 0, + password: '', + xc_password: '', + output_format: '', + output_profile: '', + channel_profiles: [], + hide_adult_content: false, + epg_days: 0, + epg_prev_days: 0, + allowed_ips: [], + }; +} + +export const getFormValidators = (user) => { + return (values) => ({ + username: !values.username + ? 'Username is required' + : values.user_level == USER_LEVELS.STREAMER && + !values.username.match(/^[a-z0-9]+$/i) + ? 'Streamer username must be alphanumeric' + : null, + password: + !user && !values.password && values.user_level != USER_LEVELS.STREAMER + ? 'Password is required' + : null, + xc_password: + values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i) + ? 'XC password must be alphanumeric' + : null, + allowed_ips: (values.allowed_ips || []).some( + (t) => !isValidNetworkEntry(t) + ) + ? 'Invalid IP address or CIDR range' + : null, + }); +} \ No newline at end of file diff --git a/frontend/src/utils/guideUtils.js b/frontend/src/utils/guideUtils.js index 58831713..9d6ec73c 100644 --- a/frontend/src/utils/guideUtils.js +++ b/frontend/src/utils/guideUtils.js @@ -11,7 +11,7 @@ import { getNow, getNowMs, roundToNearest, -} from '../utils/dateTimeUtils.js'; +} from './dateTimeUtils.js'; import API from '../api.js'; export const PROGRAM_HEIGHT = 90; @@ -309,25 +309,12 @@ export const getRuleByProgram = (rules, program) => { ); }; -export const createRecording = async (channel, program) => { - await API.createRecording({ - channel: `${channel.id}`, - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); +export const createRecording = async (values) => { + await API.createRecording(values); }; -export const createSeriesRule = async (program, mode) => { - await API.createSeriesRule({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); -}; - -export const evaluateSeriesRule = async (program) => { - await API.evaluateSeriesRules(program.tvg_id); +export const createSeriesRule = async (values) => { + await API.createSeriesRule(values); }; export const calculateLeftScrollPosition = (program, start) => { From 1dd0446701ffd51031f5c80acf023483d7630817 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:02:59 -0700 Subject: [PATCH 039/125] Added tests for utils --- .../RecordingDetailsModalUtils.test.js | 104 ++++ .../forms/__tests__/RecordingUtils.test.js | 503 ++++++++++++++++++ .../__tests__/RecurringRuleModalUtils.test.js | 163 ++---- .../SeriesRuleEditorModalUtils.test.js | 425 +++++++++++++++ .../__tests__/StreamProfileUtils.test.js | 197 +++++++ .../utils/forms/__tests__/StreamUtils.test.js | 95 ++++ .../forms/__tests__/UserAgentUtils.test.js | 95 ++++ .../utils/forms/__tests__/UserUtils.test.js | 432 +++++++++++++++ 8 files changed, 1902 insertions(+), 112 deletions(-) create mode 100644 frontend/src/utils/forms/__tests__/RecordingUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/StreamUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/UserAgentUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/UserUtils.test.js diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js index c86da2a8..303b891b 100644 --- a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js +++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js @@ -1,8 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils'; import dayjs from 'dayjs'; +import API from '../../../api.js'; + +vi.mock('../../../api.js', () => ({ + default: { + getChannel: vi.fn(), + updateRecordingMetadata: vi.fn(), + refreshArtwork: vi.fn(), + }, +})); + describe('RecordingDetailsModalUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('getStatRows', () => { it('should return all stats when all values are present', () => { const stats = { @@ -624,4 +638,94 @@ describe('RecordingDetailsModalUtils', () => { expect(result).toEqual([]); }); }); + + describe('getChannel', () => { + it('should call API.getChannel with the given id', async () => { + const mockChannel = { id: 42, name: 'Channel 1' }; + API.getChannel.mockResolvedValue(mockChannel); + + const result = await RecordingDetailsModalUtils.getChannel(42); + + expect(API.getChannel).toHaveBeenCalledWith(42); + expect(result).toEqual(mockChannel); + }); + + it('should propagate API errors', async () => { + API.getChannel.mockRejectedValue(new Error('Not found')); + + await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow('Not found'); + }); + }); + + describe('updateRecordingMetadata', () => { + it('should call API with title and description', async () => { + const mockResponse = { id: 1, title: 'Updated Title' }; + API.updateRecordingMetadata.mockResolvedValue(mockResponse); + + const recording = { id: 1 }; + const result = await RecordingDetailsModalUtils.updateRecordingMetadata( + recording, + 'Updated Title', + 'Updated description' + ); + + expect(API.updateRecordingMetadata).toHaveBeenCalledWith(1, { + title: 'Updated Title', + description: 'Updated description', + }); + expect(result).toEqual(mockResponse); + }); + + it('should default title to "Custom Recording" when editTitle is empty', async () => { + API.updateRecordingMetadata.mockResolvedValue({}); + + const recording = { id: 5 }; + await RecordingDetailsModalUtils.updateRecordingMetadata(recording, '', 'Some description'); + + expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, { + title: 'Custom Recording', + description: 'Some description', + }); + }); + + it('should default title to "Custom Recording" when editTitle is null', async () => { + API.updateRecordingMetadata.mockResolvedValue({}); + + const recording = { id: 5 }; + await RecordingDetailsModalUtils.updateRecordingMetadata(recording, null, 'desc'); + + expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, { + title: 'Custom Recording', + description: 'desc', + }); + }); + + it('should propagate API errors', async () => { + API.updateRecordingMetadata.mockRejectedValue(new Error('Server error')); + + await expect( + RecordingDetailsModalUtils.updateRecordingMetadata({ id: 1 }, 'Title', 'Desc') + ).rejects.toThrow('Server error'); + }); + }); + + describe('refreshArtwork', () => { + it('should call API.refreshArtwork with the given id', async () => { + const mockResponse = { success: true }; + API.refreshArtwork.mockResolvedValue(mockResponse); + + const result = await RecordingDetailsModalUtils.refreshArtwork(7); + + expect(API.refreshArtwork).toHaveBeenCalledWith(7); + expect(result).toEqual(mockResponse); + }); + + it('should propagate API errors', async () => { + API.refreshArtwork.mockRejectedValue(new Error('Artwork fetch failed')); + + await expect(RecordingDetailsModalUtils.refreshArtwork(7)).rejects.toThrow( + 'Artwork fetch failed' + ); + }); + }); }); diff --git a/frontend/src/utils/forms/__tests__/RecordingUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js new file mode 100644 index 00000000..be7cf1a2 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js @@ -0,0 +1,503 @@ +import { describe, it, expect, vi } from 'vitest'; +import dayjs from 'dayjs'; + +vi.mock('../../../api.js', () => ({ + default: { + getChannelsSummary: vi.fn(), + updateRecording: vi.fn(), + createRecording: vi.fn(), + createRecurringRule: vi.fn(), + }, +})); + +vi.mock('../../dateTimeUtils.js', () => ({ + add: vi.fn((d, amount, unit) => dayjs(d).add(amount, unit)), + diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)), + format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)), + getNow: vi.fn(() => dayjs('2024-06-15T10:00:00')), + initializeTime: vi.fn((val, fmts, ref, strict) => { + for (const fmt of fmts) { + const p = dayjs(val, fmt, strict); + if (p.isValid()) return p; + } + return dayjs('invalid'); + }), + isValid: vi.fn((d) => dayjs(d).isValid()), + roundToNearest: vi.fn((d) => d), + setMillisecond: vi.fn((d, ms) => dayjs(d).millisecond(ms)), + setSecond: vi.fn((d, s) => dayjs(d).second(s)), + toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())), + toTimeString: vi.fn((t) => { + if (!t) return '00:00'; + const match = String(t).match(/(\d{2}:\d{2})/); + return match ? match[1] : t; + }), +})); + +vi.mock('@mantine/form', () => ({ + isNotEmpty: vi.fn((msg) => (value) => (value ? null : msg)), +})); + +import API from '../../../api.js'; +import { + asDate, + toIsoIfDate, + toDateString, + createRoundedDate, + timeChange, + getChannelsSummary, + updateRecording, + createRecording, + createRecurringRule, + sortedChannelOptions, + numberedChannelLabel, + getSingleFormDefaults, + getRecurringFormDefaults, + buildSinglePayload, + buildRecurringPayload, + singleFormValidators, + recurringFormValidators, +} from '../RecordingUtils.js'; + +describe('RecordingUtils', () => { + // ─── asDate ─────────────────────────────────────────────────────────────────── + + describe('asDate', () => { + it('returns null for falsy input', () => { + expect(asDate(null)).toBeNull(); + expect(asDate(undefined)).toBeNull(); + expect(asDate('')).toBeNull(); + }); + + it('returns a Date instance unchanged', () => { + const d = new Date('2024-06-15'); + expect(asDate(d)).toBe(d); + }); + + it('parses a valid ISO string to a Date', () => { + const result = asDate('2024-06-15T10:00:00Z'); + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toContain('2024-06-15'); + }); + + it('returns null for an invalid date string', () => { + expect(asDate('not-a-date')).toBeNull(); + }); + }); + +// ─── toIsoIfDate ────────────────────────────────────────────────────────────── + + describe('toIsoIfDate', () => { + it('returns ISO string for a valid Date', () => { + const d = new Date('2024-06-15T00:00:00Z'); + expect(toIsoIfDate(d)).toBe(d.toISOString()); + }); + + it('returns ISO string for a valid date string', () => { + const result = toIsoIfDate('2024-06-15T10:00:00Z'); + expect(result).toMatch(/2024-06-15/); + }); + + it('returns the original value when not a valid date', () => { + expect(toIsoIfDate('some-string')).toBe('some-string'); + }); + + it('returns null for null input', () => { + expect(toIsoIfDate(null)).toBeNull(); + }); + }); + +// ─── toDateString ───────────────────────────────────────────────────────────── + + describe('toDateString', () => { + it('formats a Date to YYYY-MM-DD', () => { + const d = new Date('2024-06-15T12:00:00Z'); + const result = toDateString(d); + expect(result).toBe('2024-06-15'); + }); + + it('formats a valid ISO string to YYYY-MM-DD', () => { + expect(toDateString('2024-06-15T12:00:00')).toBe('2024-06-15'); + }); + + it('returns null for null input', () => { + expect(toDateString(null)).toBeNull(); + }); + + it('returns null for an invalid string', () => { + expect(toDateString('not-a-date')).toBeNull(); + }); + }); + +// ─── createRoundedDate ──────────────────────────────────────────────────────── + + describe('createRoundedDate', () => { + it('returns a Date', () => { + expect(createRoundedDate()).toBeInstanceOf(Date); + }); + + it('returns a Date when minutesAhead is provided', () => { + expect(createRoundedDate(60)).toBeInstanceOf(Date); + }); + }); + +// ─── timeChange ─────────────────────────────────────────────────────────────── + + describe('timeChange', () => { + it('calls setter with string directly', () => { + const setter = vi.fn(); + timeChange(setter)('10:30'); + expect(setter).toHaveBeenCalledWith('10:30'); + }); + + it('calls setter with currentTarget.value from event', () => { + const setter = vi.fn(); + timeChange(setter)({ currentTarget: { value: '09:00' } }); + expect(setter).toHaveBeenCalledWith('09:00'); + }); + + it('does not call setter for unrecognized input', () => { + const setter = vi.fn(); + timeChange(setter)(42); + expect(setter).not.toHaveBeenCalled(); + }); + }); + +// ─── API wrappers ───────────────────────────────────────────────────────────── + + describe('getChannelsSummary', () => { + it('calls API.getChannelsSummary', () => { + API.getChannelsSummary.mockResolvedValueOnce([]); + getChannelsSummary(); + expect(API.getChannelsSummary).toHaveBeenCalled(); + }); + }); + + describe('updateRecording', () => { + it('calls API.updateRecording with id and data', () => { + API.updateRecording.mockResolvedValueOnce({}); + updateRecording(1, { name: 'test' }); + expect(API.updateRecording).toHaveBeenCalledWith(1, { name: 'test' }); + }); + }); + + describe('createRecording', () => { + it('calls API.createRecording with data', () => { + API.createRecording.mockResolvedValueOnce({}); + createRecording({ channel: 1 }); + expect(API.createRecording).toHaveBeenCalledWith({ channel: 1 }); + }); + }); + + describe('createRecurringRule', () => { + it('calls API.createRecurringRule with data', () => { + API.createRecurringRule.mockResolvedValueOnce({}); + createRecurringRule({ channel: 1 }); + expect(API.createRecurringRule).toHaveBeenCalledWith({ channel: 1 }); + }); + }); + +// ─── sortedChannelOptions ───────────────────────────────────────────────────── + + describe('sortedChannelOptions', () => { + const channels = [ + { id: 3, name: 'CNN', channel_number: 20 }, + { id: 1, name: 'ESPN', channel_number: 5 }, + { id: 2, name: 'HBO', channel_number: 10 }, + ]; + + it('sorts channels by channel_number ascending', () => { + const result = sortedChannelOptions(channels); + // ESPN(5) < HBO(10) < CNN(20) → ids 1, 2, 3 + expect(result.map((o) => o.value)).toEqual(['1', '2', '3']); + }); + + it('maps each channel to { value, label }', () => { + const result = sortedChannelOptions([{ id: 7, name: 'FOX', channel_number: 3 }]); + expect(result[0]).toEqual({ value: '7', label: 'FOX' }); + }); + + it('uses custom labelFn when provided', () => { + const result = sortedChannelOptions( + [{ id: 1, name: 'ESPN', channel_number: 5 }], + (item) => `CH${item.channel_number} ${item.name}` + ); + expect(result[0].label).toBe('CH5 ESPN'); + }); + + it('falls back to "Channel id" when name is missing', () => { + const result = sortedChannelOptions([{ id: 9, channel_number: 1 }]); + expect(result[0].label).toBe('Channel 9'); + }); + + it('accepts an object (dict) of channels', () => { + const dict = { + a: { id: 1, name: 'A', channel_number: 2 }, + b: { id: 2, name: 'B', channel_number: 1 }, + }; + const result = sortedChannelOptions(dict); + expect(result[0].value).toBe('2'); + expect(result[1].value).toBe('1'); + }); + + it('returns empty array for null input', () => { + expect(sortedChannelOptions(null)).toEqual([]); + }); + + it('returns empty array for empty array input', () => { + expect(sortedChannelOptions([])).toEqual([]); + }); + + it('sorts by name when channel_numbers are equal', () => { + const input = [ + { id: 2, name: 'Zebra', channel_number: 5 }, + { id: 1, name: 'Apple', channel_number: 5 }, + ]; + const result = sortedChannelOptions(input); + expect(result[0].label).toBe('Apple'); + expect(result[1].label).toBe('Zebra'); + }); + + it('puts channels with no channel_number (0) first in numeric order', () => { + const input = [ + { id: 1, name: 'A', channel_number: 5 }, + { id: 2, name: 'B', channel_number: null }, + ]; + const result = sortedChannelOptions(input); + // null → 0, so it sorts before 5 + expect(result[0].value).toBe('2'); + }); + }); + +// ─── numberedChannelLabel ───────────────────────────────────────────────────── + + describe('numberedChannelLabel', () => { + it('returns "number - name" when both are present', () => { + expect(numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 })) + .toBe('501 - HBO'); + }); + + it('returns name only when channel_number is falsy', () => { + expect(numberedChannelLabel({ id: 1, name: 'HBO', channel_number: null })) + .toBe('HBO'); + }); + + it('falls back to "Channel id" when name is missing and number present', () => { + expect(numberedChannelLabel({ id: 5, channel_number: 10 })) + .toBe('10 - Channel 5'); + }); + + it('falls back to "Channel id" when both name and number are missing', () => { + expect(numberedChannelLabel({ id: 7 })).toBe('Channel 7'); + }); + }); + +// ─── getSingleFormDefaults ──────────────────────────────────────────────────── + + describe('getSingleFormDefaults', () => { + it('returns defaults with no arguments', () => { + const result = getSingleFormDefaults(); + expect(result.channel_id).toBe(''); + expect(result.start_time).toBeInstanceOf(Date); + expect(result.end_time).toBeInstanceOf(Date); + }); + + it('uses recording values when provided', () => { + const recording = { + channel: 3, + start_time: '2024-06-15T08:00:00Z', + end_time: '2024-06-15T09:00:00Z', + }; + const result = getSingleFormDefaults(recording); + expect(result.channel_id).toBe('3'); + expect(result.start_time).toBeInstanceOf(Date); + expect(result.end_time).toBeInstanceOf(Date); + }); + + it('uses channel id when channel is provided and recording is null', () => { + const result = getSingleFormDefaults(null, { id: 7 }); + expect(result.channel_id).toBe('7'); + }); + }); + +// ─── getRecurringFormDefaults ───────────────────────────────────────────────── + + describe('getRecurringFormDefaults', () => { + it('returns defaults with no arguments', () => { + const result = getRecurringFormDefaults(); + expect(result.channel_id).toBe(''); + expect(result.days_of_week).toEqual([]); + expect(result.rule_name).toBe(''); + expect(typeof result.start_time).toBe('string'); + expect(typeof result.end_time).toBe('string'); + expect(result.start_date).toBeInstanceOf(Date); + expect(result.end_date).toBeInstanceOf(Date); + }); + + it('uses channel name and id when channel is provided', () => { + const result = getRecurringFormDefaults({ id: 5, name: 'ESPN' }); + expect(result.channel_id).toBe('5'); + expect(result.rule_name).toBe('ESPN'); + }); + }); + +// ─── buildSinglePayload ─────────────────────────────────────────────────────── + + describe('buildSinglePayload', () => { + it('builds payload from values', () => { + const values = { + channel_id: '2', + start_time: new Date('2024-06-15T08:00:00Z'), + end_time: new Date('2024-06-15T09:00:00Z'), + }; + const result = buildSinglePayload(values); + expect(result.channel).toBe('2'); + expect(result.start_time).toContain('2024-06-15'); + expect(result.end_time).toContain('2024-06-15'); + }); + }); + +// ─── buildRecurringPayload ──────────────────────────────────────────────────── + + describe('buildRecurringPayload', () => { + it('builds payload from values', () => { + const values = { + channel_id: '3', + days_of_week: ['1', '3', '5'], + start_time: '08:00', + end_time: '09:00', + start_date: new Date('2024-06-15T12:00:00'), + end_date: new Date('2024-12-25T12:00:00'), + rule_name: ' Morning News ', + }; + const result = buildRecurringPayload(values); + expect(result.channel).toBe('3'); + expect(result.days_of_week).toEqual([1, 3, 5]); + expect(result.start_time).toBe('08:00'); + expect(result.end_time).toBe('09:00'); + expect(result.start_date).toBe('2024-06-15'); + expect(result.end_date).toBe('2024-12-25'); + expect(result.name).toBe('Morning News'); + }); + + it('handles empty days_of_week', () => { + const values = { + channel_id: '1', + days_of_week: [], + start_time: '10:00', + end_time: '11:00', + start_date: new Date('2024-06-15T12:00:00'), + end_date: new Date('2024-06-15T12:00:00'), + rule_name: '', + }; + const result = buildRecurringPayload(values); + expect(result.days_of_week).toEqual([]); + expect(result.name).toBe(''); + }); + }); + +// ─── singleFormValidators ───────────────────────────────────────────────────── + + describe('singleFormValidators', () => { + describe('end_time', () => { + it('returns error when end time is missing', () => { + expect(singleFormValidators.end_time(null, { start_time: null })).toBe( + 'Select an end time' + ); + }); + + it('returns error when end time is not after start time', () => { + const start = new Date('2024-06-15T10:00:00Z'); + const end = new Date('2024-06-15T09:00:00Z'); + expect(singleFormValidators.end_time(end, { start_time: start })).toBe( + 'End time must be after start time' + ); + }); + + it('returns error when end equals start', () => { + const t = new Date('2024-06-15T10:00:00Z'); + expect(singleFormValidators.end_time(t, { start_time: t })).toBe( + 'End time must be after start time' + ); + }); + + it('returns null when end is after start', () => { + const start = new Date('2024-06-15T09:00:00Z'); + const end = new Date('2024-06-15T10:00:00Z'); + expect(singleFormValidators.end_time(end, { start_time: start })).toBeNull(); + }); + }); + }); + +// ─── recurringFormValidators ────────────────────────────────────────────────── + + describe('recurringFormValidators', () => { + describe('days_of_week', () => { + it('returns error for empty array', () => { + expect(recurringFormValidators.days_of_week([])).toBe( + 'Pick at least one day' + ); + }); + + it('returns null for non-empty array', () => { + expect(recurringFormValidators.days_of_week([1])).toBeNull(); + }); + }); + + describe('start_time', () => { + it('returns error for falsy value', () => { + expect(recurringFormValidators.start_time(null)).toBe( + 'Select a start time' + ); + }); + + it('returns null for a valid time string', () => { + expect(recurringFormValidators.start_time('10:00')).toBeNull(); + }); + }); + + describe('end_time', () => { + it('returns error when value is falsy', () => { + expect(recurringFormValidators.end_time(null, { start_time: '2024-06-15T10:00:00' })).toBe( + 'Select an end time' + ); + }); + + it('returns error when end equals start (diff === 0)', () => { + const result = recurringFormValidators.end_time('2024-06-15T10:00:00', { + start_time: '2024-06-15T10:00:00', + }); + expect(result).toBe('End time must differ from start time'); + }); + + it('returns null when end differs from start', () => { + const result = recurringFormValidators.end_time('2024-06-15T11:00:00', { + start_time: '2024-06-15T10:00:00', + }); + expect(result).toBeNull(); + }); + }); + + describe('end_date', () => { + it('returns error when end_date is missing', () => { + expect( + recurringFormValidators.end_date(null, { start_date: new Date() }) + ).toBe('Select an end date'); + }); + + it('returns error when end_date is before start_date', () => { + const start = new Date('2024-06-15'); + const end = new Date('2024-06-01'); + expect(recurringFormValidators.end_date(end, { start_date: start })).toBe( + 'End date cannot be before start date' + ); + }); + + it('returns null when end_date is on or after start_date', () => { + const start = new Date('2024-06-15'); + const end = new Date('2024-06-15'); + expect(recurringFormValidators.end_date(end, { start_date: start })).toBeNull(); + }); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js index 04561926..fd6d8db2 100644 --- a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js +++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js @@ -10,113 +10,24 @@ vi.mock('../../../api.js', () => ({ }, })); +vi.mock('../../dateTimeUtils.js', () => ({ + toTimeString: vi.fn((t) => { + if (!t) return '00:00'; + const match = String(t).match(/T(\d{2}:\d{2})/); + return match ? match[1] : t; + }), + parseDate: vi.fn((d) => (d ? dayjs(d).toDate() : null)), + toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())), + getNow: vi.fn(() => dayjs()), + isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))), + format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)), +})); + describe('RecurringRuleModalUtils', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('getChannelOptions', () => { - it('should return sorted channel options by channel number', () => { - const channels = { - ch1: { id: 1, channel_number: '10', name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' }, - ch3: { id: 3, channel_number: '15', name: 'CBS' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '2', label: 'NBC' }, - { value: '1', label: 'ABC' }, - { value: '3', label: 'CBS' }, - ]); - }); - - it('should sort alphabetically by name when channel numbers are equal', () => { - const channels = { - ch1: { id: 1, channel_number: '10', name: 'ZBC' }, - ch2: { id: 2, channel_number: '10', name: 'ABC' }, - ch3: { id: 3, channel_number: '10', name: 'MBC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '2', label: 'ABC' }, - { value: '3', label: 'MBC' }, - { value: '1', label: 'ZBC' }, - ]); - }); - - it('should handle missing channel numbers', () => { - const channels = { - ch1: { id: 1, name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '1', label: 'ABC' }, - { value: '2', label: 'NBC' }, - ]); - }); - - it('should use fallback label when name is missing', () => { - const channels = { - ch1: { id: 1, channel_number: '10' }, - ch2: { id: 2, channel_number: '5', name: '' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '2', label: 'Channel 2' }, - { value: '1', label: 'Channel 1' }, - ]); - }); - - it('should handle empty channels object', () => { - const result = RecurringRuleModalUtils.getChannelOptions({}); - - expect(result).toEqual([]); - }); - - it('should handle null channels', () => { - const result = RecurringRuleModalUtils.getChannelOptions(null); - - expect(result).toEqual([]); - }); - - it('should handle undefined channels', () => { - const result = RecurringRuleModalUtils.getChannelOptions(undefined); - - expect(result).toEqual([]); - }); - - it('should convert channel id to string value', () => { - const channels = { - ch1: { id: 123, channel_number: '10', name: 'ABC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result[0].value).toBe('123'); - expect(typeof result[0].value).toBe('string'); - }); - - it('should handle non-numeric channel numbers', () => { - const channels = { - ch1: { id: 1, channel_number: 'HD1', name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toHaveLength(2); - }); - }); - describe('getUpcomingOccurrences', () => { let toUserTime; let userNow; @@ -310,11 +221,11 @@ describe('RecurringRuleModalUtils', () => { const values = { channel_id: '5', days_of_week: ['1', '3', '5'], - start_time: '14:30', - end_time: '16:00', - start_date: '2024-01-01', - end_date: '2024-12-31', - rule_name: 'My Rule', + start_time: '10:00', + end_time: '11:00', + start_date: dayjs('2024-06-15'), + end_date: dayjs('2024-12-25'), + rule_name: ' Test Rule ', enabled: true, }; @@ -323,11 +234,11 @@ describe('RecurringRuleModalUtils', () => { expect(API.updateRecurringRule).toHaveBeenCalledWith(1, { channel: '5', days_of_week: [1, 3, 5], - start_time: '14:30', - end_time: '16:00', - start_date: '2024-01-01', - end_date: '2024-12-31', - name: 'My Rule', + start_time: '10:00', + end_time: '11:00', + start_date: '2024-06-15', + end_date: '2024-12-25', + name: 'Test Rule', enabled: true, }); }); @@ -530,4 +441,32 @@ describe('RecurringRuleModalUtils', () => { }); }); }); + + describe('getFormDefaults', () => { + it('should return formatted defaults from rule', () => { + const rule = { + channel: 5, + days_of_week: [1, 3, 5], + name: 'Test Rule', + start_time: '2024-06-15T10:00:00', + end_time: '2024-06-15T11:00:00', + start_date: '2024-06-15', + end_date: '2024-12-25', + enabled: true, + }; + + const result = RecurringRuleModalUtils.getFormDefaults(rule); + + expect(result).toEqual({ + channel_id: '5', + days_of_week: ['1', '3', '5'], + rule_name: 'Test Rule', + start_time: '10:00', + end_time: '11:00', + start_date: dayjs('2024-06-15').toDate(), + end_date: dayjs('2024-12-25').toDate(), + enabled: true, + }); + }); + }); }); diff --git a/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js new file mode 100644 index 00000000..1731e08d --- /dev/null +++ b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js @@ -0,0 +1,425 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Dependency mocks ─────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + previewSeriesRule: vi.fn(), + }, +})); + +vi.mock('../RecordingUtils.js', () => ({ + numberedChannelLabel: vi.fn( + (item) => + item.channel_number + ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` + : item.name || `Channel ${item.id}` + ), + sortedChannelOptions: vi.fn((channels) => + (Array.isArray(channels) ? channels : []).map((c) => ({ + value: String(c.id), + label: c.name || `Channel ${c.id}`, + })) + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import API from '../../../api.js'; +import { + TITLE_MODES, + DESCRIPTION_MODES, + EPISODE_MODES, + formatRange, + getTvgOptions, + getChannelOptions, + previewSeriesRule, +} from '../SeriesRuleEditorModalUtils.js'; +import { sortedChannelOptions, numberedChannelLabel } from '../RecordingUtils.js'; + +// ───────────────────────────────────────────────────────────────────────────── + +describe('SeriesRuleEditorModalUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Constants ────────────────────────────────────────────────────────────── + + describe('TITLE_MODES', () => { + it('exports an array of mode objects', () => { + expect(Array.isArray(TITLE_MODES)).toBe(true); + expect(TITLE_MODES.length).toBeGreaterThan(0); + }); + + it('every entry has a label and value', () => { + TITLE_MODES.forEach((m) => { + expect(m).toHaveProperty('label'); + expect(m).toHaveProperty('value'); + }); + }); + + it('contains exact, contains, search and regex modes', () => { + const values = TITLE_MODES.map((m) => m.value); + expect(values).toContain('exact'); + expect(values).toContain('contains'); + expect(values).toContain('search'); + expect(values).toContain('regex'); + }); + }); + + describe('DESCRIPTION_MODES', () => { + it('exports an array of mode objects', () => { + expect(Array.isArray(DESCRIPTION_MODES)).toBe(true); + expect(DESCRIPTION_MODES.length).toBeGreaterThan(0); + }); + + it('every entry has a label and value', () => { + DESCRIPTION_MODES.forEach((m) => { + expect(m).toHaveProperty('label'); + expect(m).toHaveProperty('value'); + }); + }); + + it('contains contains, search and regex modes', () => { + const values = DESCRIPTION_MODES.map((m) => m.value); + expect(values).toContain('contains'); + expect(values).toContain('search'); + expect(values).toContain('regex'); + }); + + it('does not contain exact mode', () => { + const values = DESCRIPTION_MODES.map((m) => m.value); + expect(values).not.toContain('exact'); + }); + }); + + describe('EPISODE_MODES', () => { + it('exports an array with all and new modes', () => { + const values = EPISODE_MODES.map((m) => m.value); + expect(values).toContain('all'); + expect(values).toContain('new'); + }); + + it('every entry has a label and value', () => { + EPISODE_MODES.forEach((m) => { + expect(m).toHaveProperty('label'); + expect(m).toHaveProperty('value'); + }); + }); + }); + + // ── formatRange ──────────────────────────────────────────────────────────── + + describe('formatRange', () => { + it('formats same-day range as "date startTime - endTime"', () => { + const start = '2024-06-01T10:00:00'; + const end = '2024-06-01T11:30:00'; + const result = formatRange(start, end); + // Must include a dash separating start and end times (not "->") + expect(result).toMatch(/-(?!>)/); + expect(result).not.toMatch(/->/); + }); + + it('formats cross-day range with "->" separator', () => { + const start = '2024-06-01T23:00:00'; + const end = '2024-06-02T01:00:00'; + const result = formatRange(start, end); + expect(result).toMatch(/->/); + }); + + it('returns fallback string when dates are invalid', () => { + const result = formatRange('not-a-date', 'also-not-a-date'); + expect(result).toBe('not-a-date - also-not-a-date'); + }); + + it('includes a date string component in the output', () => { + const start = '2024-06-01T10:00:00'; + const end = '2024-06-01T11:00:00'; + const result = formatRange(start, end); + // toLocaleDateString produces something non-empty + expect(result.length).toBeGreaterThan(5); + }); + + it('handles ISO strings with timezone offsets', () => { + const start = '2024-06-01T10:00:00Z'; + const end = '2024-06-01T11:00:00Z'; + // Should not throw + expect(() => formatRange(start, end)).not.toThrow(); + }); + }); + + // ── getTvgOptions ────────────────────────────────────────────────────────── + + describe('getTvgOptions', () => { + it('returns empty array for null input', () => { + expect(getTvgOptions(null)).toEqual([]); + }); + + it('returns empty array for empty array input', () => { + expect(getTvgOptions([])).toEqual([]); + }); + + it('maps tvgs to { value, label } options', () => { + const tvgs = [{ tvg_id: 'tvg-1', name: 'Channel One' }]; + const result = getTvgOptions(tvgs); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + value: 'tvg-1', + label: 'Channel One (tvg-1)', + }); + }); + + it('uses tvg_id as label when name is missing', () => { + const tvgs = [{ tvg_id: 'tvg-no-name' }]; + const result = getTvgOptions(tvgs); + expect(result[0]).toEqual({ + value: 'tvg-no-name', + label: 'tvg-no-name', + }); + }); + + it('deduplicates by tvg_id', () => { + const tvgs = [ + { tvg_id: 'tvg-1', name: 'A' }, + { tvg_id: 'tvg-1', name: 'B' }, + { tvg_id: 'tvg-2', name: 'C' }, + ]; + const result = getTvgOptions(tvgs); + expect(result).toHaveLength(2); + const values = result.map((o) => o.value); + expect(values).toContain('tvg-1'); + expect(values).toContain('tvg-2'); + }); + + it('skips entries with no tvg_id', () => { + const tvgs = [ + { tvg_id: null, name: 'No ID' }, + { tvg_id: '', name: 'Empty ID' }, + { tvg_id: 'tvg-1', name: 'Valid' }, + ]; + const result = getTvgOptions(tvgs); + expect(result).toHaveLength(1); + expect(result[0].value).toBe('tvg-1'); + }); + + it('sorts options alphabetically by label', () => { + const tvgs = [ + { tvg_id: 'tvg-z', name: 'Zebra' }, + { tvg_id: 'tvg-a', name: 'Apple' }, + { tvg_id: 'tvg-m', name: 'Mango' }, + ]; + const result = getTvgOptions(tvgs); + const labels = result.map((o) => o.label); + expect(labels).toEqual([...labels].sort()); + }); + + it('sorts by label text including the tvg_id suffix', () => { + const tvgs = [ + { tvg_id: 'z-id', name: 'Same' }, + { tvg_id: 'a-id', name: 'Same' }, + ]; + const result = getTvgOptions(tvgs); + // "Same (a-id)" < "Same (z-id)" + expect(result[0].value).toBe('a-id'); + }); + }); + + // ── getChannelOptions ────────────────────────────────────────────────────── + + describe('getChannelOptions', () => { + const makeChannels = () => [ + { id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' }, + { id: 2, name: 'HBO', channel_number: 10, epg_data_id: 'epg-2' }, + { id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' }, + ]; + + const makeTvgsById = () => ({ + 'epg-1': { tvg_id: 'tvg-espn' }, + 'epg-2': { tvg_id: 'tvg-hbo' }, + 'epg-3': { tvg_id: 'tvg-cnn' }, + }); + + it('calls sortedChannelOptions with allChannels and numberedChannelLabel', () => { + const channels = makeChannels(); + getChannelOptions(channels, {}, null); + expect(sortedChannelOptions).toHaveBeenCalledWith( + channels, + numberedChannelLabel + ); + }); + + it('returns all channels when tvgId is null', () => { + const channels = makeChannels(); + const result = getChannelOptions(channels, makeTvgsById(), null); + expect(result).toHaveLength(3); + }); + + it('returns all channels when tvgId is empty string', () => { + const channels = makeChannels(); + const result = getChannelOptions(channels, makeTvgsById(), ''); + expect(result).toHaveLength(3); + }); + + it('places matching channels before non-matching when tvgId set', () => { + // Override sortedChannelOptions to return deterministic output + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + { value: '2', label: 'HBO' }, + { value: '3', label: 'CNN' }, + ]); + + const channels = makeChannels(); + const tvgsById = makeTvgsById(); + + // tvgId matches ESPN (epg-1 → tvg-espn) + const result = getChannelOptions(channels, tvgsById, 'tvg-espn'); + + expect(result[0].value).toBe('1'); // ESPN first (matching) + expect(result[1].value).toBe('2'); // HBO second (non-matching) + expect(result[2].value).toBe('3'); // CNN third (non-matching) + }); + + it('returns all in sorted order when no channel matches tvgId', () => { + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + { value: '2', label: 'HBO' }, + ]); + + const channels = makeChannels(); + const result = getChannelOptions(channels, makeTvgsById(), 'tvg-unknown'); + + // No matches → all in others, order preserved from sortedChannelOptions + expect(result.map((r) => r.value)).toEqual(['1', '2']); + }); + + it('handles channel with no epg_data_id (cTvg is null)', () => { + const channels = [{ id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null }]; + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '9', label: 'NoEPG' }, + ]); + + const result = getChannelOptions(channels, {}, 'tvg-1'); + + // No epg match → goes to others + expect(result).toHaveLength(1); + expect(result[0].value).toBe('9'); + }); + + it('handles missing epg_data_id entry in tvgsById', () => { + const channels = [{ id: 5, name: 'Unknown', channel_number: 1, epg_data_id: 'epg-missing' }]; + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '5', label: 'Unknown' }, + ]); + + const result = getChannelOptions(channels, {}, 'tvg-1'); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('5'); + }); + + it('handles null tvgsById gracefully', () => { + const channels = makeChannels(); + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + ]); + + expect(() => getChannelOptions(channels, null, 'tvg-espn')).not.toThrow(); + }); + + it('returns empty array for empty channels', () => { + vi.mocked(sortedChannelOptions).mockReturnValueOnce([]); + const result = getChannelOptions([], {}, 'tvg-1'); + expect(result).toEqual([]); + }); + + it('places multiple matching channels before non-matching', () => { + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + { value: '2', label: 'ESPN2' }, + { value: '3', label: 'CNN' }, + ]); + + const channels = [ + { id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' }, + { id: 2, name: 'ESPN2', channel_number: 6, epg_data_id: 'epg-4' }, + { id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' }, + ]; + const tvgsById = { + 'epg-1': { tvg_id: 'tvg-espn' }, + 'epg-4': { tvg_id: 'tvg-espn' }, // both ESPN channels share tvg_id + 'epg-3': { tvg_id: 'tvg-cnn' }, + }; + + const result = getChannelOptions(channels, tvgsById, 'tvg-espn'); + + expect(result[0].value).toBe('1'); + expect(result[1].value).toBe('2'); + expect(result[2].value).toBe('3'); + }); + }); + + // ── previewSeriesRule ────────────────────────────────────────────────────── + + describe('previewSeriesRule', () => { + it('calls API.previewSeriesRule with the key and abort signal', async () => { + const mockResult = { matches: [], total: 0 }; + vi.mocked(API.previewSeriesRule).mockResolvedValue(mockResult); + + const controller = new AbortController(); + const key = { title: 'Test Show', mode: 'all' }; + + const result = await previewSeriesRule(key, controller); + + expect(API.previewSeriesRule).toHaveBeenCalledWith(key, { + signal: controller.signal, + }); + expect(result).toBe(mockResult); + }); + + it('passes the abort signal from the controller', async () => { + vi.mocked(API.previewSeriesRule).mockResolvedValue({}); + const controller = new AbortController(); + await previewSeriesRule({ title: 'X' }, controller); + + const callArgs = vi.mocked(API.previewSeriesRule).mock.calls[0]; + expect(callArgs[1].signal).toBe(controller.signal); + }); + + it('propagates rejection from API.previewSeriesRule', async () => { + vi.mocked(API.previewSeriesRule).mockRejectedValue(new Error('Network error')); + const controller = new AbortController(); + await expect(previewSeriesRule({}, controller)).rejects.toThrow('Network error'); + }); + + it('propagates AbortError when signal is aborted', async () => { + const abortError = new DOMException('Aborted', 'AbortError'); + vi.mocked(API.previewSeriesRule).mockRejectedValue(abortError); + + const controller = new AbortController(); + controller.abort(); + + await expect(previewSeriesRule({}, controller)).rejects.toThrow('Aborted'); + }); + + it('passes through all fields of the preview key', async () => { + vi.mocked(API.previewSeriesRule).mockResolvedValue({}); + const controller = new AbortController(); + const key = { + title: 'My Show', + title_mode: 'exact', + description: 'drama', + description_mode: 'contains', + mode: 'new', + tvg_id: 'tvg-1', + channel_id: 5, + }; + + await previewSeriesRule(key, controller); + + expect(API.previewSeriesRule).toHaveBeenCalledWith( + key, + expect.objectContaining({ signal: controller.signal }) + ); + }); + }); +}); + diff --git a/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js new file mode 100644 index 00000000..3ed9357e --- /dev/null +++ b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + updateStreamProfile: vi.fn(), + addStreamProfile: vi.fn(), + }, +})); + +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn((schema) => ({ __schema: schema })), +})); + +import API from '../../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + toCommandSelection, + getResolver, + updateStreamProfile, + addStreamProfile, +} from '../StreamProfileUtils.js'; + +describe('StreamProfileUtils', () => { + // ─── BUILT_IN_COMMANDS ──────────────────────────────────────────────────────── + + describe('BUILT_IN_COMMANDS', () => { + it('includes ffmpeg, streamlink, cvlc, yt-dlp, and __custom__', () => { + const values = BUILT_IN_COMMANDS.map((c) => c.value); + expect(values).toContain('ffmpeg'); + expect(values).toContain('streamlink'); + expect(values).toContain('cvlc'); + expect(values).toContain('yt-dlp'); + expect(values).toContain('__custom__'); + }); + + it('each entry has a value and a label', () => { + for (const cmd of BUILT_IN_COMMANDS) { + expect(cmd).toHaveProperty('value'); + expect(cmd).toHaveProperty('label'); + expect(typeof cmd.value).toBe('string'); + expect(typeof cmd.label).toBe('string'); + } + }); + + it('labels the custom entry "Custom…"', () => { + const custom = BUILT_IN_COMMANDS.find((c) => c.value === '__custom__'); + expect(custom?.label).toBe('Custom…'); + }); + }); + + // ─── COMMAND_EXAMPLES ───────────────────────────────────────────────────────── + + describe('COMMAND_EXAMPLES', () => { + it('has an entry for each non-custom built-in command', () => { + const nonCustom = BUILT_IN_COMMANDS.filter((c) => c.value !== '__custom__'); + for (const cmd of nonCustom) { + expect(COMMAND_EXAMPLES).toHaveProperty(cmd.value); + expect(typeof COMMAND_EXAMPLES[cmd.value]).toBe('string'); + expect(COMMAND_EXAMPLES[cmd.value].length).toBeGreaterThan(0); + } + }); + + it('does not have an entry for __custom__', () => { + expect(COMMAND_EXAMPLES).not.toHaveProperty('__custom__'); + }); + + it('ffmpeg example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES.ffmpeg).toContain('{streamUrl}'); + }); + + it('streamlink example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES.streamlink).toContain('{streamUrl}'); + }); + + it('cvlc example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES.cvlc).toContain('{streamUrl}'); + }); + + it('yt-dlp example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES['yt-dlp']).toContain('{streamUrl}'); + }); + }); + + // ─── toCommandSelection ─────────────────────────────────────────────────────── + + describe('toCommandSelection', () => { + it('returns "ffmpeg" for "ffmpeg"', () => { + expect(toCommandSelection('ffmpeg')).toBe('ffmpeg'); + }); + + it('returns "streamlink" for "streamlink"', () => { + expect(toCommandSelection('streamlink')).toBe('streamlink'); + }); + + it('returns "cvlc" for "cvlc"', () => { + expect(toCommandSelection('cvlc')).toBe('cvlc'); + }); + + it('returns "yt-dlp" for "yt-dlp"', () => { + expect(toCommandSelection('yt-dlp')).toBe('yt-dlp'); + }); + + it('returns "__custom__" for "__custom__"', () => { + expect(toCommandSelection('__custom__')).toBe('__custom__'); + }); + + it('returns "__custom__" for an unrecognized command', () => { + expect(toCommandSelection('myspecialtool')).toBe('__custom__'); + }); + + it('returns "__custom__" for an empty string', () => { + expect(toCommandSelection('')).toBe('__custom__'); + }); + + it('returns "__custom__" for null', () => { + expect(toCommandSelection(null)).toBe('__custom__'); + }); + + it('returns "__custom__" for undefined', () => { + expect(toCommandSelection(undefined)).toBe('__custom__'); + }); + }); + + // ─── getResolver ────────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver and returns its result', () => { + const result = getResolver(); + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('returns the same resolver on subsequent calls', () => { + const r1 = getResolver(); + const r2 = getResolver(); + expect(r1).toEqual(r2); + }); + }); + + // ─── updateStreamProfile ────────────────────────────────────────────────────── + + describe('updateStreamProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.updateStreamProfile with id merged into values', () => { + API.updateStreamProfile.mockResolvedValueOnce({ id: 3, name: 'HD' }); + updateStreamProfile(3, { name: 'HD', command: 'ffmpeg' }); + expect(API.updateStreamProfile).toHaveBeenCalledWith({ + id: 3, + name: 'HD', + command: 'ffmpeg', + }); + }); + + it('returns the promise from API.updateStreamProfile', () => { + const resolved = Promise.resolve({ id: 1 }); + API.updateStreamProfile.mockReturnValueOnce(resolved); + const result = updateStreamProfile(1, {}); + expect(result).toBe(resolved); + }); + }); + + // ─── addStreamProfile ───────────────────────────────────────────────────────── + + describe('addStreamProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.addStreamProfile with the provided values', () => { + API.addStreamProfile.mockResolvedValueOnce({ id: 5 }); + addStreamProfile({ name: 'New', command: 'streamlink', parameters: '' }); + expect(API.addStreamProfile).toHaveBeenCalledWith({ + name: 'New', + command: 'streamlink', + parameters: '', + }); + }); + + it('returns the promise from API.addStreamProfile', () => { + const resolved = Promise.resolve({ id: 5 }); + API.addStreamProfile.mockReturnValueOnce(resolved); + const result = addStreamProfile({}); + expect(result).toBe(resolved); + }); + + it('passes through an empty object without error', () => { + API.addStreamProfile.mockResolvedValueOnce({}); + expect(() => addStreamProfile({})).not.toThrow(); + expect(API.addStreamProfile).toHaveBeenCalledWith({}); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/StreamUtils.test.js b/frontend/src/utils/forms/__tests__/StreamUtils.test.js new file mode 100644 index 00000000..4eeded36 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/StreamUtils.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + updateStream: vi.fn(), + addStream: vi.fn(), + }, +})); + +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn((schema) => ({ __schema: schema })), +})); + +import API from '../../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { getResolver, updateStream, addStream } from '../StreamUtils.js'; + +describe('StreamUtils', () => { + // ─── getResolver ────────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver and returns its result', () => { + const result = getResolver(); + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('returns the same resolver on subsequent calls', () => { + const r1 = getResolver(); + const r2 = getResolver(); + expect(r1).toEqual(r2); + }); + }); + +// ─── updateStream ───────────────────────────────────────────────────────────── + + describe('updateStream', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.updateStream with id merged into payload', () => { + API.updateStream.mockResolvedValueOnce({ id: 1, name: 'Test Stream' }); + updateStream(1, { name: 'Test Stream', url: 'http://example.com' }); + expect(API.updateStream).toHaveBeenCalledWith({ + id: 1, + name: 'Test Stream', + url: 'http://example.com', + }); + }); + + it('returns the promise from API.updateStream', () => { + const resolved = Promise.resolve({ id: 1 }); + API.updateStream.mockReturnValueOnce(resolved); + const result = updateStream(1, {}); + expect(result).toBe(resolved); + }); + + it('handles an empty payload without throwing', () => { + API.updateStream.mockResolvedValueOnce({}); + expect(() => updateStream(5, {})).not.toThrow(); + expect(API.updateStream).toHaveBeenCalledWith({ id: 5 }); + }); + }); + +// ─── addStream ──────────────────────────────────────────────────────────────── + + describe('addStream', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.addStream with the provided payload', () => { + API.addStream.mockResolvedValueOnce({ id: 2 }); + addStream({ name: 'New Stream', url: 'http://example.com/stream' }); + expect(API.addStream).toHaveBeenCalledWith({ + name: 'New Stream', + url: 'http://example.com/stream', + }); + }); + + it('returns the promise from API.addStream', () => { + const resolved = Promise.resolve({ id: 2 }); + API.addStream.mockReturnValueOnce(resolved); + const result = addStream({ name: 'test' }); + expect(result).toBe(resolved); + }); + + it('passes through an empty object without error', () => { + API.addStream.mockResolvedValueOnce({}); + expect(() => addStream({})).not.toThrow(); + expect(API.addStream).toHaveBeenCalledWith({}); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js new file mode 100644 index 00000000..ed6163b2 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + updateUserAgent: vi.fn(), + addUserAgent: vi.fn(), + }, +})); + +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn((schema) => ({ __schema: schema })), +})); + +import API from '../../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { getResolver, updateUserAgent, addUserAgent } from '../UserAgentUtils.js'; + +describe('UserAgentUtils', () => { + // ─── getResolver ────────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver and returns its result', () => { + const result = getResolver(); + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('returns the same resolver on subsequent calls', () => { + const r1 = getResolver(); + const r2 = getResolver(); + expect(r1).toEqual(r2); + }); + }); + +// ─── updateUserAgent ────────────────────────────────────────────────────────── + + describe('updateUserAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.updateUserAgent with id merged into values', () => { + API.updateUserAgent.mockResolvedValueOnce({ id: 1, name: 'Chrome' }); + updateUserAgent(1, { name: 'Chrome', user_agent: 'Mozilla/5.0' }); + expect(API.updateUserAgent).toHaveBeenCalledWith({ + id: 1, + name: 'Chrome', + user_agent: 'Mozilla/5.0', + }); + }); + + it('returns the promise from API.updateUserAgent', () => { + const resolved = Promise.resolve({ id: 1 }); + API.updateUserAgent.mockReturnValueOnce(resolved); + const result = updateUserAgent(1, {}); + expect(result).toBe(resolved); + }); + + it('handles an empty values object without throwing', () => { + API.updateUserAgent.mockResolvedValueOnce({}); + expect(() => updateUserAgent(5, {})).not.toThrow(); + expect(API.updateUserAgent).toHaveBeenCalledWith({ id: 5 }); + }); + }); + +// ─── addUserAgent ───────────────────────────────────────────────────────────── + + describe('addUserAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.addUserAgent with the provided values', () => { + API.addUserAgent.mockResolvedValueOnce({ id: 2 }); + addUserAgent({ name: 'Firefox', user_agent: 'Mozilla/5.0 (Firefox)' }); + expect(API.addUserAgent).toHaveBeenCalledWith({ + name: 'Firefox', + user_agent: 'Mozilla/5.0 (Firefox)', + }); + }); + + it('returns the promise from API.addUserAgent', () => { + const resolved = Promise.resolve({ id: 2 }); + API.addUserAgent.mockReturnValueOnce(resolved); + const result = addUserAgent({ name: 'test' }); + expect(result).toBe(resolved); + }); + + it('passes through an empty object without error', () => { + API.addUserAgent.mockResolvedValueOnce({}); + expect(() => addUserAgent({})).not.toThrow(); + expect(API.addUserAgent).toHaveBeenCalledWith({}); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/UserUtils.test.js b/frontend/src/utils/forms/__tests__/UserUtils.test.js new file mode 100644 index 00000000..3618228a --- /dev/null +++ b/frontend/src/utils/forms/__tests__/UserUtils.test.js @@ -0,0 +1,432 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + createUser: vi.fn(), + updateUser: vi.fn(), + generateApiKey: vi.fn(), + revokeApiKey: vi.fn(), + }, +})); + +vi.mock('../../../constants.js', () => ({ + USER_LEVELS: { + ADMIN: 0, + STREAMER: 2, + }, + NETWORK_ACCESS_OPTIONS: { + m3u: 'M3U', + xc: 'XC', + mpegts: 'MPEG-TS', + }, +})); + +vi.mock('../../networkUtils.js', () => ({ + IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, + IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/, +})); + +import API from '../../../api.js'; +import { + createUser, + updateUser, + generateApiKey, + revokeApiKey, + userToFormValues, + formValuesToPayload, + getFormInitialValues, + getFormValidators, +} from '../UserUtils.js'; + +describe('UserUtils', () => { + // ─── createUser ─────────────────────────────────────────────────────────────── + + describe('createUser', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.createUser with the provided values', () => { + API.createUser.mockResolvedValueOnce({ id: 1 }); + createUser({ username: 'alice', password: 'pass' }); + expect(API.createUser).toHaveBeenCalledWith({ username: 'alice', password: 'pass' }); + }); + + it('returns the promise from API.createUser', () => { + const resolved = Promise.resolve({ id: 1 }); + API.createUser.mockReturnValueOnce(resolved); + expect(createUser({})).toBe(resolved); + }); + }); + +// ─── updateUser ─────────────────────────────────────────────────────────────── + + describe('updateUser', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.updateUser with selfEdit=false when isAdmin is true', () => { + API.updateUser.mockResolvedValueOnce({}); + updateUser(1, { username: 'bob' }, true, { id: 1 }); + expect(API.updateUser).toHaveBeenCalledWith(1, { username: 'bob' }, false); + }); + + it('calls API.updateUser with selfEdit=true when not admin and userId matches authUser', () => { + API.updateUser.mockResolvedValueOnce({}); + updateUser(5, { username: 'carol' }, false, { id: 5 }); + expect(API.updateUser).toHaveBeenCalledWith(5, { username: 'carol' }, true); + }); + + it('calls API.updateUser with selfEdit=false when not admin and userId does not match authUser', () => { + API.updateUser.mockResolvedValueOnce({}); + updateUser(5, { username: 'dave' }, false, { id: 99 }); + expect(API.updateUser).toHaveBeenCalledWith(5, { username: 'dave' }, false); + }); + + it('returns the promise from API.updateUser', () => { + const resolved = Promise.resolve({}); + API.updateUser.mockReturnValueOnce(resolved); + expect(updateUser(1, {}, true, { id: 1 })).toBe(resolved); + }); + }); + +// ─── generateApiKey ─────────────────────────────────────────────────────────── + + describe('generateApiKey', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.generateApiKey with the payload', () => { + API.generateApiKey.mockResolvedValueOnce({ key: 'abc123' }); + generateApiKey({ user_id: 1 }); + expect(API.generateApiKey).toHaveBeenCalledWith({ user_id: 1 }); + }); + + it('returns the promise from API.generateApiKey', () => { + const resolved = Promise.resolve({ key: 'abc' }); + API.generateApiKey.mockReturnValueOnce(resolved); + expect(generateApiKey({})).toBe(resolved); + }); + }); + +// ─── revokeApiKey ───────────────────────────────────────────────────────────── + + describe('revokeApiKey', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.revokeApiKey with the payload', () => { + API.revokeApiKey.mockResolvedValueOnce({}); + revokeApiKey({ key: 'abc123' }); + expect(API.revokeApiKey).toHaveBeenCalledWith({ key: 'abc123' }); + }); + + it('returns the promise from API.revokeApiKey', () => { + const resolved = Promise.resolve({}); + API.revokeApiKey.mockReturnValueOnce(resolved); + expect(revokeApiKey({})).toBe(resolved); + }); + }); + +// ─── userToFormValues ───────────────────────────────────────────────────────── + + describe('userToFormValues', () => { + const makeUser = (overrides = {}) => ({ + username: 'alice', + first_name: 'Alice', + last_name: 'Smith', + email: 'alice@example.com', + user_level: 1, + stream_limit: 2, + channel_profiles: ['1', '2'], + custom_properties: {}, + ...overrides, + }); + + it('maps basic user fields to form values', () => { + const result = userToFormValues(makeUser()); + expect(result.username).toBe('alice'); + expect(result.first_name).toBe('Alice'); + expect(result.last_name).toBe('Smith'); + expect(result.email).toBe('alice@example.com'); + expect(result.user_level).toBe('1'); + expect(result.stream_limit).toBe(2); + }); + + it('defaults first_name and last_name to empty string when absent', () => { + const result = userToFormValues(makeUser({ first_name: null, last_name: null })); + expect(result.first_name).toBe(''); + expect(result.last_name).toBe(''); + }); + + it('defaults stream_limit to 0 when absent', () => { + const result = userToFormValues(makeUser({ stream_limit: null })); + expect(result.stream_limit).toBe(0); + }); + + it('uses channel_profiles when non-empty', () => { + const result = userToFormValues(makeUser({ channel_profiles: ['3', '4'] })); + expect(result.channel_profiles).toEqual(['3', '4']); + }); + + it('maps custom_properties fields with defaults', () => { + const result = userToFormValues(makeUser({ custom_properties: {} })); + expect(result.xc_password).toBe(''); + expect(result.output_format).toBe(''); + expect(result.output_profile).toBe(''); + expect(result.hide_adult_content).toBe(false); + expect(result.epg_days).toBe(0); + expect(result.epg_prev_days).toBe(0); + }); + + it('maps custom_properties when present', () => { + const user = makeUser({ + custom_properties: { + xc_password: 'xpass', + output_format: 'ts', + output_profile: 5, + hide_adult_content: true, + epg_days: 7, + epg_prev_days: 2, + allowed_networks: { + m3u: '192.168.1.0/24,10.0.0.1/32', + xc: '192.168.1.0/24,10.0.0.1/32', + }, + }, + }); + const result = userToFormValues(user); + expect(result.xc_password).toBe('xpass'); + expect(result.output_format).toBe('ts'); + expect(result.output_profile).toBe('5'); + expect(result.hide_adult_content).toBe(true); + expect(result.epg_days).toBe(7); + expect(result.epg_prev_days).toBe(2); + }); + + it('deduplicates allowed_ips from allowed_networks', () => { + const user = makeUser({ + custom_properties: { + allowed_networks: { + m3u: '192.168.1.0/24,10.0.0.1/32', + xc: '192.168.1.0/24,10.0.0.1/32', + }, + }, + }); + const result = userToFormValues(user); + expect(result.allowed_ips).toEqual(['192.168.1.0/24', '10.0.0.1/32']); + }); + + it('returns empty allowed_ips when no allowed_networks', () => { + const result = userToFormValues(makeUser({ custom_properties: {} })); + expect(result.allowed_ips).toEqual([]); + }); + }); + +// ─── formValuesToPayload ────────────────────────────────────────────────────── + + describe('formValuesToPayload', () => { + const makeValues = (overrides = {}) => ({ + username: 'alice', + email: 'alice@example.com', + xc_password: 'mypass', + output_format: 'ts', + output_profile: '3', + hide_adult_content: true, + epg_days: 7, + epg_prev_days: 2, + allowed_ips: ['192.168.1.0/24'], + channel_profiles: ['1', '2'], + ...overrides, + }); + + it('moves xc_password into custom_properties and removes it from payload', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.xc_password).toBeUndefined(); + expect(result.custom_properties.xc_password).toBe('mypass'); + }); + + it('moves output_format into custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.output_format).toBeUndefined(); + expect(result.custom_properties.output_format).toBe('ts'); + }); + + it('parses output_profile as int in custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.output_profile).toBeUndefined(); + expect(result.custom_properties.output_profile).toBe(3); + }); + + it('sets output_profile to null when empty string', () => { + const result = formValuesToPayload(makeValues({ output_profile: '' }), null); + expect(result.custom_properties.output_profile).toBeNull(); + }); + + it('moves hide_adult_content into custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.hide_adult_content).toBeUndefined(); + expect(result.custom_properties.hide_adult_content).toBe(true); + }); + + it('moves epg_days and epg_prev_days into custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.epg_days).toBeUndefined(); + expect(result.epg_prev_days).toBeUndefined(); + expect(result.custom_properties.epg_days).toBe(7); + expect(result.custom_properties.epg_prev_days).toBe(2); + }); + + it('populates allowed_networks for all NETWORK_KEYS when allowed_ips present', () => { + const result = formValuesToPayload(makeValues({ allowed_ips: ['192.168.1.0/24', '10.0.0.1/32'] }), null); + expect(result.allowed_ips).toBeUndefined(); + expect(result.custom_properties.allowed_networks.m3u).toBe('192.168.1.0/24,10.0.0.1/32'); + expect(result.custom_properties.allowed_networks.xc).toBe('192.168.1.0/24,10.0.0.1/32'); + expect(result.custom_properties.allowed_networks.mpegts).toBe('192.168.1.0/24,10.0.0.1/32'); + }); + + it('sets empty allowed_networks when allowed_ips is empty', () => { + const result = formValuesToPayload(makeValues({ allowed_ips: [] }), null); + expect(result.custom_properties.allowed_networks).toEqual({}); + }); + + it('sets channel_profiles to empty array when it includes "0"', () => { + const result = formValuesToPayload(makeValues({ channel_profiles: ['0'] }), null); + expect(result.channel_profiles).toEqual([]); + }); + + it('preserves channel_profiles when it does not include "0"', () => { + const result = formValuesToPayload(makeValues({ channel_profiles: ['1', '2'] }), null); + expect(result.channel_profiles).toEqual(['1', '2']); + }); + + it('merges existing custom_properties from existingUser', () => { + const existingUser = { custom_properties: { some_other_prop: 'keep_me' } }; + const result = formValuesToPayload(makeValues(), existingUser); + expect(result.custom_properties.some_other_prop).toBe('keep_me'); + }); + + it('handles null existingUser gracefully', () => { + expect(() => formValuesToPayload(makeValues(), null)).not.toThrow(); + }); + }); + +// ─── getFormInitialValues ───────────────────────────────────────────────────── + + describe('getFormInitialValues', () => { + it('returns the expected default structure', () => { + const result = getFormInitialValues(); + expect(result).toEqual({ + username: '', + first_name: '', + last_name: '', + email: '', + user_level: '0', + stream_limit: 0, + password: '', + xc_password: '', + output_format: '', + output_profile: '', + channel_profiles: [], + hide_adult_content: false, + epg_days: 0, + epg_prev_days: 0, + allowed_ips: [], + }); + }); + + it('returns a new object on each call', () => { + const a = getFormInitialValues(); + const b = getFormInitialValues(); + expect(a).not.toBe(b); + }); + }); + +// ─── getFormValidators ──────────────────────────────────────────────────────── + + describe('getFormValidators', () => { + const validate = (user, values) => getFormValidators(user)(values); + + describe('username', () => { + it('returns error when username is empty', () => { + const result = validate(null, { ...getFormInitialValues(), username: '' }); + expect(result.username).toBe('Username is required'); + }); + + it('returns null for a valid non-streamer username', () => { + const result = validate(null, { ...getFormInitialValues(), username: 'alice', user_level: '0' }); + expect(result.username).toBeNull(); + }); + + it('returns null for a valid alphanumeric streamer username', () => { + const result = validate(null, { ...getFormInitialValues(), username: 'alice123', user_level: '2' }); + expect(result.username).toBeNull(); + }); + + it('returns error for streamer username with non-alphanumeric characters', () => { + const result = validate(null, { ...getFormInitialValues(), username: 'alice_123', user_level: '2' }); + expect(result.username).toBe('Streamer username must be alphanumeric'); + }); + }); + + describe('password', () => { + it('returns error when creating a non-streamer user without a password', () => { + const result = validate(null, { ...getFormInitialValues(), user_level: '0', password: '' }); + expect(result.password).toBe('Password is required'); + }); + + it('returns null when creating a streamer user without a password', () => { + const result = validate(null, { ...getFormInitialValues(), user_level: '2', password: '' }); + expect(result.password).toBeNull(); + }); + + it('returns null when editing an existing user without a password', () => { + const result = validate({ id: 1 }, { ...getFormInitialValues(), user_level: '0', password: '' }); + expect(result.password).toBeNull(); + }); + + it('returns null when password is provided for new user', () => { + const result = validate(null, { ...getFormInitialValues(), user_level: '0', password: 'secret' }); + expect(result.password).toBeNull(); + }); + }); + + describe('xc_password', () => { + it('returns null when xc_password is empty', () => { + const result = validate(null, { ...getFormInitialValues(), xc_password: '' }); + expect(result.xc_password).toBeNull(); + }); + + it('returns null for a valid alphanumeric xc_password', () => { + const result = validate(null, { ...getFormInitialValues(), xc_password: 'abc123' }); + expect(result.xc_password).toBeNull(); + }); + + it('returns error for xc_password with non-alphanumeric characters', () => { + const result = validate(null, { ...getFormInitialValues(), xc_password: 'abc!@#' }); + expect(result.xc_password).toBe('XC password must be alphanumeric'); + }); + }); + + describe('allowed_ips', () => { + it('returns null for an empty allowed_ips array', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: [] }); + expect(result.allowed_ips).toBeNull(); + }); + + it('returns null for a valid IPv4 CIDR', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['192.168.1.0/24'] }); + expect(result.allowed_ips).toBeNull(); + }); + + it('returns null for a bare IPv4 address (auto-appends /32)', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['10.0.0.1'] }); + expect(result.allowed_ips).toBeNull(); + }); + + it('returns error for an invalid IP entry', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['not-an-ip'] }); + expect(result.allowed_ips).toBe('Invalid IP address or CIDR range'); + }); + + it('returns error when any entry in the list is invalid', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['192.168.1.0/24', 'bad-entry'] }); + expect(result.allowed_ips).toBe('Invalid IP address or CIDR range'); + }); + }); + }); +}); \ No newline at end of file From 275a93b9b550ce73829549d102fc9a09ad0dd080 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:05:07 -0700 Subject: [PATCH 040/125] Added functionality to dateTimeUtils.js --- .../src/utils/__tests__/dateTimeUtils.test.js | 77 ++++++++++++++++++- frontend/src/utils/dateTimeUtils.js | 20 ++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index bfdc4fbd..5ab6ed48 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -62,6 +62,24 @@ describe('dateTimeUtils', () => { const result = dateTimeUtils.initializeTime(date); expect(result.format()).toBe(dayjs(date).format()); }); + + it('should handle custom format and locale', () => { + const result = dateTimeUtils.initializeTime('15/01/2024', 'DD/MM/YYYY'); + expect(result.isValid()).toBe(true); + expect(result.date()).toBe(15); + expect(result.month()).toBe(0); // January = 0 + expect(result.year()).toBe(2024); + }); + + it('should handle strict parsing', () => { + // With strict=true, a date that doesn't match the format should be invalid + const invalid = dateTimeUtils.initializeTime('15-01-2024', 'YYYY-MM-DD', null, true); + expect(invalid.isValid()).toBe(false); + + // With strict=true and a matching format, it should be valid + const valid = dateTimeUtils.initializeTime('2024-01-15', 'YYYY-MM-DD', null, true); + expect(valid.isValid()).toBe(true); + }); }); describe('startOfDay', () => { @@ -433,7 +451,7 @@ describe('dateTimeUtils', () => { }); it('should return original string for unparseable format', () => { - expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM'); + expect(dateTimeUtils.toTimeString('not-a-time')).toBe('not-a-time'); }); it('should return original string for invalid format', () => { @@ -712,4 +730,61 @@ describe('dateTimeUtils', () => { }); }); }); + + describe('isValid', () => { + it('should return true for a valid date string', () => { + expect(dateTimeUtils.isValid('2024-01-15T10:00:00Z')).toBe(true); + }); + + it('should return false for an invalid date string', () => { + expect(dateTimeUtils.isValid('not-a-date')).toBe(false); + }); + + it('should return true for a Date object', () => { + expect(dateTimeUtils.isValid(new Date())).toBe(true); + }); + + it('should return false for an invalid Date object', () => { + expect(dateTimeUtils.isValid(new Date('invalid'))).toBe(false); + }); + }); + + describe('toDate', () => { + it('should convert a date string to a Date object', () => { + const result = dateTimeUtils.toDate('2024-01-15T10:00:00Z'); + expect(result).toBeInstanceOf(Date); + }); + + it('should convert a dayjs object to a Date object', () => { + const djs = dayjs('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.toDate(djs); + expect(result).toBeInstanceOf(Date); + }); + }); + + describe('setMillisecond', () => { + it('should set the millisecond on a date', () => { + const date = dayjs.utc('2024-01-15T10:00:00.000Z'); + const result = dateTimeUtils.setMillisecond(date, 500); + expect(result.millisecond()).toBe(500); + }); + + it('should return 0 milliseconds when set to 0', () => { + const date = dayjs.utc('2024-01-15T10:00:00.999Z'); + const result = dateTimeUtils.setMillisecond(date, 0); + expect(result.millisecond()).toBe(0); + }); + }); + + describe('getMillisecond', () => { + it('should return the millisecond from a date', () => { + const date = dayjs.utc('2024-01-15T14:00:00.123Z'); + expect(dateTimeUtils.getMillisecond(date)).toBe(123); + }); + + it('should return 0 for a date with no milliseconds', () => { + const date = dayjs.utc('2024-01-15T14:00:00.000Z'); + expect(dateTimeUtils.getMillisecond(date)).toBe(0); + }); + }); }); diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 3930188b..f973e0b0 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -4,6 +4,7 @@ import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; import useSettingsStore from '../store/settings'; import useLocalStorage from '../hooks/useLocalStorage'; @@ -11,12 +12,21 @@ dayjs.extend(duration); dayjs.extend(relativeTime); dayjs.extend(utc); dayjs.extend(timezone); +dayjs.extend(customParseFormat); export const convertToMs = (dateTime) => dayjs(dateTime).valueOf(); export const convertToSec = (dateTime) => dayjs(dateTime).unix(); -export const initializeTime = (dateTime) => dayjs(dateTime); +export const initializeTime = (dateTime, format = null, locale = null, strict = false) => { + if (format && locale) { + return dayjs(dateTime, format, locale, strict); + } else if (format) { + return dayjs(dateTime, format, strict); + } else { + return dayjs(dateTime); + } +} export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day'); @@ -43,6 +53,10 @@ export const getNow = () => dayjs(); export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize(); +export const isValid = (dateTime) => dayjs(dateTime).isValid(); + +export const toDate = (dateTime) => dayjs(dateTime).toDate(); + export const formatExactDuration = (seconds) => { if (seconds < 60) return `${seconds.toFixed(1)} seconds`; if (seconds < 3600) { @@ -76,6 +90,8 @@ export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value); export const setSecond = (dateTime, value) => dayjs(dateTime).second(value); +export const setMillisecond = (dateTime, value) => dayjs(dateTime).millisecond(value); + export const getMonth = (dateTime) => dayjs(dateTime).month(); export const getYear = (dateTime) => dayjs(dateTime).year(); @@ -88,6 +104,8 @@ export const getMinute = (dateTime) => dayjs(dateTime).minute(); export const getSecond = (dateTime) => dayjs(dateTime).second(); +export const getMillisecond = (dateTime) => dayjs(dateTime).millisecond(); + export const getNowMs = () => Date.now(); export const roundToNearest = (dateTime, minutes) => { From 688ca2b40552fe520ff3724d645ad5cf13daf1ef Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:06:04 -0700 Subject: [PATCH 041/125] Updated Guide component and utils --- frontend/src/pages/Guide.jsx | 19 +- frontend/src/pages/__tests__/Guide.test.jsx | 40 +- .../src/pages/__tests__/guideUtils.test.js | 1146 +++++++---------- 3 files changed, 513 insertions(+), 692 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index c445407e..aa319913 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -33,12 +33,10 @@ import { useElementSize } from '@mantine/hooks'; import { VariableSizeList } from 'react-window'; import { buildChannelIdMap, - calculateDesiredScrollPosition, calculateEarliestProgramStart, calculateEnd, calculateHourTimeline, calculateLatestProgramEnd, - calculateLeftScrollPosition, calculateNowPosition, calculateScrollPosition, calculateScrollPositionByTimeClick, @@ -47,7 +45,6 @@ import { computeRowHeights, createRecording, createSeriesRule, - evaluateSeriesRule, fetchPrograms, fetchRules, filterGuideChannels, @@ -66,6 +63,7 @@ import { PX_PER_MS, calcProgressPct, sortChannels, + evaluateSeriesRulesByTvgId, } from '../utils/guideUtils'; import API from '../api'; import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; @@ -739,13 +737,22 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - await createRecording(channel, program); + await createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + custom_properties: { program }, + }); showNotification({ title: 'Recording scheduled' }); }, []); const saveSeriesRule = useCallback(async (program, mode) => { - await createSeriesRule(program, mode); - await evaluateSeriesRule(program); + await createSeriesRule({ + tvg_id: program.tvg_id, + mode, + title: program.title, + }); + await evaluateSeriesRulesByTvgId(program.tvg_id); // recordings_refreshed WS event triggers the debounced fetchRecordings() showNotification({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', diff --git a/frontend/src/pages/__tests__/Guide.test.jsx b/frontend/src/pages/__tests__/Guide.test.jsx index d66f9be0..e1ce551a 100644 --- a/frontend/src/pages/__tests__/Guide.test.jsx +++ b/frontend/src/pages/__tests__/Guide.test.jsx @@ -9,20 +9,36 @@ import useEPGsStore from '../../store/epgs'; import useSettingsStore from '../../store/settings'; import useVideoStore from '../../store/useVideoStore'; import useLocalStorage from '../../hooks/useLocalStorage'; -import { showNotification } from '../../utils/notificationUtils.js'; import * as guideUtils from '../../utils/guideUtils'; import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js'; import * as dateTimeUtils from '../../utils/dateTimeUtils.js'; import userEvent from '@testing-library/user-event'; // Mock dependencies -vi.mock('../../store/channels'); -vi.mock('../../store/logos'); -vi.mock('../../store/epgs'); -vi.mock('../../store/settings'); -vi.mock('../../store/useVideoStore'); -vi.mock('../../hooks/useLocalStorage'); -vi.mock('../../api'); +vi.mock('../../store/channels', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/logos', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/epgs', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/settings', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/useVideoStore', () => ({ + default: vi.fn(), +})); +vi.mock('../../hooks/useLocalStorage', () => ({ + default: vi.fn(), +})); +vi.mock('../../api', () => ({ + default: { + getAllChannelIds: vi.fn(), + getChannelsSummary: vi.fn(), + }, +})); vi.mock('@mantine/hooks', () => ({ useElementSize: () => ({ @@ -235,7 +251,7 @@ vi.mock('../../components/forms/SeriesRecordingModal', () => ({ })); vi.mock('../../components/ProgramDetailModal', () => ({ __esModule: true, - default: ({ program, channel, opened, onClose, onRecord }) => + default: ({ program, opened, onClose, onRecord }) => opened ? (
{program?.title}
@@ -252,11 +268,11 @@ vi.mock('../../utils/guideUtils', async () => { fetchPrograms: vi.fn(), createRecording: vi.fn(), createSeriesRule: vi.fn(), - evaluateSeriesRule: vi.fn(), fetchRules: vi.fn(), filterGuideChannels: vi.fn(), getGroupOptions: vi.fn(), getProfileOptions: vi.fn(), + sortChannels: vi.fn((ch) => ch), }; }); vi.mock('../../utils/cards/RecordingCardUtils.js', async () => { @@ -403,7 +419,6 @@ describe('Guide', () => { ); guideUtils.createRecording.mockResolvedValue(undefined); guideUtils.createSeriesRule.mockResolvedValue(undefined); - guideUtils.evaluateSeriesRule.mockResolvedValue(undefined); guideUtils.getGroupOptions.mockReturnValue([ { value: 'all', label: 'All Groups' }, { value: 'group-1', label: 'News' }, @@ -666,8 +681,7 @@ describe('Guide', () => { await waitFor(() => { expect(guideUtils.createRecording).toHaveBeenCalledWith( - expect.objectContaining({ id: 'channel-1' }), - expect.objectContaining({ id: 'prog-1' }) + expect.objectContaining({ channel: 'channel-1' }), ); }); }); diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js index 18307016..2cd321ca 100644 --- a/frontend/src/pages/__tests__/guideUtils.test.js +++ b/frontend/src/pages/__tests__/guideUtils.test.js @@ -48,6 +48,8 @@ describe('guideUtils', () => { vi.clearAllMocks(); }); + // ── buildChannelIdMap ────────────────────────────────────────────────────── + describe('buildChannelIdMap', () => { it('should create map with channel UUIDs when no EPG data', () => { const channels = [ @@ -123,850 +125,638 @@ describe('guideUtils', () => { }); }); + // ── mapProgramsByChannel ─────────────────────────────────────────────────── + describe('mapProgramsByChannel', () => { it('should return empty map when no programs', () => { - const channelIdByTvgId = new Map(); - - const result = guideUtils.mapProgramsByChannel([], channelIdByTvgId); - + const result = guideUtils.mapProgramsByChannel([], new Map([['tvg-1', [1]]])); expect(result.size).toBe(0); }); it('should return empty map when no channel mapping', () => { - const programs = [{ tvg_id: 'tvg-1' }]; - + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }]; const result = guideUtils.mapProgramsByChannel(programs, new Map()); - expect(result.size).toBe(0); }); it('should map programs to channels', () => { - const nowMs = 1000000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); - - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + const nowMs = dayjs().valueOf(); + const startMs = nowMs - 10000; + const endMs = nowMs + 10000; + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + + expect(result.has(1)).toBe(true); expect(result.get(1)).toHaveLength(1); - expect(result.get(1)[0]).toMatchObject({ - id: 1, - tvg_id: 'tvg-1', - }); }); it('should precompute startMs and endMs', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); - dateTimeUtils.convertToMs.mockImplementation((time) => - typeof time === 'number' ? time : dayjs(time).valueOf() - ); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const expectedStartMs = dayjs(startTime).valueOf(); + const expectedEndMs = dayjs(endTime).valueOf(); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(expectedStartMs) + .mockReturnValueOnce(expectedEndMs); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(expectedStartMs - 1000); + + const programs = [{ tvg_id: 'tvg-1', start_time: startTime, end_time: endTime }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const mapped = result.get(1)[0]; - expect(result.get(1)[0]).toHaveProperty('startMs'); - expect(result.get(1)[0]).toHaveProperty('endMs'); + expect(mapped.startMs).toBe(expectedStartMs); + expect(mapped.endMs).toBe(expectedEndMs); }); it('should mark program as live when now is between start and end', () => { + const nowMs = 5000; const startMs = 1000; - const endMs = 2000; - const nowMs = 1500; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)[0].isLive).toBe(true); expect(result.get(1)[0].isPast).toBe(false); }); it('should mark program as past when now is after end', () => { + const nowMs = 20000; const startMs = 1000; - const endMs = 2000; - const nowMs = 3000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); - expect(result.get(1)[0].isLive).toBe(false); expect(result.get(1)[0].isPast).toBe(true); + expect(result.get(1)[0].isLive).toBe(false); }); it('should add program to multiple channels with same tvg_id', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 5000; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 10000 }]; + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)).toHaveLength(1); expect(result.get(2)).toHaveLength(1); - expect(result.get(3)).toHaveLength(1); }); it('should sort programs by start time', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 0; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); const programs = [ - { - id: 2, - tvg_id: 'tvg-1', - startMs: 2000, - endMs: 3000, - start_time: '2024-01-15T11:00:00Z', - end_time: '2024-01-15T12:00:00Z', - }, - { - id: 1, - tvg_id: 'tvg-1', - startMs: 1000, - endMs: 2000, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, + { tvg_id: 'tvg-1', startMs: 3000, endMs: 4000 }, + { tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }, ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const list = result.get(1); - expect(result.get(1)[0].id).toBe(1); - expect(result.get(1)[1].id).toBe(2); + expect(list[0].startMs).toBe(1000); + expect(list[1].startMs).toBe(3000); }); }); + // ── computeRowHeights ───────────────────────────────────────────────────── + describe('computeRowHeights', () => { it('should return empty array when no channels', () => { - const result = guideUtils.computeRowHeights([]); - - expect(result).toEqual([]); + expect(guideUtils.computeRowHeights([])).toEqual([]); + expect(guideUtils.computeRowHeights(null)).toEqual([]); }); it('should return default height for all channels', () => { - const channels = [{ id: 1 }, { id: 2 }]; - + const channels = [{}, {}, {}]; const result = guideUtils.computeRowHeights(channels); - - expect(result).toEqual([ - guideUtils.PROGRAM_HEIGHT, - guideUtils.PROGRAM_HEIGHT, - ]); + expect(result).toEqual([90, 90, 90]); }); it('should use custom default height when provided', () => { - const channels = [{ id: 1 }]; - const customDefault = 100; - - const result = guideUtils.computeRowHeights(channels, customDefault); - - expect(result).toEqual([customDefault]); + const channels = [{}, {}]; + const result = guideUtils.computeRowHeights(channels, 60); + expect(result).toEqual([60, 60]); }); }); + // ── fetchPrograms ───────────────────────────────────────────────────────── + describe('fetchPrograms', () => { it('should fetch and transform programs', async () => { - const mockPrograms = [ - { - id: 1, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - API.getGrid.mockResolvedValue(mockPrograms); - dateTimeUtils.convertToMs.mockReturnValue(1000); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const raw = [{ id: 1, start_time: startTime, end_time: endTime }]; + vi.mocked(API.getGrid).mockResolvedValue(raw); + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(dayjs(startTime).valueOf()) + .mockReturnValueOnce(dayjs(endTime).valueOf()); const result = await guideUtils.fetchPrograms(); - expect(API.getGrid).toHaveBeenCalledTimes(1); - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('startMs'); - expect(result[0]).toHaveProperty('endMs'); + expect(API.getGrid).toHaveBeenCalled(); + expect(result[0].startMs).toBe(dayjs(startTime).valueOf()); + expect(result[0].endMs).toBe(dayjs(endTime).valueOf()); }); }); + // ── sortChannels ────────────────────────────────────────────────────────── + describe('sortChannels', () => { it('should sort channels by channel number', () => { const channels = { - 1: { id: 1, channel_number: 3 }, - 2: { id: 2, channel_number: 1 }, - 3: { id: 3, channel_number: 2 }, + a: { id: 1, channel_number: 3 }, + b: { id: 2, channel_number: 1 }, + c: { id: 3, channel_number: 2 }, }; - const result = guideUtils.sortChannels(channels); - - expect(result[0].channel_number).toBe(1); - expect(result[1].channel_number).toBe(2); - expect(result[2].channel_number).toBe(3); + expect(result.map((c) => c.channel_number)).toEqual([1, 2, 3]); }); it('should put channels without number at end', () => { const channels = { - 1: { id: 1, channel_number: 2 }, - 2: { id: 2, channel_number: null }, - 3: { id: 3, channel_number: 1 }, + a: { id: 1, channel_number: 2 }, + b: { id: 2, channel_number: null }, + c: { id: 3, channel_number: 1 }, }; - const result = guideUtils.sortChannels(channels); - expect(result[0].channel_number).toBe(1); expect(result[1].channel_number).toBe(2); expect(result[2].channel_number).toBeNull(); }); }); + // ── filterGuideChannels ─────────────────────────────────────────────────── + describe('filterGuideChannels', () => { + const channels = [ + { id: 1, name: 'ESPN', channel_group_id: 10 }, + { id: 2, name: 'CNN', channel_group_id: 20 }, + { id: 3, name: 'ESPN2', channel_group_id: 10 }, + ]; + it('should return all channels when no filters', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(2); + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {}); + expect(result).toHaveLength(3); }); it('should filter by search query', () => { - const channels = [ - { id: 1, name: 'ESPN' }, - { id: 2, name: 'CNN' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe('ESPN'); + const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {}); + expect(result).toHaveLength(2); + expect(result.map((c) => c.name)).toEqual(['ESPN', 'ESPN2']); }); it('should filter by channel group', () => { - const channels = [ - { id: 1, name: 'Channel 1', channel_group_id: 1 }, - { id: 2, name: 'Channel 2', channel_group_id: 2 }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - '1', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].channel_group_id).toBe(1); + const result = guideUtils.filterGuideChannels(channels, '', '10', 'all', {}); + expect(result).toHaveLength(2); + expect(result.every((c) => c.channel_group_id === 10)).toBe(true); }); it('should filter by profile with array of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; const profiles = { - profile1: { + 'p1': { channels: [ { id: 1, enabled: true }, { id: 2, enabled: false }, - ], - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should filter by profile with Set of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - const profiles = { - profile1: { - channels: new Set([1]), - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should apply multiple filters together', () => { - const channels = [ - { id: 1, name: 'ESPN', channel_group_id: 1 }, - { id: 2, name: 'ESPN2', channel_group_id: 2 }, - { id: 3, name: 'CNN', channel_group_id: 1 }, - ]; - const profiles = { - profile1: { - channels: [ - { id: 1, enabled: true }, { id: 3, enabled: true }, ], }, }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - '1', - 'profile1', - profiles - ); + it('should filter by profile with Set of channels', () => { + const profiles = { + 'p1': { channels: new Set([1, 3]) }, + }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); + it('should apply multiple filters together', () => { + const profiles = { + 'p1': { channels: [{ id: 1, enabled: true }, { id: 3, enabled: true }] }, + }; + const result = guideUtils.filterGuideChannels(channels, 'espn2', '10', 'p1', profiles); expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); + expect(result[0].name).toBe('ESPN2'); }); }); + // ── calculateEarliestProgramStart ───────────────────────────────────────── + describe('calculateEarliestProgramStart', () => { it('should return default when no programs', () => { - const defaultStart = dayjs('2024-01-15T00:00:00Z'); - + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const result = guideUtils.calculateEarliestProgramStart([], defaultStart); - expect(result).toBe(defaultStart); }); it('should return earliest program start', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isBefore.mockImplementation((a, b) => - dayjs(a).isBefore(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b))); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const programs = [ - { start_time: '2024-01-15T12:00:00Z' }, - { start_time: '2024-01-15T10:00:00Z' }, - { start_time: '2024-01-15T14:00:00Z' }, + { start_time: '2024-01-01T09:00:00Z' }, + { start_time: '2024-01-01T08:00:00Z' }, + { start_time: '2024-01-01T11:00:00Z' }, ]; - const defaultStart = dayjs.utc('2024-01-16T00:00:00Z'); - const result = guideUtils.calculateEarliestProgramStart( - programs, - defaultStart - ); - - expect(result.hour()).toBe(10); + const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T08:00:00Z').toISOString()); }); }); + // ── calculateLatestProgramEnd ───────────────────────────────────────────── + describe('calculateLatestProgramEnd', () => { it('should return default when no programs', () => { - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const result = guideUtils.calculateLatestProgramEnd([], defaultEnd); - expect(result).toBe(defaultEnd); }); it('should return latest program end', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isAfter.mockImplementation((a, b) => - dayjs(a).isAfter(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isAfter).mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b))); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const programs = [ - { end_time: '2024-01-15T12:00:00Z' }, - { end_time: '2024-01-15T18:00:00Z' }, - { end_time: '2024-01-15T14:00:00Z' }, + { end_time: '2024-01-01T20:00:00Z' }, + { end_time: '2024-01-01T23:00:00Z' }, + { end_time: '2024-01-01T21:00:00Z' }, ]; - const defaultEnd = dayjs.utc('2024-01-15T00:00:00Z'); const result = guideUtils.calculateLatestProgramEnd(programs, defaultEnd); - - expect(result.hour()).toBe(18); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T23:00:00Z').toISOString()); }); }); + // ── calculateStart ──────────────────────────────────────────────────────── + describe('calculateStart', () => { it('should return earliest when before default', () => { - const earliest = dayjs('2024-01-15T08:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const earliest = dayjs('2024-01-01T08:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(earliest); }); it('should return default when earliest is after', () => { - const earliest = dayjs('2024-01-15T12:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); + const earliest = dayjs('2024-01-01T11:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(defaultStart); }); }); + // ── calculateEnd ────────────────────────────────────────────────────────── + describe('calculateEnd', () => { it('should return latest when after default', () => { - const latest = dayjs('2024-01-16T02:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(true); + const latest = dayjs('2024-01-01T23:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(latest); }); it('should return default when latest is before', () => { - const latest = dayjs('2024-01-15T22:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(false); + const latest = dayjs('2024-01-01T20:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(defaultEnd); }); }); + // ── mapChannelsById ─────────────────────────────────────────────────────── + describe('mapChannelsById', () => { it('should create map of channels by id', () => { const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, + { id: 1, name: 'ESPN' }, + { id: 2, name: 'CNN' }, ]; - const result = guideUtils.mapChannelsById(channels); - - expect(result.get(1).name).toBe('Channel 1'); - expect(result.get(2).name).toBe('Channel 2'); + expect(result.get(1)).toEqual({ id: 1, name: 'ESPN' }); + expect(result.get(2)).toEqual({ id: 2, name: 'CNN' }); }); }); + // ── mapRecordingsByProgramId ─────────────────────────────────────────────── + describe('mapRecordingsByProgramId', () => { it('should return empty map for null recordings', () => { const result = guideUtils.mapRecordingsByProgramId(null); - expect(result.size).toBe(0); }); it('should map recordings by program id', () => { const recordings = [ { - id: 1, - custom_properties: { - program: { id: 'program-1' }, - }, - }, - { - id: 2, - custom_properties: { - program: { id: 'program-2' }, - }, + custom_properties: { program: { id: 42 }, status: 'pending' }, }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - - expect(result.get('program-1').id).toBe(1); - expect(result.get('program-2').id).toBe(2); + expect(result.has(42)).toBe(true); }); it('should skip recordings without program id', () => { const recordings = [ - { - id: 1, - custom_properties: {}, - }, + { custom_properties: { status: 'pending' } }, + { custom_properties: { program: {}, status: 'pending' } }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - 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 terminalStatuses = ['stopped', 'completed', 'interrupted', 'failed']; + const recordings = terminalStatuses.map((status, i) => ({ + custom_properties: { program: { id: i + 1 }, status }, + })); const result = guideUtils.mapRecordingsByProgramId(recordings); + expect(result.size).toBe(0); + }); + it('should include non-terminal status recordings', () => { + const recordings = [ + { custom_properties: { program: { id: 1 }, status: 'pending' } }, + { custom_properties: { program: { id: 2 }, status: 'recording' } }, + ]; + const result = guideUtils.mapRecordingsByProgramId(recordings); expect(result.size).toBe(2); - expect(result.get('p5').id).toBe(5); - expect(result.get('p6').id).toBe(6); }); }); + // ── formatTime ──────────────────────────────────────────────────────────── + describe('formatTime', () => { it('should return "Today" for today', () => { - const today = dayjs(); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(true); - - const result = guideUtils.formatTime(today, 'MM/DD'); + const result = guideUtils.formatTime(now, 'MMM D'); expect(result).toBe('Today'); }); it('should return "Tomorrow" for tomorrow', () => { - const today = dayjs(); - const tomorrow = today.add(1, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const tomorrow = now.add(1, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true); - - const result = guideUtils.formatTime(tomorrow, 'MM/DD'); + const result = guideUtils.formatTime(tomorrow, 'MMM D'); expect(result).toBe('Tomorrow'); }); it('should return day name within a week', () => { - const today = dayjs(); - const future = today.add(3, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const inThreeDays = now.add(3, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(true); - dateTimeUtils.format.mockReturnValue('Wednesday'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('Wednesday'); + const result = guideUtils.formatTime(inThreeDays, 'MMM D'); + expect(result).toBe(inThreeDays.format('dddd')); }); it('should return formatted date beyond a week', () => { - const today = dayjs(); - const future = today.add(10, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const beyondWeek = now.add(10, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.format.mockReturnValue('01/25'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('01/25'); + const result = guideUtils.formatTime(beyondWeek, 'MMM D'); + expect(result).toBe(beyondWeek.format('MMM D')); }); }); + // ── calculateHourTimeline ───────────────────────────────────────────────── + describe('calculateHourTimeline', () => { it('should generate hours between start and end', () => { - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T13:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T13:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockReturnValue(true); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Today'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); expect(result).toHaveLength(3); - expect(formatDayLabel).toHaveBeenCalledTimes(3); }); it('should mark new day transitions', () => { - const start = dayjs('2024-01-15T23:00:00Z'); - const end = dayjs('2024-01-16T02:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T23:00:00Z'); + const end = dayjs('2024-01-02T02:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => + dayjs(t).utc().startOf('day') ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockImplementation((a, b, unit) => - dayjs(a).isSame(dayjs(b), unit) + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).utc().isSame(dayjs(b).utc(), unit) ); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Day'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).utc().format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); - expect(result[0].isNewDay).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].isNewDay).toBe(true); // 23:00 UTC — first entry, always new day + expect(result[1].isNewDay).toBe(true); // 00:00 UTC — crosses into Jan 2 + expect(result[2].isNewDay).toBe(false); // 01:00 UTC — same day as 00:00 }); }); + // ── calculateNowPosition ────────────────────────────────────────────────── + describe('calculateNowPosition', () => { it('should return -1 when now is before start', () => { - const now = dayjs('2024-01-15T09:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const now = dayjs('2024-01-01T09:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should return -1 when now is after end', () => { - const now = dayjs('2024-01-15T19:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(true); + const now = dayjs('2024-01-01T23:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should calculate position when now is between start and end', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(false); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); // 60 minutes const result = guideUtils.calculateNowPosition(now, start, end); - - expect(result).toBeGreaterThan(0); + // 60 minutes / 15 min increment * MINUTE_BLOCK_WIDTH(112.5) + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateScrollPosition ─────────────────────────────────────────────── + describe('calculateScrollPosition', () => { it('should calculate scroll position for current time', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T11:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); const result = guideUtils.calculateScrollPosition(now, start); - - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeGreaterThan(0); }); it('should return 0 when calculated position is negative', () => { - const now = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(0); + const now = dayjs('2024-01-01T10:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPosition(now, start); - expect(result).toBe(0); }); }); + // ── matchChannelByTvgId ─────────────────────────────────────────────────── + describe('matchChannelByTvgId', () => { it('should return null when no matching channel ids', () => { const channelIdByTvgId = new Map(); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelById = new Map([[1, { id: 1, name: 'ESPN' }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); it('should return first matching channel', () => { - const channel = { id: 1, name: 'Channel 1' }; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); - const channelById = new Map([[1, channel]]); + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); + const channelById = new Map([ + [1, { id: 1, name: 'ESPN' }], + [2, { id: 2, name: 'ESPN HD' }], + ]); - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); - - expect(result).toBe(channel); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); + expect(result).toEqual({ id: 1, name: 'ESPN' }); }); it('should return null when channel not in channelById map', () => { - const channelIdByTvgId = new Map([['tvg-1', [999]]]); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelIdByTvgId = new Map([['tvg-1', [99]]]); + const channelById = new Map([[1, { id: 1 }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); }); + // ── fetchRules ──────────────────────────────────────────────────────────── + describe('fetchRules', () => { it('should fetch series rules from API', async () => { - const mockRules = [{ id: 1, tvg_id: 'tvg-1' }]; - API.listSeriesRules.mockResolvedValue(mockRules); + const rules = [{ tvg_id: 'tvg-1', title: 'Show' }]; + vi.mocked(API.listSeriesRules).mockResolvedValue(rules); const result = await guideUtils.fetchRules(); - expect(API.listSeriesRules).toHaveBeenCalledTimes(1); - expect(result).toBe(mockRules); + expect(API.listSeriesRules).toHaveBeenCalled(); + expect(result).toEqual(rules); }); }); + // ── getRuleByProgram ────────────────────────────────────────────────────── + describe('getRuleByProgram', () => { it('should return null when no rules', () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(null, program); - + const result = guideUtils.getRuleByProgram(null, { tvg_id: 'tvg-1' }); expect(result).toBeUndefined(); }); it('should find rule by tvg_id without title', () => { - const rules = [{ tvg_id: 'tvg-1', title: null }]; - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 'tvg-1', title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Anything' }); + expect(result).toEqual(rules[0]); }); it('should find rule by tvg_id and title', () => { @@ -974,274 +764,272 @@ describe('guideUtils', () => { { tvg_id: 'tvg-1', title: 'Show A' }, { tvg_id: 'tvg-1', title: 'Show B' }, ]; - const program = { tvg_id: 'tvg-1', title: 'Show B' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[1]); + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Show B' }); + expect(result).toEqual(rules[1]); }); it('should handle string comparison for tvg_id', () => { - const rules = [{ tvg_id: 123, title: null }]; - const program = { tvg_id: '123', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 123, title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: '123', title: 'Show' }); + expect(result).toEqual(rules[0]); }); }); + // ── createRecording ─────────────────────────────────────────────────────── + describe('createRecording', () => { it('should create recording via API', async () => { - const channel = { id: 1 }; - const program = { - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }; + vi.mocked(API.createRecording).mockResolvedValue({}); + const values = { channel_id: 1, start_time: '2024-01-01T10:00:00Z' }; - await guideUtils.createRecording(channel, program); + await guideUtils.createRecording(values); - expect(API.createRecording).toHaveBeenCalledWith({ - channel: '1', - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); + expect(API.createRecording).toHaveBeenCalledWith(values); }); }); + // ── createSeriesRule ────────────────────────────────────────────────────── + describe('createSeriesRule', () => { it('should create series rule via API', async () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - const mode = 'all'; + vi.mocked(API.createSeriesRule).mockResolvedValue({}); + const values = { tvg_id: 'tvg-1', title: 'Show' }; - await guideUtils.createSeriesRule(program, mode); + await guideUtils.createSeriesRule(values); - expect(API.createSeriesRule).toHaveBeenCalledWith({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); + expect(API.createSeriesRule).toHaveBeenCalledWith(values); }); }); + // ── evaluateSeriesRule ──────────────────────────────────────────────────── + describe('evaluateSeriesRule', () => { it('should evaluate series rule via API', async () => { - const program = { tvg_id: 'tvg-1' }; + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); - await guideUtils.evaluateSeriesRule(program); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); - expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id); + expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1'); }); }); + // ── calculateLeftScrollPosition ─────────────────────────────────────────── + describe('calculateLeftScrollPosition', () => { it('should calculate left position using startMs', () => { - const program = { - startMs: dayjs.utc('2024-01-15T11:00:00Z').valueOf(), - }; - const start = dayjs.utc('2024-01-15T10:00:00Z').valueOf(); - dateTimeUtils.convertToMs.mockImplementation((time) => { - if (typeof time === 'number') return time; - return dayjs.utc(time).valueOf(); - }); + const startMs = 60 * 60 * 1000; // 1 hour in ms + const start = '2024-01-01T00:00:00Z'; + vi.mocked(dateTimeUtils.convertToMs).mockReturnValue(0); + const program = { startMs }; const result = guideUtils.calculateLeftScrollPosition(program, start); - expect(result).toBeGreaterThanOrEqual(0); + // (60 min / 15 min increment) * MINUTE_BLOCK_WIDTH + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); it('should calculate left position from start_time when no startMs', () => { - const program = { - start_time: '2024-01-15T10:30:00Z', - }; - const start = '2024-01-15T10:00:00Z'; - dateTimeUtils.convertToMs.mockImplementation((time) => - dayjs(time).valueOf() - ); + const startTimeMs = 30 * 60 * 1000; // 30 min + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(startTimeMs) // program start_time + .mockReturnValueOnce(0); // guide start - const result = guideUtils.calculateLeftScrollPosition(program, start); + const program = { start_time: '2024-01-01T00:30:00Z' }; + const result = guideUtils.calculateLeftScrollPosition(program, '2024-01-01T00:00:00Z'); - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBe((30 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateDesiredScrollPosition ──────────────────────────────────────── + describe('calculateDesiredScrollPosition', () => { it('should subtract 20 from left position', () => { const result = guideUtils.calculateDesiredScrollPosition(100); - expect(result).toBe(80); }); it('should return 0 when result would be negative', () => { const result = guideUtils.calculateDesiredScrollPosition(10); - expect(result).toBe(0); }); }); + // ── calculateScrollPositionByTimeClick ──────────────────────────────────── + describe('calculateScrollPositionByTimeClick', () => { + const makeEvent = (clientX, rectLeft, rectWidth) => ({ + currentTarget: { + getBoundingClientRect: () => ({ left: rectLeft, width: rectWidth }), + }, + clientX, + }); + it('should calculate scroll position from time click', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 100, width: 450 }), - }, - clientX: 325, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(60); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => ({ + ...dayjs(t).add(n, u), + minute: (m) => dayjs(t).add(n, u).minute(m), + })); - const result = guideUtils.calculateScrollPositionByTimeClick( - event, - clickedTime, - start - ); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + const start = dayjs('2024-01-01T10:00:00Z'); + // Click at 50% of a 600px element → 30 min into hour → snaps to 30 + const event = makeEvent(350, 100, 500); + vi.mocked(dateTimeUtils.diff).mockReturnValue(90); + + const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); expect(result).toBeGreaterThanOrEqual(0); }); it('should snap to 15-minute increments', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 112.5, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(75); - - guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); - - expect(dateTimeUtils.diff).toHaveBeenCalled(); - }); - - it('should handle click at end of hour', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 450, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(120); + // Click at 10% of a 600px element → 6 min → snaps to 0 + const event = makeEvent(160, 100, 600); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPositionByTimeClick( event, clickedTime, - start + dayjs() ); + expect(result).toBe(0); + }); - expect(dateTimeUtils.add).toHaveBeenCalledWith( - expect.anything(), - 1, - 'hour' + it('should handle click at end of hour (snappedMinute === 60)', () => { + // 100% across the element → 60 min → snappedMinute = 60 → use add(1, hour).minute(0) + const event = makeEvent(700, 100, 600); + const nextHour = dayjs('2024-01-01T11:00:00Z'); + const addResult = { minute: vi.fn().mockReturnValue(nextHour) }; + vi.mocked(dateTimeUtils.add).mockReturnValue(addResult); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + + const result = guideUtils.calculateScrollPositionByTimeClick( + event, + dayjs('2024-01-01T10:00:00Z'), + dayjs('2024-01-01T10:00:00Z') ); + expect(result).toBeGreaterThanOrEqual(0); }); }); + // ── getGroupOptions ─────────────────────────────────────────────────────── + describe('getGroupOptions', () => { it('should return only "All" when no channel groups', () => { const result = guideUtils.getGroupOptions(null, []); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Channel Groups' }]); }); it('should include groups used by channels', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 1 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result).toHaveLength(3); + expect(result.map((o) => o.label)).toContain('Sports'); + expect(result.map((o) => o.label)).toContain('News'); }); it('should exclude groups not used by any channel', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 99, name: 'Unused' }, }; - const channels = [{ id: 1, channel_group_id: 1 }]; + const guideChannels = [{ id: 1, channel_group_id: 10 }]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result.map((o) => o.label)).not.toContain('Unused'); }); it('should sort groups alphabetically', () => { const channelGroups = { - 1: { id: 1, name: 'Z Group' }, - 2: { id: 2, name: 'A Group' }, - 3: { id: 3, name: 'M Group' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'Movies' }, + 3: { id: 30, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 2 }, - { id: 3, channel_group_id: 3 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, + { id: 3, channel_group_id: 30 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result[1].label).toBe('A Group'); - expect(result[2].label).toBe('M Group'); - expect(result[3].label).toBe('Z Group'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + const labels = result.slice(1).map((o) => o.label); + expect(labels).toEqual([...labels].sort()); }); }); + // ── getProfileOptions ───────────────────────────────────────────────────── + describe('getProfileOptions', () => { it('should return only "All" when no profiles', () => { const result = guideUtils.getProfileOptions(null); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Profiles' }]); }); it('should include all profiles except id 0', () => { const profiles = { - 0: { id: '0', name: 'All' }, - 1: { id: '1', name: 'Profile 1' }, - 2: { id: '2', name: 'Profile 2' }, + '0': { id: '0', name: 'Default' }, + '1': { id: '1', name: 'Kids' }, + '2': { id: '2', name: 'Sports' }, }; const result = guideUtils.getProfileOptions(profiles); - - expect(result).toHaveLength(3); - expect(result[1].label).toBe('Profile 1'); - expect(result[2].label).toBe('Profile 2'); + expect(result).toHaveLength(3); // All + Kids + Sports + expect(result.map((o) => o.label)).not.toContain('Default'); + expect(result.map((o) => o.label)).toContain('Kids'); + expect(result.map((o) => o.label)).toContain('Sports'); }); }); + // ── calcProgressPct ─────────────────────────────────────────────────────── + + describe('calcProgressPct', () => { + it('should return 0 when now is at start', () => { + const result = guideUtils.calcProgressPct(1000, 1000, 60000); + expect(result).toBeGreaterThanOrEqual(0); + }); + + it('should return 1 when now is at or past end', () => { + // nowMs >= startMs + durationMs → elapsed >= duration → clamped to 1 + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = startMs + durationMs + 1000; // past the end + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBe(1); + }); + + it('should return value between 0 and 1 for midpoint', () => { + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = 30 * 60 * 1000; // 30 minutes in + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThan(1); + }); + }); + + // ── formatSeasonEpisode ─────────────────────────────────────────────────── + describe('formatSeasonEpisode', () => { it('should format both season and episode', () => { - expect(guideUtils.formatSeasonEpisode(1, 3)).toBe('S01E03'); + expect(guideUtils.formatSeasonEpisode(1, 2)).toBe('S01E02'); }); it('should pad numbers to 2 digits', () => { - expect(guideUtils.formatSeasonEpisode(1, 1)).toBe('S01E01'); + expect(guideUtils.formatSeasonEpisode(3, 7)).toBe('S03E07'); }); it('should handle large numbers without truncation', () => { - expect(guideUtils.formatSeasonEpisode(12, 24)).toBe('S12E24'); + expect(guideUtils.formatSeasonEpisode(10, 10)).toBe('S10E10'); }); it('should handle numbers greater than 99', () => { @@ -1249,19 +1037,19 @@ describe('guideUtils', () => { }); it('should return season only when episode is null', () => { - expect(guideUtils.formatSeasonEpisode(5, null)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, null)).toBe('S02'); }); it('should return season only when episode is undefined', () => { - expect(guideUtils.formatSeasonEpisode(5, undefined)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, undefined)).toBe('S02'); }); it('should return episode only when season is null', () => { - expect(guideUtils.formatSeasonEpisode(null, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(null, 5)).toBe('E05'); }); it('should return episode only when season is undefined', () => { - expect(guideUtils.formatSeasonEpisode(undefined, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(undefined, 5)).toBe('E05'); }); it('should return null when both are null', () => { @@ -1277,32 +1065,44 @@ describe('guideUtils', () => { }); it('should handle season zero with episode', () => { - expect(guideUtils.formatSeasonEpisode(0, 5)).toBe('S00E05'); + expect(guideUtils.formatSeasonEpisode(0, 1)).toBe('S00E01'); }); }); + // ── deleteSeriesRuleByTvgId ─────────────────────────────────────────────── + describe('deleteSeriesRuleByTvgId', () => { it('should delete series rule via API with tvg_id and title', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1', 'My Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', 'My Show'); }); it('should forward undefined title when not provided', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', undefined); }); it('should work with empty tvg_id for title-only rules', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('', 'Title-Only Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('', 'Title-Only Show'); }); }); + // ── evaluateSeriesRulesByTvgId ──────────────────────────────────────────── + describe('evaluateSeriesRulesByTvgId', () => { it('should evaluate series rules via API', async () => { + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1'); From e766c4dd54a9414536a9da4314a243b8ea5e9810 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:06:52 -0700 Subject: [PATCH 042/125] Refactored components --- frontend/src/components/forms/Recording.jsx | 252 ++++-------------- .../forms/RecordingDetailsModal.jsx | 70 ++--- .../components/forms/RecurringRuleModal.jsx | 91 ++----- .../src/components/forms/ScheduleInput.jsx | 12 +- .../components/forms/SeriesRecordingModal.jsx | 26 +- .../forms/SeriesRuleEditorModal.jsx | 124 +++------ frontend/src/components/forms/Stream.jsx | 21 +- .../src/components/forms/StreamProfile.jsx | 81 ++---- .../src/components/forms/SuperuserForm.jsx | 16 +- frontend/src/components/forms/User.jsx | 228 ++++------------ frontend/src/components/forms/UserAgent.jsx | 28 +- 11 files changed, 270 insertions(+), 679 deletions(-) diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 23f78867..163a43a7 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -1,86 +1,40 @@ import React, { useEffect, useMemo, useState } from 'react'; -import dayjs from 'dayjs'; -import API from '../../api'; import { Alert, Button, + Group, + Loader, Modal, + MultiSelect, + SegmentedControl, Select, Stack, - SegmentedControl, - MultiSelect, - Group, TextInput, - Loader, } from '@mantine/core'; -import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates'; +import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates'; import { CircleAlert } from 'lucide-react'; -import { isNotEmpty, useForm } from '@mantine/form'; +import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; - -const DAY_OPTIONS = [ - { value: '6', label: 'Sun' }, - { value: '0', label: 'Mon' }, - { value: '1', label: 'Tue' }, - { value: '2', label: 'Wed' }, - { value: '3', label: 'Thu' }, - { value: '4', label: 'Fri' }, - { value: '5', label: 'Sat' }, -]; - -const asDate = (value) => { - if (!value) return null; - if (value instanceof Date) return value; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -}; - -const toIsoIfDate = (value) => { - const dt = asDate(value); - return dt ? dt.toISOString() : value; -}; - -// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm" -const toTimeString = (value) => { - if (!value) return '00:00'; - if (typeof value === 'string') { - const parsed = dayjs( - value, - ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], - true - ); - if (parsed.isValid()) return parsed.format('HH:mm'); - return value; - } - const dt = asDate(value); - if (!dt) return '00:00'; - return dayjs(dt).format('HH:mm'); -}; - -const toDateString = (value) => { - const dt = asDate(value); - if (!dt) return null; - const year = dt.getFullYear(); - const month = String(dt.getMonth() + 1).padStart(2, '0'); - const day = String(dt.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -}; - -const createRoundedDate = (minutesAhead = 0) => { - const dt = new Date(); - dt.setSeconds(0); - dt.setMilliseconds(0); - dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); - if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead); - return dt; -}; - -// robust onChange for TimeInput (string or event) -const timeChange = (setter) => (valOrEvent) => { - if (typeof valOrEvent === 'string') setter(valOrEvent); - else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); -}; +import { + RECURRING_DAY_OPTIONS, + toTimeString, +} from '../../utils/dateTimeUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + buildRecurringPayload, + buildSinglePayload, + createRecording, + createRecurringRule, + getChannelsSummary, + getRecurringFormDefaults, + getSingleFormDefaults, + numberedChannelLabel, + recurringFormValidators, + singleFormValidators, + sortedChannelOptions, + timeChange, + updateRecording, +} from '../../utils/forms/RecordingUtils.js'; const RecordingModal = ({ recording = null, @@ -98,117 +52,29 @@ const RecordingModal = ({ const [mode, setMode] = useState('single'); const [submitting, setSubmitting] = useState(false); - const defaultStart = createRoundedDate(); - const defaultEnd = createRoundedDate(60); - const defaultDate = new Date(); - - // One-time form const singleForm = useForm({ mode: 'controlled', - initialValues: { - channel_id: recording - ? `${recording.channel}` - : channel - ? `${channel.id}` - : '', - start_time: recording - ? asDate(recording.start_time) || defaultStart - : defaultStart, - end_time: recording - ? asDate(recording.end_time) || defaultEnd - : defaultEnd, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - start_time: isNotEmpty('Select a start time'), - end_time: (value, values) => { - const start = asDate(values.start_time); - const end = asDate(value); - if (!end) return 'Select an end time'; - if (start && end <= start) return 'End time must be after start time'; - return null; - }, - }, + initialValues: getSingleFormDefaults(recording, channel), + validate: singleFormValidators, }); - // Recurring form stores times as "HH:mm" strings for stable editing const recurringForm = useForm({ mode: 'controlled', validateInputOnChange: false, validateInputOnBlur: true, - initialValues: { - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: dayjs(defaultStart).format('HH:mm'), - end_time: dayjs(defaultEnd).format('HH:mm'), - rule_name: '', - start_date: defaultDate, - end_date: defaultDate, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - start_time: (value) => (value ? null : 'Select a start time'), - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const start = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - start.isValid() && - end.isValid() && - end.diff(start, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const end = asDate(value); - const start = asDate(values.start_date); - if (!end) return 'Select an end date'; - if (start && end < start) return 'End date cannot be before start date'; - return null; - }, - }, + initialValues: getRecurringFormDefaults(channel), + validate: recurringFormValidators, }); useEffect(() => { if (!isOpen) return; - const freshStart = createRoundedDate(); - const freshEnd = createRoundedDate(60); - const freshDate = new Date(); - - if (recording && recording.id) { + if (recording?.id) { setMode('single'); - singleForm.setValues({ - channel_id: `${recording.channel}`, - start_time: asDate(recording.start_time) || defaultStart, - end_time: asDate(recording.end_time) || defaultEnd, - }); + singleForm.setValues(getSingleFormDefaults(recording, channel)); } else { - // Reset forms for fresh open - singleForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - start_time: freshStart, - end_time: freshEnd, - }); - - const startStr = dayjs(freshStart).format('HH:mm'); - recurringForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: startStr, - end_time: dayjs(freshEnd).format('HH:mm'), - rule_name: channel?.name || '', - start_date: freshDate, - end_date: freshDate, - }); + singleForm.setValues(getSingleFormDefaults(null, channel)); + recurringForm.setValues(getRecurringFormDefaults(channel)); setMode('single'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -221,7 +87,7 @@ const RecordingModal = ({ if (!isOpen) return; try { setIsChannelsLoading(true); - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (cancelled) return; setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { @@ -238,19 +104,7 @@ const RecordingModal = ({ }, [isOpen]); const channelOptions = useMemo(() => { - const list = Array.isArray(allChannels) ? [...allChannels] : []; - list.sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) return (a.name || '').localeCompare(b.name || ''); - return aNum - bNum; - }); - return list.map((item) => ({ - value: `${item.id}`, - label: item.channel_number - ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` - : item.name || `Channel ${item.id}`, - })); + return sortedChannelOptions(allChannels, numberedChannelLabel); }, [allChannels]); const resetForms = () => { @@ -267,25 +121,18 @@ const RecordingModal = ({ const handleSingleSubmit = async (values) => { try { setSubmitting(true); + const payload = buildSinglePayload(values); if (recording && recording.id) { - await API.updateRecording(recording.id, { - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await updateRecording(recording.id, payload); + showNotification({ title: 'Recording updated', message: 'Recording schedule updated successfully', color: 'green', autoClose: 2500, }); } else { - await API.createRecording({ - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await createRecording(payload); + showNotification({ title: 'Recording scheduled', message: 'One-time recording added to DVR queue', color: 'green', @@ -304,18 +151,10 @@ const RecordingModal = ({ const handleRecurringSubmit = async (values) => { try { setSubmitting(true); - await API.createRecurringRule({ - channel: values.channel_id, - days_of_week: (values.days_of_week || []).map((d) => Number(d)), - start_time: toTimeString(values.start_time), - end_time: toTimeString(values.end_time), - start_date: toDateString(values.start_date), - end_date: toDateString(values.end_date), - name: values.rule_name?.trim() || '', - }); + await createRecurringRule(buildRecurringPayload(values)); await Promise.all([fetchRecurringRules(), fetchRecordings()]); - notifications.show({ + showNotification({ title: 'Recurring rule saved', message: 'Future slots will be scheduled automatically', color: 'green', @@ -427,7 +266,10 @@ const RecordingModal = ({ key={recurringForm.key('days_of_week')} label="Every" placeholder="Select days" - data={DAY_OPTIONS} + data={RECURRING_DAY_OPTIONS.map((opt) => ({ + value: String(opt.value), + label: opt.label, + }))} searchable clearable nothingFoundMessage="No match" diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index bc8b245f..43351765 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -1,11 +1,13 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api'; import { + format, + isAfter, + isBefore, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; -import React from 'react'; -import { Pencil, RefreshCcw, Check, X } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Check, Pencil, RefreshCcw, X } from 'lucide-react'; import { ActionIcon, Badge, @@ -21,7 +23,6 @@ import { TextInput, } from '@mantine/core'; import useVideoStore from '../../store/useVideoStore.jsx'; -import { notifications } from '@mantine/notifications'; import defaultLogo from '../../images/logo.png'; import { deleteRecordingById, @@ -33,10 +34,14 @@ import { runComSkip, } from '../../utils/cards/RecordingCardUtils.js'; import { + getChannel, getRating, getStatRows, getUpcomingEpisodes, + refreshArtwork, + updateRecordingMetadata, } from '../../utils/forms/RecordingDetailsModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; const RecordingDetailsModal = ({ opened, @@ -51,21 +56,21 @@ const RecordingDetailsModal = ({ }) => { const allRecordings = useChannelsStore((s) => s.recordings); // Local channel cache to avoid the global channels map - const [channelsById, setChannelsById] = React.useState({}); + const [channelsById, setChannelsById] = useState({}); const { toUserTime, userNow } = useTimeHelpers(); - const [childOpen, setChildOpen] = React.useState(false); - const [childRec, setChildRec] = React.useState(null); + const [childOpen, setChildOpen] = useState(false); + const [childRec, setChildRec] = useState(null); const { timeFormat: timeformat, dateFormat: dateformat } = useDateTimeFormat(); - const [editing, setEditing] = React.useState(false); + const [editing, setEditing] = 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(() => { + const safeRecording = useMemo(() => { if (recording?.id && Array.isArray(allRecordings)) { const found = allRecordings.find((r) => r.id === recording.id); if (found) { @@ -81,7 +86,7 @@ const RecordingDetailsModal = ({ const program = customProps.program || {}; // Derive poster URL from live store data instead of the stale prop snapshot. - const livePosterUrl = React.useMemo( + const livePosterUrl = useMemo( () => getPosterUrl( customProps.poster_logo_id, @@ -93,17 +98,17 @@ const RecordingDetailsModal = ({ // 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 [savedTitle, setSavedTitle] = useState(null); + const [savedDescription, setSavedDescription] = 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(''); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); // Reset optimistic state when the recording changes - React.useEffect(() => { + useEffect(() => { setSavedTitle(null); setSavedDescription(null); setEditing(false); @@ -129,7 +134,7 @@ const RecordingDetailsModal = ({ const isSeriesGroup = Boolean( safeRecording._group_count && safeRecording._group_count > 1 ); - const upcomingEpisodes = React.useMemo(() => { + const upcomingEpisodes = useMemo(() => { return getUpcomingEpisodes( isSeriesGroup, allRecordings, @@ -147,7 +152,7 @@ const RecordingDetailsModal = ({ ]); // Ensure channel is available for a given id - const loadChannel = React.useCallback( + const loadChannel = useCallback( async (id) => { if (!id) { return null; @@ -159,7 +164,7 @@ const RecordingDetailsModal = ({ } try { - const ch = await API.getChannel(id); + const ch = await getChannel(id); if (ch && ch.id === id) { setChannelsById((prev) => ({ ...prev, [id]: ch })); return ch; @@ -177,7 +182,7 @@ const RecordingDetailsModal = ({ ); // When opening a child episode, fetch that episode's channel - React.useEffect(() => { + useEffect(() => { if (!childOpen || !childRec) return; loadChannel(childRec.channel); }, [childOpen, childRec, loadChannel]); @@ -188,7 +193,7 @@ const RecordingDetailsModal = ({ const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if (now.isAfter(s) && now.isBefore(e)) { + if (isAfter(now, s) && isBefore(now, e)) { const ch = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null); @@ -228,14 +233,11 @@ const RecordingDetailsModal = ({ const saveMetadata = async () => { try { - await API.updateRecordingMetadata(recording.id, { - title: editTitle || 'Custom Recording', - description: editDescription, - }); + await updateRecordingMetadata(recording, editTitle, editDescription); setSavedTitle(editTitle || 'Custom Recording'); setSavedDescription(editDescription); setEditing(false); - notifications.show({ + showNotification({ title: 'Saved', message: 'Recording metadata updated', color: 'green', @@ -249,8 +251,8 @@ const RecordingDetailsModal = ({ const handleRefreshArtwork = async (e) => { e.stopPropagation?.(); try { - await API.refreshArtwork(recording.id); - notifications.show({ + await refreshArtwork(recording.id); + showNotification({ title: 'Refreshing artwork', message: 'Poster resolution started', color: 'blue.5', @@ -265,7 +267,7 @@ const RecordingDetailsModal = ({ e.stopPropagation?.(); try { await runComSkip(recording); - notifications.show({ + showNotification({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', @@ -344,8 +346,8 @@ const RecordingDetailsModal = ({ )} - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} @@ -468,7 +470,7 @@ const RecordingDetailsModal = ({ {onWatchLive && } {onWatchRecording && } - {onEdit && start.isAfter(userNow()) && } + {onEdit && isAfter(start, userNow()) && } {(customProps.status === 'completed' || customProps.status === 'stopped' || customProps.status === 'interrupted') && @@ -486,8 +488,8 @@ const RecordingDetailsModal = ({ - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} {rating && ( @@ -599,7 +601,7 @@ const RecordingDetailsModal = ({ title: { color: 'white' }, }} > - {isSeriesGroup ? Series() : Movie()} + {isSeriesGroup ? : } ); }; diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx index 9fe49220..d9007d34 100644 --- a/frontend/src/components/forms/RecurringRuleModal.jsx +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -1,16 +1,15 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api.js'; import { - parseDate, + format, + getNow, RECURRING_DAY_OPTIONS, + toDate, toTimeString, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from '@mantine/form'; -import dayjs from 'dayjs'; -import { notifications } from '@mantine/notifications'; import { Badge, Button, @@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates'; import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js'; import { deleteRecurringRuleById, - getChannelOptions, + getFormDefaults, getUpcomingOccurrences, updateRecurringRule, updateRecurringRuleEnabled, } from '../../utils/forms/RecurringRuleModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getChannelsSummary, + getRecurringFormDefaults, + recurringFormValidators, + sortedChannelOptions, +} from '../../utils/forms/RecordingUtils.js'; const RecurringRuleModal = ({ opened, @@ -56,66 +62,18 @@ const RecurringRuleModal = ({ const rule = recurringRules.find((r) => r.id === ruleId); const channelOptions = useMemo(() => { - return getChannelOptions(allChannels); + return sortedChannelOptions(allChannels); }, [allChannels]); const form = useForm({ mode: 'controlled', - initialValues: { - channel_id: '', - days_of_week: [], - rule_name: '', - start_time: dayjs().startOf('hour').format('HH:mm'), - end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'), - start_date: dayjs().toDate(), - end_date: dayjs().toDate(), - enabled: true, - }, - validate: { - channel_id: (value) => (value ? null : 'Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const startValue = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - startValue.isValid() && - endValue.isValid() && - endValue.diff(startValue, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const endDate = dayjs(value); - const startDate = dayjs(values.start_date); - if (!value) return 'Select an end date'; - if (startDate.isValid() && endDate.isBefore(startDate, 'day')) { - return 'End date cannot be before start date'; - } - return null; - }, - }, + initialValues: { ...getRecurringFormDefaults(), enabled: true }, + validate: recurringFormValidators, }); useEffect(() => { if (opened && rule) { - form.setValues({ - channel_id: `${rule.channel}`, - days_of_week: (rule.days_of_week || []).map((d) => String(d)), - rule_name: rule.name || '', - start_time: toTimeString(rule.start_time), - end_time: toTimeString(rule.end_time), - start_date: parseDate(rule.start_date) || dayjs().toDate(), - end_date: parseDate(rule.end_date), - enabled: Boolean(rule.enabled), - }); + form.setValues(getFormDefaults(rule)); } else { form.reset(); } @@ -127,7 +85,7 @@ const RecurringRuleModal = ({ let cancelled = false; (async () => { try { - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { console.warn('Failed to load channels for recurring rule modal', e); @@ -149,7 +107,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRule(ruleId, values); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule updated', message: 'Schedule adjustments saved', color: 'green', @@ -169,7 +127,7 @@ const RecurringRuleModal = ({ try { await deleteRecurringRuleById(ruleId); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule removed', message: 'All future occurrences were cancelled', color: 'red', @@ -189,7 +147,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRuleEnabled(ruleId, checked); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: checked ? 'Recurring rule enabled' : 'Recurring rule paused', message: checked ? 'Future occurrences will resume' @@ -210,7 +168,7 @@ const RecurringRuleModal = ({ try { await deleteRecordingById(occurrence.id); // recording_cancelled WS event handles recording list update - notifications.show({ + showNotification({ title: 'Occurrence cancelled', message: 'The selected airing was removed', color: 'yellow', @@ -246,7 +204,7 @@ const RecurringRuleModal = ({ setDeleting(true); try { await deleteRecordingById(sourceRecording.id); - notifications.show({ + showNotification({ title: 'Recording deleted', color: 'green', autoClose: 2500, @@ -275,7 +233,7 @@ const RecurringRuleModal = ({ }; const handleStartDateChange = (value) => { - form.setFieldValue('start_date', value || dayjs().toDate()); + form.setFieldValue('start_date', value || toDate(getNow())); }; const handleEndDateChange = (value) => { @@ -302,10 +260,11 @@ const RecurringRuleModal = ({ - {occStart.format(`${dateformat}, YYYY`)} + {format(occStart, `${dateformat}, YYYY`)} - {occStart.format(timeformat)} – {occEnd.format(timeformat)} + {format(occStart, timeformat)} –{' '} + {format(occEnd, timeformat)} diff --git a/frontend/src/components/forms/ScheduleInput.jsx b/frontend/src/components/forms/ScheduleInput.jsx index eb4aa70d..a0cbb854 100644 --- a/frontend/src/components/forms/ScheduleInput.jsx +++ b/frontend/src/components/forms/ScheduleInput.jsx @@ -38,8 +38,8 @@ import { Popover, ActionIcon, Group, - Divider, - SimpleGrid, + PopoverTarget, + PopoverDropdown, } from '@mantine/core'; import { Info } from 'lucide-react'; import { validateCronExpression } from '../../utils/cronUtils'; @@ -118,12 +118,12 @@ export default function ScheduleInput({ Cron Expression - + - - + + COMMON EXAMPLES @@ -169,7 +169,7 @@ export default function ScheduleInput({ 30 14 1 * * - + } diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx index 67aaced5..6ec6cea4 100644 --- a/frontend/src/components/forms/SeriesRecordingModal.jsx +++ b/frontend/src/components/forms/SeriesRecordingModal.jsx @@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = { regex: 'Title regex', }; +const renderRuleSummary = (r) => { + const titleMode = (r.title_mode || 'exact').toLowerCase(); + const parts = []; + parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); + if (r.title) { + parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); + } + if (r.description) { + parts.push(`Description: "${r.description}"`); + } + return parts.join(' | '); +}; + export default function SeriesRecordingModal({ opened, onClose, @@ -59,19 +72,6 @@ export default function SeriesRecordingModal({ onRulesUpdate(updated); }; - const renderRuleSummary = (r) => { - const titleMode = (r.title_mode || 'exact').toLowerCase(); - const parts = []; - parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); - if (r.title) { - parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); - } - if (r.description) { - parts.push(`Description: "${r.description}"`); - } - return parts.join(' | '); - }; - return ( <> ${e.toLocaleString()}`; - } catch { - return `${start} - ${end}`; - } -} +import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js'; +import { + createSeriesRule, + evaluateSeriesRulesByTvgId, +} from '../../utils/guideUtils.js'; +import { + DESCRIPTION_MODES, + EPISODE_MODES, + formatRange, + getChannelOptions, + getTvgOptions, + previewSeriesRule, + TITLE_MODES, +} from '../../utils/forms/SeriesRuleEditorModalUtils.js'; export default function SeriesRuleEditorModal({ opened, @@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({ useEffect(() => { if (!opened) return; let cancelled = false; - API.getChannelsSummary() + getChannelsSummary() .then((chans) => { if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); }) @@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({ setPreviewLoading(true); setPreviewError(null); - API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal }) + previewSeriesRule(debouncedPreviewKey, controller) .then((resp) => { if (controller.signal.aborted) return; setPreview(resp || { matches: [], total: 0 }); @@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({ // EPG channel options for the tvg_id selector. Deduplicate by tvg_id value // since the same channel can appear across multiple EPG sources. const tvgOptions = useMemo(() => { - const seen = new Set(); - const options = []; - for (const t of tvgs || []) { - if (!t.tvg_id || seen.has(t.tvg_id)) continue; - seen.add(t.tvg_id); - options.push({ - value: t.tvg_id, - label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id, - }); - } - return options.sort((a, b) => a.label.localeCompare(b.label)); + return getTvgOptions(tvgs); }, [tvgs]); - // Channel select options: prefer channels matching the selected tvg_id. const channelOptions = useMemo(() => { - const sorted = [...allChannels].sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum !== bNum) return aNum - bNum; - return (a.name || '').localeCompare(b.name || ''); - }); - const matching = []; - const others = []; - for (const c of sorted) { - const item = { - value: String(c.id), - label: c.channel_number - ? `${c.channel_number} - ${c.name || `Channel ${c.id}`}` - : c.name || `Channel ${c.id}`, - }; - const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null; - if (tvgId && cTvg && String(cTvg) === String(tvgId)) { - matching.push(item); - } else { - others.push(item); - } - } - return [...matching, ...others]; + return getChannelOptions(allChannels, tvgsById, tvgId); }, [allChannels, tvgsById, tvgId]); const canSave = !!(payload.title || payload.description); @@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({ const handleSave = async () => { setSaving(true); try { - await API.createSeriesRule(payload); + await createSeriesRule(payload); // Trigger evaluation so matching upcoming programs get scheduled. try { - await API.evaluateSeriesRules(payload.tvg_id); + await evaluateSeriesRulesByTvgId(payload.tvg_id); await useChannelsStore.getState().fetchRecordings(); } catch (e) { console.warn('Failed to evaluate after save', e); @@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({ showNotification({ title: 'Series rule saved' }); if (onSaved) await onSaved(); onClose(); + } catch (e) { + console.error('Failed to save series rule', e); } finally { setSaving(false); } @@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({ )} - + {(preview.matches || []).map((p) => ( @@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({ )} - +
+ ), + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data.map((item) => ( + + ))} +
+ ), + Select: ({ label, disabled, rightSection, data, ...props }) => ( +
+ + {rightSection} + +
+ ), + Stack: ({ children }) =>
{children}
, + TextInput: ({ label, placeholder, ...props }) => ( +
+ + +
+ ), +})); + +// ── @mantine/dates ───────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DatePickerInput: ({ label, value, onChange }) => ( +
+ + onChange(e.target.value ? new Date(e.target.value) : null)} + /> +
+ ), + DateTimePicker: ({ label, ...props }) => ( +
+ + +
+ ), + TimeInput: ({ label, value, onChange, onBlur }) => ( +
+ + +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleAlert: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js'; + +const setupStoreMock = () => { + const mockFetchRecordings = vi.fn().mockResolvedValue(undefined); + const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules }) + ); + + return { mockFetchRecordings, mockFetchRecurringRules }; +}; + +const makeRecording = (overrides = {}) => ({ + id: 'rec-1', + start_time: '2024-06-01T10:00:00Z', + end_time: '2024-06-01T11:00:00Z', + custom_properties: { program: { title: 'Test Show' } }, + ...overrides, +}); + +const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 }); + +describe('RecordingModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([ + { id: 'ch-1', name: 'HBO', channel_number: 501 }, + ]); + vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined); + setupStoreMock(); + }); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Alert ────────────────────────────────────────────────────────────────── + + describe('scheduling conflict alert', () => { + it('renders the scheduling conflicts alert', () => { + render(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts'); + }); + }); + + // ── Mode switching ───────────────────────────────────────────────────────── + + describe('mode switching', () => { + it('defaults to "single" mode', () => { + render(); + expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true'); + }); + + it('switches to recurring mode when Recurring button clicked', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('mode-recurring')).toHaveAttribute('data-active', 'true'); + }); + + it('shows DateTimePicker fields in single mode', () => { + render(); + expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument(); + expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument(); + }); + + it('shows recurring fields when in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument(); + }); + + it('disables mode toggle when editing an existing recording', () => { + render(); + expect(screen.getByTestId('mode-single')).toBeDisabled(); + expect(screen.getByTestId('mode-recurring')).toBeDisabled(); + }); + + it('shows "Schedule Recording" submit button in single mode', () => { + render(); + expect(screen.getByText('Schedule Recording')).toBeInTheDocument(); + }); + + it('shows "Save Rule" submit button in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByText('Save Rule')).toBeInTheDocument(); + }); + }); + + // ── Channel loading ──────────────────────────────────────────────────────── + + describe('channel loading', () => { + it('calls getChannelsSummary when modal opens', async () => { + render(); + await waitFor(() => { + expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled(); + }); + }); + + it('calls sortedChannelOptions with loaded channels', async () => { + const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }]; + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + channels, + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => { + vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail')); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + [], + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('does not load channels when modal is closed', () => { + render(); + expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled(); + }); + }); + + // ── Single form submit (create) ──────────────────────────────────────────── + + describe('single mode – create recording', () => { + it('calls buildSinglePayload with form values on submit', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecording when no existing recording', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).toHaveBeenCalled(); + }); + }); + + it('shows "Recording scheduled" notification after successful create', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording scheduled', color: 'green' }) + ); + }); + }); + + it('calls fetchRecordings after successful create', async () => { + const { mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful create', async () => { + const onClose = vi.fn(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not call showNotification when createRecording throws', async () => { + vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail')); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Single form submit (update) ──────────────────────────────────────────── + + describe('single mode – update recording', () => { + it('calls updateRecording when editing an existing recording', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.updateRecording).toHaveBeenCalledWith( + 'rec-1', + expect.anything() + ); + }); + }); + + it('does not call createRecording when updating', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).not.toHaveBeenCalled(); + }); + }); + + it('shows "Recording updated" notification after successful update', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording updated', color: 'green' }) + ); + }); + }); + }); + + // ── Recurring form submit ────────────────────────────────────────────────── + + describe('recurring mode – create rule', () => { + it('calls buildRecurringPayload with form values on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecurringRule on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecurringRule).toHaveBeenCalled(); + }); + }); + + it('calls fetchRecurringRules and fetchRecordings on success', async () => { + const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(mockFetchRecurringRules).toHaveBeenCalled(); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('shows "Recurring rule saved" notification on success', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recurring rule saved', color: 'green' }) + ); + }); + }); + + it('calls onClose after successful recurring submit', async () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not show notification when createRecurringRule throws', async () => { + vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail')); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Form initialization ──────────────────────────────────────────────────── + + describe('form initialization', () => { + it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => { + const recording = makeRecording(); + const channel = makeChannel(); + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(recording, channel); + }); + + it('calls getSingleFormDefaults with null when opening for new recording', () => { + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null); + }); + + it('calls getRecurringFormDefaults with channel on open', () => { + const channel = makeChannel(); + render(); + expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel); + }); + }); + + // ── Close / reset ────────────────────────────────────────────────────────── + + describe('close and reset', () => { + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx new file mode 100644 index 00000000..91216ac1 --- /dev/null +++ b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx @@ -0,0 +1,770 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore.jsx', () => ({ + default: Object.assign(vi.fn(), { + getState: vi.fn(() => ({ showVideo: vi.fn() })), + }), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn(), + isAfter: vi.fn(), + isBefore: vi.fn(), + useDateTimeFormat: vi.fn(), + useTimeHelpers: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + deleteRecordingById: vi.fn(), + getChannelLogoUrl: vi.fn(), + getPosterUrl: vi.fn(), + getRecordingUrl: vi.fn(), + getSeasonLabel: vi.fn(), + getShowVideoUrl: vi.fn(), + runComSkip: vi.fn(), +})); + +vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({ + getChannel: vi.fn(), + getRating: vi.fn(), + getStatRows: vi.fn(), + getUpcomingEpisodes: vi.fn(), + refreshArtwork: vi.fn(), + updateRecordingMetadata: vi.fn(), +})); + +vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Check: () => , + Pencil: () => , + RefreshCcw: () => , + X: () => , +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Badge: ({ children, color }) => ( + {children} + ), + Button: ({ children, onClick, disabled, loading, size, variant, color }) => ( + + ), + Card: ({ children, onClick, style }) => ( +
{children}
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c, fw, style }) => ( + {children} + ), + Textarea: ({ label, value, onChange, placeholder, ...props }) => ( +
+ +