mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge remote-tracking branch 'upstream/dev' into rewrite-feature
# Conflicts: # apps/output/views.py # frontend/src/utils/pages/SettingsUtils.js
This commit is contained in:
commit
341c133c74
96 changed files with 12085 additions and 2145 deletions
31
.github/workflows/issue-template-check.yml
vendored
Normal file
31
.github/workflows/issue-template-check.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Request a bot user token
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
||||
|
||||
# Do the actual check
|
||||
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
event-type: issue
|
||||
required-type: "Bug, Feature"
|
||||
enforcement: close-and-lock
|
||||
bypass-for-members: true
|
||||
close-comment: |
|
||||
## Issue not opened from a template
|
||||
|
||||
|
||||
This issue was closed because it was not opened using one of the available issue templates.
|
||||
|
||||
Please [open a new issue]({new-issue-url}) and select the appropriate template. This helps us triage and address issues efficiently.
|
||||
65
.github/workflows/pr-compliance-check.yml
vendored
Normal file
65
.github/workflows/pr-compliance-check.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Request a bot user token
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
||||
|
||||
# Delete old bot comments before posting fresh ones
|
||||
- uses: Dispatcharr/repo-bot/actions/comment-collapse@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
mode: delete
|
||||
|
||||
# Template + Agreement Check
|
||||
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
event-type: pull_request
|
||||
required-markers: "## How was it tested?, ## Checklist, - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)"
|
||||
enforcement: comment-only
|
||||
bypass-for-members: true
|
||||
close-comment: |
|
||||
## PR requirements not met
|
||||
|
||||
|
||||
Your PR description is missing one or more required sections. Please ensure all of the following are present and filled out exactly as they appear in the [PR template](../blob/dev/.github/pull_request_template.md).:
|
||||
|
||||
- **How was it tested?** Heading (describe how you verified your changes)
|
||||
- **Checklist** Heading (completed from the [pull request template](../blob/dev/.github/pull_request_template.md))
|
||||
- **Contributor License Agreement** Checklist Item (the following item must appear checked in your description):
|
||||
|
||||
> - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)
|
||||
|
||||
|
||||
Edit your PR description to add any missing items. See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md) for full contribution guidelines. Pull requests that do not follow the template, or that are wholly AI-generated or CLI-created, will be closed.
|
||||
|
||||
# Target Check
|
||||
- uses: Dispatcharr/repo-bot/actions/branch-guard@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
allowed-targets: "dev"
|
||||
enforcement: comment-only
|
||||
bypass-for-members: true
|
||||
comment: |
|
||||
## Wrong target branch
|
||||
|
||||
|
||||
This PR targets `{target-branch}`, but all contributions must target the `dev` branch.
|
||||
|
||||
> **To fix this:**
|
||||
> 1. Open the PR and click **Edit** next to the title
|
||||
> 2. Change the base branch from `{target-branch}` to `dev`
|
||||
> 3. Save the change
|
||||
|
||||
|
||||
Unsure about our contribution guidelines? See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md).
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -7,13 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Public IP display in the sidebar is now blurred by default and reveals on click.** Prevents accidental exposure in screenshots and screen shares. A new toggle in Settings > System Settings lets users disable IP and geolocation fetching entirely. A `DISPATCHARR_ENABLE_IP_LOOKUP` environment variable provides a container-level override; when set to `false` the toggle is hidden from the UI and cannot be changed. (Closes #1302) — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Configurable per-page count and sticky pagination footer in Plugin Browse.** The pagination controls now live in a fixed footer bar at the bottom of the page. A page-size selector (9 / 18 / 27 / 36) sits alongside the pagination widget and an item range readout (`X to Y of Z`). The selected page size is persisted in `localStorage` so it survives page navigations. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **VOD basic sync now stores additional metadata from the provider's stream list.** When a provider includes `director`, `cast`, `release_date`, or `trailer`/`youtube_trailer` in its `get_vod_streams` response, Dispatcharr now captures and stores those fields in `custom_properties` during the initial sync pass. Previously, this data was discarded and only populated if an advanced per-movie refresh was triggered. - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
|
||||
### Changed
|
||||
|
||||
- **IP lookup result delivered via WebSocket push.** When the background lookup completes, an `ip_lookup_complete` event is pushed to all connected clients so the sidebar IP field populates without polling. A `Skeleton` placeholder is shown while the lookup is in progress.
|
||||
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
|
||||
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
|
||||
- **`get_vod_streams` XC API response was missing metadata fields available from basic sync.** When a provider includes `director`, `cast`, `release_date`, `plot`, `genre`, or `year` in its `get_vod_streams` response, Dispatcharr stores those fields during the basic sync pass but was not including them in its own `get_vod_streams` XC output. The endpoint now outputs all six fields. The `trailer` key in the response also mapped to the wrong internal key (`trailer` instead of `youtube_trailer`) following the storage key rename, so the trailer field was always empty until an advanced refresh ran. Both issues are corrected. Users with existing libraries should trigger a VOD provider refresh to populate the missing fields. (Fixes #1228)
|
||||
- **Docker base image now uses a multi-stage build.** The builder stage installs compilers and dev headers (`gcc`, `g++`, `gfortran`, `build-essential`, `libopenblas-dev`, `libpcre3-dev`, `python3.13-dev`, `ninja-build`) to create the virtual environment and compile the legacy NumPy wheel (cpu-baseline=none for old hardware). The final runtime image starts from the same ffmpeg base, copies only the prebuilt venv and wheel from the builder, and installs only the runtime libraries needed at runtime - eliminating compiler binaries from production containers and reducing final image size. — Thanks [@kensac](https://github.com/kensac)
|
||||
- **`libpq-dev` removed from both build and runtime stages.** The project uses `psycopg[binary]` (psycopg3), which bundles its own statically linked copy of libpq inside the wheel. No system libpq headers or shared library are needed at build or runtime.
|
||||
- **Database driver upgraded from `psycopg2` to `psycopg3` (`psycopg[binary]`).** psycopg3 rewrote its network I/O layer in Python, so `gevent`'s `monkey.patch_all()` makes it gevent-cooperative without any additional patching. The `psycogreen` dependency and its driver-patching block in `gevent_patch.py` have been removed. Django's native psycopg3 connection pool is explicitly disabled (`pool: False`) so `django-db-geventpool` remains in sole control of connection lifecycle. The `dropdb` management command was updated to the `psycopg` API (`psycopg.connect`, `psycopg.sql`).
|
||||
- **Frontend unit tests extended to additional form components and the Guide page.** `ProgramRecordingModal`, `Recording`, `RecordingDetailsModal`, `RecurringRuleModal`, `ScheduleInput`, `SeriesRecordingModal`, `SeriesRuleEditorModal`, `Stream`, `StreamProfile`, `SuperuserForm`, `User`, `UserAgent`, and `VODCategoryFilter` now have Vitest + Testing Library test suites. Business logic was extracted from these components into corresponding utility modules and is exercised through the new tests. The Guide page was similarly refactored with extracted utils, and `dateTimeUtils.js` was extended to support the new test coverage. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
|
||||
### Performance
|
||||
|
||||
- **`get_vod_streams` and `get_series` XC API response times reduced significantly for large libraries.** Both endpoints previously loaded all active relations per title (one row per account that carries the same movie/series), then discarded duplicates in Python. With large libraries across multiple active accounts this fetched a multiple of N rows. Both queries now use a single `DISTINCT ON (movie_id/series_id)` pass over the relation table ordered by account priority, returning exactly one row per title. Logo URL construction (`reverse()` + `build_absolute_uri_with_port()`) is also computed once and reused via prefix/suffix string concatenation, matching the existing pattern in `get_live_streams`. The `m3u_account` JOIN in `get_series` was also removed (it was fetched but never read in the loop). Additionally, `get_series` previously had no `order_by` on `M3USeriesRelation`, so output order was non-deterministic (physical storage order). Both endpoints now sort alphabetically by title. Observed improvements: 18s → 12s for 48k movies; 9.5s → 3.5s for 11k series.
|
||||
- **Database connections are now managed by a persistent per-worker pool.** Previously each request opened a fresh TCP connection to PostgreSQL, paid a full authentication handshake, and closed the connection at request end. `django-db-geventpool` now maintains a pool of warm connections per uWSGI worker; requests borrow a connection and return it when done, eliminating connection-setup overhead on every request. Pool size is bounded (`MAX_CONNS=8` per worker, `REUSE_CONNS=3` warm connections kept idle) to stay comfortably within PostgreSQL's default `max_connections=100` across all uWSGI workers, Celery workers, and Daphne. — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
|
||||
### Security
|
||||
|
||||
- **M3U endpoint no longer reflects POST body content in error responses.** The error message for disallowed POST requests previously echoed the raw request body back to the caller in a `text/html` response, which could be used for reflected XSS. The body is no longer included in the response. - Thanks [@sebastiondev](https://github.com/sebastiondev)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **IP lookup no longer blocks the settings page load.** The `environment` endpoint previously made up to three sequential HTTP calls (ipify, ipapi.co, ip-api.com) with 5s timeouts each, blocking the page for up to 15s if any were unreachable. The lookups now run in a background thread on first request and results are cached in Redis for 1 hour, so the endpoint returns immediately on every call. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Authenticated users were not identified in VOD connection cards and stream events when streaming via the web player.** The VOD proxy now accepts a JWT via a `?token=` query parameter so browser `<video>` elements (which cannot send `Authorization` headers) can authenticate. The token is read on the initial request, the resolved user ID is stored in Redis, then retrieved on the actual streaming request after the session redirect. Previously the redirect discarded auth context and all JWT-authenticated VOD sessions were tracked as anonymous. (Fixes #1224)
|
||||
- **Deleting the last playlist (or any playlist) crashed the entire UI.** `removePlaylists()` in the playlists store filtered the `playlists` array but never removed the corresponding entries from the `profiles` map. After deletion, components reading `profiles[deletedId]` received `undefined` and crashed with `undefined is not an object (evaluating 'P[R].name')`, replacing the entire page with an error screen. The profiles map is now cleaned up atomically in the same state update as the playlists array. (Fixes #1269) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Per-channel stream profile override was ignored during streaming.** `Channel.get_stream_profile()` read `self.stream_profile` directly, bypassing any `ChannelOverride.stream_profile` set by the user on auto-synced channels. The method now resolves through `effective_stream_profile_obj`, which checks the channel's override record first and falls back to the channel's own field. Channels without an override continue to behave identically to before. (Fixes #1268) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Web-player output profile was ignored for live streams started outside the Channels page.** `getShowVideoUrl` in `RecordingCardUtils.js` returned a raw proxy URL without calling `buildLiveStreamUrl`, so the output profile preference stored in `localStorage` was not applied when launching a stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, or Recording Card. Only the Channels page (StreamsTable) was calling `buildLiveStreamUrl` correctly. One additional import and one changed return value fix all affected entry points. (Fixes #1304) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Plugins with available updates were not sorting to the top of the Plugin Browse list.** The sort weight function previously treated `update_available` plugins the same as any other installed plugin, leaving them buried. They now receive the highest sort priority (below the search/filter results header) so users can spot pending updates immediately. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Disabled state on the size-labeled install button rendered with a warm tint instead of appearing clearly disabled.** The CSS filter was `brightness(0.65) saturate(0.7)`, which left a faint color cast. It is now `grayscale(1) brightness(0.55)`, matching the standard disabled appearance. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state.
|
||||
- **Cast and actors lists were silently truncated to the first name.** When a provider returns `cast` or `actors` as a JSON array, the helper used during both movie and series basic sync and movie advanced refresh only extracted the first element. All names in the array are now joined into a comma-separated string. Providers that return cast as a plain string are unaffected.
|
||||
|
||||
## [0.25.1] - 2026-05-23
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from django.conf import settings
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from .models import User
|
||||
|
|
@ -84,3 +86,18 @@ class ApiKeyAuthentication(authentication.BaseAuthentication):
|
|||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
|
||||
|
||||
class QueryParamJWTAuthentication(JWTAuthentication):
|
||||
"""Reads a JWT from the `token` query parameter. Used for media endpoints
|
||||
where the browser cannot set Authorization headers (e.g. <video src>)."""
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = request.GET.get('token')
|
||||
if not raw_token:
|
||||
return None
|
||||
try:
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
return self.get_user(validated_token), validated_token
|
||||
except (InvalidToken, TokenError):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ def get_backup_dir() -> Path:
|
|||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
"""Check if we're using PostgreSQL."""
|
||||
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
|
||||
return "postgresql" in settings.DATABASES["default"]["ENGINE"]
|
||||
|
||||
|
||||
def _get_pg_env() -> dict:
|
||||
|
|
@ -171,30 +170,25 @@ def _restore_postgresql(dump_file: Path) -> None:
|
|||
|
||||
|
||||
def _dump_sqlite(output_file: Path) -> None:
|
||||
"""Dump SQLite database using sqlite3 .backup command."""
|
||||
logger.info("Dumping SQLite database with sqlite3 .backup...")
|
||||
import sqlite3 as _sqlite3
|
||||
logger.info("Dumping SQLite database...")
|
||||
db_path = Path(settings.DATABASES["default"]["NAME"])
|
||||
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"SQLite database not found: {db_path}")
|
||||
|
||||
# Use sqlite3 .backup command via stdin for reliable execution
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=f".backup '{output_file}'\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
src = _sqlite3.connect(str(db_path))
|
||||
dst = _sqlite3.connect(str(output_file))
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
src.close()
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 backup failed: {result.stderr}")
|
||||
raise RuntimeError(f"sqlite3 backup failed: {result.stderr}")
|
||||
|
||||
# Verify the backup file was created
|
||||
if not output_file.exists():
|
||||
raise RuntimeError("sqlite3 backup failed: output file not created")
|
||||
raise RuntimeError("SQLite backup failed: output file not created")
|
||||
|
||||
logger.info(f"sqlite3 backup completed successfully: {output_file}")
|
||||
logger.info(f"SQLite backup completed successfully: {output_file}")
|
||||
|
||||
|
||||
def _restore_sqlite(dump_file: Path) -> None:
|
||||
|
|
@ -216,23 +210,20 @@ def _restore_sqlite(dump_file: Path) -> None:
|
|||
# We can simply copy it over the existing database
|
||||
shutil.copy2(dump_file, db_path)
|
||||
|
||||
# Verify the restore worked by checking if sqlite3 can read it
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=".tables\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 verification failed: {result.stderr}")
|
||||
# Try to restore from backup
|
||||
# Verify the restore worked by checking if the file is a readable SQLite database
|
||||
import sqlite3 as _sqlite3
|
||||
try:
|
||||
conn = _sqlite3.connect(str(db_path))
|
||||
conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
conn.close()
|
||||
except _sqlite3.DatabaseError as exc:
|
||||
logger.error(f"SQLite verification failed: {exc}")
|
||||
if backup_current and backup_current.exists():
|
||||
shutil.copy2(backup_current, db_path)
|
||||
logger.info("Restored original database from backup")
|
||||
raise RuntimeError(f"sqlite3 restore verification failed: {result.stderr}")
|
||||
raise RuntimeError(f"SQLite restore verification failed: {exc}") from exc
|
||||
|
||||
logger.info("sqlite3 restore completed successfully")
|
||||
logger.info("SQLite restore completed successfully")
|
||||
|
||||
|
||||
def create_backup() -> Path:
|
||||
|
|
|
|||
|
|
@ -485,7 +485,7 @@ class Channel(models.Model):
|
|||
|
||||
# @TODO: honor stream's stream profile
|
||||
def get_stream_profile(self):
|
||||
stream_profile = self.stream_profile
|
||||
stream_profile = self.effective_stream_profile_obj
|
||||
if not stream_profile:
|
||||
stream_profile = StreamProfile.objects.get(
|
||||
id=CoreSettings.get_default_stream_profile_id()
|
||||
|
|
|
|||
|
|
@ -19,10 +19,9 @@ from apps.epg.serializers import EPGDataSerializer
|
|||
from core.models import StreamProfile
|
||||
from apps.epg.models import EPGData
|
||||
from django.db import connection, transaction
|
||||
from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||
|
||||
|
||||
class LogoSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -57,13 +56,12 @@ class LogoSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def get_cache_url(self, obj):
|
||||
# return f"/api/channels/logos/{obj.id}/cache/"
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[obj.id])
|
||||
)
|
||||
return reverse("api:channels:logo-cache", args=[obj.id])
|
||||
if not request:
|
||||
return f"/api/channels/logos/{obj.id}/cache/"
|
||||
if not hasattr(self, "_cache_url_prefix"):
|
||||
self._cache_url_prefix = build_absolute_uri_with_port(request, "")
|
||||
return f"{self._cache_url_prefix}/api/channels/logos/{obj.id}/cache/"
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get the number of channels using this logo"""
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from apps.m3u.utils import calculate_tuner_count
|
|||
from apps.proxy.utils import get_user_active_connections
|
||||
import regex
|
||||
from core.models import CoreSettings
|
||||
from core.utils import log_system_event
|
||||
from core.utils import log_system_event, build_absolute_uri_with_port
|
||||
import hashlib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -128,7 +128,7 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
# Check if this is a POST request with data (which we don't want to allow)
|
||||
if request.method == "POST" and request.body:
|
||||
if request.body.decode() != '{}':
|
||||
return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode()))
|
||||
return HttpResponseForbidden("POST requests with body are not allowed.")
|
||||
|
||||
if user is not None:
|
||||
if user.user_level < 10:
|
||||
|
|
@ -2717,60 +2717,79 @@ def xc_get_vod_categories(user):
|
|||
|
||||
def xc_get_vod_streams(request, user, category_id=None):
|
||||
"""Get VOD streams (movies) for XtreamCodes API"""
|
||||
from apps.vod.models import Movie, M3UMovieRelation
|
||||
from django.db.models import Prefetch
|
||||
from apps.vod.models import M3UMovieRelation
|
||||
from django.db import connection
|
||||
|
||||
rel_filters = {"m3u_account__is_active": True}
|
||||
if category_id:
|
||||
rel_filters["category_id"] = category_id
|
||||
|
||||
base_qs = (
|
||||
M3UMovieRelation.objects
|
||||
.filter(**rel_filters)
|
||||
.select_related('movie', 'movie__logo', 'category')
|
||||
)
|
||||
|
||||
if connection.vendor == 'postgresql':
|
||||
# DISTINCT ON returns one row per movie (highest-priority active relation)
|
||||
# in a single query. ORDER BY must lead with the DISTINCT field.
|
||||
relations = list(
|
||||
base_qs.order_by('movie_id', '-m3u_account__priority', 'id')
|
||||
.distinct('movie_id')
|
||||
)
|
||||
else:
|
||||
# SQLite fallback: fetch all matching relations, deduplicate in Python.
|
||||
seen: dict = {}
|
||||
for rel in base_qs.order_by('-m3u_account__priority', 'id'):
|
||||
if rel.movie_id not in seen:
|
||||
seen[rel.movie_id] = rel
|
||||
relations = list(seen.values())
|
||||
|
||||
# Precompute logo URL prefix/suffix once (mirrors _xc_live_streams_setup)
|
||||
# so each row only needs a string concat instead of reverse() + URI build.
|
||||
_base_url = build_absolute_uri_with_port(request, "")
|
||||
_sample_logo_path = reverse("api:vod:vodlogo-cache", args=[0])
|
||||
_logo_prefix_raw, _, _logo_suffix_raw = _sample_logo_path.partition("/0/")
|
||||
_logo_url_prefix = _base_url + _logo_prefix_raw + "/"
|
||||
_logo_url_suffix = "/" + _logo_suffix_raw
|
||||
|
||||
# Sort by name (DISTINCT ON forces ORDER BY movie_id; SQLite path is unsorted).
|
||||
relations.sort(key=lambda r: (r.movie.name or "").lower())
|
||||
|
||||
streams = []
|
||||
append = streams.append
|
||||
for num, relation in enumerate(relations, 1):
|
||||
movie = relation.movie
|
||||
custom_props = movie.custom_properties or {}
|
||||
category = relation.category
|
||||
category_id_str = str(category.id) if category else "0"
|
||||
category_id_list = [category.id] if category else []
|
||||
rating = movie.rating
|
||||
logo = movie.logo
|
||||
|
||||
# All authenticated users get access to VOD from all active M3U accounts
|
||||
filters = {"m3u_relations__m3u_account__is_active": True}
|
||||
|
||||
if category_id:
|
||||
filters["m3u_relations__category_id"] = category_id
|
||||
|
||||
# Optimize with prefetch_related to eliminate N+1 queries
|
||||
# This loads all relations in a single query instead of one per movie
|
||||
movies = Movie.objects.filter(**filters).select_related('logo').prefetch_related(
|
||||
Prefetch(
|
||||
'm3u_relations',
|
||||
queryset=M3UMovieRelation.objects.filter(
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account', 'category').order_by('-m3u_account__priority', 'id'),
|
||||
to_attr='active_relations'
|
||||
)
|
||||
).distinct()
|
||||
|
||||
for movie in movies:
|
||||
# Get the first (highest priority) relation from prefetched data
|
||||
# This avoids the N+1 query problem entirely
|
||||
if hasattr(movie, 'active_relations') and movie.active_relations:
|
||||
relation = movie.active_relations[0]
|
||||
else:
|
||||
# Fallback - should rarely be needed with proper prefetching
|
||||
continue
|
||||
|
||||
streams.append({
|
||||
"num": movie.id,
|
||||
append({
|
||||
"num": num,
|
||||
"name": movie.name,
|
||||
"stream_type": "movie",
|
||||
"stream_id": movie.id,
|
||||
"stream_icon": (
|
||||
None if not movie.logo
|
||||
else build_absolute_uri_with_port(
|
||||
request,
|
||||
reverse("api:vod:vodlogo-cache", args=[movie.logo.id])
|
||||
)
|
||||
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
|
||||
),
|
||||
#'stream_icon': movie.logo.url if movie.logo else '',
|
||||
"rating": movie.rating or "0",
|
||||
"rating_5based": round(float(movie.rating or 0) / 2, 2) if movie.rating else 0,
|
||||
"rating": rating or "0",
|
||||
"rating_5based": round(float(rating or 0) / 2, 2) if rating else 0,
|
||||
"added": str(int(movie.created_at.timestamp())),
|
||||
"is_adult": 0,
|
||||
"tmdb_id": movie.tmdb_id or "",
|
||||
"imdb_id": movie.imdb_id or "",
|
||||
"trailer": (movie.custom_properties or {}).get('trailer') or "",
|
||||
"category_id": str(relation.category.id) if relation.category else "0",
|
||||
"category_ids": [int(relation.category.id)] if relation.category else [],
|
||||
"trailer": custom_props.get('youtube_trailer') or "",
|
||||
"plot": movie.description or "",
|
||||
"genre": movie.genre or "",
|
||||
"year": movie.year or "",
|
||||
"director": custom_props.get('director', ''),
|
||||
"cast": custom_props.get('actors', ''),
|
||||
"release_date": custom_props.get('release_date', ''),
|
||||
"category_id": category_id_str,
|
||||
"category_ids": category_id_list,
|
||||
"container_extension": relation.container_extension or "mp4",
|
||||
"custom_sid": None,
|
||||
"direct_source": "",
|
||||
|
|
@ -2804,47 +2823,70 @@ def xc_get_series_categories(user):
|
|||
def xc_get_series(request, user, category_id=None):
|
||||
"""Get series list for XtreamCodes API"""
|
||||
from apps.vod.models import M3USeriesRelation
|
||||
from django.db import connection
|
||||
|
||||
series_list = []
|
||||
|
||||
# All authenticated users get access to series from all active M3U accounts
|
||||
filters = {"m3u_account__is_active": True}
|
||||
|
||||
rel_filters = {"m3u_account__is_active": True}
|
||||
if category_id:
|
||||
filters["category_id"] = category_id
|
||||
rel_filters["category_id"] = category_id
|
||||
|
||||
# Get series relations instead of series directly
|
||||
series_relations = M3USeriesRelation.objects.filter(**filters).select_related(
|
||||
'series', 'series__logo', 'category', 'm3u_account'
|
||||
base_qs = (
|
||||
M3USeriesRelation.objects
|
||||
.filter(**rel_filters)
|
||||
.select_related('series', 'series__logo', 'category')
|
||||
)
|
||||
|
||||
for relation in series_relations:
|
||||
if connection.vendor == 'postgresql':
|
||||
relations = list(
|
||||
base_qs.order_by('series_id', '-m3u_account__priority', 'id')
|
||||
.distinct('series_id')
|
||||
)
|
||||
else:
|
||||
seen: dict = {}
|
||||
for rel in base_qs.order_by('-m3u_account__priority', 'id'):
|
||||
if rel.series_id not in seen:
|
||||
seen[rel.series_id] = rel
|
||||
relations = list(seen.values())
|
||||
|
||||
_base_url = build_absolute_uri_with_port(request, "")
|
||||
_sample_logo_path = reverse("api:vod:vodlogo-cache", args=[0])
|
||||
_logo_prefix_raw, _, _logo_suffix_raw = _sample_logo_path.partition("/0/")
|
||||
_logo_url_prefix = _base_url + _logo_prefix_raw + "/"
|
||||
_logo_url_suffix = "/" + _logo_suffix_raw
|
||||
|
||||
relations.sort(key=lambda r: (r.series.name or "").lower())
|
||||
|
||||
series_list = []
|
||||
append = series_list.append
|
||||
for num, relation in enumerate(relations, 1):
|
||||
series = relation.series
|
||||
series_list.append({
|
||||
"num": relation.id, # Use relation ID
|
||||
custom_props = series.custom_properties or {}
|
||||
category = relation.category
|
||||
rating = series.rating
|
||||
logo = series.logo
|
||||
year_str = str(series.year) if series.year else ""
|
||||
release_date = custom_props.get('release_date', year_str)
|
||||
|
||||
append({
|
||||
"num": num,
|
||||
"name": series.name,
|
||||
"series_id": relation.id, # Use relation ID
|
||||
"series_id": relation.id,
|
||||
"cover": (
|
||||
None if not series.logo
|
||||
else build_absolute_uri_with_port(
|
||||
request,
|
||||
reverse("api:vod:vodlogo-cache", args=[series.logo.id])
|
||||
)
|
||||
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
|
||||
),
|
||||
"plot": series.description or "",
|
||||
"cast": series.custom_properties.get('cast', '') if series.custom_properties else "",
|
||||
"director": series.custom_properties.get('director', '') if series.custom_properties else "",
|
||||
"cast": custom_props.get('cast', ''),
|
||||
"director": custom_props.get('director', ''),
|
||||
"genre": series.genre or "",
|
||||
"release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""),
|
||||
"releaseDate": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""),
|
||||
"release_date": release_date,
|
||||
"releaseDate": release_date,
|
||||
"last_modified": str(int(relation.updated_at.timestamp())),
|
||||
"rating": str(series.rating or "0"),
|
||||
"rating_5based": str(round(float(series.rating or 0) / 2, 2)) if series.rating else "0",
|
||||
"backdrop_path": series.custom_properties.get('backdrop_path', []) if series.custom_properties else [],
|
||||
"youtube_trailer": series.custom_properties.get('youtube_trailer', '') if series.custom_properties else "",
|
||||
"episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "",
|
||||
"category_id": str(relation.category.id) if relation.category else "0",
|
||||
"category_ids": [int(relation.category.id)] if relation.category else [],
|
||||
"rating": str(rating or "0"),
|
||||
"rating_5based": str(round(float(rating or 0) / 2, 2)) if rating else "0",
|
||||
"backdrop_path": custom_props.get('backdrop_path', []),
|
||||
"youtube_trailer": custom_props.get('youtube_trailer', ''),
|
||||
"episode_run_time": custom_props.get('episode_run_time', ''),
|
||||
"category_id": str(category.id) if category else "0",
|
||||
"category_ids": [category.id] if category else [],
|
||||
"tmdb_id": series.tmdb_id or "",
|
||||
"imdb_id": series.imdb_id or "",
|
||||
})
|
||||
|
|
@ -3277,87 +3319,6 @@ def xc_series_stream(request, username, password, stream_id, extension):
|
|||
return HttpResponseRedirect(vod_url)
|
||||
|
||||
|
||||
def get_host_and_port(request):
|
||||
"""
|
||||
Returns (host, port) for building absolute URIs.
|
||||
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
|
||||
- Falls back to Host header.
|
||||
- Returns None for port if using standard ports (80/443) to omit from URLs.
|
||||
- In dev, uses 5656 as a guess if port cannot be determined.
|
||||
"""
|
||||
# Determine the scheme first - needed for standard port detection
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
standard_port = "443" if scheme == "https" else "80"
|
||||
|
||||
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
|
||||
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
|
||||
if xfh:
|
||||
if ":" in xfh:
|
||||
host, port = xfh.split(":", 1)
|
||||
# Omit standard ports from URLs
|
||||
if port == standard_port:
|
||||
return host, None
|
||||
# Non-standard port in X-Forwarded-Host - return it
|
||||
# This handles reverse proxies on non-standard ports (e.g., https://example.com:8443)
|
||||
return host, port
|
||||
else:
|
||||
host = xfh
|
||||
|
||||
# Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
# If X-Forwarded-Proto is set but no valid port, assume standard
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO"):
|
||||
return host, None
|
||||
|
||||
# 2. Try Host header
|
||||
raw_host = request.get_host()
|
||||
if ":" in raw_host:
|
||||
host, port = raw_host.split(":", 1)
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
else:
|
||||
host = raw_host
|
||||
|
||||
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present)
|
||||
# If so, assume standard port for the scheme (don't trust SERVER_PORT in this case)
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
|
||||
return host, None
|
||||
|
||||
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
|
||||
port = request.META.get("SERVER_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 6. Dev fallback: guess port 5656
|
||||
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
|
||||
return host, "5656"
|
||||
|
||||
# 7. Final fallback: assume standard port for scheme (omit from URL)
|
||||
return host, None
|
||||
|
||||
def build_absolute_uri_with_port(request, path):
|
||||
"""
|
||||
Build an absolute URI with optional port.
|
||||
Port is omitted from URL if None (standard port for scheme).
|
||||
"""
|
||||
host, port = get_host_and_port(request)
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
|
||||
if port:
|
||||
return f"{scheme}://{host}:{port}{path}"
|
||||
else:
|
||||
return f"{scheme}://{host}{path}"
|
||||
|
||||
def format_duration_hms(seconds):
|
||||
"""
|
||||
Format a duration in seconds as HH:MM:SS zero-padded string.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import time
|
|||
import random
|
||||
import logging
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from django.http import JsonResponse, Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
|
@ -14,10 +15,12 @@ from apps.vod.models import Movie, Series, Episode
|
|||
from apps.m3u.models import M3UAccountProfile
|
||||
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key
|
||||
from .utils import get_client_info
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.permissions import IsAdmin
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
|
||||
from apps.proxy.utils import check_user_stream_limits
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
|
|
@ -293,6 +296,7 @@ def _transform_url(original_url, m3u_profile):
|
|||
return original_url
|
||||
|
||||
@api_view(["GET"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None):
|
||||
"""
|
||||
|
|
@ -306,7 +310,8 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
"""
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
if user is None and hasattr(request, "user") and request.user.is_authenticated:
|
||||
user = request.user
|
||||
logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}")
|
||||
logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}")
|
||||
logger.info(f"[VOD-REQUEST] Request method: {request.method}")
|
||||
|
|
@ -384,39 +389,66 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||
logger.info(f"[VOD-SESSION] Creating new session: {new_session_id}")
|
||||
|
||||
# Preserve any query parameters (except session_id)
|
||||
# Preserve any query parameters (except session_id and token)
|
||||
query_params = dict(request.GET)
|
||||
query_params.pop('session_id', None) # Remove if present
|
||||
query_params.pop('session_id', None)
|
||||
query_params.pop('token', None) # Token not needed after session is established
|
||||
|
||||
if user:
|
||||
redirect_url = f"{request.path}?session_id={new_session_id}"
|
||||
if query_params:
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{redirect_url}&{query_string}"
|
||||
else:
|
||||
# Build redirect URL with session ID in path, preserve query parameters
|
||||
# The VOD proxy URL patterns accept session_id in the path, so we redirect
|
||||
# to a path-based URL. XC endpoints (/movie/<user>/<pass>/<id>.<ext>) have
|
||||
# a fixed shape and instead read session_id from a query parameter.
|
||||
is_vod_proxy_path = request.path.startswith('/proxy/vod/')
|
||||
|
||||
if is_vod_proxy_path:
|
||||
path_parts = request.path.rstrip('/').split('/')
|
||||
|
||||
# Construct new path: /vod/movie/UUID/SESSION_ID or /vod/movie/UUID/SESSION_ID/PROFILE_ID/
|
||||
if profile_id:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/"
|
||||
else:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}"
|
||||
|
||||
if query_params:
|
||||
from urllib.parse import urlencode
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{new_path}?{query_string}"
|
||||
else:
|
||||
redirect_url = new_path
|
||||
else:
|
||||
# XC path: keep the original path, put session_id in the query string
|
||||
query_params['session_id'] = new_session_id
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{request.path}?{query_string}"
|
||||
|
||||
logger.info(f"[VOD-SESSION] Redirecting to path-based URL: {redirect_url}")
|
||||
|
||||
# Persist the authenticated user to Redis so the streaming request
|
||||
# (which arrives without the token after the redirect) can resolve it.
|
||||
if user:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r:
|
||||
_r.set(f"vod_session_user:{new_session_id}", user.id, ex=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return HttpResponse(
|
||||
status=301,
|
||||
headers={'Location': redirect_url}
|
||||
)
|
||||
|
||||
# Resolve user from Redis session mapping when the streaming request
|
||||
# arrives without auth credentials (token was stripped from redirect URL).
|
||||
# Only needed on the first streaming request - skip if connection already exists.
|
||||
if user is None:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r and not _r.exists(f"vod_persistent_connection:{session_id}"):
|
||||
stored_uid = _r.get(f"vod_session_user:{session_id}")
|
||||
if stored_uid:
|
||||
user = User.objects.filter(id=int(stored_uid)).first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if user:
|
||||
if not check_user_stream_limits(user, session_id, media_id=content_id):
|
||||
return JsonResponse(
|
||||
|
|
@ -506,6 +538,7 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
return HttpResponse(f"Streaming error: {str(e)}", status=500)
|
||||
|
||||
@api_view(["HEAD"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def head_vod(request, content_type, content_id, session_id=None, profile_id=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -120,11 +120,30 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
"""Get detailed movie information from the original provider, throttled to 24h."""
|
||||
movie = self.get_object()
|
||||
|
||||
# Get the highest priority active relation
|
||||
relation = M3UMovieRelation.objects.filter(
|
||||
relation_id = request.query_params.get('relation_id')
|
||||
if relation_id is not None:
|
||||
try:
|
||||
relation_id = int(relation_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'error': 'Invalid relation_id'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
qs = M3UMovieRelation.objects.filter(
|
||||
movie=movie,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first()
|
||||
).select_related('m3u_account')
|
||||
|
||||
if relation_id is not None:
|
||||
relation = qs.filter(id=relation_id).first()
|
||||
if not relation:
|
||||
return Response(
|
||||
{'error': 'Relation not found or not active'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
else:
|
||||
relation = qs.order_by('-m3u_account__priority', 'id').first()
|
||||
|
||||
if not relation:
|
||||
return Response(
|
||||
|
|
@ -326,11 +345,30 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
series = self.get_object()
|
||||
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})")
|
||||
|
||||
# Get the highest priority active relation
|
||||
relation = M3USeriesRelation.objects.filter(
|
||||
relation_id = request.query_params.get('relation_id')
|
||||
if relation_id is not None:
|
||||
try:
|
||||
relation_id = int(relation_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'error': 'Invalid relation_id'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
qs = M3USeriesRelation.objects.filter(
|
||||
series=series,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first()
|
||||
).select_related('m3u_account')
|
||||
|
||||
if relation_id is not None:
|
||||
relation = qs.filter(id=relation_id).first()
|
||||
if not relation:
|
||||
return Response(
|
||||
{'error': 'Relation not found or not active'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
else:
|
||||
relation = qs.order_by('-m3u_account__priority', 'id').first()
|
||||
|
||||
if not relation:
|
||||
return Response(
|
||||
|
|
|
|||
|
|
@ -439,6 +439,26 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
trailer = extract_string_from_array_or_string(trailer_raw) if trailer_raw else None
|
||||
logo_url = movie_data.get('stream_icon') or ''
|
||||
|
||||
director = extract_string_from_array_or_string(
|
||||
movie_data.get('director') or ''
|
||||
)
|
||||
actors_raw = movie_data.get('actors') or movie_data.get('cast') or ''
|
||||
if isinstance(actors_raw, list):
|
||||
actors = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
|
||||
else:
|
||||
actors = actors_raw.strip() if actors_raw else None
|
||||
release_date = movie_data.get('release_date') or movie_data.get('releasedate') or ''
|
||||
|
||||
custom_props = {}
|
||||
if trailer:
|
||||
custom_props['youtube_trailer'] = trailer
|
||||
if director:
|
||||
custom_props['director'] = director
|
||||
if actors:
|
||||
custom_props['actors'] = actors
|
||||
if release_date:
|
||||
custom_props['release_date'] = release_date
|
||||
|
||||
movie_props = {
|
||||
'name': name,
|
||||
'year': year,
|
||||
|
|
@ -448,7 +468,7 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
'rating': rating,
|
||||
'genre': genre,
|
||||
'duration_secs': duration_secs,
|
||||
'custom_properties': {'trailer': trailer} if trailer else None,
|
||||
'custom_properties': custom_props or None,
|
||||
}
|
||||
|
||||
movie_keys[movie_key] = {
|
||||
|
|
@ -548,8 +568,18 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
|
||||
for field, value in movie_props.items():
|
||||
if field == 'custom_properties':
|
||||
if value != movie.custom_properties:
|
||||
movie.custom_properties = value
|
||||
# Merge: preserve advanced-refresh keys; don't overwrite director/actors/release_date if already set.
|
||||
existing_cp = movie.custom_properties or {}
|
||||
incoming_cp = value or {}
|
||||
merged = dict(existing_cp)
|
||||
for k, v in incoming_cp.items():
|
||||
if k in ('director', 'actors', 'release_date'):
|
||||
if not existing_cp.get(k):
|
||||
merged[k] = v
|
||||
else:
|
||||
merged[k] = v
|
||||
if merged != existing_cp:
|
||||
movie.custom_properties = merged
|
||||
updated = True
|
||||
elif getattr(movie, field) != value:
|
||||
setattr(movie, field, value)
|
||||
|
|
@ -564,8 +594,6 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
movie._logo_to_update = new_logo
|
||||
logo_updated = True
|
||||
elif movie.logo_id:
|
||||
# Logo URL exists but logo creation failed or logo not found
|
||||
# Clear the orphaned logo reference
|
||||
logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference")
|
||||
movie._logo_to_update = None
|
||||
logo_updated = True
|
||||
|
|
@ -784,7 +812,14 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
|
|||
value = series_data.get(key)
|
||||
if value:
|
||||
# For string-like fields that might be arrays, extract clean strings
|
||||
if key in ['poster_path', 'youtube_trailer', 'cast', 'director']:
|
||||
if key == 'cast':
|
||||
if isinstance(value, list):
|
||||
clean_value = ', '.join(s.strip() for s in value if s and str(s).strip()) or None
|
||||
else:
|
||||
clean_value = extract_string_from_array_or_string(value)
|
||||
if clean_value:
|
||||
additional_metadata[key] = clean_value
|
||||
elif key in ['poster_path', 'youtube_trailer', 'director']:
|
||||
clean_value = extract_string_from_array_or_string(value)
|
||||
if clean_value:
|
||||
additional_metadata[key] = clean_value
|
||||
|
|
@ -2156,26 +2191,25 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False):
|
|||
movie.imdb_id = imdb_id_to_set
|
||||
updated = True
|
||||
logger.debug(f"Set imdb_id {imdb_id_to_set} on movie {movie.id}")
|
||||
# Only update trailer if we have a non-empty value and either no existing value or existing value is empty
|
||||
if should_update_field(custom_props.get('youtube_trailer'), info.get('trailer')):
|
||||
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('trailer'))
|
||||
updated = True
|
||||
if should_update_field(custom_props.get('youtube_trailer'), info.get('youtube_trailer')):
|
||||
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('youtube_trailer'))
|
||||
updated = True
|
||||
# Only update backdrop_path if we have a non-empty value and either no existing value or existing value is empty
|
||||
if should_update_field(custom_props.get('backdrop_path'), info.get('backdrop_path')):
|
||||
backdrop_url = extract_string_from_array_or_string(info.get('backdrop_path'))
|
||||
custom_props['backdrop_path'] = [backdrop_url] if backdrop_url else None
|
||||
updated = True
|
||||
# Only update actors if we have a non-empty value and either no existing value or existing value is empty
|
||||
if should_update_field(custom_props.get('actors'), info.get('actors')):
|
||||
custom_props['actors'] = extract_string_from_array_or_string(info.get('actors'))
|
||||
updated = True
|
||||
if should_update_field(custom_props.get('actors'), info.get('cast')):
|
||||
custom_props['actors'] = extract_string_from_array_or_string(info.get('cast'))
|
||||
updated = True
|
||||
# Only update director if we have a non-empty value and either no existing value or existing value is empty
|
||||
for actors_key in ('actors', 'cast'):
|
||||
actors_raw = info.get(actors_key)
|
||||
if should_update_field(custom_props.get('actors'), actors_raw):
|
||||
if isinstance(actors_raw, list):
|
||||
custom_props['actors'] = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
|
||||
else:
|
||||
custom_props['actors'] = extract_string_from_array_or_string(actors_raw)
|
||||
updated = True
|
||||
break
|
||||
if should_update_field(custom_props.get('director'), info.get('director')):
|
||||
custom_props['director'] = extract_string_from_array_or_string(info.get('director'))
|
||||
updated = True
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# core/api_views.py
|
||||
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
from django.conf import settings as django_settings
|
||||
|
|
@ -8,11 +7,9 @@ from django.db import models
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from .models import (
|
||||
UserAgent,
|
||||
StreamProfile,
|
||||
|
|
@ -32,8 +29,10 @@ from .serializers import (
|
|||
)
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import requests
|
||||
import os
|
||||
from django.core.cache import cache
|
||||
from core.tasks import rehash_streams
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
|
|
@ -287,6 +286,73 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
|||
|
||||
|
||||
|
||||
_IP_CACHE_KEY = "dispatcharr:ip_lookup_result"
|
||||
_IP_CACHE_TTL = 3600 # 1 hour
|
||||
_IP_LOCK_KEY = "dispatcharr:ip_lookup_lock"
|
||||
|
||||
|
||||
def _perform_ip_lookup():
|
||||
"""Run IP and geolocation lookups in a background thread and cache the result."""
|
||||
public_ip = None
|
||||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
city = None
|
||||
|
||||
try:
|
||||
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
|
||||
r.raise_for_status()
|
||||
public_ip = r.json().get("ip")
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("203.0.113.1", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(public_ip)
|
||||
except (ValueError, TypeError):
|
||||
public_ip = None
|
||||
|
||||
if public_ip:
|
||||
try:
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code")
|
||||
country_name = geo.get("country_name")
|
||||
city = geo.get("city")
|
||||
else:
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode")
|
||||
country_name = geo.get("country")
|
||||
city = geo.get("city")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
|
||||
result = {
|
||||
"public_ip": public_ip,
|
||||
"local_ip": local_ip,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"city": city,
|
||||
}
|
||||
cache.set(_IP_CACHE_KEY, result, _IP_CACHE_TTL)
|
||||
cache.delete(_IP_LOCK_KEY)
|
||||
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update("updates", "update", {"type": "ip_lookup_complete", **result})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
description="Endpoint for environment details",
|
||||
)
|
||||
|
|
@ -297,55 +363,27 @@ def environment(request):
|
|||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
city = None
|
||||
ip_lookup_pending = False
|
||||
|
||||
# 1) Get the public IP from ipify.org API
|
||||
try:
|
||||
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
|
||||
r.raise_for_status()
|
||||
public_ip = r.json().get("ip")
|
||||
except requests.RequestException as e:
|
||||
public_ip = f"Error: {e}"
|
||||
ip_lookup_env_disabled = not getattr(django_settings, "ENABLE_IP_LOOKUP", True)
|
||||
ip_lookup_db_enabled = CoreSettings.get_system_settings().get("enable_ip_lookup", True)
|
||||
ip_lookup_enabled = not ip_lookup_env_disabled and ip_lookup_db_enabled
|
||||
|
||||
# 2) Get the local IP by connecting to a public DNS server
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# connect to a "public" address so the OS can determine our local interface
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception as e:
|
||||
local_ip = f"Error: {e}"
|
||||
if ip_lookup_enabled:
|
||||
cached = cache.get(_IP_CACHE_KEY)
|
||||
if cached is not None:
|
||||
public_ip = cached.get("public_ip")
|
||||
local_ip = cached.get("local_ip")
|
||||
country_code = cached.get("country_code")
|
||||
country_name = cached.get("country_name")
|
||||
city = cached.get("city")
|
||||
else:
|
||||
if cache.add(_IP_LOCK_KEY, True, 30):
|
||||
threading.Thread(target=_perform_ip_lookup, daemon=True).start()
|
||||
ip_lookup_pending = True
|
||||
|
||||
# 3) Get geolocation data from ipapi.co or ip-api.com
|
||||
if public_ip and "Error" not in public_ip:
|
||||
try:
|
||||
# Attempt to get geo information from ipapi.co first
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code") # e.g. "US"
|
||||
country_name = geo.get("country_name") # e.g. "United States"
|
||||
|
||||
else:
|
||||
# If ipapi.co fails, fallback to ip-api.com
|
||||
# only supports http requests for free tier
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode") # e.g. "US"
|
||||
country_name = geo.get("country") # e.g. "United States"
|
||||
|
||||
else:
|
||||
raise Exception("Geo lookup failed with both services")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
country_code = None
|
||||
country_name = None
|
||||
|
||||
# 4) Get environment mode and TLS status from settings
|
||||
# Get environment mode and TLS status from settings
|
||||
postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False)
|
||||
|
||||
return Response(
|
||||
|
|
@ -355,6 +393,10 @@ def environment(request):
|
|||
"local_ip": local_ip,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"city": city,
|
||||
"ip_lookup_enabled": ip_lookup_enabled,
|
||||
"ip_lookup_env_disabled": ip_lookup_env_disabled,
|
||||
"ip_lookup_pending": ip_lookup_pending,
|
||||
"env_mode": os.getenv("DISPATCHARR_ENV", "aio"),
|
||||
"redis_tls": {
|
||||
"enabled": getattr(django_settings, "REDIS_SSL", False),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import sys
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
|
|
@ -38,8 +38,7 @@ class Command(BaseCommand):
|
|||
maintenance_db = 'postgres'
|
||||
try:
|
||||
self.stdout.write("Connecting to maintenance database...")
|
||||
conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, **ssl_kwargs)
|
||||
conn.autocommit = True
|
||||
conn = psycopg.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, autocommit=True, **ssl_kwargs)
|
||||
cur = conn.cursor()
|
||||
self.stdout.write(f"Dropping database '{db_name}'...")
|
||||
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ class CoreSettings(models.Model):
|
|||
"max_system_events": 100,
|
||||
"preferred_region": None,
|
||||
"auto_import_mapped_files": True,
|
||||
"enable_ip_lookup": True,
|
||||
})
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ class EpgIgnoreListsTest(TestCase):
|
|||
|
||||
|
||||
class DropDBCommandTlsTest(TestCase):
|
||||
"""Verify dropdb management command passes TLS parameters to psycopg2."""
|
||||
"""Verify dropdb management command passes TLS parameters to psycopg."""
|
||||
databases = []
|
||||
|
||||
_DB_WITH_TLS = {
|
||||
|
|
@ -184,7 +184,7 @@ class DropDBCommandTlsTest(TestCase):
|
|||
}
|
||||
}
|
||||
|
||||
@patch('core.management.commands.dropdb.psycopg2.connect')
|
||||
@patch('core.management.commands.dropdb.psycopg.connect')
|
||||
@patch('core.management.commands.dropdb.connection')
|
||||
@patch('builtins.input', return_value='yes')
|
||||
def test_dropdb_passes_ssl_kwargs_when_tls_enabled(self, _inp, _conn, mock_connect):
|
||||
|
|
@ -199,13 +199,14 @@ class DropDBCommandTlsTest(TestCase):
|
|||
mock_connect.assert_called_once_with(
|
||||
dbname='postgres', user='testuser', password='testpass',
|
||||
host='localhost', port=5432,
|
||||
autocommit=True,
|
||||
sslmode='verify-full',
|
||||
sslrootcert='/certs/ca.crt',
|
||||
sslcert='/certs/client.crt',
|
||||
sslkey='/certs/client.key',
|
||||
)
|
||||
|
||||
@patch('core.management.commands.dropdb.psycopg2.connect')
|
||||
@patch('core.management.commands.dropdb.psycopg.connect')
|
||||
@patch('core.management.commands.dropdb.connection')
|
||||
@patch('builtins.input', return_value='yes')
|
||||
def test_dropdb_no_ssl_kwargs_when_tls_disabled(self, _inp, _conn, mock_connect):
|
||||
|
|
@ -220,4 +221,5 @@ class DropDBCommandTlsTest(TestCase):
|
|||
mock_connect.assert_called_once_with(
|
||||
dbname='postgres', user='testuser', password='testpass',
|
||||
host='localhost', port=5432,
|
||||
autocommit=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -752,6 +752,76 @@ def send_websocket_notification(notification):
|
|||
logger.error(f"Failed to send WebSocket notification: {e}")
|
||||
|
||||
|
||||
def get_host_and_port(request):
|
||||
"""
|
||||
Returns (host, port) for building absolute URIs.
|
||||
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
|
||||
- Falls back to Host header.
|
||||
- Returns None for port if using standard ports (80/443) to omit from URLs.
|
||||
- In dev, uses 5656 as a guess if port cannot be determined.
|
||||
"""
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
standard_port = "443" if scheme == "https" else "80"
|
||||
|
||||
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
|
||||
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
|
||||
if xfh:
|
||||
if ":" in xfh:
|
||||
host, port = xfh.split(":", 1)
|
||||
if port == standard_port:
|
||||
return host, None
|
||||
return host, port
|
||||
else:
|
||||
host = xfh
|
||||
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO"):
|
||||
return host, None
|
||||
|
||||
# 2. Try Host header
|
||||
raw_host = request.get_host()
|
||||
if ":" in raw_host:
|
||||
host, port = raw_host.split(":", 1)
|
||||
return host, None if port == standard_port else port
|
||||
else:
|
||||
host = raw_host
|
||||
|
||||
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 4. Behind a reverse proxy with no port info - assume standard port
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
|
||||
return host, None
|
||||
|
||||
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
|
||||
port = request.META.get("SERVER_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 6. Dev fallback
|
||||
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
|
||||
return host, "5656"
|
||||
|
||||
# 7. Final fallback: assume standard port for scheme
|
||||
return host, None
|
||||
|
||||
|
||||
def build_absolute_uri_with_port(request, path):
|
||||
"""
|
||||
Build an absolute URI with optional port.
|
||||
Port is omitted from URL if None (standard port for scheme).
|
||||
"""
|
||||
host, port = get_host_and_port(request)
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
if port:
|
||||
return f"{scheme}://{host}:{port}{path}"
|
||||
return f"{scheme}://{host}{path}"
|
||||
|
||||
|
||||
def send_notification_dismissed(notification_key):
|
||||
"""
|
||||
Notify all connected clients that a notification was dismissed.
|
||||
|
|
|
|||
|
|
@ -64,11 +64,9 @@ configure_variables() {
|
|||
POSTGRES_PASSWORD="secret"
|
||||
NGINX_HTTP_PORT="9191"
|
||||
WEBSOCKET_PORT="8001"
|
||||
GUNICORN_RUNTIME_DIR="dispatcharr"
|
||||
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
|
||||
PYTHON_BIN=$(command -v python3)
|
||||
UWSGI_RUNTIME_DIR="dispatcharr"
|
||||
UWSGI_SOCKET="/run/${UWSGI_RUNTIME_DIR}/dispatcharr.sock"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
NGINX_SITE="/etc/nginx/sites-available/dispatcharr"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
|
|
@ -79,8 +77,8 @@ install_packages() {
|
|||
echo ">>> Installing system packages..."
|
||||
apt-get update
|
||||
declare -a packages=(
|
||||
git curl wget build-essential gcc libpq-dev
|
||||
python3-dev python3-venv python3-pip nginx redis-server
|
||||
git curl wget build-essential gcc libpq-dev libpcre3-dev
|
||||
nginx redis-server
|
||||
postgresql postgresql-contrib ffmpeg procps streamlink
|
||||
sudo
|
||||
)
|
||||
|
|
@ -113,6 +111,11 @@ create_dispatcharr_user() {
|
|||
##############################################################################
|
||||
|
||||
setup_postgresql() {
|
||||
echo ">>> Waiting for PostgreSQL to accept connections..."
|
||||
until pg_isready -h /var/run/postgresql >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ">>> Checking PostgreSQL database and user..."
|
||||
|
||||
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
|
||||
|
|
@ -143,7 +146,7 @@ setup_postgresql() {
|
|||
|
||||
clone_dispatcharr_repo() {
|
||||
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
|
||||
|
||||
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR"
|
||||
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
|
||||
|
|
@ -171,14 +174,8 @@ EOSU
|
|||
# 6) Setup Python Environment
|
||||
##############################################################################
|
||||
|
||||
install_uv() {
|
||||
echo ">>> Installing UV package manager..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
|
||||
setup_python_env() {
|
||||
echo ">>> Setting up Python virtual environment with UV..."
|
||||
echo ">>> Setting up Python virtual environment with UV (Python 3.13)..."
|
||||
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
set -euo pipefail
|
||||
|
|
@ -188,13 +185,12 @@ setup_python_env() {
|
|||
command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
rm -rf env
|
||||
$PYTHON_BIN -m venv env
|
||||
env/bin/python -m ensurepip --upgrade
|
||||
# uv creates the venv with a managed Python 3.13 (auto-downloads if missing),
|
||||
# avoiding system Python version mismatches on Debian 12 / Ubuntu 24.04.
|
||||
uv venv --python 3.13 env
|
||||
|
||||
export UV_PROJECT_ENVIRONMENT="$APP_DIR/env"
|
||||
uv sync --no-dev
|
||||
|
||||
env/bin/python -m pip install -q gunicorn
|
||||
EOSU
|
||||
|
||||
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
|
||||
|
|
@ -291,17 +287,40 @@ EOSU
|
|||
configure_services() {
|
||||
echo ">>> Creating systemd service files..."
|
||||
|
||||
# Gunicorn
|
||||
# uWSGI config
|
||||
cat <<EOF >${APP_DIR}/uwsgi-debian.ini
|
||||
[uwsgi]
|
||||
chdir = ${APP_DIR}
|
||||
module = dispatcharr.wsgi:application
|
||||
virtualenv = ${APP_DIR}/env
|
||||
master = true
|
||||
workers = 4
|
||||
socket = ${UWSGI_SOCKET}
|
||||
chmod-socket = 666
|
||||
vacuum = true
|
||||
die-on-term = true
|
||||
gevent = 100
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
lazy-apps = true
|
||||
buffer-size = 65536
|
||||
socket-timeout = 600
|
||||
thunder-lock = true
|
||||
EOF
|
||||
|
||||
chown ${DISPATCH_USER}:${DISPATCH_GROUP} ${APP_DIR}/uwsgi-debian.ini
|
||||
|
||||
# uWSGI
|
||||
cat <<EOF >${SYSTEMD_DIR}/dispatcharr.service
|
||||
[Unit]
|
||||
Description=Gunicorn for Dispatcharr
|
||||
Description=uWSGI for Dispatcharr
|
||||
After=network.target postgresql.service redis-server.service
|
||||
|
||||
[Service]
|
||||
User=${DISPATCH_USER}
|
||||
Group=${DISPATCH_GROUP}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
RuntimeDirectory=${GUNICORN_RUNTIME_DIR}
|
||||
RuntimeDirectory=${UWSGI_RUNTIME_DIR}
|
||||
RuntimeDirectoryMode=0775
|
||||
EnvironmentFile=/opt/dispatcharr/.env
|
||||
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
|
||||
|
|
@ -310,12 +329,7 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
|
|||
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
Environment="POSTGRES_HOST=localhost"
|
||||
ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done'
|
||||
ExecStart=${APP_DIR}/env/bin/gunicorn \\
|
||||
--workers=4 \\
|
||||
--worker-class=gevent \\
|
||||
--timeout=300 \\
|
||||
--bind unix:${GUNICORN_SOCKET} \\
|
||||
dispatcharr.wsgi:application
|
||||
ExecStart=${APP_DIR}/env/bin/uwsgi --ini ${APP_DIR}/uwsgi-debian.ini
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
SyslogIdentifier=dispatcharr
|
||||
|
|
@ -412,9 +426,14 @@ EOF
|
|||
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
|
||||
server {
|
||||
listen ${NGINX_HTTP_PORT};
|
||||
client_max_body_size 0;
|
||||
|
||||
location / {
|
||||
include proxy_params;
|
||||
proxy_pass http://unix:${GUNICORN_SOCKET};
|
||||
include uwsgi_params;
|
||||
uwsgi_param HTTP_X_REAL_IP \$remote_addr;
|
||||
uwsgi_read_timeout 600;
|
||||
uwsgi_send_timeout 600;
|
||||
uwsgi_pass unix:${UWSGI_SOCKET};
|
||||
}
|
||||
location /static/ {
|
||||
alias ${APP_DIR}/static/;
|
||||
|
|
@ -465,7 +484,7 @@ show_summary() {
|
|||
=================================================
|
||||
Dispatcharr installation (or update) complete!
|
||||
Nginx is listening on port ${NGINX_HTTP_PORT}.
|
||||
Gunicorn socket: ${GUNICORN_SOCKET}.
|
||||
uWSGI socket: ${UWSGI_SOCKET}.
|
||||
WebSockets on port ${WEBSOCKET_PORT} (path /ws/).
|
||||
|
||||
You can check logs via:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from asgiref.sync import sync_to_async
|
||||
import regex, logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -16,7 +17,6 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer):
|
|||
try:
|
||||
await self.accept()
|
||||
await self.channel_layer.group_add(self.room_name, self.channel_name)
|
||||
# Send a connection confirmation to the client with consistent format
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'connection_established',
|
||||
'data': {
|
||||
|
|
@ -24,13 +24,22 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer):
|
|||
'message': 'WebSocket connection established successfully'
|
||||
}
|
||||
}))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in WebSocket connect: {str(e)}")
|
||||
# If an error occurs during connection, attempt to close
|
||||
# If the IP lookup already completed before the client connected,
|
||||
# push the cached result immediately so the Skeleton resolves.
|
||||
try:
|
||||
await self.close(code=1011) # Internal server error
|
||||
from django.core.cache import cache
|
||||
from core.api_views import _IP_CACHE_KEY
|
||||
cached_ip = await sync_to_async(cache.get)(_IP_CACHE_KEY)
|
||||
if cached_ip:
|
||||
await self.send(text_data=json.dumps({
|
||||
'data': {'type': 'ip_lookup_complete', **cached_ip}
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not push cached IP result on connect: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in WebSocket connect: {str(e)}")
|
||||
try:
|
||||
await self.close(code=1011)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
"""
|
||||
Loaded via uWSGI's `import = dispatcharr.gevent_patch` directive.
|
||||
|
||||
Two things happen here:
|
||||
gevent stdlib monkey-patching - replaces blocking socket/threading/os
|
||||
primitives with gevent-cooperative versions. `gevent-early-monkey-patch = true`
|
||||
in the ini should already have done this; calling it again is a safe no-op if
|
||||
it did, and a necessary fallback if it didn't (e.g. older uWSGI build).
|
||||
|
||||
1. gevent stdlib monkey-patching - replaces blocking socket/threading/os
|
||||
primitives with gevent-cooperative versions. `gevent-early-monkey-patch = true`
|
||||
in the ini should already have done this; calling it again is a safe no-op if
|
||||
it did, and a necessary fallback if it didn't (e.g. older uWSGI build).
|
||||
|
||||
2. psycogreen - installs a wait callback on psycopg2 so libpq yields to the
|
||||
gevent hub during I/O instead of blocking the OS thread.
|
||||
|
||||
Without (1), `async_to_sync(channel_layer.group_send)` in send_websocket_update
|
||||
Without this, `async_to_sync(channel_layer.group_send)` in send_websocket_update
|
||||
calls epoll_wait() directly, which blocks the OS thread and freezes all greenlets
|
||||
on the worker until the call returns. With (1), select.epoll is replaced by
|
||||
monkey-patching, which breaks asyncio event loop creation in threadpool threads.
|
||||
on the worker until the call returns. With monkey-patching, select.epoll is
|
||||
replaced, which breaks asyncio event loop creation in threadpool threads.
|
||||
send_websocket_update therefore uses a synchronous Redis path in gevent workers
|
||||
instead of asyncio - see _gevent_ws_send() in core/utils.py.
|
||||
|
||||
Without (2), psycopg2 network calls pin the worker during slow/stalled queries.
|
||||
psycopg3 uses Python's socket layer for I/O, so monkey.patch_all() provides
|
||||
gevent compatibility without any additional driver patching.
|
||||
|
||||
Celery and Daphne run in separate daemon processes and do not load this module.
|
||||
|
||||
|
|
@ -40,15 +36,4 @@ if not monkey.is_module_patched("socket"):
|
|||
else:
|
||||
print("[gevent_patch] gevent stdlib monkey-patching already active.", flush=True)
|
||||
|
||||
try:
|
||||
from psycogreen.gevent import patch_psycopg
|
||||
patch_psycopg()
|
||||
print("[gevent_patch] psycogreen: psycopg2 patched for gevent.", flush=True)
|
||||
except ImportError:
|
||||
print(
|
||||
"[gevent_patch] WARNING: psycogreen not installed - "
|
||||
"psycopg2 will block the gevent hub during DB I/O. "
|
||||
"Run: uv pip install psycogreen",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ if REDIS_SSL:
|
|||
else:
|
||||
print("Redis TLS: disabled")
|
||||
|
||||
ENABLE_IP_LOOKUP = os.environ.get("DISPATCHARR_ENABLE_IP_LOOKUP", "true").lower() == "true"
|
||||
|
||||
# Set DEBUG to True for development, False for production
|
||||
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
|
||||
DEBUG = True
|
||||
|
|
@ -112,7 +114,7 @@ XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5
|
|||
|
||||
# Database optimization settings
|
||||
DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries
|
||||
DATABASE_CONN_MAX_AGE = 0 # Close after each request; gevent makes per-greenlet connections
|
||||
DATABASE_CONN_MAX_AGE = 0 # geventpool intercepts close(); pool handles reuse
|
||||
|
||||
# Disable atomic requests for performance-sensitive views
|
||||
ATOMIC_REQUESTS = False
|
||||
|
|
@ -222,13 +224,18 @@ if os.getenv("DB_ENGINE", None) == "sqlite":
|
|||
else:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"ENGINE": "django_db_geventpool.backends.postgresql_psycopg3",
|
||||
"NAME": os.environ.get("POSTGRES_DB", "dispatcharr"),
|
||||
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||
"PORT": int(os.environ.get("POSTGRES_PORT", 5432)),
|
||||
"CONN_MAX_AGE": DATABASE_CONN_MAX_AGE,
|
||||
"OPTIONS": {
|
||||
"MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100
|
||||
"REUSE_CONNS": 3, # Connections to keep warm between requests
|
||||
"pool": False, # Disable Django's native psycopg3 pool; geventpool manages connections
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,9 +246,9 @@ else:
|
|||
("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY),
|
||||
], "PostgreSQL")
|
||||
|
||||
DATABASES["default"]["OPTIONS"] = {
|
||||
DATABASES["default"]["OPTIONS"].update({
|
||||
"sslmode": POSTGRES_SSL_MODE,
|
||||
}
|
||||
})
|
||||
if POSTGRES_SSL_CA_CERT:
|
||||
DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT
|
||||
if POSTGRES_SSL_CERT:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
FROM lscr.io/linuxserver/ffmpeg:latest
|
||||
# ==============================================================================
|
||||
# Stage 1: builder
|
||||
# Installs the Python toolchain + compilers, creates the virtual environment and
|
||||
# builds the legacy (CPU-baseline=none) NumPy wheel. None of these compilers end
|
||||
# up in the final image — only the artifacts below are copied forward.
|
||||
# ==============================================================================
|
||||
FROM lscr.io/linuxserver/ffmpeg:latest AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy
|
||||
|
|
@ -15,10 +21,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
|
|||
&& apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
python3.13 python3.13-dev python3.13-venv libpython3.13 \
|
||||
libpcre3 libpcre3-dev libpq-dev procps pciutils \
|
||||
nginx comskip \
|
||||
vlc-bin vlc-plugin-base \
|
||||
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build
|
||||
libpcre3 libpcre3-dev \
|
||||
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Install UV ---
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
|
@ -28,11 +33,12 @@ WORKDIR /tmp/build
|
|||
COPY pyproject.toml /tmp/build/
|
||||
COPY version.py /tmp/build/
|
||||
COPY README.md /tmp/build/
|
||||
RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \
|
||||
rm -rf /tmp/build
|
||||
RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev
|
||||
WORKDIR /
|
||||
|
||||
# --- Build legacy NumPy wheel for old hardware (store for runtime switching) ---
|
||||
# build/pip are installed into the venv only long enough to produce the wheel,
|
||||
# then uninstalled so they are not carried into the final image's venv.
|
||||
RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \
|
||||
cd /tmp && \
|
||||
$UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \
|
||||
|
|
@ -40,14 +46,44 @@ RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build
|
|||
cd numpy-*/ && \
|
||||
$UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
|
||||
mv dist/*.whl /opt/ && \
|
||||
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \
|
||||
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz /tmp/build && \
|
||||
uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip
|
||||
|
||||
# --- Clean up build dependencies to reduce image size ---
|
||||
RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \
|
||||
apt-get autoremove -y --purge && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /root/.cache /tmp/*
|
||||
# ==============================================================================
|
||||
# Stage 2: final runtime image
|
||||
# Same ffmpeg base, but installs only the runtime system libraries (no compilers)
|
||||
# and copies the prebuilt virtual environment + NumPy wheel from the builder.
|
||||
# ==============================================================================
|
||||
FROM lscr.io/linuxserver/ffmpeg:latest AS final
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy
|
||||
ENV VIRTUAL_ENV=/dispatcharrpy
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# --- Install runtime system dependencies (no build toolchain) ---
|
||||
# python3.13 + libpython3.13 back the copied venv; libpcre3 backs uWSGI;
|
||||
# libopenblas0 backs the legacy NumPy wheel; ca-certificates/gnupg2/curl/wget
|
||||
# and software-properties-common are kept for TLS and the AIO runtime apt step.
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
ca-certificates software-properties-common gnupg2 curl wget \
|
||||
&& add-apt-repository ppa:deadsnakes/ppa \
|
||||
&& apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
python3.13 python3.13-venv libpython3.13 \
|
||||
libpcre3 libopenblas0 procps pciutils \
|
||||
nginx comskip \
|
||||
vlc-bin vlc-plugin-base \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Install UV (used at runtime to swap in the legacy NumPy wheel) ---
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# --- Copy prebuilt virtual environment and legacy NumPy wheel from the builder ---
|
||||
COPY --from=builder /dispatcharrpy /dispatcharrpy
|
||||
COPY --from=builder /opt/numpy-*.whl /opt/
|
||||
|
||||
# --- Set up Redis 7.x ---
|
||||
RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export POSTGRES_DIR=/data/db
|
|||
variables=(
|
||||
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
|
||||
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
|
||||
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
|
||||
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENABLE_IP_LOOKUP
|
||||
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
|
||||
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
|
||||
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ gevent = 100
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ gevent = 100
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ gevent = 400 # Each unused greenlet costs ~2-4KB of memory
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ gevent = 400 # Each unused greenlet costs ~2-4KB of memory
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
|
|
@ -965,6 +965,12 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'ip_lookup_complete': {
|
||||
const { type: _t, ...ipData } = parsedEvent.data;
|
||||
useSettingsStore.getState().setEnvironmentFields(ipData);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unknown websocket event type: ${parsedEvent.data?.type}`
|
||||
|
|
|
|||
|
|
@ -3526,10 +3526,11 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getMovieProviderInfo(movieId) {
|
||||
static async getMovieProviderInfo(movieId, relationId = null) {
|
||||
try {
|
||||
const params = relationId ? `?relation_id=${relationId}` : '';
|
||||
const response = await request(
|
||||
`${host}/api/vod/movies/${movieId}/provider-info/`
|
||||
`${host}/api/vod/movies/${movieId}/provider-info/${params}`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -3568,11 +3569,12 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getSeriesInfo(seriesId) {
|
||||
static async getSeriesInfo(seriesId, relationId = null) {
|
||||
try {
|
||||
// Call the provider-info endpoint that includes episodes
|
||||
const params = new URLSearchParams({ include_episodes: 'true' });
|
||||
if (relationId) params.set('relation_id', relationId);
|
||||
const response = await request(
|
||||
`${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true`
|
||||
`${host}/api/vod/series/${seriesId}/provider-info/?${params}`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -470,6 +470,13 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
const onChangeSelectedProvider = (value) => {
|
||||
const provider = providers.find((p) => p.id.toString() === value);
|
||||
setSelectedProvider(provider);
|
||||
if (provider) {
|
||||
setLoadingDetails(true);
|
||||
fetchSeriesInfo(series.id, provider.id)
|
||||
.then((details) => setDetailedSeries(details))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingDetails(false));
|
||||
}
|
||||
};
|
||||
|
||||
if (!series) return null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { copyToClipboard } from '../utils';
|
||||
import {
|
||||
|
|
@ -18,10 +18,10 @@ import {
|
|||
Box,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
AppShellNavbar,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import logo from '../images/logo.png';
|
||||
|
|
@ -161,10 +161,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
const getNavOrder = useAuthStore((s) => s.getNavOrder);
|
||||
const getHiddenNav = useAuthStore((s) => s.getHiddenNav);
|
||||
|
||||
const publicIPRef = useRef(null);
|
||||
|
||||
const [userFormOpen, setUserFormOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [ipRevealed, setIpRevealed] = useState(false);
|
||||
|
||||
const closeUserForm = () => setUserFormOpen(false);
|
||||
|
||||
|
|
@ -289,35 +288,106 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
}}
|
||||
>
|
||||
{isAuthenticated && (
|
||||
<Stack gap="sm">
|
||||
{!collapsed && (
|
||||
<TextInput
|
||||
label="Public IP"
|
||||
ref={publicIPRef}
|
||||
value={environment.public_ip}
|
||||
readOnly={true}
|
||||
leftSection={
|
||||
environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={environment.country_name || environment.country_code}
|
||||
title={
|
||||
environment.country_name || environment.country_code
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={copyPublicIP}
|
||||
<Stack gap="sm" style={{ width: '100%' }}>
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
environment.ip_lookup_pending && (
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Skeleton height={36} radius="sm" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
!environment.ip_lookup_pending &&
|
||||
environment.public_ip &&
|
||||
!environment.public_ip.startsWith('Error') && (
|
||||
<Box
|
||||
onClick={() => setIpRevealed((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
height: '36px',
|
||||
paddingLeft: '10px',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={
|
||||
environment.country_name || environment.country_code
|
||||
}
|
||||
title={[
|
||||
environment.country_name || environment.country_code,
|
||||
environment.city,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Box style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const ip = environment.public_ip;
|
||||
const isIPv6 = ip.includes(':');
|
||||
const sep = isIPv6 ? ':' : '.';
|
||||
const parts = ip.split(sep);
|
||||
const splitAt = isIPv6 ? 4 : 2;
|
||||
const visible =
|
||||
parts.slice(0, splitAt).join(sep) + sep;
|
||||
const hidden = parts.slice(splitAt).join(sep);
|
||||
return (
|
||||
<>
|
||||
{visible}
|
||||
<span
|
||||
style={{
|
||||
filter: ipRevealed ? 'none' : 'blur(5px)',
|
||||
transition: 'filter 0.15s',
|
||||
userSelect: ipRevealed ? 'text' : 'none',
|
||||
}}
|
||||
>
|
||||
{hidden}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyPublicIP();
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!collapsed && authUser && (
|
||||
<Group
|
||||
|
|
@ -336,7 +406,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
</Group>
|
||||
)}
|
||||
{collapsed && (
|
||||
<Group gap="xs">
|
||||
<Group justify="center" style={{ width: '100%' }}>
|
||||
<Avatar src="" radius="xl" />
|
||||
</Group>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
formatStreamLabel,
|
||||
getYouTubeEmbedUrl,
|
||||
imdbUrl,
|
||||
tmdbUrl
|
||||
tmdbUrl,
|
||||
} from '../utils/components/SeriesModalUtils.js';
|
||||
import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
|
||||
import {
|
||||
|
|
@ -38,7 +38,7 @@ const Movie = ({
|
|||
hasMultipleProviders,
|
||||
selectedProvider,
|
||||
detailedVOD,
|
||||
vod
|
||||
vod,
|
||||
}) => {
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
|
|
@ -71,28 +71,19 @@ const Movie = ({
|
|||
<Title order={3}>{displayVOD.name}</Title>
|
||||
|
||||
{/* Original name if different */}
|
||||
{displayVOD.o_name &&
|
||||
displayVOD.o_name !== displayVOD.name && (
|
||||
<Text size="sm" c="dimmed" fs="italic">
|
||||
Original: {displayVOD.o_name}
|
||||
</Text>
|
||||
)}
|
||||
{displayVOD.o_name && displayVOD.o_name !== displayVOD.name && (
|
||||
<Text size="sm" c="dimmed" fs="italic">
|
||||
Original: {displayVOD.o_name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group spacing="md">
|
||||
{displayVOD.year && (
|
||||
<Badge color="blue">{displayVOD.year}</Badge>
|
||||
)}
|
||||
{displayVOD.year && <Badge color="blue">{displayVOD.year}</Badge>}
|
||||
{displayVOD.duration_secs && (
|
||||
<Badge color="gray">
|
||||
{formatDuration(displayVOD.duration_secs)}
|
||||
</Badge>
|
||||
)}
|
||||
{displayVOD.rating && (
|
||||
<Badge color="yellow">{displayVOD.rating}</Badge>
|
||||
)}
|
||||
{displayVOD.age && (
|
||||
<Badge color="orange">{displayVOD.age}</Badge>
|
||||
<Badge color="gray">{formatDuration(displayVOD.duration_secs)}</Badge>
|
||||
)}
|
||||
{displayVOD.rating && <Badge color="yellow">{displayVOD.rating}</Badge>}
|
||||
{displayVOD.age && <Badge color="orange">{displayVOD.age}</Badge>}
|
||||
<Badge color="green">Movie</Badge>
|
||||
{/* imdb_id and tmdb_id badges */}
|
||||
{displayVOD.imdb_id && (
|
||||
|
|
@ -203,12 +194,15 @@ const Movie = ({
|
|||
|
||||
const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
|
||||
const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
|
||||
const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
|
||||
const hasDetails =
|
||||
techDetails.bitrate || techDetails.video || techDetails.audio;
|
||||
|
||||
if (!hasDetails) return null;
|
||||
|
||||
const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0;
|
||||
const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0;
|
||||
const hasVideo =
|
||||
techDetails.video && Object.keys(techDetails.video).length > 0;
|
||||
const hasAudio =
|
||||
techDetails.audio && Object.keys(techDetails.audio).length > 0;
|
||||
|
||||
return (
|
||||
<Stack spacing={4} mt="xs">
|
||||
|
|
@ -217,7 +211,9 @@ const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
|
|||
{selectedProvider && (
|
||||
<Text size="xs" c="dimmed" weight="normal" span ml={8}>
|
||||
(from {selectedProvider.m3u_account.name}
|
||||
{selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
|
||||
{selectedProvider.stream_id &&
|
||||
` - Stream ${selectedProvider.stream_id}`}
|
||||
)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
|
@ -315,16 +311,21 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
}, [opened]);
|
||||
|
||||
const onClickYouTubeTrailer = () => {
|
||||
setTrailerUrl(
|
||||
getYouTubeEmbedUrl(displayVOD.youtube_trailer)
|
||||
);
|
||||
setTrailerUrl(getYouTubeEmbedUrl(displayVOD.youtube_trailer));
|
||||
setTrailerModalOpened(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeSelectedProvider = (value) => {
|
||||
const provider = providers.find((p) => p.id.toString() === value);
|
||||
setSelectedProvider(provider);
|
||||
}
|
||||
if (provider) {
|
||||
setLoadingDetails(true);
|
||||
fetchMovieDetailsFromProvider(vod.id, provider.id)
|
||||
.then((details) => setDetailedVOD(details))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingDetails(false));
|
||||
}
|
||||
};
|
||||
|
||||
if (!vod) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import useSettingsStore from '../../store/settings';
|
|||
import { copyToClipboard } from '../../utils';
|
||||
|
||||
// Mock stores
|
||||
vi.mock('../../store/auth', () => ({
|
||||
default: {
|
||||
getState: () => ({ accessToken: null }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../store/useVODStore', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -96,14 +96,6 @@ vi.mock('@mantine/core', async () => {
|
|||
</Component>
|
||||
);
|
||||
},
|
||||
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
{leftSection}
|
||||
<input value={value} onChange={onChange} />
|
||||
{rightSection}
|
||||
</div>
|
||||
),
|
||||
ActionIcon: ({ children, onClick, ...props }) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
|
|
@ -122,6 +114,7 @@ vi.mock('@mantine/core', async () => {
|
|||
</nav>
|
||||
),
|
||||
ScrollArea: ({ children }) => <div>{children}</div>,
|
||||
Skeleton: ({ height }) => <div data-testid="skeleton" style={{ height }} />,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
|
@ -290,8 +283,7 @@ describe('Sidebar', () => {
|
|||
it('should render public IP with country flag', () => {
|
||||
renderSidebar();
|
||||
|
||||
const ipInput = screen.getByDisplayValue('192.168.1.1');
|
||||
expect(ipInput).toBeInTheDocument();
|
||||
expect(screen.getByText('Public IP')).toBeInTheDocument();
|
||||
|
||||
const flag = screen.getByAltText('United States');
|
||||
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
|
||||
|
|
@ -492,7 +484,7 @@ describe('Sidebar', () => {
|
|||
});
|
||||
|
||||
renderSidebar();
|
||||
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Public IP')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('img', { name: /flag/i })
|
||||
).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,86 +1,40 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
SegmentedControl,
|
||||
MultiSelect,
|
||||
Group,
|
||||
TextInput,
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates';
|
||||
import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates';
|
||||
import { CircleAlert } from 'lucide-react';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '6', label: 'Sun' },
|
||||
{ value: '0', label: 'Mon' },
|
||||
{ value: '1', label: 'Tue' },
|
||||
{ value: '2', label: 'Wed' },
|
||||
{ value: '3', label: 'Thu' },
|
||||
{ value: '4', label: 'Fri' },
|
||||
{ value: '5', label: 'Sat' },
|
||||
];
|
||||
|
||||
const asDate = (value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const toIsoIfDate = (value) => {
|
||||
const dt = asDate(value);
|
||||
return dt ? dt.toISOString() : value;
|
||||
};
|
||||
|
||||
// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm"
|
||||
const toTimeString = (value) => {
|
||||
if (!value) return '00:00';
|
||||
if (typeof value === 'string') {
|
||||
const parsed = dayjs(
|
||||
value,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'],
|
||||
true
|
||||
);
|
||||
if (parsed.isValid()) return parsed.format('HH:mm');
|
||||
return value;
|
||||
}
|
||||
const dt = asDate(value);
|
||||
if (!dt) return '00:00';
|
||||
return dayjs(dt).format('HH:mm');
|
||||
};
|
||||
|
||||
const toDateString = (value) => {
|
||||
const dt = asDate(value);
|
||||
if (!dt) return null;
|
||||
const year = dt.getFullYear();
|
||||
const month = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(dt.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const createRoundedDate = (minutesAhead = 0) => {
|
||||
const dt = new Date();
|
||||
dt.setSeconds(0);
|
||||
dt.setMilliseconds(0);
|
||||
dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30);
|
||||
if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead);
|
||||
return dt;
|
||||
};
|
||||
|
||||
// robust onChange for TimeInput (string or event)
|
||||
const timeChange = (setter) => (valOrEvent) => {
|
||||
if (typeof valOrEvent === 'string') setter(valOrEvent);
|
||||
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
|
||||
};
|
||||
import {
|
||||
RECURRING_DAY_OPTIONS,
|
||||
toTimeString,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
buildRecurringPayload,
|
||||
buildSinglePayload,
|
||||
createRecording,
|
||||
createRecurringRule,
|
||||
getChannelsSummary,
|
||||
getRecurringFormDefaults,
|
||||
getSingleFormDefaults,
|
||||
numberedChannelLabel,
|
||||
recurringFormValidators,
|
||||
singleFormValidators,
|
||||
sortedChannelOptions,
|
||||
timeChange,
|
||||
updateRecording,
|
||||
} from '../../utils/forms/RecordingUtils.js';
|
||||
|
||||
const RecordingModal = ({
|
||||
recording = null,
|
||||
|
|
@ -98,117 +52,29 @@ const RecordingModal = ({
|
|||
const [mode, setMode] = useState('single');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const defaultStart = createRoundedDate();
|
||||
const defaultEnd = createRoundedDate(60);
|
||||
const defaultDate = new Date();
|
||||
|
||||
// One-time form
|
||||
const singleForm = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: recording
|
||||
? `${recording.channel}`
|
||||
: channel
|
||||
? `${channel.id}`
|
||||
: '',
|
||||
start_time: recording
|
||||
? asDate(recording.start_time) || defaultStart
|
||||
: defaultStart,
|
||||
end_time: recording
|
||||
? asDate(recording.end_time) || defaultEnd
|
||||
: defaultEnd,
|
||||
},
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
start_time: isNotEmpty('Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
const start = asDate(values.start_time);
|
||||
const end = asDate(value);
|
||||
if (!end) return 'Select an end time';
|
||||
if (start && end <= start) return 'End time must be after start time';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
initialValues: getSingleFormDefaults(recording, channel),
|
||||
validate: singleFormValidators,
|
||||
});
|
||||
|
||||
// Recurring form stores times as "HH:mm" strings for stable editing
|
||||
const recurringForm = useForm({
|
||||
mode: 'controlled',
|
||||
validateInputOnChange: false,
|
||||
validateInputOnBlur: true,
|
||||
initialValues: {
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
days_of_week: [],
|
||||
start_time: dayjs(defaultStart).format('HH:mm'),
|
||||
end_time: dayjs(defaultEnd).format('HH:mm'),
|
||||
rule_name: '',
|
||||
start_date: defaultDate,
|
||||
end_date: defaultDate,
|
||||
},
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
days_of_week: (value) =>
|
||||
value && value.length ? null : 'Pick at least one day',
|
||||
start_time: (value) => (value ? null : 'Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
if (!value) return 'Select an end time';
|
||||
const start = dayjs(
|
||||
values.start_time,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
true
|
||||
);
|
||||
const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
|
||||
if (
|
||||
start.isValid() &&
|
||||
end.isValid() &&
|
||||
end.diff(start, 'minute') === 0
|
||||
) {
|
||||
return 'End time must differ from start time';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
end_date: (value, values) => {
|
||||
const end = asDate(value);
|
||||
const start = asDate(values.start_date);
|
||||
if (!end) return 'Select an end date';
|
||||
if (start && end < start) return 'End date cannot be before start date';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
initialValues: getRecurringFormDefaults(channel),
|
||||
validate: recurringFormValidators,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const freshStart = createRoundedDate();
|
||||
const freshEnd = createRoundedDate(60);
|
||||
const freshDate = new Date();
|
||||
|
||||
if (recording && recording.id) {
|
||||
if (recording?.id) {
|
||||
setMode('single');
|
||||
singleForm.setValues({
|
||||
channel_id: `${recording.channel}`,
|
||||
start_time: asDate(recording.start_time) || defaultStart,
|
||||
end_time: asDate(recording.end_time) || defaultEnd,
|
||||
});
|
||||
singleForm.setValues(getSingleFormDefaults(recording, channel));
|
||||
} else {
|
||||
// Reset forms for fresh open
|
||||
singleForm.setValues({
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
start_time: freshStart,
|
||||
end_time: freshEnd,
|
||||
});
|
||||
|
||||
const startStr = dayjs(freshStart).format('HH:mm');
|
||||
recurringForm.setValues({
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
days_of_week: [],
|
||||
start_time: startStr,
|
||||
end_time: dayjs(freshEnd).format('HH:mm'),
|
||||
rule_name: channel?.name || '',
|
||||
start_date: freshDate,
|
||||
end_date: freshDate,
|
||||
});
|
||||
singleForm.setValues(getSingleFormDefaults(null, channel));
|
||||
recurringForm.setValues(getRecurringFormDefaults(channel));
|
||||
setMode('single');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -221,7 +87,7 @@ const RecordingModal = ({
|
|||
if (!isOpen) return;
|
||||
try {
|
||||
setIsChannelsLoading(true);
|
||||
const chans = await API.getChannelsSummary();
|
||||
const chans = await getChannelsSummary();
|
||||
if (cancelled) return;
|
||||
setAllChannels(Array.isArray(chans) ? chans : []);
|
||||
} catch (e) {
|
||||
|
|
@ -238,19 +104,7 @@ const RecordingModal = ({
|
|||
}, [isOpen]);
|
||||
|
||||
const channelOptions = useMemo(() => {
|
||||
const list = Array.isArray(allChannels) ? [...allChannels] : [];
|
||||
list.sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
|
||||
return aNum - bNum;
|
||||
});
|
||||
return list.map((item) => ({
|
||||
value: `${item.id}`,
|
||||
label: item.channel_number
|
||||
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
|
||||
: item.name || `Channel ${item.id}`,
|
||||
}));
|
||||
return sortedChannelOptions(allChannels, numberedChannelLabel);
|
||||
}, [allChannels]);
|
||||
|
||||
const resetForms = () => {
|
||||
|
|
@ -267,25 +121,18 @@ const RecordingModal = ({
|
|||
const handleSingleSubmit = async (values) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload = buildSinglePayload(values);
|
||||
if (recording && recording.id) {
|
||||
await API.updateRecording(recording.id, {
|
||||
channel: values.channel_id,
|
||||
start_time: toIsoIfDate(values.start_time),
|
||||
end_time: toIsoIfDate(values.end_time),
|
||||
});
|
||||
notifications.show({
|
||||
await updateRecording(recording.id, payload);
|
||||
showNotification({
|
||||
title: 'Recording updated',
|
||||
message: 'Recording schedule updated successfully',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
});
|
||||
} else {
|
||||
await API.createRecording({
|
||||
channel: values.channel_id,
|
||||
start_time: toIsoIfDate(values.start_time),
|
||||
end_time: toIsoIfDate(values.end_time),
|
||||
});
|
||||
notifications.show({
|
||||
await createRecording(payload);
|
||||
showNotification({
|
||||
title: 'Recording scheduled',
|
||||
message: 'One-time recording added to DVR queue',
|
||||
color: 'green',
|
||||
|
|
@ -304,18 +151,10 @@ const RecordingModal = ({
|
|||
const handleRecurringSubmit = async (values) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await API.createRecurringRule({
|
||||
channel: values.channel_id,
|
||||
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
|
||||
start_time: toTimeString(values.start_time),
|
||||
end_time: toTimeString(values.end_time),
|
||||
start_date: toDateString(values.start_date),
|
||||
end_date: toDateString(values.end_date),
|
||||
name: values.rule_name?.trim() || '',
|
||||
});
|
||||
await createRecurringRule(buildRecurringPayload(values));
|
||||
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recurring rule saved',
|
||||
message: 'Future slots will be scheduled automatically',
|
||||
color: 'green',
|
||||
|
|
@ -427,7 +266,10 @@ const RecordingModal = ({
|
|||
key={recurringForm.key('days_of_week')}
|
||||
label="Every"
|
||||
placeholder="Select days"
|
||||
data={DAY_OPTIONS}
|
||||
data={RECURRING_DAY_OPTIONS.map((opt) => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No match"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import API from '../../api';
|
||||
import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import React from 'react';
|
||||
import { Pencil, RefreshCcw, Check, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Pencil, RefreshCcw, X } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
|
|
@ -21,7 +23,6 @@ import {
|
|||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import useVideoStore from '../../store/useVideoStore.jsx';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import defaultLogo from '../../images/logo.png';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
|
|
@ -33,10 +34,98 @@ import {
|
|||
runComSkip,
|
||||
} from '../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
getChannel,
|
||||
getRating,
|
||||
getStatRows,
|
||||
getUpcomingEpisodes,
|
||||
refreshArtwork,
|
||||
updateRecordingMetadata,
|
||||
} from '../../utils/forms/RecordingDetailsModalUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
|
||||
const EpisodeRow = ({
|
||||
rec,
|
||||
recording,
|
||||
channel,
|
||||
channelsById,
|
||||
livePosterUrl,
|
||||
toUserTime,
|
||||
dateformat,
|
||||
timeformat,
|
||||
onOpenChild,
|
||||
}) => {
|
||||
const cp = rec.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
const start = toUserTime(rec.start_time);
|
||||
const end = toUserTime(rec.end_time);
|
||||
const season = cp.season ?? pr?.custom_properties?.season;
|
||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
||||
const onscreen =
|
||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||
const se = getSeasonLabel(season, episode, onscreen);
|
||||
const posterLogoId = cp.poster_logo_id;
|
||||
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
|
||||
const epChannel =
|
||||
channelsById[rec.channel] ||
|
||||
(rec.channel === recording?.channel ? channel : null);
|
||||
|
||||
const onRemove = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
try {
|
||||
await deleteRecordingById(rec.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upcoming recording', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
||||
onClick={() => onOpenChild(rec)}
|
||||
>
|
||||
<Flex gap="sm" align="center">
|
||||
<Image
|
||||
src={purl}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={pr.title}
|
||||
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
|
||||
/>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group justify="space-between">
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
title={pr.sub_title || pr.title}
|
||||
>
|
||||
{pr.sub_title || pr.title}
|
||||
</Text>
|
||||
{se && (
|
||||
<Badge color="gray" variant="light">
|
||||
{se}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs">
|
||||
{format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{format(end, timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
<Button size="xs" color="red" variant="light" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const RecordingDetailsModal = ({
|
||||
opened,
|
||||
|
|
@ -51,21 +140,21 @@ const RecordingDetailsModal = ({
|
|||
}) => {
|
||||
const allRecordings = useChannelsStore((s) => s.recordings);
|
||||
// Local channel cache to avoid the global channels map
|
||||
const [channelsById, setChannelsById] = React.useState({});
|
||||
const [channelsById, setChannelsById] = useState({});
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const [childOpen, setChildOpen] = React.useState(false);
|
||||
const [childRec, setChildRec] = React.useState(null);
|
||||
const [childOpen, setChildOpen] = useState(false);
|
||||
const [childRec, setChildRec] = useState(null);
|
||||
const { timeFormat: timeformat, dateFormat: dateformat } =
|
||||
useDateTimeFormat();
|
||||
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
// Prefer the store version of the recording for live updates
|
||||
// (e.g., after artwork refresh or metadata edit via WebSocket).
|
||||
// Preserve _group_count from the categorized prop — the store version
|
||||
// doesn't carry this client-side field, so without merging it back
|
||||
// isSeriesGroup would always be false and the episode list hidden.
|
||||
const safeRecording = React.useMemo(() => {
|
||||
const safeRecording = useMemo(() => {
|
||||
if (recording?.id && Array.isArray(allRecordings)) {
|
||||
const found = allRecordings.find((r) => r.id === recording.id);
|
||||
if (found) {
|
||||
|
|
@ -81,7 +170,7 @@ const RecordingDetailsModal = ({
|
|||
const program = customProps.program || {};
|
||||
|
||||
// Derive poster URL from live store data instead of the stale prop snapshot.
|
||||
const livePosterUrl = React.useMemo(
|
||||
const livePosterUrl = useMemo(
|
||||
() =>
|
||||
getPosterUrl(
|
||||
customProps.poster_logo_id,
|
||||
|
|
@ -93,17 +182,17 @@ const RecordingDetailsModal = ({
|
|||
|
||||
// Optimistic overrides — show saved values immediately without waiting
|
||||
// for the WebSocket round-trip to refresh the store.
|
||||
const [savedTitle, setSavedTitle] = React.useState(null);
|
||||
const [savedDescription, setSavedDescription] = React.useState(null);
|
||||
const [savedTitle, setSavedTitle] = useState(null);
|
||||
const [savedDescription, setSavedDescription] = useState(null);
|
||||
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
|
||||
const description =
|
||||
savedDescription ?? (program.description || customProps.description || '');
|
||||
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [editDescription, setEditDescription] = React.useState('');
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
// Reset optimistic state when the recording changes
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setSavedTitle(null);
|
||||
setSavedDescription(null);
|
||||
setEditing(false);
|
||||
|
|
@ -129,7 +218,7 @@ const RecordingDetailsModal = ({
|
|||
const isSeriesGroup = Boolean(
|
||||
safeRecording._group_count && safeRecording._group_count > 1
|
||||
);
|
||||
const upcomingEpisodes = React.useMemo(() => {
|
||||
const upcomingEpisodes = useMemo(() => {
|
||||
return getUpcomingEpisodes(
|
||||
isSeriesGroup,
|
||||
allRecordings,
|
||||
|
|
@ -147,7 +236,7 @@ const RecordingDetailsModal = ({
|
|||
]);
|
||||
|
||||
// Ensure channel is available for a given id
|
||||
const loadChannel = React.useCallback(
|
||||
const loadChannel = useCallback(
|
||||
async (id) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
|
|
@ -159,7 +248,7 @@ const RecordingDetailsModal = ({
|
|||
}
|
||||
|
||||
try {
|
||||
const ch = await API.getChannel(id);
|
||||
const ch = await getChannel(id);
|
||||
if (ch && ch.id === id) {
|
||||
setChannelsById((prev) => ({ ...prev, [id]: ch }));
|
||||
return ch;
|
||||
|
|
@ -177,7 +266,7 @@ const RecordingDetailsModal = ({
|
|||
);
|
||||
|
||||
// When opening a child episode, fetch that episode's channel
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!childOpen || !childRec) return;
|
||||
loadChannel(childRec.channel);
|
||||
}, [childOpen, childRec, loadChannel]);
|
||||
|
|
@ -188,7 +277,7 @@ const RecordingDetailsModal = ({
|
|||
const s = toUserTime(rec.start_time);
|
||||
const e = toUserTime(rec.end_time);
|
||||
|
||||
if (now.isAfter(s) && now.isBefore(e)) {
|
||||
if (isAfter(now, s) && isBefore(now, e)) {
|
||||
const ch =
|
||||
channelsById[rec.channel] ||
|
||||
(rec.channel === recording?.channel ? channel : null);
|
||||
|
|
@ -228,14 +317,11 @@ const RecordingDetailsModal = ({
|
|||
|
||||
const saveMetadata = async () => {
|
||||
try {
|
||||
await API.updateRecordingMetadata(recording.id, {
|
||||
title: editTitle || 'Custom Recording',
|
||||
description: editDescription,
|
||||
});
|
||||
await updateRecordingMetadata(recording, editTitle, editDescription);
|
||||
setSavedTitle(editTitle || 'Custom Recording');
|
||||
setSavedDescription(editDescription);
|
||||
setEditing(false);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Saved',
|
||||
message: 'Recording metadata updated',
|
||||
color: 'green',
|
||||
|
|
@ -249,8 +335,8 @@ const RecordingDetailsModal = ({
|
|||
const handleRefreshArtwork = async (e) => {
|
||||
e.stopPropagation?.();
|
||||
try {
|
||||
await API.refreshArtwork(recording.id);
|
||||
notifications.show({
|
||||
await refreshArtwork(recording.id);
|
||||
showNotification({
|
||||
title: 'Refreshing artwork',
|
||||
message: 'Poster resolution started',
|
||||
color: 'blue.5',
|
||||
|
|
@ -265,7 +351,7 @@ const RecordingDetailsModal = ({
|
|||
e.stopPropagation?.();
|
||||
try {
|
||||
await runComSkip(recording);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Removing commercials',
|
||||
message: 'Queued comskip for this recording',
|
||||
color: 'blue.5',
|
||||
|
|
@ -278,86 +364,6 @@ const RecordingDetailsModal = ({
|
|||
|
||||
if (!recording) return null;
|
||||
|
||||
const EpisodeRow = ({ rec }) => {
|
||||
const cp = rec.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
const start = toUserTime(rec.start_time);
|
||||
const end = toUserTime(rec.end_time);
|
||||
const season = cp.season ?? pr?.custom_properties?.season;
|
||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
||||
const onscreen =
|
||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||
const se = getSeasonLabel(season, episode, onscreen);
|
||||
const posterLogoId = cp.poster_logo_id;
|
||||
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
|
||||
const epChannel =
|
||||
channelsById[rec.channel] ||
|
||||
(rec.channel === recording?.channel ? channel : null);
|
||||
|
||||
const onRemove = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
try {
|
||||
await deleteRecordingById(rec.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upcoming recording', error);
|
||||
}
|
||||
// recording_cancelled WS event triggers the debounced fetchRecordings()
|
||||
};
|
||||
|
||||
const handleOnMainCardClick = () => {
|
||||
setChildRec(rec);
|
||||
setChildOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
||||
onClick={handleOnMainCardClick}
|
||||
>
|
||||
<Flex gap="sm" align="center">
|
||||
<Image
|
||||
src={purl}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={pr.title || recordingName}
|
||||
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
|
||||
/>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group justify="space-between">
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
title={pr.sub_title || pr.title}
|
||||
>
|
||||
{pr.sub_title || pr.title}
|
||||
</Text>
|
||||
{se && (
|
||||
<Badge color="gray" variant="light">
|
||||
{se}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
<Button size="xs" color="red" variant="light" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchLive = () => {
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -414,7 +420,21 @@ const RecordingDetailsModal = ({
|
|||
</Text>
|
||||
)}
|
||||
{upcomingEpisodes.map((ep) => (
|
||||
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
|
||||
<EpisodeRow
|
||||
key={`ep-${ep.id}`}
|
||||
rec={ep}
|
||||
recording={recording}
|
||||
channel={channel}
|
||||
channelsById={channelsById}
|
||||
livePosterUrl={livePosterUrl}
|
||||
toUserTime={toUserTime}
|
||||
dateformat={dateformat}
|
||||
timeformat={timeformat}
|
||||
onOpenChild={(rec) => {
|
||||
setChildRec(rec);
|
||||
setChildOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{childOpen && childRec && (
|
||||
<RecordingDetailsModal
|
||||
|
|
@ -468,7 +488,7 @@ const RecordingDetailsModal = ({
|
|||
<Group gap={8}>
|
||||
{onWatchLive && <WatchLive />}
|
||||
{onWatchRecording && <WatchRecording />}
|
||||
{onEdit && start.isAfter(userNow()) && <Edit />}
|
||||
{onEdit && isAfter(start, userNow()) && <Edit />}
|
||||
{(customProps.status === 'completed' ||
|
||||
customProps.status === 'stopped' ||
|
||||
customProps.status === 'interrupted') &&
|
||||
|
|
@ -486,8 +506,8 @@ const RecordingDetailsModal = ({
|
|||
</Group>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
{format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{format(end, timeformat)}
|
||||
</Text>
|
||||
{rating && (
|
||||
<Group gap={8}>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import API from '../../api.js';
|
||||
import {
|
||||
parseDate,
|
||||
format,
|
||||
getNow,
|
||||
RECURRING_DAY_OPTIONS,
|
||||
toDate,
|
||||
toTimeString,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import dayjs from 'dayjs';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
|
|
@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates';
|
|||
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
deleteRecurringRuleById,
|
||||
getChannelOptions,
|
||||
getFormDefaults,
|
||||
getUpcomingOccurrences,
|
||||
updateRecurringRule,
|
||||
updateRecurringRuleEnabled,
|
||||
} from '../../utils/forms/RecurringRuleModalUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
getChannelsSummary,
|
||||
getRecurringFormDefaults,
|
||||
recurringFormValidators,
|
||||
sortedChannelOptions,
|
||||
} from '../../utils/forms/RecordingUtils.js';
|
||||
|
||||
const RecurringRuleModal = ({
|
||||
opened,
|
||||
|
|
@ -56,66 +62,18 @@ const RecurringRuleModal = ({
|
|||
const rule = recurringRules.find((r) => r.id === ruleId);
|
||||
|
||||
const channelOptions = useMemo(() => {
|
||||
return getChannelOptions(allChannels);
|
||||
return sortedChannelOptions(allChannels);
|
||||
}, [allChannels]);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: '',
|
||||
days_of_week: [],
|
||||
rule_name: '',
|
||||
start_time: dayjs().startOf('hour').format('HH:mm'),
|
||||
end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'),
|
||||
start_date: dayjs().toDate(),
|
||||
end_date: dayjs().toDate(),
|
||||
enabled: true,
|
||||
},
|
||||
validate: {
|
||||
channel_id: (value) => (value ? null : 'Select a channel'),
|
||||
days_of_week: (value) =>
|
||||
value && value.length ? null : 'Pick at least one day',
|
||||
end_time: (value, values) => {
|
||||
if (!value) return 'Select an end time';
|
||||
const startValue = dayjs(
|
||||
values.start_time,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
true
|
||||
);
|
||||
const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
|
||||
if (
|
||||
startValue.isValid() &&
|
||||
endValue.isValid() &&
|
||||
endValue.diff(startValue, 'minute') === 0
|
||||
) {
|
||||
return 'End time must differ from start time';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
end_date: (value, values) => {
|
||||
const endDate = dayjs(value);
|
||||
const startDate = dayjs(values.start_date);
|
||||
if (!value) return 'Select an end date';
|
||||
if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
|
||||
return 'End date cannot be before start date';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
initialValues: { ...getRecurringFormDefaults(), enabled: true },
|
||||
validate: recurringFormValidators,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && rule) {
|
||||
form.setValues({
|
||||
channel_id: `${rule.channel}`,
|
||||
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
|
||||
rule_name: rule.name || '',
|
||||
start_time: toTimeString(rule.start_time),
|
||||
end_time: toTimeString(rule.end_time),
|
||||
start_date: parseDate(rule.start_date) || dayjs().toDate(),
|
||||
end_date: parseDate(rule.end_date),
|
||||
enabled: Boolean(rule.enabled),
|
||||
});
|
||||
form.setValues(getFormDefaults(rule));
|
||||
} else {
|
||||
form.reset();
|
||||
}
|
||||
|
|
@ -127,7 +85,7 @@ const RecurringRuleModal = ({
|
|||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const chans = await API.getChannelsSummary();
|
||||
const chans = await getChannelsSummary();
|
||||
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load channels for recurring rule modal', e);
|
||||
|
|
@ -149,7 +107,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await updateRecurringRule(ruleId, values);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recurring rule updated',
|
||||
message: 'Schedule adjustments saved',
|
||||
color: 'green',
|
||||
|
|
@ -169,7 +127,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await deleteRecurringRuleById(ruleId);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recurring rule removed',
|
||||
message: 'All future occurrences were cancelled',
|
||||
color: 'red',
|
||||
|
|
@ -189,7 +147,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await updateRecurringRuleEnabled(ruleId, checked);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
|
||||
message: checked
|
||||
? 'Future occurrences will resume'
|
||||
|
|
@ -210,7 +168,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await deleteRecordingById(occurrence.id);
|
||||
// recording_cancelled WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Occurrence cancelled',
|
||||
message: 'The selected airing was removed',
|
||||
color: 'yellow',
|
||||
|
|
@ -246,7 +204,7 @@ const RecurringRuleModal = ({
|
|||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecordingById(sourceRecording.id);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recording deleted',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
|
|
@ -275,7 +233,7 @@ const RecurringRuleModal = ({
|
|||
};
|
||||
|
||||
const handleStartDateChange = (value) => {
|
||||
form.setFieldValue('start_date', value || dayjs().toDate());
|
||||
form.setFieldValue('start_date', value || toDate(getNow()));
|
||||
};
|
||||
|
||||
const handleEndDateChange = (value) => {
|
||||
|
|
@ -302,10 +260,11 @@ const RecurringRuleModal = ({
|
|||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2} flex={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{occStart.format(`${dateformat}, YYYY`)}
|
||||
{format(occStart, `${dateformat}, YYYY`)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{occStart.format(timeformat)} – {occEnd.format(timeformat)}
|
||||
{format(occStart, timeformat)} –{' '}
|
||||
{format(occEnd, timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ import {
|
|||
Popover,
|
||||
ActionIcon,
|
||||
Group,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
PopoverTarget,
|
||||
PopoverDropdown,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
|
|
@ -118,12 +118,12 @@ export default function ScheduleInput({
|
|||
<Group gap="xs">
|
||||
Cron Expression
|
||||
<Popover width={320} position="top" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<PopoverTarget>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<Info size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="sm">
|
||||
</PopoverTarget>
|
||||
<PopoverDropdown p="sm">
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
COMMON EXAMPLES
|
||||
</Text>
|
||||
|
|
@ -169,7 +169,7 @@ export default function ScheduleInput({
|
|||
<Code size="xs">30 14 1 * *</Code>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</PopoverDropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = {
|
|||
regex: 'Title regex',
|
||||
};
|
||||
|
||||
const renderRuleSummary = (r) => {
|
||||
const titleMode = (r.title_mode || 'exact').toLowerCase();
|
||||
const parts = [];
|
||||
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
|
||||
if (r.title) {
|
||||
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
|
||||
}
|
||||
if (r.description) {
|
||||
parts.push(`Description: "${r.description}"`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
};
|
||||
|
||||
export default function SeriesRecordingModal({
|
||||
opened,
|
||||
onClose,
|
||||
|
|
@ -59,19 +72,6 @@ export default function SeriesRecordingModal({
|
|||
onRulesUpdate(updated);
|
||||
};
|
||||
|
||||
const renderRuleSummary = (r) => {
|
||||
const titleMode = (r.title_mode || 'exact').toLowerCase();
|
||||
const parts = [];
|
||||
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
|
||||
if (r.title) {
|
||||
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
|
||||
}
|
||||
if (r.description) {
|
||||
parts.push(`Description: "${r.description}"`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
|
|
|||
|
|
@ -1,64 +1,37 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollAreaAutosize,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
SegmentedControl,
|
||||
Group,
|
||||
Button,
|
||||
Select,
|
||||
Badge,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
Alert,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import API from '../../api.js';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import useEPGsStore from '../../store/epgs.jsx';
|
||||
import { useDebounce } from '../../utils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
|
||||
const TITLE_MODES = [
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
|
||||
const DESCRIPTION_MODES = [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
|
||||
const EPISODE_MODES = [
|
||||
{ label: 'All episodes', value: 'all' },
|
||||
{ label: 'New only', value: 'new' },
|
||||
];
|
||||
|
||||
function formatRange(start, end) {
|
||||
try {
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
const sameDay = s.toDateString() === e.toDateString();
|
||||
const dateStr = s.toLocaleDateString();
|
||||
const startStr = s.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const endStr = e.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return sameDay
|
||||
? `${dateStr} ${startStr} - ${endStr}`
|
||||
: `${dateStr} ${startStr} -> ${e.toLocaleString()}`;
|
||||
} catch {
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
}
|
||||
import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js';
|
||||
import {
|
||||
createSeriesRule,
|
||||
evaluateSeriesRulesByTvgId,
|
||||
} from '../../utils/guideUtils.js';
|
||||
import {
|
||||
DESCRIPTION_MODES,
|
||||
EPISODE_MODES,
|
||||
formatRange,
|
||||
getChannelOptions,
|
||||
getTvgOptions,
|
||||
previewSeriesRule,
|
||||
TITLE_MODES,
|
||||
} from '../../utils/forms/SeriesRuleEditorModalUtils.js';
|
||||
|
||||
export default function SeriesRuleEditorModal({
|
||||
opened,
|
||||
|
|
@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({
|
|||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
let cancelled = false;
|
||||
API.getChannelsSummary()
|
||||
getChannelsSummary()
|
||||
.then((chans) => {
|
||||
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
|
||||
})
|
||||
|
|
@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({
|
|||
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal })
|
||||
previewSeriesRule(debouncedPreviewKey, controller)
|
||||
.then((resp) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPreview(resp || { matches: [], total: 0 });
|
||||
|
|
@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({
|
|||
// EPG channel options for the tvg_id selector. Deduplicate by tvg_id value
|
||||
// since the same channel can appear across multiple EPG sources.
|
||||
const tvgOptions = useMemo(() => {
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
for (const t of tvgs || []) {
|
||||
if (!t.tvg_id || seen.has(t.tvg_id)) continue;
|
||||
seen.add(t.tvg_id);
|
||||
options.push({
|
||||
value: t.tvg_id,
|
||||
label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
|
||||
});
|
||||
}
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return getTvgOptions(tvgs);
|
||||
}, [tvgs]);
|
||||
|
||||
// Channel select options: prefer channels matching the selected tvg_id.
|
||||
const channelOptions = useMemo(() => {
|
||||
const sorted = [...allChannels].sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum !== bNum) return aNum - bNum;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
const matching = [];
|
||||
const others = [];
|
||||
for (const c of sorted) {
|
||||
const item = {
|
||||
value: String(c.id),
|
||||
label: c.channel_number
|
||||
? `${c.channel_number} - ${c.name || `Channel ${c.id}`}`
|
||||
: c.name || `Channel ${c.id}`,
|
||||
};
|
||||
const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null;
|
||||
if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
|
||||
matching.push(item);
|
||||
} else {
|
||||
others.push(item);
|
||||
}
|
||||
}
|
||||
return [...matching, ...others];
|
||||
return getChannelOptions(allChannels, tvgsById, tvgId);
|
||||
}, [allChannels, tvgsById, tvgId]);
|
||||
|
||||
const canSave = !!(payload.title || payload.description);
|
||||
|
|
@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({
|
|||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await API.createSeriesRule(payload);
|
||||
await createSeriesRule(payload);
|
||||
// Trigger evaluation so matching upcoming programs get scheduled.
|
||||
try {
|
||||
await API.evaluateSeriesRules(payload.tvg_id);
|
||||
await evaluateSeriesRulesByTvgId(payload.tvg_id);
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to evaluate after save', e);
|
||||
|
|
@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({
|
|||
showNotification({ title: 'Series rule saved' });
|
||||
if (onSaved) await onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to save series rule', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<ScrollArea.Autosize mah={240}>
|
||||
<ScrollAreaAutosize mah={240}>
|
||||
<Stack gap={4}>
|
||||
{(preview.matches || []).map((p) => (
|
||||
<Group key={p.id} gap="xs" wrap="nowrap" align="flex-start">
|
||||
|
|
@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({
|
|||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</ScrollAreaAutosize>
|
||||
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
// Modal.js
|
||||
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 useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
|
||||
import { Button, Flex, Modal, Select, TextInput } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
});
|
||||
import {
|
||||
addStream,
|
||||
getResolver,
|
||||
updateStream,
|
||||
} from '../../utils/forms/StreamUtils.js';
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
|
|
@ -40,7 +37,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
|
|
@ -58,9 +55,9 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...payload });
|
||||
await updateStream(stream.id, payload);
|
||||
} else {
|
||||
await API.addStream(payload);
|
||||
await addStream(payload);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
|
|||
|
|
@ -1,50 +1,25 @@
|
|||
// StreamProfile form
|
||||
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 useUserAgentsStore from '../../store/userAgents';
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
Button,
|
||||
Flex,
|
||||
Stack,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
|
||||
// Built-in commands supported by Dispatcharr out of the box.
|
||||
const BUILT_IN_COMMANDS = [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: 'streamlink', label: 'Streamlink' },
|
||||
{ value: 'cvlc', label: 'VLC' },
|
||||
{ value: 'yt-dlp', label: 'yt-dlp' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
];
|
||||
|
||||
// Default parameter examples for each built-in command.
|
||||
const COMMAND_EXAMPLES = {
|
||||
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
|
||||
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
|
||||
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
|
||||
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
|
||||
};
|
||||
|
||||
// Returns '__custom__' when the command isn't one of the built-ins,
|
||||
// otherwise returns the command value itself.
|
||||
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 {
|
||||
addStreamProfile,
|
||||
BUILT_IN_COMMANDS,
|
||||
COMMAND_EXAMPLES,
|
||||
getResolver,
|
||||
toCommandSelection,
|
||||
updateStreamProfile,
|
||||
} from '../../utils/forms/StreamProfileUtils.js';
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
|
|
@ -73,7 +48,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
// Sync form + dropdown selection whenever the target profile or modal state changes
|
||||
|
|
@ -84,9 +59,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
|
||||
const onSubmit = async (values) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
await updateStreamProfile(profile.id, values);
|
||||
} else {
|
||||
await API.addStreamProfile(values);
|
||||
await addStreamProfile(values);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
@ -102,6 +77,17 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
const userAgentValue = watch('user_agent');
|
||||
const isActiveValue = watch('is_active');
|
||||
|
||||
const handleOnChangeCommand = (val) => {
|
||||
setCommandSelection(val);
|
||||
// For built-in selections, write the real command value immediately
|
||||
if (val !== '__custom__') {
|
||||
setValue('command', val, { shouldValidate: true });
|
||||
} else {
|
||||
// Clear so the user enters their own value
|
||||
setValue('command', '', { shouldValidate: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
|
|
@ -127,16 +113,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
data={BUILT_IN_COMMANDS}
|
||||
disabled={isLocked}
|
||||
value={commandSelection}
|
||||
onChange={(val) => {
|
||||
setCommandSelection(val);
|
||||
// For built-in selections, write the real command value immediately
|
||||
if (val !== '__custom__') {
|
||||
setValue('command', val, { shouldValidate: true });
|
||||
} else {
|
||||
// Clear so the user enters their own value
|
||||
setValue('command', '', { shouldValidate: false });
|
||||
}
|
||||
}}
|
||||
onChange={handleOnChangeCommand}
|
||||
error={isCustom ? undefined : errors.command?.message}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,21 @@ import useAuthStore from '../../store/auth';
|
|||
import useSettingsStore from '../../store/settings';
|
||||
import logo from '../../assets/logo.png';
|
||||
|
||||
const createSuperUser = (formData) => {
|
||||
return API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
};
|
||||
|
||||
function SuperuserForm() {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [_error, setError] = useState('');
|
||||
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
|
||||
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
|
||||
const storedVersion = useSettingsStore((s) => s.version);
|
||||
|
|
@ -43,11 +51,7 @@ function SuperuserForm() {
|
|||
e.preventDefault();
|
||||
try {
|
||||
console.log(formData);
|
||||
const response = await API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
const response = await createSuperUser(formData);
|
||||
if (response.superuser_exists) {
|
||||
setSuperuserExists(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,41 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Stack,
|
||||
MultiSelect,
|
||||
ActionIcon,
|
||||
Switch,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
TagsInput,
|
||||
Text,
|
||||
TextInput,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { RotateCcwKey, X } from 'lucide-react';
|
||||
import { Copy, Key } from 'lucide-react';
|
||||
import { Copy, Key, RotateCcwKey, X } from 'lucide-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import useOutputProfilesStore from '../../store/outputProfiles';
|
||||
import {
|
||||
USER_LEVELS,
|
||||
USER_LEVEL_LABELS,
|
||||
NETWORK_ACCESS_OPTIONS,
|
||||
} from '../../constants';
|
||||
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import { copyToClipboard } from '../../utils';
|
||||
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
|
||||
|
||||
const isValidNetworkEntry = (entry) =>
|
||||
entry.match(IPV4_CIDR_REGEX) ||
|
||||
entry.match(IPV6_CIDR_REGEX) ||
|
||||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
|
||||
(entry + '/128').match(IPV6_CIDR_REGEX);
|
||||
|
||||
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
|
||||
import {
|
||||
createUser,
|
||||
formValuesToPayload,
|
||||
generateApiKey,
|
||||
getFormInitialValues,
|
||||
getFormValidators,
|
||||
revokeApiKey,
|
||||
updateUser,
|
||||
userToFormValues,
|
||||
} from '../../utils/forms/UserUtils.js';
|
||||
|
||||
const User = ({ user = null, isOpen, onClose }) => {
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
|
|
@ -48,52 +46,15 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
const [, setEnableXC] = useState(false);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState(new Set());
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generatedKey, setGeneratedKey] = useState(null);
|
||||
const [_generatedKey, setGeneratedKey] = useState(null);
|
||||
const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
user_level: '0',
|
||||
stream_limit: 0,
|
||||
password: '',
|
||||
xc_password: '',
|
||||
output_format: '',
|
||||
output_profile: '',
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
epg_days: 0,
|
||||
epg_prev_days: 0,
|
||||
allowed_ips: [],
|
||||
},
|
||||
|
||||
validate: (values) => ({
|
||||
username: !values.username
|
||||
? 'Username is required'
|
||||
: values.user_level == USER_LEVELS.STREAMER &&
|
||||
!values.username.match(/^[a-z0-9]+$/i)
|
||||
? 'Streamer username must be alphanumeric'
|
||||
: null,
|
||||
password:
|
||||
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
|
||||
? 'Password is required'
|
||||
: null,
|
||||
xc_password:
|
||||
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
|
||||
? 'XC password must be alphanumeric'
|
||||
: null,
|
||||
allowed_ips: (values.allowed_ips || []).some(
|
||||
(t) => !isValidNetworkEntry(t)
|
||||
)
|
||||
? 'Invalid IP address or CIDR range'
|
||||
: null,
|
||||
}),
|
||||
initialValues: getFormInitialValues(),
|
||||
validate: getFormValidators(user),
|
||||
});
|
||||
|
||||
const onChannelProfilesChange = (values) => {
|
||||
|
|
@ -110,65 +71,18 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const payload = formValuesToPayload(form.getValues(), user);
|
||||
|
||||
const customProps = user?.custom_properties || {};
|
||||
|
||||
customProps.xc_password = values.xc_password || '';
|
||||
delete values.xc_password;
|
||||
|
||||
customProps.output_format = values.output_format || null;
|
||||
delete values.output_format;
|
||||
|
||||
customProps.output_profile = values.output_profile
|
||||
? parseInt(values.output_profile, 10)
|
||||
: null;
|
||||
delete values.output_profile;
|
||||
|
||||
customProps.hide_adult_content = values.hide_adult_content || false;
|
||||
delete values.hide_adult_content;
|
||||
|
||||
customProps.epg_days = values.epg_days || 0;
|
||||
delete values.epg_days;
|
||||
customProps.epg_prev_days = values.epg_prev_days || 0;
|
||||
delete values.epg_prev_days;
|
||||
|
||||
values.custom_properties = customProps;
|
||||
|
||||
// Serialize per-user network restrictions into custom_properties (same list for all types)
|
||||
const joined = (values.allowed_ips || []).join(',');
|
||||
delete values.allowed_ips;
|
||||
const allowed_networks = {};
|
||||
if (joined)
|
||||
NETWORK_KEYS.forEach((key) => {
|
||||
allowed_networks[key] = joined;
|
||||
});
|
||||
customProps.allowed_networks = allowed_networks;
|
||||
|
||||
if (values.channel_profiles.includes('0')) {
|
||||
values.channel_profiles = [];
|
||||
}
|
||||
|
||||
if (!user && values.user_level == USER_LEVELS.STREAMER) {
|
||||
values.password = Math.random().toString(36).slice(2);
|
||||
if (!user && payload.user_level == USER_LEVELS.STREAMER) {
|
||||
payload.password = Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
await API.createUser(values);
|
||||
await createUser(payload);
|
||||
} else {
|
||||
if (!values.password) {
|
||||
delete values.password;
|
||||
}
|
||||
|
||||
const response = await API.updateUser(
|
||||
user.id,
|
||||
values,
|
||||
isAdmin ? false : authUser.id === user.id
|
||||
);
|
||||
|
||||
if (user.id == authUser.id) {
|
||||
setUser(response);
|
||||
}
|
||||
if (!payload.password) delete payload.password;
|
||||
const response = await updateUser(user.id, payload, isAdmin, authUser);
|
||||
if (user.id == authUser.id) setUser(response);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
|
@ -178,38 +92,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
const customProps = user.custom_properties || {};
|
||||
const networks = customProps.allowed_networks || {};
|
||||
form.setValues(userToFormValues(user));
|
||||
|
||||
form.setValues({
|
||||
username: user.username,
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email,
|
||||
user_level: `${user.user_level}`,
|
||||
stream_limit: user.stream_limit || 0,
|
||||
channel_profiles:
|
||||
user.channel_profiles.length > 0
|
||||
? user.channel_profiles.map((id) => `${id}`)
|
||||
: ['0'],
|
||||
xc_password: customProps.xc_password || '',
|
||||
output_format: customProps.output_format || '',
|
||||
output_profile: customProps.output_profile
|
||||
? `${customProps.output_profile}`
|
||||
: '',
|
||||
hide_adult_content: customProps.hide_adult_content || false,
|
||||
epg_days: customProps.epg_days || 0,
|
||||
epg_prev_days: customProps.epg_prev_days || 0,
|
||||
allowed_ips: [
|
||||
...new Set(
|
||||
NETWORK_KEYS.flatMap((key) =>
|
||||
networks[key] ? networks[key].split(',').filter(Boolean) : []
|
||||
)
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
if (customProps.xc_password) {
|
||||
if (user.custom_properties?.xc_password) {
|
||||
setEnableXC(true);
|
||||
}
|
||||
|
||||
|
|
@ -248,13 +133,13 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
payload.user_id = user.id;
|
||||
}
|
||||
|
||||
const resp = await API.generateApiKey(payload);
|
||||
const resp = await generateApiKey(payload);
|
||||
const newKey = resp && (resp.key || resp.raw_key);
|
||||
if (newKey) {
|
||||
setGeneratedKey(newKey);
|
||||
setUserAPIKey(newKey);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// API shows notifications
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
|
|
@ -271,7 +156,8 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
payload.user_id = user.id;
|
||||
}
|
||||
|
||||
const resp = await API.revokeApiKey(payload);
|
||||
const resp = await revokeApiKey(payload);
|
||||
// backend returns { success: true } - clear local state
|
||||
if (resp && resp.success) {
|
||||
setGeneratedKey(null);
|
||||
setUserAPIKey(null);
|
||||
|
|
@ -280,7 +166,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
setUser({ ...authUser, api_key: null });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// API shows notifications
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
|
|
@ -291,16 +177,16 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Tabs defaultValue="account">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="account">Account</Tabs.Tab>
|
||||
<TabsList mb="md">
|
||||
<TabsTab value="account">Account</TabsTab>
|
||||
{showPermissions && (
|
||||
<Tabs.Tab value="permissions">Permissions</Tabs.Tab>
|
||||
<TabsTab value="permissions">Permissions</TabsTab>
|
||||
)}
|
||||
<Tabs.Tab value="epg">EPG Defaults</Tabs.Tab>
|
||||
<Tabs.Tab value="api">API & XC</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<TabsTab value="epg">EPG Defaults</TabsTab>
|
||||
<TabsTab value="api">API & XC</TabsTab>
|
||||
</TabsList>
|
||||
|
||||
<Tabs.Panel value="account">
|
||||
<TabsPanel value="account">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
|
|
@ -335,10 +221,10 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
|
||||
{showPermissions && (
|
||||
<Tabs.Panel value="permissions">
|
||||
<TabsPanel value="permissions">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
|
|
@ -375,10 +261,10 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
key={form.key('hide_adult_content')}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
)}
|
||||
|
||||
<Tabs.Panel value="epg">
|
||||
<TabsPanel value="epg">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
These defaults apply when no URL parameters are specified and
|
||||
|
|
@ -404,9 +290,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
|
||||
<Tabs.Panel value="api">
|
||||
<TabsPanel value="api">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="XC Password"
|
||||
|
|
@ -524,7 +410,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
|
|
|
|||
|
|
@ -1,26 +1,12 @@
|
|||
// Modal.js
|
||||
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, Checkbox, Flex, Modal, Space, TextInput } from '@mantine/core';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
});
|
||||
addUserAgent,
|
||||
getResolver,
|
||||
updateUserAgent,
|
||||
} from '../../utils/forms/UserAgentUtils.js';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const defaultValues = useMemo(
|
||||
|
|
@ -42,14 +28,14 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
await updateUserAgent(userAgent.id, values);
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
await addUserAgent(values);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,304 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Dependency mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteRecordingById: vi.fn(),
|
||||
deleteSeriesAndRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/guideUtils.js', () => ({
|
||||
deleteSeriesRuleByTvgId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../SeriesRuleEditorModal.jsx', () => ({
|
||||
default: ({ opened, onClose }) =>
|
||||
opened ? (
|
||||
<div data-testid="series-rule-editor-modal">
|
||||
<button data-testid="series-rule-editor-close" onClick={onClose}>
|
||||
Close Editor
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/core', async () => ({
|
||||
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,
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, disabled, loading, color, variant }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Anchor: ({ children, onClick }) => (
|
||||
<a data-testid="anchor" onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ProgramRecordingModal from '../ProgramRecordingModal.jsx';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
deleteSeriesAndRule,
|
||||
} from '../../../utils/cards/RecordingCardUtils.js';
|
||||
import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeProgram = (overrides = {}) => ({
|
||||
tvg_id: 'tvg-1',
|
||||
title: 'Test Show',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
program: makeProgram(),
|
||||
recording: null,
|
||||
existingRuleMode: null,
|
||||
existingRule: null,
|
||||
onRecordOne: vi.fn(),
|
||||
onRecordSeriesAll: vi.fn(),
|
||||
onRecordSeriesNew: vi.fn(),
|
||||
onExistingRuleModeChange: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ─── ProgramRecordingModal ─────────────────────────────────────────────────────
|
||||
|
||||
describe('ProgramRecordingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteSeriesRuleByTvgId).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<ProgramRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<ProgramRecordingModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProgramRecordingModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleRemoveRecording ──────────────────────────────────────────────────
|
||||
|
||||
describe('handleRemoveRecording', () => {
|
||||
it('calls deleteRecordingById with the recording id', async () => {
|
||||
const recording = makeRecording();
|
||||
render(<ProgramRecordingModal {...defaultProps({ recording })} />);
|
||||
fireEvent.click(screen.getByText('Remove this recording'));
|
||||
await waitFor(() => {
|
||||
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after deleting recording', async () => {
|
||||
const onClose = vi.fn();
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Remove this recording'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('still calls onClose when deleteRecordingById throws', async () => {
|
||||
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
|
||||
const onClose = vi.fn();
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Remove this recording'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleRemoveSeries ─────────────────────────────────────────────────────
|
||||
|
||||
describe('handleRemoveSeries', () => {
|
||||
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
|
||||
const program = makeProgram({ tvg_id: 'tvg-2', title: 'My Series' });
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ program, recording, existingRuleMode: 'series' })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove this series/i));
|
||||
await waitFor(() => {
|
||||
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
|
||||
tvg_id: 'tvg-2',
|
||||
title: 'My Series',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after removing series', async () => {
|
||||
const onClose = vi.fn();
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ onClose, recording, existingRuleMode: 'series' })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove this series/i));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleRemoveSeriesRule ─────────────────────────────────────────────────
|
||||
|
||||
describe('handleRemoveSeriesRule', () => {
|
||||
it('calls deleteSeriesRuleByTvgId with tvg_id and title', async () => {
|
||||
const program = makeProgram({ tvg_id: 'tvg-3', title: 'Rule Show' });
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ program, existingRuleMode: 'rule' })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove series rule/i));
|
||||
await waitFor(() => {
|
||||
expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith(
|
||||
'tvg-3',
|
||||
'Rule Show'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onExistingRuleModeChange(null) after removing rule', async () => {
|
||||
const onExistingRuleModeChange = vi.fn();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({
|
||||
existingRuleMode: 'rule',
|
||||
onExistingRuleModeChange,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove series rule/i));
|
||||
await waitFor(() => {
|
||||
expect(onExistingRuleModeChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after removing rule', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ existingRuleMode: 'rule', onClose })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove series rule/i));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Record actions ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('record actions', () => {
|
||||
it('calls onRecordOne when "Record Once" is clicked', () => {
|
||||
const onRecordOne = vi.fn();
|
||||
render(<ProgramRecordingModal {...defaultProps({ onRecordOne })} />);
|
||||
fireEvent.click(screen.getByText('Just this one'));
|
||||
expect(onRecordOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onRecordSeriesAll when "Record All" is clicked', () => {
|
||||
const onRecordSeriesAll = vi.fn();
|
||||
render(
|
||||
<ProgramRecordingModal {...defaultProps({ onRecordSeriesAll })} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Every episode'));
|
||||
expect(onRecordSeriesAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onRecordSeriesNew when "Record New" is clicked', () => {
|
||||
const onRecordSeriesNew = vi.fn();
|
||||
render(
|
||||
<ProgramRecordingModal {...defaultProps({ onRecordSeriesNew })} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('New episodes only'));
|
||||
expect(onRecordSeriesNew).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SeriesRuleEditorModal ──────────────────────────────────────────────────
|
||||
|
||||
describe('SeriesRuleEditorModal', () => {
|
||||
it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({
|
||||
existingRuleMode: 'rule',
|
||||
existingRule: { id: 1 },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Customize rule/i));
|
||||
expect(
|
||||
screen.getByTestId('series-rule-editor-modal')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes SeriesRuleEditorModal when its onClose is called', () => {
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({
|
||||
existingRuleMode: 'rule',
|
||||
existingRule: { id: 1 },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Customize rule/i));
|
||||
fireEvent.click(screen.getByTestId('series-rule-editor-close'));
|
||||
expect(
|
||||
screen.queryByTestId('series-rule-editor-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
580
frontend/src/components/forms/__tests__/Recording.test.jsx
Normal file
580
frontend/src/components/forms/__tests__/Recording.test.jsx
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RecordingModal from '../Recording';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/dateTimeUtils.js', () => ({
|
||||
RECURRING_DAY_OPTIONS: [
|
||||
{ value: 'mon', label: 'Monday' },
|
||||
{ value: 'tue', label: 'Tuesday' },
|
||||
],
|
||||
toTimeString: vi.fn((val) => val),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
|
||||
buildRecurringPayload: vi.fn((v) => v),
|
||||
buildSinglePayload: vi.fn((v) => v),
|
||||
createRecording: vi.fn(),
|
||||
createRecurringRule: vi.fn(),
|
||||
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: '501 - HBO' }]),
|
||||
numberedChannelLabel: vi.fn((item) =>
|
||||
item.channel_number ? `${item.channel_number} - ${item.name}` : item.name
|
||||
),
|
||||
getChannelsSummary: vi.fn(),
|
||||
getRecurringFormDefaults: vi.fn(() => ({
|
||||
channel_id: '',
|
||||
rule_name: '',
|
||||
days_of_week: [],
|
||||
start_date: new Date('2024-01-01'),
|
||||
end_date: null,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
})),
|
||||
getSingleFormDefaults: vi.fn(() => ({
|
||||
channel_id: 'ch-1',
|
||||
start_time: new Date('2024-06-01T10:00:00'),
|
||||
end_time: new Date('2024-06-01T11:00:00'),
|
||||
})),
|
||||
recurringFormValidators: {},
|
||||
singleFormValidators: {},
|
||||
timeChange: vi.fn((fn) => (e) => fn(e.target.value)),
|
||||
updateRecording: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── @mantine/form ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({
|
||||
useForm: vi.fn(({ initialValues }) => {
|
||||
const values = { ...initialValues };
|
||||
return {
|
||||
values,
|
||||
key: vi.fn((k) => k),
|
||||
getInputProps: vi.fn((k) => ({
|
||||
name: k,
|
||||
value: values[k] ?? '',
|
||||
onChange: vi.fn(),
|
||||
})),
|
||||
onSubmit: vi.fn((handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler(values);
|
||||
}),
|
||||
reset: vi.fn(),
|
||||
setValues: vi.fn((newVals) => Object.assign(values, newVals)),
|
||||
setFieldValue: vi.fn((k, v) => {
|
||||
values[k] = v;
|
||||
}),
|
||||
validateField: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ children, title }) => (
|
||||
<div data-testid="alert">
|
||||
<span data-testid="alert-title">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, loading, type }) => (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
data-loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Loader: ({ size, color }) => (
|
||||
<span data-testid="loader" data-size={size} data-color={color} />
|
||||
),
|
||||
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, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`multiselect-${label}`}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
SegmentedControl: ({ value, onChange, data, disabled }) => (
|
||||
<div data-testid="segmented-control">
|
||||
{data.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-testid={`mode-${item.value}`}
|
||||
data-active={value === item.value}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, disabled, rightSection, data, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{rightSection}
|
||||
<select data-testid={`select-${label}`} disabled={disabled} {...props}>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
TextInput: ({ label, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`textinput-${label}`}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── @mantine/dates ─────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/dates', () => ({
|
||||
DatePickerInput: ({ label, value, onChange }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`datepicker-${label}`}
|
||||
value={value ? (value.toISOString?.() ?? value) : ''}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value ? new Date(e.target.value) : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
DateTimePicker: ({ label, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input data-testid={`datetimepicker-${label}`} {...props} />
|
||||
</div>
|
||||
),
|
||||
TimeInput: ({ label, value, onChange, onBlur }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`timeinput-${label}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
CircleAlert: () => <svg data-testid="icon-circle-alert" />,
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
|
||||
|
||||
const setupStoreMock = () => {
|
||||
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
fetchRecordings: mockFetchRecordings,
|
||||
fetchRecurringRules: mockFetchRecurringRules,
|
||||
})
|
||||
);
|
||||
|
||||
return { mockFetchRecordings, mockFetchRecurringRules };
|
||||
};
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
start_time: '2024-06-01T10:00:00Z',
|
||||
end_time: '2024-06-01T11:00:00Z',
|
||||
custom_properties: { program: { title: 'Test Show' } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
|
||||
|
||||
describe('RecordingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
|
||||
{ id: 'ch-1', name: 'HBO', channel_number: 501 },
|
||||
]);
|
||||
vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined);
|
||||
setupStoreMock();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Alert ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('scheduling conflict alert', () => {
|
||||
it('renders the scheduling conflicts alert', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-title')).toHaveTextContent(
|
||||
'Scheduling Conflicts'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mode switching ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('mode switching', () => {
|
||||
it('defaults to "single" mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('mode-single')).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('switches to recurring mode when Recurring button clicked', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
expect(screen.getByTestId('mode-recurring')).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows DateTimePicker fields in single mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows recurring fields when in recurring mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables mode toggle when editing an existing recording', () => {
|
||||
render(
|
||||
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
|
||||
);
|
||||
expect(screen.getByTestId('mode-single')).toBeDisabled();
|
||||
expect(screen.getByTestId('mode-recurring')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows "Schedule Recording" submit button in single mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Schedule Recording')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Save Rule" submit button in recurring mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
expect(screen.getByText('Save Rule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel loading ────────────────────────────────────────────────────────
|
||||
|
||||
describe('channel loading', () => {
|
||||
it('calls getChannelsSummary when modal opens', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with loaded channels', async () => {
|
||||
const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }];
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels);
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
|
||||
channels,
|
||||
RecordingUtils.numberedChannelLabel
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
|
||||
[],
|
||||
RecordingUtils.numberedChannelLabel
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not load channels when modal is closed', () => {
|
||||
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
|
||||
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single form submit (create) ────────────────────────────────────────────
|
||||
|
||||
describe('single mode – create recording', () => {
|
||||
it('calls buildSinglePayload with form values on submit', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createRecording when no existing recording', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.createRecording).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recording scheduled" notification after successful create', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recording scheduled',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after successful create', async () => {
|
||||
const { mockFetchRecordings } = setupStoreMock();
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful create', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingModal isOpen onClose={onClose} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call showNotification when createRecording throws', async () => {
|
||||
vi.mocked(RecordingUtils.createRecording).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single form submit (update) ────────────────────────────────────────────
|
||||
|
||||
describe('single mode – update recording', () => {
|
||||
it('calls updateRecording when editing an existing recording', async () => {
|
||||
render(
|
||||
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.updateRecording).toHaveBeenCalledWith(
|
||||
'rec-1',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call createRecording when updating', async () => {
|
||||
render(
|
||||
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.createRecording).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recording updated" notification after successful update', async () => {
|
||||
render(
|
||||
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recording updated',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Recurring form submit ──────────────────────────────────────────────────
|
||||
|
||||
describe('recurring mode – create rule', () => {
|
||||
it('calls buildRecurringPayload with form values on submit', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createRecurringRule on submit', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.createRecurringRule).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules and fetchRecordings on success', async () => {
|
||||
const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock();
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule saved" notification on success', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule saved',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful recurring submit', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingModal isOpen onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when createRecurringRule throws', async () => {
|
||||
vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form initialization ────────────────────────────────────────────────────
|
||||
|
||||
describe('form initialization', () => {
|
||||
it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => {
|
||||
const recording = makeRecording();
|
||||
const channel = makeChannel();
|
||||
render(
|
||||
<RecordingModal
|
||||
isOpen
|
||||
recording={recording}
|
||||
channel={channel}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
|
||||
recording,
|
||||
channel
|
||||
);
|
||||
});
|
||||
|
||||
it('calls getSingleFormDefaults with null when opening for new recording', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('calls getRecurringFormDefaults with channel on open', () => {
|
||||
const channel = makeChannel();
|
||||
render(<RecordingModal isOpen channel={channel} onClose={vi.fn()} />);
|
||||
expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(
|
||||
channel
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Close / reset ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('close and reset', () => {
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingModal isOpen onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,806 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/useVideoStore.jsx', () => ({
|
||||
default: Object.assign(vi.fn(), {
|
||||
getState: vi.fn(() => ({ showVideo: vi.fn() })),
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/dateTimeUtils.js', () => ({
|
||||
format: vi.fn(),
|
||||
isAfter: vi.fn(),
|
||||
isBefore: vi.fn(),
|
||||
useDateTimeFormat: vi.fn(),
|
||||
useTimeHelpers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteRecordingById: vi.fn(),
|
||||
getChannelLogoUrl: vi.fn(),
|
||||
getPosterUrl: vi.fn(),
|
||||
getRecordingUrl: vi.fn(),
|
||||
getSeasonLabel: vi.fn(),
|
||||
getShowVideoUrl: vi.fn(),
|
||||
runComSkip: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({
|
||||
getChannel: vi.fn(),
|
||||
getRating: vi.fn(),
|
||||
getStatRows: vi.fn(),
|
||||
getUpcomingEpisodes: vi.fn(),
|
||||
refreshArtwork: vi.fn(),
|
||||
updateRecordingMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => <svg data-testid="icon-check" />,
|
||||
Pencil: () => <svg data-testid="icon-pencil" />,
|
||||
RefreshCcw: () => <svg data-testid="icon-refresh" />,
|
||||
X: () => <svg data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, disabled }) => (
|
||||
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-loading={loading}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Card: ({ children, onClick, style }) => (
|
||||
<div data-testid="card" onClick={onClick} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Image: ({ src, alt, fallbackSrc }) => (
|
||||
<img src={src} alt={alt} data-fallback={fallbackSrc} />
|
||||
),
|
||||
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, c, fw, style }) => (
|
||||
<span data-size={size} data-color={c} data-fw={fw} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Textarea: ({ label, value, onChange, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<textarea
|
||||
data-testid={`textarea-${label ?? placeholder ?? 'unknown'}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, value, onChange, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label />
|
||||
<input
|
||||
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
style={props.style}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import RecordingDetailsModal from '../RecordingDetailsModal';
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import useVideoStore from '../../../store/useVideoStore.jsx';
|
||||
import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../../utils/dateTimeUtils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js';
|
||||
import * as RecordingDetailsModalUtils from '../../../utils/forms/RecordingDetailsModalUtils.js';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const PAST = '2020-01-01T10:00:00Z';
|
||||
const FUTURE = '2099-01-01T10:00:00Z';
|
||||
const NOW = '2024-06-01T12:00:00Z';
|
||||
|
||||
const makeMoment = (isoString) => {
|
||||
const d = dayjs(isoString);
|
||||
return {
|
||||
isAfter: (other) => d.isAfter(other?._d ?? other),
|
||||
isBefore: (other) => d.isBefore(other?._d ?? other),
|
||||
format: vi.fn((fmt) => d.format(fmt)),
|
||||
_d: d.toDate(),
|
||||
};
|
||||
};
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
channel: 'ch-1',
|
||||
start_time: PAST,
|
||||
end_time: PAST,
|
||||
_group_count: 1,
|
||||
custom_properties: {
|
||||
status: 'completed',
|
||||
file_url: '/recordings/test.ts',
|
||||
program: {
|
||||
title: 'Test Show',
|
||||
description: 'A test description',
|
||||
sub_title: 'Pilot',
|
||||
},
|
||||
...overrides.custom_properties,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
|
||||
|
||||
const mockShowVideo = vi.fn();
|
||||
|
||||
const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
|
||||
const nowMoment = makeMoment(now);
|
||||
const startMoment = makeMoment(recording.start_time);
|
||||
const endMoment = makeMoment(recording.end_time);
|
||||
|
||||
vi.mocked(useTimeHelpers).mockReturnValue({
|
||||
toUserTime: (iso) => {
|
||||
if (iso === recording.start_time) return startMoment;
|
||||
if (iso === recording.end_time) return endMoment;
|
||||
return makeMoment(iso);
|
||||
},
|
||||
userNow: () => nowMoment,
|
||||
});
|
||||
|
||||
vi.mocked(useDateTimeFormat).mockReturnValue({
|
||||
timeFormat: 'HH:mm',
|
||||
dateFormat: 'MM/DD',
|
||||
});
|
||||
|
||||
vi.mocked(format).mockImplementation((moment, fmt) => moment.format(fmt));
|
||||
vi.mocked(isAfter).mockImplementation((a, b) => a.isAfter(b));
|
||||
vi.mocked(isBefore).mockImplementation((a, b) => a.isBefore(b));
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ recordings: [recording] })
|
||||
);
|
||||
|
||||
mockShowVideo.mockReset();
|
||||
vi.mocked(useVideoStore).mockImplementation((sel) =>
|
||||
sel({ showVideo: mockShowVideo })
|
||||
);
|
||||
useVideoStore.getState = vi.fn(() => ({ showVideo: mockShowVideo }));
|
||||
|
||||
vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
|
||||
vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
|
||||
vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue(
|
||||
'/recordings/test.ts'
|
||||
);
|
||||
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
|
||||
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
|
||||
|
||||
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue(null);
|
||||
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([]);
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([]);
|
||||
vi.mocked(RecordingDetailsModalUtils.getChannel).mockResolvedValue(null);
|
||||
};
|
||||
|
||||
const defaultProps = () => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
recording: makeRecording(),
|
||||
channel: makeChannel(),
|
||||
posterUrl: '/poster.jpg',
|
||||
onWatchLive: vi.fn(),
|
||||
onWatchRecording: vi.fn(),
|
||||
env_mode: 'production',
|
||||
onEdit: vi.fn(),
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
describe('RecordingDetailsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
vi.mocked(
|
||||
RecordingDetailsModalUtils.updateRecordingMetadata
|
||||
).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when opened is false', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} opened={false} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null when recording is not provided', () => {
|
||||
const { container } = render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={null} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingDetailsModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Content rendering ────────────────────────────────────────────────────────
|
||||
|
||||
describe('content rendering', () => {
|
||||
it('renders the recording title', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Custom Recording" when no program title', () => {
|
||||
const recording = makeRecording({
|
||||
custom_properties: { status: 'completed', program: {} },
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the description', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('A test description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the poster image', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByAltText('Test Show')).toHaveAttribute(
|
||||
'src',
|
||||
'/poster.jpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the season/episode label when present', () => {
|
||||
const seriesRecording = makeRecording({ _group_count: 2 });
|
||||
const mockEpisode = makeRecording({ id: 'ep-1', _group_count: 1 });
|
||||
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[mockEpisode]
|
||||
);
|
||||
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
|
||||
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={seriesRecording}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('S01E02')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rating when present', () => {
|
||||
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue('TV-MA');
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('TV-MA')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat rows when available', () => {
|
||||
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([
|
||||
['Video Codec', 'H264'],
|
||||
['Audio Codec', 'AAC'],
|
||||
]);
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Stream Stats')).toBeInTheDocument();
|
||||
expect(screen.getByText('Video Codec')).toBeInTheDocument();
|
||||
expect(screen.getByText('H264')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Watch buttons ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Watch button', () => {
|
||||
it('renders Watch button for completed recording', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Watch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onWatchRecording when Watch is clicked', () => {
|
||||
const onWatchRecording = vi.fn();
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
onWatchRecording={onWatchRecording}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Watch'));
|
||||
expect(onWatchRecording).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Watch button is disabled when no file_url', () => {
|
||||
const recording = makeRecording({
|
||||
custom_properties: {
|
||||
status: 'completed',
|
||||
program: { title: 'Test Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Watch')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Watch Live button', () => {
|
||||
it('renders Watch Live button for in-progress recording', () => {
|
||||
const recording = makeRecording({
|
||||
start_time: PAST,
|
||||
end_time: FUTURE,
|
||||
custom_properties: {
|
||||
status: 'recording',
|
||||
file_url: '/f.ts',
|
||||
program: { title: 'Live Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Watch Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onWatchLive when Watch Live is clicked', () => {
|
||||
const recording = makeRecording({
|
||||
start_time: PAST,
|
||||
end_time: FUTURE,
|
||||
custom_properties: {
|
||||
status: 'recording',
|
||||
file_url: '/f.ts',
|
||||
program: { title: 'Live Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
const onWatchLive = vi.fn();
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={recording}
|
||||
onWatchLive={onWatchLive}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Watch Live'));
|
||||
expect(onWatchLive).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit button ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Edit button', () => {
|
||||
it('renders the Edit button', () => {
|
||||
const futureRecording = makeRecording({
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
});
|
||||
setupMocks({ recording: futureRecording });
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={futureRecording}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onEdit when Edit is clicked', () => {
|
||||
const futureRecording = makeRecording({
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
});
|
||||
setupMocks({ recording: futureRecording });
|
||||
const onEdit = vi.fn();
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={futureRecording}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(onEdit).toHaveBeenCalledWith(futureRecording);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inline editing ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('inline metadata editing', () => {
|
||||
it('shows edit inputs when pencil icon is clicked', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
expect(
|
||||
screen.getByTestId('textinput-Recording title')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills title input with current title', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
expect(screen.getByTestId('textinput-Recording title')).toHaveValue(
|
||||
'Test Show'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-fills description input with current description', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue(
|
||||
'A test description'
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels editing when X icon is clicked', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
expect(
|
||||
screen.getByTestId('textinput-Recording title')
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
|
||||
expect(
|
||||
screen.queryByTestId('textinput-Recording title')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls updateRecordingMetadata with new title and description on save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: 'New Title' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('textarea-Description (optional)'), {
|
||||
target: { value: 'New Description' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecordingDetailsModalUtils.updateRecordingMetadata
|
||||
).toHaveBeenCalledWith(makeRecording(), 'New Title', 'New Description');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows saved title optimistically after save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: 'Updated Title' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Updated Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when updateRecordingMetadata throws', async () => {
|
||||
vi.mocked(
|
||||
RecordingDetailsModalUtils.updateRecordingMetadata
|
||||
).mockRejectedValue(new Error('fail'));
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses "Custom Recording" when title is cleared on save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh artwork ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('refresh artwork', () => {
|
||||
it('calls refreshArtwork with recording id on click', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Refresh artwork'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(RecordingDetailsModalUtils.refreshArtwork).toHaveBeenCalledWith(
|
||||
'rec-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after refreshArtwork', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Refresh artwork'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: 'blue.5' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when refreshArtwork throws', async () => {
|
||||
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Refresh artwork'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── ComSkip ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('run comskip', () => {
|
||||
it('calls runComSkip with the recording', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove commercials'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
|
||||
makeRecording()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after runComSkip', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove commercials'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: expect.any(String) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when runComSkip throws', async () => {
|
||||
vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove commercials'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show Remove commercials when comskip is completed', () => {
|
||||
const recording = makeRecording({
|
||||
custom_properties: {
|
||||
status: 'completed',
|
||||
file_url: '/f.ts',
|
||||
comskip: { status: 'completed' },
|
||||
program: { title: 'Test Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Series / episodes ────────────────────────────────────────────────────────
|
||||
|
||||
describe('series group', () => {
|
||||
const makeSeriesRecording = () =>
|
||||
makeRecording({
|
||||
_group_count: 3,
|
||||
custom_properties: {
|
||||
status: 'scheduled',
|
||||
program: { title: 'Series Show', tvg_id: 'series-1' },
|
||||
},
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
});
|
||||
|
||||
const makeEpisode = (id = 'ep-1') => ({
|
||||
id,
|
||||
channel: 'ch-1',
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
custom_properties: {
|
||||
status: 'scheduled',
|
||||
program: { title: 'Episode', sub_title: `Sub ${id}` },
|
||||
},
|
||||
});
|
||||
|
||||
it('renders upcoming episodes list when isSeriesGroup is true', () => {
|
||||
const recording = makeSeriesRecording();
|
||||
const episode = makeEpisode();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[episode]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Sub ep-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No upcoming episodes" when episode list is empty for series', () => {
|
||||
const recording = makeSeriesRecording();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText(/no upcoming episodes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens child modal when an episode card is clicked', async () => {
|
||||
const recording = makeSeriesRecording();
|
||||
const episode = makeEpisode();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[episode]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
const episodeCards = screen.getAllByTestId('card');
|
||||
fireEvent.click(episodeCards[episodeCards.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('modal').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls deleteRecordingById when episode remove is clicked', async () => {
|
||||
const recording = makeSeriesRecording();
|
||||
const episode = makeEpisode();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[episode]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
|
||||
'ep-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── safeRecording merging ────────────────────────────────────────────────────
|
||||
|
||||
describe('safeRecording store merge', () => {
|
||||
it('picks updated recording from store while preserving _group_count', () => {
|
||||
const original = makeRecording({ _group_count: 3 });
|
||||
const storeVersion = {
|
||||
...makeRecording(),
|
||||
id: 'rec-1',
|
||||
custom_properties: {
|
||||
...makeRecording().custom_properties,
|
||||
program: { title: 'Updated Title' },
|
||||
},
|
||||
};
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ recordings: [storeVersion] })
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={original} />
|
||||
);
|
||||
expect(screen.getByText(/Updated Title/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Optimistic state reset ───────────────────────────────────────────────────
|
||||
|
||||
describe('optimistic state reset on recording change', () => {
|
||||
it('resets saved title when recording id changes', async () => {
|
||||
const { rerender } = render(
|
||||
<RecordingDetailsModal {...defaultProps()} />
|
||||
);
|
||||
// Save a new title
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: 'Custom' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// Switch to a different recording
|
||||
const newRecording = makeRecording({ id: 'rec-2' });
|
||||
setupMocks({ recording: newRecording });
|
||||
rerender(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={newRecording} />
|
||||
);
|
||||
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,985 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/dateTimeUtils.js', () => ({
|
||||
format: vi.fn((moment, fmt) => `formatted-${fmt}`),
|
||||
getNow: vi.fn(() => '2024-06-01T12:00:00Z'),
|
||||
RECURRING_DAY_OPTIONS: [
|
||||
{ value: 'mon', label: 'Monday' },
|
||||
{ value: 'tue', label: 'Tuesday' },
|
||||
{ value: 'wed', label: 'Wednesday' },
|
||||
],
|
||||
toDate: vi.fn((val) => new Date(val)),
|
||||
toTimeString: vi.fn((val) => val),
|
||||
useDateTimeFormat: vi.fn(),
|
||||
useTimeHelpers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteRecordingById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecurringRuleModalUtils.js', () => ({
|
||||
deleteRecurringRuleById: vi.fn(),
|
||||
getFormDefaults: vi.fn(),
|
||||
getUpcomingOccurrences: vi.fn(),
|
||||
updateRecurringRule: vi.fn(),
|
||||
updateRecurringRuleEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
|
||||
getChannelsSummary: vi.fn(),
|
||||
getRecurringFormDefaults: vi.fn(),
|
||||
recurringFormValidators: {},
|
||||
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: 'HBO' }]),
|
||||
}));
|
||||
|
||||
// ── @mantine/form ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ initialValues }) => {
|
||||
const [values, setValuesState] = React.useState({ ...initialValues });
|
||||
return {
|
||||
values,
|
||||
key: vi.fn((k) => k),
|
||||
getInputProps: vi.fn((k) => ({
|
||||
name: k,
|
||||
value: values[k] ?? '',
|
||||
onChange: vi.fn((e) => {
|
||||
const v = e?.currentTarget?.value ?? e?.target?.value ?? e;
|
||||
setValuesState((prev) => ({ ...prev, [k]: v }));
|
||||
}),
|
||||
})),
|
||||
onSubmit: vi.fn((handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler(values);
|
||||
}),
|
||||
reset: vi.fn(() => setValuesState({ ...initialValues })),
|
||||
setValues: vi.fn((newVals) =>
|
||||
setValuesState((prev) => ({ ...prev, ...newVals }))
|
||||
),
|
||||
setFieldValue: vi.fn((k, v) =>
|
||||
setValuesState((prev) => ({ ...prev, [k]: v }))
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
type,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
disabled,
|
||||
}) => (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Card: ({ children }) => <div data-testid="occurrence-card">{children}</div>,
|
||||
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, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select data-testid={`multiselect-${label}`} multiple {...props}>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, data, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select data-testid={`select-${label}`} {...props}>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Switch: ({ checked, onChange, label, disabled }) => (
|
||||
<label>
|
||||
<input
|
||||
data-testid="switch"
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
// Mirror the event shape the component expects: event.currentTarget.checked
|
||||
onChange({ currentTarget: { checked: e.target.checked } });
|
||||
}}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
),
|
||||
Text: ({ children, size, c, fw }) => (
|
||||
<span data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
TextInput: ({ label, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── @mantine/dates ─────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/dates', () => ({
|
||||
DatePickerInput: ({ label, value, onChange }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`datepicker-${label}`}
|
||||
value={value ? (value.toISOString?.() ?? String(value)) : ''}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value ? new Date(e.target.value) : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TimeInput: ({ label, value, onChange }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`timeinput-${label}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import {
|
||||
format,
|
||||
toTimeString,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../../utils/dateTimeUtils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import { deleteRecordingById } from '../../../utils/cards/RecordingCardUtils.js';
|
||||
import * as RecurringRuleModalUtils from '../../../utils/forms/RecurringRuleModalUtils.js';
|
||||
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
|
||||
import { useForm } from '@mantine/form';
|
||||
import RecurringRuleModal from '../RecurringRuleModal';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const FUTURE = '2099-01-01T10:00:00Z';
|
||||
const NOW = '2024-06-01T12:00:00Z';
|
||||
|
||||
const makeMoment = (isoString) => {
|
||||
const d = dayjs(isoString);
|
||||
return {
|
||||
isAfter: (other) => d.isAfter(other?._d ?? other),
|
||||
isBefore: (other) => d.isBefore(other?._d ?? other),
|
||||
format: vi.fn((fmt) => `formatted-${fmt}`),
|
||||
_d: d.toDate(),
|
||||
};
|
||||
};
|
||||
|
||||
const makeRule = (overrides = {}) => ({
|
||||
id: 'rule-1',
|
||||
name: 'Morning News',
|
||||
channel: 'ch-1',
|
||||
channel_id: 'ch-1',
|
||||
days_of_week: ['mon', 'tue'],
|
||||
start_date: new Date('2024-01-01'),
|
||||
end_date: null,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeOccurrence = (id = 'occ-1') => ({
|
||||
id,
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
custom_properties: { program: { title: 'Episode' } },
|
||||
});
|
||||
|
||||
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
custom_properties: { program: { title: 'Morning News' } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
|
||||
const nowMoment = makeMoment(NOW);
|
||||
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
recurringRules: rule ? [rule] : [],
|
||||
fetchRecurringRules: mockFetchRecurringRules,
|
||||
recordings: [],
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(useTimeHelpers).mockReturnValue({
|
||||
toUserTime: (iso) => makeMoment(iso),
|
||||
userNow: () => nowMoment,
|
||||
});
|
||||
|
||||
vi.mocked(useDateTimeFormat).mockReturnValue({
|
||||
timeFormat: 'HH:mm',
|
||||
dateFormat: 'MM/DD',
|
||||
});
|
||||
|
||||
vi.mocked(format).mockImplementation((moment, fmt) => `formatted-${fmt}`);
|
||||
|
||||
vi.mocked(RecordingUtils.getRecurringFormDefaults).mockReturnValue({
|
||||
channel_id: '',
|
||||
rule_name: '',
|
||||
days_of_week: [],
|
||||
start_date: new Date('2024-01-01'),
|
||||
end_date: null,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
enabled: rule?.enabled ?? true,
|
||||
});
|
||||
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
|
||||
makeChannel(),
|
||||
]);
|
||||
|
||||
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
|
||||
channel_id: rule?.channel_id ?? '',
|
||||
rule_name: rule?.name ?? '',
|
||||
days_of_week: rule?.days_of_week ?? [],
|
||||
start_date: rule?.start_date ?? new Date('2024-01-01'),
|
||||
end_date: rule?.end_date ?? null,
|
||||
start_time: rule?.start_time ?? '08:00',
|
||||
end_time: rule?.end_time ?? '09:00',
|
||||
enabled: rule?.enabled ?? true,
|
||||
});
|
||||
|
||||
vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(
|
||||
occurrences
|
||||
);
|
||||
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
vi.mocked(
|
||||
RecurringRuleModalUtils.updateRecurringRuleEnabled
|
||||
).mockResolvedValue(undefined);
|
||||
vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
|
||||
|
||||
return { mockFetchRecurringRules };
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
ruleId: 'rule-1',
|
||||
recording: null,
|
||||
onEditOccurrence: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
describe('RecurringRuleModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when opened is false', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses rule name as modal title', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Morning News'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to "Recurring Rule" when rule has no name', () => {
|
||||
setupMocks({ rule: makeRule({ name: '' }) });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Recurring Rule'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Missing rule fallback ────────────────────────────────────────────────────
|
||||
|
||||
describe('missing rule fallback', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks({ rule: null });
|
||||
});
|
||||
|
||||
it('renders fallback message when rule does not exist', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The recurring rule for this recording no longer exists.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Delete Recording button when sourceRecording is provided', () => {
|
||||
render(
|
||||
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
|
||||
);
|
||||
expect(screen.getByText('Delete Recording')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Delete Recording button when sourceRecording is null', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} recording={null} />);
|
||||
expect(screen.queryByText('Delete Recording')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls deleteRecordingById when Delete Recording is confirmed', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
recording={makeRecording()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recording deleted" notification after deleting orphaned recording', async () => {
|
||||
render(
|
||||
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recording deleted',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after deleting orphaned recording', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
recording={makeRecording()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked in fallback', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
recording={makeRecording()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show notification when deleteRecordingById throws', async () => {
|
||||
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
|
||||
render(
|
||||
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders the Channel select', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-Channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Rule name input', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Every (days of week) multiselect', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('multiselect-Every')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Start date and End date pickers', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('datepicker-Start date')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('datepicker-End date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Start time and End time inputs', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the enabled/paused Switch', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switch reflects rule enabled state', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('switch')).toBeChecked();
|
||||
});
|
||||
|
||||
it('switch is unchecked when rule is paused', () => {
|
||||
setupMocks({ rule: makeRule({ enabled: false }) });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('switch')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders channel name in header', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByText('HBO')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders channel number fallback when channel not in allChannels', async () => {
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([]);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Channel ch-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel loading ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('channel loading', () => {
|
||||
it('calls getChannelsSummary when opened', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call getChannelsSummary when closed', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
|
||||
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with loaded channels', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([
|
||||
makeChannel(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form initialization from rule ─────────────────────────────────────────────
|
||||
|
||||
describe('form initialization', () => {
|
||||
it('calls getFormDefaults with rule when opened', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(RecurringRuleModalUtils.getFormDefaults).toHaveBeenCalledWith(
|
||||
makeRule()
|
||||
);
|
||||
});
|
||||
|
||||
it('calls form.setValues with getFormDefaults result', () => {
|
||||
const expectedDefaults = {
|
||||
channel_id: 'ch-1',
|
||||
rule_name: 'Morning News',
|
||||
days_of_week: ['mon', 'tue'],
|
||||
start_date: new Date('2024-01-01'),
|
||||
end_date: null,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue(
|
||||
expectedDefaults
|
||||
);
|
||||
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
|
||||
// form instance is created during render — read after
|
||||
const formMock = vi.mocked(useForm).mock.results[0].value;
|
||||
expect(formMock.setValues).toHaveBeenCalledWith(expectedDefaults);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Save changes ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('save changes', () => {
|
||||
it('calls updateRecurringRule on form submit', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecurringRuleModalUtils.updateRecurringRule
|
||||
).toHaveBeenCalledWith('rule-1', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules after save', async () => {
|
||||
const { mockFetchRecurringRules } = setupMocks();
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule updated" notification on success', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule updated',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful save', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when updateRecurringRule throws', async () => {
|
||||
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('Save changes button shows loading state while saving', async () => {
|
||||
let resolve;
|
||||
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Save changes')).toHaveAttribute(
|
||||
'data-loading',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete rule ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('delete rule', () => {
|
||||
it('renders the Delete rule button', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Delete rule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls deleteRecurringRuleById when Delete rule is clicked', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecurringRuleModalUtils.deleteRecurringRuleById
|
||||
).toHaveBeenCalledWith('rule-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules after delete', async () => {
|
||||
const { mockFetchRecurringRules } = setupMocks();
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule removed" notification on success', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule removed',
|
||||
color: 'red',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful delete', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when deleteRecurringRuleById throws', async () => {
|
||||
vi.mocked(
|
||||
RecurringRuleModalUtils.deleteRecurringRuleById
|
||||
).mockRejectedValue(new Error('fail'));
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Toggle enabled ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('toggle enabled', () => {
|
||||
it('calls updateRecurringRuleEnabled with true when switch is toggled on', async () => {
|
||||
setupMocks({ rule: makeRule({ enabled: false }) });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const sw = screen.getByTestId('switch');
|
||||
expect(sw).not.toBeChecked();
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecurringRuleModalUtils.updateRecurringRuleEnabled
|
||||
).toHaveBeenCalledWith('rule-1', true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateRecurringRuleEnabled with false when switch is toggled off', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const sw = screen.getByTestId('switch');
|
||||
expect(sw).toBeChecked();
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecurringRuleModalUtils.updateRecurringRuleEnabled
|
||||
).toHaveBeenCalledWith('rule-1', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule enabled" notification when enabling', async () => {
|
||||
setupMocks({ rule: makeRule({ enabled: false }) });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule enabled',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule paused" notification when disabling', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule paused',
|
||||
color: 'yellow',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules after toggle', async () => {
|
||||
const { mockFetchRecurringRules } = setupMocks();
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when updateRecurringRuleEnabled throws', async () => {
|
||||
vi.mocked(
|
||||
RecurringRuleModalUtils.updateRecurringRuleEnabled
|
||||
).mockRejectedValue(new Error('fail'));
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Upcoming occurrences ─────────────────────────────────────────────────────
|
||||
|
||||
describe('upcoming occurrences', () => {
|
||||
it('renders the occurrence count badge', () => {
|
||||
setupMocks({ occurrences: [makeOccurrence(), makeOccurrence('occ-2')] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('badge')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('shows "No future airings" when no upcoming occurrences', () => {
|
||||
setupMocks({ occurrences: [] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByText('No future airings currently scheduled.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders occurrence cards when occurrences exist', () => {
|
||||
setupMocks({
|
||||
occurrences: [makeOccurrence(), makeOccurrence('occ-2')],
|
||||
});
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getAllByTestId('occurrence-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders Edit and Cancel buttons for each occurrence', () => {
|
||||
setupMocks({ occurrences: [makeOccurrence()] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(
|
||||
RecurringRuleModalUtils.getUpcomingOccurrences
|
||||
).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Function),
|
||||
'rule-1',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit occurrence ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('edit occurrence', () => {
|
||||
it('calls onClose and onEditOccurrence when Edit is clicked', () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
const onClose = vi.fn();
|
||||
const onEditOccurrence = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
onEditOccurrence={onEditOccurrence}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(onEditOccurrence).toHaveBeenCalledWith(occ);
|
||||
});
|
||||
|
||||
it('does not throw when onEditOccurrence is not provided', () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
render(
|
||||
<RecurringRuleModal {...defaultProps()} onEditOccurrence={undefined} />
|
||||
);
|
||||
expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cancel occurrence ────────────────────────────────────────────────────────
|
||||
|
||||
describe('cancel occurrence', () => {
|
||||
it('calls deleteRecordingById when Cancel is clicked', async () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(deleteRecordingById).toHaveBeenCalledWith('occ-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Occurrence cancelled" notification on success', async () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Occurrence cancelled',
|
||||
color: 'yellow',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when deleteRecordingById throws', async () => {
|
||||
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
|
||||
setupMocks({ occurrences: [makeOccurrence()] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state on Cancel button while deleting occurrence', async () => {
|
||||
let resolve;
|
||||
vi.mocked(deleteRecordingById).mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
setupMocks({ occurrences: [makeOccurrence()] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cancel')).toHaveAttribute(
|
||||
'data-loading',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Time/date field handlers ─────────────────────────────────────────────────
|
||||
|
||||
describe('field change handlers', () => {
|
||||
it('calls toTimeString when start time changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('timeinput-Start time'), {
|
||||
target: { value: '09:00' },
|
||||
});
|
||||
expect(toTimeString).toHaveBeenCalledWith('09:00');
|
||||
});
|
||||
|
||||
it('calls toTimeString when end time changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('timeinput-End time'), {
|
||||
target: { value: '10:00' },
|
||||
});
|
||||
expect(toTimeString).toHaveBeenCalledWith('10:00');
|
||||
});
|
||||
|
||||
it('updates end_date field when end date picker changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const picker = screen.getByTestId('datepicker-End date');
|
||||
fireEvent.change(picker, { target: { value: '2024-12-31' } });
|
||||
// No throw — handler wired correctly
|
||||
});
|
||||
|
||||
it('updates start_date field when start date picker changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const picker = screen.getByTestId('datepicker-Start date');
|
||||
fireEvent.change(picker, { target: { value: '2024-07-01' } });
|
||||
// No throw — handler wired correctly
|
||||
});
|
||||
});
|
||||
});
|
||||
427
frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
Normal file
427
frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import ScheduleInput from '../ScheduleInput';
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
TextInput: ({
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}) => (
|
||||
<div>
|
||||
<label>{typeof label === 'string' ? label : 'Cron Expression'}</label>
|
||||
<input
|
||||
data-testid="textinput-cron"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
{description && <span data-testid="cron-description">{description}</span>}
|
||||
{error && <span data-testid="cron-error">{error}</span>}
|
||||
</div>
|
||||
),
|
||||
NumberInput: ({ label, description, value, onChange, min, disabled }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid="numberinput-interval"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{description && (
|
||||
<span data-testid="interval-description">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
Anchor: ({ children, onClick }) => (
|
||||
<a
|
||||
data-testid={`anchor-${String(children).replace(/\s+/g, '-').toLowerCase()}`}
|
||||
onClick={onClick}
|
||||
href="#"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
Code: ({ children }) => <code>{children}</code>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Popover: ({ children }) => <div>{children}</div>,
|
||||
PopoverTarget: ({ children }) => <div>{children}</div>,
|
||||
PopoverDropdown: ({ children }) => (
|
||||
<div data-testid="popover-dropdown">{children}</div>
|
||||
),
|
||||
ActionIcon: ({ children, onClick }) => (
|
||||
<button data-testid="action-icon-info" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Info: () => <svg data-testid="icon-info" />,
|
||||
}));
|
||||
|
||||
// ── cronUtils ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/cronUtils', () => ({
|
||||
validateCronExpression: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── CronBuilder ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../CronBuilder', () => ({
|
||||
default: ({ opened, onClose, onApply, currentValue }) =>
|
||||
opened ? (
|
||||
<div data-testid="cron-builder">
|
||||
<span data-testid="cron-builder-value">{currentValue}</span>
|
||||
<button
|
||||
data-testid="cron-builder-apply"
|
||||
onClick={() => onApply('0 3 * * *')}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button data-testid="cron-builder-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import { validateCronExpression } from '../../../utils/cronUtils';
|
||||
|
||||
const defaultIntervalProps = () => ({
|
||||
scheduleType: 'interval',
|
||||
onScheduleTypeChange: vi.fn(),
|
||||
intervalValue: 6,
|
||||
onIntervalChange: vi.fn(),
|
||||
cronValue: '',
|
||||
onCronChange: vi.fn(),
|
||||
});
|
||||
|
||||
const defaultCronProps = () => ({
|
||||
scheduleType: 'cron',
|
||||
onScheduleTypeChange: vi.fn(),
|
||||
cronValue: '0 3 * * *',
|
||||
onCronChange: vi.fn(),
|
||||
intervalValue: 0,
|
||||
onIntervalChange: vi.fn(),
|
||||
});
|
||||
|
||||
describe('ScheduleInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
});
|
||||
|
||||
// ── Interval mode ────────────────────────────────────────────────────────
|
||||
|
||||
describe('interval mode', () => {
|
||||
it('renders NumberInput in interval mode', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the intervalValue', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByTestId('numberinput-interval')).toHaveValue(6);
|
||||
});
|
||||
|
||||
it('renders default interval label', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByText('Refresh Interval (hours)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom intervalLabel', () => {
|
||||
render(
|
||||
<ScheduleInput
|
||||
{...defaultIntervalProps()}
|
||||
intervalLabel="My Custom Label"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('My Custom Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default interval description', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByTestId('interval-description')).toHaveTextContent(
|
||||
'How often to refresh (0 to disable)'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onIntervalChange when value changes', () => {
|
||||
const props = defaultIntervalProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.change(screen.getByTestId('numberinput-interval'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
expect(props.onIntervalChange).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('shows "Use cron schedule" link in interval mode', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(
|
||||
screen.getByTestId('anchor-use-cron-schedule')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
|
||||
const props = defaultIntervalProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-use-cron-schedule'));
|
||||
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('cron');
|
||||
});
|
||||
|
||||
it('does not show cron switch link when disabled', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
|
||||
expect(
|
||||
screen.queryByTestId('anchor-use-cron-schedule')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the NumberInput when disabled prop is true', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
|
||||
expect(screen.getByTestId('numberinput-interval')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders custom switchToCronLabel', () => {
|
||||
render(
|
||||
<ScheduleInput
|
||||
{...defaultIntervalProps()}
|
||||
switchToCronLabel="Use custom cron schedule"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Children (simple) mode ────────────────────────────────────────────────
|
||||
|
||||
describe('children (simple) mode', () => {
|
||||
it('renders children instead of NumberInput', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultIntervalProps()}>
|
||||
<div data-testid="custom-child">Custom content</div>
|
||||
</ScheduleInput>
|
||||
);
|
||||
expect(screen.getByTestId('custom-child')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('numberinput-interval')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows cron toggle link below children', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultIntervalProps()}>
|
||||
<div>Child</div>
|
||||
</ScheduleInput>
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('anchor-use-cron-schedule')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show cron link when disabled with children', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultIntervalProps()} disabled>
|
||||
<div>Child</div>
|
||||
</ScheduleInput>
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('anchor-use-cron-schedule')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cron mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('cron mode', () => {
|
||||
it('renders TextInput in cron mode', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.getByTestId('textinput-cron')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the cronValue', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.getByTestId('textinput-cron')).toHaveValue('0 3 * * *');
|
||||
});
|
||||
|
||||
it('shows "Use interval schedule" link in cron mode', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(
|
||||
screen.getByTestId('anchor-use-interval-schedule')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Open Cron Builder" link', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(
|
||||
screen.getByTestId('anchor-open-cron-builder')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-use-interval-schedule'));
|
||||
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('interval');
|
||||
expect(props.onCronChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('calls onCronChange when cron input changes', () => {
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.change(screen.getByTestId('textinput-cron'), {
|
||||
target: { value: '0 5 * * *' },
|
||||
});
|
||||
expect(props.onCronChange).toHaveBeenCalledWith('0 5 * * *');
|
||||
});
|
||||
|
||||
it('disables the TextInput when disabled prop is true', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} disabled />);
|
||||
expect(screen.getByTestId('textinput-cron')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not show cron links when disabled', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} disabled />);
|
||||
expect(
|
||||
screen.queryByTestId('anchor-use-interval-schedule')
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('anchor-open-cron-builder')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom switchToIntervalLabel', () => {
|
||||
render(
|
||||
<ScheduleInput
|
||||
{...defaultCronProps()}
|
||||
switchToIntervalLabel="Use simple schedule"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cron validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('cron validation', () => {
|
||||
it('shows no error when cron is valid', () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message when cron is invalid', async () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({
|
||||
valid: false,
|
||||
error: 'Invalid cron expression',
|
||||
});
|
||||
render(<ScheduleInput {...defaultCronProps()} cronValue="bad cron" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cron-error')).toHaveTextContent(
|
||||
'Invalid cron expression'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears error when cron input is cleared', async () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
const props = { ...defaultCronProps(), cronValue: '' };
|
||||
render(<ScheduleInput {...props} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls validateCronExpression on cron value change', () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.change(screen.getByTestId('textinput-cron'), {
|
||||
target: { value: '0 6 * * 1' },
|
||||
});
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * 1');
|
||||
});
|
||||
|
||||
it('does not call validateCronExpression in interval mode', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(validateCronExpression).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cron Builder ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('CronBuilder', () => {
|
||||
it('CronBuilder is not visible initially', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens CronBuilder when "Open Cron Builder" is clicked', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
|
||||
expect(screen.getByTestId('cron-builder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes current cronValue to CronBuilder', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} cronValue="0 4 * * *" />);
|
||||
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
|
||||
expect(screen.getByTestId('cron-builder-value')).toHaveTextContent(
|
||||
'0 4 * * *'
|
||||
);
|
||||
});
|
||||
|
||||
it('closes CronBuilder when onClose is triggered', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
|
||||
fireEvent.click(screen.getByTestId('cron-builder-close'));
|
||||
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCronChange with applied value from CronBuilder', () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
|
||||
fireEvent.click(screen.getByTestId('cron-builder-apply'));
|
||||
expect(props.onCronChange).toHaveBeenCalledWith('0 3 * * *');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default props ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default props', () => {
|
||||
it('defaults to interval mode when scheduleType is not provided', () => {
|
||||
render(
|
||||
<ScheduleInput
|
||||
onScheduleTypeChange={vi.fn()}
|
||||
onIntervalChange={vi.fn()}
|
||||
onCronChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults intervalValue to 0', () => {
|
||||
render(
|
||||
<ScheduleInput
|
||||
onScheduleTypeChange={vi.fn()}
|
||||
onIntervalChange={vi.fn()}
|
||||
onCronChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,455 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import SeriesRecordingModal from '../SeriesRecordingModal';
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
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, c, fw }) => (
|
||||
<span data-testid="text" data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, variant, color, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Store ──────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({
|
||||
default: { getState: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteSeriesAndRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/guideUtils.js', () => ({
|
||||
evaluateSeriesRulesByTvgId: vi.fn(),
|
||||
fetchRules: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── SeriesRuleEditorModal ──────────────────────────────────────────────────────
|
||||
vi.mock('../SeriesRuleEditorModal', () => ({
|
||||
default: ({ opened, onClose, initialRule, onSaved }) =>
|
||||
opened ? (
|
||||
<div data-testid="series-rule-editor">
|
||||
<span data-testid="editor-rule">
|
||||
{initialRule ? initialRule.title : 'new'}
|
||||
</span>
|
||||
<button data-testid="editor-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button data-testid="editor-save" onClick={onSaved}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
evaluateSeriesRulesByTvgId,
|
||||
fetchRules,
|
||||
} from '../../../utils/guideUtils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
|
||||
const makeRule = (overrides = {}) => ({
|
||||
tvg_id: 'tvg-1',
|
||||
title: 'Test Show',
|
||||
mode: 'new',
|
||||
title_mode: 'exact',
|
||||
description: '',
|
||||
channel_id: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
rules: [makeRule()],
|
||||
onRulesUpdate: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const setupMocks = () => {
|
||||
vi.mocked(useChannelsStore.getState).mockReturnValue({
|
||||
fetchRecordings: mockFetchRecordings,
|
||||
});
|
||||
vi.mocked(fetchRules).mockResolvedValue([makeRule()]);
|
||||
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
|
||||
};
|
||||
|
||||
describe('SeriesRecordingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders when opened is true', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when opened is false', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal title "Series Recording Rules"', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Series Recording Rules'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('empty state', () => {
|
||||
it('shows "No series rules configured" when rules is empty', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps({ rules: [] })} />);
|
||||
expect(
|
||||
screen.getByText('No series rules configured')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No series rules configured" when rules is null', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps({ rules: null })} />);
|
||||
expect(
|
||||
screen.getByText('No series rules configured')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "No series rules configured" when rules are present', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.queryByText('No series rules configured')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('rule rendering', () => {
|
||||
it('renders rule title', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Test Show')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to tvg_id when rule has no title', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({
|
||||
rules: [makeRule({ title: '', tvg_id: 'tvg-fallback' })],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Pinned channel" badge when channel_id is set', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ channel_id: 'ch-1' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Pinned channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Pinned channel" badge when channel_id is null', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.queryByText('Pinned channel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple rules', () => {
|
||||
const rules = [
|
||||
makeRule({ tvg_id: 'tvg-1', title: 'Show One' }),
|
||||
makeRule({ tvg_id: 'tvg-2', title: 'Show Two' }),
|
||||
];
|
||||
render(<SeriesRecordingModal {...defaultProps({ rules })} />);
|
||||
expect(screen.getByText('Show One')).toBeInTheDocument();
|
||||
expect(screen.getByText('Show Two')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Edit, Evaluate Now, and Remove buttons for each rule', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Evaluate Now')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remove')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Add rule" button', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Add rule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule summary ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('rule summary', () => {
|
||||
it('shows "New episodes" for mode new', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ mode: 'new' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/New episodes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Every episode" for mode all', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ mode: 'all' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Every episode/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows exact title label in summary', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({
|
||||
rules: [makeRule({ title_mode: 'exact', title: 'Test Show' })],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows contains label in summary', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({
|
||||
rules: [makeRule({ title_mode: 'contains', title: 'Test' })],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows regex label in summary', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({
|
||||
rules: [makeRule({ title_mode: 'regex', title: '^Test' })],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows description in summary when present', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ description: 'A drama' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Description: "A drama"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show description in summary when empty', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.queryByText(/Description:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Evaluate Now ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Evaluate Now', () => {
|
||||
it('calls evaluateSeriesRulesByTvgId with rule tvg_id', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalledWith('tvg-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after evaluation', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Evaluated" notification after evaluation', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Evaluated',
|
||||
message: 'Checked for episodes',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('still shows notification when fetchRecordings rejects', async () => {
|
||||
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Evaluated' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remove series ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Remove series', () => {
|
||||
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
|
||||
tvg_id: 'tvg-1',
|
||||
title: 'Test Show',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after removal', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRules after removal', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(fetchRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRulesUpdate with fetched rules after removal', async () => {
|
||||
const updated = [makeRule({ title: 'Updated Show' })];
|
||||
vi.mocked(fetchRules).mockResolvedValue(updated);
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
|
||||
it('still calls onRulesUpdate when fetchRecordings rejects', async () => {
|
||||
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(props.onRulesUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Add rule / Editor ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Add rule', () => {
|
||||
it('opens SeriesRuleEditorModal when "Add rule" is clicked', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add rule'));
|
||||
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes null initialRule when opening via "Add rule"', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add rule'));
|
||||
expect(screen.getByTestId('editor-rule')).toHaveTextContent('new');
|
||||
});
|
||||
|
||||
it('closes SeriesRuleEditorModal when editor onClose fires', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add rule'));
|
||||
fireEvent.click(screen.getByTestId('editor-close'));
|
||||
expect(
|
||||
screen.queryByTestId('series-rule-editor')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit rule ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Edit rule', () => {
|
||||
it('opens SeriesRuleEditorModal with the selected rule when Edit is clicked', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('editor-rule')).toHaveTextContent('Test Show');
|
||||
});
|
||||
|
||||
it('calls fetchRules and onRulesUpdate when editor onSaved fires', async () => {
|
||||
const updated = [makeRule({ title: 'Saved Show' })];
|
||||
vi.mocked(fetchRules).mockResolvedValue(updated);
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
fireEvent.click(screen.getByTestId('editor-save'));
|
||||
await waitFor(() => {
|
||||
expect(fetchRules).toHaveBeenCalled();
|
||||
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes editor after save', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
fireEvent.click(screen.getByTestId('editor-save'));
|
||||
await waitFor(() => {
|
||||
// editor is still open (onSaved doesn't auto-close) — just verify no error
|
||||
expect(fetchRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,730 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/epgs.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils.js', () => ({
|
||||
useDebounce: vi.fn((val) => val),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
|
||||
getChannelsSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/guideUtils.js', () => ({
|
||||
createSeriesRule: vi.fn(),
|
||||
evaluateSeriesRulesByTvgId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/SeriesRuleEditorModalUtils.js', () => ({
|
||||
TITLE_MODES: [
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
],
|
||||
DESCRIPTION_MODES: [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
],
|
||||
EPISODE_MODES: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'New only', value: 'new' },
|
||||
],
|
||||
formatRange: vi.fn((start, end) => `${start} - ${end}`),
|
||||
getChannelOptions: vi.fn(() => [{ value: '10', label: '501 - HBO' }]),
|
||||
getTvgOptions: vi.fn(),
|
||||
previewSeriesRule: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ children, color }) => (
|
||||
<div data-testid="alert" data-color={color}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, variant }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-variant={variant}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Divider: () => <hr />,
|
||||
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,
|
||||
ScrollAreaAutosize: ({ children }) => <div>{children}</div>,
|
||||
SegmentedControl: ({ data, value, onChange, size }) => (
|
||||
<div data-testid="segmented-control" data-size={size}>
|
||||
{data.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-active={value === item.value}
|
||||
onClick={() => onChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, placeholder, data, value, onChange, description }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && (
|
||||
<span data-testid="select-description">{description}</span>
|
||||
)}
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{(data || []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size, c, fw }) => (
|
||||
<span data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Textarea: ({ label, placeholder, value, onChange, description }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <span>{description}</span>}
|
||||
<textarea
|
||||
data-testid="textarea-description"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, placeholder, value, onChange, description }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <span>{description}</span>}
|
||||
<input
|
||||
data-testid="input-title"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────
|
||||
import SeriesRuleEditorModal from '../SeriesRuleEditorModal';
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import useEPGsStore from '../../../store/epgs.jsx';
|
||||
import { useDebounce } from '../../../utils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import { getChannelsSummary } from '../../../utils/forms/RecordingUtils.js';
|
||||
import {
|
||||
createSeriesRule,
|
||||
evaluateSeriesRulesByTvgId,
|
||||
} from '../../../utils/guideUtils.js';
|
||||
import {
|
||||
getTvgOptions,
|
||||
previewSeriesRule,
|
||||
formatRange,
|
||||
} from '../../../utils/forms/SeriesRuleEditorModalUtils.js';
|
||||
|
||||
// ── Shared test data ───────────────────────────────────────────────────────
|
||||
const mockTvgs = [
|
||||
{ id: 1, tvg_id: 'tvg-1', name: 'Channel One' },
|
||||
{ id: 2, tvg_id: 'tvg-2', name: 'Channel Two' },
|
||||
];
|
||||
|
||||
const mockTvgsById = {
|
||||
1: { tvg_id: 'tvg-1', name: 'Channel One' },
|
||||
2: { tvg_id: 'tvg-2', name: 'Channel Two' },
|
||||
};
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 10, name: 'HBO', channel_number: 501, epg_data_id: 1 },
|
||||
{ id: 20, name: 'CNN', channel_number: 102, epg_data_id: 2 },
|
||||
];
|
||||
|
||||
const mockTvgOptions = [
|
||||
{ value: 'tvg-1', label: 'Channel One (tvg-1)' },
|
||||
{ value: 'tvg-2', label: 'Channel Two (tvg-2)' },
|
||||
];
|
||||
|
||||
const mockPreviewResponse = {
|
||||
matches: [
|
||||
{
|
||||
id: 'p1',
|
||||
title: 'Match Show',
|
||||
sub_title: 'Episode 1',
|
||||
description: 'A great show',
|
||||
start_time: '2024-01-01T10:00:00Z',
|
||||
end_time: '2024-01-01T11:00:00Z',
|
||||
tvg_id: 'tvg-1',
|
||||
is_new: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
epg_found: true,
|
||||
};
|
||||
|
||||
// ── Setup helper ───────────────────────────────────────────────────────────
|
||||
const setupMocks = () => {
|
||||
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useEPGsStore).mockImplementation((selector) =>
|
||||
selector({ tvgs: mockTvgs, tvgsById: mockTvgsById })
|
||||
);
|
||||
|
||||
// Mock getState for the imperative call in handleSave
|
||||
useChannelsStore.getState = vi.fn().mockReturnValue({
|
||||
fetchRecordings: mockFetchRecordings,
|
||||
});
|
||||
|
||||
vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
|
||||
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: [],
|
||||
total: 0,
|
||||
epg_found: true,
|
||||
});
|
||||
vi.mocked(createSeriesRule).mockResolvedValue({ id: 'rule-1' });
|
||||
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
|
||||
vi.mocked(useDebounce).mockImplementation((val) => val);
|
||||
vi.mocked(formatRange).mockImplementation((s, e) => `${s} - ${e}`);
|
||||
|
||||
return { mockFetchRecordings };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
describe('SeriesRuleEditorModal', () => {
|
||||
let defaultProps;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
|
||||
defaultProps = {
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
initialRule: null,
|
||||
onSaved: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "New Series Rule" title when initialRule is null', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'New Series Rule'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "Edit Series Rule" title when initialRule is provided', () => {
|
||||
render(
|
||||
<SeriesRuleEditorModal
|
||||
{...defaultProps}
|
||||
initialRule={{ tvg_id: 'tvg-1', mode: 'all', title: 'Test' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Edit Series Rule'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the title input', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('input-title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the description textarea', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('textarea-description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Cancel and Save rule buttons', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save rule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save rule button is disabled when no title or description', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByText('Save rule')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Save rule button is enabled when title is provided', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'My Show' },
|
||||
});
|
||||
expect(screen.getByText('Save rule')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('Save rule button is enabled when description is provided', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('textarea-description'), {
|
||||
target: { value: 'some description' },
|
||||
});
|
||||
expect(screen.getByText('Save rule')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── State hydration from initialRule ─────────────────────────────────────
|
||||
|
||||
describe('state hydration', () => {
|
||||
const initialRule = {
|
||||
tvg_id: 'tvg-1',
|
||||
mode: 'new',
|
||||
title: 'Pre-filled Title',
|
||||
title_mode: 'contains',
|
||||
description: 'Pre-filled description',
|
||||
description_mode: 'exact',
|
||||
channel_id: 10,
|
||||
};
|
||||
|
||||
it('pre-fills title from initialRule', () => {
|
||||
render(
|
||||
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
|
||||
);
|
||||
expect(screen.getByTestId('input-title')).toHaveValue('Pre-filled Title');
|
||||
});
|
||||
|
||||
it('pre-fills description from initialRule', () => {
|
||||
render(
|
||||
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
|
||||
);
|
||||
expect(screen.getByTestId('textarea-description')).toHaveValue(
|
||||
'Pre-filled description'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default mode "all" when initialRule has no mode', () => {
|
||||
render(
|
||||
<SeriesRuleEditorModal {...defaultProps} initialRule={{ title: 'X' }} />
|
||||
);
|
||||
// "All" segmented button should be active (data-active=true)
|
||||
const allBtn = screen
|
||||
.getAllByText('All')
|
||||
.find((el) => el.closest('[data-testid="segmented-control"]'));
|
||||
expect(allBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resets fields when modal is reopened without initialRule', async () => {
|
||||
const { rerender } = render(
|
||||
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
|
||||
);
|
||||
rerender(
|
||||
<SeriesRuleEditorModal
|
||||
{...defaultProps}
|
||||
opened={false}
|
||||
initialRule={null}
|
||||
/>
|
||||
);
|
||||
rerender(
|
||||
<SeriesRuleEditorModal
|
||||
{...defaultProps}
|
||||
opened={true}
|
||||
initialRule={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-title')).toHaveValue('');
|
||||
expect(screen.getByTestId('textarea-description')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel loading ───────────────────────────────────────────────────────
|
||||
|
||||
describe('channel loading', () => {
|
||||
it('calls getChannelsSummary when modal opens', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call getChannelsSummary when modal is closed', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
|
||||
expect(getChannelsSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles getChannelsSummary rejection gracefully', async () => {
|
||||
vi.mocked(getChannelsSummary).mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
// Should not throw; channel options just empty
|
||||
await waitFor(() => {
|
||||
expect(getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── TVG options ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('EPG channel options', () => {
|
||||
it('calls getTvgOptions with tvgs from store', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(getTvgOptions).toHaveBeenCalledWith(mockTvgs);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('preview', () => {
|
||||
it('does not call previewSeriesRule when title, description, and tvg_id are all empty', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
await waitFor(() => expect(getChannelsSummary).toHaveBeenCalled());
|
||||
expect(previewSeriesRule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls previewSeriesRule when title is entered', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'My Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(previewSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'My Show' }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls previewSeriesRule when description is entered', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('textarea-description'), {
|
||||
target: { value: 'some words' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(previewSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: 'some words' }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders preview match results', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Match Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Match Show/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders preview badge with match count', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: [{ id: 'p1', title: 'X' }],
|
||||
total: 5,
|
||||
epg_found: true,
|
||||
});
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'X' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('badge')).toHaveTextContent('1 of 5');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows preview error alert when previewSeriesRule rejects', async () => {
|
||||
vi.mocked(previewSeriesRule).mockRejectedValue(
|
||||
new Error('Preview failed')
|
||||
);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Boom' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert')).toHaveTextContent('Preview failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "No EPG channel matches" warning when epg_found is false and tvg_id is set', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: [],
|
||||
total: 0,
|
||||
epg_found: false,
|
||||
});
|
||||
render(
|
||||
<SeriesRuleEditorModal
|
||||
{...defaultProps}
|
||||
initialRule={{ tvg_id: 'tvg-999', title: 'X' }}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No EPG channel matches this tvg_id/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows warn alert when preview.warn is true', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: Array(50).fill({ id: 'x', title: 'X' }),
|
||||
total: 50,
|
||||
epg_found: true,
|
||||
warn: true,
|
||||
});
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'X' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/This rule matches many programs/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "No matching upcoming programs" when matches empty and filter set', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: [],
|
||||
total: 0,
|
||||
epg_found: true,
|
||||
});
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Unknown Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No matching upcoming programs/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders sub_title in preview match', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Match Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Episode 1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders "(NEW)" marker for new episodes in preview', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Match Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/(NEW)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleSave ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('handleSave', () => {
|
||||
const renderWithTitle = () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'My Show' },
|
||||
});
|
||||
};
|
||||
|
||||
it('calls createSeriesRule with correct payload', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'My Show', mode: 'all' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls evaluateSeriesRulesByTvgId after createSeriesRule', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after save', async () => {
|
||||
const { mockFetchRecordings } = setupMocks();
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls showNotification on success', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Series rule saved' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSaved callback on success', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSaved).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after save', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call showNotification when createSeriesRule throws', async () => {
|
||||
vi.mocked(createSeriesRule).mockRejectedValue(new Error('Server error'));
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalled();
|
||||
});
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
|
||||
vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(
|
||||
new Error('eval fail')
|
||||
);
|
||||
renderWithTitle();
|
||||
await expect(
|
||||
waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('does not crash when fetchRecordings throws', async () => {
|
||||
useChannelsStore.getState = vi.fn().mockReturnValue({
|
||||
fetchRecordings: vi.fn().mockRejectedValue(new Error('fetch fail')),
|
||||
});
|
||||
renderWithTitle();
|
||||
await expect(
|
||||
waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('includes channel_id in payload when channelId is set', async () => {
|
||||
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
|
||||
render(
|
||||
<SeriesRuleEditorModal
|
||||
{...defaultProps}
|
||||
initialRule={{ channel_id: 10, title: 'Show' }}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel_id: 10 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('omits channel_id from payload when no channel selected', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(createSeriesRule).mock.calls[0][0];
|
||||
expect(call).not.toHaveProperty('channel_id');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cancel button ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Cancel button', () => {
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SegmentedControl interactions ─────────────────────────────────────────
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
it('changes title mode when SegmentedControl is clicked', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByText('Contains')
|
||||
.find((el) => el.closest('[data-testid="segmented-control"]'))
|
||||
);
|
||||
// No error thrown; state updated without crash
|
||||
});
|
||||
|
||||
it('changes episode mode to "New only"', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('New only'));
|
||||
// Payload mode becomes 'new'; verify via save
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'new' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
572
frontend/src/components/forms/__tests__/Stream.test.jsx
Normal file
572
frontend/src/components/forms/__tests__/Stream.test.jsx
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module-level form state (survives re-renders, accessible in beforeEach) ───
|
||||
const __form = { values: {}, resetSpy: null };
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/streamProfiles.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/StreamUtils.js', () => ({
|
||||
addStream: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
updateStream: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── 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 });
|
||||
};
|
||||
|
||||
// Keep reset stable across re-renders so useEffect([defaultValues, reset])
|
||||
// in Stream.jsx does not fire on every render and cause an infinite loop.
|
||||
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;
|
||||
}
|
||||
const reset = resetRef.current;
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: {}, isSubmitting: false },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @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,
|
||||
Select: ({ label, value, onChange, data, placeholder, error }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">{placeholder ?? `-- ${label} --`}</option>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, name, value, onChange, error, ...rest }) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<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 Stream from '../Stream';
|
||||
import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import * as StreamUtils from '../../../utils/forms/StreamUtils.js';
|
||||
|
||||
// ── Shared test data ───────────────────────────────────────────────────────────
|
||||
const mockProfiles = [
|
||||
{ id: 1, name: 'Default' },
|
||||
{ id: 2, name: 'HD Profile' },
|
||||
];
|
||||
|
||||
const mockChannelGroups = {
|
||||
10: { id: 10, name: 'Sports' },
|
||||
20: { id: 20, name: 'News' },
|
||||
};
|
||||
|
||||
const makeStream = (overrides = {}) => ({
|
||||
id: 'stream-1',
|
||||
name: 'Test Stream',
|
||||
url: 'http://example.com/stream',
|
||||
channel_group: 10,
|
||||
stream_profile_id: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = () => {
|
||||
vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
|
||||
sel({ profiles: mockProfiles })
|
||||
);
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ channelGroups: mockChannelGroups })
|
||||
);
|
||||
vi.mocked(StreamUtils.addStream).mockResolvedValue(undefined);
|
||||
vi.mocked(StreamUtils.updateStream).mockResolvedValue(undefined);
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
stream: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Stream', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset module-level form state before each test
|
||||
__form.values = {};
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<Stream {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Stream" as the modal title', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream');
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Stream {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders Stream Name input', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Stream URL input', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-url')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Group select', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-group')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Stream Profile select', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-stream-profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Submit button', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates Group select with channel groups', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Sports')).toBeInTheDocument();
|
||||
expect(screen.getByText('News')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates Stream Profile select with profiles', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
expect(screen.getByText('HD Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Optional" placeholder for Stream Profile', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Optional')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('pre-fills name from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('input-stream-name')).toHaveValue(
|
||||
'Test Stream'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-fills url from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('input-stream-url')).toHaveValue(
|
||||
'http://example.com/stream'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-selects group from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('select-group')).toHaveValue('10');
|
||||
});
|
||||
|
||||
it('pre-selects stream profile from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('select-stream-profile')).toHaveValue('1');
|
||||
});
|
||||
|
||||
it('renders empty name when no stream provided', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty url when no stream provided', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-url')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty group when stream has no channel_group', () => {
|
||||
render(
|
||||
<Stream
|
||||
{...defaultProps({ stream: makeStream({ channel_group: null }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('select-group')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty profile when stream has no stream_profile_id', () => {
|
||||
render(
|
||||
<Stream
|
||||
{...defaultProps({ stream: makeStream({ stream_profile_id: null }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('select-stream-profile')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create stream ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('create stream (no existing stream)', () => {
|
||||
it('calls addStream on submit when no stream id', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'New Stream' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://new.stream/live' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'New Stream',
|
||||
url: 'http://new.stream/live',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateStream when creating', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'New Stream' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://new.stream' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful create', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Stream {...defaultProps({ onClose })} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'New Stream' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://new.stream' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('converts channel_group string to integer in payload', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-group'), {
|
||||
target: { value: '10' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel_group: 10 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets channel_group to null when no group selected', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
|
||||
expect(call.channel_group).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('converts stream_profile_id string to integer in payload', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-stream-profile'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stream_profile_id: 2 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets stream_profile_id to null when no profile selected', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
|
||||
expect(call.stream_profile_id).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update stream ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('update stream (existing stream)', () => {
|
||||
it('calls updateStream with the stream id on submit', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
|
||||
'stream-1',
|
||||
expect.objectContaining({
|
||||
name: 'Test Stream',
|
||||
url: 'http://example.com/stream',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addStream when updating', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful update', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Stream {...defaultProps({ stream: makeStream(), onClose })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('converts channel_group back to integer for update payload', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
|
||||
'stream-1',
|
||||
expect.objectContaining({ channel_group: 10 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('converts stream_profile_id back to integer for update payload', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
|
||||
'stream-1',
|
||||
expect.objectContaining({ stream_profile_id: 1 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getResolver ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls getResolver on mount', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(StreamUtils.getResolver).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form reset on stream change ────────────────────────────────────────────
|
||||
|
||||
describe('form reset', () => {
|
||||
it('resets the form when stream prop changes', () => {
|
||||
const { rerender } = render(<Stream {...defaultProps()} />);
|
||||
|
||||
rerender(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit button state ────────────────────────────────────────────────────
|
||||
|
||||
describe('submit button', () => {
|
||||
it('Submit button is not disabled by default', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Submit')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
647
frontend/src/components/forms/__tests__/StreamProfile.test.jsx
Normal file
647
frontend/src/components/forms/__tests__/StreamProfile.test.jsx
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
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 };
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
|
||||
addStreamProfile: vi.fn(),
|
||||
updateStreamProfile: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
toCommandSelection: vi.fn((cmd) => {
|
||||
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
|
||||
return builtins.includes(cmd) ? cmd : '__custom__';
|
||||
}),
|
||||
BUILT_IN_COMMANDS: [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: 'streamlink', label: 'Streamlink' },
|
||||
{ value: 'cvlc', label: 'VLC' },
|
||||
{ value: 'yt-dlp', label: 'yt-dlp' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
],
|
||||
COMMAND_EXAMPLES: {
|
||||
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
|
||||
streamlink:
|
||||
'{streamUrl} --http-header User-Agent={userAgent} best --stdout',
|
||||
},
|
||||
}));
|
||||
|
||||
// ── 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,
|
||||
placeholder,
|
||||
error,
|
||||
clearable,
|
||||
disabled,
|
||||
description,
|
||||
}) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <div>{description}</div>}
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value || (clearable ? null : ''))}
|
||||
>
|
||||
<option value="">{placeholder ?? `-- ${label} --`}</option>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Textarea: ({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
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}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</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 StreamProfile from '../StreamProfile';
|
||||
import useUserAgentsStore from '../../../store/userAgents';
|
||||
import * as StreamProfileUtils from '../../../utils/forms/StreamProfileUtils.js';
|
||||
|
||||
// ── Shared test data ───────────────────────────────────────────────────────────
|
||||
const mockUserAgents = [
|
||||
{ id: 1, name: 'Chrome' },
|
||||
{ id: 2, name: 'Firefox' },
|
||||
];
|
||||
|
||||
const makeProfile = (overrides = {}) => ({
|
||||
id: 'sp-1',
|
||||
name: 'My Profile',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-i {streamUrl}',
|
||||
is_active: true,
|
||||
user_agent: '1',
|
||||
locked: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = () => {
|
||||
vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
|
||||
sel({ userAgents: mockUserAgents })
|
||||
);
|
||||
vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
|
||||
vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
vi.mocked(StreamProfileUtils.getResolver).mockReturnValue(undefined);
|
||||
vi.mocked(StreamProfileUtils.toCommandSelection).mockImplementation((cmd) => {
|
||||
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
|
||||
return builtins.includes(cmd) ? cmd : '__custom__';
|
||||
});
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
profile: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('StreamProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__form.values = {};
|
||||
__form.resetSpy = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when isOpen is false', () => {
|
||||
render(<StreamProfile {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Stream Profile" as the modal title', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Stream Profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<StreamProfile {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders Name input', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Command select', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Parameters textarea', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders User-Agent select', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-user-agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Is Active checkbox', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Save button', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates Command select with built-in options', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
|
||||
expect(screen.getByText('Streamlink')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('populates User-Agent select with user agents', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('Chrome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Firefox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('pre-fills name from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('My Profile');
|
||||
});
|
||||
|
||||
it('pre-fills parameters from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
|
||||
'-i {streamUrl}'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-selects command from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
|
||||
});
|
||||
|
||||
it('pre-selects user-agent from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('select-user-agent')).toHaveValue('1');
|
||||
});
|
||||
|
||||
it('checkbox is checked when is_active is true', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
|
||||
});
|
||||
|
||||
it('checkbox is unchecked when is_active is false', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('defaults name to empty string when no profile', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('defaults parameters to empty string when no profile', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Command selection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('command selection', () => {
|
||||
it('does not show Custom Command input when a built-in is selected', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(
|
||||
screen.queryByTestId('input-custom-command')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Custom Command input when Custom… is selected', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Custom Command input when switching from custom back to built-in', () => {
|
||||
render(<StreamProfile {...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('shows Custom Command input when profile has a custom command', () => {
|
||||
vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue(
|
||||
'__custom__'
|
||||
);
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({
|
||||
profile: makeProfile({ command: '/usr/bin/mycmd' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets command form value when built-in is selected', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'streamlink' },
|
||||
});
|
||||
expect(__form.values.command).toBe('streamlink');
|
||||
});
|
||||
|
||||
it('clears command form value when Custom… is selected', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(__form.values.command).toBe('');
|
||||
});
|
||||
|
||||
it('shows parameters example hint for ffmpeg', () => {
|
||||
const { container } = render(
|
||||
<StreamProfile {...defaultProps({ profile: makeProfile() })} />
|
||||
);
|
||||
expect(container.textContent).toMatch(/-user_agent \{userAgent\}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Is Active checkbox ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Is Active checkbox', () => {
|
||||
it('toggles is_active to false when unchecked', () => {
|
||||
render(<StreamProfile {...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(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active'));
|
||||
expect(__form.values.is_active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── User-Agent select ───────────────────────────────────────────────────────
|
||||
|
||||
describe('User-Agent select', () => {
|
||||
it('updates user_agent form value when changed', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-user-agent'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
expect(__form.values.user_agent).toBe('2');
|
||||
});
|
||||
|
||||
it('sets user_agent to empty string when cleared', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.change(screen.getByTestId('select-user-agent'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
expect(__form.values.user_agent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Locked profile ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('locked profile', () => {
|
||||
it('disables Name input when profile is locked', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Command select when profile is locked', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('select-command')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Parameters textarea when profile is locked', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not disable inputs when profile is not locked', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('input-name')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not disable inputs when no profile is provided', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create profile ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('create profile (no existing profile)', () => {
|
||||
it('calls addStreamProfile on submit when no profile id', async () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'ffmpeg' },
|
||||
});
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.addStreamProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Profile' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateStreamProfile when creating', async () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.updateStreamProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful create', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<StreamProfile {...defaultProps({ onClose })} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update profile ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('update profile (existing profile)', () => {
|
||||
it('calls updateStreamProfile with the profile id on submit', async () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.updateStreamProfile).toHaveBeenCalledWith(
|
||||
'sp-1',
|
||||
expect.objectContaining({ name: 'My Profile' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addStreamProfile when updating', async () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.addStreamProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful update', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<StreamProfile {...defaultProps({ profile: makeProfile(), onClose })} />
|
||||
);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form reset ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form reset', () => {
|
||||
it('resets the form when profile prop changes', () => {
|
||||
const { rerender } = render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
rerender(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls reset after successful submit', async () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getResolver ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls getResolver on mount', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(StreamProfileUtils.getResolver).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
333
frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
Normal file
333
frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Asset mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
createSuperUser: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, fullWidth }) => (
|
||||
<button type={type} data-fullwidth={fullWidth}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Center: ({ children, style }) => <div style={style}>{children}</div>,
|
||||
Divider: ({ style }) => <hr style={style} />,
|
||||
Image: ({ src, alt }) => <img src={src} alt={alt} />,
|
||||
Paper: ({ children, style }) => <div style={style}>{children}</div>,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size, color, align }) => (
|
||||
<span data-size={size} data-color={color} data-align={align}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
TextInput: ({ label, name, value, onChange, required, type }) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${name}`}
|
||||
type={type ?? 'text'}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Title: ({ children, order, align }) => {
|
||||
const Tag = `h${order ?? 1}`;
|
||||
return <Tag data-align={align}>{children}</Tag>;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import SuperuserForm from '../SuperuserForm';
|
||||
import API from '../../../api';
|
||||
import useAuthStore from '../../../store/auth';
|
||||
import useSettingsStore from '../../../store/settings';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const setupMocks = ({
|
||||
version = {},
|
||||
fetchVersion = vi.fn(),
|
||||
setSuperuserExists = vi.fn(),
|
||||
} = {}) => {
|
||||
vi.mocked(useAuthStore).mockImplementation((sel) =>
|
||||
sel({ setSuperuserExists })
|
||||
);
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ fetchVersion, version })
|
||||
);
|
||||
return { fetchVersion, setSuperuserExists };
|
||||
};
|
||||
|
||||
describe('SuperuserForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the Dispatcharr title', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the welcome message', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Welcome! Create your Super User Account to get started.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the logo image', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Username input', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByTestId('input-username')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Password input with type="password"', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
const input = screen.getByTestId('input-password');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('renders Email input with type="email"', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
const input = screen.getByTestId('input-email');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'email');
|
||||
});
|
||||
|
||||
it('renders the Create Account button', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Create Account' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render version text when version is not loaded', () => {
|
||||
setupMocks({ version: {} });
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.queryByText(/^v/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders version text when version is loaded', () => {
|
||||
setupMocks({ version: { version: '1.2.3' } });
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useEffect', () => {
|
||||
it('calls fetchVersion on mount', () => {
|
||||
const { fetchVersion } = setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(fetchVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call fetchVersion again on re-render with same fetchVersion ref', () => {
|
||||
const { fetchVersion } = setupMocks();
|
||||
const { rerender } = render(<SuperuserForm />);
|
||||
rerender(<SuperuserForm />);
|
||||
// fetchVersion is stable, so useEffect should only fire once
|
||||
expect(fetchVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form field interactions ────────────────────────────────────────────────
|
||||
|
||||
describe('form field interactions', () => {
|
||||
it('updates username field when typed', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
expect(screen.getByTestId('input-username')).toHaveValue('admin');
|
||||
});
|
||||
|
||||
it('updates password field when typed', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
fireEvent.change(screen.getByTestId('input-password'), {
|
||||
target: { name: 'password', value: 'secret123' },
|
||||
});
|
||||
expect(screen.getByTestId('input-password')).toHaveValue('secret123');
|
||||
});
|
||||
|
||||
it('updates email field when typed', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
fireEvent.change(screen.getByTestId('input-email'), {
|
||||
target: { name: 'email', value: 'admin@example.com' },
|
||||
});
|
||||
expect(screen.getByTestId('input-email')).toHaveValue(
|
||||
'admin@example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('initializes all fields as empty strings', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByTestId('input-username')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-password')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-email')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls API.createSuperUser with form values on submit', async () => {
|
||||
setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: false,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-password'), {
|
||||
target: { name: 'password', value: 'secret' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-email'), {
|
||||
target: { name: 'email', value: 'admin@test.com' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalledWith({
|
||||
username: 'admin',
|
||||
password: 'secret',
|
||||
email: 'admin@test.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setSuperuserExists(true) when response.superuser_exists is true', async () => {
|
||||
const { setSuperuserExists } = setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: true,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setSuperuserExists).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call setSuperuserExists when response.superuser_exists is false', async () => {
|
||||
const { setSuperuserExists } = setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: false,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setSuperuserExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when API.createSuperUser rejects', async () => {
|
||||
setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await expect(
|
||||
waitFor(() => expect(API.createSuperUser).toHaveBeenCalled())
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('does not call setSuperuserExists when API throws', async () => {
|
||||
const { setSuperuserExists } = setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setSuperuserExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits with empty email when email field is left blank', async () => {
|
||||
setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: false,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-password'), {
|
||||
target: { name: 'password', value: 'secret' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: '' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
810
frontend/src/components/forms/__tests__/User.test.jsx
Normal file
810
frontend/src/components/forms/__tests__/User.test.jsx
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../constants', () => ({
|
||||
USER_LEVELS: { ADMIN: 1, STREAMER: 2, USER: 3 },
|
||||
USER_LEVEL_LABELS: { 1: 'Admin', 2: 'Streamer', 3: 'User' },
|
||||
}));
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils', () => ({ copyToClipboard: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/forms/UserUtils.js', () => ({
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
generateApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
formValuesToPayload: vi.fn(),
|
||||
getFormInitialValues: vi.fn(() => ({})),
|
||||
getFormValidators: vi.fn(() => ({})),
|
||||
userToFormValues: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// ── Mantine form ───────────────────────────────────────────────────────────────
|
||||
const mockForm = {
|
||||
getInputProps: vi.fn(() => ({})),
|
||||
key: vi.fn((k) => k),
|
||||
setValues: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
getValues: vi.fn(() => ({ user_level: '3' })),
|
||||
onSubmit: vi.fn((fn) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return fn();
|
||||
}),
|
||||
submitting: false,
|
||||
};
|
||||
|
||||
vi.mock('@mantine/form', () => ({
|
||||
useForm: vi.fn(() => mockForm),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Copy: () => <svg data-testid="icon-copy" />,
|
||||
Key: () => <svg data-testid="icon-key" />,
|
||||
RotateCcwKey: () => <svg data-testid="icon-rotate-ccw-key" />,
|
||||
X: () => <svg data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, disabled }) => (
|
||||
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, type, leftSection }) => (
|
||||
<button
|
||||
type={type || 'button'}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={String(loading)}
|
||||
>
|
||||
{leftSection}
|
||||
{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,
|
||||
MultiSelect: ({ label, onChange }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`multiselect-${label}`}
|
||||
onChange={(e) =>
|
||||
onChange?.(e.target.value ? e.target.value.split(',') : [])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
NumberInput: ({ label }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input />
|
||||
</div>
|
||||
),
|
||||
PasswordInput: ({ label, disabled }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input type="password" disabled={disabled} />
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, disabled }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select disabled={disabled} />
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Switch: ({ label }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
),
|
||||
Tabs: ({ children }) => <div>{children}</div>,
|
||||
TabsList: ({ children }) => <div>{children}</div>,
|
||||
TabsPanel: ({ children, value }) => (
|
||||
<div data-testid={`panel-${value}`}>{children}</div>
|
||||
),
|
||||
TabsTab: ({ children, value }) => (
|
||||
<button data-testid={`tab-${value}`}>{children}</button>
|
||||
),
|
||||
TagsInput: ({ label }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input />
|
||||
</div>
|
||||
),
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
TextInput: ({ label, value, disabled, rightSection }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`textinput-${label}`}
|
||||
defaultValue={value}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{rightSection}
|
||||
</div>
|
||||
),
|
||||
useMantineTheme: () => ({
|
||||
colors: { red: Array(10).fill('#ff0000') },
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import useOutputProfilesStore from '../../../store/outputProfiles';
|
||||
import useAuthStore from '../../../store/auth';
|
||||
import * as UserUtils from '../../../utils/forms/UserUtils.js';
|
||||
import { copyToClipboard } from '../../../utils';
|
||||
import User from '../User';
|
||||
|
||||
// ── Factories ──────────────────────────────────────────────────────────────────
|
||||
const makeAdminUser = (overrides = {}) => ({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
user_level: 1,
|
||||
api_key: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRegularUser = (overrides = {}) => ({
|
||||
id: 2,
|
||||
username: 'user1',
|
||||
user_level: 3,
|
||||
api_key: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({
|
||||
authUser = makeAdminUser(),
|
||||
profiles = {},
|
||||
outputProfiles = [],
|
||||
} = {}) => {
|
||||
const mockSetUser = vi.fn();
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) => sel({ profiles }));
|
||||
vi.mocked(useOutputProfilesStore).mockImplementation((sel) =>
|
||||
sel({ profiles: outputProfiles })
|
||||
);
|
||||
vi.mocked(useAuthStore).mockImplementation((sel) =>
|
||||
sel({ user: authUser, setUser: mockSetUser })
|
||||
);
|
||||
|
||||
// Reset form state
|
||||
mockForm.getValues.mockReturnValue({ user_level: '3' });
|
||||
mockForm.onSubmit.mockImplementation((fn) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return fn();
|
||||
});
|
||||
|
||||
return { mockSetUser };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('User', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(UserUtils.createUser).mockResolvedValue({});
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue({});
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
|
||||
key: 'new-api-key',
|
||||
});
|
||||
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: true });
|
||||
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({ user_level: 3 });
|
||||
vi.mocked(UserUtils.getFormInitialValues).mockReturnValue({});
|
||||
vi.mocked(UserUtils.getFormValidators).mockReturnValue({});
|
||||
vi.mocked(UserUtils.userToFormValues).mockReturnValue({});
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
setupMocks();
|
||||
render(<User isOpen={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
setupMocks();
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
setupMocks();
|
||||
const onClose = vi.fn();
|
||||
render(<User isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tabs', () => {
|
||||
it('always renders Account, EPG, and API tabs', () => {
|
||||
setupMocks();
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('tab-account')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tab-epg')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tab-api')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Permissions tab when admin edits another user', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={makeRegularUser()} />);
|
||||
expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Permissions tab when admin edits themselves', () => {
|
||||
const admin = makeAdminUser();
|
||||
setupMocks({ authUser: admin });
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
|
||||
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Permissions tab for non-admin user', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 99 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Admin-only fields ────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin-only fields', () => {
|
||||
it('shows Output Format Override for admin', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Output Format Override')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Output Format Override for non-admin', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 5 })}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByText('Output Format Override')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Output Profile Override for admin', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Allowed IPs for admin', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Allowed IPs for non-admin', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 5 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── API key generation ────────────────────────────────────────────────────────
|
||||
|
||||
describe('API key generation', () => {
|
||||
it('shows "Generate API Key" when no key exists', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Regenerate API Key" when user already has an api_key', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'existing-key' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
|
||||
key: 'brand-new-key',
|
||||
});
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.generateApiKey).toHaveBeenCalledWith({ user_id: 2 });
|
||||
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('also sets the key when response contains raw_key instead of key', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
|
||||
raw_key: 'raw-value',
|
||||
});
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call generateApiKey when canGenerateKey is false', async () => {
|
||||
// non-admin editing someone else
|
||||
setupMocks({ authUser: makeRegularUser({ id: 5 }) });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
// No button visible since canGenerateKey is false
|
||||
expect(screen.queryByText('Generate API Key')).not.toBeInTheDocument();
|
||||
expect(UserUtils.generateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not update key when generateApiKey returns a response without key/raw_key', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
// button text should NOT change since no key was received
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the generated API key in a text input', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
const keyInput = screen.getByTestId('textinput-API Key');
|
||||
expect(keyInput).toHaveValue('show-me');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── API key revocation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('API key revocation', () => {
|
||||
it('shows "Revoke API Key" when a key exists', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "Revoke API Key" when no key exists', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls revokeApiKey with user_id and clears the key on success', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.revokeApiKey).toHaveBeenCalledWith({ user_id: 2 });
|
||||
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates auth store when admin revokes their own key', async () => {
|
||||
const admin = makeAdminUser({ id: 1, api_key: 'my-key' });
|
||||
const { mockSetUser } = setupMocks({ authUser: admin });
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ api_key: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clear key when revokeApiKey returns success: false', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: false });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'still-here' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not update auth store when revoking another user's key", async () => {
|
||||
const admin = makeAdminUser({ id: 1 });
|
||||
const { mockSetUser } = setupMocks({ authUser: admin });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'other-key' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy to clipboard ────────────────────────────────────────────────────────
|
||||
|
||||
describe('copy API key to clipboard', () => {
|
||||
it('calls copyToClipboard with the current key', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'copy-me' })}
|
||||
/>
|
||||
);
|
||||
|
||||
const copyButton = screen.getByTestId('icon-copy').closest('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(
|
||||
'copy-me',
|
||||
expect.objectContaining({ successTitle: 'API Key Copied!' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls createUser when no user prop is given', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.createUser).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateUser when editing an existing user', async () => {
|
||||
const admin = makeAdminUser();
|
||||
setupMocks({ authUser: admin });
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.updateUser).toHaveBeenCalledWith(
|
||||
2,
|
||||
expect.any(Object),
|
||||
true, // isAdmin
|
||||
admin
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates auth store when admin saves their own profile', async () => {
|
||||
const admin = makeAdminUser({ id: 1 });
|
||||
const updatedAdmin = { ...admin, email: 'new@example.com' };
|
||||
const { mockSetUser } = setupMocks({ authUser: admin });
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue(updatedAdmin);
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).toHaveBeenCalledWith(updatedAdmin);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets form and calls onClose after successful submission', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<User isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.reset).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('removes password from payload when updating and payload.password is falsy', async () => {
|
||||
const admin = makeAdminUser();
|
||||
setupMocks({ authUser: admin });
|
||||
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({
|
||||
user_level: 3,
|
||||
password: '',
|
||||
});
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
const payload = vi.mocked(UserUtils.updateUser).mock.calls[0][1];
|
||||
expect(payload).not.toHaveProperty('password');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useEffect on user prop', () => {
|
||||
it('calls userToFormValues and sets form values when user has an id', () => {
|
||||
const user = makeRegularUser({ id: 2 });
|
||||
vi.mocked(UserUtils.userToFormValues).mockReturnValue({
|
||||
username: 'user1',
|
||||
});
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={user} />);
|
||||
|
||||
expect(UserUtils.userToFormValues).toHaveBeenCalledWith(user);
|
||||
expect(mockForm.setValues).toHaveBeenCalledWith({ username: 'user1' });
|
||||
});
|
||||
|
||||
it('resets form when no user is provided', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
expect(mockForm.reset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── XC password generation ───────────────────────────────────────────────────
|
||||
|
||||
describe('XC password generation', () => {
|
||||
it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const rotateButton = screen
|
||||
.getByTestId('icon-rotate-ccw-key')
|
||||
.closest('button');
|
||||
fireEvent.click(rotateButton);
|
||||
|
||||
expect(mockForm.setValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xc_password: expect.any(String) })
|
||||
);
|
||||
});
|
||||
|
||||
it('disables the XC password rotate button for non-admin', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 5 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const rotateButton = screen
|
||||
.getByTestId('icon-rotate-ccw-key')
|
||||
.closest('button');
|
||||
expect(rotateButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel profiles logic ───────────────────────────────────────────────────
|
||||
|
||||
describe('channel profiles logic', () => {
|
||||
const profiles = { 1: { id: 1, name: 'HD' }, 2: { id: 2, name: 'SD' } };
|
||||
|
||||
it('excludes "All" (0) when other profiles are selected alongside it', () => {
|
||||
setupMocks({ authUser: makeAdminUser(), profiles });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
|
||||
fireEvent.change(multiSelect, { target: { value: '1,2' } });
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
|
||||
'channel_profiles',
|
||||
expect.not.arrayContaining(['0'])
|
||||
);
|
||||
});
|
||||
|
||||
it('sets only ["0"] when "All" is newly selected', () => {
|
||||
setupMocks({ authUser: makeAdminUser(), profiles });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
|
||||
fireEvent.change(multiSelect, { target: { value: '0' } });
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
|
||||
'0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows multiple non-all profiles together', () => {
|
||||
setupMocks({ authUser: makeAdminUser(), profiles });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
|
||||
fireEvent.change(multiSelect, { target: { value: '1,2' } });
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
|
||||
'1',
|
||||
'2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
279
frontend/src/components/forms/__tests__/UserAgent.test.jsx
Normal file
279
frontend/src/components/forms/__tests__/UserAgent.test.jsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/UserAgentUtils.js', () => ({
|
||||
addUserAgent: vi.fn(),
|
||||
updateUserAgent: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, disabled, type }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="checkbox-is-active"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
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,
|
||||
Space: () => <div />,
|
||||
TextInput: ({ label, error, ...rest }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input data-testid={`input-${label}`} aria-label={label} {...rest} />
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import * as UserAgentUtils from '../../../utils/forms/UserAgentUtils.js';
|
||||
import UserAgent from '../UserAgent';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeUserAgent = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'Chrome',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
description: 'Chrome browser',
|
||||
is_active: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UserAgent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(UserAgentUtils.addUserAgent).mockResolvedValue({});
|
||||
vi.mocked(UserAgentUtils.updateUserAgent).mockResolvedValue({});
|
||||
vi.mocked(UserAgentUtils.getResolver).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<UserAgent isOpen={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal title "User-Agent"', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('User-Agent');
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<UserAgent isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('renders empty fields when no userAgent prop is given', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-User-Agent')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-Description')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('pre-fills fields from userAgent prop', () => {
|
||||
render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
|
||||
expect(screen.getByTestId('input-User-Agent')).toHaveValue('Mozilla/5.0');
|
||||
expect(screen.getByTestId('input-Description')).toHaveValue(
|
||||
'Chrome browser'
|
||||
);
|
||||
});
|
||||
|
||||
it('checks Is Active checkbox when userAgent.is_active is true', () => {
|
||||
render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent({ is_active: true })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
|
||||
});
|
||||
|
||||
it('unchecks Is Active checkbox when userAgent.is_active is false', () => {
|
||||
render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent({ is_active: false })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('defaults Is Active to checked when no userAgent prop given', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Field interactions ───────────────────────────────────────────────────────
|
||||
|
||||
describe('field interactions', () => {
|
||||
it('toggles Is Active checkbox', () => {
|
||||
render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent({ is_active: true })}
|
||||
/>
|
||||
);
|
||||
const checkbox = screen.getByTestId('checkbox-is-active');
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls addUserAgent when no userAgent id is given', async () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.addUserAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
|
||||
render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.updateUserAgent).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'Chrome' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addUserAgent when updating', async () => {
|
||||
render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.addUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateUserAgent when creating', async () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.updateUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful submission', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<UserAgent isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes is_active value in the submitted payload', async () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
const checkbox = screen.getByTestId('checkbox-is-active');
|
||||
fireEvent.click(checkbox); // uncheck it
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.addUserAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_active: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect reset ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('form reset on userAgent change', () => {
|
||||
it('resets form values when userAgent prop changes', () => {
|
||||
const { rerender } = render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
|
||||
|
||||
rerender(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent({
|
||||
id: 2,
|
||||
name: 'Firefox',
|
||||
user_agent: 'Gecko/20100101',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
|
||||
});
|
||||
|
||||
it('resets to empty when userAgent prop is removed', () => {
|
||||
const { rerender } = render(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent()}
|
||||
/>
|
||||
);
|
||||
rerender(<UserAgent isOpen={true} onClose={vi.fn()} userAgent={null} />);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
CircleCheck: () => <svg data-testid="icon-circle-check" />,
|
||||
CircleX: () => <svg data-testid="icon-circle-x" />,
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, disabled, color, variant }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange, disabled }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
onChange={(e) =>
|
||||
onChange?.({ currentTarget: { checked: e.target.checked } })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
SimpleGrid: ({ children }) => <div data-testid="simple-grid">{children}</div>,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
TextInput: ({ label, value, onChange, placeholder }) => (
|
||||
<input
|
||||
aria-label={label ?? placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
SegmentedControl: ({ value, onChange, data }) => (
|
||||
<div data-testid="segmented-control">
|
||||
{data.map((item) => (
|
||||
<button
|
||||
key={item.value ?? item}
|
||||
data-testid={`segment-${item.value ?? item}`}
|
||||
onClick={() => onChange(item.value ?? item)}
|
||||
data-active={value === (item.value ?? item) ? 'true' : 'false'}
|
||||
>
|
||||
{item.label ?? item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useVODStore from '../../../store/useVODStore';
|
||||
import VODCategoryFilter from '../VODCategoryFilter';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeCategories = () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Action',
|
||||
m3u_accounts: [{ m3u_account: 10, enabled: true }],
|
||||
category_type: 'movies',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Comedy',
|
||||
m3u_accounts: [{ m3u_account: 10, enabled: false }],
|
||||
category_type: 'movies',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Drama',
|
||||
m3u_accounts: [{ m3u_account: 10, enabled: true }],
|
||||
category_type: 'movies',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'News',
|
||||
m3u_accounts: [{ m3u_account: 10, enabled: true }],
|
||||
category_type: 'series',
|
||||
},
|
||||
];
|
||||
|
||||
const makePlaylist = (overrides = {}) => ({
|
||||
id: 10,
|
||||
name: 'My Playlist',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const categoriesToDict = (arr) =>
|
||||
arr.reduce((acc, cat) => ({ ...acc, [cat.id]: cat }), {});
|
||||
|
||||
const setupMocks = ({ categories = makeCategories() } = {}) => {
|
||||
const dict = Array.isArray(categories)
|
||||
? categoriesToDict(categories)
|
||||
: categories;
|
||||
vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => {
|
||||
return {
|
||||
playlist: makePlaylist(),
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: true },
|
||||
{ id: 2, name: 'Comedy', enabled: false },
|
||||
{ id: 3, name: 'Drama', enabled: true },
|
||||
],
|
||||
setCategoryStates: vi.fn(),
|
||||
type: 'movies',
|
||||
autoEnableNewGroups: true,
|
||||
setAutoEnableNewGroups: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('VODCategoryFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByTestId('simple-grid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a button for each category matching the type and playlist', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Action' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Comedy' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render categories of a different type', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: 'News' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the text filter input', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the segmented status control', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Enable All and Disable All buttons', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByText('Select Visible')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deselect Visible')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Auto-enable new groups checkbox', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByLabelText(/automatically enable new/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Text filter ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('text filter', () => {
|
||||
it('hides categories that do not match the filter', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
const input = screen.getByPlaceholderText(/filter/i);
|
||||
fireEvent.change(input, { target: { value: 'act' } });
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Action' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Comedy' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
|
||||
target: { value: 'COMEDY' },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Comedy' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Action' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories when filter is cleared', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
const input = screen.getByPlaceholderText(/filter/i);
|
||||
fireEvent.change(input, { target: { value: 'act' } });
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Action' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Comedy' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no categories when filter matches nothing', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
|
||||
target: { value: 'zzznomatch' },
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Action' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Status filter ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('status filter', () => {
|
||||
it('shows only enabled categories when "Enabled" segment is selected', () => {
|
||||
const props = defaultProps({
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: true },
|
||||
{ id: 2, name: 'Comedy', enabled: false },
|
||||
{ id: 3, name: 'Drama', enabled: true },
|
||||
],
|
||||
});
|
||||
render(<VODCategoryFilter {...props} />);
|
||||
fireEvent.click(screen.getByTestId('segment-enabled'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Action' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Comedy' })
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only disabled categories when "Disabled" segment is selected', () => {
|
||||
const props = defaultProps({
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: true },
|
||||
{ id: 2, name: 'Comedy', enabled: false },
|
||||
{ id: 3, name: 'Drama', enabled: true },
|
||||
],
|
||||
});
|
||||
render(<VODCategoryFilter {...props} />);
|
||||
fireEvent.click(screen.getByTestId('segment-disabled'));
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Action' })
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Comedy' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories when "All" segment is active', () => {
|
||||
const props = defaultProps({
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: true },
|
||||
{ id: 2, name: 'Comedy', enabled: false },
|
||||
{ id: 3, name: 'Drama', enabled: true },
|
||||
],
|
||||
});
|
||||
render(<VODCategoryFilter {...props} />);
|
||||
fireEvent.click(screen.getByTestId('segment-disabled'));
|
||||
fireEvent.click(screen.getByTestId('segment-all'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Action' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Comedy' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Combined filters ──────────────────────────────────────────────────────
|
||||
|
||||
describe('combined text + status filters', () => {
|
||||
it('applies both text and status filters simultaneously', () => {
|
||||
const props = defaultProps();
|
||||
render(<VODCategoryFilter {...props} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
|
||||
target: { value: 'o' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('segment-disabled'));
|
||||
// "Comedy" matches "o" AND is disabled; "Action" matches "o" but is enabled
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Comedy' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Action' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Enable All / Disable All ──────────────────────────────────────────────
|
||||
|
||||
describe('Enable All button', () => {
|
||||
it('calls setCategoryStates with all visible categories set to true', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
setCategoryStates,
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: false },
|
||||
{ id: 2, name: 'Comedy', enabled: false },
|
||||
{ id: 3, name: 'Drama', enabled: false },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Select Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(true);
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(true);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('only enables filtered categories when a text filter is active', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
setCategoryStates,
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: false },
|
||||
{ id: 2, name: 'Comedy', enabled: false },
|
||||
{ id: 3, name: 'Drama', enabled: false },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
|
||||
target: { value: 'act' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Select Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(true);
|
||||
// Comedy and Drama were filtered out — their state should be unchanged
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disable All button', () => {
|
||||
it('calls setCategoryStates with all visible categories set to false', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(<VODCategoryFilter {...defaultProps({ setCategoryStates })} />);
|
||||
fireEvent.click(screen.getByText('Deselect Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('only disables filtered categories when a text filter is active', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
setCategoryStates,
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: true },
|
||||
{ id: 2, name: 'Comedy', enabled: true },
|
||||
{ id: 3, name: 'Drama', enabled: true },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
|
||||
target: { value: 'comedy' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Deselect Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(true);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auto-enable new groups ────────────────────────────────────────────────
|
||||
|
||||
describe('autoEnableNewGroups checkbox', () => {
|
||||
it('calls setAutoEnableNewGroups(true) when checked', () => {
|
||||
const setAutoEnableNewGroups = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
autoEnableNewGroups: false,
|
||||
setAutoEnableNewGroups,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
|
||||
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls setAutoEnableNewGroups(false) when unchecked', () => {
|
||||
const setAutoEnableNewGroups = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
autoEnableNewGroups: true,
|
||||
setAutoEnableNewGroups,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
|
||||
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── No playlist / empty categories ───────────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('renders no category buttons when categories list is empty', () => {
|
||||
setupMocks({ categories: [] });
|
||||
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Action' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no category buttons when categoryStates is empty', () => {
|
||||
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Action' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders categories only for the matching playlist id', () => {
|
||||
setupMocks({
|
||||
categories: [
|
||||
...makeCategories(),
|
||||
{
|
||||
id: 99,
|
||||
name: 'Foreign',
|
||||
m3u_accounts: [{ m3u_account: 99, enabled: true }],
|
||||
category_type: 'movies',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Foreign' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
|
|||
const settings = useSettingsStore((s) => s.settings);
|
||||
const isModular =
|
||||
useSettingsStore((s) => s.environment.env_mode) === 'modular';
|
||||
const ipLookupEnvDisabled = useSettingsStore(
|
||||
(s) => s.environment.ip_lookup_env_disabled
|
||||
);
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
|
|
@ -108,6 +111,23 @@ const SystemSettingsForm = React.memo(({ active }) => {
|
|||
id="auto_import_mapped_files"
|
||||
/>
|
||||
</Group>
|
||||
{!ipLookupEnvDisabled && (
|
||||
<Group justify="space-between" pt={5}>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
Enable IP Lookup
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Fetch and display the instance's public IP and country flag in the
|
||||
sidebar.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
{...form.getInputProps('enable_ip_lookup', { type: 'checkbox' })}
|
||||
id="enable_ip_lookup"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{isModular && (
|
||||
<>
|
||||
<Divider my="md" label="Connection Security" labelPosition="left" />
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ const SizedInstallButton = ({
|
|||
: 'var(--mantine-primary-color-filled-hover)'
|
||||
: colorVar,
|
||||
filter: isDisabled
|
||||
? 'brightness(0.65) saturate(0.7)'
|
||||
? 'grayscale(1) brightness(0.55)'
|
||||
: hovered
|
||||
? 'brightness(0.86)'
|
||||
: 'brightness(0.82)',
|
||||
|
|
|
|||
|
|
@ -33,12 +33,10 @@ import { useElementSize } from '@mantine/hooks';
|
|||
import { VariableSizeList } from 'react-window';
|
||||
import {
|
||||
buildChannelIdMap,
|
||||
calculateDesiredScrollPosition,
|
||||
calculateEarliestProgramStart,
|
||||
calculateEnd,
|
||||
calculateHourTimeline,
|
||||
calculateLatestProgramEnd,
|
||||
calculateLeftScrollPosition,
|
||||
calculateNowPosition,
|
||||
calculateScrollPosition,
|
||||
calculateScrollPositionByTimeClick,
|
||||
|
|
@ -47,7 +45,6 @@ import {
|
|||
computeRowHeights,
|
||||
createRecording,
|
||||
createSeriesRule,
|
||||
evaluateSeriesRule,
|
||||
fetchPrograms,
|
||||
fetchRules,
|
||||
filterGuideChannels,
|
||||
|
|
@ -66,6 +63,7 @@ import {
|
|||
PX_PER_MS,
|
||||
calcProgressPct,
|
||||
sortChannels,
|
||||
evaluateSeriesRulesByTvgId,
|
||||
} from '../utils/guideUtils';
|
||||
import API from '../api';
|
||||
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
|
||||
|
|
@ -739,13 +737,22 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
return;
|
||||
}
|
||||
|
||||
await createRecording(channel, program);
|
||||
await createRecording({
|
||||
channel: `${channel.id}`,
|
||||
start_time: program.start_time,
|
||||
end_time: program.end_time,
|
||||
custom_properties: { program },
|
||||
});
|
||||
showNotification({ title: 'Recording scheduled' });
|
||||
}, []);
|
||||
|
||||
const saveSeriesRule = useCallback(async (program, mode) => {
|
||||
await createSeriesRule(program, mode);
|
||||
await evaluateSeriesRule(program);
|
||||
await createSeriesRule({
|
||||
tvg_id: program.tvg_id,
|
||||
mode,
|
||||
title: program.title,
|
||||
});
|
||||
await evaluateSeriesRulesByTvgId(program.tvg_id);
|
||||
// recordings_refreshed WS event triggers the debounced fetchRecordings()
|
||||
showNotification({
|
||||
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NativeSelect,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Select,
|
||||
|
|
@ -68,6 +69,7 @@ export default function PluginBrowsePage() {
|
|||
const saveIntervalTimer = useRef(null);
|
||||
|
||||
const recentlyInstalledSlugs = useRef(new Set());
|
||||
const recentlyUpdatedSlugs = useRef(new Set());
|
||||
const recentlyUninstalledSlugs = useRef(new Set());
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
|
@ -75,7 +77,14 @@ export default function PluginBrowsePage() {
|
|||
const [filterRepo, setFilterRepo] = useState('all');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const perPage = 9;
|
||||
const [perPage, setPerPage] = useState(() => {
|
||||
const stored = localStorage.getItem('pluginBrowsePerPage');
|
||||
return stored && !isNaN(Number(stored)) ? Number(stored) : 9;
|
||||
});
|
||||
const handlePerPageChange = (value) => {
|
||||
setPerPage(Number(value));
|
||||
localStorage.setItem('pluginBrowsePerPage', value);
|
||||
};
|
||||
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
|
|
@ -294,6 +303,8 @@ export default function PluginBrowsePage() {
|
|||
// Pre-sort weights: deprecated → installed → incompatible sink to bottom (in that order)
|
||||
// Recently installed plugins are exempt so they don't jump away after install
|
||||
const weight = (p) => {
|
||||
if (p.install_status === 'update_available') return -1;
|
||||
if (recentlyUpdatedSlugs.current.has(p.slug)) return -1;
|
||||
if (recentlyInstalledSlugs.current.has(p.slug)) return 0;
|
||||
if (recentlyUninstalledSlugs.current.has(p.slug)) return 2;
|
||||
const meetsMin =
|
||||
|
|
@ -335,10 +346,10 @@ export default function PluginBrowsePage() {
|
|||
appVersion,
|
||||
]);
|
||||
|
||||
// Reset to page 1 when filters/search change
|
||||
// Reset to page 1 when filters/search/page-size change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchQuery, filterRepo, filterStatus, sortBy]);
|
||||
}, [searchQuery, filterRepo, filterStatus, sortBy, perPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredPlugins.length / perPage);
|
||||
const paginatedPlugins = filteredPlugins.slice(
|
||||
|
|
@ -347,7 +358,15 @@ export default function PluginBrowsePage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<AppShellMain p={16}>
|
||||
<AppShellMain
|
||||
style={{
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<Box p={16} pb={60} style={{ flex: 1 }}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
|
|
@ -467,38 +486,69 @@ export default function PluginBrowsePage() {
|
|||
)}
|
||||
|
||||
{!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) recentlyInstalledSlugs.current.add(slug);
|
||||
}}
|
||||
onInstalled={(slug) => {
|
||||
if (slug) recentlyInstalledSlugs.current.add(slug);
|
||||
fetchAvailablePlugins();
|
||||
}}
|
||||
onUninstalled={(slug) => {
|
||||
if (slug) recentlyUninstalledSlugs.current.add(slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="lg">
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
total={totalPages}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 'var(--app-shell-navbar-offset, 0rem)',
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
background: '#1A1A1E',
|
||||
borderTop: '1px solid #2A2A2E',
|
||||
}}
|
||||
>
|
||||
<Group gap={5} justify="center" style={{ padding: 8 }}>
|
||||
<Text size="xs">Page Size</Text>
|
||||
<NativeSelect
|
||||
size="xxs"
|
||||
value={String(perPage)}
|
||||
data={['9', '18', '27', '36']}
|
||||
onChange={(e) => handlePerPageChange(e.target.value)}
|
||||
styles={{ input: { textAlignLast: 'center' } }}
|
||||
/>
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
size="xs"
|
||||
withEdges
|
||||
/>
|
||||
<Text size="xs">
|
||||
{`${(page - 1) * perPage + 1} to ${Math.min(page * perPage, filteredPlugins.length)} of ${filteredPlugins.length}`}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Manage Repos Modal */}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,36 @@ import useEPGsStore from '../../store/epgs';
|
|||
import useSettingsStore from '../../store/settings';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import * as guideUtils from '../../utils/guideUtils';
|
||||
import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js';
|
||||
import * as dateTimeUtils from '../../utils/dateTimeUtils.js';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../store/channels');
|
||||
vi.mock('../../store/logos');
|
||||
vi.mock('../../store/epgs');
|
||||
vi.mock('../../store/settings');
|
||||
vi.mock('../../store/useVideoStore');
|
||||
vi.mock('../../hooks/useLocalStorage');
|
||||
vi.mock('../../api');
|
||||
vi.mock('../../store/channels', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../store/logos', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../store/epgs', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../store/settings', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../store/useVideoStore', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../hooks/useLocalStorage', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../api', () => ({
|
||||
default: {
|
||||
getAllChannelIds: vi.fn(),
|
||||
getChannelsSummary: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/hooks', () => ({
|
||||
useElementSize: () => ({
|
||||
|
|
@ -235,7 +251,7 @@ vi.mock('../../components/forms/SeriesRecordingModal', () => ({
|
|||
}));
|
||||
vi.mock('../../components/ProgramDetailModal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ program, channel, opened, onClose, onRecord }) =>
|
||||
default: ({ program, opened, onClose, onRecord }) =>
|
||||
opened ? (
|
||||
<div data-testid="program-detail-modal">
|
||||
<div>{program?.title}</div>
|
||||
|
|
@ -252,11 +268,11 @@ vi.mock('../../utils/guideUtils', async () => {
|
|||
fetchPrograms: vi.fn(),
|
||||
createRecording: vi.fn(),
|
||||
createSeriesRule: vi.fn(),
|
||||
evaluateSeriesRule: vi.fn(),
|
||||
fetchRules: vi.fn(),
|
||||
filterGuideChannels: vi.fn(),
|
||||
getGroupOptions: vi.fn(),
|
||||
getProfileOptions: vi.fn(),
|
||||
sortChannels: vi.fn((ch) => ch),
|
||||
};
|
||||
});
|
||||
vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
|
||||
|
|
@ -403,7 +419,6 @@ describe('Guide', () => {
|
|||
);
|
||||
guideUtils.createRecording.mockResolvedValue(undefined);
|
||||
guideUtils.createSeriesRule.mockResolvedValue(undefined);
|
||||
guideUtils.evaluateSeriesRule.mockResolvedValue(undefined);
|
||||
guideUtils.getGroupOptions.mockReturnValue([
|
||||
{ value: 'all', label: 'All Groups' },
|
||||
{ value: 'group-1', label: 'News' },
|
||||
|
|
@ -666,8 +681,7 @@ describe('Guide', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(guideUtils.createRecording).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'channel-1' }),
|
||||
expect.objectContaining({ id: 'prog-1' })
|
||||
expect.objectContaining({ channel: 'channel-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -194,15 +194,25 @@ describe('usePlaylistsStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should remove playlists', () => {
|
||||
it('should remove playlists and their profiles', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.playlists = [
|
||||
{ id: 'playlist1', name: 'Playlist 1' },
|
||||
{ id: 'playlist2', name: 'Playlist 2' },
|
||||
{ id: 'playlist3', name: 'Playlist 3' },
|
||||
];
|
||||
result.current.addPlaylist({
|
||||
id: 'playlist1',
|
||||
name: 'Playlist 1',
|
||||
profiles: ['profile1'],
|
||||
});
|
||||
result.current.addPlaylist({
|
||||
id: 'playlist2',
|
||||
name: 'Playlist 2',
|
||||
profiles: ['profile2'],
|
||||
});
|
||||
result.current.addPlaylist({
|
||||
id: 'playlist3',
|
||||
name: 'Playlist 3',
|
||||
profiles: ['profile3'],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -210,8 +220,11 @@ describe('usePlaylistsStore', () => {
|
|||
});
|
||||
|
||||
expect(result.current.playlists).toEqual([
|
||||
{ id: 'playlist2', name: 'Playlist 2' },
|
||||
{ id: 'playlist2', name: 'Playlist 2', profiles: ['profile2'] },
|
||||
]);
|
||||
expect(result.current.profiles).toEqual({ playlist2: ['profile2'] });
|
||||
expect(result.current.profiles.playlist1).toBeUndefined();
|
||||
expect(result.current.profiles.playlist3).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set refresh progress with two parameters', () => {
|
||||
|
|
|
|||
|
|
@ -87,7 +87,11 @@ describe('useSettingsStore', () => {
|
|||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
city: '',
|
||||
env_mode: 'aio',
|
||||
ip_lookup_enabled: true,
|
||||
ip_lookup_env_disabled: false,
|
||||
ip_lookup_pending: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ describe('useVODStore', () => {
|
|||
movieDetails = await result.current.fetchMovieDetailsFromProvider(1);
|
||||
});
|
||||
|
||||
expect(api.getMovieProviderInfo).toHaveBeenCalledWith(1);
|
||||
expect(api.getMovieProviderInfo).toHaveBeenCalledWith(1, null);
|
||||
expect(movieDetails.name).toBe('Provider Movie');
|
||||
expect(movieDetails.description).toBe('From provider');
|
||||
expect(movieDetails.backdrop_path).toEqual(['path1', 'path2']);
|
||||
|
|
@ -446,7 +446,7 @@ describe('useVODStore', () => {
|
|||
seriesInfo = await result.current.fetchSeriesInfo(1);
|
||||
});
|
||||
|
||||
expect(api.getSeriesInfo).toHaveBeenCalledWith(1);
|
||||
expect(api.getSeriesInfo).toHaveBeenCalledWith(1, null);
|
||||
expect(seriesInfo.id).toBe(1);
|
||||
expect(seriesInfo.name).toBe('Test Series');
|
||||
expect(seriesInfo.episodesList).toHaveLength(2);
|
||||
|
|
|
|||
|
|
@ -86,12 +86,16 @@ const usePlaylistsStore = create((set) => ({
|
|||
})),
|
||||
|
||||
removePlaylists: (playlistIds) =>
|
||||
set((state) => ({
|
||||
playlists: state.playlists.filter(
|
||||
(playlist) => !playlistIds.includes(playlist.id)
|
||||
),
|
||||
// @TODO: remove playlist profiles here
|
||||
})),
|
||||
set((state) => {
|
||||
const updatedProfiles = { ...state.profiles };
|
||||
playlistIds.forEach((id) => delete updatedProfiles[id]);
|
||||
return {
|
||||
playlists: state.playlists.filter(
|
||||
(playlist) => !playlistIds.includes(playlist.id)
|
||||
),
|
||||
profiles: updatedProfiles,
|
||||
};
|
||||
}),
|
||||
|
||||
setRefreshProgress: (accountIdOrData, data) =>
|
||||
set((state) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const useSettingsStore = create((set, get) => ({
|
|||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
city: '',
|
||||
env_mode: 'aio',
|
||||
ip_lookup_enabled: true,
|
||||
ip_lookup_env_disabled: false,
|
||||
ip_lookup_pending: false,
|
||||
},
|
||||
version: {
|
||||
version: '',
|
||||
|
|
@ -40,7 +44,11 @@ const useSettingsStore = create((set, get) => ({
|
|||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
city: '',
|
||||
env_mode: 'aio',
|
||||
ip_lookup_enabled: true,
|
||||
ip_lookup_env_disabled: false,
|
||||
ip_lookup_pending: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -78,6 +86,15 @@ const useSettingsStore = create((set, get) => ({
|
|||
}
|
||||
},
|
||||
|
||||
setEnvironmentFields: (fields) =>
|
||||
set((state) => ({
|
||||
environment: {
|
||||
...state.environment,
|
||||
...fields,
|
||||
ip_lookup_pending: false,
|
||||
},
|
||||
})),
|
||||
|
||||
updateSetting: (setting) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [setting.key]: setting },
|
||||
|
|
|
|||
|
|
@ -240,10 +240,10 @@ const useVODStore = create((set, get) => ({
|
|||
}
|
||||
},
|
||||
|
||||
fetchMovieDetailsFromProvider: async (movieId) => {
|
||||
fetchMovieDetailsFromProvider: async (movieId, relationId = null) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await api.getMovieProviderInfo(movieId);
|
||||
const response = await api.getMovieProviderInfo(movieId, relationId);
|
||||
|
||||
// Transform the response data to match our expected format
|
||||
const movieDetails = getMovieDetailsWithProvider(response, movieId);
|
||||
|
|
@ -346,10 +346,10 @@ const useVODStore = create((set, get) => ({
|
|||
return { content: updatedContent };
|
||||
}),
|
||||
|
||||
fetchSeriesInfo: async (seriesId) => {
|
||||
fetchSeriesInfo: async (seriesId, relationId = null) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await api.getSeriesInfo(seriesId);
|
||||
const response = await api.getSeriesInfo(seriesId, relationId);
|
||||
|
||||
// Transform the response data to match our expected format
|
||||
const seriesInfo = getSeriesDetails(response, seriesId);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,24 @@ describe('dateTimeUtils', () => {
|
|||
const result = dateTimeUtils.initializeTime(date);
|
||||
expect(result.format()).toBe(dayjs(date).format());
|
||||
});
|
||||
|
||||
it('should handle custom format and locale', () => {
|
||||
const result = dateTimeUtils.initializeTime('15/01/2024', 'DD/MM/YYYY');
|
||||
expect(result.isValid()).toBe(true);
|
||||
expect(result.date()).toBe(15);
|
||||
expect(result.month()).toBe(0); // January = 0
|
||||
expect(result.year()).toBe(2024);
|
||||
});
|
||||
|
||||
it('should handle strict parsing', () => {
|
||||
// With strict=true, a date that doesn't match the format should be invalid
|
||||
const invalid = dateTimeUtils.initializeTime('15-01-2024', 'YYYY-MM-DD', null, true);
|
||||
expect(invalid.isValid()).toBe(false);
|
||||
|
||||
// With strict=true and a matching format, it should be valid
|
||||
const valid = dateTimeUtils.initializeTime('2024-01-15', 'YYYY-MM-DD', null, true);
|
||||
expect(valid.isValid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOfDay', () => {
|
||||
|
|
@ -433,7 +451,7 @@ describe('dateTimeUtils', () => {
|
|||
});
|
||||
|
||||
it('should return original string for unparseable format', () => {
|
||||
expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM');
|
||||
expect(dateTimeUtils.toTimeString('not-a-time')).toBe('not-a-time');
|
||||
});
|
||||
|
||||
it('should return original string for invalid format', () => {
|
||||
|
|
@ -712,4 +730,61 @@ describe('dateTimeUtils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid', () => {
|
||||
it('should return true for a valid date string', () => {
|
||||
expect(dateTimeUtils.isValid('2024-01-15T10:00:00Z')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for an invalid date string', () => {
|
||||
expect(dateTimeUtils.isValid('not-a-date')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a Date object', () => {
|
||||
expect(dateTimeUtils.isValid(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for an invalid Date object', () => {
|
||||
expect(dateTimeUtils.isValid(new Date('invalid'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDate', () => {
|
||||
it('should convert a date string to a Date object', () => {
|
||||
const result = dateTimeUtils.toDate('2024-01-15T10:00:00Z');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should convert a dayjs object to a Date object', () => {
|
||||
const djs = dayjs('2024-01-15T10:00:00Z');
|
||||
const result = dateTimeUtils.toDate(djs);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMillisecond', () => {
|
||||
it('should set the millisecond on a date', () => {
|
||||
const date = dayjs.utc('2024-01-15T10:00:00.000Z');
|
||||
const result = dateTimeUtils.setMillisecond(date, 500);
|
||||
expect(result.millisecond()).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 0 milliseconds when set to 0', () => {
|
||||
const date = dayjs.utc('2024-01-15T10:00:00.999Z');
|
||||
const result = dateTimeUtils.setMillisecond(date, 0);
|
||||
expect(result.millisecond()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMillisecond', () => {
|
||||
it('should return the millisecond from a date', () => {
|
||||
const date = dayjs.utc('2024-01-15T14:00:00.123Z');
|
||||
expect(dateTimeUtils.getMillisecond(date)).toBe(123);
|
||||
});
|
||||
|
||||
it('should return 0 for a date with no milliseconds', () => {
|
||||
const date = dayjs.utc('2024-01-15T14:00:00.000Z');
|
||||
expect(dateTimeUtils.getMillisecond(date)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import API from '../../api.js';
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import defaultLogo from '../../images/logo.png';
|
||||
import { formatSeasonEpisode } from '../guideUtils.js';
|
||||
import { buildLiveStreamUrl } from '../components/FloatingVideoUtils.js';
|
||||
|
||||
export const removeRecording = (id) => {
|
||||
// Optimistically remove immediately from UI
|
||||
|
|
@ -63,7 +64,7 @@ export const getShowVideoUrl = (channel, env_mode) => {
|
|||
if (env_mode === 'dev') {
|
||||
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
|
||||
}
|
||||
return url;
|
||||
return buildLiveStreamUrl(url);
|
||||
};
|
||||
|
||||
export const runComSkip = async (recording) => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ vi.mock('../../../store/channels');
|
|||
describe('RecordingCardUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('removeRecording', () => {
|
||||
|
|
@ -147,19 +148,32 @@ describe('RecordingCardUtils', () => {
|
|||
});
|
||||
|
||||
describe('getShowVideoUrl', () => {
|
||||
it('returns proxy URL for channel', () => {
|
||||
it('returns proxy URL with mpegts output format for channel', () => {
|
||||
const channel = { uuid: 'channel-123' };
|
||||
const result = getShowVideoUrl(channel, 'production');
|
||||
|
||||
expect(result).toBe('/proxy/ts/stream/channel-123');
|
||||
expect(result).toBe('/proxy/ts/stream/channel-123?output_format=mpegts');
|
||||
});
|
||||
|
||||
it('prepends dev server URL in dev mode', () => {
|
||||
it('includes output_profile when set in player prefs', () => {
|
||||
localStorage.setItem(
|
||||
'dispatcharr-player-prefs',
|
||||
JSON.stringify({ webPlayerOutputProfileId: 5 })
|
||||
);
|
||||
const channel = { uuid: 'channel-123' };
|
||||
const result = getShowVideoUrl(channel, 'production');
|
||||
|
||||
expect(result).toBe(
|
||||
'/proxy/ts/stream/channel-123?output_format=mpegts&output_profile=5'
|
||||
);
|
||||
});
|
||||
|
||||
it('prepends dev server URL in dev mode with output params', () => {
|
||||
const channel = { uuid: 'channel-123' };
|
||||
const result = getShowVideoUrl(channel, 'dev');
|
||||
|
||||
expect(result).toMatch(
|
||||
/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/
|
||||
/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123\?output_format=mpegts$/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import useAuthStore from '../../store/auth';
|
||||
|
||||
export const imdbUrl = (imdb_id) =>
|
||||
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
|
||||
|
||||
|
|
@ -27,7 +29,9 @@ const extractQuality = (relation) => {
|
|||
|
||||
// 2. Secondary: Custom properties detailed_info
|
||||
if (relation.custom_properties?.detailed_info) {
|
||||
const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info);
|
||||
const fromDetailedInfo = getQualityFromDetailedInfo(
|
||||
relation.custom_properties.detailed_info
|
||||
);
|
||||
if (fromDetailedInfo) return fromDetailedInfo;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +59,10 @@ const getQualityFromBackend = (qualityInfo) => {
|
|||
const getQualityFromDetailedInfo = (detailedInfo) => {
|
||||
// Check video dimensions first
|
||||
if (detailedInfo.video?.width && detailedInfo.video?.height) {
|
||||
return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height);
|
||||
return getQualityInfoFromDimensions(
|
||||
detailedInfo.video.width,
|
||||
detailedInfo.video.height
|
||||
);
|
||||
}
|
||||
|
||||
// Check name field
|
||||
|
|
@ -91,7 +98,7 @@ const getQualityInfoFromDimensions = (width, height) => {
|
|||
} else {
|
||||
return ` - ${width}x${height}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sortEpisodesList = (episodesList) => {
|
||||
return episodesList.sort((a, b) => {
|
||||
|
|
@ -123,15 +130,18 @@ export const sortBySeasonNumber = (episodesBySeason) => {
|
|||
export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => {
|
||||
let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
|
||||
|
||||
// Add selected provider as query parameter if available
|
||||
const params = new URLSearchParams();
|
||||
if (selectedProvider) {
|
||||
// Use stream_id for most specific selection, fallback to account_id
|
||||
if (selectedProvider.stream_id) {
|
||||
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
|
||||
params.set('stream_id', selectedProvider.stream_id);
|
||||
} else {
|
||||
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
|
||||
params.set('m3u_account_id', selectedProvider.m3u_account.id);
|
||||
}
|
||||
}
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) params.set('token', token);
|
||||
if (params.toString())
|
||||
streamUrl += `?${params.toString().replace(/\+/g, '%20')}`;
|
||||
|
||||
if (env_mode === 'dev') {
|
||||
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import useAuthStore from '../../store/auth';
|
||||
|
||||
const hasValidTechnicalDetails = (obj) => {
|
||||
return obj?.bitrate || obj?.video || obj?.audio;
|
||||
};
|
||||
|
|
@ -60,15 +62,18 @@ export const getTechnicalDetails = (selectedProvider, defaultVOD) => {
|
|||
export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => {
|
||||
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
|
||||
|
||||
// Add selected provider as query parameter if available
|
||||
const params = new URLSearchParams();
|
||||
if (selectedProvider) {
|
||||
// Use stream_id for most specific selection, fallback to account_id
|
||||
if (selectedProvider.stream_id) {
|
||||
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
|
||||
params.set('stream_id', selectedProvider.stream_id);
|
||||
} else {
|
||||
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
|
||||
params.set('m3u_account_id', selectedProvider.m3u_account.id);
|
||||
}
|
||||
}
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) params.set('token', token);
|
||||
if (params.toString())
|
||||
streamUrl += `?${params.toString().replace(/\+/g, '%20')}`;
|
||||
|
||||
if (env_mode === 'dev') {
|
||||
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import duration from 'dayjs/plugin/duration';
|
|||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
|
||||
|
|
@ -11,12 +12,21 @@ dayjs.extend(duration);
|
|||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
|
||||
|
||||
export const convertToSec = (dateTime) => dayjs(dateTime).unix();
|
||||
|
||||
export const initializeTime = (dateTime) => dayjs(dateTime);
|
||||
export const initializeTime = (dateTime, format = null, locale = null, strict = false) => {
|
||||
if (format && locale) {
|
||||
return dayjs(dateTime, format, locale, strict);
|
||||
} else if (format) {
|
||||
return dayjs(dateTime, format, strict);
|
||||
} else {
|
||||
return dayjs(dateTime);
|
||||
}
|
||||
}
|
||||
|
||||
export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
|
||||
|
||||
|
|
@ -43,6 +53,10 @@ export const getNow = () => dayjs();
|
|||
export const toFriendlyDuration = (dateTime, unit) =>
|
||||
dayjs.duration(dateTime, unit).humanize();
|
||||
|
||||
export const isValid = (dateTime) => dayjs(dateTime).isValid();
|
||||
|
||||
export const toDate = (dateTime) => dayjs(dateTime).toDate();
|
||||
|
||||
export const formatExactDuration = (seconds) => {
|
||||
if (seconds < 60) return `${seconds.toFixed(1)} seconds`;
|
||||
if (seconds < 3600) {
|
||||
|
|
@ -76,6 +90,8 @@ export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value);
|
|||
|
||||
export const setSecond = (dateTime, value) => dayjs(dateTime).second(value);
|
||||
|
||||
export const setMillisecond = (dateTime, value) => dayjs(dateTime).millisecond(value);
|
||||
|
||||
export const getMonth = (dateTime) => dayjs(dateTime).month();
|
||||
|
||||
export const getYear = (dateTime) => dayjs(dateTime).year();
|
||||
|
|
@ -88,6 +104,8 @@ export const getMinute = (dateTime) => dayjs(dateTime).minute();
|
|||
|
||||
export const getSecond = (dateTime) => dayjs(dateTime).second();
|
||||
|
||||
export const getMillisecond = (dateTime) => dayjs(dateTime).millisecond();
|
||||
|
||||
export const getNowMs = () => Date.now();
|
||||
|
||||
export const roundToNearest = (dateTime, minutes) => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import API from '../../api.js';
|
||||
|
||||
export const getStatRows = (stats) => {
|
||||
return [
|
||||
['Video Codec', stats.video_codec],
|
||||
|
|
@ -86,3 +88,22 @@ export const getUpcomingEpisodes = (
|
|||
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
|
||||
);
|
||||
};
|
||||
|
||||
export const getChannel = (id) => {
|
||||
return API.getChannel(id);
|
||||
};
|
||||
|
||||
export const updateRecordingMetadata = (
|
||||
recording,
|
||||
editTitle,
|
||||
editDescription
|
||||
) => {
|
||||
return API.updateRecordingMetadata(recording.id, {
|
||||
title: editTitle || 'Custom Recording',
|
||||
description: editDescription,
|
||||
});
|
||||
};
|
||||
|
||||
export const refreshArtwork = (id) => {
|
||||
return API.refreshArtwork(id);
|
||||
};
|
||||
|
|
|
|||
181
frontend/src/utils/forms/RecordingUtils.js
Normal file
181
frontend/src/utils/forms/RecordingUtils.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import API from '../../api.js';
|
||||
import {
|
||||
add,
|
||||
diff,
|
||||
format,
|
||||
getNow,
|
||||
initializeTime,
|
||||
isValid,
|
||||
roundToNearest,
|
||||
setMillisecond,
|
||||
setSecond,
|
||||
toDate,
|
||||
toTimeString,
|
||||
} from '../dateTimeUtils.js';
|
||||
import { isNotEmpty } from '@mantine/form';
|
||||
|
||||
export const asDate = (value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
export const toIsoIfDate = (value) => {
|
||||
const dt = asDate(value);
|
||||
return dt ? dt.toISOString() : value;
|
||||
};
|
||||
|
||||
export const toDateString = (value) => {
|
||||
const dt = asDate(value);
|
||||
return dt ? format(dt, 'YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
export const createRoundedDate = (minutesAhead = 0) => {
|
||||
let rounded = roundToNearest(getNow(), 30);
|
||||
rounded = setSecond(rounded, 0);
|
||||
rounded = setMillisecond(rounded, 0);
|
||||
return toDate(minutesAhead ? add(rounded, minutesAhead, 'minute') : rounded);
|
||||
};
|
||||
|
||||
// robust onChange for TimeInput (string or event)
|
||||
export const timeChange = (setter) => (valOrEvent) => {
|
||||
if (typeof valOrEvent === 'string') setter(valOrEvent);
|
||||
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
|
||||
};
|
||||
|
||||
export const getChannelsSummary = () => {
|
||||
return API.getChannelsSummary();
|
||||
};
|
||||
export const updateRecording = (id, data) => {
|
||||
return API.updateRecording(id, data);
|
||||
};
|
||||
export const createRecording = (data) => {
|
||||
return API.createRecording(data);
|
||||
};
|
||||
export const createRecurringRule = (data) => {
|
||||
return API.createRecurringRule(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts a channels array or object by channel_number (then name) and maps
|
||||
* each entry to a { value, label } select option.
|
||||
*
|
||||
* @param {Array|Object} channels
|
||||
* @param {(item: object) => string} [labelFn] defaults to `name || "Channel id"`
|
||||
*/
|
||||
export const sortedChannelOptions = (channels, labelFn) => {
|
||||
const list = Array.isArray(channels)
|
||||
? channels
|
||||
: Object.values(channels || {});
|
||||
|
||||
const defaultLabel = (item) => item.name || `Channel ${item.id}`;
|
||||
|
||||
return [...list]
|
||||
.sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum !== bNum) return aNum - bNum;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
})
|
||||
.map((item) => ({
|
||||
value: String(item.id),
|
||||
label: (labelFn ?? defaultLabel)(item),
|
||||
}));
|
||||
};
|
||||
|
||||
/** Label that includes the channel number prefix used in Recording/SeriesRule UIs. */
|
||||
export const numberedChannelLabel = (item) =>
|
||||
item.channel_number
|
||||
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
|
||||
: item.name || `Channel ${item.id}`;
|
||||
|
||||
export const getSingleFormDefaults = (recording = null, channel = null) => {
|
||||
const defaultStart = createRoundedDate();
|
||||
const defaultEnd = createRoundedDate(60);
|
||||
return {
|
||||
channel_id: recording
|
||||
? `${recording.channel}`
|
||||
: channel
|
||||
? `${channel.id}`
|
||||
: '',
|
||||
start_time: recording
|
||||
? asDate(recording.start_time) || defaultStart
|
||||
: defaultStart,
|
||||
end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export const getRecurringFormDefaults = (channel = null) => {
|
||||
const defaultStart = createRoundedDate();
|
||||
const defaultEnd = createRoundedDate(60);
|
||||
const defaultDate = new Date();
|
||||
return {
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
days_of_week: [],
|
||||
start_time: format(defaultStart, 'HH:mm'),
|
||||
end_time: format(defaultEnd, 'HH:mm'),
|
||||
rule_name: channel?.name || '',
|
||||
start_date: defaultDate,
|
||||
end_date: defaultDate,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSinglePayload = (values) => ({
|
||||
channel: values.channel_id,
|
||||
start_time: toIsoIfDate(values.start_time),
|
||||
end_time: toIsoIfDate(values.end_time),
|
||||
});
|
||||
|
||||
export const buildRecurringPayload = (values) => ({
|
||||
channel: values.channel_id,
|
||||
days_of_week: (values.days_of_week || []).map(Number),
|
||||
start_time: toTimeString(values.start_time),
|
||||
end_time: toTimeString(values.end_time),
|
||||
start_date: toDateString(values.start_date),
|
||||
end_date: toDateString(values.end_date),
|
||||
name: values.rule_name?.trim() || '',
|
||||
});
|
||||
|
||||
export const singleFormValidators = {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
start_time: isNotEmpty('Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
const start = asDate(values.start_time);
|
||||
const end = asDate(value);
|
||||
if (!end) return 'Select an end time';
|
||||
if (start && end <= start) return 'End time must be after start time';
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export const recurringFormValidators = {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
days_of_week: (value) => (value?.length ? null : 'Pick at least one day'),
|
||||
start_time: (value) => (value ? null : 'Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
if (!value) return 'Select an end time';
|
||||
const start = initializeTime(
|
||||
values.start_time,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
null,
|
||||
true
|
||||
);
|
||||
const end = initializeTime(
|
||||
value,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
null,
|
||||
true
|
||||
);
|
||||
if (isValid(start) && isValid(end) && diff(start, end, 'minute') === 0)
|
||||
return 'End time must differ from start time';
|
||||
return null;
|
||||
},
|
||||
end_date: (value, values) => {
|
||||
const end = asDate(value);
|
||||
const start = asDate(values.start_date);
|
||||
if (!end) return 'Select an end date';
|
||||
if (start && end < start) return 'End date cannot be before start date';
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,25 +1,12 @@
|
|||
import API from '../../api.js';
|
||||
import { toTimeString } from '../dateTimeUtils.js';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const getChannelOptions = (channels) => {
|
||||
const list = Array.isArray(channels)
|
||||
? channels
|
||||
: Object.values(channels || {});
|
||||
return list
|
||||
.sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum === bNum) {
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
}
|
||||
return aNum - bNum;
|
||||
})
|
||||
.map((item) => ({
|
||||
value: `${item.id}`,
|
||||
label: item.name || `Channel ${item.id}`,
|
||||
}));
|
||||
};
|
||||
import {
|
||||
getNow,
|
||||
isAfter,
|
||||
parseDate,
|
||||
toDate,
|
||||
toTimeString,
|
||||
} from '../dateTimeUtils.js';
|
||||
import { buildRecurringPayload } from './RecordingUtils.js';
|
||||
|
||||
export const getUpcomingOccurrences = (
|
||||
recordings,
|
||||
|
|
@ -35,7 +22,7 @@ export const getUpcomingOccurrences = (
|
|||
.filter(
|
||||
(rec) =>
|
||||
rec?.custom_properties?.rule?.id === ruleId &&
|
||||
toUserTime(rec.start_time).isAfter(now)
|
||||
isAfter(toUserTime(rec.start_time), now)
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
|
|
@ -45,17 +32,7 @@ export const getUpcomingOccurrences = (
|
|||
|
||||
export const updateRecurringRule = async (ruleId, values) => {
|
||||
await API.updateRecurringRule(ruleId, {
|
||||
channel: values.channel_id,
|
||||
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
|
||||
start_time: toTimeString(values.start_time),
|
||||
end_time: toTimeString(values.end_time),
|
||||
start_date: values.start_date
|
||||
? dayjs(values.start_date).format('YYYY-MM-DD')
|
||||
: null,
|
||||
end_date: values.end_date
|
||||
? dayjs(values.end_date).format('YYYY-MM-DD')
|
||||
: null,
|
||||
name: values.rule_name?.trim() || '',
|
||||
...buildRecurringPayload(values),
|
||||
enabled: Boolean(values.enabled),
|
||||
});
|
||||
};
|
||||
|
|
@ -67,3 +44,16 @@ export const deleteRecurringRuleById = async (ruleId) => {
|
|||
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
|
||||
await API.updateRecurringRule(ruleId, { enabled: checked });
|
||||
};
|
||||
|
||||
export const getFormDefaults = (rule) => {
|
||||
return {
|
||||
channel_id: `${rule.channel}`,
|
||||
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
|
||||
rule_name: rule.name || '',
|
||||
start_time: toTimeString(rule.start_time),
|
||||
end_time: toTimeString(rule.end_time),
|
||||
start_date: parseDate(rule.start_date) || toDate(getNow()),
|
||||
end_date: parseDate(rule.end_date),
|
||||
enabled: Boolean(rule.enabled),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
84
frontend/src/utils/forms/SeriesRuleEditorModalUtils.js
Normal file
84
frontend/src/utils/forms/SeriesRuleEditorModalUtils.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import API from '../../api.js';
|
||||
import {
|
||||
numberedChannelLabel,
|
||||
sortedChannelOptions,
|
||||
} from './RecordingUtils.js';
|
||||
|
||||
export const TITLE_MODES = [
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
export const DESCRIPTION_MODES = [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
export const EPISODE_MODES = [
|
||||
{ label: 'All episodes', value: 'all' },
|
||||
{ label: 'New only', value: 'new' },
|
||||
];
|
||||
|
||||
export function formatRange(start, end) {
|
||||
try {
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
|
||||
if (isNaN(s) || isNaN(e)) throw new Error('Invalid date');
|
||||
|
||||
const sameDay = s.toDateString() === e.toDateString();
|
||||
const dateStr = s.toLocaleDateString();
|
||||
const startStr = s.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const endStr = e.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return sameDay
|
||||
? `${dateStr} ${startStr} - ${endStr}`
|
||||
: `${dateStr} ${startStr} -> ${e.toLocaleString()}`;
|
||||
} catch {
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const previewSeriesRule = (debouncedPreviewKey, controller) => {
|
||||
return API.previewSeriesRule(debouncedPreviewKey, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTvgOptions = (tvgs) => {
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
for (const t of tvgs || []) {
|
||||
if (!t.tvg_id || seen.has(t.tvg_id)) continue;
|
||||
seen.add(t.tvg_id);
|
||||
options.push({
|
||||
value: t.tvg_id,
|
||||
label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
|
||||
});
|
||||
}
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
export const getChannelOptions = (allChannels, tvgsById, tvgId) => {
|
||||
const sorted = sortedChannelOptions(allChannels, numberedChannelLabel);
|
||||
const matching = [];
|
||||
const others = [];
|
||||
for (const item of sorted) {
|
||||
const channel = allChannels.find((c) => String(c.id) === item.value);
|
||||
const cTvg = channel?.epg_data_id
|
||||
? tvgsById?.[channel.epg_data_id]?.tvg_id
|
||||
: null;
|
||||
if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
|
||||
matching.push(item);
|
||||
} else {
|
||||
others.push(item);
|
||||
}
|
||||
}
|
||||
return [...matching, ...others];
|
||||
};
|
||||
44
frontend/src/utils/forms/StreamProfileUtils.js
Normal file
44
frontend/src/utils/forms/StreamProfileUtils.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Built-in commands supported by Dispatcharr out of the box.
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import API from '../../api.js';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const BUILT_IN_COMMANDS = [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: 'streamlink', label: 'Streamlink' },
|
||||
{ value: 'cvlc', label: 'VLC' },
|
||||
{ value: 'yt-dlp', label: 'yt-dlp' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
];
|
||||
|
||||
// Default parameter examples for each built-in command.
|
||||
export const COMMAND_EXAMPLES = {
|
||||
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
|
||||
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
|
||||
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
|
||||
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
|
||||
};
|
||||
|
||||
// Returns '__custom__' when the command isn't one of the built-ins,
|
||||
// otherwise returns the command value itself.
|
||||
export const toCommandSelection = (command) =>
|
||||
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
|
||||
? command
|
||||
: '__custom__';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string(),
|
||||
});
|
||||
|
||||
export const getResolver = () => {
|
||||
return yupResolver(schema);
|
||||
};
|
||||
|
||||
export const updateStreamProfile = (profileId, values) => {
|
||||
return API.updateStreamProfile({ id: profileId, ...values });
|
||||
};
|
||||
export const addStreamProfile = (values) => {
|
||||
return API.addStreamProfile(values);
|
||||
};
|
||||
19
frontend/src/utils/forms/StreamUtils.js
Normal file
19
frontend/src/utils/forms/StreamUtils.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as Yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import API from '../../api.js';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
});
|
||||
|
||||
export const getResolver = () => {
|
||||
return yupResolver(schema);
|
||||
};
|
||||
|
||||
export const updateStream = (streamId, payload) => {
|
||||
return API.updateStream({ id: streamId, ...payload });
|
||||
};
|
||||
export const addStream = (payload) => {
|
||||
return API.addStream(payload);
|
||||
};
|
||||
17
frontend/src/utils/forms/UserAgentUtils.js
Normal file
17
frontend/src/utils/forms/UserAgentUtils.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import API from '../../api.js';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
});
|
||||
export const getResolver = () => {
|
||||
return yupResolver(schema);
|
||||
};
|
||||
export const updateUserAgent = (userAgentId, values) => {
|
||||
return API.updateUserAgent({ id: userAgentId, ...values });
|
||||
};
|
||||
export const addUserAgent = (values) => {
|
||||
return API.addUserAgent(values);
|
||||
};
|
||||
148
frontend/src/utils/forms/UserUtils.js
Normal file
148
frontend/src/utils/forms/UserUtils.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { NETWORK_ACCESS_OPTIONS, USER_LEVELS } from '../../constants.js';
|
||||
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../networkUtils.js';
|
||||
import API from '../../api.js';
|
||||
|
||||
const isValidNetworkEntry = (entry) =>
|
||||
entry.match(IPV4_CIDR_REGEX) ||
|
||||
entry.match(IPV6_CIDR_REGEX) ||
|
||||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
|
||||
(entry + '/128').match(IPV6_CIDR_REGEX);
|
||||
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
|
||||
|
||||
export const createUser = (values) => {
|
||||
return API.createUser(values);
|
||||
};
|
||||
|
||||
export const updateUser = (userId, values, isAdmin, authUser) => {
|
||||
return API.updateUser(
|
||||
userId,
|
||||
values,
|
||||
isAdmin ? false : authUser.id === userId
|
||||
);
|
||||
};
|
||||
|
||||
export const generateApiKey = (payload) => {
|
||||
return API.generateApiKey(payload);
|
||||
};
|
||||
|
||||
export const revokeApiKey = (payload) => {
|
||||
return API.revokeApiKey(payload);
|
||||
};
|
||||
|
||||
export const userToFormValues = (user) => {
|
||||
const customProps = user.custom_properties || {};
|
||||
const networks = customProps.allowed_networks || {};
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email,
|
||||
user_level: `${user.user_level}`,
|
||||
stream_limit: user.stream_limit || 0,
|
||||
channel_profiles:
|
||||
user.channel_profiles.length > 0
|
||||
? user.channel_profiles.map((id) => `${id}`)
|
||||
: ['0'],
|
||||
xc_password: customProps.xc_password || '',
|
||||
output_format: customProps.output_format || '',
|
||||
output_profile: customProps.output_profile
|
||||
? `${customProps.output_profile}`
|
||||
: '',
|
||||
hide_adult_content: customProps.hide_adult_content || false,
|
||||
epg_days: customProps.epg_days || 0,
|
||||
epg_prev_days: customProps.epg_prev_days || 0,
|
||||
allowed_ips: [
|
||||
...new Set(
|
||||
NETWORK_KEYS.flatMap((key) =>
|
||||
networks[key] ? networks[key].split(',').filter(Boolean) : []
|
||||
)
|
||||
),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const formValuesToPayload = (values, existingUser) => {
|
||||
const customProps = { ...(existingUser?.custom_properties || {}) };
|
||||
const payload = { ...values };
|
||||
|
||||
customProps.xc_password = payload.xc_password || '';
|
||||
delete payload.xc_password;
|
||||
|
||||
customProps.output_format = payload.output_format || null;
|
||||
delete payload.output_format;
|
||||
|
||||
customProps.output_profile = payload.output_profile
|
||||
? parseInt(payload.output_profile, 10)
|
||||
: null;
|
||||
delete payload.output_profile;
|
||||
|
||||
customProps.hide_adult_content = payload.hide_adult_content || false;
|
||||
delete payload.hide_adult_content;
|
||||
|
||||
customProps.epg_days = payload.epg_days || 0;
|
||||
delete payload.epg_days;
|
||||
|
||||
customProps.epg_prev_days = payload.epg_prev_days || 0;
|
||||
delete payload.epg_prev_days;
|
||||
|
||||
const joined = (payload.allowed_ips || []).join(',');
|
||||
delete payload.allowed_ips;
|
||||
const allowed_networks = {};
|
||||
if (joined) {
|
||||
NETWORK_KEYS.forEach((key) => {
|
||||
allowed_networks[key] = joined;
|
||||
});
|
||||
}
|
||||
customProps.allowed_networks = allowed_networks;
|
||||
|
||||
payload.custom_properties = customProps;
|
||||
|
||||
if (payload.channel_profiles?.includes('0')) {
|
||||
payload.channel_profiles = [];
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const getFormInitialValues = () => {
|
||||
return {
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
user_level: '0',
|
||||
stream_limit: 0,
|
||||
password: '',
|
||||
xc_password: '',
|
||||
output_format: '',
|
||||
output_profile: '',
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
epg_days: 0,
|
||||
epg_prev_days: 0,
|
||||
allowed_ips: [],
|
||||
};
|
||||
};
|
||||
|
||||
export const getFormValidators = (user) => {
|
||||
return (values) => ({
|
||||
username: !values.username
|
||||
? 'Username is required'
|
||||
: values.user_level == USER_LEVELS.STREAMER &&
|
||||
!values.username.match(/^[a-z0-9]+$/i)
|
||||
? 'Streamer username must be alphanumeric'
|
||||
: null,
|
||||
password:
|
||||
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
|
||||
? 'Password is required'
|
||||
: null,
|
||||
xc_password:
|
||||
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
|
||||
? 'XC password must be alphanumeric'
|
||||
: null,
|
||||
allowed_ips: (values.allowed_ips || []).some((t) => !isValidNetworkEntry(t))
|
||||
? 'Invalid IP address or CIDR range'
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,8 +1,21 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils';
|
||||
import dayjs from 'dayjs';
|
||||
import API from '../../../api.js';
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
getChannel: vi.fn(),
|
||||
updateRecordingMetadata: vi.fn(),
|
||||
refreshArtwork: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('RecordingDetailsModalUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getStatRows', () => {
|
||||
it('should return all stats when all values are present', () => {
|
||||
const stats = {
|
||||
|
|
@ -624,4 +637,108 @@ describe('RecordingDetailsModalUtils', () => {
|
|||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChannel', () => {
|
||||
it('should call API.getChannel with the given id', async () => {
|
||||
const mockChannel = { id: 42, name: 'Channel 1' };
|
||||
API.getChannel.mockResolvedValue(mockChannel);
|
||||
|
||||
const result = await RecordingDetailsModalUtils.getChannel(42);
|
||||
|
||||
expect(API.getChannel).toHaveBeenCalledWith(42);
|
||||
expect(result).toEqual(mockChannel);
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
API.getChannel.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow(
|
||||
'Not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecordingMetadata', () => {
|
||||
it('should call API with title and description', async () => {
|
||||
const mockResponse = { id: 1, title: 'Updated Title' };
|
||||
API.updateRecordingMetadata.mockResolvedValue(mockResponse);
|
||||
|
||||
const recording = { id: 1 };
|
||||
const result = await RecordingDetailsModalUtils.updateRecordingMetadata(
|
||||
recording,
|
||||
'Updated Title',
|
||||
'Updated description'
|
||||
);
|
||||
|
||||
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(1, {
|
||||
title: 'Updated Title',
|
||||
description: 'Updated description',
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should default title to "Custom Recording" when editTitle is empty', async () => {
|
||||
API.updateRecordingMetadata.mockResolvedValue({});
|
||||
|
||||
const recording = { id: 5 };
|
||||
await RecordingDetailsModalUtils.updateRecordingMetadata(
|
||||
recording,
|
||||
'',
|
||||
'Some description'
|
||||
);
|
||||
|
||||
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
|
||||
title: 'Custom Recording',
|
||||
description: 'Some description',
|
||||
});
|
||||
});
|
||||
|
||||
it('should default title to "Custom Recording" when editTitle is null', async () => {
|
||||
API.updateRecordingMetadata.mockResolvedValue({});
|
||||
|
||||
const recording = { id: 5 };
|
||||
await RecordingDetailsModalUtils.updateRecordingMetadata(
|
||||
recording,
|
||||
null,
|
||||
'desc'
|
||||
);
|
||||
|
||||
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
|
||||
title: 'Custom Recording',
|
||||
description: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
API.updateRecordingMetadata.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
await expect(
|
||||
RecordingDetailsModalUtils.updateRecordingMetadata(
|
||||
{ id: 1 },
|
||||
'Title',
|
||||
'Desc'
|
||||
)
|
||||
).rejects.toThrow('Server error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshArtwork', () => {
|
||||
it('should call API.refreshArtwork with the given id', async () => {
|
||||
const mockResponse = { success: true };
|
||||
API.refreshArtwork.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await RecordingDetailsModalUtils.refreshArtwork(7);
|
||||
|
||||
expect(API.refreshArtwork).toHaveBeenCalledWith(7);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
API.refreshArtwork.mockRejectedValue(new Error('Artwork fetch failed'));
|
||||
|
||||
await expect(
|
||||
RecordingDetailsModalUtils.refreshArtwork(7)
|
||||
).rejects.toThrow('Artwork fetch failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
514
frontend/src/utils/forms/__tests__/RecordingUtils.test.js
Normal file
514
frontend/src/utils/forms/__tests__/RecordingUtils.test.js
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
getChannelsSummary: vi.fn(),
|
||||
updateRecording: vi.fn(),
|
||||
createRecording: vi.fn(),
|
||||
createRecurringRule: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../dateTimeUtils.js', () => ({
|
||||
add: vi.fn((d, amount, unit) => dayjs(d).add(amount, unit)),
|
||||
diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)),
|
||||
format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)),
|
||||
getNow: vi.fn(() => dayjs('2024-06-15T10:00:00')),
|
||||
initializeTime: vi.fn((val, fmts, ref, strict) => {
|
||||
for (const fmt of fmts) {
|
||||
const p = dayjs(val, fmt, strict);
|
||||
if (p.isValid()) return p;
|
||||
}
|
||||
return dayjs('invalid');
|
||||
}),
|
||||
isValid: vi.fn((d) => dayjs(d).isValid()),
|
||||
roundToNearest: vi.fn((d) => d),
|
||||
setMillisecond: vi.fn((d, ms) => dayjs(d).millisecond(ms)),
|
||||
setSecond: vi.fn((d, s) => dayjs(d).second(s)),
|
||||
toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())),
|
||||
toTimeString: vi.fn((t) => {
|
||||
if (!t) return '00:00';
|
||||
const match = String(t).match(/(\d{2}:\d{2})/);
|
||||
return match ? match[1] : t;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/form', () => ({
|
||||
isNotEmpty: vi.fn((msg) => (value) => (value ? null : msg)),
|
||||
}));
|
||||
|
||||
import API from '../../../api.js';
|
||||
import {
|
||||
asDate,
|
||||
toIsoIfDate,
|
||||
toDateString,
|
||||
createRoundedDate,
|
||||
timeChange,
|
||||
getChannelsSummary,
|
||||
updateRecording,
|
||||
createRecording,
|
||||
createRecurringRule,
|
||||
sortedChannelOptions,
|
||||
numberedChannelLabel,
|
||||
getSingleFormDefaults,
|
||||
getRecurringFormDefaults,
|
||||
buildSinglePayload,
|
||||
buildRecurringPayload,
|
||||
singleFormValidators,
|
||||
recurringFormValidators,
|
||||
} from '../RecordingUtils.js';
|
||||
|
||||
describe('RecordingUtils', () => {
|
||||
// ─── asDate ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('asDate', () => {
|
||||
it('returns null for falsy input', () => {
|
||||
expect(asDate(null)).toBeNull();
|
||||
expect(asDate(undefined)).toBeNull();
|
||||
expect(asDate('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a Date instance unchanged', () => {
|
||||
const d = new Date('2024-06-15');
|
||||
expect(asDate(d)).toBe(d);
|
||||
});
|
||||
|
||||
it('parses a valid ISO string to a Date', () => {
|
||||
const result = asDate('2024-06-15T10:00:00Z');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.toISOString()).toContain('2024-06-15');
|
||||
});
|
||||
|
||||
it('returns null for an invalid date string', () => {
|
||||
expect(asDate('not-a-date')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toIsoIfDate ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('toIsoIfDate', () => {
|
||||
it('returns ISO string for a valid Date', () => {
|
||||
const d = new Date('2024-06-15T00:00:00Z');
|
||||
expect(toIsoIfDate(d)).toBe(d.toISOString());
|
||||
});
|
||||
|
||||
it('returns ISO string for a valid date string', () => {
|
||||
const result = toIsoIfDate('2024-06-15T10:00:00Z');
|
||||
expect(result).toMatch(/2024-06-15/);
|
||||
});
|
||||
|
||||
it('returns the original value when not a valid date', () => {
|
||||
expect(toIsoIfDate('some-string')).toBe('some-string');
|
||||
});
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(toIsoIfDate(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toDateString ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('toDateString', () => {
|
||||
it('formats a Date to YYYY-MM-DD', () => {
|
||||
const d = new Date('2024-06-15T12:00:00Z');
|
||||
const result = toDateString(d);
|
||||
expect(result).toBe('2024-06-15');
|
||||
});
|
||||
|
||||
it('formats a valid ISO string to YYYY-MM-DD', () => {
|
||||
expect(toDateString('2024-06-15T12:00:00')).toBe('2024-06-15');
|
||||
});
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(toDateString(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an invalid string', () => {
|
||||
expect(toDateString('not-a-date')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createRoundedDate ────────────────────────────────────────────────────────
|
||||
|
||||
describe('createRoundedDate', () => {
|
||||
it('returns a Date', () => {
|
||||
expect(createRoundedDate()).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns a Date when minutesAhead is provided', () => {
|
||||
expect(createRoundedDate(60)).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── timeChange ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('timeChange', () => {
|
||||
it('calls setter with string directly', () => {
|
||||
const setter = vi.fn();
|
||||
timeChange(setter)('10:30');
|
||||
expect(setter).toHaveBeenCalledWith('10:30');
|
||||
});
|
||||
|
||||
it('calls setter with currentTarget.value from event', () => {
|
||||
const setter = vi.fn();
|
||||
timeChange(setter)({ currentTarget: { value: '09:00' } });
|
||||
expect(setter).toHaveBeenCalledWith('09:00');
|
||||
});
|
||||
|
||||
it('does not call setter for unrecognized input', () => {
|
||||
const setter = vi.fn();
|
||||
timeChange(setter)(42);
|
||||
expect(setter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API wrappers ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getChannelsSummary', () => {
|
||||
it('calls API.getChannelsSummary', () => {
|
||||
API.getChannelsSummary.mockResolvedValueOnce([]);
|
||||
getChannelsSummary();
|
||||
expect(API.getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecording', () => {
|
||||
it('calls API.updateRecording with id and data', () => {
|
||||
API.updateRecording.mockResolvedValueOnce({});
|
||||
updateRecording(1, { name: 'test' });
|
||||
expect(API.updateRecording).toHaveBeenCalledWith(1, { name: 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRecording', () => {
|
||||
it('calls API.createRecording with data', () => {
|
||||
API.createRecording.mockResolvedValueOnce({});
|
||||
createRecording({ channel: 1 });
|
||||
expect(API.createRecording).toHaveBeenCalledWith({ channel: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRecurringRule', () => {
|
||||
it('calls API.createRecurringRule with data', () => {
|
||||
API.createRecurringRule.mockResolvedValueOnce({});
|
||||
createRecurringRule({ channel: 1 });
|
||||
expect(API.createRecurringRule).toHaveBeenCalledWith({ channel: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sortedChannelOptions ─────────────────────────────────────────────────────
|
||||
|
||||
describe('sortedChannelOptions', () => {
|
||||
const channels = [
|
||||
{ id: 3, name: 'CNN', channel_number: 20 },
|
||||
{ id: 1, name: 'ESPN', channel_number: 5 },
|
||||
{ id: 2, name: 'HBO', channel_number: 10 },
|
||||
];
|
||||
|
||||
it('sorts channels by channel_number ascending', () => {
|
||||
const result = sortedChannelOptions(channels);
|
||||
// ESPN(5) < HBO(10) < CNN(20) → ids 1, 2, 3
|
||||
expect(result.map((o) => o.value)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('maps each channel to { value, label }', () => {
|
||||
const result = sortedChannelOptions([
|
||||
{ id: 7, name: 'FOX', channel_number: 3 },
|
||||
]);
|
||||
expect(result[0]).toEqual({ value: '7', label: 'FOX' });
|
||||
});
|
||||
|
||||
it('uses custom labelFn when provided', () => {
|
||||
const result = sortedChannelOptions(
|
||||
[{ id: 1, name: 'ESPN', channel_number: 5 }],
|
||||
(item) => `CH${item.channel_number} ${item.name}`
|
||||
);
|
||||
expect(result[0].label).toBe('CH5 ESPN');
|
||||
});
|
||||
|
||||
it('falls back to "Channel id" when name is missing', () => {
|
||||
const result = sortedChannelOptions([{ id: 9, channel_number: 1 }]);
|
||||
expect(result[0].label).toBe('Channel 9');
|
||||
});
|
||||
|
||||
it('accepts an object (dict) of channels', () => {
|
||||
const dict = {
|
||||
a: { id: 1, name: 'A', channel_number: 2 },
|
||||
b: { id: 2, name: 'B', channel_number: 1 },
|
||||
};
|
||||
const result = sortedChannelOptions(dict);
|
||||
expect(result[0].value).toBe('2');
|
||||
expect(result[1].value).toBe('1');
|
||||
});
|
||||
|
||||
it('returns empty array for null input', () => {
|
||||
expect(sortedChannelOptions(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty array input', () => {
|
||||
expect(sortedChannelOptions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts by name when channel_numbers are equal', () => {
|
||||
const input = [
|
||||
{ id: 2, name: 'Zebra', channel_number: 5 },
|
||||
{ id: 1, name: 'Apple', channel_number: 5 },
|
||||
];
|
||||
const result = sortedChannelOptions(input);
|
||||
expect(result[0].label).toBe('Apple');
|
||||
expect(result[1].label).toBe('Zebra');
|
||||
});
|
||||
|
||||
it('puts channels with no channel_number (0) first in numeric order', () => {
|
||||
const input = [
|
||||
{ id: 1, name: 'A', channel_number: 5 },
|
||||
{ id: 2, name: 'B', channel_number: null },
|
||||
];
|
||||
const result = sortedChannelOptions(input);
|
||||
// null → 0, so it sorts before 5
|
||||
expect(result[0].value).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── numberedChannelLabel ─────────────────────────────────────────────────────
|
||||
|
||||
describe('numberedChannelLabel', () => {
|
||||
it('returns "number - name" when both are present', () => {
|
||||
expect(
|
||||
numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 })
|
||||
).toBe('501 - HBO');
|
||||
});
|
||||
|
||||
it('returns name only when channel_number is falsy', () => {
|
||||
expect(
|
||||
numberedChannelLabel({ id: 1, name: 'HBO', channel_number: null })
|
||||
).toBe('HBO');
|
||||
});
|
||||
|
||||
it('falls back to "Channel id" when name is missing and number present', () => {
|
||||
expect(numberedChannelLabel({ id: 5, channel_number: 10 })).toBe(
|
||||
'10 - Channel 5'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to "Channel id" when both name and number are missing', () => {
|
||||
expect(numberedChannelLabel({ id: 7 })).toBe('Channel 7');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getSingleFormDefaults ────────────────────────────────────────────────────
|
||||
|
||||
describe('getSingleFormDefaults', () => {
|
||||
it('returns defaults with no arguments', () => {
|
||||
const result = getSingleFormDefaults();
|
||||
expect(result.channel_id).toBe('');
|
||||
expect(result.start_time).toBeInstanceOf(Date);
|
||||
expect(result.end_time).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('uses recording values when provided', () => {
|
||||
const recording = {
|
||||
channel: 3,
|
||||
start_time: '2024-06-15T08:00:00Z',
|
||||
end_time: '2024-06-15T09:00:00Z',
|
||||
};
|
||||
const result = getSingleFormDefaults(recording);
|
||||
expect(result.channel_id).toBe('3');
|
||||
expect(result.start_time).toBeInstanceOf(Date);
|
||||
expect(result.end_time).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('uses channel id when channel is provided and recording is null', () => {
|
||||
const result = getSingleFormDefaults(null, { id: 7 });
|
||||
expect(result.channel_id).toBe('7');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getRecurringFormDefaults ─────────────────────────────────────────────────
|
||||
|
||||
describe('getRecurringFormDefaults', () => {
|
||||
it('returns defaults with no arguments', () => {
|
||||
const result = getRecurringFormDefaults();
|
||||
expect(result.channel_id).toBe('');
|
||||
expect(result.days_of_week).toEqual([]);
|
||||
expect(result.rule_name).toBe('');
|
||||
expect(typeof result.start_time).toBe('string');
|
||||
expect(typeof result.end_time).toBe('string');
|
||||
expect(result.start_date).toBeInstanceOf(Date);
|
||||
expect(result.end_date).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('uses channel name and id when channel is provided', () => {
|
||||
const result = getRecurringFormDefaults({ id: 5, name: 'ESPN' });
|
||||
expect(result.channel_id).toBe('5');
|
||||
expect(result.rule_name).toBe('ESPN');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildSinglePayload ───────────────────────────────────────────────────────
|
||||
|
||||
describe('buildSinglePayload', () => {
|
||||
it('builds payload from values', () => {
|
||||
const values = {
|
||||
channel_id: '2',
|
||||
start_time: new Date('2024-06-15T08:00:00Z'),
|
||||
end_time: new Date('2024-06-15T09:00:00Z'),
|
||||
};
|
||||
const result = buildSinglePayload(values);
|
||||
expect(result.channel).toBe('2');
|
||||
expect(result.start_time).toContain('2024-06-15');
|
||||
expect(result.end_time).toContain('2024-06-15');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildRecurringPayload ────────────────────────────────────────────────────
|
||||
|
||||
describe('buildRecurringPayload', () => {
|
||||
it('builds payload from values', () => {
|
||||
const values = {
|
||||
channel_id: '3',
|
||||
days_of_week: ['1', '3', '5'],
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
start_date: new Date('2024-06-15T12:00:00'),
|
||||
end_date: new Date('2024-12-25T12:00:00'),
|
||||
rule_name: ' Morning News ',
|
||||
};
|
||||
const result = buildRecurringPayload(values);
|
||||
expect(result.channel).toBe('3');
|
||||
expect(result.days_of_week).toEqual([1, 3, 5]);
|
||||
expect(result.start_time).toBe('08:00');
|
||||
expect(result.end_time).toBe('09:00');
|
||||
expect(result.start_date).toBe('2024-06-15');
|
||||
expect(result.end_date).toBe('2024-12-25');
|
||||
expect(result.name).toBe('Morning News');
|
||||
});
|
||||
|
||||
it('handles empty days_of_week', () => {
|
||||
const values = {
|
||||
channel_id: '1',
|
||||
days_of_week: [],
|
||||
start_time: '10:00',
|
||||
end_time: '11:00',
|
||||
start_date: new Date('2024-06-15T12:00:00'),
|
||||
end_date: new Date('2024-06-15T12:00:00'),
|
||||
rule_name: '',
|
||||
};
|
||||
const result = buildRecurringPayload(values);
|
||||
expect(result.days_of_week).toEqual([]);
|
||||
expect(result.name).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── singleFormValidators ─────────────────────────────────────────────────────
|
||||
|
||||
describe('singleFormValidators', () => {
|
||||
describe('end_time', () => {
|
||||
it('returns error when end time is missing', () => {
|
||||
expect(singleFormValidators.end_time(null, { start_time: null })).toBe(
|
||||
'Select an end time'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when end time is not after start time', () => {
|
||||
const start = new Date('2024-06-15T10:00:00Z');
|
||||
const end = new Date('2024-06-15T09:00:00Z');
|
||||
expect(singleFormValidators.end_time(end, { start_time: start })).toBe(
|
||||
'End time must be after start time'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when end equals start', () => {
|
||||
const t = new Date('2024-06-15T10:00:00Z');
|
||||
expect(singleFormValidators.end_time(t, { start_time: t })).toBe(
|
||||
'End time must be after start time'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when end is after start', () => {
|
||||
const start = new Date('2024-06-15T09:00:00Z');
|
||||
const end = new Date('2024-06-15T10:00:00Z');
|
||||
expect(
|
||||
singleFormValidators.end_time(end, { start_time: start })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── recurringFormValidators ──────────────────────────────────────────────────
|
||||
|
||||
describe('recurringFormValidators', () => {
|
||||
describe('days_of_week', () => {
|
||||
it('returns error for empty array', () => {
|
||||
expect(recurringFormValidators.days_of_week([])).toBe(
|
||||
'Pick at least one day'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for non-empty array', () => {
|
||||
expect(recurringFormValidators.days_of_week([1])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start_time', () => {
|
||||
it('returns error for falsy value', () => {
|
||||
expect(recurringFormValidators.start_time(null)).toBe(
|
||||
'Select a start time'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for a valid time string', () => {
|
||||
expect(recurringFormValidators.start_time('10:00')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('end_time', () => {
|
||||
it('returns error when value is falsy', () => {
|
||||
expect(
|
||||
recurringFormValidators.end_time(null, {
|
||||
start_time: '2024-06-15T10:00:00',
|
||||
})
|
||||
).toBe('Select an end time');
|
||||
});
|
||||
|
||||
it('returns error when end equals start (diff === 0)', () => {
|
||||
const result = recurringFormValidators.end_time('2024-06-15T10:00:00', {
|
||||
start_time: '2024-06-15T10:00:00',
|
||||
});
|
||||
expect(result).toBe('End time must differ from start time');
|
||||
});
|
||||
|
||||
it('returns null when end differs from start', () => {
|
||||
const result = recurringFormValidators.end_time('2024-06-15T11:00:00', {
|
||||
start_time: '2024-06-15T10:00:00',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('end_date', () => {
|
||||
it('returns error when end_date is missing', () => {
|
||||
expect(
|
||||
recurringFormValidators.end_date(null, { start_date: new Date() })
|
||||
).toBe('Select an end date');
|
||||
});
|
||||
|
||||
it('returns error when end_date is before start_date', () => {
|
||||
const start = new Date('2024-06-15');
|
||||
const end = new Date('2024-06-01');
|
||||
expect(
|
||||
recurringFormValidators.end_date(end, { start_date: start })
|
||||
).toBe('End date cannot be before start date');
|
||||
});
|
||||
|
||||
it('returns null when end_date is on or after start_date', () => {
|
||||
const start = new Date('2024-06-15');
|
||||
const end = new Date('2024-06-15');
|
||||
expect(
|
||||
recurringFormValidators.end_date(end, { start_date: start })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,113 +10,24 @@ vi.mock('../../../api.js', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../dateTimeUtils.js', () => ({
|
||||
toTimeString: vi.fn((t) => {
|
||||
if (!t) return '00:00';
|
||||
const match = String(t).match(/T(\d{2}:\d{2})/);
|
||||
return match ? match[1] : t;
|
||||
}),
|
||||
parseDate: vi.fn((d) => (d ? dayjs(d).toDate() : null)),
|
||||
toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())),
|
||||
getNow: vi.fn(() => dayjs()),
|
||||
isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))),
|
||||
format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)),
|
||||
}));
|
||||
|
||||
describe('RecurringRuleModalUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getChannelOptions', () => {
|
||||
it('should return sorted channel options by channel number', () => {
|
||||
const channels = {
|
||||
ch1: { id: 1, channel_number: '10', name: 'ABC' },
|
||||
ch2: { id: 2, channel_number: '5', name: 'NBC' },
|
||||
ch3: { id: 3, channel_number: '15', name: 'CBS' },
|
||||
};
|
||||
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(channels);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ value: '2', label: 'NBC' },
|
||||
{ value: '1', label: 'ABC' },
|
||||
{ value: '3', label: 'CBS' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort alphabetically by name when channel numbers are equal', () => {
|
||||
const channels = {
|
||||
ch1: { id: 1, channel_number: '10', name: 'ZBC' },
|
||||
ch2: { id: 2, channel_number: '10', name: 'ABC' },
|
||||
ch3: { id: 3, channel_number: '10', name: 'MBC' },
|
||||
};
|
||||
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(channels);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ value: '2', label: 'ABC' },
|
||||
{ value: '3', label: 'MBC' },
|
||||
{ value: '1', label: 'ZBC' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle missing channel numbers', () => {
|
||||
const channels = {
|
||||
ch1: { id: 1, name: 'ABC' },
|
||||
ch2: { id: 2, channel_number: '5', name: 'NBC' },
|
||||
};
|
||||
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(channels);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ value: '1', label: 'ABC' },
|
||||
{ value: '2', label: 'NBC' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use fallback label when name is missing', () => {
|
||||
const channels = {
|
||||
ch1: { id: 1, channel_number: '10' },
|
||||
ch2: { id: 2, channel_number: '5', name: '' },
|
||||
};
|
||||
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(channels);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ value: '2', label: 'Channel 2' },
|
||||
{ value: '1', label: 'Channel 1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty channels object', () => {
|
||||
const result = RecurringRuleModalUtils.getChannelOptions({});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null channels', () => {
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(null);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined channels', () => {
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(undefined);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should convert channel id to string value', () => {
|
||||
const channels = {
|
||||
ch1: { id: 123, channel_number: '10', name: 'ABC' },
|
||||
};
|
||||
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(channels);
|
||||
|
||||
expect(result[0].value).toBe('123');
|
||||
expect(typeof result[0].value).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle non-numeric channel numbers', () => {
|
||||
const channels = {
|
||||
ch1: { id: 1, channel_number: 'HD1', name: 'ABC' },
|
||||
ch2: { id: 2, channel_number: '5', name: 'NBC' },
|
||||
};
|
||||
|
||||
const result = RecurringRuleModalUtils.getChannelOptions(channels);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpcomingOccurrences', () => {
|
||||
let toUserTime;
|
||||
let userNow;
|
||||
|
|
@ -310,11 +221,11 @@ describe('RecurringRuleModalUtils', () => {
|
|||
const values = {
|
||||
channel_id: '5',
|
||||
days_of_week: ['1', '3', '5'],
|
||||
start_time: '14:30',
|
||||
end_time: '16:00',
|
||||
start_date: '2024-01-01',
|
||||
end_date: '2024-12-31',
|
||||
rule_name: 'My Rule',
|
||||
start_time: '10:00',
|
||||
end_time: '11:00',
|
||||
start_date: dayjs('2024-06-15'),
|
||||
end_date: dayjs('2024-12-25'),
|
||||
rule_name: ' Test Rule ',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
|
|
@ -323,11 +234,11 @@ describe('RecurringRuleModalUtils', () => {
|
|||
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
|
||||
channel: '5',
|
||||
days_of_week: [1, 3, 5],
|
||||
start_time: '14:30',
|
||||
end_time: '16:00',
|
||||
start_date: '2024-01-01',
|
||||
end_date: '2024-12-31',
|
||||
name: 'My Rule',
|
||||
start_time: '10:00',
|
||||
end_time: '11:00',
|
||||
start_date: '2024-06-15',
|
||||
end_date: '2024-12-25',
|
||||
name: 'Test Rule',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -530,4 +441,32 @@ describe('RecurringRuleModalUtils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormDefaults', () => {
|
||||
it('should return formatted defaults from rule', () => {
|
||||
const rule = {
|
||||
channel: 5,
|
||||
days_of_week: [1, 3, 5],
|
||||
name: 'Test Rule',
|
||||
start_time: '2024-06-15T10:00:00',
|
||||
end_time: '2024-06-15T11:00:00',
|
||||
start_date: '2024-06-15',
|
||||
end_date: '2024-12-25',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const result = RecurringRuleModalUtils.getFormDefaults(rule);
|
||||
|
||||
expect(result).toEqual({
|
||||
channel_id: '5',
|
||||
days_of_week: ['1', '3', '5'],
|
||||
rule_name: 'Test Rule',
|
||||
start_time: '10:00',
|
||||
end_time: '11:00',
|
||||
start_date: dayjs('2024-06-15').toDate(),
|
||||
end_date: dayjs('2024-12-25').toDate(),
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,441 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Dependency mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
previewSeriesRule: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../RecordingUtils.js', () => ({
|
||||
numberedChannelLabel: vi.fn((item) =>
|
||||
item.channel_number
|
||||
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
|
||||
: item.name || `Channel ${item.id}`
|
||||
),
|
||||
sortedChannelOptions: vi.fn((channels) =>
|
||||
(Array.isArray(channels) ? channels : []).map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name || `Channel ${c.id}`,
|
||||
}))
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import API from '../../../api.js';
|
||||
import {
|
||||
TITLE_MODES,
|
||||
DESCRIPTION_MODES,
|
||||
EPISODE_MODES,
|
||||
formatRange,
|
||||
getTvgOptions,
|
||||
getChannelOptions,
|
||||
previewSeriesRule,
|
||||
} from '../SeriesRuleEditorModalUtils.js';
|
||||
import {
|
||||
sortedChannelOptions,
|
||||
numberedChannelLabel,
|
||||
} from '../RecordingUtils.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SeriesRuleEditorModalUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TITLE_MODES', () => {
|
||||
it('exports an array of mode objects', () => {
|
||||
expect(Array.isArray(TITLE_MODES)).toBe(true);
|
||||
expect(TITLE_MODES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every entry has a label and value', () => {
|
||||
TITLE_MODES.forEach((m) => {
|
||||
expect(m).toHaveProperty('label');
|
||||
expect(m).toHaveProperty('value');
|
||||
});
|
||||
});
|
||||
|
||||
it('contains exact, contains, search and regex modes', () => {
|
||||
const values = TITLE_MODES.map((m) => m.value);
|
||||
expect(values).toContain('exact');
|
||||
expect(values).toContain('contains');
|
||||
expect(values).toContain('search');
|
||||
expect(values).toContain('regex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DESCRIPTION_MODES', () => {
|
||||
it('exports an array of mode objects', () => {
|
||||
expect(Array.isArray(DESCRIPTION_MODES)).toBe(true);
|
||||
expect(DESCRIPTION_MODES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every entry has a label and value', () => {
|
||||
DESCRIPTION_MODES.forEach((m) => {
|
||||
expect(m).toHaveProperty('label');
|
||||
expect(m).toHaveProperty('value');
|
||||
});
|
||||
});
|
||||
|
||||
it('contains contains, search and regex modes', () => {
|
||||
const values = DESCRIPTION_MODES.map((m) => m.value);
|
||||
expect(values).toContain('contains');
|
||||
expect(values).toContain('search');
|
||||
expect(values).toContain('regex');
|
||||
});
|
||||
|
||||
it('does not contain exact mode', () => {
|
||||
const values = DESCRIPTION_MODES.map((m) => m.value);
|
||||
expect(values).not.toContain('exact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EPISODE_MODES', () => {
|
||||
it('exports an array with all and new modes', () => {
|
||||
const values = EPISODE_MODES.map((m) => m.value);
|
||||
expect(values).toContain('all');
|
||||
expect(values).toContain('new');
|
||||
});
|
||||
|
||||
it('every entry has a label and value', () => {
|
||||
EPISODE_MODES.forEach((m) => {
|
||||
expect(m).toHaveProperty('label');
|
||||
expect(m).toHaveProperty('value');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatRange ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatRange', () => {
|
||||
it('formats same-day range as "date startTime - endTime"', () => {
|
||||
const start = '2024-06-01T10:00:00';
|
||||
const end = '2024-06-01T11:30:00';
|
||||
const result = formatRange(start, end);
|
||||
// Must include a dash separating start and end times (not "->")
|
||||
expect(result).toMatch(/-(?!>)/);
|
||||
expect(result).not.toMatch(/->/);
|
||||
});
|
||||
|
||||
it('formats cross-day range with "->" separator', () => {
|
||||
const start = '2024-06-01T23:00:00';
|
||||
const end = '2024-06-02T01:00:00';
|
||||
const result = formatRange(start, end);
|
||||
expect(result).toMatch(/->/);
|
||||
});
|
||||
|
||||
it('returns fallback string when dates are invalid', () => {
|
||||
const result = formatRange('not-a-date', 'also-not-a-date');
|
||||
expect(result).toBe('not-a-date - also-not-a-date');
|
||||
});
|
||||
|
||||
it('includes a date string component in the output', () => {
|
||||
const start = '2024-06-01T10:00:00';
|
||||
const end = '2024-06-01T11:00:00';
|
||||
const result = formatRange(start, end);
|
||||
// toLocaleDateString produces something non-empty
|
||||
expect(result.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('handles ISO strings with timezone offsets', () => {
|
||||
const start = '2024-06-01T10:00:00Z';
|
||||
const end = '2024-06-01T11:00:00Z';
|
||||
// Should not throw
|
||||
expect(() => formatRange(start, end)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getTvgOptions ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('getTvgOptions', () => {
|
||||
it('returns empty array for null input', () => {
|
||||
expect(getTvgOptions(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty array input', () => {
|
||||
expect(getTvgOptions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps tvgs to { value, label } options', () => {
|
||||
const tvgs = [{ tvg_id: 'tvg-1', name: 'Channel One' }];
|
||||
const result = getTvgOptions(tvgs);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
value: 'tvg-1',
|
||||
label: 'Channel One (tvg-1)',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses tvg_id as label when name is missing', () => {
|
||||
const tvgs = [{ tvg_id: 'tvg-no-name' }];
|
||||
const result = getTvgOptions(tvgs);
|
||||
expect(result[0]).toEqual({
|
||||
value: 'tvg-no-name',
|
||||
label: 'tvg-no-name',
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates by tvg_id', () => {
|
||||
const tvgs = [
|
||||
{ tvg_id: 'tvg-1', name: 'A' },
|
||||
{ tvg_id: 'tvg-1', name: 'B' },
|
||||
{ tvg_id: 'tvg-2', name: 'C' },
|
||||
];
|
||||
const result = getTvgOptions(tvgs);
|
||||
expect(result).toHaveLength(2);
|
||||
const values = result.map((o) => o.value);
|
||||
expect(values).toContain('tvg-1');
|
||||
expect(values).toContain('tvg-2');
|
||||
});
|
||||
|
||||
it('skips entries with no tvg_id', () => {
|
||||
const tvgs = [
|
||||
{ tvg_id: null, name: 'No ID' },
|
||||
{ tvg_id: '', name: 'Empty ID' },
|
||||
{ tvg_id: 'tvg-1', name: 'Valid' },
|
||||
];
|
||||
const result = getTvgOptions(tvgs);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe('tvg-1');
|
||||
});
|
||||
|
||||
it('sorts options alphabetically by label', () => {
|
||||
const tvgs = [
|
||||
{ tvg_id: 'tvg-z', name: 'Zebra' },
|
||||
{ tvg_id: 'tvg-a', name: 'Apple' },
|
||||
{ tvg_id: 'tvg-m', name: 'Mango' },
|
||||
];
|
||||
const result = getTvgOptions(tvgs);
|
||||
const labels = result.map((o) => o.label);
|
||||
expect(labels).toEqual([...labels].sort());
|
||||
});
|
||||
|
||||
it('sorts by label text including the tvg_id suffix', () => {
|
||||
const tvgs = [
|
||||
{ tvg_id: 'z-id', name: 'Same' },
|
||||
{ tvg_id: 'a-id', name: 'Same' },
|
||||
];
|
||||
const result = getTvgOptions(tvgs);
|
||||
// "Same (a-id)" < "Same (z-id)"
|
||||
expect(result[0].value).toBe('a-id');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChannelOptions ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getChannelOptions', () => {
|
||||
const makeChannels = () => [
|
||||
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
|
||||
{ id: 2, name: 'HBO', channel_number: 10, epg_data_id: 'epg-2' },
|
||||
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
|
||||
];
|
||||
|
||||
const makeTvgsById = () => ({
|
||||
'epg-1': { tvg_id: 'tvg-espn' },
|
||||
'epg-2': { tvg_id: 'tvg-hbo' },
|
||||
'epg-3': { tvg_id: 'tvg-cnn' },
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with allChannels and numberedChannelLabel', () => {
|
||||
const channels = makeChannels();
|
||||
getChannelOptions(channels, {}, null);
|
||||
expect(sortedChannelOptions).toHaveBeenCalledWith(
|
||||
channels,
|
||||
numberedChannelLabel
|
||||
);
|
||||
});
|
||||
|
||||
it('returns all channels when tvgId is null', () => {
|
||||
const channels = makeChannels();
|
||||
const result = getChannelOptions(channels, makeTvgsById(), null);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns all channels when tvgId is empty string', () => {
|
||||
const channels = makeChannels();
|
||||
const result = getChannelOptions(channels, makeTvgsById(), '');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('places matching channels before non-matching when tvgId set', () => {
|
||||
// Override sortedChannelOptions to return deterministic output
|
||||
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
|
||||
{ value: '1', label: 'ESPN' },
|
||||
{ value: '2', label: 'HBO' },
|
||||
{ value: '3', label: 'CNN' },
|
||||
]);
|
||||
|
||||
const channels = makeChannels();
|
||||
const tvgsById = makeTvgsById();
|
||||
|
||||
// tvgId matches ESPN (epg-1 → tvg-espn)
|
||||
const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
|
||||
|
||||
expect(result[0].value).toBe('1'); // ESPN first (matching)
|
||||
expect(result[1].value).toBe('2'); // HBO second (non-matching)
|
||||
expect(result[2].value).toBe('3'); // CNN third (non-matching)
|
||||
});
|
||||
|
||||
it('returns all in sorted order when no channel matches tvgId', () => {
|
||||
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
|
||||
{ value: '1', label: 'ESPN' },
|
||||
{ value: '2', label: 'HBO' },
|
||||
]);
|
||||
|
||||
const channels = makeChannels();
|
||||
const result = getChannelOptions(channels, makeTvgsById(), 'tvg-unknown');
|
||||
|
||||
// No matches → all in others, order preserved from sortedChannelOptions
|
||||
expect(result.map((r) => r.value)).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('handles channel with no epg_data_id (cTvg is null)', () => {
|
||||
const channels = [
|
||||
{ id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null },
|
||||
];
|
||||
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
|
||||
{ value: '9', label: 'NoEPG' },
|
||||
]);
|
||||
|
||||
const result = getChannelOptions(channels, {}, 'tvg-1');
|
||||
|
||||
// No epg match → goes to others
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe('9');
|
||||
});
|
||||
|
||||
it('handles missing epg_data_id entry in tvgsById', () => {
|
||||
const channels = [
|
||||
{
|
||||
id: 5,
|
||||
name: 'Unknown',
|
||||
channel_number: 1,
|
||||
epg_data_id: 'epg-missing',
|
||||
},
|
||||
];
|
||||
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
|
||||
{ value: '5', label: 'Unknown' },
|
||||
]);
|
||||
|
||||
const result = getChannelOptions(channels, {}, 'tvg-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe('5');
|
||||
});
|
||||
|
||||
it('handles null tvgsById gracefully', () => {
|
||||
const channels = makeChannels();
|
||||
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
|
||||
{ value: '1', label: 'ESPN' },
|
||||
]);
|
||||
|
||||
expect(() => getChannelOptions(channels, null, 'tvg-espn')).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns empty array for empty channels', () => {
|
||||
vi.mocked(sortedChannelOptions).mockReturnValueOnce([]);
|
||||
const result = getChannelOptions([], {}, 'tvg-1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('places multiple matching channels before non-matching', () => {
|
||||
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
|
||||
{ value: '1', label: 'ESPN' },
|
||||
{ value: '2', label: 'ESPN2' },
|
||||
{ value: '3', label: 'CNN' },
|
||||
]);
|
||||
|
||||
const channels = [
|
||||
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
|
||||
{ id: 2, name: 'ESPN2', channel_number: 6, epg_data_id: 'epg-4' },
|
||||
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
|
||||
];
|
||||
const tvgsById = {
|
||||
'epg-1': { tvg_id: 'tvg-espn' },
|
||||
'epg-4': { tvg_id: 'tvg-espn' }, // both ESPN channels share tvg_id
|
||||
'epg-3': { tvg_id: 'tvg-cnn' },
|
||||
};
|
||||
|
||||
const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
|
||||
|
||||
expect(result[0].value).toBe('1');
|
||||
expect(result[1].value).toBe('2');
|
||||
expect(result[2].value).toBe('3');
|
||||
});
|
||||
});
|
||||
|
||||
// ── previewSeriesRule ──────────────────────────────────────────────────────
|
||||
|
||||
describe('previewSeriesRule', () => {
|
||||
it('calls API.previewSeriesRule with the key and abort signal', async () => {
|
||||
const mockResult = { matches: [], total: 0 };
|
||||
vi.mocked(API.previewSeriesRule).mockResolvedValue(mockResult);
|
||||
|
||||
const controller = new AbortController();
|
||||
const key = { title: 'Test Show', mode: 'all' };
|
||||
|
||||
const result = await previewSeriesRule(key, controller);
|
||||
|
||||
expect(API.previewSeriesRule).toHaveBeenCalledWith(key, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result).toBe(mockResult);
|
||||
});
|
||||
|
||||
it('passes the abort signal from the controller', async () => {
|
||||
vi.mocked(API.previewSeriesRule).mockResolvedValue({});
|
||||
const controller = new AbortController();
|
||||
await previewSeriesRule({ title: 'X' }, controller);
|
||||
|
||||
const callArgs = vi.mocked(API.previewSeriesRule).mock.calls[0];
|
||||
expect(callArgs[1].signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it('propagates rejection from API.previewSeriesRule', async () => {
|
||||
vi.mocked(API.previewSeriesRule).mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
const controller = new AbortController();
|
||||
await expect(previewSeriesRule({}, controller)).rejects.toThrow(
|
||||
'Network error'
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates AbortError when signal is aborted', async () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError');
|
||||
vi.mocked(API.previewSeriesRule).mockRejectedValue(abortError);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(previewSeriesRule({}, controller)).rejects.toThrow(
|
||||
'Aborted'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through all fields of the preview key', async () => {
|
||||
vi.mocked(API.previewSeriesRule).mockResolvedValue({});
|
||||
const controller = new AbortController();
|
||||
const key = {
|
||||
title: 'My Show',
|
||||
title_mode: 'exact',
|
||||
description: 'drama',
|
||||
description_mode: 'contains',
|
||||
mode: 'new',
|
||||
tvg_id: 'tvg-1',
|
||||
channel_id: 5,
|
||||
};
|
||||
|
||||
await previewSeriesRule(key, controller);
|
||||
|
||||
expect(API.previewSeriesRule).toHaveBeenCalledWith(
|
||||
key,
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
199
frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js
Normal file
199
frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
updateStreamProfile: vi.fn(),
|
||||
addStreamProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@hookform/resolvers/yup', () => ({
|
||||
yupResolver: vi.fn((schema) => ({ __schema: schema })),
|
||||
}));
|
||||
|
||||
import API from '../../../api.js';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import {
|
||||
BUILT_IN_COMMANDS,
|
||||
COMMAND_EXAMPLES,
|
||||
toCommandSelection,
|
||||
getResolver,
|
||||
updateStreamProfile,
|
||||
addStreamProfile,
|
||||
} from '../StreamProfileUtils.js';
|
||||
|
||||
describe('StreamProfileUtils', () => {
|
||||
// ─── BUILT_IN_COMMANDS ────────────────────────────────────────────────────────
|
||||
|
||||
describe('BUILT_IN_COMMANDS', () => {
|
||||
it('includes ffmpeg, streamlink, cvlc, yt-dlp, and __custom__', () => {
|
||||
const values = BUILT_IN_COMMANDS.map((c) => c.value);
|
||||
expect(values).toContain('ffmpeg');
|
||||
expect(values).toContain('streamlink');
|
||||
expect(values).toContain('cvlc');
|
||||
expect(values).toContain('yt-dlp');
|
||||
expect(values).toContain('__custom__');
|
||||
});
|
||||
|
||||
it('each entry has a value and a label', () => {
|
||||
for (const cmd of BUILT_IN_COMMANDS) {
|
||||
expect(cmd).toHaveProperty('value');
|
||||
expect(cmd).toHaveProperty('label');
|
||||
expect(typeof cmd.value).toBe('string');
|
||||
expect(typeof cmd.label).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('labels the custom entry "Custom…"', () => {
|
||||
const custom = BUILT_IN_COMMANDS.find((c) => c.value === '__custom__');
|
||||
expect(custom?.label).toBe('Custom…');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── COMMAND_EXAMPLES ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('COMMAND_EXAMPLES', () => {
|
||||
it('has an entry for each non-custom built-in command', () => {
|
||||
const nonCustom = BUILT_IN_COMMANDS.filter(
|
||||
(c) => c.value !== '__custom__'
|
||||
);
|
||||
for (const cmd of nonCustom) {
|
||||
expect(COMMAND_EXAMPLES).toHaveProperty(cmd.value);
|
||||
expect(typeof COMMAND_EXAMPLES[cmd.value]).toBe('string');
|
||||
expect(COMMAND_EXAMPLES[cmd.value].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not have an entry for __custom__', () => {
|
||||
expect(COMMAND_EXAMPLES).not.toHaveProperty('__custom__');
|
||||
});
|
||||
|
||||
it('ffmpeg example contains {streamUrl}', () => {
|
||||
expect(COMMAND_EXAMPLES.ffmpeg).toContain('{streamUrl}');
|
||||
});
|
||||
|
||||
it('streamlink example contains {streamUrl}', () => {
|
||||
expect(COMMAND_EXAMPLES.streamlink).toContain('{streamUrl}');
|
||||
});
|
||||
|
||||
it('cvlc example contains {streamUrl}', () => {
|
||||
expect(COMMAND_EXAMPLES.cvlc).toContain('{streamUrl}');
|
||||
});
|
||||
|
||||
it('yt-dlp example contains {streamUrl}', () => {
|
||||
expect(COMMAND_EXAMPLES['yt-dlp']).toContain('{streamUrl}');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toCommandSelection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('toCommandSelection', () => {
|
||||
it('returns "ffmpeg" for "ffmpeg"', () => {
|
||||
expect(toCommandSelection('ffmpeg')).toBe('ffmpeg');
|
||||
});
|
||||
|
||||
it('returns "streamlink" for "streamlink"', () => {
|
||||
expect(toCommandSelection('streamlink')).toBe('streamlink');
|
||||
});
|
||||
|
||||
it('returns "cvlc" for "cvlc"', () => {
|
||||
expect(toCommandSelection('cvlc')).toBe('cvlc');
|
||||
});
|
||||
|
||||
it('returns "yt-dlp" for "yt-dlp"', () => {
|
||||
expect(toCommandSelection('yt-dlp')).toBe('yt-dlp');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for "__custom__"', () => {
|
||||
expect(toCommandSelection('__custom__')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for an unrecognized command', () => {
|
||||
expect(toCommandSelection('myspecialtool')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for an empty string', () => {
|
||||
expect(toCommandSelection('')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for null', () => {
|
||||
expect(toCommandSelection(null)).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for undefined', () => {
|
||||
expect(toCommandSelection(undefined)).toBe('__custom__');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getResolver ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls yupResolver and returns its result', () => {
|
||||
const result = getResolver();
|
||||
expect(yupResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns the same resolver on subsequent calls', () => {
|
||||
const r1 = getResolver();
|
||||
const r2 = getResolver();
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateStreamProfile ──────────────────────────────────────────────────────
|
||||
|
||||
describe('updateStreamProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls API.updateStreamProfile with id merged into values', () => {
|
||||
API.updateStreamProfile.mockResolvedValueOnce({ id: 3, name: 'HD' });
|
||||
updateStreamProfile(3, { name: 'HD', command: 'ffmpeg' });
|
||||
expect(API.updateStreamProfile).toHaveBeenCalledWith({
|
||||
id: 3,
|
||||
name: 'HD',
|
||||
command: 'ffmpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the promise from API.updateStreamProfile', () => {
|
||||
const resolved = Promise.resolve({ id: 1 });
|
||||
API.updateStreamProfile.mockReturnValueOnce(resolved);
|
||||
const result = updateStreamProfile(1, {});
|
||||
expect(result).toBe(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addStreamProfile ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('addStreamProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls API.addStreamProfile with the provided values', () => {
|
||||
API.addStreamProfile.mockResolvedValueOnce({ id: 5 });
|
||||
addStreamProfile({ name: 'New', command: 'streamlink', parameters: '' });
|
||||
expect(API.addStreamProfile).toHaveBeenCalledWith({
|
||||
name: 'New',
|
||||
command: 'streamlink',
|
||||
parameters: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the promise from API.addStreamProfile', () => {
|
||||
const resolved = Promise.resolve({ id: 5 });
|
||||
API.addStreamProfile.mockReturnValueOnce(resolved);
|
||||
const result = addStreamProfile({});
|
||||
expect(result).toBe(resolved);
|
||||
});
|
||||
|
||||
it('passes through an empty object without error', () => {
|
||||
API.addStreamProfile.mockResolvedValueOnce({});
|
||||
expect(() => addStreamProfile({})).not.toThrow();
|
||||
expect(API.addStreamProfile).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
});
|
||||
95
frontend/src/utils/forms/__tests__/StreamUtils.test.js
Normal file
95
frontend/src/utils/forms/__tests__/StreamUtils.test.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
updateStream: vi.fn(),
|
||||
addStream: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@hookform/resolvers/yup', () => ({
|
||||
yupResolver: vi.fn((schema) => ({ __schema: schema })),
|
||||
}));
|
||||
|
||||
import API from '../../../api.js';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { getResolver, updateStream, addStream } from '../StreamUtils.js';
|
||||
|
||||
describe('StreamUtils', () => {
|
||||
// ─── getResolver ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls yupResolver and returns its result', () => {
|
||||
const result = getResolver();
|
||||
expect(yupResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns the same resolver on subsequent calls', () => {
|
||||
const r1 = getResolver();
|
||||
const r2 = getResolver();
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateStream ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateStream', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls API.updateStream with id merged into payload', () => {
|
||||
API.updateStream.mockResolvedValueOnce({ id: 1, name: 'Test Stream' });
|
||||
updateStream(1, { name: 'Test Stream', url: 'http://example.com' });
|
||||
expect(API.updateStream).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Test Stream',
|
||||
url: 'http://example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the promise from API.updateStream', () => {
|
||||
const resolved = Promise.resolve({ id: 1 });
|
||||
API.updateStream.mockReturnValueOnce(resolved);
|
||||
const result = updateStream(1, {});
|
||||
expect(result).toBe(resolved);
|
||||
});
|
||||
|
||||
it('handles an empty payload without throwing', () => {
|
||||
API.updateStream.mockResolvedValueOnce({});
|
||||
expect(() => updateStream(5, {})).not.toThrow();
|
||||
expect(API.updateStream).toHaveBeenCalledWith({ id: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addStream ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('addStream', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls API.addStream with the provided payload', () => {
|
||||
API.addStream.mockResolvedValueOnce({ id: 2 });
|
||||
addStream({ name: 'New Stream', url: 'http://example.com/stream' });
|
||||
expect(API.addStream).toHaveBeenCalledWith({
|
||||
name: 'New Stream',
|
||||
url: 'http://example.com/stream',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the promise from API.addStream', () => {
|
||||
const resolved = Promise.resolve({ id: 2 });
|
||||
API.addStream.mockReturnValueOnce(resolved);
|
||||
const result = addStream({ name: 'test' });
|
||||
expect(result).toBe(resolved);
|
||||
});
|
||||
|
||||
it('passes through an empty object without error', () => {
|
||||
API.addStream.mockResolvedValueOnce({});
|
||||
expect(() => addStream({})).not.toThrow();
|
||||
expect(API.addStream).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
});
|
||||
99
frontend/src/utils/forms/__tests__/UserAgentUtils.test.js
Normal file
99
frontend/src/utils/forms/__tests__/UserAgentUtils.test.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
updateUserAgent: vi.fn(),
|
||||
addUserAgent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@hookform/resolvers/yup', () => ({
|
||||
yupResolver: vi.fn((schema) => ({ __schema: schema })),
|
||||
}));
|
||||
|
||||
import API from '../../../api.js';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import {
|
||||
getResolver,
|
||||
updateUserAgent,
|
||||
addUserAgent,
|
||||
} from '../UserAgentUtils.js';
|
||||
|
||||
describe('UserAgentUtils', () => {
|
||||
// ─── getResolver ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls yupResolver and returns its result', () => {
|
||||
const result = getResolver();
|
||||
expect(yupResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns the same resolver on subsequent calls', () => {
|
||||
const r1 = getResolver();
|
||||
const r2 = getResolver();
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateUserAgent ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateUserAgent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls API.updateUserAgent with id merged into values', () => {
|
||||
API.updateUserAgent.mockResolvedValueOnce({ id: 1, name: 'Chrome' });
|
||||
updateUserAgent(1, { name: 'Chrome', user_agent: 'Mozilla/5.0' });
|
||||
expect(API.updateUserAgent).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Chrome',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the promise from API.updateUserAgent', () => {
|
||||
const resolved = Promise.resolve({ id: 1 });
|
||||
API.updateUserAgent.mockReturnValueOnce(resolved);
|
||||
const result = updateUserAgent(1, {});
|
||||
expect(result).toBe(resolved);
|
||||
});
|
||||
|
||||
it('handles an empty values object without throwing', () => {
|
||||
API.updateUserAgent.mockResolvedValueOnce({});
|
||||
expect(() => updateUserAgent(5, {})).not.toThrow();
|
||||
expect(API.updateUserAgent).toHaveBeenCalledWith({ id: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addUserAgent ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('addUserAgent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls API.addUserAgent with the provided values', () => {
|
||||
API.addUserAgent.mockResolvedValueOnce({ id: 2 });
|
||||
addUserAgent({ name: 'Firefox', user_agent: 'Mozilla/5.0 (Firefox)' });
|
||||
expect(API.addUserAgent).toHaveBeenCalledWith({
|
||||
name: 'Firefox',
|
||||
user_agent: 'Mozilla/5.0 (Firefox)',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the promise from API.addUserAgent', () => {
|
||||
const resolved = Promise.resolve({ id: 2 });
|
||||
API.addUserAgent.mockReturnValueOnce(resolved);
|
||||
const result = addUserAgent({ name: 'test' });
|
||||
expect(result).toBe(resolved);
|
||||
});
|
||||
|
||||
it('passes through an empty object without error', () => {
|
||||
API.addUserAgent.mockResolvedValueOnce({});
|
||||
expect(() => addUserAgent({})).not.toThrow();
|
||||
expect(API.addUserAgent).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
});
|
||||
525
frontend/src/utils/forms/__tests__/UserUtils.test.js
Normal file
525
frontend/src/utils/forms/__tests__/UserUtils.test.js
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
generateApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../constants.js', () => ({
|
||||
USER_LEVELS: {
|
||||
ADMIN: 0,
|
||||
STREAMER: 2,
|
||||
},
|
||||
NETWORK_ACCESS_OPTIONS: {
|
||||
m3u: 'M3U',
|
||||
xc: 'XC',
|
||||
mpegts: 'MPEG-TS',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../networkUtils.js', () => ({
|
||||
IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
|
||||
IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/,
|
||||
}));
|
||||
|
||||
import API from '../../../api.js';
|
||||
import {
|
||||
createUser,
|
||||
updateUser,
|
||||
generateApiKey,
|
||||
revokeApiKey,
|
||||
userToFormValues,
|
||||
formValuesToPayload,
|
||||
getFormInitialValues,
|
||||
getFormValidators,
|
||||
} from '../UserUtils.js';
|
||||
|
||||
describe('UserUtils', () => {
|
||||
// ─── createUser ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createUser', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('calls API.createUser with the provided values', () => {
|
||||
API.createUser.mockResolvedValueOnce({ id: 1 });
|
||||
createUser({ username: 'alice', password: 'pass' });
|
||||
expect(API.createUser).toHaveBeenCalledWith({
|
||||
username: 'alice',
|
||||
password: 'pass',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the promise from API.createUser', () => {
|
||||
const resolved = Promise.resolve({ id: 1 });
|
||||
API.createUser.mockReturnValueOnce(resolved);
|
||||
expect(createUser({})).toBe(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateUser ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateUser', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('calls API.updateUser with selfEdit=false when isAdmin is true', () => {
|
||||
API.updateUser.mockResolvedValueOnce({});
|
||||
updateUser(1, { username: 'bob' }, true, { id: 1 });
|
||||
expect(API.updateUser).toHaveBeenCalledWith(
|
||||
1,
|
||||
{ username: 'bob' },
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('calls API.updateUser with selfEdit=true when not admin and userId matches authUser', () => {
|
||||
API.updateUser.mockResolvedValueOnce({});
|
||||
updateUser(5, { username: 'carol' }, false, { id: 5 });
|
||||
expect(API.updateUser).toHaveBeenCalledWith(
|
||||
5,
|
||||
{ username: 'carol' },
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('calls API.updateUser with selfEdit=false when not admin and userId does not match authUser', () => {
|
||||
API.updateUser.mockResolvedValueOnce({});
|
||||
updateUser(5, { username: 'dave' }, false, { id: 99 });
|
||||
expect(API.updateUser).toHaveBeenCalledWith(
|
||||
5,
|
||||
{ username: 'dave' },
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the promise from API.updateUser', () => {
|
||||
const resolved = Promise.resolve({});
|
||||
API.updateUser.mockReturnValueOnce(resolved);
|
||||
expect(updateUser(1, {}, true, { id: 1 })).toBe(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── generateApiKey ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('calls API.generateApiKey with the payload', () => {
|
||||
API.generateApiKey.mockResolvedValueOnce({ key: 'abc123' });
|
||||
generateApiKey({ user_id: 1 });
|
||||
expect(API.generateApiKey).toHaveBeenCalledWith({ user_id: 1 });
|
||||
});
|
||||
|
||||
it('returns the promise from API.generateApiKey', () => {
|
||||
const resolved = Promise.resolve({ key: 'abc' });
|
||||
API.generateApiKey.mockReturnValueOnce(resolved);
|
||||
expect(generateApiKey({})).toBe(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── revokeApiKey ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('revokeApiKey', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('calls API.revokeApiKey with the payload', () => {
|
||||
API.revokeApiKey.mockResolvedValueOnce({});
|
||||
revokeApiKey({ key: 'abc123' });
|
||||
expect(API.revokeApiKey).toHaveBeenCalledWith({ key: 'abc123' });
|
||||
});
|
||||
|
||||
it('returns the promise from API.revokeApiKey', () => {
|
||||
const resolved = Promise.resolve({});
|
||||
API.revokeApiKey.mockReturnValueOnce(resolved);
|
||||
expect(revokeApiKey({})).toBe(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── userToFormValues ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('userToFormValues', () => {
|
||||
const makeUser = (overrides = {}) => ({
|
||||
username: 'alice',
|
||||
first_name: 'Alice',
|
||||
last_name: 'Smith',
|
||||
email: 'alice@example.com',
|
||||
user_level: 1,
|
||||
stream_limit: 2,
|
||||
channel_profiles: ['1', '2'],
|
||||
custom_properties: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('maps basic user fields to form values', () => {
|
||||
const result = userToFormValues(makeUser());
|
||||
expect(result.username).toBe('alice');
|
||||
expect(result.first_name).toBe('Alice');
|
||||
expect(result.last_name).toBe('Smith');
|
||||
expect(result.email).toBe('alice@example.com');
|
||||
expect(result.user_level).toBe('1');
|
||||
expect(result.stream_limit).toBe(2);
|
||||
});
|
||||
|
||||
it('defaults first_name and last_name to empty string when absent', () => {
|
||||
const result = userToFormValues(
|
||||
makeUser({ first_name: null, last_name: null })
|
||||
);
|
||||
expect(result.first_name).toBe('');
|
||||
expect(result.last_name).toBe('');
|
||||
});
|
||||
|
||||
it('defaults stream_limit to 0 when absent', () => {
|
||||
const result = userToFormValues(makeUser({ stream_limit: null }));
|
||||
expect(result.stream_limit).toBe(0);
|
||||
});
|
||||
|
||||
it('uses channel_profiles when non-empty', () => {
|
||||
const result = userToFormValues(
|
||||
makeUser({ channel_profiles: ['3', '4'] })
|
||||
);
|
||||
expect(result.channel_profiles).toEqual(['3', '4']);
|
||||
});
|
||||
|
||||
it('maps custom_properties fields with defaults', () => {
|
||||
const result = userToFormValues(makeUser({ custom_properties: {} }));
|
||||
expect(result.xc_password).toBe('');
|
||||
expect(result.output_format).toBe('');
|
||||
expect(result.output_profile).toBe('');
|
||||
expect(result.hide_adult_content).toBe(false);
|
||||
expect(result.epg_days).toBe(0);
|
||||
expect(result.epg_prev_days).toBe(0);
|
||||
});
|
||||
|
||||
it('maps custom_properties when present', () => {
|
||||
const user = makeUser({
|
||||
custom_properties: {
|
||||
xc_password: 'xpass',
|
||||
output_format: 'ts',
|
||||
output_profile: 5,
|
||||
hide_adult_content: true,
|
||||
epg_days: 7,
|
||||
epg_prev_days: 2,
|
||||
allowed_networks: {
|
||||
m3u: '192.168.1.0/24,10.0.0.1/32',
|
||||
xc: '192.168.1.0/24,10.0.0.1/32',
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = userToFormValues(user);
|
||||
expect(result.xc_password).toBe('xpass');
|
||||
expect(result.output_format).toBe('ts');
|
||||
expect(result.output_profile).toBe('5');
|
||||
expect(result.hide_adult_content).toBe(true);
|
||||
expect(result.epg_days).toBe(7);
|
||||
expect(result.epg_prev_days).toBe(2);
|
||||
});
|
||||
|
||||
it('deduplicates allowed_ips from allowed_networks', () => {
|
||||
const user = makeUser({
|
||||
custom_properties: {
|
||||
allowed_networks: {
|
||||
m3u: '192.168.1.0/24,10.0.0.1/32',
|
||||
xc: '192.168.1.0/24,10.0.0.1/32',
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = userToFormValues(user);
|
||||
expect(result.allowed_ips).toEqual(['192.168.1.0/24', '10.0.0.1/32']);
|
||||
});
|
||||
|
||||
it('returns empty allowed_ips when no allowed_networks', () => {
|
||||
const result = userToFormValues(makeUser({ custom_properties: {} }));
|
||||
expect(result.allowed_ips).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formValuesToPayload ──────────────────────────────────────────────────────
|
||||
|
||||
describe('formValuesToPayload', () => {
|
||||
const makeValues = (overrides = {}) => ({
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
xc_password: 'mypass',
|
||||
output_format: 'ts',
|
||||
output_profile: '3',
|
||||
hide_adult_content: true,
|
||||
epg_days: 7,
|
||||
epg_prev_days: 2,
|
||||
allowed_ips: ['192.168.1.0/24'],
|
||||
channel_profiles: ['1', '2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('moves xc_password into custom_properties and removes it from payload', () => {
|
||||
const result = formValuesToPayload(makeValues(), null);
|
||||
expect(result.xc_password).toBeUndefined();
|
||||
expect(result.custom_properties.xc_password).toBe('mypass');
|
||||
});
|
||||
|
||||
it('moves output_format into custom_properties', () => {
|
||||
const result = formValuesToPayload(makeValues(), null);
|
||||
expect(result.output_format).toBeUndefined();
|
||||
expect(result.custom_properties.output_format).toBe('ts');
|
||||
});
|
||||
|
||||
it('parses output_profile as int in custom_properties', () => {
|
||||
const result = formValuesToPayload(makeValues(), null);
|
||||
expect(result.output_profile).toBeUndefined();
|
||||
expect(result.custom_properties.output_profile).toBe(3);
|
||||
});
|
||||
|
||||
it('sets output_profile to null when empty string', () => {
|
||||
const result = formValuesToPayload(
|
||||
makeValues({ output_profile: '' }),
|
||||
null
|
||||
);
|
||||
expect(result.custom_properties.output_profile).toBeNull();
|
||||
});
|
||||
|
||||
it('moves hide_adult_content into custom_properties', () => {
|
||||
const result = formValuesToPayload(makeValues(), null);
|
||||
expect(result.hide_adult_content).toBeUndefined();
|
||||
expect(result.custom_properties.hide_adult_content).toBe(true);
|
||||
});
|
||||
|
||||
it('moves epg_days and epg_prev_days into custom_properties', () => {
|
||||
const result = formValuesToPayload(makeValues(), null);
|
||||
expect(result.epg_days).toBeUndefined();
|
||||
expect(result.epg_prev_days).toBeUndefined();
|
||||
expect(result.custom_properties.epg_days).toBe(7);
|
||||
expect(result.custom_properties.epg_prev_days).toBe(2);
|
||||
});
|
||||
|
||||
it('populates allowed_networks for all NETWORK_KEYS when allowed_ips present', () => {
|
||||
const result = formValuesToPayload(
|
||||
makeValues({ allowed_ips: ['192.168.1.0/24', '10.0.0.1/32'] }),
|
||||
null
|
||||
);
|
||||
expect(result.allowed_ips).toBeUndefined();
|
||||
expect(result.custom_properties.allowed_networks.m3u).toBe(
|
||||
'192.168.1.0/24,10.0.0.1/32'
|
||||
);
|
||||
expect(result.custom_properties.allowed_networks.xc).toBe(
|
||||
'192.168.1.0/24,10.0.0.1/32'
|
||||
);
|
||||
expect(result.custom_properties.allowed_networks.mpegts).toBe(
|
||||
'192.168.1.0/24,10.0.0.1/32'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets empty allowed_networks when allowed_ips is empty', () => {
|
||||
const result = formValuesToPayload(makeValues({ allowed_ips: [] }), null);
|
||||
expect(result.custom_properties.allowed_networks).toEqual({});
|
||||
});
|
||||
|
||||
it('sets channel_profiles to empty array when it includes "0"', () => {
|
||||
const result = formValuesToPayload(
|
||||
makeValues({ channel_profiles: ['0'] }),
|
||||
null
|
||||
);
|
||||
expect(result.channel_profiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves channel_profiles when it does not include "0"', () => {
|
||||
const result = formValuesToPayload(
|
||||
makeValues({ channel_profiles: ['1', '2'] }),
|
||||
null
|
||||
);
|
||||
expect(result.channel_profiles).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('merges existing custom_properties from existingUser', () => {
|
||||
const existingUser = {
|
||||
custom_properties: { some_other_prop: 'keep_me' },
|
||||
};
|
||||
const result = formValuesToPayload(makeValues(), existingUser);
|
||||
expect(result.custom_properties.some_other_prop).toBe('keep_me');
|
||||
});
|
||||
|
||||
it('handles null existingUser gracefully', () => {
|
||||
expect(() => formValuesToPayload(makeValues(), null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getFormInitialValues ─────────────────────────────────────────────────────
|
||||
|
||||
describe('getFormInitialValues', () => {
|
||||
it('returns the expected default structure', () => {
|
||||
const result = getFormInitialValues();
|
||||
expect(result).toEqual({
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
user_level: '0',
|
||||
stream_limit: 0,
|
||||
password: '',
|
||||
xc_password: '',
|
||||
output_format: '',
|
||||
output_profile: '',
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
epg_days: 0,
|
||||
epg_prev_days: 0,
|
||||
allowed_ips: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a new object on each call', () => {
|
||||
const a = getFormInitialValues();
|
||||
const b = getFormInitialValues();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getFormValidators ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getFormValidators', () => {
|
||||
const validate = (user, values) => getFormValidators(user)(values);
|
||||
|
||||
describe('username', () => {
|
||||
it('returns error when username is empty', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
username: '',
|
||||
});
|
||||
expect(result.username).toBe('Username is required');
|
||||
});
|
||||
|
||||
it('returns null for a valid non-streamer username', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
username: 'alice',
|
||||
user_level: '0',
|
||||
});
|
||||
expect(result.username).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a valid alphanumeric streamer username', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
username: 'alice123',
|
||||
user_level: '2',
|
||||
});
|
||||
expect(result.username).toBeNull();
|
||||
});
|
||||
|
||||
it('returns error for streamer username with non-alphanumeric characters', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
username: 'alice_123',
|
||||
user_level: '2',
|
||||
});
|
||||
expect(result.username).toBe('Streamer username must be alphanumeric');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password', () => {
|
||||
it('returns error when creating a non-streamer user without a password', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
user_level: '0',
|
||||
password: '',
|
||||
});
|
||||
expect(result.password).toBe('Password is required');
|
||||
});
|
||||
|
||||
it('returns null when creating a streamer user without a password', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
user_level: '2',
|
||||
password: '',
|
||||
});
|
||||
expect(result.password).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when editing an existing user without a password', () => {
|
||||
const result = validate(
|
||||
{ id: 1 },
|
||||
{ ...getFormInitialValues(), user_level: '0', password: '' }
|
||||
);
|
||||
expect(result.password).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when password is provided for new user', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
user_level: '0',
|
||||
password: 'secret',
|
||||
});
|
||||
expect(result.password).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('xc_password', () => {
|
||||
it('returns null when xc_password is empty', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
xc_password: '',
|
||||
});
|
||||
expect(result.xc_password).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a valid alphanumeric xc_password', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
xc_password: 'abc123',
|
||||
});
|
||||
expect(result.xc_password).toBeNull();
|
||||
});
|
||||
|
||||
it('returns error for xc_password with non-alphanumeric characters', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
xc_password: 'abc!@#',
|
||||
});
|
||||
expect(result.xc_password).toBe('XC password must be alphanumeric');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowed_ips', () => {
|
||||
it('returns null for an empty allowed_ips array', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
allowed_ips: [],
|
||||
});
|
||||
expect(result.allowed_ips).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a valid IPv4 CIDR', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
allowed_ips: ['192.168.1.0/24'],
|
||||
});
|
||||
expect(result.allowed_ips).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a bare IPv4 address (auto-appends /32)', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
allowed_ips: ['10.0.0.1'],
|
||||
});
|
||||
expect(result.allowed_ips).toBeNull();
|
||||
});
|
||||
|
||||
it('returns error for an invalid IP entry', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
allowed_ips: ['not-an-ip'],
|
||||
});
|
||||
expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
|
||||
});
|
||||
|
||||
it('returns error when any entry in the list is invalid', () => {
|
||||
const result = validate(null, {
|
||||
...getFormInitialValues(),
|
||||
allowed_ips: ['192.168.1.0/24', 'bad-entry'],
|
||||
});
|
||||
expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,5 +3,6 @@ export const getSystemSettingsFormInitialValues = () => {
|
|||
max_system_events: 100,
|
||||
preferred_region: '',
|
||||
auto_import_mapped_files: true,
|
||||
enable_ip_lookup: true,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ describe('SystemSettingsFormUtils', () => {
|
|||
max_system_events: 100,
|
||||
preferred_region: '',
|
||||
auto_import_mapped_files: true,
|
||||
enable_ip_lookup: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
getNow,
|
||||
getNowMs,
|
||||
roundToNearest,
|
||||
} from '../utils/dateTimeUtils.js';
|
||||
} from './dateTimeUtils.js';
|
||||
import API from '../api.js';
|
||||
|
||||
export const PROGRAM_HEIGHT = 90;
|
||||
|
|
@ -309,25 +309,12 @@ export const getRuleByProgram = (rules, program) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const createRecording = async (channel, program) => {
|
||||
await API.createRecording({
|
||||
channel: `${channel.id}`,
|
||||
start_time: program.start_time,
|
||||
end_time: program.end_time,
|
||||
custom_properties: { program },
|
||||
});
|
||||
export const createRecording = async (values) => {
|
||||
await API.createRecording(values);
|
||||
};
|
||||
|
||||
export const createSeriesRule = async (program, mode) => {
|
||||
await API.createSeriesRule({
|
||||
tvg_id: program.tvg_id,
|
||||
mode,
|
||||
title: program.title,
|
||||
});
|
||||
};
|
||||
|
||||
export const evaluateSeriesRule = async (program) => {
|
||||
await API.evaluateSeriesRules(program.tvg_id);
|
||||
export const createSeriesRule = async (values) => {
|
||||
await API.createSeriesRule(values);
|
||||
};
|
||||
|
||||
export const calculateLeftScrollPosition = (program, start) => {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
|
|||
'max_system_events',
|
||||
'preferred_region',
|
||||
'auto_import_mapped_files',
|
||||
'enable_ip_lookup',
|
||||
];
|
||||
const timeshiftFields = [
|
||||
'timeshift_default_timezone',
|
||||
|
|
@ -142,6 +143,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
|
|||
'schedule_enabled',
|
||||
'auto_import_mapped_files',
|
||||
'timeshift_debug_logging',
|
||||
'enable_ip_lookup',
|
||||
];
|
||||
if (booleanFields.includes(formKey) && value != null) {
|
||||
value = typeof value === 'boolean' ? value : Boolean(value);
|
||||
|
|
@ -380,6 +382,10 @@ export const parseSettings = (settings) => {
|
|||
typeof systemSettings.auto_import_mapped_files === 'boolean'
|
||||
? systemSettings.auto_import_mapped_files
|
||||
: true;
|
||||
parsed.enable_ip_lookup =
|
||||
typeof systemSettings.enable_ip_lookup === 'boolean'
|
||||
? systemSettings.enable_ip_lookup
|
||||
: true;
|
||||
}
|
||||
|
||||
// Timeshift settings - direct mapping with timeshift_ prefixed form keys
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ requires-python = ">=3.13"
|
|||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"Django==6.0.5",
|
||||
"psycopg2-binary==2.9.12",
|
||||
"psycopg[binary]",
|
||||
"celery[redis]==5.6.3",
|
||||
"djangorestframework==3.17.1",
|
||||
"requests==2.33.1",
|
||||
|
|
@ -18,7 +18,7 @@ dependencies = [
|
|||
"python-vlc",
|
||||
"yt-dlp",
|
||||
"gevent==26.4.0",
|
||||
"psycogreen",
|
||||
"django-db-geventpool",
|
||||
"daphne",
|
||||
"uwsgi",
|
||||
"django-cors-headers",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue