Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nagelm/1441

This commit is contained in:
SergeantPanda 2026-07-17 15:19:35 +00:00
commit 9ee5ba2535
57 changed files with 9166 additions and 1569 deletions

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ celerybeat-schedule*
dump.rdb
debugpy*
uwsgi.sock
.uwsgi-reload
package-lock.json
models
.idea

View file

@ -10,22 +10,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb)
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom)
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. Sessions accept an optional programme `duration` in minutes, using the same buffered duration handling as XC clients. `POST /api/catchup/sessions/<session_id>/position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
- **Catch-up admin APIs.** `GET /proxy/catchup/stats/` lists active catch-up viewers; `POST /proxy/catchup/programs/` returns batch EPG metadata for those sessions; `POST /proxy/catchup/stop_client/` stops a viewer from the admin UI.
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. When playback continues past the original programme into the catch-up buffer, the card advances to the next EPG programme. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
- **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated).
- **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure.
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams.
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order.
- **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full.
- **Per-client session pool.** The first request without `session_id` and no fingerprint match receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Reconnects that omit `session_id` but match an existing pool entry (same user, IP, and user-agent) are served immediately without a redirect, matching IPTV client fast-forward behaviour. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching. Plain GET restarts stream from byte 0 with upstream `Content-Length` when known (provider-faithful). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot.
- **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level.
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter.
- Verbose timeshift logging follows the standard logger DEBUG level.
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via per-user `epg_prev_days` or `?prev_days=` URL parameter.
- **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer.
- **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. The Channels and Streams table filter menus include **Only Catch-up** to narrow each list to catch-up entries.
- **Combined connection stats API.** `GET /proxy/stats/` (admin) returns live, VOD, and catch-up connection stats in one JSON response (`live`, `vod`, `catchup`, `timestamp`). The Stats page polls this endpoint instead of separate live and VOD stats requests.
- **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings` and `EpgSettingsForm`. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings`. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests extended to forms, modals, Connect, and Plugin Browse.** `OutputProfile`, `ServerGroup`, `CreateChannelModal`, `ProfileModal`, `Connect`, `ConnectLogs`, `PluginBrowse`, and `useEpgPreview` now have Vitest + Testing Library suites. Repo-management UI was extracted from `PluginBrowse` into `ManageReposModal`; form/schema helpers moved into `OutputProfileUtils` and `ServerGroupUtils`, with plugin-repo settings helpers added to `PluginsUtils`. — Thanks [@nick4810](https://github.com/nick4810)
### Changed
@ -42,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Swagger/OpenAPI schema generation no longer fails under concurrent gevent requests.** Concurrent hits to `/api/schema/` (for example Swagger UI loading) could race on DRF's shared `AutoSchema` state and raise `AssertionError: Schema generation REQUIRES a view instance`, leaving Swagger empty. Cold builds could also hang the gevent uWSGI worker. Schema introspection now runs in a real OS thread, builds are single-flight per process, and the result is cached in Django's cache (Redis) so all workers share it.
- **`/proxy/ts/change_stream` now persists `stream_id` and waits for the real switch outcome in multi-worker deployments.** When the request landed on a worker that didn't own the channel, the owning worker's `STREAM_SWITCH` pubsub handler performed the switch but wrote only `url`/`user_agent` back to the channel's Redis metadata, so `GET /proxy/ts/status` kept reporting the old `stream_id` indefinitely; the handler now persists the full switch metadata (`stream_id`, `m3u_profile`, stream name) via the same `_update_channel_metadata()` helper the direct owner path uses. `stream_name` from the initial `get_stream_info_for_switch()` lookup is now forwarded through the pubsub payload (and from `change_stream` / `next_stream` on the direct owner path) so the owner no longer re-queries the database when writing metadata. The endpoint also no longer returns an optimistic 200 on the non-owner path: the requesting worker waits (up to 15s) for the owner to confirm via the `switch_status` Redis key and answers 502 if the owner reports failure or 504 if no confirmation arrives (e.g. owner worker gone); `next_stream` gets the same treatment. Requesting a switch to the URL the channel is already playing is now treated as success (with a metadata refresh) instead of a silent no-op, and the `switch_status` key now expires (60s TTL) instead of persisting forever. (Fixes #1412)
- **XC live streams, `/panel_api.php`, and `/xmltv.php` no longer crash when a visible channel has no channel number.** Since `channel_number` became nullable, auto-sync and compact numbering can leave a channel with `effective_channel_number=None` while it remains visible in output (`hidden_from_output=False`). The XC live-stream number map skipped those rows but still tried to emit them, causing `KeyError` on `get_live_streams` and a 500 on `/panel_api.php` (`xc_get_info`). Authenticated XMLTV export (`/xmltv.php`, profile EPG with a logged-in user) had the same gap and could fail chunk-cache rebuilds with `KeyError` on `channel_num_map[channel.id]`. Null-number channels are now deferred into the existing collision-free integer assignment pass (same second loop as fractional numbers) and receive the next available integer; `_xc_channel_entry` also falls back to `channel.id` if a row is still unmapped. HDHR lineup behaviour is unchanged (null-number channels remain omitted there).
- **System events and Connect dispatch no longer close the DB connection mid-call outside worker contexts.** `log_system_event()` and `dispatch_event_system()` always called `close_old_connections()` in `finally` blocks, which broke Django `TestCase` transactions and could interrupt in-flight ORM work when Connect/plugin handlers ran synchronously. Closes are now limited to gevent uWSGI workers (and the Celery sync-dispatch path where gevent is patched but no hub runs). Celery workers still reset connections via existing `task_postrun` / `task_prerun` hooks on the standard PostgreSQL backend (no geventpool).

200
apps/api/schema_views.py Normal file
View file

@ -0,0 +1,200 @@
"""OpenAPI schema views that are safe under gevent concurrency.
DRF's AutoSchema descriptor keeps mutable per-class state (``self.view``).
When uWSGI serves requests with gevent, concurrent ``/api/schema/`` builds
interleave on that state and raise:
AssertionError: Schema generation REQUIRES a view instance
Cold builds also frequently hang the gevent hub when introspection runs in a
greenlet. Generation therefore runs in a real OS thread (gevent ThreadPool),
with per-process single-flight coordination and a Django/Redis cache shared
by all workers.
"""
from __future__ import annotations
import copy
import logging
import threading
from typing import Callable, Dict, Optional
from django.conf import settings
from django.core.cache import cache
from rest_framework.response import Response
from drf_spectacular.views import SpectacularAPIView
logger = logging.getLogger(__name__)
try:
# Real OS threads; .get() waits without blocking the gevent hub.
from gevent import monkey
from gevent.threadpool import ThreadPool
_SCHEMA_POOL: Optional[ThreadPool] = ThreadPool(maxsize=1)
_GEVENT_PATCHED = monkey.is_module_patched("threading")
except ImportError: # pragma: no cover - non-gevent runtimes
_SCHEMA_POOL = None
_GEVENT_PATCHED = False
# Greenlet-safe after gevent monkey-patching.
_guard = threading.Lock()
_in_progress: Dict[str, threading.Event] = {}
_flight_results: Dict[str, dict] = {}
_SCHEMA_CACHE_PREFIX = "openapi:schema:"
_SCHEMA_CACHE_VER_KEY = f"{_SCHEMA_CACHE_PREFIX}cache_ver"
_BUILD_TIMEOUT_SECONDS = 120
def _run_schema_build(build: Callable[[], dict]) -> dict:
"""Run schema introspection off the gevent hub when monkey-patched."""
if _SCHEMA_POOL is not None and _GEVENT_PATCHED:
return _SCHEMA_POOL.spawn(build).get(timeout=_BUILD_TIMEOUT_SECONDS)
return build()
def _cache_supports_delete_pattern() -> bool:
return callable(getattr(cache, "delete_pattern", None))
def clear_schema_cache() -> None:
"""Invalidate cached schemas (tests / forced refresh after deploy)."""
with _guard:
_flight_results.clear()
try:
if _cache_supports_delete_pattern():
cache.delete_pattern(f"{_SCHEMA_CACHE_PREFIX}*")
return
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 0
cache.set(_SCHEMA_CACHE_VER_KEY, int(ver) + 1, timeout=None)
except Exception:
logger.warning("Failed to clear OpenAPI schema cache", exc_info=True)
def _key_namespace() -> str:
"""Stable on Redis (pattern delete); versioned on LocMem for test clears."""
if _cache_supports_delete_pattern():
return "shared"
try:
ver = cache.get(_SCHEMA_CACHE_VER_KEY)
if ver is None:
cache.add(_SCHEMA_CACHE_VER_KEY, 1, timeout=None)
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 1
return f"v{int(ver)}"
except Exception:
return "v1"
class LockedSpectacularAPIView(SpectacularAPIView):
"""SpectacularAPIView with single-flight generation and Django cache."""
def _resolve_version(self, request):
return self.api_version or request.version or self._get_version_parameter(
request
)
def _cache_key(self, request) -> str:
version = self._resolve_version(request)
lang = request.GET.get("lang") if settings.USE_I18N else None
urlconf = self.urlconf
if urlconf is None:
urlconf_part = "default"
else:
urlconf_part = getattr(urlconf, "__name__", None) or type(urlconf).__name__
patterns_part = "custom" if self.patterns is not None else "default"
return (
f"{_SCHEMA_CACHE_PREFIX}{_key_namespace()}:"
f"{version}:{lang}:{self.serve_public}:{urlconf_part}:{patterns_part}"
)
def _cache_get(self, key: str) -> Optional[dict]:
try:
return cache.get(key)
except Exception:
logger.warning("OpenAPI schema cache get failed", exc_info=True)
return None
def _cache_set(self, key: str, schema: dict) -> None:
try:
# Use CACHES['default'] TIMEOUT (3600s in settings).
cache.set(key, schema)
except Exception:
logger.warning("OpenAPI schema cache set failed", exc_info=True)
def _build_schema(self, request, version: Optional[str]) -> dict:
generator = self.generator_class(
urlconf=self.urlconf,
api_version=version,
patterns=self.patterns,
)
return generator.get_schema(request=request, public=self.serve_public)
def _get_schema_response(self, request):
version = self._resolve_version(request)
cache_key = self._cache_key(request)
cached = self._cache_get(cache_key)
if cached is not None:
return self._schema_response(request, version, cached)
with _guard:
cached = self._cache_get(cache_key)
if cached is not None:
return self._schema_response(request, version, cached)
event = _in_progress.get(cache_key)
if event is None:
event = threading.Event()
_in_progress[cache_key] = event
is_builder = True
else:
is_builder = False
if not is_builder:
if not event.wait(timeout=_BUILD_TIMEOUT_SECONDS):
return Response(
{"detail": "Timed out waiting for OpenAPI schema generation."},
status=503,
)
cached = self._cache_get(cache_key)
if cached is None:
with _guard:
cached = _flight_results.get(cache_key)
if cached is None:
return Response(
{"detail": "OpenAPI schema generation failed."},
status=500,
)
return self._schema_response(request, version, cached)
try:
schema = _run_schema_build(
lambda: self._build_schema(request, version)
)
with _guard:
_flight_results[cache_key] = schema
self._cache_set(cache_key, schema)
return self._schema_response(request, version, schema)
except Exception:
logger.exception("OpenAPI schema generation failed")
return Response(
{"detail": "OpenAPI schema generation failed."},
status=500,
)
finally:
with _guard:
_in_progress.pop(cache_key, None)
event.set()
def _schema_response(self, request, version, schema: dict) -> Response:
# LocMem returns the same object; deepcopy keeps responses isolated.
return Response(
data=copy.deepcopy(schema),
headers={
"Content-Disposition": (
f'inline; filename="{self._get_filename(request, version)}"'
)
},
)

View file

@ -1,5 +1,7 @@
from django.urls import path, include, re_path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
from drf_spectacular.views import SpectacularSwaggerView, SpectacularRedocView
from apps.api.schema_views import LockedSpectacularAPIView
app_name = 'api'
@ -22,9 +24,9 @@ urlpatterns = [
# OpenAPI Schema and Documentation (drf-spectacular)
path('schema/', SpectacularAPIView.as_view(), name='schema'),
# OpenAPI schema (single-flight + Django cache; see apps.api.schema_views)
path('schema/', LockedSpectacularAPIView.as_view(), name='schema'),
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'),
path('swagger.json', LockedSpectacularAPIView.as_view(), name='schema-json'),
]

View file

@ -44,13 +44,6 @@ class ResolveXcEpgPrevDaysTests(TestCase):
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3)
mock_compute.assert_not_called()
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
@patch("core.models.CoreSettings.get_xmltv_prev_days_override", return_value=7)
def test_epg_settings_override_prevents_auto_detect(self, _override, mock_compute):
request = self.factory.get("/xmltv.php")
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 7)
mock_compute.assert_not_called()
class CatchupRollupActiveAccountTests(TestCase):
"""Denormalized catch-up flags ignore disabled M3U accounts."""

View file

@ -67,8 +67,7 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
Resolution order:
1. URL ``?prev_days=`` (explicit; 0 means no past programmes)
2. ``user.custom_properties.epg_prev_days``
3. ``CoreSettings.epg_settings.xmltv_prev_days_override`` when > 0
4. Auto-detect (only when *auto_detect_fallback* is True)
3. Auto-detect (only when *auto_detect_fallback* is True)
"""
user_custom = (user.custom_properties or {}) if user else {}
url_prev = request.GET.get("prev_days")
@ -85,14 +84,6 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
except (ValueError, TypeError):
return 0
from core.models import CoreSettings
try:
override = int(CoreSettings.get_xmltv_prev_days_override() or 0)
except (TypeError, ValueError):
override = 0
if override > 0:
return max(0, min(override, MAX_AUTO_PREV_DAYS))
if auto_detect_fallback:
return compute_provider_archive_days_capped()
return 0

View file

@ -10,6 +10,11 @@ urlpatterns = [
api_views.CatchupSessionCreateAPIView.as_view(),
name="catchup-session-create",
),
path(
"sessions/<str:session_id>/position/",
api_views.CatchupSessionPositionAPIView.as_view(),
name="catchup-session-position",
),
path(
"sessions/<str:session_id>/",
api_views.CatchupSessionDestroyAPIView.as_view(),

View file

@ -9,9 +9,10 @@ from rest_framework.views import APIView
from apps.accounts.permissions import IsStandardUser
from apps.channels.models import Channel
from apps.channels.utils import get_channel_catchup_streams
from core.utils import RedisClient
from dispatcharr.utils import network_access_allowed
from .helpers import parse_catchup_timestamp
from .helpers import MAX_DURATION_MINUTES, parse_catchup_timestamp
from .sessions import (
HANDSHAKE_TTL_SECONDS,
SESSION_IDLE_TTL_SECONDS,
@ -19,7 +20,11 @@ from .sessions import (
delete_catchup_session,
user_owns_catchup_session,
)
from .views import _user_can_access_channel
from .stats import update_catchup_session_position
from .views import _trigger_timeshift_stats_update, _user_can_access_channel
# Programme length cap expressed in seconds for position reports.
_MAX_POSITION_SECS = MAX_DURATION_MINUTES * 60
class CatchupSessionCreateSerializer(serializers.Serializer):
@ -32,6 +37,16 @@ class CatchupSessionCreateSerializer(serializers.Serializer):
"ISO-8601 (2026-07-09T14:00:00Z), Unix epoch, or XC wall-clock shapes."
),
)
duration = serializers.IntegerField(
required=False,
min_value=1,
max_value=MAX_DURATION_MINUTES,
help_text=(
"Optional programme length in minutes. Preferred over EPG when "
"supplied. A short buffer is added for provider archive lag. "
"Omit to derive the length from EPG."
),
)
class CatchupSessionResponseSerializer(serializers.Serializer):
@ -42,6 +57,11 @@ class CatchupSessionResponseSerializer(serializers.Serializer):
)
channel_uuid = serializers.UUIDField()
start = serializers.CharField()
duration = serializers.IntegerField(
required=False,
allow_null=True,
help_text="Programme length in minutes if supplied at creation, else null.",
)
class CatchupSessionCreateAPIView(APIView):
@ -58,6 +78,11 @@ class CatchupSessionCreateAPIView(APIView):
"**``start``** is the programme's broadcast start time in UTC "
"(from EPG ``start_time``). It selects *which* archived show to "
"fetch, not when the viewer pressed play.\n\n"
"**``duration``** is optional programme length in minutes. Native "
"clients should send it when their guide knows the programme "
"length. Dispatcharr uses it before local EPG duration, adds a "
"short provider-lag buffer, and falls back to EPG duration, then "
"the default archive window when omitted.\n\n"
f"The player should open ``playback_url`` within "
f"**{HANDSHAKE_TTL_SECONDS} seconds** (see ``expires_at``). After the "
"first byte request, the session stays valid with a "
@ -120,7 +145,12 @@ class CatchupSessionCreateAPIView(APIView):
)
try:
payload = create_catchup_session(user=user, channel=channel, start=start)
payload = create_catchup_session(
user=user,
channel=channel,
start=start,
duration=body.validated_data.get("duration"),
)
except RuntimeError:
return Response(
{"error": "Session service unavailable"},
@ -166,3 +196,79 @@ class CatchupSessionDestroyAPIView(APIView):
delete_catchup_session(session_id)
return Response(status=status.HTTP_204_NO_CONTENT)
class CatchupSessionPositionSerializer(serializers.Serializer):
position_secs = serializers.FloatField(
min_value=0,
max_value=_MAX_POSITION_SECS,
help_text=(
"Current playhead within the programme, in seconds from programme start."
),
)
paused = serializers.BooleanField(
required=False,
help_text=(
"When true, admin stats freeze at ``position_secs`` (no wall-clock "
"advance). When false, clear pause. Omit to leave pause state unchanged."
),
)
class CatchupSessionPositionAPIView(APIView):
"""Native clients report local playhead / pause for catch-up stats polish."""
permission_classes = [IsStandardUser]
@extend_schema(
description=(
"Update the reported playhead for an active catch-up session.\n\n"
"Native apps can call this while paused or after local scrubbing so "
"admin stats stay aligned with what the viewer sees. This does "
"**not** seek the provider stream; HTTP ``Range`` / a new archive "
"open still control bytes.\n\n"
"Requires an active playback connection for ``session_id``. Also "
"refreshes the session idle TTL."
),
request=CatchupSessionPositionSerializer,
responses={
204: None,
400: inline_serializer(
name="CatchupSessionPositionError",
fields={"error": serializers.CharField()},
),
403: inline_serializer(
name="CatchupSessionPositionForbidden",
fields={"error": serializers.CharField()},
),
404: inline_serializer(
name="CatchupSessionPositionNotFound",
fields={"error": serializers.CharField()},
),
},
tags=["catchup"],
)
def post(self, request, session_id):
if not network_access_allowed(request, "STREAMS", request.user):
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
if not user_owns_catchup_session(session_id, request.user.id):
return Response({"error": "Session not found"}, status=status.HTTP_404_NOT_FOUND)
body = CatchupSessionPositionSerializer(data=request.data)
body.is_valid(raise_exception=True)
updated = update_catchup_session_position(
session_id,
position_secs=body.validated_data["position_secs"],
paused=body.validated_data.get("paused"),
user_id=request.user.id,
)
if not updated:
return Response(
{"error": "No active playback for this session"},
status=status.HTTP_404_NOT_FOUND,
)
_trigger_timeshift_stats_update(RedisClient.get_client())
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -1,12 +1,16 @@
"""URL builders and timestamp helpers for XC catch-up."""
import logging
import math
import re
import time
from collections import namedtuple
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from zoneinfo import ZoneInfo
from apps.timeshift.redis_keys import TimeshiftRedisKeys
logger = logging.getLogger(__name__)
# Credentials for the profile whose pool slot was reserved (not raw account fields).
@ -15,10 +19,13 @@ TimeshiftCredentials = namedtuple(
)
DEFAULT_DURATION_MINUTES = 120
# Extra minutes added to client/EPG programme length when asking the provider.
# IPTV archives commonly lag live by about 30 seconds to 2 minutes, so a bare
# programme length tends to include the previous show's tail and clip the end.
DURATION_BUFFER_MINUTES = 5
MAX_DURATION_MINUTES = 480
# Wall-clock shapes seen from XC / iPlayTV / TiviMate clients. Compiled once.
# Wall-clock shapes seen from XC catch-up clients. Compiled once.
_CATCHUP_WALL_CLOCK_RE = re.compile(
r"^"
r"(?P<date>\d{4}-\d{2}-\d{2})"
@ -38,7 +45,7 @@ def normalize_catchup_timestamp_input(timestamp_str):
"""Map a client catch-up timestamp to an ISO-8601 string for ``fromisoformat``.
Supported inputs:
- ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate colon-dash)
- ``YYYY-MM-DD:HH-MM`` (XC colon-dash)
- ``YYYY-MM-DD_HH-MM`` (XC underscore)
- ``YYYY-MM-DD:HH:MM[:SS]`` (XC colon time in catch-up URLs)
- ``YYYY-MM-DD HH:MM[:SS]`` (EPG / SQL datetime)
@ -158,8 +165,9 @@ def get_programme_duration(channel, timestamp_str):
timestamp_str: Programme start in UTC (same shape as the client URL).
Returns:
Programme length plus a small buffer, capped at ``MAX_DURATION_MINUTES``,
or ``DEFAULT_DURATION_MINUTES`` when EPG lookup fails.
Programme length plus ``DURATION_BUFFER_MINUTES`` (provider archive lag),
capped at ``MAX_DURATION_MINUTES``, or ``DEFAULT_DURATION_MINUTES`` when
EPG lookup fails.
"""
try:
dt = parse_catchup_timestamp(timestamp_str)
@ -183,8 +191,54 @@ def get_programme_duration(channel, timestamp_str):
return DEFAULT_DURATION_MINUTES
def get_programme_info(channel, timestamp_str):
"""Return EPG metadata for the programme airing at *timestamp_str*."""
def client_duration_to_window(value):
"""Convert a client-supplied programme length (minutes) to an archive window.
Args:
value: Raw client hint (str/int). PATH XC duration segment, QUERY
``duration=``, or the native session's stored ``duration``.
Returns:
Minutes to request from the provider (client length +
``DURATION_BUFFER_MINUTES``, capped), or ``None`` when the hint is
missing or not a usable positive integer.
The buffer matches the EPG path: clients usually send exact guide length
(for example ``.../60/.../123.ts`` for a 60-minute show), but provider
archives lag live, so requesting that bare length clips the end.
"""
if value is None:
return None
try:
minutes = int(str(value).strip())
except (TypeError, ValueError):
return None
if minutes <= 0:
return None
return min(minutes + DURATION_BUFFER_MINUTES, MAX_DURATION_MINUTES)
def resolve_catchup_duration(channel, timestamp_str, client_hint=None):
"""Pick the catch-up archive window in minutes.
Preference order: a sane client-supplied hint, then the EPG programme
length, then ``DEFAULT_DURATION_MINUTES``.
"""
window = client_duration_to_window(client_hint)
if window is not None:
return window
return get_programme_duration(channel, timestamp_str)
def get_programme_info(channel, timestamp_str, position_secs=None):
"""Return EPG metadata for the programme airing at *timestamp_str*.
When ``position_secs`` is set (playhead within the archive, relative to the
programme that contains ``timestamp_str``), and that playhead is at or past
the programme's end, resolve the guide entry at the playhead instead. That
keeps catch-up stats cards on the show the viewer has actually reached when
they keep watching into the provider buffer / next programme.
"""
try:
dt = parse_catchup_timestamp(timestamp_str)
if dt is None:
@ -199,6 +253,19 @@ def get_programme_info(channel, timestamp_str):
if not programme:
return None
if position_secs is not None:
try:
offset = max(0.0, float(position_secs))
except (TypeError, ValueError):
offset = 0.0
playhead = programme.start_time + timedelta(seconds=offset)
if playhead >= programme.end_time:
advanced = channel.epg_data.programs.filter(
start_time__lte=playhead, end_time__gt=playhead
).first()
if advanced is not None:
programme = advanced
duration_seconds = (programme.end_time - programme.start_time).total_seconds()
return {
"title": programme.title,
@ -216,10 +283,19 @@ def get_catchup_programmes_for_sessions(sessions):
"""Resolve EPG metadata for catch-up stats cards (batch, on demand).
Each session dict needs ``session_id``, ``channel_uuid``, and
``programme_start``. Called by the frontend when active sessions or seek
position change, not on every stats poll.
``programme_start``. Optional ``position_secs`` (playhead within the
archive, relative to the programme containing ``programme_start``)
advances the returned programme when past that show's end. When omitted,
an estimate is taken from the session's Redis stats metadata when present.
"""
from apps.channels.models import Channel
from apps.timeshift.stats import (
_client_paused,
_decode_hash,
compute_playback_position_secs,
find_stats_channel_for_session,
)
from core.utils import RedisClient
if not sessions:
return []
@ -238,12 +314,40 @@ def get_catchup_programmes_for_sessions(sessions):
for ch in Channel.objects.filter(uuid__in=uuids).select_related("epg_data")
}
redis_client = RedisClient.get_client()
results = []
for session in valid:
channel_uuid = str(session["channel_uuid"])
programme_start = session["programme_start"]
channel = channels_by_uuid.get(channel_uuid)
position_secs = session.get("position_secs")
if position_secs is not None:
try:
position_secs = float(position_secs)
except (TypeError, ValueError):
position_secs = None
# Resolve the guide entry for the URL first so Redis playhead math has
# an EPG start; then re-resolve with position to advance past the end.
info = get_programme_info(channel, programme_start) if channel else None
if position_secs is None and info is not None and redis_client is not None:
position_secs = _position_secs_from_stats(
redis_client,
session_id=session["session_id"],
programme_start=programme_start,
epg_start_iso=info["start_time"],
compute_playback_position_secs=compute_playback_position_secs,
find_stats_channel_for_session=find_stats_channel_for_session,
client_paused=_client_paused,
decode_hash=_decode_hash,
)
if channel is not None and position_secs is not None:
advanced = get_programme_info(
channel, programme_start, position_secs=position_secs,
)
if advanced is not None:
info = advanced
entry = {
"session_id": session["session_id"],
"channel_uuid": channel_uuid,
@ -262,6 +366,46 @@ def get_catchup_programmes_for_sessions(sessions):
return results
def _position_secs_from_stats(
redis_client,
*,
session_id,
programme_start,
epg_start_iso,
compute_playback_position_secs,
find_stats_channel_for_session,
client_paused,
decode_hash,
):
"""Best-effort uncapped playhead from timeshift stats Redis metadata."""
try:
stats_channel_id = find_stats_channel_for_session(redis_client, session_id)
if not stats_channel_id:
return None
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id)
client_data = decode_hash(redis_client.hgetall(client_key))
if not client_data:
return None
playback_base_raw = client_data.get("playback_base_secs")
playback_base_secs = None
if playback_base_raw not in (None, ""):
try:
playback_base_secs = float(playback_base_raw)
except (TypeError, ValueError):
playback_base_secs = None
return compute_playback_position_secs(
programme_start,
epg_start_iso,
client_data.get("position_anchor_at"),
time.time(),
duration_secs=None,
playback_base_secs=playback_base_secs,
paused=client_paused(client_data.get("paused")),
)
except Exception:
return None
def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes):
"""QUERY layout: ``/streaming/timeshift.php?username=...&start=...``"""
return (
@ -339,3 +483,51 @@ def format_timestamp_as_underscore(timestamp):
def format_timestamp_as_sql_datetime(timestamp):
"""Reshape to ``YYYY-MM-DD HH:MM:SS`` without timezone conversion."""
return _reshape_timestamp(timestamp, "%Y-%m-%d %H:%M:%S", "SQL")
def programme_age_days(timestamp_str, *, now=None):
"""Archive depth in whole days needed to cover a catch-up start timestamp.
Returns:
``None`` if the timestamp cannot be parsed, ``0`` if start is at/after
*now*, otherwise ``ceil(elapsed / 86400)`` (at least 1).
"""
dt = parse_catchup_timestamp(timestamp_str)
if dt is None:
return None
if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None)
elif getattr(now, "tzinfo", None) is not None:
now = now.astimezone(timezone.utc).replace(tzinfo=None)
elapsed = (now - dt).total_seconds()
if elapsed <= 0:
return 0
return max(1, math.ceil(elapsed / 86400.0))
def order_catchup_streams_for_timestamp(streams, timestamp_str, *, now=None):
"""Prefer streams whose ``catchup_days`` cover the programme age.
Relative channel order is preserved within the preferred and fallback
groups. Streams with unknown/zero ``catchup_days`` stay preferred so
incomplete provider metadata does not skip a possible server. Unparseable
timestamps leave the input order unchanged.
"""
age = programme_age_days(timestamp_str, now=now)
if age is None:
return list(streams)
preferred = []
fallback = []
for stream in streams:
raw = getattr(stream, "catchup_days", None)
try:
days = int(raw) if raw is not None else 0
except (TypeError, ValueError):
days = 0
if days <= 0 or days >= age:
preferred.append(stream)
else:
fallback.append(stream)
return preferred + fallback

View file

@ -38,8 +38,12 @@ def mint_catchup_session_id():
return mint_session_id()
def create_catchup_session(*, user, channel, start):
"""Persist a new playback session and return metadata for the API response."""
def create_catchup_session(*, user, channel, start, duration=None):
"""Persist a new playback session and return metadata for the API response.
``duration`` is an optional programme length in minutes. When supplied it is
preferred over EPG at playback time (see ``resolve_catchup_duration``).
"""
redis_client = RedisClient.get_client()
if redis_client is None:
raise RuntimeError("Redis unavailable")
@ -47,16 +51,16 @@ def create_catchup_session(*, user, channel, start):
session_id = mint_session_id()
now = int(time.time())
key = TimeshiftRedisKeys.api_session(session_id)
redis_client.hset(
key,
mapping={
"user_id": str(user.id),
"channel_uuid": str(channel.uuid),
"channel_id": str(channel.id),
"start": str(start),
"created_at": str(now),
},
)
mapping = {
"user_id": str(user.id),
"channel_uuid": str(channel.uuid),
"channel_id": str(channel.id),
"start": str(start),
"created_at": str(now),
}
if duration is not None:
mapping["duration"] = str(duration)
redis_client.hset(key, mapping=mapping)
redis_client.expire(key, HANDSHAKE_TTL_SECONDS)
handshake_expires_at = now + HANDSHAKE_TTL_SECONDS
@ -68,6 +72,7 @@ def create_catchup_session(*, user, channel, start):
"expires_at": handshake_expires_at,
"channel_uuid": str(channel.uuid),
"start": str(start),
"duration": duration,
}
@ -157,8 +162,9 @@ def resolve_catchup_playback(session_id, channel_uuid):
"""Resolve user and programme start for a tokenless playback request.
Returns:
``(user, start)`` on success, or ``None`` if the session is invalid,
expired, or bound to a different channel.
``(user, start, duration)`` on success, or ``None`` if the session is
invalid, expired, or bound to a different channel. ``duration`` is the
stored client programme length in minutes, or ``None`` when unset.
"""
record = get_catchup_session(session_id)
if not record:
@ -184,7 +190,7 @@ def resolve_catchup_playback(session_id, channel_uuid):
if not start:
return None
return user, str(start)
return user, str(start), record.get("duration")
def user_owns_catchup_session(session_id, user_id):

View file

@ -13,8 +13,13 @@ from apps.m3u.models import M3UAccountProfile
from apps.proxy.live_proxy.constants import ChannelMetadataField
from apps.timeshift.redis_keys import TimeshiftRedisKeys, parse_stats_channel_id
from apps.timeshift.helpers import parse_catchup_timestamp
from core.utils import RedisClient
logger = logging.getLogger(__name__)
# Shared with views near-EOF classification (common ~1.88MB duration probes).
EOF_PROBE_TAIL_BYTES = 2_097_152
_STREAM_STATS_TO_METADATA = {
"video_codec": ChannelMetadataField.VIDEO_CODEC,
"resolution": ChannelMetadataField.RESOLUTION,
@ -165,6 +170,32 @@ def resolve_stats_playback_fields(
existing_programme_start is not None
and existing_programme_start != timestamp_utc
)
# Near-EOF duration probes must not reanchor stats to end-of-file.
if (
range_start is not None
and representation_length is not None
and not programme_changed
):
try:
start = int(range_start)
total = int(representation_length)
except (TypeError, ValueError):
start = None
total = None
if start is not None and total is not None and total > 0:
if start >= max(0, total - EOF_PROBE_TAIL_BYTES):
try:
keep_base = (
float(existing_playback_base)
if existing_playback_base is not None
else None
)
except (TypeError, ValueError):
keep_base = None
keep_anchor = existing_position_anchor or now
return keep_base, keep_anchor
byte_base = compute_playback_base_from_byte_range(
range_start, representation_length, programme_duration_secs,
)
@ -192,6 +223,7 @@ def compute_playback_position_secs(
current_time,
duration_secs=None,
playback_base_secs=None,
paused=False,
):
"""Best-effort catch-up play position in seconds within the programme.
@ -203,9 +235,12 @@ def compute_playback_position_secs(
Native players (VLC) often keep the programme URL fixed and seek via
``Range: bytes=``; in that case ``playback_base_secs`` carries the mapped
position at the anchor instead of the URL timestamp offset.
When ``paused`` is true, wall-clock since the anchor is ignored so admin
stats stay frozen at the last reported playhead.
"""
elapsed_since_anchor = 0.0
if position_anchor_at:
if not paused and position_anchor_at:
try:
elapsed_since_anchor = max(0.0, current_time - float(position_anchor_at))
except (TypeError, ValueError):
@ -258,6 +293,81 @@ def find_stats_channel_for_session(redis_client, session_id):
return None
def _client_paused(raw_value):
if raw_value is None or raw_value == "":
return False
value = _decode_value(raw_value).strip().lower()
return value in {"1", "true", "yes"}
def update_catchup_session_position(
session_id,
*,
position_secs,
paused=None,
user_id=None,
redis_client=None,
):
"""Record a native client's playhead for catch-up stats.
Updates the active stats client hash for ``session_id`` and refreshes the
API session idle TTL. Does not seek the provider stream.
Returns:
``True`` when metadata was updated, ``False`` when there is no active
playback stats entry (or Redis is unavailable).
"""
from apps.timeshift.sessions import touch_catchup_session
if redis_client is None:
redis_client = RedisClient.get_client()
if redis_client is None or not session_id:
return False
stats_channel_id = find_stats_channel_for_session(redis_client, session_id)
if not stats_channel_id:
return False
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id)
client_set_key = TimeshiftRedisKeys.clients(stats_channel_id)
metadata_key = TimeshiftRedisKeys.channel_metadata(stats_channel_id)
try:
if not redis_client.exists(client_key):
return False
if user_id is not None:
stored_user = redis_client.hget(client_key, "user_id")
if stored_user is not None and str(_decode_value(stored_user)) != str(user_id):
return False
except Exception:
return False
now = str(time.time())
mapping = {
"playback_base_secs": str(float(position_secs)),
"position_anchor_at": now,
"last_active": now,
}
# Must match apps.timeshift.views.CLIENT_TTL_SECONDS (stats / fingerprint).
client_ttl_seconds = 60
try:
pipe = redis_client.pipeline(transaction=False)
pipe.hset(client_key, mapping=mapping)
if paused is True:
pipe.hset(client_key, "paused", "1")
elif paused is False:
pipe.hdel(client_key, "paused")
pipe.expire(client_key, client_ttl_seconds)
pipe.expire(client_set_key, client_ttl_seconds)
pipe.expire(metadata_key, client_ttl_seconds)
pipe.execute()
except Exception:
return False
touch_catchup_session(session_id, redis_client=redis_client)
return True
def build_timeshift_stats_data(redis_client):
"""Build catch-up stats payload from Redis session metadata."""
empty = {
@ -339,6 +449,8 @@ def build_timeshift_stats_data(redis_client):
except (TypeError, ValueError):
playback_base_secs = None
paused = _client_paused(client_data.get("paused"))
connected_at = client_data.get("connected_at")
duration = 0
if connected_at:
@ -358,6 +470,7 @@ def build_timeshift_stats_data(redis_client):
"programme_start": programme_start,
"position_anchor_at": client_data.get("position_anchor_at"),
"playback_base_secs": playback_base_secs,
"paused": paused,
"m3u_profile_id": int(m3u_profile_id) if m3u_profile_id else None,
"ip_address": client_data.get("ip_address", "Unknown"),
"user_agent": client_data.get("user_agent", "unknown"),
@ -437,6 +550,7 @@ def build_timeshift_stats_data(redis_client):
"programme_start": conn["programme_start"],
"position_anchor_at": position_anchor_at,
"playback_base_secs": playback_base_secs,
"paused": bool(conn.get("paused")),
"resolution": conn.get("resolution"),
"source_fps": conn.get("source_fps"),
"video_codec": conn.get("video_codec"),

View file

@ -15,13 +15,15 @@ from apps.timeshift.helpers import (
format_timestamp_as_sql_datetime,
format_timestamp_as_underscore,
normalize_catchup_timestamp_input,
order_catchup_streams_for_timestamp,
parse_catchup_timestamp,
programme_age_days,
)
def _make_creds():
# The builders consume resolved per-profile credentials, never an account
# object get_transformed_credentials() produces these in the view.
# object - get_transformed_credentials() produces these in the view.
return TimeshiftCredentials("http://example.test", "user", "pass")
@ -335,3 +337,116 @@ class GetProgrammeDurationTests(TestCase):
from unittest.mock import MagicMock
from apps.timeshift.helpers import get_programme_duration
self.assertEqual(get_programme_duration(MagicMock(), "garbage"), 120)
class ClientDurationTests(TestCase):
"""Client-supplied programme length: sanitised, buffered for provider lag,
capped, and preferred over EPG when usable."""
def test_valid_hint_gets_buffer(self):
from apps.timeshift.helpers import client_duration_to_window
self.assertEqual(client_duration_to_window(30), 35)
self.assertEqual(client_duration_to_window("30"), 35)
def test_hint_capped_at_max(self):
from apps.timeshift.helpers import client_duration_to_window
self.assertEqual(client_duration_to_window(1000), 480)
def test_unusable_hint_returns_none(self):
from apps.timeshift.helpers import client_duration_to_window
for bad in (None, "", "abc", "0", "-5", 0, -10):
self.assertIsNone(client_duration_to_window(bad))
def test_resolve_prefers_client_hint(self):
from unittest.mock import MagicMock
from apps.timeshift.helpers import resolve_catchup_duration
# EPG would say 120 (no programme), but a valid hint wins.
channel = MagicMock(epg_data=None)
self.assertEqual(
resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint="30"),
35,
)
def test_resolve_falls_back_to_epg_when_hint_missing(self):
from unittest.mock import MagicMock
from apps.timeshift.helpers import resolve_catchup_duration
channel = MagicMock(epg_data=None)
self.assertEqual(
resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint=None),
120,
)
class ProgrammeAgeAndStreamOrderTests(TestCase):
"""Archive-age helpers used to prefer deep catch-up providers first."""
def test_programme_age_days_ceil(self):
now = datetime(2026, 7, 16, 12, 0, 0)
self.assertEqual(
programme_age_days("2026-07-12:12-00", now=now),
4,
)
self.assertEqual(
programme_age_days("2026-07-15:12-00", now=now),
1,
)
self.assertEqual(
programme_age_days("2026-07-16:12-00", now=now),
0,
)
def test_programme_age_days_unparseable(self):
self.assertIsNone(programme_age_days("garbage"))
def test_order_prefers_covering_streams_then_fallback(self):
from types import SimpleNamespace
now = datetime(2026, 7, 16, 12, 0, 0)
streams = [
SimpleNamespace(catchup_days=2, name="p1"),
SimpleNamespace(catchup_days=1, name="p2"),
SimpleNamespace(catchup_days=5, name="p3"),
]
ordered = order_catchup_streams_for_timestamp(
streams, "2026-07-12:12-00", now=now
)
self.assertEqual([s.name for s in ordered], ["p3", "p1", "p2"])
def test_order_keeps_channel_order_within_groups(self):
from types import SimpleNamespace
now = datetime(2026, 7, 16, 12, 0, 0)
streams = [
SimpleNamespace(catchup_days=7, name="a"),
SimpleNamespace(catchup_days=2, name="b"),
SimpleNamespace(catchup_days=14, name="c"),
SimpleNamespace(catchup_days=1, name="d"),
]
ordered = order_catchup_streams_for_timestamp(
streams, "2026-07-12:12-00", now=now
)
self.assertEqual([s.name for s in ordered], ["a", "c", "b", "d"])
def test_unknown_catchup_days_stay_preferred(self):
from types import SimpleNamespace
now = datetime(2026, 7, 16, 12, 0, 0)
streams = [
SimpleNamespace(catchup_days=2, name="short"),
SimpleNamespace(catchup_days=0, name="unknown"),
SimpleNamespace(catchup_days=5, name="deep"),
]
ordered = order_catchup_streams_for_timestamp(
streams, "2026-07-12:12-00", now=now
)
self.assertEqual([s.name for s in ordered], ["unknown", "deep", "short"])
def test_unparseable_timestamp_preserves_order(self):
from types import SimpleNamespace
streams = [
SimpleNamespace(catchup_days=2, name="a"),
SimpleNamespace(catchup_days=5, name="b"),
]
ordered = order_catchup_streams_for_timestamp(streams, "garbage")
self.assertEqual([s.name for s in ordered], ["a", "b"])

View file

@ -126,6 +126,38 @@ class CatchupSessionApiTests(TestCase):
self.assertEqual(data["channel_uuid"], str(self.channel.uuid))
self.assertEqual(data["start"], "2026-06-08T17:00:00Z")
self.assertGreater(data["expires_at"], int(time.time()))
self.assertIsNone(data["duration"])
@patch.object(sessions.RedisClient, "get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
def test_post_accepts_duration(self, _net, redis_mock):
redis_mock.return_value = self.redis
response = self.client.post(
self._create_url(),
{
"channel_uuid": str(self.channel.uuid),
"start": "2026-06-08T17:00:00Z",
"duration": 30,
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["duration"], 30)
@patch.object(sessions.RedisClient, "get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
def test_post_rejects_out_of_range_duration(self, _net, redis_mock):
redis_mock.return_value = self.redis
response = self.client.post(
self._create_url(),
{
"channel_uuid": str(self.channel.uuid),
"start": "2026-06-08T17:00:00Z",
"duration": 0,
},
format="json",
)
self.assertEqual(response.status_code, 400)
@patch.object(sessions.RedisClient, "get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
@ -174,6 +206,139 @@ class CatchupSessionApiTests(TestCase):
self.assertEqual(deleted.status_code, 404)
@override_settings(
CACHES={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "catchup-session-position-tests",
}
},
REST_FRAMEWORK={
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"apps.accounts.authentication.ApiKeyAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"apps.accounts.permissions.IsAdmin",
],
},
)
class CatchupSessionPositionApiTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create(
username="catchup-position-user",
user_level=User.UserLevel.STANDARD,
)
cls.account = M3UAccount.objects.create(
name="catchup-position-acct",
server_url="http://example.test",
account_type="XC",
is_active=True,
)
cls.channel = Channel.objects.create(
name="Catchup Position Channel",
is_catchup=True,
catchup_days=7,
)
cls.stream = Stream.objects.create(
name="catchup-position-stream",
url="http://example.test/live",
m3u_account=cls.account,
is_catchup=True,
catchup_days=7,
custom_properties={"stream_id": "111"},
)
ChannelStream.objects.create(
channel=cls.channel, stream=cls.stream, order=0,
)
def setUp(self):
from apps.timeshift.tests.test_views import _FakeRedis
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.redis = _FakeRedis()
def _seed_active_playback(self, session_id):
stats_channel_id = f"{self.channel.id}_{session_id}"
self.redis.hset(
TimeshiftRedisKeys.channel_metadata(stats_channel_id),
mapping={"state": "active"},
)
self.redis.sadd(TimeshiftRedisKeys.clients(stats_channel_id), session_id)
self.redis.hset(
TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id),
mapping={
"user_id": str(self.user.id),
"username": self.user.username,
"programme_start": "2026-06-08T17:00:00Z",
"position_anchor_at": "1000.0",
},
)
return stats_channel_id
@patch("apps.timeshift.api_views._trigger_timeshift_stats_update")
@patch("apps.timeshift.api_views.RedisClient.get_client")
@patch("apps.timeshift.stats.RedisClient.get_client")
@patch("apps.timeshift.sessions.RedisClient.get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
def test_position_updates_active_session(
self, _net, sessions_redis, stats_redis, api_redis, trigger_mock,
):
sessions_redis.return_value = self.redis
stats_redis.return_value = self.redis
api_redis.return_value = self.redis
created = self.client.post(
"/api/catchup/sessions/",
{
"channel_uuid": str(self.channel.uuid),
"start": "2026-06-08T17:00:00Z",
},
format="json",
)
session_id = created.json()["session_id"]
stats_channel_id = self._seed_active_playback(session_id)
response = self.client.post(
f"/api/catchup/sessions/{session_id}/position/",
{"position_secs": 842, "paused": True},
format="json",
)
self.assertEqual(response.status_code, 204)
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id)
data = self.redis.hgetall(client_key)
self.assertEqual(data["playback_base_secs"], "842.0")
self.assertEqual(data["paused"], "1")
trigger_mock.assert_called_once()
@patch("apps.timeshift.api_views.RedisClient.get_client")
@patch("apps.timeshift.stats.RedisClient.get_client")
@patch("apps.timeshift.sessions.RedisClient.get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
def test_position_without_playback_returns_404(
self, _net, sessions_redis, stats_redis, api_redis,
):
sessions_redis.return_value = self.redis
stats_redis.return_value = self.redis
api_redis.return_value = self.redis
created = self.client.post(
"/api/catchup/sessions/",
{
"channel_uuid": str(self.channel.uuid),
"start": "2026-06-08T17:00:00Z",
},
format="json",
)
session_id = created.json()["session_id"]
response = self.client.post(
f"/api/catchup/sessions/{session_id}/position/",
{"position_secs": 10},
format="json",
)
self.assertEqual(response.status_code, 404)
class CatchupSessionResolveTests(TestCase):
@classmethod
def setUpTestData(cls):
@ -235,6 +400,32 @@ class CatchupSessionResolveTests(TestCase):
sessions.resolve_catchup_playback(session_id, other_uuid),
)
@patch.object(sessions.RedisClient, "get_client")
def test_create_and_resolve_round_trips_duration(self, redis_mock):
redis_mock.return_value = self.redis
payload = sessions.create_catchup_session(
user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z",
duration=30,
)
self.assertEqual(payload["duration"], 30)
resolved = sessions.resolve_catchup_playback(
payload["session_id"], self.channel.uuid,
)
self.assertIsNotNone(resolved)
self.assertEqual(resolved[2], "30")
@patch.object(sessions.RedisClient, "get_client")
def test_create_without_duration_resolves_none(self, redis_mock):
redis_mock.return_value = self.redis
payload = sessions.create_catchup_session(
user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z",
)
self.assertIsNone(payload["duration"])
resolved = sessions.resolve_catchup_playback(
payload["session_id"], self.channel.uuid,
)
self.assertIsNone(resolved[2])
class CatchupProxySessionAuthTests(TestCase):
"""Playback via API session without JWT."""
@ -252,7 +443,7 @@ class CatchupProxySessionAuthTests(TestCase):
self, channel_cls, _access, serve, _net, resolve_mock,
):
user = MagicMock(id=42, is_authenticated=False)
resolve_mock.return_value = (user, "2026-06-08T17:00:00Z")
resolve_mock.return_value = (user, "2026-06-08T17:00:00Z", None)
channel_cls.objects.get.return_value = MagicMock(
id=8, uuid=self.channel_uuid,
)
@ -277,7 +468,7 @@ class CatchupProxySessionAuthTests(TestCase):
@patch.object(views, "resolve_catchup_playback")
@patch.object(views, "network_access_allowed", return_value=True)
def test_mismatched_jwt_and_session_returns_403(self, _net, resolve_mock):
resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z")
resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z", None)
request = self.factory.get(
f"/proxy/catchup/{self.channel_uuid}?session_id=test",
)
@ -312,7 +503,7 @@ class CatchupProxySessionAuthTests(TestCase):
patch.object(views, "_serve_catchup", return_value=HttpResponse("ok")) as serve:
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 200)
serve.assert_called_once()

View file

@ -21,6 +21,7 @@ from apps.timeshift.stats import (
resolve_stats_playback_fields,
seed_stream_stats_metadata,
stream_stats_to_metadata_fields,
update_catchup_session_position,
)
from apps.timeshift.tests.test_views import _FakeRedis
@ -55,6 +56,43 @@ class GetProgrammeInfoTests(TestCase):
self.assertEqual(info["sub_title"], "Local Edition")
self.assertEqual(info["duration_secs"], 45 * 60)
def test_get_programme_info_advances_past_end(self):
from datetime import datetime, timedelta, timezone as dt_timezone
from unittest.mock import MagicMock
start = datetime(2026, 6, 8, 17, 0, tzinfo=dt_timezone.utc)
first = MagicMock(
title="Show A",
sub_title="",
description="",
start_time=start,
end_time=start + timedelta(minutes=30),
)
second = MagicMock(
title="Show B",
sub_title="",
description="",
start_time=start + timedelta(minutes=30),
end_time=start + timedelta(minutes=60),
)
channel = MagicMock()
def _filter(**kwargs):
qs = MagicMock()
dt = kwargs["start_time__lte"]
chosen = None
for prog in (first, second):
if prog.start_time <= dt and prog.end_time > dt:
chosen = prog
break
qs.first.return_value = chosen
return qs
channel.epg_data.programs.filter.side_effect = _filter
info = get_programme_info(channel, "2026-06-08:17-00", position_secs=31 * 60)
self.assertEqual(info["title"], "Show B")
self.assertEqual(info["duration_secs"], 30 * 60)
class ComputePlaybackPositionTests(TestCase):
EPG_START = "2026-07-10T14:00:00+00:00"
@ -108,6 +146,18 @@ class ComputePlaybackPositionTests(TestCase):
)
self.assertAlmostEqual(pos, 5 * 60)
def test_paused_freezes_wall_clock_advance(self):
pos = compute_playback_position_secs(
"2026-07-10:14-00",
self.EPG_START,
position_anchor_at=1000.0,
current_time=1300.0,
duration_secs=3600,
playback_base_secs=900.0,
paused=True,
)
self.assertAlmostEqual(pos, 900.0)
class ByteRangePlaybackTests(TestCase):
def test_compute_playback_base_from_byte_range(self):
@ -144,6 +194,22 @@ class ByteRangePlaybackTests(TestCase):
self.assertIsNone(base)
self.assertEqual(anchor, "2000.0")
def test_resolve_near_eof_probe_keeps_existing_position(self):
# Clients probe ~1.88MB from EOF for duration; must not flash to end.
total = 8_783_238_116
base, anchor = resolve_stats_playback_fields(
timestamp_utc="2026-07-14:14-59",
existing_programme_start="2026-07-14:14-59",
existing_position_anchor="1000.0",
existing_playback_base="2100.0",
range_start=total - 1_880_000,
representation_length=total,
programme_duration_secs=3600,
now="2000.0",
)
self.assertAlmostEqual(base, 2100.0)
self.assertEqual(anchor, "1000.0")
class TimeshiftStreamStatsTests(TestCase):
def test_stream_stats_to_metadata_fields(self):
@ -248,12 +314,45 @@ class BuildTimeshiftStatsDataTests(TestCase):
self.assertNotIn("playback_position_secs", session)
self.assertEqual(session["channel_name"], "Catch-up Stats Channel")
self.assertEqual(session["resolution"], "1920x1080")
self.assertFalse(session["paused"])
self.assertEqual(session["connections"][0]["ip_address"], "10.0.0.5")
def test_find_stats_channel_for_session(self):
found = find_stats_channel_for_session(self.redis, self.session_id)
self.assertEqual(found, self.stats_channel_id)
def test_update_catchup_session_position_sets_base_and_pause(self):
updated = update_catchup_session_position(
self.session_id,
position_secs=842.5,
paused=True,
user_id=1,
redis_client=self.redis,
)
self.assertTrue(updated)
client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id)
data = self.redis.hgetall(client_key)
self.assertEqual(data["playback_base_secs"], "842.5")
self.assertEqual(data["paused"], "1")
self.assertIsNotNone(data.get("position_anchor_at"))
@patch("apps.timeshift.stats.Channel")
def test_build_includes_paused_flag(self, mock_channel_model):
channel = MagicMock()
channel.id = self.channel_id
channel.name = "Catch-up Stats Channel"
channel.uuid = "00000000-0000-0000-0000-000000000042"
channel.logo_id = None
channel.logo = None
mock_channel_model.objects.select_related.return_value.filter.return_value = [
channel,
]
client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id)
self.redis.hset(client_key, mapping={"paused": "1", "playback_base_secs": "100"})
session = build_timeshift_stats_data(self.redis)["timeshift_sessions"][0]
self.assertTrue(session["paused"])
self.assertEqual(session["playback_base_secs"], 100.0)
@patch("apps.timeshift.stats.Channel")
def test_skips_clients_missing_required_metadata(self, mock_channel_model):
mock_channel_model.objects.select_related.return_value.filter.return_value = []

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -280,18 +280,8 @@ class CoreSettings(models.Model):
"epg_match_ignore_prefixes": [],
"epg_match_ignore_suffixes": [],
"epg_match_ignore_custom": [],
# XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30).
"xmltv_prev_days_override": 0,
})
@classmethod
def get_xmltv_prev_days_override(cls):
"""Global XC XMLTV prev_days default (0 = auto-detect from provider archives)."""
try:
return int(cls.get_epg_settings().get("xmltv_prev_days_override", 0) or 0)
except (TypeError, ValueError):
return 0
@classmethod
def _safe_string_list(cls, value):
"""Return a list of strings, filtering out non-list or non-string values."""

View file

@ -710,7 +710,7 @@ def validate_flexible_url(value):
# Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234
# Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value
# Also supports authentication: rtsp://user:pass@hostname/path
non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$'
non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9_\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$'
non_fqdn_match = re.match(non_fqdn_pattern, value)
if non_fqdn_match:

View file

@ -7,7 +7,7 @@ from .routing import websocket_urlpatterns
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
from apps.proxy.live_proxy.views import stream_xc
from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode
from apps.timeshift.views import timeshift_proxy
from apps.timeshift.views import timeshift_proxy, timeshift_proxy_query
urlpatterns = [
# API Routes
@ -43,10 +43,15 @@ urlpatterns = [
name="xc_stream_endpoint",
),
path(
"timeshift/<str:username>/<str:password>/<str:stream_id>/<str:timestamp>/<str:duration>",
"timeshift/<str:username>/<str:password>/<str:duration>/<str:timestamp>/<str:channel_id>",
timeshift_proxy,
name="timeshift_proxy",
),
path(
"streaming/timeshift.php",
timeshift_proxy_query,
name="timeshift_proxy_query",
),
# XC VOD endpoints
path(
"movie/<str:username>/<str:password>/<str:stream_id>.<str:extension>",

View file

@ -66,7 +66,9 @@ ignore-write-errors = true
disable-write-exception = true
# Debugging settings
py-autoreload = 1
# Reload API workers: touch /app/.uwsgi-reload
; py-autoreload = 1
touch-workers-reload = /app/.uwsgi-reload
honour-stdin = true
# Environment variables

View file

@ -84,6 +84,8 @@ const ProgramPreview = ({
label = 'Now Playing:',
timelineMode = 'live',
playbackElapsedSeconds = 0,
accentColor = 'green.5',
accentIconColor = '#22c55e',
}) => {
const [isExpanded, setIsExpanded] = useState(false);
@ -119,8 +121,8 @@ const ProgramPreview = ({
return (
<>
<Group gap={5} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
<Radio size="14" style={{ color: accentIconColor, flexShrink: 0 }} />
<Text size="xs" fw={500} c={accentColor} style={{ flexShrink: 0 }}>
{label}
</Text>
<Tooltip label={program.title}>

View file

@ -164,7 +164,7 @@ const TimeshiftConnectionCard = ({
null;
// Play position from URL timestamp + EPG window + stream-open anchor.
const playbackBaseRef = useRef({ base: null, receivedAtMs: 0 });
const playbackBaseRef = useRef({ base: null, receivedAtMs: 0, paused: false });
useEffect(() => {
const computed = computeCatchupPlaybackSeconds({
programmeStart: timeshiftSession.programme_start,
@ -172,27 +172,35 @@ const TimeshiftConnectionCard = ({
programDurationSecs: programmePreview?.duration_secs,
positionAnchorAt: timeshiftSession.position_anchor_at,
playbackBaseSecs: timeshiftSession.playback_base_secs,
paused: Boolean(timeshiftSession.paused),
nowMs: Date.now(),
});
if (computed != null) {
playbackBaseRef.current = {
base: computed,
receivedAtMs: Date.now(),
paused: Boolean(timeshiftSession.paused),
};
}
}, [
timeshiftSession.programme_start,
timeshiftSession.position_anchor_at,
timeshiftSession.playback_base_secs,
timeshiftSession.paused,
programmePreview?.start_time,
programmePreview?.duration_secs,
]);
const { base: playbackBase, receivedAtMs: playbackReceivedAtMs } =
playbackBaseRef.current;
const {
base: playbackBase,
receivedAtMs: playbackReceivedAtMs,
paused: playbackPaused,
} = playbackBaseRef.current;
const playbackElapsedSeconds =
playbackBase != null
? playbackBase + (Date.now() - playbackReceivedAtMs) / 1000
? playbackPaused
? playbackBase
: playbackBase + (Date.now() - playbackReceivedAtMs) / 1000
: getConnectionDurationSeconds(connection);
const getConnectionStartTime = useCallback(
@ -291,7 +299,9 @@ const TimeshiftConnectionCard = ({
<ProgramPreview
program={programmePreview}
timelineMode="catchup"
label="Watching:"
label={timeshiftSession.paused ? 'Paused:' : 'Watching:'}
accentColor={timeshiftSession.paused ? 'yellow.5' : 'green.5'}
accentIconColor={timeshiftSession.paused ? '#eab308' : '#22c55e'}
playbackElapsedSeconds={playbackElapsedSeconds}
/>
</Box>

View file

@ -1,8 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import {
Modal,
TextInput,
@ -13,27 +10,14 @@ import {
Stack,
Checkbox,
} from '@mantine/core';
const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: '__custom__', label: 'Custom…' },
];
const COMMAND_EXAMPLES = {
ffmpeg:
'-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1',
};
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(),
});
import {
addOutputProfile,
BUILT_IN_COMMANDS,
COMMAND_EXAMPLES,
getResolver,
toCommandSelection,
updateOutputProfile,
} from '../../utils/forms/OutputProfileUtils';
const OutputProfile = ({ profile = null, isOpen, onClose }) => {
const [commandSelection, setCommandSelection] = useState('ffmpeg');
@ -57,7 +41,7 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
useEffect(() => {
@ -67,9 +51,9 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => {
const onSubmit = async (values) => {
if (profile?.id) {
await API.updateOutputProfile({ id: profile.id, ...values });
await updateOutputProfile({ id: profile.id, ...values });
} else {
await API.addOutputProfile(values);
await addOutputProfile(values);
}
reset();
onClose();

View file

@ -1,20 +1,13 @@
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import { Button, Flex, Modal, TextInput } from '@mantine/core';
import {
getResolver,
updateServerGroup,
addServerGroup,
} from '../../utils/forms/ServerGroupUtils';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
});
const ServerGroupForm = ({
serverGroup = null,
isOpen,
onClose,
onSaved,
}) => {
const ServerGroupForm = ({ serverGroup = null, isOpen, onClose, onSaved }) => {
const defaultValues = useMemo(
() => ({
name: serverGroup?.name || '',
@ -29,16 +22,13 @@ const ServerGroupForm = ({
reset,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
let response;
if (serverGroup?.id) {
response = await API.updateServerGroup({ id: serverGroup.id, ...values });
} else {
response = await API.addServerGroup(values);
}
const response = serverGroup?.id
? await updateServerGroup({ id: serverGroup.id, ...values })
: await addServerGroup(values);
if (response) {
onSaved?.(response);

View file

@ -0,0 +1,534 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state
const __form = { values: {}, resetSpy: null };
// Utility mocks
vi.mock('../../../utils/forms/OutputProfileUtils', () => ({
BUILT_IN_COMMANDS: [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: '__custom__', label: 'Custom…' },
],
COMMAND_EXAMPLES: {
ffmpeg: '-i pipe:0 -c:v libx264 -f mpegts pipe:1',
},
addOutputProfile: vi.fn(),
updateOutputProfile: vi.fn(),
getResolver: vi.fn(() => undefined),
toCommandSelection: vi.fn((cmd) =>
cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__'
),
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const setValue = (name, value) => updateField(name, value);
const watch = (name) => formValues[name];
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset: resetRef.current,
setValue,
watch,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<div>
<label htmlFor="checkbox-is-active">{label}</label>
<input
id="checkbox-is-active"
data-testid="checkbox-is-active"
type="checkbox"
checked={checked ?? false}
onChange={(e) =>
onChange({ currentTarget: { checked: e.target.checked } })
}
/>
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Select: ({ label, value, onChange, data, disabled }) => (
<div>
<label>{label}</label>
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Textarea: ({
label,
name,
value,
onChange,
placeholder,
description,
disabled,
...rest
}) => (
<div>
<label htmlFor={name}>{label}</label>
{description && (
<div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>
{description}
</div>
)}
<textarea
id={name}
name={name}
data-testid={`textarea-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
{...rest}
/>
</div>
),
TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
disabled={disabled}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import OutputProfile from '../OutputProfile';
import * as OutputProfileUtils from '../../../utils/forms/OutputProfileUtils';
// Shared helpers
const makeProfile = (overrides = {}) => ({
id: 1,
name: 'HD Transcode',
command: 'ffmpeg',
parameters: '-i pipe:0 -c:v copy -f mpegts pipe:1',
is_active: true,
locked: false,
...overrides,
});
const defaultProps = (overrides = {}) => ({
profile: null,
isOpen: true,
onClose: vi.fn(),
...overrides,
});
//
describe('OutputProfile', () => {
beforeEach(() => {
vi.resetAllMocks();
__form.values = {};
__form.resetSpy = null;
vi.mocked(OutputProfileUtils.addOutputProfile).mockResolvedValue(undefined);
vi.mocked(OutputProfileUtils.updateOutputProfile).mockResolvedValue(
undefined
);
vi.mocked(OutputProfileUtils.getResolver).mockReturnValue(undefined);
vi.mocked(OutputProfileUtils.toCommandSelection).mockImplementation(
(cmd) => (cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__')
);
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
render(<OutputProfile {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Output Profile" as the modal title', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Output Profile'
);
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<OutputProfile {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders the Name input', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toBeInTheDocument();
});
it('renders the Command select', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('select-command')).toBeInTheDocument();
});
it('renders the Parameters textarea', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
});
it('renders the Is Active checkbox', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
});
it('renders the Save button', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('populates the Command select with built-in options', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
});
it('does not show Custom Command input when a built-in is selected', () => {
render(<OutputProfile {...defaultProps()} />);
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('name field is empty for a new profile', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toHaveValue('');
});
it('command select defaults to ffmpeg', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
});
it('is_active checkbox is checked by default', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('parameters field is empty for a new profile', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
});
});
// Profile pre-fill
describe('profile pre-fill', () => {
it('pre-fills the name from the profile', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('input-name')).toHaveValue('HD Transcode');
});
it('pre-fills the parameters from the profile', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
'-i pipe:0 -c:v copy -f mpegts pipe:1'
);
});
it('pre-selects the command from the profile', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
});
it('is_active checkbox is unchecked when profile has is_active: false', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
it('shows Custom Command input when profile has a custom command', () => {
vi.mocked(OutputProfileUtils.toCommandSelection).mockReturnValue(
'__custom__'
);
render(
<OutputProfile
{...defaultProps({
profile: makeProfile({ command: '/usr/bin/mycmd' }),
})}
/>
);
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
});
// Locked profile
describe('locked profile', () => {
it('disables the Name input when profile is locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('input-name')).toBeDisabled();
});
it('disables the Command select when profile is locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('select-command')).toBeDisabled();
});
it('disables the Parameters textarea when profile is locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
});
it('does not disable inputs when profile is not locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: false }) })}
/>
);
expect(screen.getByTestId('input-name')).not.toBeDisabled();
expect(screen.getByTestId('select-command')).not.toBeDisabled();
expect(screen.getByTestId('textarea-parameters')).not.toBeDisabled();
});
});
// Command selection
describe('command selection', () => {
it('shows Custom Command input when Custom… is selected', () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
it('hides Custom Command input when switching back to a built-in', () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
it('sets command form value when switching to a built-in', () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
expect(__form.values.command).toBe('ffmpeg');
});
it('clears command form value when Custom… is selected', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(__form.values.command).toBe('');
});
it('shows parameters example in the description for ffmpeg', () => {
const { container } = render(
<OutputProfile {...defaultProps({ profile: makeProfile() })} />
);
expect(container.textContent).toMatch(/-i pipe:0/);
});
});
// Is Active checkbox
describe('Is Active checkbox', () => {
it('toggles is_active to false when unchecked', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(false);
});
it('toggles is_active to true when checked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(true);
});
});
// Form submission
describe('form submission', () => {
it('calls addOutputProfile when submitting a new profile', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Profile', command: 'ffmpeg' })
);
});
});
it('does not call updateOutputProfile for a new profile', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalled();
});
expect(OutputProfileUtils.updateOutputProfile).not.toHaveBeenCalled();
});
it('calls updateOutputProfile when submitting an existing profile', async () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, name: 'HD Transcode' })
);
});
});
it('does not call addOutputProfile for an existing profile', async () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalled();
});
expect(OutputProfileUtils.addOutputProfile).not.toHaveBeenCalled();
});
it('calls onClose after successful submission', async () => {
const onClose = vi.fn();
render(<OutputProfile {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('calls reset after successful submission', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(__form.resetSpy).toHaveBeenCalled();
});
});
it('passes is_active value to addOutputProfile', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.click(screen.getByTestId('checkbox-is-active')); // uncheck false
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
expect.objectContaining({ is_active: false })
);
});
});
});
});

View file

@ -0,0 +1,363 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state
const __form = { values: {}, resetSpy: null };
// Utility mocks
vi.mock('../../../utils/forms/ServerGroupUtils', () => ({
addServerGroup: vi.fn(),
updateServerGroup: vi.fn(),
getResolver: vi.fn(() => undefined),
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [_formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset: resetRef.current,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
TextInput: ({
label,
name,
value,
onChange,
error,
description,
...rest
}) => (
<div>
<label htmlFor={name}>{label}</label>
{description && <div>{description}</div>}
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import ServerGroupForm from '../ServerGroup';
import * as ServerGroupUtils from '../../../utils/forms/ServerGroupUtils';
// Shared helpers
const makeServerGroup = (overrides = {}) => ({
id: 1,
name: 'US East',
...overrides,
});
const defaultProps = (overrides = {}) => ({
serverGroup: null,
isOpen: true,
onClose: vi.fn(),
onSaved: vi.fn(),
...overrides,
});
//
describe('ServerGroupForm', () => {
beforeEach(() => {
vi.resetAllMocks();
__form.values = {};
__form.resetSpy = null;
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue({
id: 2,
name: 'New Group',
});
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue({
id: 1,
name: 'Updated',
});
vi.mocked(ServerGroupUtils.getResolver).mockReturnValue(undefined);
});
// Visibility
describe('visibility', () => {
it('renders the form when isOpen is true', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders nothing when isOpen is false', () => {
render(<ServerGroupForm {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Server Group" as the modal title', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Server Group'
);
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<ServerGroupForm {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders the Name input', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toBeInTheDocument();
});
it('renders the Submit button', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByText('Submit')).toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('name input is empty when no serverGroup is provided', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toHaveValue('');
});
it('pre-fills the name from the serverGroup prop', () => {
render(
<ServerGroupForm
{...defaultProps({
serverGroup: makeServerGroup({ name: 'EU West' }),
})}
/>
);
expect(screen.getByTestId('input-name')).toHaveValue('EU West');
});
});
// Create (no id)
describe('create (no serverGroup.id)', () => {
it('calls addServerGroup with the form values', async () => {
render(<ServerGroupForm {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Group' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Group' })
);
});
});
it('does not call updateServerGroup when creating', async () => {
render(<ServerGroupForm {...defaultProps()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
});
expect(ServerGroupUtils.updateServerGroup).not.toHaveBeenCalled();
});
it('calls onSaved with the API response when response is truthy', async () => {
const onSaved = vi.fn();
const response = { id: 5, name: 'New Group' };
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(response);
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledWith(response);
});
});
it('does not call onSaved when addServerGroup returns null', async () => {
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(null);
const onSaved = vi.fn();
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
});
expect(onSaved).not.toHaveBeenCalled();
});
it('calls onClose after submission regardless of response', async () => {
const onClose = vi.fn();
render(<ServerGroupForm {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('calls reset after submission', async () => {
render(<ServerGroupForm {...defaultProps()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(__form.resetSpy).toHaveBeenCalled();
});
});
});
// Update (with id)
describe('update (serverGroup with id)', () => {
it('calls updateServerGroup with id and form values', async () => {
render(
<ServerGroupForm
{...defaultProps({
serverGroup: makeServerGroup({ id: 7, name: 'Old Name' }),
})}
/>
);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalledWith(
expect.objectContaining({ id: 7, name: 'New Name' })
);
});
});
it('does not call addServerGroup when updating', async () => {
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup() })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
});
expect(ServerGroupUtils.addServerGroup).not.toHaveBeenCalled();
});
it('calls onSaved with the updated response', async () => {
const updated = { id: 1, name: 'Updated Name' };
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(updated);
const onSaved = vi.fn();
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledWith(updated);
});
});
it('does not call onSaved when updateServerGroup returns null', async () => {
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(null);
const onSaved = vi.fn();
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
});
expect(onSaved).not.toHaveBeenCalled();
});
it('calls onClose after update regardless of response', async () => {
const onClose = vi.fn();
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup(), onClose })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// onSaved optional
describe('onSaved optional', () => {
it('does not throw when onSaved is not provided and response is truthy', async () => {
render(<ServerGroupForm isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await expect(
waitFor(() =>
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled()
)
).resolves.not.toThrow();
});
});
});

View file

@ -1,74 +0,0 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core';
import { EPG_SETTINGS_OPTIONS } from '../../../constants.js';
import {
getChangedSettings,
parseSettings,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import { getEpgSettingsFormInitialValues } from '../../../utils/forms/settings/EpgSettingsFormUtils.js';
const EpgSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const [saved, setSaved] = useState(false);
const form = useForm({
mode: 'controlled',
initialValues: getEpgSettingsFormInitialValues(),
});
useEffect(() => {
if (!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
const parsed = parseSettings(settings);
form.setFieldValue(
'xmltv_prev_days_override',
parsed.xmltv_prev_days_override ?? 0
);
}
}, [settings]);
const onSubmit = async () => {
setSaved(false);
const changedSettings = getChangedSettings(form.getValues(), settings);
try {
await saveChangedSettings(settings, changedSettings);
setSaved(true);
} catch (error) {
console.error('Error saving EPG settings:', error);
}
};
const prevDaysConfig = EPG_SETTINGS_OPTIONS.xmltv_prev_days_override;
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="md">
{saved && (
<Alert variant="light" color="green" title="Saved Successfully" />
)}
<NumberInput
label={prevDaysConfig.label}
description={prevDaysConfig.description}
min={0}
max={30}
{...form.getInputProps('xmltv_prev_days_override')}
/>
<Text size="xs" c="dimmed">
Per-user defaults and URL parameters still override this global value.
EPG channel matching options are configured from the Channels page.
</Text>
<Flex justify="flex-end">
<Button type="submit">Save</Button>
</Flex>
</Stack>
</form>
);
});
export default EpgSettingsForm;

View file

@ -1,417 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import EpgSettingsForm from '../EpgSettingsForm';
// Store mock
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
// @mantine/form mock
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
// @mantine/core mock
vi.mock('@mantine/core', () => ({
Alert: ({ title, color, variant }) => (
<div data-testid="alert" data-color={color} data-variant={variant}>
{title}
</div>
),
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children, justify }) => <div data-justify={justify}>{children}</div>,
NumberInput: ({ label, description, min, max, value, onChange }) => (
<div>
{label && <label data-testid="number-input-label">{label}</label>}
{description && (
<span data-testid="number-input-description">{description}</span>
)}
<input
data-testid="number-input"
type="number"
min={min}
max={max}
value={value ?? 0}
onChange={(e) => onChange?.(Number(e.target.value))}
/>
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Text: ({ children, size, c }) => (
<span data-testid="text" data-size={size} data-color={c}>
{children}
</span>
),
}));
// SettingsUtils mock
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
getChangedSettings: vi.fn(),
parseSettings: vi.fn(),
saveChangedSettings: vi.fn(),
}));
// EpgSettingsFormUtils mock
vi.mock('../../../../utils/forms/settings/EpgSettingsFormUtils.js', () => ({
getEpgSettingsFormInitialValues: vi.fn(() => ({
xmltv_prev_days_override: 0,
})),
}));
//
// Imports after mocks
//
import useSettingsStore from '../../../../store/settings.jsx';
import { useForm } from '@mantine/form';
import * as SettingsUtils from '../../../../utils/pages/SettingsUtils.js';
import { getEpgSettingsFormInitialValues } from '../../../../utils/forms/settings/EpgSettingsFormUtils.js';
import { EPG_SETTINGS_OPTIONS } from '../../../../constants.js';
// Form mock factory
let mockForm;
const createMockForm = (initialValues = { xmltv_prev_days_override: 0 }) => {
const state = { ...initialValues };
return {
values: state,
setFieldValue: vi.fn((field, value) => {
state[field] = value;
}),
getInputProps: vi.fn((field) => ({
value: state[field],
onChange: vi.fn((val) => {
state[field] = val;
}),
})),
getValues: vi.fn(() => ({ ...state })),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler();
}),
submitting: false,
};
};
// Settings factories
const makeSettings = (epgValue = {}) => ({
epg_settings: { id: 1, value: epgValue },
});
//
describe('EpgSettingsForm', () => {
beforeEach(() => {
vi.clearAllMocks();
mockForm = createMockForm();
vi.mocked(useForm).mockReturnValue(mockForm);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 0,
});
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue({});
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
});
// Rendering
describe('rendering', () => {
it('renders without crashing', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label)
).toBeInTheDocument();
});
it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description
)
).toBeInTheDocument();
});
it('renders the disclaimer text about per-user defaults', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/Per-user defaults and URL parameters still override this global value/i
)
).toBeInTheDocument();
});
it('renders the EPG channel matching hint', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/EPG channel matching options are configured from the Channels page/i
)
).toBeInTheDocument();
});
it('renders a Save button of type="submit"', () => {
render(<EpgSettingsForm active={true} />);
const btn = screen.getByText('Save');
expect(btn).toBeInTheDocument();
expect(btn).toHaveAttribute('type', 'submit');
});
it('does not show the "Saved Successfully" alert initially', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('NumberInput has min=0', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0');
});
it('NumberInput has max=30', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30');
});
it('NumberInput starts with value 0 from initial form values', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveValue(0);
});
});
// Initialization
describe('initialization', () => {
it('calls getEpgSettingsFormInitialValues to seed useForm', () => {
render(<EpgSettingsForm active={true} />);
expect(getEpgSettingsFormInitialValues).toHaveBeenCalled();
});
it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => {
vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({
xmltv_prev_days_override: 5,
});
render(<EpgSettingsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({
initialValues: { xmltv_prev_days_override: 5 },
})
);
});
it('calls useForm with mode="controlled"', () => {
render(<EpgSettingsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'controlled' })
);
});
});
// Settings effect
describe('settings effect', () => {
it('calls parseSettings when settings are provided', () => {
const settings = makeSettings({ xmltv_prev_days_override: 7 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 7,
});
render(<EpgSettingsForm active={true} />);
expect(SettingsUtils.parseSettings).toHaveBeenCalledWith(settings);
});
it('calls setFieldValue with parsed xmltv_prev_days_override', () => {
const settings = makeSettings({ xmltv_prev_days_override: 14 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 14,
});
render(<EpgSettingsForm active={true} />);
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'xmltv_prev_days_override',
14
);
});
it('calls setFieldValue with 0 when parsed value is undefined', () => {
const settings = makeSettings({});
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({});
render(<EpgSettingsForm active={true} />);
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'xmltv_prev_days_override',
0
);
});
it('does not call parseSettings when settings is null', () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
render(<EpgSettingsForm active={true} />);
expect(SettingsUtils.parseSettings).not.toHaveBeenCalled();
});
});
// active prop effect
describe('active prop effect', () => {
it('resets the saved alert when active changes to false', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
const { rerender } = render(<EpgSettingsForm active={true} />);
// Trigger a successful save to get saved=true
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
// Switching active to false should dismiss the alert
rerender(<EpgSettingsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show alert when active starts as false', () => {
render(<EpgSettingsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Submit success
describe('submit — success', () => {
beforeEach(() => {
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
});
it('calls getChangedSettings with form values and settings on submit', async () => {
const settings = makeSettings({ xmltv_prev_days_override: 3 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
render(<EpgSettingsForm active={true} />);
// Set the value after render so the settings useEffect (which resets the
// field to the parseSettings result) has already run and won't overwrite it.
mockForm.values.xmltv_prev_days_override = 5;
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
expect.objectContaining({ xmltv_prev_days_override: 5 }),
settings
);
});
});
it('calls saveChangedSettings with settings and changedSettings on submit', async () => {
const settings = makeSettings({ xmltv_prev_days_override: 3 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
const changed = { xmltv_prev_days_override: 7 };
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue(changed);
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith(
settings,
changed
);
});
});
it('shows "Saved Successfully" alert after a successful save', async () => {
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
});
});
it('clears saved state before re-submitting', async () => {
render(<EpgSettingsForm active={true} />);
// First save
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
// Second save saved resets to false then true again
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
});
});
// Submit error
describe('submit — error', () => {
it('does not show alert when saveChangedSettings throws', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
new Error('network error')
);
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
});
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not throw when saveChangedSettings rejects', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
new Error('fail')
);
render(<EpgSettingsForm active={true} />);
await expect(
waitFor(() => fireEvent.click(screen.getByText('Save')))
).resolves.not.toThrow();
});
});
// getInputProps wiring
describe('getInputProps wiring', () => {
it('calls form.getInputProps with xmltv_prev_days_override', () => {
render(<EpgSettingsForm active={true} />);
expect(mockForm.getInputProps).toHaveBeenCalledWith(
'xmltv_prev_days_override'
);
});
});
});

View file

@ -4,6 +4,7 @@ import {
Stack,
Text,
Radio,
RadioGroup,
NumberInput,
Checkbox,
Group,
@ -101,11 +102,7 @@ const CreateChannelModal = ({
<Divider label="Channel Number" labelPosition="left" />
<Radio.Group
value={mode}
onChange={onModeChange}
label={numberingLabel}
>
<RadioGroup value={mode} onChange={onModeChange} label={numberingLabel}>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
@ -148,7 +145,7 @@ const CreateChannelModal = ({
}
/>
</Stack>
</Radio.Group>
</RadioGroup>
{mode === customModeValue && (
<NumberInput

View file

@ -0,0 +1,567 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Modal,
NumberInput,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
import ConfirmationDialog from '../ConfirmationDialog.jsx';
import { usePluginStore } from '../../store/plugins.jsx';
import { showNotification } from '../../utils/notificationUtils.js';
import {
getPluginRepoSettings,
previewPluginRepo,
updatePluginRepoSettings,
} from '../../utils/pages/PluginsUtils.js';
export default function ManageReposModal({ opened, onClose }) {
const repos = usePluginStore((s) => s.repos);
const reposLoading = usePluginStore((s) => s.reposLoading);
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
const refreshRepo = usePluginStore((s) => s.refreshRepo);
const addRepo = usePluginStore((s) => s.addRepo);
const removeRepo = usePluginStore((s) => s.removeRepo);
const updateRepo = usePluginStore((s) => s.updateRepo);
const [refreshInterval, setRefreshInterval] = useState(6);
const [savingInterval, setSavingInterval] = useState(false);
const saveIntervalTimer = useRef(null);
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
const [editKeyValue, setEditKeyValue] = useState('');
const [savingKey, setSavingKey] = useState(false);
const [showAddRepo, setShowAddRepo] = useState(false);
const [newRepoUrl, setNewRepoUrl] = useState('');
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
const [addingRepo, setAddingRepo] = useState(false);
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
const [repoPreview, setRepoPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewTimer = useRef(null);
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const loadRepoSettings = useCallback(async () => {
const data = await getPluginRepoSettings();
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
}, []);
const handleSaveInterval = useCallback((val) => {
const hours = val ?? 0;
setRefreshInterval(hours);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
saveIntervalTimer.current = setTimeout(async () => {
setSavingInterval(true);
try {
await updatePluginRepoSettings({ refresh_interval_hours: hours });
} catch {
// Error notification handled by API layer
} finally {
setSavingInterval(false);
}
}, 800);
}, []);
// Debounced manifest preview
const fetchPreview = useCallback((url, publicKey) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
setRepoPreview(null);
setPreviewLoading(false);
return;
}
setPreviewLoading(true);
previewTimer.current = setTimeout(async () => {
const result = await previewPluginRepo(url.trim(), publicKey?.trim());
setRepoPreview(result);
setPreviewLoading(false);
}, 600);
}, []);
const handleAddRepo = useCallback(async () => {
if (!newRepoUrl.trim()) return;
setAddingRepo(true);
try {
await addRepo({
url: newRepoUrl.trim(),
public_key: newRepoPublicKey.trim(),
});
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
setShowAddRepo(false);
await fetchAvailablePlugins();
showNotification({
title: 'Added',
message: 'Plugin repo added',
color: 'green',
});
} catch {
// Error notification handled by API layer
} finally {
setAddingRepo(false);
}
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
const handleDeleteRepo = useCallback(
async (id) => {
await removeRepo(id);
setDeleteConfirmId(null);
await fetchAvailablePlugins();
showNotification({
title: 'Removed',
message: 'Plugin repo removed',
color: 'green',
});
},
[removeRepo, fetchAvailablePlugins]
);
const handleEditKey = useCallback((repo) => {
setEditingKeyRepoId(repo.id);
setEditKeyValue(repo.public_key || '');
}, []);
const handleSaveKey = useCallback(async () => {
if (editingKeyRepoId == null) return;
setSavingKey(true);
try {
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
await refreshRepo(editingKeyRepoId);
await fetchAvailablePlugins();
showNotification({
title: 'Updated',
message: 'Public key updated',
color: 'green',
});
setEditingKeyRepoId(null);
setEditKeyValue('');
} catch {
showNotification({
title: 'Error',
message: 'Failed to update key',
color: 'red',
});
} finally {
setSavingKey(false);
}
}, [
editingKeyRepoId,
editKeyValue,
updateRepo,
refreshRepo,
fetchAvailablePlugins,
]);
// Load settings when modal opens
useEffect(() => {
if (opened) loadRepoSettings();
}, [opened, loadRepoSettings]);
// Cleanup any pending timers on unmount
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
};
}, []);
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={
<Group justify="space-between" align="flex-start" w="100%">
<div style={{ flex: 1 }}>
<Text fw={600}>Plugin Repositories</Text>
<Text size="sm" c="dimmed" mt={4}>
Add third-party plugin repositories or manage existing ones.
Manifests are fetched automatically at the configured interval.
</Text>
</div>
<div style={{ textAlign: 'left', flexShrink: 0 }}>
<Text size="sm" fw={500} mb={2}>
Refresh Interval
</Text>
<NumberInput
value={refreshInterval}
onChange={handleSaveInterval}
min={0}
max={168}
size="xs"
disabled={savingInterval}
w={115}
/>
<Text size="xs" c="dimmed" mt={2}>
Hours, 0 to disable
</Text>
</div>
</Group>
}
centered
size="lg"
styles={{
title: { width: '100%' },
header: { alignItems: 'flex-start' },
}}
>
<Stack gap="md">
{reposLoading && repos.length === 0 && <Loader size="sm" />}
{repos.map((repo) => (
<React.Fragment key={repo.id}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{
padding: '8px 12px',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-dark-6)',
}}
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap="xs" align="center">
<Text fw={500} size="sm" lineClamp={1}>
{repo.name}
</Text>
{repo.is_official && (
<Badge
size="xs"
variant="filled"
style={{ backgroundColor: '#14917E' }}
>
Official Repo
</Badge>
)}
{repo.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repo.signature_verified === false && (
<Badge
size="xs"
variant="light"
color="red"
leftSection={<ShieldAlert size={10} />}
>
Invalid Signature
</Badge>
)}
</Group>
{repo.registry_url ? (
<Text size="xs" c="dimmed" lineClamp={1}>
<a
href={repo.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repo.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{repo.url}
</Text>
{repo.last_fetched && (
<Text
size="xs"
c={
repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? 'red'
: 'dimmed'
}
>
Last fetched:{' '}
{new Date(repo.last_fetched).toLocaleString()}
{repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? ` · ${repo.last_fetch_status}`
: repo.plugin_count != null
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
: ''}
</Text>
)}
</Box>
{!repo.is_official && (
<Stack gap={4} align="center">
<ActionIcon
variant="subtle"
color="gray"
title="Edit public key"
onClick={() => handleEditKey(repo)}
>
<KeyRound size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
title="Remove repo"
onClick={() => setDeleteConfirmId(repo.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Stack>
)}
</Group>
{editingKeyRepoId === repo.id && (
<Stack gap="xs" mt="xs">
<Textarea
placeholder={
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
}
value={editKeyValue}
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
size="xs"
minRows={3}
autosize
/>
<Group gap="xs">
<Button
size="xs"
onClick={handleSaveKey}
loading={savingKey}
>
Save Key
</Button>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => setEditingKeyRepoId(null)}
>
Cancel
</Button>
</Group>
</Stack>
)}
</React.Fragment>
))}
{!showAddRepo ? (
<Button
variant="light"
leftSection={<Plus size={16} />}
size="sm"
onClick={() => setShowAddRepo(true)}
>
Add Repository
</Button>
) : (
<>
<Text fw={500} size="sm" mt="sm">
Add Repository
</Text>
<Box
style={{
padding: '8px 12px',
borderRadius: 8,
minHeight: 90,
display: 'flex',
alignItems: 'center',
backgroundColor: 'var(--mantine-color-dark-6)',
border:
repoPreview && !previewLoading && !repoPreview.valid
? '1px solid var(--mantine-color-red-7)'
: 'none',
}}
>
{previewLoading ? (
<Group gap="xs" align="center">
<Loader size={14} />
<Text size="xs" c="dimmed">
Checking manifest...
</Text>
</Group>
) : repoPreview ? (
repoPreview.valid ? (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">
{repoPreview.registry_name}
</Text>
{repoPreview.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repoPreview.signature_verified === false && (
<>
<Badge
size="xs"
variant="light"
color="gray"
leftSection={<ShieldCheck size={10} />}
>
Signed Manifest
</Badge>
<Text
size="xs"
c="var(--mantine-color-yellow-6)"
fs="italic"
>
Public key required for verification
</Text>
</>
)}
{repoPreview.signature_verified == null && (
<Badge size="xs" variant="light" color="gray">
No Signature
</Badge>
)}
</Group>
{repoPreview.registry_url ? (
<Text size="xs" c="dimmed">
<a
href={repoPreview.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repoPreview.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{newRepoUrl.trim()}
</Text>
<Text size="xs" c="dimmed">
{repoPreview.plugin_count} plugin
{repoPreview.plugin_count !== 1 ? 's' : ''} available
</Text>
</Box>
) : (
<Text size="xs" c="red">
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
</Text>
)
) : (
<Text size="xs" c="yellow">
Third-party repositories are not reviewed by the Dispatcharr
team.
<br />
Adding sources and installing plugins is done at your own
risk.
</Text>
)}
</Box>
<TextInput
placeholder="Repository Manifest URL (ending in .json)"
value={newRepoUrl}
onChange={(e) => {
setNewRepoUrl(e.currentTarget.value);
fetchPreview(e.currentTarget.value, newRepoPublicKey);
}}
size="sm"
/>
<Textarea
placeholder={
gpgKeyFocused
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
: 'Optional: Paste public GPG key here'
}
value={newRepoPublicKey}
onChange={(e) => {
const value = e.currentTarget.value;
setNewRepoPublicKey(value);
fetchPreview(newRepoUrl, value);
}}
size="sm"
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
maxRows={8}
autosize
onFocus={() => setGpgKeyFocused(true)}
onBlur={() => {
if (!newRepoPublicKey) setGpgKeyFocused(false);
}}
styles={
repoPreview?.valid &&
repoPreview?.signature_verified === false &&
!newRepoPublicKey.trim()
? {
input: { borderColor: 'var(--mantine-color-yellow-6)' },
}
: undefined
}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setShowAddRepo(false);
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
}}
>
Cancel
</Button>
<Button
onClick={handleAddRepo}
loading={addingRepo}
disabled={!newRepoUrl.trim()}
leftSection={<Plus size={16} />}
size="sm"
>
Add Repo
</Button>
</Group>
</>
)}
</Stack>
</Modal>
<ConfirmationDialog
opened={deleteConfirmId != null}
onClose={() => setDeleteConfirmId(null)}
onConfirm={() => handleDeleteRepo(deleteConfirmId)}
title="Remove Repository"
message={
<>
<Text size="sm">
Are you sure you want to remove this repository?
</Text>
<Text size="xs" c="dimmed" mt="xs">
Plugins installed from this repo will remain installed but become
unmanaged.
</Text>
</>
}
confirmLabel="Remove"
size="sm"
/>
</>
);
}

View file

@ -13,10 +13,18 @@ import {
} from '@mantine/core';
import { Copy, SquareMinus, SquarePen } from 'lucide-react';
import API from '../../api';
import { notifications } from '@mantine/notifications';
import { showNotification } from '../../utils/notificationUtils';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS } from '../../constants';
const updateChannelProfile = (values) => {
return API.updateChannelProfile(values);
}
const duplicateChannelProfile = (profileId, newName) => {
return API.duplicateChannelProfile(profileId, newName);
}
const ProfileModal = ({ opened, onClose, mode, profile }) => {
const [profileNameInput, setProfileNameInput] = useState('');
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
@ -40,7 +48,7 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
if (!mode || !profile) return;
if (!trimmedName) {
notifications.show({
showNotification({
title: 'Profile name is required',
color: 'red.5',
});
@ -53,13 +61,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
return;
}
const updatedProfile = await API.updateChannelProfile({
const updatedProfile = await updateChannelProfile({
id: profile.id,
name: trimmedName,
});
if (updatedProfile) {
notifications.show({
showNotification({
title: 'Profile renamed',
message: `${profile.name}${trimmedName}`,
color: 'green.5',
@ -69,13 +77,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
}
if (mode === 'duplicate') {
const duplicatedProfile = await API.duplicateChannelProfile(
const duplicatedProfile = await duplicateChannelProfile(
profile.id,
trimmedName
);
if (duplicatedProfile) {
notifications.show({
showNotification({
title: 'Profile duplicated',
message: `${profile.name} copied to ${duplicatedProfile.name}`,
color: 'green.5',

View file

@ -0,0 +1,539 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// @mantine/core
vi.mock('@mantine/core', () => {
const React = require('react');
// Named declarations so reference equality checks (child.type === Stack) work
function Stack({ children }) {
return <div>{children}</div>;
}
function RadioItem({ value, label, description, _onChange }) {
return (
<label>
<input
type="radio"
data-testid={`radio-${value}`}
value={value}
onChange={() => _onChange?.(value)}
aria-label={label}
/>
{label}
{description && (
<span data-testid={`radio-desc-${value}`}>{description}</span>
)}
</label>
);
}
function RadioGroup({ children, onChange, label }) {
// Inject _onChange into Radio children, handling the Stack wrapper
const inject = (child) => {
if (!React.isValidElement(child)) return child;
if (child.type === RadioItem) {
return React.cloneElement(child, { _onChange: onChange });
}
if (child.type === Stack && child.props.children) {
return React.cloneElement(child, {
children: React.Children.map(child.props.children, inject),
});
}
return child;
};
return (
<div>
{label && <label>{label}</label>}
{React.Children.map(children, inject)}
</div>
);
}
return {
Button: ({ children, onClick, variant }) => (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<div>
<input
type="checkbox"
data-testid="remember-checkbox"
checked={checked ?? false}
onChange={(e) =>
onChange({ currentTarget: { checked: e.target.checked } })
}
/>
{label && <label>{label}</label>}
</div>
),
Divider: ({ label }) => <hr aria-label={label} />,
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, data, value, onChange }) => {
const flat = (data ?? []).flatMap((g) =>
g.items ? g.items : [{ value: g.value, label: g.label }]
);
return (
<div>
{label && <label>{label}</label>}
{flat.map((opt) => (
<button
key={opt.value}
data-testid={`profile-option-${opt.value}`}
onClick={() => onChange([...(value ?? []), opt.value])}
>
{opt.label}
</button>
))}
</div>
);
},
NumberInput: ({
label,
value,
onChange,
min,
placeholder,
description,
}) => (
<div>
{label && <label>{label}</label>}
<input
type="number"
data-testid="number-input"
value={value ?? ''}
min={min}
placeholder={placeholder}
onChange={(e) => onChange(Number(e.target.value))}
/>
{description && <span>{description}</span>}
</div>
),
Radio: RadioItem,
RadioGroup,
Stack,
Text: ({ children, c }) => <span data-color={c}>{children}</span>,
};
});
// Imports after mocks
import CreateChannelModal from '../CreateChannelModal';
// Shared helpers
const makeProfiles = () => [
{ id: '0', name: 'All Profiles' }, // should be filtered out
{ id: '1', name: 'Profile One' },
{ id: '2', name: 'Profile Two' },
];
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
mode: 'provider',
onModeChange: vi.fn(),
numberValue: '',
onNumberValueChange: vi.fn(),
rememberChoice: false,
onRememberChoiceChange: vi.fn(),
onConfirm: vi.fn(),
isBulk: false,
streamCount: 1,
streamName: 'My Stream',
selectedProfileIds: [],
onProfileIdsChange: vi.fn(),
channelProfiles: makeProfiles(),
...overrides,
});
//
describe('CreateChannelModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<CreateChannelModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(<CreateChannelModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Title and labels
describe('title and labels', () => {
it('shows "Create Channel" title for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Create Channel'
);
});
it('shows "Create Channels Options" title for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Create Channels Options'
);
});
it('shows "Create Channel" confirm button for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(
screen.getByRole('button', { name: 'Create Channel' })
).toBeInTheDocument();
});
it('shows "Create Channels" confirm button for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByText('Create Channels')).toBeInTheDocument();
});
it('shows "Number Assignment" numbering label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByText('Number Assignment')).toBeInTheDocument();
});
it('shows "Numbering Mode" numbering label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByText('Numbering Mode')).toBeInTheDocument();
});
});
// Description text
describe('description text', () => {
it('shows the streamName in the description for single mode', () => {
render(
<CreateChannelModal {...defaultProps({ streamName: 'ESPN HD' })} />
);
expect(screen.getByText(/ESPN HD/)).toBeInTheDocument();
});
it('shows the streamCount in the description for bulk mode', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, streamCount: 5 })}
/>
);
expect(screen.getByText(/5 channels/)).toBeInTheDocument();
});
});
// Radio options
describe('radio options', () => {
it('renders all four radio options', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('radio-provider')).toBeInTheDocument();
expect(screen.getByTestId('radio-auto')).toBeInTheDocument();
expect(screen.getByTestId('radio-highest')).toBeInTheDocument();
expect(screen.getByTestId('radio-specific')).toBeInTheDocument();
});
it('renders "Use Provider Number" label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByLabelText('Use Provider Number')).toBeInTheDocument();
});
it('renders "Use Provider Numbers" label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByLabelText('Use Provider Numbers')).toBeInTheDocument();
});
it('renders "Auto-Assign Next Available" label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(
screen.getByLabelText('Auto-Assign Next Available')
).toBeInTheDocument();
});
it('renders "Auto-Assign Sequential" label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(
screen.getByLabelText('Auto-Assign Sequential')
).toBeInTheDocument();
});
it('renders "Use Specific Number" label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByLabelText('Use Specific Number')).toBeInTheDocument();
});
it('renders "Start from Custom Number" label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(
screen.getByLabelText('Start from Custom Number')
).toBeInTheDocument();
});
it('calls onModeChange when a radio option is selected', () => {
const onModeChange = vi.fn();
render(<CreateChannelModal {...defaultProps({ onModeChange })} />);
fireEvent.click(screen.getByLabelText('Auto-Assign Next Available'));
expect(onModeChange).toHaveBeenCalledWith('auto');
});
});
// NumberInput visibility
describe('NumberInput visibility', () => {
it('does not show NumberInput when mode is "provider"', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'provider' })} />);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('does not show NumberInput when mode is "auto"', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'auto' })} />);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('does not show NumberInput when mode is "highest"', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'highest' })} />);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('shows NumberInput when mode is "specific" in single mode', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('shows NumberInput when mode is "custom" in bulk mode', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, mode: 'custom' })}
/>
);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('does not show NumberInput when mode is "specific" but isBulk is true', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, mode: 'specific' })}
/>
);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('calls onNumberValueChange when NumberInput value changes', () => {
const onNumberValueChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({
mode: 'specific',
numberValue: 5,
onNumberValueChange,
})}
/>
);
fireEvent.change(screen.getByTestId('number-input'), {
target: { value: '10' },
});
expect(onNumberValueChange).toHaveBeenCalledWith(10);
});
it('shows "Channel Number" label in single mode', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
expect(screen.getByText('Channel Number')).toBeInTheDocument();
});
it('shows "Starting Channel Number" label in bulk mode', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, mode: 'custom' })}
/>
);
expect(screen.getByText('Starting Channel Number')).toBeInTheDocument();
});
});
// Remember choice checkbox
describe('remember choice checkbox', () => {
it('renders unchecked when rememberChoice is false', () => {
render(
<CreateChannelModal {...defaultProps({ rememberChoice: false })} />
);
expect(screen.getByTestId('remember-checkbox')).not.toBeChecked();
});
it('renders checked when rememberChoice is true', () => {
render(
<CreateChannelModal {...defaultProps({ rememberChoice: true })} />
);
expect(screen.getByTestId('remember-checkbox')).toBeChecked();
});
it('calls onRememberChoiceChange with true when checked', () => {
const onRememberChoiceChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ rememberChoice: false, onRememberChoiceChange })}
/>
);
fireEvent.click(screen.getByTestId('remember-checkbox'));
expect(onRememberChoiceChange).toHaveBeenCalledWith(true);
});
it('calls onRememberChoiceChange with false when unchecked', () => {
const onRememberChoiceChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ rememberChoice: true, onRememberChoiceChange })}
/>
);
fireEvent.click(screen.getByTestId('remember-checkbox'));
expect(onRememberChoiceChange).toHaveBeenCalledWith(false);
});
});
// Action buttons
describe('action buttons', () => {
it('calls onConfirm when the confirm button is clicked', () => {
const onConfirm = vi.fn();
render(<CreateChannelModal {...defaultProps({ onConfirm })} />);
fireEvent.click(screen.getByRole('button', { name: 'Create Channel' }));
expect(onConfirm).toHaveBeenCalled();
});
it('calls onClose when the Cancel button is clicked', () => {
const onClose = vi.fn();
render(<CreateChannelModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
});
// Channel profiles
describe('channel profiles', () => {
it('renders the "All Profiles" special option', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('profile-option-all')).toBeInTheDocument();
});
it('renders the "No Profiles" special option', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('profile-option-none')).toBeInTheDocument();
});
it('renders channel profile options (excluding id "0")', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('profile-option-1')).toBeInTheDocument();
expect(screen.getByTestId('profile-option-2')).toBeInTheDocument();
});
it('does not render the profile with id "0"', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.queryByTestId('profile-option-0')).not.toBeInTheDocument();
});
it('renders "All Profiles" and "No Profiles" labels', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByText('All Profiles')).toBeInTheDocument();
expect(screen.getByText('No Profiles')).toBeInTheDocument();
});
});
// handleProfileChange logic
describe('handleProfileChange', () => {
it('selects only "all" when "All Profiles" is clicked', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-all'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['all']);
});
it('selects only "none" when "No Profiles" is clicked', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-none'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
});
it('removes "all" when a specific profile is added while "all" is selected', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-1'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['1']);
});
it('removes "none" when a specific profile is added while "none" is selected', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({
selectedProfileIds: ['none'],
onProfileIdsChange,
})}
/>
);
fireEvent.click(screen.getByTestId('profile-option-2'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['2']);
});
it('allows selecting multiple specific profiles', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: ['1'], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-2'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['1', '2']);
});
it('replaces existing "all" selection when "none" is clicked last', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
/>
);
// Mock passes ['all', 'none'] to onChange, making lastSelected = 'none'
fireEvent.click(screen.getByTestId('profile-option-none'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
});
});
});

View file

@ -0,0 +1,597 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
} from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mock
vi.mock('../../../store/plugins.jsx', () => ({
usePluginStore: vi.fn(),
}));
// Utility mocks
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/pages/PluginsUtils.js', () => ({
getPluginRepoSettings: vi.fn(),
previewPluginRepo: vi.fn(),
updatePluginRepoSettings: vi.fn(),
}));
// ConfirmationDialog mock
vi.mock('../../ConfirmationDialog.jsx', () => ({
default: ({ opened, onClose, onConfirm, title, confirmLabel }) =>
opened ? (
<div data-testid="confirmation-dialog">
<div data-testid="dialog-title">{title}</div>
<button data-testid="dialog-confirm" onClick={onConfirm}>
{confirmLabel || 'Confirm'}
</button>
<button data-testid="dialog-close" onClick={onClose}>
Cancel
</button>
</div>
) : null,
}));
// lucide-react
vi.mock('lucide-react', () => ({
KeyRound: () => <svg data-testid="icon-key-round" />,
Plus: () => <svg data-testid="icon-plus" />,
ShieldAlert: () => <svg data-testid="icon-shield-alert" />,
ShieldCheck: () => <svg data-testid="icon-shield-check" />,
Trash2: () => <svg data-testid="icon-trash-2" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, title, color, variant }) => (
<button
data-testid="action-icon"
title={title}
data-color={color}
data-variant={variant}
onClick={onClick}
>
{children}
</button>
),
Badge: ({ children, color, variant, leftSection }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{leftSection}
{children}
</span>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({
children,
onClick,
loading,
disabled,
variant,
color,
leftSection,
}) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: ({ size }) => <div data-testid="loader" data-size={size} />,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-header">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
NumberInput: ({ value, onChange, min, max, disabled }) => (
<input
type="number"
data-testid="refresh-interval-input"
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
disabled={disabled}
/>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, fw, size, c }) => (
<span data-fw={fw} data-size={size} data-color={c}>
{children}
</span>
),
Textarea: ({ value, onChange, placeholder, onFocus, onBlur }) => (
<textarea
data-testid="textarea"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
onFocus={onFocus}
onBlur={onBlur}
/>
),
TextInput: ({ value, onChange, placeholder }) => (
<input
data-testid="repo-url-input"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
/>
),
}));
// Imports after mocks
import ManageReposModal from '../ManageReposModal';
import { usePluginStore } from '../../../store/plugins.jsx';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
getPluginRepoSettings,
previewPluginRepo,
updatePluginRepoSettings,
} from '../../../utils/pages/PluginsUtils.js';
// Shared helpers
const makeRepo = (overrides = {}) => ({
id: 1,
name: 'Main Repo',
url: 'https://example.com/manifest.json',
is_official: false,
signature_verified: null,
registry_url: null,
last_fetched: null,
last_fetch_status: null,
plugin_count: null,
public_key: '',
...overrides,
});
let mockFetchAvailablePlugins;
let mockRefreshRepo;
let mockAddRepo;
let mockRemoveRepo;
let mockUpdateRepo;
const setupStore = ({ repos = [], reposLoading = false } = {}) => {
mockFetchAvailablePlugins = vi.fn().mockResolvedValue(undefined);
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
mockAddRepo = vi.fn().mockResolvedValue(undefined);
mockRemoveRepo = vi.fn().mockResolvedValue(undefined);
mockUpdateRepo = vi.fn().mockResolvedValue(undefined);
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos,
reposLoading,
fetchAvailablePlugins: mockFetchAvailablePlugins,
refreshRepo: mockRefreshRepo,
addRepo: mockAddRepo,
removeRepo: mockRemoveRepo,
updateRepo: mockUpdateRepo,
})
);
};
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
...overrides,
});
//
describe('ManageReposModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupStore();
vi.mocked(getPluginRepoSettings).mockResolvedValue({
refresh_interval_hours: 6,
});
vi.mocked(updatePluginRepoSettings).mockResolvedValue(undefined);
vi.mocked(previewPluginRepo).mockResolvedValue(null);
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<ManageReposModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(<ManageReposModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Settings load
describe('settings load', () => {
it('calls getPluginRepoSettings when opened', async () => {
render(<ManageReposModal {...defaultProps()} />);
await waitFor(() => {
expect(getPluginRepoSettings).toHaveBeenCalledTimes(1);
});
});
it('does not call getPluginRepoSettings when closed', async () => {
render(<ManageReposModal {...defaultProps({ opened: false })} />);
expect(getPluginRepoSettings).not.toHaveBeenCalled();
});
it('loads the refresh interval from settings', async () => {
vi.mocked(getPluginRepoSettings).mockResolvedValue({
refresh_interval_hours: 24,
});
render(<ManageReposModal {...defaultProps()} />);
await waitFor(() => {
expect(screen.getByTestId('refresh-interval-input')).toHaveValue(24);
});
});
});
// Repos list
describe('repos list', () => {
it('renders a row for each repo', () => {
setupStore({
repos: [
makeRepo({ id: 1, name: 'Repo A' }),
makeRepo({ id: 2, name: 'Repo B' }),
],
});
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Repo A')).toBeInTheDocument();
expect(screen.getByText('Repo B')).toBeInTheDocument();
});
it('shows loader when loading and no repos are present', () => {
setupStore({ reposLoading: true, repos: [] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
});
it('does not show loader when repos are present', () => {
setupStore({ reposLoading: true, repos: [makeRepo()] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
it('shows "Official Repo" badge for official repos', () => {
setupStore({ repos: [makeRepo({ is_official: true })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Official Repo')).toBeInTheDocument();
});
it('shows "Verified Signature" badge when signature_verified is true', () => {
setupStore({ repos: [makeRepo({ signature_verified: true })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Verified Signature')).toBeInTheDocument();
});
it('shows "Invalid Signature" badge when signature_verified is false', () => {
setupStore({ repos: [makeRepo({ signature_verified: false })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Invalid Signature')).toBeInTheDocument();
});
it('shows edit and delete buttons only for non-official repos', () => {
setupStore({ repos: [makeRepo({ is_official: false })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByTitle('Edit public key')).toBeInTheDocument();
expect(screen.getByTitle('Remove repo')).toBeInTheDocument();
});
it('does not show edit and delete buttons for official repos', () => {
setupStore({ repos: [makeRepo({ is_official: true })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.queryByTitle('Edit public key')).not.toBeInTheDocument();
expect(screen.queryByTitle('Remove repo')).not.toBeInTheDocument();
});
});
// Refresh interval
describe('refresh interval', () => {
it('calls updatePluginRepoSettings (debounced) when interval changes', async () => {
vi.useFakeTimers();
render(<ManageReposModal {...defaultProps()} />);
await act(async () => {
await vi.runAllTimersAsync(); // flush loadRepoSettings
});
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
target: { value: '12' },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(900);
});
expect(updatePluginRepoSettings).toHaveBeenCalledWith({
refresh_interval_hours: 12,
});
vi.useRealTimers();
});
it('does not call updatePluginRepoSettings before the debounce delay', async () => {
vi.useFakeTimers();
render(<ManageReposModal {...defaultProps()} />);
await act(async () => {
await vi.runAllTimersAsync();
});
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
target: { value: '12' },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(updatePluginRepoSettings).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
// Edit public key
describe('edit public key', () => {
it('shows key editor when Edit button is clicked', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
expect(screen.getByTestId('textarea')).toBeInTheDocument();
expect(screen.getByText('Save Key')).toBeInTheDocument();
});
it('pre-fills key editor with existing public key', () => {
setupStore({ repos: [makeRepo({ id: 1, public_key: 'existing-key' })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
expect(screen.getByTestId('textarea')).toHaveValue('existing-key');
});
it('hides key editor when Cancel is clicked', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Cancel'));
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
});
it('calls updateRepo, refreshRepo, and fetchAvailablePlugins on Save Key', async () => {
setupStore({ repos: [makeRepo({ id: 7 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.change(screen.getByTestId('textarea'), {
target: { value: 'new-key' },
});
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(mockUpdateRepo).toHaveBeenCalledWith(7, {
public_key: 'new-key',
});
expect(mockRefreshRepo).toHaveBeenCalledWith(7);
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
});
});
it('shows "Updated" notification after successful key save', async () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Updated', color: 'green' })
);
});
});
it('shows error notification when key save fails', async () => {
mockUpdateRepo = vi.fn().mockRejectedValue(new Error('Network error'));
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos: [makeRepo({ id: 1 })],
reposLoading: false,
fetchAvailablePlugins: mockFetchAvailablePlugins,
refreshRepo: mockRefreshRepo,
addRepo: mockAddRepo,
removeRepo: mockRemoveRepo,
updateRepo: mockUpdateRepo,
})
);
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Error', color: 'red' })
);
});
});
it('closes key editor after successful save', async () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
});
});
});
// Delete repo
describe('delete repo', () => {
it('opens ConfirmationDialog when delete button is clicked', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Remove Repository'
);
});
it('closes ConfirmationDialog when cancelled', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
fireEvent.click(screen.getByTestId('dialog-close'));
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('calls removeRepo and fetchAvailablePlugins when confirmed', async () => {
setupStore({ repos: [makeRepo({ id: 5 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
fireEvent.click(screen.getByTestId('dialog-confirm'));
await waitFor(() => {
expect(mockRemoveRepo).toHaveBeenCalledWith(5);
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
});
});
it('shows "Removed" notification after delete', async () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
fireEvent.click(screen.getByTestId('dialog-confirm'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Removed', color: 'green' })
);
});
});
});
// Add repository
describe('add repository', () => {
it('shows "Add Repository" button initially', () => {
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Add Repository')).toBeInTheDocument();
});
it('shows the add form when "Add Repository" is clicked', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
expect(screen.getByTestId('repo-url-input')).toBeInTheDocument();
expect(screen.getByText('Add Repo')).toBeInTheDocument();
});
it('hides the form and resets when Cancel is clicked', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.click(screen.getByText('Cancel'));
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
expect(screen.getByText('Add Repository')).toBeInTheDocument();
});
it('"Add Repo" button is disabled when URL is empty', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
expect(screen.getByText('Add Repo')).toBeDisabled();
});
it('"Add Repo" button is enabled when URL is entered', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
expect(screen.getByText('Add Repo')).not.toBeDisabled();
});
it('calls addRepo and fetchAvailablePlugins when submitted', async () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
fireEvent.click(screen.getByText('Add Repo'));
await waitFor(() => {
expect(mockAddRepo).toHaveBeenCalledWith(
expect.objectContaining({ url: 'https://example.com/manifest.json' })
);
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
});
});
it('shows "Added" notification after successful add', async () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
fireEvent.click(screen.getByText('Add Repo'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Added', color: 'green' })
);
});
});
it('closes the form after a successful add', async () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
fireEvent.click(screen.getByText('Add Repo'));
await waitFor(() => {
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
});
});
it('triggers previewPluginRepo (debounced) when a valid URL is entered', async () => {
vi.useFakeTimers();
render(<ManageReposModal {...defaultProps()} />);
await act(async () => {
await vi.runAllTimersAsync();
});
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(700);
});
expect(previewPluginRepo).toHaveBeenCalledWith(
'https://example.com/manifest.json',
''
);
vi.useRealTimers();
});
});
});

View file

@ -0,0 +1,743 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// API mock
vi.mock('../../../api', () => ({
default: {
updateChannelProfile: vi.fn(),
duplicateChannelProfile: vi.fn(),
},
}));
// Notification mock
vi.mock('../../../utils/notificationUtils', () => ({
showNotification: vi.fn(),
}));
// Store mock
vi.mock('../../../store/channels', () => ({
default: vi.fn(),
}));
// Constants mock
vi.mock('../../../constants', () => ({
USER_LEVELS: { STREAMER: 0, STANDARD: 1, ADMIN: 10 },
}));
// lucide-react
vi.mock('lucide-react', () => ({
Copy: () => <svg data-testid="icon-copy" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Alert: ({ children, title, color }) => (
<div data-testid="alert" data-color={color}>
{title && <div data-testid="alert-title">{title}</div>}
{children}
</div>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, variant, size }) => (
<button onClick={onClick} data-variant={variant} data-size={size}>
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
TextInput: ({ label, value, onChange, placeholder }) => (
<div>
{label && <label>{label}</label>}
<input
data-testid="profile-name-input"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
/>
</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
// Imports after mocks
import ProfileModal, { renderProfileOption } from '../ProfileModal';
import API from '../../../api';
import useChannelsStore from '../../../store/channels';
import { showNotification } from '../../../utils/notificationUtils';
// Shared helpers
const makeProfile = (overrides = {}) => ({
id: 1,
name: 'My Profile',
...overrides,
});
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
mode: 'edit',
profile: makeProfile(),
...overrides,
});
//
describe('ProfileModal', () => {
let mockSetSelectedProfileId;
beforeEach(() => {
vi.resetAllMocks();
mockSetSelectedProfileId = vi.fn();
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ setSelectedProfileId: mockSetSelectedProfileId })
);
vi.mocked(API.updateChannelProfile).mockResolvedValue({
id: 1,
name: 'Updated',
});
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
id: 2,
name: 'My Profile Copy',
});
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<ProfileModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<ProfileModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Title
describe('title', () => {
it('shows "Rename Profile: {name}" title in edit mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ name: 'Work Profile' }),
})}
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Rename Profile: Work Profile'
);
});
it('shows "Duplicate Profile: {name}" title in duplicate mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ name: 'Work Profile' }),
})}
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Duplicate Profile: Work Profile'
);
});
});
// Warning alert
describe('warning alert', () => {
it('shows the warning alert in edit mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
it('does not show the warning alert in duplicate mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Initial name value
describe('initial name value', () => {
it('pre-fills the input with the profile name in edit mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ name: 'My Profile' }),
})}
/>
);
expect(screen.getByTestId('profile-name-input')).toHaveValue(
'My Profile'
);
});
it('pre-fills the input with "{name} Copy" in duplicate mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ name: 'My Profile' }),
})}
/>
);
expect(screen.getByTestId('profile-name-input')).toHaveValue(
'My Profile Copy'
);
});
});
// Button labels
describe('button labels', () => {
it('shows "Save" button in edit mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('shows "Duplicate" button in duplicate mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
expect(screen.getByText('Duplicate')).toBeInTheDocument();
});
});
// Cancel / close
describe('cancel / close', () => {
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn();
render(<ProfileModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<ProfileModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Submit validation
describe('submit validation', () => {
it('shows notification when name is empty', () => {
render(<ProfileModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Save'));
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Profile name is required',
color: 'red.5',
})
);
});
it('shows notification when name is only whitespace', () => {
render(<ProfileModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: ' ' },
});
fireEvent.click(screen.getByText('Save'));
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Profile name is required' })
);
});
it('does not call API when name is empty', () => {
render(<ProfileModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Save'));
expect(API.updateChannelProfile).not.toHaveBeenCalled();
});
it('returns early without notification or API call when profile is undefined', () => {
render(<ProfileModal {...defaultProps({ profile: undefined })} />);
fireEvent.click(screen.getByText('Save'));
expect(API.updateChannelProfile).not.toHaveBeenCalled();
expect(showNotification).not.toHaveBeenCalled();
});
});
// Edit mode submission
describe('edit mode submission', () => {
it('closes without calling API when name is unchanged', async () => {
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'edit',
profile: makeProfile({ name: 'My Profile' }),
})}
/>
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
expect(API.updateChannelProfile).not.toHaveBeenCalled();
});
it('calls updateChannelProfile with id and new name when name changes', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ id: 7, name: 'Old Name' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(API.updateChannelProfile).toHaveBeenCalledWith({
id: 7,
name: 'New Name',
});
});
});
it('trims whitespace from the name before submitting', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ id: 1, name: 'Old' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: ' Trimmed Name ' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(API.updateChannelProfile).toHaveBeenCalledWith({
id: 1,
name: 'Trimmed Name',
});
});
});
it('shows "Profile renamed" notification on success', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ name: 'Old Name' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Profile renamed',
color: 'green.5',
})
);
});
});
it('calls onClose after a successful rename', async () => {
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'edit',
profile: makeProfile({ name: 'Old' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not notify or close when updateChannelProfile returns null', async () => {
vi.mocked(API.updateChannelProfile).mockResolvedValue(null);
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'edit',
profile: makeProfile({ name: 'Old' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(API.updateChannelProfile).toHaveBeenCalled();
});
expect(showNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ title: 'Profile renamed' })
);
expect(onClose).not.toHaveBeenCalled();
});
});
// Duplicate mode submission
describe('duplicate mode submission', () => {
it('calls duplicateChannelProfile with profile id and default copy name', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ id: 5, name: 'My Profile' }),
})}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
5,
'My Profile Copy'
);
});
});
it('uses the custom name entered by the user', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ id: 3, name: 'Base' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'Custom Copy Name' },
});
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
3,
'Custom Copy Name'
);
});
});
it('shows "Profile duplicated" notification on success', async () => {
render(
<ProfileModal
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Profile duplicated',
color: 'green.5',
})
);
});
});
it('calls setSelectedProfileId with the string id of the duplicated profile', async () => {
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
id: 99,
name: 'Copy',
});
render(
<ProfileModal
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(mockSetSelectedProfileId).toHaveBeenCalledWith('99');
});
});
it('calls onClose after a successful duplication', async () => {
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'duplicate',
profile: makeProfile(),
})}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not call setSelectedProfileId or close when duplicateChannelProfile returns null', async () => {
vi.mocked(API.duplicateChannelProfile).mockResolvedValue(null);
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'duplicate',
profile: makeProfile(),
})}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(API.duplicateChannelProfile).toHaveBeenCalled();
});
expect(mockSetSelectedProfileId).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
});
});
//
describe('renderProfileOption', () => {
const mockTheme = {
tailwind: {
yellow: { 3: '#f59e0b' },
green: { 5: '#22c55e' },
red: { 6: '#dc2626' },
},
};
const adminUser = { user_level: 10 };
const nonAdminUser = { user_level: 1 };
const makeRenderArgs = (overrides = {}) => ({
theme: mockTheme,
profiles: [],
onEditProfile: vi.fn(),
onDeleteProfile: vi.fn(),
authUser: adminUser,
...overrides,
});
const renderOption = (optionValue, optionLabel, args) => {
const renderFn = renderProfileOption(
args.theme,
args.profiles,
args.onEditProfile,
args.onDeleteProfile,
args.authUser
);
const { container } = render(
renderFn({ option: { value: optionValue, label: optionLabel } })
);
return container;
};
beforeEach(() => {
vi.clearAllMocks();
});
// Rendering
describe('rendering', () => {
it('renders the option label', () => {
renderOption('1', 'Profile One', makeRenderArgs());
expect(screen.getByText('Profile One')).toBeInTheDocument();
});
it('shows action icons when option value is not "0"', () => {
renderOption('1', 'Profile One', makeRenderArgs());
expect(screen.getByTestId('icon-square-pen')).toBeInTheDocument();
expect(screen.getByTestId('icon-copy')).toBeInTheDocument();
expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument();
});
it('does not show action icons when option value is "0"', () => {
renderOption('0', 'All Profiles', makeRenderArgs());
expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-copy')).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-square-minus')).not.toBeInTheDocument();
});
});
// Rename action
describe('rename action', () => {
it('calls onEditProfile with "edit" and option value when rename is clicked', () => {
const args = makeRenderArgs();
renderOption('3', 'Profile Three', args);
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
expect(args.onEditProfile).toHaveBeenCalledWith('edit', '3');
});
it('rename button is enabled for admin user', () => {
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
expect(
screen.getByTestId('icon-square-pen').closest('button')
).not.toBeDisabled();
});
it('rename button is disabled for non-admin user', () => {
renderOption(
'1',
'Profile One',
makeRenderArgs({ authUser: nonAdminUser })
);
expect(
screen.getByTestId('icon-square-pen').closest('button')
).toBeDisabled();
});
});
// Duplicate action
describe('duplicate action', () => {
it('calls onEditProfile with "duplicate" and option value when duplicate is clicked', () => {
const args = makeRenderArgs();
renderOption('5', 'Profile Five', args);
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
expect(args.onEditProfile).toHaveBeenCalledWith('duplicate', '5');
});
it('duplicate button is enabled for admin user', () => {
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
expect(
screen.getByTestId('icon-copy').closest('button')
).not.toBeDisabled();
});
it('duplicate button is disabled for non-admin user', () => {
renderOption(
'1',
'Profile One',
makeRenderArgs({ authUser: nonAdminUser })
);
expect(screen.getByTestId('icon-copy').closest('button')).toBeDisabled();
});
});
// Delete action
describe('delete action', () => {
it('calls onDeleteProfile with option value when delete is clicked', () => {
const args = makeRenderArgs();
renderOption('7', 'Profile Seven', args);
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
expect(args.onDeleteProfile).toHaveBeenCalledWith('7');
});
it('delete button is disabled for non-admin user', () => {
renderOption(
'1',
'Profile One',
makeRenderArgs({ authUser: nonAdminUser })
);
expect(
screen.getByTestId('icon-square-minus').closest('button')
).toBeDisabled();
});
});
// Event propagation
describe('event propagation', () => {
it('does not propagate click event when rename is clicked', () => {
const args = makeRenderArgs();
const parentClick = vi.fn();
const renderFn = renderProfileOption(
mockTheme,
[],
args.onEditProfile,
args.onDeleteProfile,
adminUser
);
render(
<div onClick={parentClick}>
{renderFn({ option: { value: '1', label: 'Profile' } })}
</div>
);
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
expect(parentClick).not.toHaveBeenCalled();
});
it('does not propagate click event when duplicate is clicked', () => {
const args = makeRenderArgs();
const parentClick = vi.fn();
const renderFn = renderProfileOption(
mockTheme,
[],
args.onEditProfile,
args.onDeleteProfile,
adminUser
);
render(
<div onClick={parentClick}>
{renderFn({ option: { value: '1', label: 'Profile' } })}
</div>
);
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
expect(parentClick).not.toHaveBeenCalled();
});
it('does not propagate click event when delete is clicked', () => {
const args = makeRenderArgs();
const parentClick = vi.fn();
const renderFn = renderProfileOption(
mockTheme,
[],
args.onEditProfile,
args.onDeleteProfile,
adminUser
);
render(
<div onClick={parentClick}>
{renderFn({ option: { value: '1', label: 'Profile' } })}
</div>
);
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
expect(parentClick).not.toHaveBeenCalled();
});
});
});

View file

@ -71,14 +71,6 @@ export const PROXY_SETTINGS_OPTIONS = {
},
};
export const EPG_SETTINGS_OPTIONS = {
xmltv_prev_days_override: {
label: 'XMLTV prev_days Override (catch-up)',
description:
'Days of past programmes in the XC EPG output. 0 = auto-detect from the providers tv_archive_duration (capped at 30).',
},
};
export const USER_LIMITS_OPTIONS = {
terminate_on_limit_exceeded: {
label: 'Terminate on Limit Exceeded',

View file

@ -0,0 +1,325 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// API mock
vi.mock('../../api', () => ({
default: {
getCurrentProgramForEpg: vi.fn(),
},
}));
// Imports after mocks
import API from '../../api';
import { useEpgPreview } from '../useEpgPreview';
//
describe('useEpgPreview', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
// Invalid / empty epgDataId
describe('invalid epgDataId', () => {
it.each([null, undefined, '', '0'])(
'returns defaults and skips the API call when epgDataId is %s',
async (id) => {
const { result } = renderHook(() => useEpgPreview(id));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(false);
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
}
);
});
// Initial state
describe('initial state with a valid epgDataId', () => {
it('sets isLoadingProgram true and hasFetchedProgram false immediately', () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
const { result } = renderHook(() => useEpgPreview('epg-1'));
expect(result.current.isLoadingProgram).toBe(true);
expect(result.current.hasFetchedProgram).toBe(false);
expect(result.current.currentProgram).toBe(null);
});
});
// Successful fetch
describe('successful fetch', () => {
it('calls the API with the provided epgDataId', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
renderHook(() => useEpgPreview('epg-42'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-42');
});
it('sets currentProgram to the returned program', async () => {
const program = { id: 7, title: 'News at Six', channel: 'BBC' };
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(program);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toEqual(program);
});
it('sets isLoadingProgram to false after a successful fetch', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isLoadingProgram).toBe(false);
});
it('sets hasFetchedProgram to true after a successful fetch', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.hasFetchedProgram).toBe(true);
});
it('sets currentProgram to null and completes when API returns null', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
});
// Parsing retry
describe('parsing retry', () => {
it('retries after a delay when program.parsing is true', async () => {
const parsingProgram = { id: 1, parsing: true };
const readyProgram = { id: 1, title: 'News', parsing: false };
vi.mocked(API.getCurrentProgramForEpg)
.mockResolvedValueOnce(parsingProgram)
.mockResolvedValueOnce(readyProgram);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
expect(result.current.currentProgram).toEqual(readyProgram);
expect(result.current.hasFetchedProgram).toBe(true);
expect(result.current.isLoadingProgram).toBe(false);
});
it('does not set a still-parsing program as currentProgram', async () => {
const parsingProgram = { id: 1, parsing: true };
const readyProgram = { id: 1, title: 'Ready', parsing: false };
vi.mocked(API.getCurrentProgramForEpg)
.mockResolvedValueOnce(parsingProgram)
.mockResolvedValueOnce(readyProgram);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toEqual(readyProgram);
expect(result.current.currentProgram?.parsing).toBeFalsy();
});
it('resolves to null after all retries are exhausted with parsing: true', async () => {
// Always returns parsing: true the hook will exhaust retries/deadline
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
id: 1,
parsing: true,
});
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
});
// Error handling
describe('error handling', () => {
it('retries after an API error and sets currentProgram on the next success', async () => {
const program = { id: 1, title: 'News' };
vi.mocked(API.getCurrentProgramForEpg)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(program);
vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
expect(result.current.currentProgram).toEqual(program);
expect(result.current.hasFetchedProgram).toBe(true);
});
it('resolves to null after all retries are exhausted by persistent errors', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockRejectedValue(
new Error('Persistent error')
);
vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
});
// epgDataId changes
describe('epgDataId changes', () => {
it('resets to defaults when epgDataId changes to null', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
id: 1,
title: 'News',
});
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
initialProps: { id: 'epg-1' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).not.toBe(null);
rerender({ id: null });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(false);
});
it('resets to defaults when epgDataId changes to "0"', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
id: 1,
title: 'News',
});
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
initialProps: { id: 'epg-1' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
rerender({ id: '0' });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(false);
});
it('fetches with the new epgDataId when it changes to another valid value', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
const { rerender } = renderHook(({ id }) => useEpgPreview(id), {
initialProps: { id: 'epg-1' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
rerender({ id: 'epg-2' });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-1');
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-2');
});
});
// Cleanup on unmount
describe('cleanup on unmount', () => {
it('does not update currentProgram after unmount', async () => {
let resolveProgram;
vi.mocked(API.getCurrentProgramForEpg).mockReturnValue(
new Promise((resolve) => {
resolveProgram = resolve;
})
);
const { result, unmount } = renderHook(() => useEpgPreview('epg-1'));
// Unmount before the API call resolves
unmount();
// Resolve the pending request
await act(async () => {
resolveProgram({ id: 1, title: 'Late News' });
await vi.runAllTimersAsync();
});
// currentProgram should remain null the cancelled flag prevented the update
expect(result.current.currentProgram).toBe(null);
});
});
});

View file

@ -1,6 +1,10 @@
import { useEffect, useState } from 'react';
import API from '../api';
const getCurrentProgramForEpg = (epgId) => {
return API.getCurrentProgramForEpg(epgId);
};
export const useEpgPreview = (epgDataId) => {
const [currentProgram, setCurrentProgram] = useState(null);
const [isLoadingProgram, setIsLoadingProgram] = useState(false);
@ -28,7 +32,7 @@ export const useEpgPreview = (epgDataId) => {
if (cancelled || Date.now() - startTime > deadlineMs) break;
try {
const program = await API.getCurrentProgramForEpg(epgDataId);
const program = await getCurrentProgramForEpg(epgDataId);
if (cancelled) return;
if (program && program.parsing && attempt < maxRetries) {

View file

@ -18,6 +18,14 @@ import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react';
import ConnectionForm from '../components/forms/Connection';
import { SUBSCRIPTION_EVENTS } from '../constants';
const deleteConnectIntegration = (id) => {
return API.deleteConnectIntegration(id);
};
const updateConnectIntegration = (id, values) => {
return API.updateConnectIntegration(id, values);
};
export default function ConnectPage() {
const { integrations, isLoading, fetchIntegrations } = useConnectStore();
const theme = useMantineTheme();
@ -40,7 +48,7 @@ export default function ConnectPage() {
const deleteConnection = async (id) => {
console.log('Deleting connection', id);
await API.deleteConnectIntegration(id);
await deleteConnectIntegration(id);
};
return (
@ -99,15 +107,14 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) {
const toggleIntegration = async () => {
try {
await API.updateConnectIntegration(integration.id, {
await updateConnectIntegration(integration.id, {
...integration,
enabled: !enabled,
});
setEnabled(!enabled);
} catch (error) {
console.error('Failed to update integration', error);
} finally {
}
}
};
return (

View file

@ -18,6 +18,10 @@ import { SUBSCRIPTION_EVENTS } from '../constants';
import { CustomTable, useTable } from '../components/tables/CustomTable';
import { copyToClipboard } from '../utils';
const getConnectLogs = (params) => {
return API.getConnectLogs(params);
};
export default function ConnectLogsPage() {
const { integrations, fetchIntegrations } = useConnectStore();
@ -51,7 +55,7 @@ export default function ConnectLogsPage() {
if (filters.type) params.type = filters.type;
if (filters.integration) params.integration = filters.integration;
const data = await API.getConnectLogs(params);
const data = await getConnectLogs(params);
const results = Array.isArray(data) ? data : data?.results || [];
setLogs(results);
setCount(data?.count || results.length || 0);

View file

@ -7,66 +7,34 @@ import {
Button,
Group,
Loader,
Modal,
NativeSelect,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import API from '../api.js';
import {
RefreshCcw,
Trash2,
Plus,
Search,
KeyRound,
ShieldCheck,
ShieldAlert,
Package,
} from 'lucide-react';
import { Package, RefreshCcw, Search } from 'lucide-react';
import { usePluginStore } from '../store/plugins.jsx';
import useSettingsStore from '../store/settings.jsx';
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
import ManageReposModal from '../components/modals/ManageReposModal.jsx';
import { showNotification } from '../utils/notificationUtils.js';
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
import { compareVersions } from '../utils/components/pluginUtils.js';
export default function PluginBrowsePage() {
const repos = usePluginStore((s) => s.repos);
const reposLoading = usePluginStore((s) => s.reposLoading);
const availablePlugins = usePluginStore((s) => s.availablePlugins);
const availableLoading = usePluginStore((s) => s.availableLoading);
const fetchRepos = usePluginStore((s) => s.fetchRepos);
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
const refreshRepo = usePluginStore((s) => s.refreshRepo);
const addRepo = usePluginStore((s) => s.addRepo);
const removeRepo = usePluginStore((s) => s.removeRepo);
const updateRepo = usePluginStore((s) => s.updateRepo);
const appVersion = useSettingsStore((s) => s.version?.version || '');
const [repoModalOpen, setRepoModalOpen] = useState(false);
const [newRepoUrl, setNewRepoUrl] = useState('');
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
const [addingRepo, setAddingRepo] = useState(false);
const [refreshingAll, setRefreshingAll] = useState(false);
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
const [editKeyValue, setEditKeyValue] = useState('');
const [savingKey, setSavingKey] = useState(false);
const [showAddRepo, setShowAddRepo] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
const [repoPreview, setRepoPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewTimer = useRef(null);
const [refreshInterval, setRefreshInterval] = useState(6);
const [savingInterval, setSavingInterval] = useState(false);
const saveIntervalTimer = useRef(null);
const recentlyInstalledSlugs = useRef(new Set());
const recentlyUpdatedSlugs = useRef(new Set());
@ -121,130 +89,6 @@ export default function PluginBrowsePage() {
}
}, [refreshRepo, fetchAvailablePlugins]);
const handleAddRepo = useCallback(async () => {
if (!newRepoUrl.trim()) return;
setAddingRepo(true);
try {
await addRepo({
url: newRepoUrl.trim(),
public_key: newRepoPublicKey.trim(),
});
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
setShowAddRepo(false);
await fetchAvailablePlugins();
showNotification({
title: 'Added',
message: 'Plugin repo added',
color: 'green',
});
} catch {
// Error notification handled by API layer
} finally {
setAddingRepo(false);
}
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
const handleDeleteRepo = useCallback(
async (id) => {
await removeRepo(id);
setDeleteConfirmId(null);
await fetchAvailablePlugins();
showNotification({
title: 'Removed',
message: 'Plugin repo removed',
color: 'green',
});
},
[removeRepo, fetchAvailablePlugins]
);
const handleEditKey = useCallback((repo) => {
setEditingKeyRepoId(repo.id);
setEditKeyValue(repo.public_key || '');
}, []);
const handleSaveKey = useCallback(async () => {
if (editingKeyRepoId == null) return;
setSavingKey(true);
try {
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
await refreshRepo(editingKeyRepoId);
await fetchAvailablePlugins();
showNotification({
title: 'Updated',
message: 'Public key updated',
color: 'green',
});
setEditingKeyRepoId(null);
setEditKeyValue('');
} catch {
showNotification({
title: 'Error',
message: 'Failed to update key',
color: 'red',
});
} finally {
setSavingKey(false);
}
}, [
editingKeyRepoId,
editKeyValue,
updateRepo,
refreshRepo,
fetchAvailablePlugins,
]);
const loadRepoSettings = useCallback(async () => {
const data = await API.getPluginRepoSettings();
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
}, []);
const handleSaveInterval = useCallback((val) => {
const hours = val ?? 0;
setRefreshInterval(hours);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
saveIntervalTimer.current = setTimeout(async () => {
setSavingInterval(true);
try {
await API.updatePluginRepoSettings({ refresh_interval_hours: hours });
} catch {
// Error notification handled by API layer
} finally {
setSavingInterval(false);
}
}, 800);
}, []);
// Debounced manifest preview
const fetchPreview = useCallback((url, publicKey) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
setRepoPreview(null);
setPreviewLoading(false);
return;
}
setPreviewLoading(true);
previewTimer.current = setTimeout(async () => {
const result = await API.previewPluginRepo(url.trim(), publicKey?.trim());
setRepoPreview(result);
setPreviewLoading(false);
}, 600);
}, []);
// Cleanup any pending timers on unmount
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
};
}, []);
// Load settings when modal opens
useEffect(() => {
if (repoModalOpen) loadRepoSettings();
}, [repoModalOpen, loadRepoSettings]);
const loading = availableLoading && availablePlugins.length === 0;
// Build repo filter options from available plugins
@ -367,153 +211,152 @@ export default function PluginBrowsePage() {
}}
>
<Box p={16} pb={60} style={{ flex: 1 }}>
<Group justify="space-between" mb="md">
<Group gap="xs" align="center">
<Text fw={700} size="lg">
Find Plugins
</Text>
{availablePlugins.length > 0 && (
<Badge variant="light" color="gray" size="sm">
{availablePlugins.length} Plugins Available
</Badge>
)}
{repos.length > 1 && (
<Badge variant="light" color="gray" size="sm">
{repos.length} Repos
</Badge>
)}
<Group justify="space-between" mb="md">
<Group gap="xs" align="center">
<Text fw={700} size="lg">
Find Plugins
</Text>
{availablePlugins.length > 0 && (
<Badge variant="light" color="gray" size="sm">
{availablePlugins.length} Plugins Available
</Badge>
)}
{repos.length > 1 && (
<Badge variant="light" color="gray" size="sm">
{repos.length} Repos
</Badge>
)}
</Group>
<Group>
<Button
size="xs"
variant="light"
color="teal"
component="a"
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
target="_blank"
rel="noopener noreferrer"
leftSection={<Package size={14} />}
>
Publish Your Plugin
</Button>
<Button
size="xs"
variant="light"
onClick={() => setRepoModalOpen(true)}
>
Manage Repos
</Button>
<ActionIcon
variant="light"
onClick={handleRefreshAll}
title="Refresh all repos"
loading={refreshingAll}
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
<Group>
<Button
size="xs"
variant="light"
color="teal"
component="a"
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
target="_blank"
rel="noopener noreferrer"
leftSection={<Package size={14} />}
>
Publish Your Plugin
</Button>
<Button
size="xs"
variant="light"
onClick={() => setRepoModalOpen(true)}
>
Manage Repos
</Button>
<ActionIcon
variant="light"
onClick={handleRefreshAll}
title="Refresh all repos"
loading={refreshingAll}
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
{loading && <Loader />}
{loading && <Loader />}
{!loading && (
<Group gap="sm" mb="md" wrap="wrap">
<TextInput
placeholder="Search plugins..."
leftSection={<Search size={14} />}
size="xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
/>
<Select
size="xs"
allowDeselect={false}
value={sortBy}
onChange={setSortBy}
data={[
{ value: 'name-asc', label: 'Name A-Z' },
{ value: 'name-desc', label: 'Name Z-A' },
{ value: 'author', label: 'Author' },
{ value: 'updated', label: 'Recently Updated' },
]}
style={{ width: 170 }}
/>
{repoOptions.length > 2 && (
{!loading && (
<Group gap="sm" mb="md" wrap="wrap">
<TextInput
placeholder="Search plugins..."
leftSection={<Search size={14} />}
size="xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
/>
<Select
size="xs"
allowDeselect={false}
value={filterRepo}
onChange={setFilterRepo}
data={repoOptions}
style={{ width: 160 }}
value={sortBy}
onChange={setSortBy}
data={[
{ value: 'name-asc', label: 'Name A-Z' },
{ value: 'name-desc', label: 'Name Z-A' },
{ value: 'author', label: 'Author' },
{ value: 'updated', label: 'Recently Updated' },
]}
style={{ width: 170 }}
/>
)}
<Select
size="xs"
allowDeselect={false}
value={filterStatus}
onChange={setFilterStatus}
data={[
{ value: 'all', label: 'All Plugins' },
{ value: 'installed', label: 'Installed' },
{ value: 'not-installed', label: 'Not Installed' },
{ value: 'compatible', label: 'Compatible' },
]}
style={{ width: 150 }}
/>
</Group>
)}
{repoOptions.length > 2 && (
<Select
size="xs"
allowDeselect={false}
value={filterRepo}
onChange={setFilterRepo}
data={repoOptions}
style={{ width: 160 }}
/>
)}
<Select
size="xs"
allowDeselect={false}
value={filterStatus}
onChange={setFilterStatus}
data={[
{ value: 'all', label: 'All Plugins' },
{ value: 'installed', label: 'Installed' },
{ value: 'not-installed', label: 'Not Installed' },
{ value: 'compatible', label: 'Compatible' },
]}
style={{ width: 150 }}
/>
</Group>
)}
{!loading &&
filteredPlugins.length === 0 &&
availablePlugins.length > 0 && (
{!loading &&
filteredPlugins.length === 0 &&
availablePlugins.length > 0 && (
<Box>
<Text c="dimmed">
No plugins match your filters. Try adjusting your search or
filter criteria.
</Text>
</Box>
)}
{!loading && availablePlugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins match your filters. Try adjusting your search or filter
criteria.
No plugins available. Try refreshing repos or adding a new plugin
repository.
</Text>
</Box>
)}
{!loading && availablePlugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins available. Try refreshing repos or adding a new plugin
repository.
</Text>
</Box>
)}
{!loading && filteredPlugins.length > 0 && (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
{paginatedPlugins.map((p) => (
<AvailablePluginCard
key={`${p.repo_id}-${p.slug}`}
plugin={p}
appVersion={appVersion}
multiRepo={repos.length > 1}
onBeforeInstall={(slug) => {
if (slug) {
if (p.install_status === 'update_available') {
recentlyUpdatedSlugs.current.add(slug);
} else {
recentlyInstalledSlugs.current.add(slug);
{!loading && filteredPlugins.length > 0 && (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
{paginatedPlugins.map((p) => (
<AvailablePluginCard
key={`${p.repo_id}-${p.slug}`}
plugin={p}
appVersion={appVersion}
multiRepo={repos.length > 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);
}}
/>
))}
</SimpleGrid>
)}
}}
onInstalled={(slug) => {
if (slug) recentlyInstalledSlugs.current.add(slug);
fetchAvailablePlugins();
}}
onUninstalled={(slug) => {
if (slug) recentlyUninstalledSlugs.current.add(slug);
}}
/>
))}
</SimpleGrid>
)}
</Box>
{!loading && filteredPlugins.length > 0 && (
@ -551,398 +394,10 @@ export default function PluginBrowsePage() {
</Box>
)}
{/* Manage Repos Modal */}
<Modal
<ManageReposModal
opened={repoModalOpen}
onClose={() => setRepoModalOpen(false)}
title={
<Group justify="space-between" align="flex-start" w="100%">
<div style={{ flex: 1 }}>
<Text fw={600}>Plugin Repositories</Text>
<Text size="sm" c="dimmed" mt={4}>
Add third-party plugin repositories or manage existing ones.
Manifests are fetched automatically at the configured interval.
</Text>
</div>
<div style={{ textAlign: 'left', flexShrink: 0 }}>
<Text size="sm" fw={500} mb={2}>
Refresh Interval
</Text>
<NumberInput
value={refreshInterval}
onChange={handleSaveInterval}
min={0}
max={168}
size="xs"
disabled={savingInterval}
w={115}
/>
<Text size="xs" c="dimmed" mt={2}>
Hours, 0 to disable
</Text>
</div>
</Group>
}
centered
size="lg"
styles={{
title: { width: '100%' },
header: { alignItems: 'flex-start' },
}}
>
<Stack gap="md">
{reposLoading && repos.length === 0 && <Loader size="sm" />}
{repos.map((repo) => (
<React.Fragment key={repo.id}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{
padding: '8px 12px',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-dark-6)',
}}
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap="xs" align="center">
<Text fw={500} size="sm" lineClamp={1}>
{repo.name}
</Text>
{repo.is_official && (
<Badge
size="xs"
variant="filled"
style={{ backgroundColor: '#14917E' }}
>
Official Repo
</Badge>
)}
{repo.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repo.signature_verified === false && (
<Badge
size="xs"
variant="light"
color="red"
leftSection={<ShieldAlert size={10} />}
>
Invalid Signature
</Badge>
)}
</Group>
{repo.registry_url ? (
<Text size="xs" c="dimmed" lineClamp={1}>
<a
href={repo.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repo.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{repo.url}
</Text>
{repo.last_fetched && (
<Text
size="xs"
c={
repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? 'red'
: 'dimmed'
}
>
Last fetched:{' '}
{new Date(repo.last_fetched).toLocaleString()}
{repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? ` · ${repo.last_fetch_status}`
: repo.plugin_count != null
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
: ''}
</Text>
)}
</Box>
{!repo.is_official && (
<Stack gap={4} align="center">
<ActionIcon
variant="subtle"
color="gray"
title="Edit public key"
onClick={() => handleEditKey(repo)}
>
<KeyRound size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
title="Remove repo"
onClick={() => setDeleteConfirmId(repo.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Stack>
)}
</Group>
{editingKeyRepoId === repo.id && (
<Stack gap="xs" mt="xs">
<Textarea
placeholder={
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
}
value={editKeyValue}
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
size="xs"
minRows={3}
autosize
/>
<Group gap="xs">
<Button
size="xs"
onClick={handleSaveKey}
loading={savingKey}
>
Save Key
</Button>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => setEditingKeyRepoId(null)}
>
Cancel
</Button>
</Group>
</Stack>
)}
</React.Fragment>
))}
{!showAddRepo ? (
<Button
variant="light"
leftSection={<Plus size={16} />}
size="sm"
onClick={() => setShowAddRepo(true)}
>
Add Repository
</Button>
) : (
<>
<Text fw={500} size="sm" mt="sm">
Add Repository
</Text>
<Box
style={{
padding: '8px 12px',
borderRadius: 8,
minHeight: 90,
display: 'flex',
alignItems: 'center',
backgroundColor: 'var(--mantine-color-dark-6)',
border:
repoPreview && !previewLoading && !repoPreview.valid
? '1px solid var(--mantine-color-red-7)'
: 'none',
}}
>
{previewLoading ? (
<Group gap="xs" align="center">
<Loader size={14} />
<Text size="xs" c="dimmed">
Checking manifest...
</Text>
</Group>
) : repoPreview ? (
repoPreview.valid ? (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">
{repoPreview.registry_name}
</Text>
{repoPreview.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repoPreview.signature_verified === false && (
<>
<Badge
size="xs"
variant="light"
color="gray"
leftSection={<ShieldCheck size={10} />}
>
Signed Manifest
</Badge>
<Text
size="xs"
c="var(--mantine-color-yellow-6)"
fs="italic"
>
Public key required for verification
</Text>
</>
)}
{repoPreview.signature_verified == null && (
<Badge size="xs" variant="light" color="gray">
No Signature
</Badge>
)}
</Group>
{repoPreview.registry_url ? (
<Text size="xs" c="dimmed">
<a
href={repoPreview.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repoPreview.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{newRepoUrl.trim()}
</Text>
<Text size="xs" c="dimmed">
{repoPreview.plugin_count} plugin
{repoPreview.plugin_count !== 1 ? 's' : ''} available
</Text>
</Box>
) : (
<Text size="xs" c="red">
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
</Text>
)
) : (
<Text size="xs" c="yellow">
Third-party repositories are not reviewed by the Dispatcharr
team.
<br />
Adding sources and installing plugins is done at your own
risk.
</Text>
)}
</Box>
<TextInput
placeholder="Repository Manifest URL (ending in .json)"
value={newRepoUrl}
onChange={(e) => {
setNewRepoUrl(e.currentTarget.value);
fetchPreview(e.currentTarget.value, newRepoPublicKey);
}}
size="sm"
/>
<Textarea
placeholder={
gpgKeyFocused
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
: 'Optional: Paste public GPG key here'
}
value={newRepoPublicKey}
onChange={(e) => {
const value = e.currentTarget.value;
setNewRepoPublicKey(value);
fetchPreview(newRepoUrl, value);
}}
size="sm"
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
maxRows={8}
autosize
onFocus={() => setGpgKeyFocused(true)}
onBlur={() => {
if (!newRepoPublicKey) setGpgKeyFocused(false);
}}
styles={
repoPreview?.valid &&
repoPreview?.signature_verified === false &&
!newRepoPublicKey.trim()
? {
input: { borderColor: 'var(--mantine-color-yellow-6)' },
}
: undefined
}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setShowAddRepo(false);
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
}}
>
Cancel
</Button>
<Button
onClick={handleAddRepo}
loading={addingRepo}
disabled={!newRepoUrl.trim()}
leftSection={<Plus size={16} />}
size="sm"
>
Add Repo
</Button>
</Group>
</>
)}
</Stack>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteConfirmId != null}
onClose={() => setDeleteConfirmId(null)}
title="Remove Repository"
size="sm"
centered
>
<Text size="sm">Are you sure you want to remove this repository?</Text>
<Text size="xs" c="dimmed" mt="xs">
Plugins installed from this repo will remain installed but become
unmanaged.
</Text>
<Group mt="md" justify="flex-end" gap="xs">
<Button
variant="subtle"
color="gray"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
<Button color="red" onClick={() => handleDeleteRepo(deleteConfirmId)}>
Remove
</Button>
</Group>
</Modal>
/>
</AppShellMain>
);
}

View file

@ -45,9 +45,6 @@ const DvrSettingsForm = React.lazy(
const SystemSettingsForm = React.lazy(
() => import('../components/forms/settings/SystemSettingsForm.jsx')
);
const EpgSettingsForm = React.lazy(
() => import('../components/forms/settings/EpgSettingsForm.jsx')
);
const NavOrderForm = React.lazy(
() => import('../components/forms/settings/NavOrderForm.jsx')
);
@ -125,19 +122,6 @@ const SettingsPage = () => {
</AccordionPanel>
</AccordionItem>
<AccordionItem value="epg-settings">
<AccordionControl>EPG</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<EpgSettingsForm
active={accordianValue === 'epg-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="system-settings">
<AccordionControl>System Settings</AccordionControl>
<AccordionPanel>

View file

@ -34,6 +34,11 @@ import {
stopTimeshiftSession,
stopVODClient,
} from '../utils/pages/StatsUtils.js';
import {
computeCatchupArchivePositionSecs,
computeCatchupPlaybackSeconds,
isCatchupPlayheadOutsideProgram,
} from '../utils/cards/TimeshiftConnectionCardUtils.js';
const VodConnectionCard = React.lazy(
() => import('../components/cards/VodConnectionCard.jsx')
);
@ -342,8 +347,9 @@ const StatsPage = () => {
};
}, [activeChannelIds]); // Only re-run when active channel set changes
// Programme metadata for catch-up cards: fetch when sessions or seek
// position (programme_start) change, then reschedule at programme end.
// Catch-up programme metadata: fetch when missing or when the playhead has
// left the cached programme's window; otherwise keep that result and only
// do cheap local checks until the next boundary.
const timeshiftProgrammeKey = useMemo(() => {
return timeshiftSessions
.map((session) => `${session.session_id}:${session.programme_start || ''}`)
@ -352,9 +358,31 @@ const StatsPage = () => {
}, [timeshiftSessions]);
const timeshiftSessionsRef = useRef(timeshiftSessions);
const catchupProgramsRef = useRef(catchupPrograms);
const programmeCheckRef = useRef(() => {});
const playbackFingerprintRef = useRef('');
useEffect(() => {
timeshiftSessionsRef.current = timeshiftSessions;
const fingerprint = timeshiftSessions
.map(
(session) =>
`${session.session_id}:${session.playback_base_secs ?? ''}:${session.position_anchor_at ?? ''}:${session.paused ? 1 : 0}`
)
.sort()
.join(',');
if (
playbackFingerprintRef.current &&
fingerprint !== playbackFingerprintRef.current
) {
programmeCheckRef.current();
}
playbackFingerprintRef.current = fingerprint;
}, [timeshiftSessions]);
useEffect(() => {
catchupProgramsRef.current = catchupPrograms;
}, [catchupPrograms]);
useEffect(() => {
if (!timeshiftProgrammeKey) {
@ -362,45 +390,151 @@ const StatsPage = () => {
return;
}
let cancelled = false;
let timer = null;
let inFlight = false;
const fetchPrograms = async () => {
const sessions = timeshiftSessionsRef.current.map((session) => ({
session_id: session.session_id,
channel_uuid: session.channel_uuid,
programme_start: session.programme_start,
}));
const programs = await getCatchupPrograms(sessions);
setCatchupPrograms(programs);
if (programs && Object.keys(programs).length > 0) {
const now = new Date();
let nearestEndTime = null;
Object.values(programs).forEach((program) => {
if (program && program.end_time) {
const endTime = new Date(program.end_time);
if (
endTime > now &&
(!nearestEndTime || endTime < nearestEndTime)
) {
nearestEndTime = endTime;
}
}
});
if (nearestEndTime) {
const timeUntilChange = nearestEndTime.getTime() - now.getTime();
const fetchDelay = Math.max(timeUntilChange + 5000, 0);
timer = setTimeout(fetchPrograms, fetchDelay);
}
const clearTimer = () => {
if (timer != null) {
clearTimeout(timer);
timer = null;
}
};
const sessionNeedsProgrammeFetch = (session, program) => {
if (!program?.start_time || program.duration_secs == null) {
return true;
}
return isCatchupPlayheadOutsideProgram({
programmeStart: session.programme_start,
programStartTime: program.start_time,
programDurationSecs: program.duration_secs,
positionAnchorAt: session.position_anchor_at,
playbackBaseSecs: session.playback_base_secs,
paused: Boolean(session.paused),
});
};
const msUntilNearestBoundary = () => {
let nearestMs = null;
timeshiftSessionsRef.current.forEach((session) => {
const program = catchupProgramsRef.current?.[session.session_id];
if (!program?.start_time || program.duration_secs == null) {
return;
}
const position = computeCatchupPlaybackSeconds({
programmeStart: session.programme_start,
programStartTime: program.start_time,
programDurationSecs: program.duration_secs,
positionAnchorAt: session.position_anchor_at,
playbackBaseSecs: session.playback_base_secs,
paused: Boolean(session.paused),
capToDuration: false,
allowNegative: true,
});
if (position == null) {
return;
}
if (position < 0) {
nearestMs = 0;
return;
}
const remainingMs = Math.max(
0,
(Number(program.duration_secs) - position) * 1000
);
if (nearestMs == null || remainingMs < nearestMs) {
nearestMs = remainingMs;
}
});
return nearestMs;
};
const scheduleCheck = () => {
clearTimer();
if (cancelled) {
return;
}
const sessions = timeshiftSessionsRef.current;
const programs = catchupProgramsRef.current || {};
if (
sessions.some((session) =>
sessionNeedsProgrammeFetch(session, programs[session.session_id])
)
) {
fetchPrograms();
return;
}
// Wake near the programme end; also at least every 2s so a seek/heartbeat
// that jumps the playhead is noticed without hitting the API.
const remainingMs = msUntilNearestBoundary();
const delay =
remainingMs == null
? 2000
: Math.min(Math.max(remainingMs + 250, 250), 2000);
timer = setTimeout(scheduleCheck, delay);
};
const fetchPrograms = async () => {
if (cancelled || inFlight) {
return;
}
inFlight = true;
clearTimer();
try {
const existingPrograms = catchupProgramsRef.current || {};
const sessions = timeshiftSessionsRef.current.map((session) => {
const existing = existingPrograms[session.session_id];
// Archive playhead relative to the URL programme (uncapped). When the
// card has already advanced and we only have URL math, omit position
// and let the API fall back to Redis.
const positionSecs = computeCatchupArchivePositionSecs({
programmeStart: session.programme_start,
programStartTime: existing?.start_time,
positionAnchorAt: session.position_anchor_at,
playbackBaseSecs: session.playback_base_secs,
paused: Boolean(session.paused),
});
const payload = {
session_id: session.session_id,
channel_uuid: session.channel_uuid,
programme_start: session.programme_start,
};
if (positionSecs != null) {
payload.position_secs = positionSecs;
}
return payload;
});
const programs = await getCatchupPrograms(sessions);
if (cancelled) {
return;
}
catchupProgramsRef.current = programs;
setCatchupPrograms(programs);
const stillOutside = timeshiftSessionsRef.current.some((session) =>
sessionNeedsProgrammeFetch(session, programs[session.session_id])
);
// No next guide entry (or unknown playhead): back off instead of spinning.
timer = setTimeout(
stillOutside ? fetchPrograms : scheduleCheck,
stillOutside ? 30_000 : 250
);
} finally {
inFlight = false;
}
};
programmeCheckRef.current = scheduleCheck;
fetchPrograms();
return () => {
if (timer) clearTimeout(timer);
cancelled = true;
clearTimer();
programmeCheckRef.current = () => {};
};
}, [timeshiftProgrammeKey]);

View file

@ -0,0 +1,395 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// API mock
vi.mock('../../api', () => ({
default: {
deleteConnectIntegration: vi.fn(),
updateConnectIntegration: vi.fn(),
},
}));
// Store mock
vi.mock('../../store/connect', () => ({
default: vi.fn(),
}));
// Constants mock
vi.mock('../../constants', () => ({
SUBSCRIPTION_EVENTS: {
channel_start: 'Channel Started',
channel_stop: 'Channel Stopped',
recording_start: 'Recording Started',
},
}));
// ConnectionForm mock
vi.mock('../../components/forms/Connection', () => ({
default: ({ connection, isOpen, onClose }) =>
isOpen ? (
<div data-testid="connection-form">
<div data-testid="connection-form-id">{connection?.id ?? 'new'}</div>
<button data-testid="connection-form-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
// lucide-react
vi.mock('lucide-react', () => ({
SquarePlus: () => <svg data-testid="icon-square-plus" />,
Webhook: () => <svg data-testid="icon-webhook" />,
FileCode: () => <svg data-testid="icon-file-code" />,
Logs: () => <svg data-testid="icon-logs" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
Badge: ({ children, color, variant, size }) => (
<span
data-testid="badge"
data-color={color}
data-variant={variant}
data-size={size}
>
{children}
</span>
),
Box: ({ children, display, style }) => (
<div data-display={display} style={style}>
{children}
</div>
),
Button: ({ children, onClick, variant, color, size, leftSection }) => (
<button
onClick={onClick}
data-variant={variant}
data-color={color}
data-size={size}
>
{leftSection}
{children}
</button>
),
Card: ({ children }) => <div data-testid="card">{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
data-testid="toggle-switch"
checked={checked ?? false}
onChange={onChange}
/>
{label}
</label>
),
Text: ({ children, fw }) => <span data-fw={fw}>{children}</span>,
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
useMantineTheme: () => ({
tailwind: { green: { 5: '#22c55e' } },
}),
}));
// Imports after mocks
import ConnectPage from '../Connect';
import API from '../../api';
import useConnectStore from '../../store/connect';
// Shared helpers
const makeIntegration = (overrides = {}) => ({
id: 1,
name: 'My Webhook',
type: 'webhook',
enabled: true,
config: { url: 'https://example.com/hook' },
subscriptions: [
{ event: 'channel_start', enabled: true },
{ event: 'channel_stop', enabled: false },
],
...overrides,
});
const setupStore = (overrides = {}) => {
const fetchIntegrations = vi.fn();
vi.mocked(useConnectStore).mockReturnValue({
integrations: [],
isLoading: false,
fetchIntegrations,
...overrides,
});
return { fetchIntegrations };
};
//
describe('ConnectPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
});
// Initialization
describe('initialization', () => {
it('calls fetchIntegrations on mount', () => {
const { fetchIntegrations } = setupStore();
render(<ConnectPage />);
expect(fetchIntegrations).toHaveBeenCalledTimes(1);
});
});
// Loading state
describe('loading state', () => {
it('shows loading indicator when isLoading is true', () => {
setupStore({ isLoading: true });
render(<ConnectPage />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('does not show loading indicator when isLoading is false', () => {
setupStore({ isLoading: false });
render(<ConnectPage />);
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
});
// Integration list
describe('integration list', () => {
it('renders a card for each integration', () => {
setupStore({
integrations: [
makeIntegration({ id: 1 }),
makeIntegration({ id: 2, name: 'Other' }),
],
});
render(<ConnectPage />);
expect(screen.getAllByTestId('card')).toHaveLength(2);
});
it('renders integration names', () => {
setupStore({ integrations: [makeIntegration({ name: 'Plex Hook' })] });
render(<ConnectPage />);
expect(screen.getByText('Plex Hook')).toBeInTheDocument();
});
it('shows no cards when integrations list is empty', () => {
setupStore({ integrations: [] });
render(<ConnectPage />);
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
});
});
// New Connection button
describe('"New Connection" button', () => {
it('renders the New Connection button', () => {
setupStore();
render(<ConnectPage />);
expect(screen.getByText('New Connection')).toBeInTheDocument();
});
it('ConnectionForm is not visible initially', () => {
setupStore();
render(<ConnectPage />);
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
});
it('opens ConnectionForm with no connection when New Connection is clicked', () => {
setupStore();
render(<ConnectPage />);
fireEvent.click(screen.getByText('New Connection'));
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('new');
});
it('closes ConnectionForm when its close button is clicked', () => {
setupStore();
render(<ConnectPage />);
fireEvent.click(screen.getByText('New Connection'));
fireEvent.click(screen.getByTestId('connection-form-close'));
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
});
});
// Edit connection
describe('edit connection', () => {
it('opens ConnectionForm with the integration when Edit is clicked', () => {
const integration = makeIntegration({ id: 7, name: 'My Hook' });
setupStore({ integrations: [integration] });
render(<ConnectPage />);
fireEvent.click(screen.getByText('Edit'));
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('7');
});
});
// Delete connection
describe('delete connection', () => {
it('calls deleteConnectIntegration with the integration id when Delete is clicked', async () => {
setupStore({ integrations: [makeIntegration({ id: 3 })] });
render(<ConnectPage />);
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(3);
});
});
});
});
//
describe('IntegrationRow', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
});
const renderRow = (integrationOverrides = {}) => {
const integration = makeIntegration(integrationOverrides);
const { fetchIntegrations } = setupStore({ integrations: [integration] });
render(<ConnectPage />);
return { integration, fetchIntegrations };
};
// Type icons
describe('type icons', () => {
it('shows webhook icon for webhook type', () => {
renderRow({ type: 'webhook' });
expect(screen.getByTestId('icon-webhook')).toBeInTheDocument();
});
it('shows file code icon for non-webhook type', () => {
renderRow({ type: 'script' });
expect(screen.getByTestId('icon-file-code')).toBeInTheDocument();
});
});
// Target display
describe('target display', () => {
it('shows webhook URL for webhook type', () => {
renderRow({
type: 'webhook',
config: { url: 'https://hooks.example.com' },
});
expect(screen.getByText('https://hooks.example.com')).toBeInTheDocument();
});
it('shows script path for non-webhook type', () => {
renderRow({ type: 'script', config: { path: '/scripts/my-script.sh' } });
expect(screen.getByText('/scripts/my-script.sh')).toBeInTheDocument();
});
});
// Enabled switch
describe('enabled switch', () => {
it('renders checked when integration.enabled is true', () => {
renderRow({ enabled: true });
expect(screen.getByTestId('toggle-switch')).toBeChecked();
});
it('renders unchecked when integration.enabled is false', () => {
renderRow({ enabled: false });
expect(screen.getByTestId('toggle-switch')).not.toBeChecked();
});
it('calls updateConnectIntegration with toggled enabled value on toggle', async () => {
renderRow({ id: 5, enabled: true });
fireEvent.click(screen.getByTestId('toggle-switch'));
await waitFor(() => {
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
5,
expect.objectContaining({ enabled: false })
);
});
});
it('toggles from false to true', async () => {
renderRow({ id: 5, enabled: false });
fireEvent.click(screen.getByTestId('toggle-switch'));
await waitFor(() => {
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
5,
expect.objectContaining({ enabled: true })
);
});
});
it('does not throw when updateConnectIntegration fails', async () => {
vi.mocked(API.updateConnectIntegration).mockRejectedValue(
new Error('fail')
);
vi.spyOn(console, 'error').mockImplementation(() => {});
renderRow({ enabled: true });
await expect(
waitFor(() => fireEvent.click(screen.getByTestId('toggle-switch')))
).resolves.not.toThrow();
});
});
// Subscription badges
describe('subscription badges', () => {
it('renders a badge for each enabled subscription', () => {
renderRow({
subscriptions: [
{ event: 'channel_start', enabled: true },
{ event: 'recording_start', enabled: true },
],
});
expect(screen.getByText('Channel Started')).toBeInTheDocument();
expect(screen.getByText('Recording Started')).toBeInTheDocument();
});
it('does not render badges for disabled subscriptions', () => {
renderRow({
subscriptions: [
{ event: 'channel_start', enabled: true },
{ event: 'channel_stop', enabled: false },
],
});
expect(screen.getByText('Channel Started')).toBeInTheDocument();
expect(screen.queryByText('Channel Stopped')).not.toBeInTheDocument();
});
it('falls back to the raw event name when not in SUBSCRIPTION_EVENTS', () => {
renderRow({
subscriptions: [{ event: 'custom_event', enabled: true }],
});
expect(screen.getByText('custom_event')).toBeInTheDocument();
});
});
// Action buttons
describe('action buttons', () => {
it('opens ConnectionForm with the integration when Edit is clicked', () => {
const integration = makeIntegration({ id: 9, name: 'Test Hook' });
setupStore({ integrations: [integration] });
render(<ConnectPage />);
fireEvent.click(screen.getByText('Edit'));
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('9');
});
it('calls deleteConnectIntegration with the correct id when Delete is clicked', async () => {
renderRow({ id: 11 });
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(11);
});
});
});
});

View file

@ -0,0 +1,462 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
within,
} from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// API mock
vi.mock('../../api', () => ({
default: {
getConnectLogs: vi.fn(),
},
}));
// Store mock
vi.mock('../../store/connect', () => ({
default: vi.fn(),
}));
// Constants mock
vi.mock('../../constants', () => ({
SUBSCRIPTION_EVENTS: {
channel_start: 'Channel Started',
channel_stop: 'Channel Stopped',
},
}));
// CustomTable mock
vi.mock('../../components/tables/CustomTable', () => ({
CustomTable: () => <div data-testid="custom-table" />,
useTable: vi.fn(() => ({})),
}));
// Utils mock
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
// lucide-react
vi.mock('lucide-react', () => ({
FileCode: () => <svg data-testid="icon-file-code" />,
Webhook: () => <svg data-testid="icon-webhook" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
Badge: ({ children, color, variant }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{children}
</span>
),
Box: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
LoadingOverlay: ({ visible }) =>
visible ? <div data-testid="loading-overlay" /> : null,
NativeSelect: ({ value, onChange, data }) => (
<select data-testid="page-size-select" value={value} onChange={onChange}>
{data?.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
),
Pagination: ({ total, value, onChange }) => (
<div data-testid="pagination">
<span data-testid="pagination-total">{total}</span>
<button
data-testid="next-page"
onClick={() => onChange(value + 1)}
disabled={value >= total}
>
Next
</button>
</div>
),
Paper: ({ children }) => <div>{children}</div>,
// Distinguish type filter (has 'webhook' option) from integration filter
Select: ({ data, value, onChange }) => {
const isTypeFilter = data?.some((d) => d.value === 'webhook');
return (
<select
data-testid={isTypeFilter ? 'select-type' : 'select-integration'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
>
{data?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
},
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
Title: ({ children, order }) => <h3 data-order={order}>{children}</h3>,
}));
// Imports after mocks
import ConnectLogsPage from '../ConnectLogs';
import API from '../../api';
import useConnectStore from '../../store/connect';
// Shared helpers
const makeIntegration = (overrides = {}) => ({
id: 1,
name: 'My Webhook',
type: 'webhook',
...overrides,
});
const setupStore = ({
integrations = [],
fetchIntegrations = vi.fn(),
} = {}) => {
vi.mocked(useConnectStore).mockReturnValue({
integrations,
fetchIntegrations,
});
return { fetchIntegrations };
};
const setupApiResponse = (overrides = {}) => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 0,
...overrides,
});
};
//
describe('ConnectLogsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
setupApiResponse();
setupStore();
});
// Rendering
describe('rendering', () => {
it('renders the "Connect Logs" title', () => {
render(<ConnectLogsPage />);
expect(screen.getByText('Connect Logs')).toBeInTheDocument();
});
it('renders the type filter select', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('select-type')).toBeInTheDocument();
});
it('renders the integration filter select', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('select-integration')).toBeInTheDocument();
});
it('renders the table', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
});
it('renders the page size select and pagination', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('page-size-select')).toBeInTheDocument();
expect(screen.getByTestId('pagination')).toBeInTheDocument();
});
});
// Integration initialization
describe('integration initialization', () => {
it('calls fetchIntegrations on mount when integrations list is empty', async () => {
const { fetchIntegrations } = setupStore({ integrations: [] });
render(<ConnectLogsPage />);
await waitFor(() => {
expect(fetchIntegrations).toHaveBeenCalled();
});
});
it('does not call fetchIntegrations when integrations are already loaded', async () => {
const { fetchIntegrations } = setupStore({
integrations: [makeIntegration()],
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
});
expect(fetchIntegrations).not.toHaveBeenCalled();
});
});
// API calls
describe('API calls', () => {
it('calls getConnectLogs on mount with page=1 and page_size=50', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, page_size: 50 })
);
});
});
it('does not include type in params when type filter is empty', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
});
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
expect(callArgs).not.toHaveProperty('type');
});
it('does not include integration in params when integration filter is empty', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
});
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
expect(callArgs).not.toHaveProperty('integration');
});
it('handles an array API response', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue([]);
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
});
});
it('extracts count from a paginated API response', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 77,
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByText(/77/)).toBeInTheDocument();
});
});
});
// Loading state
describe('loading state', () => {
it('shows LoadingOverlay while the fetch is in progress', async () => {
let resolve;
vi.mocked(API.getConnectLogs).mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument();
});
await act(async () => {
resolve({ results: [], count: 0 });
});
});
it('hides LoadingOverlay after the fetch completes', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
});
});
});
// Pagination string
describe('pagination string', () => {
it('shows the correct range and total when results are returned', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 120,
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByText('Showing 1-50 of 120')).toBeInTheDocument();
});
});
it('shows "Showing 1-0 of 0" when there are no logs', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByText('Showing 1-0 of 0')).toBeInTheDocument();
});
});
});
// Page count
describe('page count', () => {
it('computes pageCount as ceil(count / pageSize)', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 110,
});
render(<ConnectLogsPage />);
await waitFor(() => {
// ceil(110 / 50) = 3
expect(screen.getByTestId('pagination-total')).toHaveTextContent('3');
});
});
it('shows at least 1 page when count is 0', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByTestId('pagination-total')).toHaveTextContent('1');
});
});
});
// Type filter
describe('type filter', () => {
it('populates type filter with All, Webhooks, Scripts options', () => {
render(<ConnectLogsPage />);
const typeSelect = screen.getByTestId('select-type');
expect(
within(typeSelect).getByRole('option', { name: 'All' })
).toBeInTheDocument();
expect(
within(typeSelect).getByRole('option', { name: 'Webhooks' })
).toBeInTheDocument();
expect(
within(typeSelect).getByRole('option', { name: 'Scripts' })
).toBeInTheDocument();
});
it('refetches with type param when type filter changes', async () => {
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('select-type'), {
target: { value: 'webhook' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ type: 'webhook' })
);
});
});
it('omits type from params when filter is reset to empty', async () => {
render(<ConnectLogsPage />);
fireEvent.change(screen.getByTestId('select-type'), {
target: { value: 'webhook' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ type: 'webhook' })
);
});
vi.mocked(API.getConnectLogs).mockClear();
fireEvent.change(screen.getByTestId('select-type'), {
target: { value: '' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
const args = vi.mocked(API.getConnectLogs).mock.calls[0][0];
expect(args).not.toHaveProperty('type');
});
});
});
// Integration filter
describe('integration filter', () => {
it('populates integration select from store integrations', async () => {
setupStore({
integrations: [makeIntegration({ id: 3, name: 'Plex Hook' })],
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(
screen.getByRole('option', { name: 'Plex Hook' })
).toBeInTheDocument();
});
});
it('refetches with integration param when integration filter changes', async () => {
setupStore({
integrations: [makeIntegration({ id: 5, name: 'Hook 5' })],
});
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('select-integration'), {
target: { value: '5' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ integration: '5' })
);
});
});
});
// Page size change
describe('page size change', () => {
it('refetches with new page_size when page size is changed', async () => {
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('page-size-select'), {
target: { value: '100' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page_size: 100 })
);
});
});
it('resets to page 1 when page size changes', async () => {
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('page-size-select'), {
target: { value: '25' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page: 1 })
);
});
});
});
// Page navigation
describe('page navigation', () => {
it('refetches with page 2 when the next page button is clicked', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 100,
});
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByTestId('next-page'));
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page: 2 })
);
});
});
});
});

View file

@ -0,0 +1,631 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mocks
vi.mock('../../store/plugins.jsx', () => {
const mockStore = vi.fn();
mockStore.getState = vi.fn(() => ({ repos: [], invalidatePlugins: vi.fn() }));
return { usePluginStore: mockStore };
});
vi.mock('../../store/settings.jsx', () => ({
default: vi.fn(),
}));
// Component mocks
vi.mock('../../components/cards/AvailablePluginCard.jsx', () => ({
default: ({ plugin }) => (
<div data-testid="plugin-card" data-slug={plugin.slug}>
{plugin.name}
</div>
),
}));
vi.mock('../../components/modals/ManageReposModal.jsx', () => ({
default: ({ opened, onClose }) =>
opened ? (
<div data-testid="manage-repos-modal">
<button data-testid="manage-repos-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
// Utility mocks
vi.mock('../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../utils/pages/PluginsUtils.js', () => ({
reloadPlugins: vi.fn(),
}));
vi.mock('../../utils/components/pluginUtils.js', () => ({
compareVersions: vi.fn(() => 0),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Package: () => <svg data-testid="icon-package" />,
RefreshCcw: () => <svg data-testid="icon-refresh" />,
Search: () => <svg data-testid="icon-search" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, loading, title }) => (
<button
data-testid="action-icon"
data-loading={loading}
title={title}
onClick={onClick}
>
{children}
</button>
),
AppShellMain: ({ children }) => <main>{children}</main>,
Badge: ({ children, color, variant }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{children}
</span>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, href, variant, color, leftSection }) => (
<button
onClick={onClick}
data-href={href}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: () => <div data-testid="loader" />,
NativeSelect: ({ value, onChange, data }) => (
<select data-testid="page-size-select" value={value} onChange={onChange}>
{data?.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
),
Pagination: ({ total, value, onChange }) => (
<div data-testid="pagination">
<span data-testid="pagination-total">{total}</span>
<span data-testid="pagination-value">{value}</span>
<button
data-testid="next-page"
onClick={() => onChange(value + 1)}
disabled={value >= total}
>
Next
</button>
</div>
),
Select: ({ data, value, onChange }) => {
const isSort = data?.some((d) => d.value === 'name-asc');
const isStatus = data?.some((d) => d.value === 'installed');
const testId = isSort
? 'sort-select'
: isStatus
? 'status-select'
: 'repo-select';
return (
<select
data-testid={testId}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
>
{data?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
},
SimpleGrid: ({ children }) => <div data-testid="plugin-grid">{children}</div>,
Text: ({ children, fw, size, c }) => (
<span data-fw={fw} data-size={size} data-color={c}>
{children}
</span>
),
TextInput: ({ value, onChange, placeholder }) => (
<input
data-testid="search-input"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
/>
),
}));
// Imports after mocks
import PluginBrowsePage from '../PluginBrowse';
import { usePluginStore } from '../../store/plugins.jsx';
import useSettingsStore from '../../store/settings.jsx';
import { showNotification } from '../../utils/notificationUtils.js';
import { reloadPlugins } from '../../utils/pages/PluginsUtils.js';
// Shared helpers
const makePlugin = (overrides = {}) => ({
slug: 'my-plugin',
repo_id: 1,
repo_name: 'Main Repo',
name: 'My Plugin',
description: 'A test plugin',
author: 'Test Author',
installed: false,
install_status: null,
deprecated: false,
min_dispatcharr_version: null,
max_dispatcharr_version: null,
last_updated: '2024-01-01',
...overrides,
});
const makeRepo = (overrides = {}) => ({
id: 1,
name: 'Main Repo',
url: 'https://example.com',
...overrides,
});
let mockFetchRepos;
let mockFetchAvailablePlugins;
let mockRefreshRepo;
let mockInvalidatePlugins;
const setupStore = ({
repos = [],
availablePlugins = [],
availableLoading = false,
} = {}) => {
mockFetchRepos = vi.fn();
mockFetchAvailablePlugins = vi.fn();
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
mockInvalidatePlugins = vi.fn();
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos,
availablePlugins,
availableLoading,
fetchRepos: mockFetchRepos,
fetchAvailablePlugins: mockFetchAvailablePlugins,
refreshRepo: mockRefreshRepo,
})
);
vi.mocked(usePluginStore).getState.mockReturnValue({
repos,
invalidatePlugins: mockInvalidatePlugins,
});
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ version: { version: '1.0.0' } })
);
};
//
describe('PluginBrowsePage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(reloadPlugins).mockResolvedValue(undefined);
setupStore();
localStorage.clear();
});
// Initialization
describe('initialization', () => {
it('calls fetchRepos and fetchAvailablePlugins on mount', () => {
render(<PluginBrowsePage />);
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
expect(mockFetchAvailablePlugins).toHaveBeenCalledTimes(1);
});
it('does not refetch on re-render (hasFetched guard)', () => {
const { rerender } = render(<PluginBrowsePage />);
rerender(<PluginBrowsePage />);
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
});
});
// Rendering
describe('rendering', () => {
it('renders "Find Plugins" title', () => {
render(<PluginBrowsePage />);
expect(screen.getByText('Find Plugins')).toBeInTheDocument();
});
it('shows plugin count badge when plugins are available', () => {
setupStore({
availablePlugins: [makePlugin(), makePlugin({ slug: 'p2' })],
});
render(<PluginBrowsePage />);
expect(screen.getByText('2 Plugins Available')).toBeInTheDocument();
});
it('does not show plugin count badge when no plugins', () => {
render(<PluginBrowsePage />);
expect(screen.queryByText(/Plugins Available/)).not.toBeInTheDocument();
});
it('shows repo count badge when repos > 1', () => {
setupStore({ repos: [makeRepo(), makeRepo({ id: 2 })] });
render(<PluginBrowsePage />);
expect(screen.getByText('2 Repos')).toBeInTheDocument();
});
it('does not show repo count badge when only 1 repo', () => {
setupStore({ repos: [makeRepo()] });
render(<PluginBrowsePage />);
expect(screen.queryByText(/\d+ Repos/)).not.toBeInTheDocument();
});
it('shows Loader when loading and no plugins yet', () => {
setupStore({ availableLoading: true, availablePlugins: [] });
render(<PluginBrowsePage />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
});
it('does not show Loader when not loading', () => {
setupStore({ availableLoading: false });
render(<PluginBrowsePage />);
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
it('does not show Loader when loading but plugins are already loaded', () => {
setupStore({ availableLoading: true, availablePlugins: [makePlugin()] });
render(<PluginBrowsePage />);
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
});
// Empty states
describe('empty states', () => {
it('shows "No plugins available" when there are no plugins', () => {
render(<PluginBrowsePage />);
expect(
screen.getByText(/No plugins available. Try refreshing repos/)
).toBeInTheDocument();
});
it('shows "No plugins match" when all plugins are filtered out', () => {
setupStore({ availablePlugins: [makePlugin({ name: 'Plex' })] });
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'xyznotfound' },
});
expect(
screen.getByText(/No plugins match your filters/)
).toBeInTheDocument();
});
it('does not show "No plugins match" when there are no plugins at all', () => {
render(<PluginBrowsePage />);
expect(
screen.queryByText(/No plugins match your filters/)
).not.toBeInTheDocument();
});
});
// Plugin cards
describe('plugin cards', () => {
it('renders a card for each visible plugin', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', name: 'Plugin A' }),
makePlugin({ slug: 'b', name: 'Plugin B' }),
],
});
render(<PluginBrowsePage />);
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
});
it('renders no cards when no plugins available', () => {
render(<PluginBrowsePage />);
expect(screen.queryByTestId('plugin-card')).not.toBeInTheDocument();
});
});
// Manage Repos modal
describe('Manage Repos modal', () => {
it('ManageReposModal is not visible initially', () => {
render(<PluginBrowsePage />);
expect(
screen.queryByTestId('manage-repos-modal')
).not.toBeInTheDocument();
});
it('opens ManageReposModal when "Manage Repos" button is clicked', () => {
render(<PluginBrowsePage />);
fireEvent.click(screen.getByText('Manage Repos'));
expect(screen.getByTestId('manage-repos-modal')).toBeInTheDocument();
});
it('closes ManageReposModal when its close handler is called', () => {
render(<PluginBrowsePage />);
fireEvent.click(screen.getByText('Manage Repos'));
fireEvent.click(screen.getByTestId('manage-repos-close'));
expect(
screen.queryByTestId('manage-repos-modal')
).not.toBeInTheDocument();
});
});
// Refresh all
describe('handleRefreshAll', () => {
it('calls refreshRepo for each repo in the store', async () => {
const repos = [makeRepo({ id: 1 }), makeRepo({ id: 2 })];
setupStore({ repos });
vi.mocked(usePluginStore).getState.mockReturnValue({
repos,
invalidatePlugins: mockInvalidatePlugins,
});
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(mockRefreshRepo).toHaveBeenCalledWith(1);
expect(mockRefreshRepo).toHaveBeenCalledWith(2);
});
});
it('calls fetchAvailablePlugins, reloadPlugins, and invalidatePlugins after refresh', async () => {
setupStore({ repos: [makeRepo()] });
vi.mocked(usePluginStore).getState.mockReturnValue({
repos: [makeRepo()],
invalidatePlugins: mockInvalidatePlugins,
});
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
expect(reloadPlugins).toHaveBeenCalled();
expect(mockInvalidatePlugins).toHaveBeenCalled();
});
});
it('shows success notification on successful refresh', async () => {
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Refreshed', color: 'green' })
);
});
});
it('shows error notification when refresh fails', async () => {
mockRefreshRepo = vi.fn().mockRejectedValue(new Error('Network error'));
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos: [makeRepo()],
availablePlugins: [],
availableLoading: false,
fetchRepos: vi.fn(),
fetchAvailablePlugins: vi.fn(),
refreshRepo: mockRefreshRepo,
})
);
vi.mocked(usePluginStore).getState.mockReturnValue({
repos: [makeRepo()],
invalidatePlugins: vi.fn(),
});
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Error', color: 'red' })
);
});
});
});
// Search filter
describe('search filter', () => {
it('filters plugins by name', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'plex', name: 'Plex Plugin' }),
makePlugin({ slug: 'other', name: 'Other Plugin' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'plex' },
});
const cards = screen.getAllByTestId('plugin-card');
expect(cards).toHaveLength(1);
expect(cards[0]).toHaveTextContent('Plex Plugin');
});
it('filters plugins by description', () => {
setupStore({
availablePlugins: [
makePlugin({
slug: 'a',
name: 'Alpha',
description: 'streaming service',
}),
makePlugin({ slug: 'b', name: 'Beta', description: 'other thing' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'streaming' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
it('filters plugins by author', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', name: 'A', author: 'alice' }),
makePlugin({ slug: 'b', name: 'B', author: 'bob' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'alice' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
it('is case-insensitive', () => {
setupStore({ availablePlugins: [makePlugin({ name: 'PlexPlugin' })] });
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'PLEXPLUGIN' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
});
// Status filter
describe('status filter', () => {
it('filters to installed plugins only', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', installed: true }),
makePlugin({ slug: 'b', installed: false }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('status-select'), {
target: { value: 'installed' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
it('filters to not-installed plugins only', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', installed: true }),
makePlugin({ slug: 'b', installed: false }),
makePlugin({ slug: 'c', installed: false }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('status-select'), {
target: { value: 'not-installed' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
});
});
// Repo filter
describe('repo filter', () => {
it('does not show repo filter when only one repo', () => {
setupStore({
availablePlugins: [makePlugin({ repo_id: 1, repo_name: 'Repo 1' })],
});
render(<PluginBrowsePage />);
// repoOptions.length = 2 (all + 1 repo), which is NOT > 2, so hidden
expect(screen.queryByTestId('repo-select')).not.toBeInTheDocument();
});
it('shows repo filter when there are multiple repos', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
],
});
render(<PluginBrowsePage />);
// repoOptions.length = 4 (all + 3 repos), which IS > 2
expect(screen.getByTestId('repo-select')).toBeInTheDocument();
});
it('filters plugins by selected repo', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('repo-select'), {
target: { value: '2' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
});
// Pagination
describe('pagination', () => {
it('shows pagination bar only when there are filtered plugins', () => {
render(<PluginBrowsePage />);
expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
});
it('shows pagination when plugins are available', () => {
setupStore({ availablePlugins: [makePlugin()] });
render(<PluginBrowsePage />);
expect(screen.getByTestId('pagination')).toBeInTheDocument();
});
it('shows correct pagination range text', () => {
const plugins = Array.from({ length: 5 }, (_, i) =>
makePlugin({ slug: `plugin-${i}`, name: `Plugin ${i}` })
);
setupStore({ availablePlugins: plugins });
render(<PluginBrowsePage />);
expect(screen.getByText('1 to 5 of 5')).toBeInTheDocument();
});
it('saves perPage to localStorage when page size changes', () => {
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
setupStore({ availablePlugins: [makePlugin()] });
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('page-size-select'), {
target: { value: '18' },
});
expect(setItemSpy).toHaveBeenCalledWith('pluginBrowsePerPage', '18');
});
it('reads initial perPage from localStorage', () => {
localStorage.setItem('pluginBrowsePerPage', '27');
const plugins = Array.from({ length: 27 }, (_, i) =>
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
);
setupStore({ availablePlugins: plugins });
render(<PluginBrowsePage />);
expect(screen.getByTestId('page-size-select')).toHaveValue('27');
});
it('resets page to 1 when search query changes', () => {
const plugins = Array.from({ length: 20 }, (_, i) =>
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
);
setupStore({ availablePlugins: plugins });
render(<PluginBrowsePage />);
// Go to page 2
fireEvent.click(screen.getByTestId('next-page'));
expect(screen.getByTestId('pagination-value')).toHaveTextContent('2');
// Change search page resets
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'Plugin' },
});
expect(screen.getByTestId('pagination-value')).toHaveTextContent('1');
});
});
});

View file

@ -23,22 +23,39 @@ export const computeCatchupPlaybackSeconds = ({
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
capToDuration = true,
allowNegative = false,
}) => {
let elapsedSinceAnchor = 0;
if (positionAnchorAt != null) {
if (!paused && positionAnchorAt != null) {
const anchor = Number(positionAnchorAt);
if (!Number.isNaN(anchor)) {
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
}
}
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
let position = Number(playbackBaseSecs) + elapsedSinceAnchor;
if (position < 0) {
// Byte-range / client-reported bases are relative to the programme that
// contained the original catch-up URL. When the stats card has advanced to a
// later guide entry, rebase onto that programme's start.
let effectiveBase = playbackBaseSecs;
if (effectiveBase != null && !Number.isNaN(Number(effectiveBase))) {
const urlMs = parseCatchupTimestampMs(programmeStart);
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
if (
urlMs != null &&
epgStartMs != null &&
!Number.isNaN(epgStartMs) &&
epgStartMs > urlMs
) {
effectiveBase = Number(effectiveBase) - (epgStartMs - urlMs) / 1000;
}
let position = Number(effectiveBase) + elapsedSinceAnchor;
if (!allowNegative && position < 0) {
position = 0;
}
if (programDurationSecs != null) {
if (capToDuration && programDurationSecs != null) {
position = Math.min(position, Number(programDurationSecs));
}
return position;
@ -51,15 +68,88 @@ export const computeCatchupPlaybackSeconds = ({
}
const urlOffsetSec = (urlMs - epgStartMs) / 1000;
let position = urlOffsetSec + elapsedSinceAnchor;
if (position < 0) {
if (!allowNegative && position < 0) {
position = 0;
}
if (programDurationSecs != null) {
if (capToDuration && programDurationSecs != null) {
position = Math.min(position, Number(programDurationSecs));
}
return position;
};
/**
* True when the catch-up playhead is outside the displayed programme window.
* Used to fetch the next guide entry once, then cache until that show ends.
*/
export const isCatchupPlayheadOutsideProgram = ({
programmeStart,
programStartTime,
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
}) => {
if (programDurationSecs == null || !programStartTime) {
return true;
}
const position = computeCatchupPlaybackSeconds({
programmeStart,
programStartTime,
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused,
nowMs,
capToDuration: false,
allowNegative: true,
});
if (position == null) {
return false;
}
return position < 0 || position >= Number(programDurationSecs);
};
/**
* Archive playhead relative to the programme containing ``programmeStart``.
* Uncapped so callers can detect when viewing has moved past that show.
* Does not rebase onto a later displayed programme.
*/
export const computeCatchupArchivePositionSecs = ({
programmeStart,
programStartTime,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
}) => {
let elapsedSinceAnchor = 0;
if (!paused && positionAnchorAt != null) {
const anchor = Number(positionAnchorAt);
if (!Number.isNaN(anchor)) {
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
}
}
// playback_base is always relative to the URL programme, even after the
// stats card has advanced to a later guide entry.
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
return Math.max(0, Number(playbackBaseSecs) + elapsedSinceAnchor);
}
const urlMs = parseCatchupTimestampMs(programmeStart);
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
if (urlMs == null || epgStartMs == null || Number.isNaN(epgStartMs)) {
return null;
}
// Only valid while programStartTime is the programme that contains the URL.
// Once the card has advanced, omit position and let the API use Redis.
if (epgStartMs > urlMs) {
return null;
}
return Math.max(0, (urlMs - epgStartMs) / 1000 + elapsedSinceAnchor);
};
export const calculateConnectionDuration = (connection) => {
const seconds = getConnectionDurationSeconds(connection);
return toFriendlyDuration(seconds, 'seconds');

View file

@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import {
computeCatchupPlaybackSeconds,
computeCatchupArchivePositionSecs,
isCatchupPlayheadOutsideProgram,
parseCatchupTimestampMs,
} from '../TimeshiftConnectionCardUtils.js';
@ -35,4 +37,121 @@ describe('computeCatchupPlaybackSeconds', () => {
});
expect(position).toBeCloseTo(1830, 5);
});
it('does not advance while paused', () => {
const position = computeCatchupPlaybackSeconds({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 3600,
positionAnchorAt: 1000,
playbackBaseSecs: 900,
paused: true,
nowMs: 1300 * 1000,
});
expect(position).toBeCloseTo(900, 5);
});
it('rebases byte-range playhead onto a later displayed programme', () => {
const position = computeCatchupPlaybackSeconds({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1900,
nowMs: 1000 * 1000,
});
// 1900s from 14:00 is 100s into the 14:30 programme.
expect(position).toBeCloseTo(100, 5);
});
it('can return uncapped position past programme end', () => {
const position = computeCatchupPlaybackSeconds({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1790,
nowMs: 1040 * 1000,
capToDuration: false,
});
expect(position).toBeCloseTo(1830, 5);
});
});
describe('isCatchupPlayheadOutsideProgram', () => {
it('is false while playhead is inside the programme', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 900,
nowMs: 1000 * 1000,
})
).toBe(false);
});
it('is true once playhead reaches programme end', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1800,
nowMs: 1000 * 1000,
})
).toBe(true);
});
it('detects leaving a later displayed programme', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 3600,
nowMs: 1000 * 1000,
})
).toBe(true);
});
it('detects rewind before a later displayed programme', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1200,
nowMs: 1000 * 1000,
})
).toBe(true);
});
});
describe('computeCatchupArchivePositionSecs', () => {
it('returns uncapped absolute playhead from playback base', () => {
const position = computeCatchupArchivePositionSecs({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
positionAnchorAt: 1000,
playbackBaseSecs: 1900,
nowMs: 1030 * 1000,
});
expect(position).toBeCloseTo(1930, 5);
});
it('keeps URL-relative base after the card advances to a later programme', () => {
const position = computeCatchupArchivePositionSecs({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
positionAnchorAt: 1000,
playbackBaseSecs: 1900,
nowMs: 1000 * 1000,
});
expect(position).toBeCloseTo(1900, 5);
});
});

View file

@ -0,0 +1,36 @@
import * as Yup from 'yup';
import API from '../../api';
import { yupResolver } from '@hookform/resolvers/yup';
export const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: '__custom__', label: 'Custom…' },
];
export const COMMAND_EXAMPLES = {
ffmpeg:
'-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1',
};
export const toCommandSelection = (command) =>
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
? command
: '__custom__';
export const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string(),
});
export const addOutputProfile = (values) => {
return API.addOutputProfile(values);
};
export const updateOutputProfile = (values) => {
return API.updateOutputProfile(values);
};
export const getResolver = () => {
return yupResolver(schema);
};

View file

@ -0,0 +1,16 @@
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
});
export const getResolver = () => {
return yupResolver(schema);
};
export const updateServerGroup = (values) => {
return API.updateServerGroup(values);
};
export const addServerGroup = (values) => {
return API.addServerGroup(values);
};

View file

@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────────────
vi.mock('../../../api', () => ({
default: {
addOutputProfile: vi.fn(),
updateOutputProfile: vi.fn(),
},
}));
const mockResolver = vi.fn();
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn(() => mockResolver),
}));
// ── Imports after mocks ────────────────────────────────────────────────────────
import API from '../../../api';
import { yupResolver } from '@hookform/resolvers/yup';
import {
BUILT_IN_COMMANDS,
COMMAND_EXAMPLES,
toCommandSelection,
schema,
addOutputProfile,
updateOutputProfile,
getResolver,
} from '../OutputProfileUtils';
// ──────────────────────────────────────────────────────────────────────────────
describe('OutputProfileUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(yupResolver).mockReturnValue(mockResolver);
});
// ── Constants ──────────────────────────────────────────────────────────────
describe('BUILT_IN_COMMANDS', () => {
it('includes the ffmpeg entry', () => {
expect(BUILT_IN_COMMANDS).toContainEqual({
value: 'ffmpeg',
label: 'FFmpeg',
});
});
it('includes the custom entry', () => {
expect(BUILT_IN_COMMANDS).toContainEqual({
value: '__custom__',
label: 'Custom…',
});
});
});
describe('COMMAND_EXAMPLES', () => {
it('has a non-empty string example for ffmpeg', () => {
expect(typeof COMMAND_EXAMPLES.ffmpeg).toBe('string');
expect(COMMAND_EXAMPLES.ffmpeg.length).toBeGreaterThan(0);
});
});
// ── toCommandSelection ─────────────────────────────────────────────────────
describe('toCommandSelection', () => {
it('returns the command value when it matches a non-custom built-in', () => {
expect(toCommandSelection('ffmpeg')).toBe('ffmpeg');
});
it('returns "__custom__" when command is "__custom__"', () => {
// __custom__ is in BUILT_IN_COMMANDS but excluded by the o.value !== '__custom__' guard
expect(toCommandSelection('__custom__')).toBe('__custom__');
});
it('returns "__custom__" for an unrecognized command string', () => {
expect(toCommandSelection('my-arbitrary-tool')).toBe('__custom__');
});
it('returns "__custom__" for an empty string', () => {
expect(toCommandSelection('')).toBe('__custom__');
});
it('returns "__custom__" for undefined', () => {
expect(toCommandSelection(undefined)).toBe('__custom__');
});
});
// ── schema ─────────────────────────────────────────────────────────────────
describe('schema', () => {
it('validates a fully populated object', async () => {
await expect(
schema.validate({
name: 'HD Profile',
command: 'ffmpeg',
parameters: '-c:v copy',
})
).resolves.toMatchObject({
name: 'HD Profile',
command: 'ffmpeg',
parameters: '-c:v copy',
});
});
it('validates when parameters is omitted (optional)', async () => {
await expect(
schema.validate({ name: 'HD Profile', command: 'ffmpeg' })
).resolves.toMatchObject({ name: 'HD Profile', command: 'ffmpeg' });
});
it('rejects when name is missing', async () => {
await expect(schema.validate({ command: 'ffmpeg' })).rejects.toThrow(
'Name is required'
);
});
it('rejects when name is an empty string', async () => {
await expect(
schema.validate({ name: '', command: 'ffmpeg' })
).rejects.toThrow('Name is required');
});
it('rejects when command is missing', async () => {
await expect(schema.validate({ name: 'HD Profile' })).rejects.toThrow(
'Command is required'
);
});
it('rejects when command is an empty string', async () => {
await expect(
schema.validate({ name: 'HD Profile', command: '' })
).rejects.toThrow('Command is required');
});
});
// ── addOutputProfile ───────────────────────────────────────────────────────
describe('addOutputProfile', () => {
it('calls API.addOutputProfile with the provided values', async () => {
const values = { name: 'New Profile', command: 'ffmpeg', parameters: '' };
vi.mocked(API.addOutputProfile).mockResolvedValue({ id: 1, ...values });
await addOutputProfile(values);
expect(API.addOutputProfile).toHaveBeenCalledWith(values);
expect(API.addOutputProfile).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { name: 'New Profile', command: 'ffmpeg' };
const response = { id: 42, ...values };
vi.mocked(API.addOutputProfile).mockResolvedValue(response);
const result = await addOutputProfile(values);
expect(result).toEqual(response);
});
it('propagates errors thrown by API.addOutputProfile', async () => {
vi.mocked(API.addOutputProfile).mockRejectedValue(
new Error('Network error')
);
await expect(addOutputProfile({})).rejects.toThrow('Network error');
});
});
// ── updateOutputProfile ────────────────────────────────────────────────────
describe('updateOutputProfile', () => {
it('calls API.updateOutputProfile with the provided values', async () => {
const values = { id: 1, name: 'Updated Profile', command: 'ffmpeg' };
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
await updateOutputProfile(values);
expect(API.updateOutputProfile).toHaveBeenCalledWith(values);
expect(API.updateOutputProfile).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { id: 7, name: 'Updated Profile', command: 'ffmpeg' };
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
const result = await updateOutputProfile(values);
expect(result).toEqual(values);
});
it('propagates errors thrown by API.updateOutputProfile', async () => {
vi.mocked(API.updateOutputProfile).mockRejectedValue(
new Error('Update failed')
);
await expect(updateOutputProfile({})).rejects.toThrow('Update failed');
});
});
// ── getResolver ────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('returns the result of yupResolver called with schema', () => {
const resolver = getResolver();
expect(yupResolver).toHaveBeenCalledWith(schema);
expect(resolver).toBe(mockResolver);
});
});
});

View file

@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────────────
vi.mock('../../../api', () => ({
default: {
addServerGroup: vi.fn(),
updateServerGroup: vi.fn(),
},
}));
const mockResolver = vi.fn();
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn(() => mockResolver),
}));
// ── Imports after mocks ────────────────────────────────────────────────────────
import API from '../../../api';
import { yupResolver } from '@hookform/resolvers/yup';
import {
getResolver,
addServerGroup,
updateServerGroup,
} from '../ServerGroupUtils';
// ──────────────────────────────────────────────────────────────────────────────
describe('ServerGroupUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(yupResolver).mockReturnValue(mockResolver);
});
// ── getResolver ────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('calls yupResolver with a schema and returns the result', () => {
const resolver = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(1);
expect(yupResolver).toHaveBeenCalledWith(expect.any(Object));
expect(resolver).toBe(mockResolver);
});
it('returns a new resolver on each call', () => {
const resolverA = getResolver();
const resolverB = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(2);
expect(resolverA).toBe(mockResolver);
expect(resolverB).toBe(mockResolver);
});
});
// ── addServerGroup ─────────────────────────────────────────────────────────
describe('addServerGroup', () => {
it('calls API.addServerGroup with the provided values', async () => {
const values = { name: 'US East' };
vi.mocked(API.addServerGroup).mockResolvedValue({ id: 1, ...values });
await addServerGroup(values);
expect(API.addServerGroup).toHaveBeenCalledWith(values);
expect(API.addServerGroup).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { name: 'US East' };
const response = { id: 1, ...values };
vi.mocked(API.addServerGroup).mockResolvedValue(response);
const result = await addServerGroup(values);
expect(result).toEqual(response);
});
it('propagates errors thrown by API.addServerGroup', async () => {
vi.mocked(API.addServerGroup).mockRejectedValue(new Error('Network error'));
await expect(addServerGroup({ name: 'Test' })).rejects.toThrow('Network error');
});
});
// ── updateServerGroup ──────────────────────────────────────────────────────
describe('updateServerGroup', () => {
it('calls API.updateServerGroup with the provided values', async () => {
const values = { id: 5, name: 'EU West' };
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
await updateServerGroup(values);
expect(API.updateServerGroup).toHaveBeenCalledWith(values);
expect(API.updateServerGroup).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { id: 5, name: 'EU West' };
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
const result = await updateServerGroup(values);
expect(result).toEqual(values);
});
it('propagates errors thrown by API.updateServerGroup', async () => {
vi.mocked(API.updateServerGroup).mockRejectedValue(new Error('Update failed'));
await expect(updateServerGroup({ id: 1, name: 'Test' })).rejects.toThrow('Update failed');
});
});
});

View file

@ -1,3 +0,0 @@
export const getEpgSettingsFormInitialValues = () => ({
xmltv_prev_days_override: 0,
});

View file

@ -25,3 +25,12 @@ export const deletePluginByKey = (key) => {
export const getPluginDetailManifest = (repoId, manifestUrl) => {
return API.getPluginDetailManifest(repoId, manifestUrl);
};
export const getPluginRepoSettings = () => {
return API.getPluginRepoSettings();
};
export const updatePluginRepoSettings = (values) => {
return API.updatePluginRepoSettings(values);
};
export const previewPluginRepo = (url, publicKey) => {
return API.previewPluginRepo(url, publicKey);
};

View file

@ -39,7 +39,6 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'epg_match_ignore_prefixes',
'epg_match_ignore_suffixes',
'epg_match_ignore_custom',
'xmltv_prev_days_override',
];
const dvrFields = [
'tv_template',
@ -126,7 +125,6 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'retention_count',
'schedule_day_of_week',
'max_system_events',
'xmltv_prev_days_override',
];
if (numericFields.includes(formKey) && value != null) {
value = typeof value === 'number' ? value : parseInt(value, 10);
@ -224,20 +222,6 @@ export const getChangedSettings = (values, settings) => {
continue;
}
if (settingKey === 'xmltv_prev_days_override') {
const baseline = Number(
settings['epg_settings']?.value?.xmltv_prev_days_override ?? 0,
);
const nextVal =
typeof actualValue === 'number'
? actualValue
: parseInt(actualValue, 10) || 0;
if (nextVal !== baseline) {
changedSettings[settingKey] = nextVal;
}
continue;
}
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
if (Array.isArray(actualValue)) {
actualValue = actualValue.join(',');
@ -312,12 +296,6 @@ export const parseSettings = (settings) => {
epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)
? epgSettings.epg_match_ignore_custom
: [];
parsed.xmltv_prev_days_override =
epgSettings && epgSettings.xmltv_prev_days_override != null
? typeof epgSettings.xmltv_prev_days_override === 'number'
? epgSettings.xmltv_prev_days_override
: parseInt(epgSettings.xmltv_prev_days_override, 10) || 0
: 0;
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;

View file

@ -11,6 +11,9 @@ vi.mock('../../../api.js', () => ({
reloadPlugins: vi.fn(),
deletePlugin: vi.fn(),
getPluginDetailManifest: vi.fn(),
getPluginRepoSettings: vi.fn(),
updatePluginRepoSettings: vi.fn(),
previewPluginRepo: vi.fn(),
},
}));
@ -344,4 +347,86 @@ describe('PluginsUtils', () => {
expect(API.getPluginDetailManifest).toHaveBeenCalledWith(null, null);
});
});
describe('getPluginRepoSettings', () => {
it('should call API getPluginRepoSettings', () => {
PluginsUtils.getPluginRepoSettings();
expect(API.getPluginRepoSettings).toHaveBeenCalledTimes(1);
});
it('should return API response', () => {
const mockResponse = { repos: [] };
API.getPluginRepoSettings.mockReturnValue(mockResponse);
const result = PluginsUtils.getPluginRepoSettings();
expect(result).toEqual(mockResponse);
});
});
describe('updatePluginRepoSettings', () => {
it('should call API updatePluginRepoSettings with values', () => {
const values = { repos: ['https://example.com/repo.json'] };
PluginsUtils.updatePluginRepoSettings(values);
expect(API.updatePluginRepoSettings).toHaveBeenCalledWith(values);
expect(API.updatePluginRepoSettings).toHaveBeenCalledTimes(1);
});
it('should return API response', () => {
const values = { repos: ['https://example.com/repo.json'] };
const mockResponse = { success: true };
API.updatePluginRepoSettings.mockReturnValue(mockResponse);
const result = PluginsUtils.updatePluginRepoSettings(values);
expect(result).toEqual(mockResponse);
});
});
describe('previewPluginRepo', () => {
it('should call API previewPluginRepo with url and publicKey', () => {
const url = 'https://example.com/repo.json';
const publicKey = 'public-key';
PluginsUtils.previewPluginRepo(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledWith(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledTimes(1);
});
it('should return API response', () => {
const url = 'https://example.com/repo.json';
const publicKey = 'public-key';
const mockResponse = { name: 'Test Repo', plugins: [] };
API.previewPluginRepo.mockReturnValue(mockResponse);
const result = PluginsUtils.previewPluginRepo(url, publicKey);
expect(result).toEqual(mockResponse);
});
it('should handle empty string url and publicKey', () => {
const url = '';
const publicKey = '';
PluginsUtils.previewPluginRepo(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledWith('', '');
});
it('should handle null url and publicKey', () => {
const url = null;
const publicKey = null;
PluginsUtils.previewPluginRepo(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledWith(null, null);
});
});
});

View file

@ -10,21 +10,21 @@ export default defineConfig({
server: {
port: 9191,
// proxy: {
// "/api": {
// target: "http://localhost:5656", // Backend server
// changeOrigin: true,
// secure: false, // Set to true if backend uses HTTPS
// // rewrite: (path) => path.replace(/^\/api/, ""), // Optional path rewrite
// },
// "/ws": {
// target: "http://localhost:8001", // Backend server
// changeOrigin: true,
// secure: false, // Set to true if backend uses HTTPS
// // rewrite: (path) => path.replace(/^\/api/, ""), // Optional path rewrite
// },
// },
// Without this, /api/* is served as the React SPA in debug mode and
// Swagger UI at /api/swagger/ never loads the OpenAPI schema.
proxy: {
"/api": {
target: "http://127.0.0.1:5656",
changeOrigin: true,
secure: false,
},
"/ws": {
target: "http://127.0.0.1:8001",
changeOrigin: true,
secure: false,
ws: true,
},
},
},
test: {

View file

@ -0,0 +1,103 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import time
from unittest.mock import patch
from django.test import SimpleTestCase, RequestFactory, override_settings
from apps.api.schema_views import LockedSpectacularAPIView, clear_schema_cache
def _counting_generator(calls):
class CountingGenerator:
def __init__(self, *args, **kwargs):
pass
def get_schema(self, request=None, public=True):
calls["n"] += 1
return {"openapi": "3.0.3", "paths": {}, "info": {"title": "t"}}
return CountingGenerator
@override_settings(
CACHES={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "openapi-schema-tests",
}
}
)
class LockedSpectacularAPIViewTests(SimpleTestCase):
def setUp(self):
clear_schema_cache()
self.factory = RequestFactory()
def tearDown(self):
clear_schema_cache()
def test_schema_cache_is_reused(self):
view = LockedSpectacularAPIView.as_view()
calls = {"n": 0}
with patch.object(
LockedSpectacularAPIView, "generator_class", _counting_generator(calls)
):
for _ in range(3):
response = view(self.factory.get("/api/schema/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(calls["n"], 1)
def test_clear_schema_cache_forces_rebuild(self):
view = LockedSpectacularAPIView.as_view()
calls = {"n": 0}
with patch.object(
LockedSpectacularAPIView, "generator_class", _counting_generator(calls)
):
self.assertEqual(view(self.factory.get("/api/schema/")).status_code, 200)
clear_schema_cache()
self.assertEqual(view(self.factory.get("/api/schema/")).status_code, 200)
self.assertEqual(calls["n"], 2)
def test_concurrent_first_load_uses_single_build(self):
view = LockedSpectacularAPIView.as_view()
call_count = 0
in_flight = 0
max_in_flight = 0
counter_lock = threading.Lock()
started = threading.Barrier(8)
class SlowGenerator:
def __init__(self, *args, **kwargs):
pass
def get_schema(self, request=None, public=True):
nonlocal call_count, in_flight, max_in_flight
with counter_lock:
call_count += 1
in_flight += 1
max_in_flight = max(max_in_flight, in_flight)
time.sleep(0.05)
with counter_lock:
in_flight -= 1
return {"openapi": "3.0.3", "paths": {}, "info": {"title": "t"}}
with patch.object(
LockedSpectacularAPIView, "generator_class", SlowGenerator
):
def hit(_):
started.wait(timeout=5)
response = view(self.factory.get("/api/schema/"))
self.assertEqual(response.status_code, 200)
return response.data
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(hit, i) for i in range(8)]
results = [f.result(timeout=10) for f in as_completed(futures)]
self.assertEqual(call_count, 1)
self.assertEqual(max_in_flight, 1)
self.assertTrue(all(r.get("openapi") == "3.0.3" for r in results))