Merge branch 'Dispatcharr:main' into fix/947-Connection-capacity-leak-failed-stream-initialization-permanently-consumes-connection-slot

This commit is contained in:
Matt Grutza 2026-02-10 17:51:25 -06:00 committed by GitHub
commit 681ffe1779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 4245 additions and 1850 deletions

View file

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.19.0] - 2026-02-10
### Added
- Add system notifications and update checks
@ -32,6 +34,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `useWarningsStore` tests for warning suppression functionality
- Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Redis authentication support for modular deployments: Added support for authentication when connecting to external Redis instances using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). REDIS_PASSWORD and REDIS_USER environment variables with URL encoding for special characters. (Closes #937) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name.
- Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing.
- Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown.
- Plugin action buttons can define `button_label`, `button_variant`, and `button_color` (e.g., Stop in red), falling back to “Run” for older plugins.
- Plugin card metadata: plugins can specify `author` and `help_url` in `plugin.json` to show author and docs link in the UI.
- Plugin cards can now be expanded/collapsed by clicking the header or chevron to hide settings and actions.
### Changed
@ -58,6 +67,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible.
- Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir)
- Hardened plugin loading to avoid executing plugin code unless the plugin is enabled.
- Prevented plugin package names from shadowing standard library or installed modules by namespacing plugin imports with safe aliases.
- Added safety limits to plugin ZIP imports (file count and size caps) and sanitized plugin keys derived from uploads.
- Enforced strict boolean parsing for plugin enable/disable requests to avoid accidental enables from truthy strings.
- Applied plugin field defaults server-side when running actions so plugins receive expected settings even before a user saves.
- Plugin settings UI improvements: render `info`/`text` fields, support `input_type: password`, show descriptions/placeholders, surface save failures, and keep settings in sync after refresh.
- Disabled plugins now collapse settings/actions to match the closed state before first enable.
- Plugin card header controls (delete/version/toggle) now stay right-aligned even with long descriptions.
- Improved plugin logo resolution (case-insensitive paths + absolute URLs), fixing dev UI logo loading without a Vite proxy.
- Plugin reload now hits the backend, clears module caches across workers, and refreshes the UI so code changes apply without a full backend restart.
- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths.
- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action.
- Plugin enable/disable toggles now update immediately without requiring a full page refresh.
## [0.18.1] - 2026-01-27

View file

@ -8,7 +8,43 @@ This document explains how to build, install, and use Python plugins in Dispatch
1) Create a folder under `/app/data/plugins/my_plugin/` (host path `data/plugins/my_plugin/` in the repo).
2) Add a `plugin.py` file exporting a `Plugin` class:
2) Add a `plugin.json` manifest (new standard) and a `plugin.py` file:
`/app/data/plugins/my_plugin/plugin.json`
```json
{
"name": "My Plugin",
"version": "0.1.0",
"description": "Does something useful",
"author": "Acme Labs",
"help_url": "https://example.com/docs/my-plugin",
"fields": [
{ "id": "enabled", "label": "Enabled", "type": "boolean", "default": true },
{ "id": "limit", "label": "Item limit", "type": "number", "default": 5 },
{
"id": "mode",
"label": "Mode",
"type": "select",
"default": "safe",
"options": [
{ "value": "safe", "label": "Safe" },
{ "value": "fast", "label": "Fast" }
]
},
{ "id": "note", "label": "Note", "type": "string", "default": "" }
],
"actions": [
{
"id": "do_work",
"label": "Do Work",
"description": "Process items",
"button_label": "Run Job",
"button_variant": "filled",
"button_color": "blue"
}
]
}
```
```
# /app/data/plugins/my_plugin/plugin.py
@ -16,6 +52,8 @@ class Plugin:
name = "My Plugin"
version = "0.1.0"
description = "Does something useful"
author = "Acme Labs"
help_url = "https://example.com/docs/my-plugin"
# Settings fields rendered by the UI and persisted by the backend
fields = [
@ -31,7 +69,14 @@ class Plugin:
# Actions appear as buttons. Clicking one calls run(action, params, context)
actions = [
{"id": "do_work", "label": "Do Work", "description": "Process items"},
{
"id": "do_work",
"label": "Do Work",
"description": "Process items",
"button_label": "Run Job",
"button_variant": "filled",
"button_color": "blue",
},
]
def run(self, action: str, params: dict, context: dict):
@ -59,8 +104,10 @@ class Plugin:
- Each plugin is a directory containing either:
- `plugin.py` exporting a `Plugin` class, or
- a Python package (`__init__.py`) exporting a `Plugin` class.
- New standard: include a `plugin.json` manifest alongside your code for safe metadata discovery.
- Optional: include `logo.png` next to `plugin.py` to show a logo in the UI.
The directory name (lowercased, spaces as `_`) is used as the registry key and module import path (e.g. `my_plugin.plugin`).
The directory name (lowercased, spaces as `_`) is used as the registry key. Plugins are imported under a safe internal package name; if the folder name is a valid identifier (and not reserved), it is also registered as an alias for convenience.
---
@ -69,7 +116,8 @@ The directory name (lowercased, spaces as `_`) is used as the registry key and m
- Discovery runs at server startup and on-demand when:
- Fetching the plugins list from the UI
- Hitting `POST /api/plugins/plugins/reload/`
- The loader imports each plugin module and instantiates `Plugin()`.
- The loader reads `plugin.json` for metadata without executing plugin code.
- Plugin code is only imported and instantiated when the plugin is enabled.
- Metadata (name, version, description) and a per-plugin settings JSON are stored in the DB.
Backend code:
@ -80,6 +128,45 @@ Backend code:
---
## Plugin Manifest (`plugin.json`)
`plugin.json` lets Dispatcharr list your plugin safely without executing code. It should live next to `plugin.py`.
Example:
```
{
"name": "My Plugin",
"version": "1.2.3",
"description": "Does something useful",
"author": "Acme Labs",
"help_url": "https://example.com/docs/my-plugin",
"fields": [
{ "id": "limit", "label": "Item limit", "type": "number", "default": 5 }
],
"actions": [
{
"id": "do_work",
"label": "Do Work",
"description": "Process items",
"button_label": "Run Job",
"button_variant": "filled",
"button_color": "blue"
}
]
}
```
Notes:
- `author` and `help_url` are optional. If provided, the UI shows “By {author}” and a Docs link.
- If your plugin includes a `logo.png` file next to `plugin.py`, it will be shown on the plugin card.
If `plugin.json` is missing or invalid, the plugin is treated as **legacy**:
- The name is inferred from the folder name.
- `logo.png` still displays if present.
- The UI shows a warning asking the developer to upgrade to the new standard.
---
## Plugin Interface
Export a `Plugin` class. Supported attributes and behavior:
@ -87,34 +174,72 @@ Export a `Plugin` class. Supported attributes and behavior:
- `name` (str): Human-readable name.
- `version` (str): Semantic version string.
- `description` (str): Short description.
- `author` (str, optional): Author or team name shown on the card.
- `help_url` (str, optional): Docs/support link shown on the card.
- `fields` (list): Settings schema used by the UI to render controls.
- `actions` (list): Available actions; the UI renders a Run button for each.
- `actions` (list): Available actions; the UI renders a button for each (defaults to Run).
- `run(action, params, context)` (callable): Invoked when a user clicks an action.
- `stop(context)` (optional callable): Invoked when the plugin is disabled, deleted, or reloaded so you can gracefully shut down any processes you started. If `stop()` is not defined but you have an action with id `stop`, Dispatcharr will call `run("stop", {}, context)` as a fallback.
### Settings Schema
Supported field `type`s:
- `boolean`
- `number`
- `string`
- `string` (single-line text)
- `text` (multi-line textarea)
- `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`)
- `info` (display-only text; useful for headings or notes)
Common field keys:
- `id` (str): Settings key.
- `label` (str): Label shown in the UI.
- `type` (str): One of above.
- `default` (any): Default value used until saved.
- `help_text` (str, optional): Shown under the control.
- `help_text` / `description` (str, optional): Shown under the control.
- `placeholder` (str, optional): Placeholder text for inputs.
- `input_type` (str, optional): For `string` fields, set to `"password"` to mask input.
- `options` (list, for select): List of `{value, label}`.
Notes:
- For `info` fields, you can use `description`/`help_text` (or `value`) to show the text.
The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`.
### Example: stop() Hook
```
import signal
class Plugin:
name = "Example Plugin"
version = "1.0.0"
description = "Shows how to shut down gracefully."
def run(self, action: str, params: dict, context: dict):
# Start a subprocess or background task here and store its PID.
# Example: save pid in /data or in your own module-level variable.
return {"status": "ok"}
def stop(self, context: dict):
logger = context.get("logger")
pid = self._read_pid() # your helper
if pid:
try:
os.kill(pid, signal.SIGTERM)
logger.info("Stopped process %s", pid)
except Exception:
logger.exception("Failed to stop process %s", pid)
```
Read settings in `run` via `context["settings"]`.
### Actions
Each action is a dict:
- `id` (str): Unique action id.
- `label` (str): Button label.
- `label` (str): Action label.
- `description` (str, optional): Helper text.
- `button_label` (str, optional): Button text (defaults to “Run”).
- `button_variant` (str, optional): Button style (Mantine variants like `filled`, `outline`, `subtle`).
- `button_color` (str, optional): Button color (e.g., `red`, `blue`, `orange`).
Clicking an action calls your plugins `run(action, params, context)` and shows a notification with the result or error.
@ -182,6 +307,110 @@ Plugins are server-side Python code running within the Django application. You c
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
### Important: Dont Ask Users for URL/User/Password
Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the apps models, tasks, and internal utilities.
Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because:
- It encourages users to enter privileged credentials.
- Malicious plugins could exfiltrate credentials.
- It duplicates access that plugins already have internally.
If you are writing a plugin, **use internal Python APIs** (models/tasks/utils) instead of making HTTP calls with user credentials.
### When You Do Need HTTP
In rare cases you may need to call a Dispatcharr HTTP endpoint (for example, to reuse an existing API response serializer). In that case:
1. **Do not ask the user for credentials.**
Use the backends internal access where possible.
2. Prefer **local/internal URLs** (never user-provided):
- Docker: `http://web:9191` (service name inside the container network)
- Dev: `http://127.0.0.1:5656`
3. Use Django helpers when building URLs:
```
from django.urls import reverse
path = reverse("api:channels:list") # example name
url = f"http://127.0.0.1:5656{path}"
```
4. Use a short timeout and robust error handling:
```
import requests
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
```
### Examples: Preferred Internal Access (No HTTP, No Credentials)
**Example 1: List channels directly from the DB**
```
from apps.channels.models import Channel
channels = Channel.objects.all().values("id", "name", "number")[:50]
return {"status": "ok", "channels": list(channels)}
```
**Example 2: Kick off an existing refresh task**
```
from apps.m3u.tasks import refresh_m3u_accounts
from apps.epg.tasks import refresh_all_epg_data
refresh_m3u_accounts.delay()
refresh_all_epg_data.delay()
return {"status": "queued"}
```
**Example 3: Send a WebSocket update to the UI**
```
from core.utils import send_websocket_update
send_websocket_update(
"updates",
"update",
{"type": "plugin", "plugin": "my_plugin", "message": "Refresh queued"}
)
```
### Example: HTTP Access (Only If You Must)
**Find the endpoint**
- Use `reverse()` with the named route when possible.
- If you dont know the route name, inspect `apps/*/api_urls.py` or Djangos URL config to find it.
```
from django.urls import reverse
import requests
path = reverse("api:channels:list") # named route from apps/channels/api_urls.py
url = f"http://127.0.0.1:5656{path}"
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
```
### How Developers Find the API
1. **Prefer internal models/tasks** (best and safest).
2. **Check `apps/*/api_urls.py`** for named routes and endpoint patterns.
- Example: `apps/channels/api_urls.py` for channel endpoints.
3. **Find the view** referenced in the URL config to see required params.
- Example: `apps/channels/api_views.py` or `apps/epg/api_views.py`.
4. **Use `reverse()`** with the named route to build the path.
- This avoids hardcoding paths and keeps plugins compatible if URLs change.
5. **Only use internal hostnames** (never user-provided URL).
### What Plugins Can Access
Because plugins run inside the server process, they can:
- Read and write database models (same permissions as the app)
- Invoke Celery tasks
- Send websocket updates
- Read configuration and settings
Treat plugins as **trusted server code** and avoid exposing sensitive data in plugin settings or logs.
---
## REST Endpoints (for UI and tooling)
@ -203,7 +432,9 @@ Notes:
- In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder.
- The archive should contain either `plugin.py` or a Python package (`__init__.py`).
- Include `plugin.json` in the plugin folder to provide metadata without executing code.
- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default).
- If `plugin.json` is missing, the plugin is marked as legacy and the UI will show a warning.
---
@ -214,6 +445,7 @@ Notes:
- The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code.
- The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button.
- Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403.
- Dispatcharr will not import or execute plugin code unless the plugin is enabled.
---
@ -263,7 +495,7 @@ class Plugin:
## Troubleshooting
- Plugin not listed: ensure the folder exists and contains `plugin.py` with a `Plugin` class.
- Import errors: the folder name is the import name; avoid spaces or exotic characters.
- Import errors: ensure the folder contains `plugin.py` or a package `__init__.py`. Folder names with spaces or dashes are supported; if you need to import by folder name inside your plugin, use a valid Python identifier.
- No confirmation: include a boolean field with `id: "confirm"` and set it to true or default true.
- HTTP 403 on run: the plugin is disabled; enable it from the toggle or via the `enabled/` endpoint.

View file

@ -7,6 +7,7 @@ from .api_views import (
PluginEnabledAPIView,
PluginImportAPIView,
PluginDeleteAPIView,
PluginLogoAPIView,
)
app_name = "plugins"
@ -19,4 +20,5 @@ urlpatterns = [
path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"),
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
path("plugins/<str:key>/logo/", PluginLogoAPIView.as_view(), name="logo"),
]

View file

@ -1,19 +1,21 @@
import logging
import re
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.decorators import api_view
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
import io
from django.http import FileResponse
import os
import zipfile
import shutil
import tempfile
from urllib.parse import urlparse
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_method,
)
from dispatcharr.utils import network_access_allowed
from .loader import PluginManager
from .models import PluginConfig
@ -21,6 +23,42 @@ from .models import PluginConfig
logger = logging.getLogger(__name__)
MAX_PLUGIN_IMPORT_FILES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILES", 2000)
MAX_PLUGIN_IMPORT_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_BYTES", 200 * 1024 * 1024)
MAX_PLUGIN_IMPORT_FILE_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILE_BYTES", 200 * 1024 * 1024)
def _parse_bool(value):
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("true", "1", "yes", "y", "on"):
return True
if normalized in ("false", "0", "no", "n", "off"):
return False
return None
def _sanitize_plugin_key(value: str) -> str:
base = os.path.basename(value or "")
base = base.replace(" ", "_").lower()
base = re.sub(r"[^a-z0-9_-]", "_", base)
base = base.strip("._-")
return base or "plugin"
def _absolutize_logo_url(request, url: str | None) -> str | None:
if not url or not request:
return url
parsed = urlparse(url)
if parsed.scheme:
return url
return request.build_absolute_uri(url)
class PluginsListAPIView(APIView):
def get_permissions(self):
try:
@ -32,9 +70,12 @@ class PluginsListAPIView(APIView):
def get(self, request):
pm = PluginManager.get()
# Ensure registry is up-to-date on each request
pm.discover_plugins()
return Response({"plugins": pm.list_plugins()})
# Prefer cached registry; reload explicitly via the reload endpoint
pm.discover_plugins(sync_db=False, use_cache=True)
plugins = pm.list_plugins()
for plugin in plugins:
plugin["logo_url"] = _absolutize_logo_url(request, plugin.get("logo_url"))
return Response({"plugins": plugins})
class PluginReloadAPIView(APIView):
@ -48,7 +89,8 @@ class PluginReloadAPIView(APIView):
def post(self, request):
pm = PluginManager.get()
pm.discover_plugins()
pm.stop_all_plugins(reason="reload")
pm.discover_plugins(force_reload=True)
return Response({"success": True, "count": len(pm._registry)})
@ -81,6 +123,19 @@ class PluginImportAPIView(APIView):
if not file_members:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST)
if len(file_members) > MAX_PLUGIN_IMPORT_FILES:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Archive has too many files"}, status=status.HTTP_400_BAD_REQUEST)
total_size = 0
for member in file_members:
total_size += member.file_size
if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Archive contains a file that is too large"}, status=status.HTTP_400_BAD_REQUEST)
if total_size > MAX_PLUGIN_IMPORT_BYTES:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Archive is too large"}, status=status.HTTP_400_BAD_REQUEST)
for member in file_members:
name = member.filename
@ -115,7 +170,31 @@ class PluginImportAPIView(APIView):
plugin_key = os.path.basename(chosen.rstrip(os.sep))
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
plugin_key = base_name
plugin_key = plugin_key.replace(" ", "_").lower()
plugin_key = _sanitize_plugin_key(plugin_key)
if len(plugin_key) > 128:
plugin_key = plugin_key[:128]
logo_bytes = None
try:
logo_candidates = []
chosen_abs = os.path.abspath(chosen)
for dirpath, _, filenames in os.walk(tmp_root):
for filename in filenames:
if filename.lower() != "logo.png":
continue
full_path = os.path.join(dirpath, filename)
full_abs = os.path.abspath(full_path)
try:
in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs
except Exception:
in_chosen = False
depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep))
logo_candidates.append((0 if in_chosen else 1, depth, full_path))
if logo_candidates:
logo_candidates.sort()
with open(logo_candidates[0][2], "rb") as fh:
logo_bytes = fh.read()
except Exception:
logo_bytes = None
final_dir = os.path.join(plugins_dir, plugin_key)
if os.path.exists(final_dir):
@ -136,6 +215,12 @@ class PluginImportAPIView(APIView):
shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item))
else:
shutil.move(chosen, final_dir)
if logo_bytes:
try:
with open(os.path.join(final_dir, "logo.png"), "wb") as fh:
fh.write(logo_bytes)
except Exception:
pass
# Cleanup temp
shutil.rmtree(tmp_root, ignore_errors=True)
target_dir = final_dir
@ -145,49 +230,50 @@ class PluginImportAPIView(APIView):
except Exception:
pass
# Reload discovery and validate plugin entry
pm.discover_plugins()
plugin = pm._registry.get(plugin_key)
if not plugin:
# Cleanup the copied folder to avoid leaving invalid plugin behind
try:
shutil.rmtree(target_dir, ignore_errors=True)
except Exception:
pass
return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
# Extra validation: ensure Plugin.run exists
instance = getattr(plugin, "instance", None)
run_method = getattr(instance, "run", None)
if not callable(run_method):
try:
shutil.rmtree(target_dir, ignore_errors=True)
except Exception:
pass
return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST)
# Find DB config to return enabled/ever_enabled
# Ensure DB config exists (untrusted plugins are registered without loading)
try:
cfg = PluginConfig.objects.get(key=plugin_key)
enabled = cfg.enabled
ever_enabled = getattr(cfg, "ever_enabled", False)
except PluginConfig.DoesNotExist:
enabled = False
ever_enabled = False
cfg, _ = PluginConfig.objects.get_or_create(
key=plugin_key,
defaults={
"name": plugin_key,
"version": "",
"description": "",
"settings": {},
},
)
except Exception:
cfg = None
return Response({
"success": True,
"plugin": {
"key": plugin.key,
"name": plugin.name,
"version": plugin.version,
"description": plugin.description,
"enabled": enabled,
"ever_enabled": ever_enabled,
"fields": plugin.fields or [],
"actions": plugin.actions or [],
# Reload discovery to register the plugin (trusted plugins will load)
pm.discover_plugins(force_reload=True)
plugin_entry = None
try:
plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == plugin_key), None)
except Exception:
plugin_entry = None
if not plugin_entry:
logo_path = os.path.join(plugins_dir, plugin_key, "logo.png")
logo_url = f"/api/plugins/plugins/{plugin_key}/logo/" if os.path.isfile(logo_path) else None
legacy = not os.path.isfile(os.path.join(plugins_dir, plugin_key, "plugin.json"))
plugin_entry = {
"key": plugin_key,
"name": cfg.name if cfg else plugin_key,
"version": cfg.version if cfg else "",
"description": cfg.description if cfg else "",
"enabled": cfg.enabled if cfg else False,
"ever_enabled": getattr(cfg, "ever_enabled", False) if cfg else False,
"fields": [],
"actions": [],
"trusted": bool(cfg and (cfg.ever_enabled or cfg.enabled)),
"loaded": False,
"missing": False,
"legacy": legacy,
"logo_url": logo_url,
}
})
plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
return Response({"success": True, "plugin": plugin_entry})
class PluginSettingsAPIView(APIView):
@ -254,21 +340,63 @@ class PluginEnabledAPIView(APIView):
return [Authenticated()]
def post(self, request, key):
enabled = request.data.get("enabled")
if enabled is None:
enabled_raw = request.data.get("enabled")
if enabled_raw is None:
return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
enabled = _parse_bool(enabled_raw)
if enabled is None:
return Response({"success": False, "error": "Invalid 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
try:
cfg = PluginConfig.objects.get(key=key)
cfg.enabled = bool(enabled)
pm = PluginManager.get()
if not enabled and cfg.enabled:
try:
pm.stop_plugin(key, reason="disable")
except Exception:
logger.exception("Failed to stop plugin '%s' on disable", key)
cfg.enabled = enabled
# Mark that this plugin has been enabled at least once
if cfg.enabled and not cfg.ever_enabled:
cfg.ever_enabled = True
cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"])
return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled})
pm.discover_plugins(force_reload=True)
plugin_entry = None
try:
plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == key), None)
except Exception:
plugin_entry = None
response = {"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled}
if plugin_entry:
plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
response["plugin"] = plugin_entry
return Response(response)
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
class PluginLogoAPIView(APIView):
def get_permissions(self):
return []
def get(self, request, key):
if not network_access_allowed(request, "UI"):
return Response({"success": False, "error": "Network access denied"}, status=status.HTTP_403_FORBIDDEN)
pm = PluginManager.get()
pm.discover_plugins(use_cache=True)
plugins_dir = pm.plugins_dir
logo_path = os.path.join(plugins_dir, key, "logo.png")
lp = pm.get_plugin(key)
if lp and getattr(lp, "path", None):
logo_path = os.path.join(lp.path, "logo.png")
abs_plugins = os.path.abspath(plugins_dir) + os.sep
abs_target = os.path.abspath(logo_path)
if not abs_target.startswith(abs_plugins):
return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
if not os.path.isfile(logo_path):
return Response({"success": False, "error": "Logo not found"}, status=status.HTTP_404_NOT_FOUND)
return FileResponse(open(logo_path, "rb"), content_type="image/png")
class PluginDeleteAPIView(APIView):
def get_permissions(self):
try:
@ -280,6 +408,10 @@ class PluginDeleteAPIView(APIView):
def delete(self, request, key):
pm = PluginManager.get()
try:
pm.stop_plugin(key, reason="delete")
except Exception:
logger.exception("Failed to stop plugin '%s' before delete", key)
plugins_dir = pm.plugins_dir
target_dir = os.path.join(plugins_dir, key)
# Safety: ensure path inside plugins_dir
@ -302,5 +434,5 @@ class PluginDeleteAPIView(APIView):
pass
# Reload registry
pm.discover_plugins()
pm.discover_plugins(force_reload=True)
return Response({"success": True})

View file

@ -1,8 +1,12 @@
import importlib
import importlib.util
import json
import logging
import os
import re
import sys
import threading
import types
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@ -19,10 +23,17 @@ class LoadedPlugin:
name: str
version: str = ""
description: str = ""
author: str = ""
help_url: str = ""
module: Any = None
instance: Any = None
fields: List[Dict[str, Any]] = field(default_factory=list)
actions: List[Dict[str, Any]] = field(default_factory=list)
trusted: bool = False
loaded: bool = False
path: Optional[str] = None
folder_name: Optional[str] = None
legacy: bool = False
class PluginManager:
@ -39,70 +50,254 @@ class PluginManager:
def __init__(self) -> None:
self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins")
self._registry: Dict[str, LoadedPlugin] = {}
self._package_names: Dict[str, str] = {}
self._alias_names: Dict[str, str] = {}
self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token")
self._last_reload_token = 0.0
self._lock = threading.RLock()
# Ensure plugins directory exists
os.makedirs(self.plugins_dir, exist_ok=True)
if self.plugins_dir not in sys.path:
sys.path.append(self.plugins_dir)
def discover_plugins(self, *, sync_db: bool = True) -> Dict[str, LoadedPlugin]:
def discover_plugins(
self,
*,
sync_db: bool = True,
force_reload: bool = False,
use_cache: bool = False,
) -> Dict[str, LoadedPlugin]:
token = self._get_reload_token()
if use_cache and not force_reload:
with self._lock:
if self._registry and token <= self._last_reload_token:
return self._registry
if token > self._last_reload_token:
force_reload = True
if force_reload:
self._touch_reload_token()
token = self._get_reload_token()
if sync_db:
logger.info(f"Discovering plugins in {self.plugins_dir}")
else:
logger.debug(f"Discovering plugins (no DB sync) in {self.plugins_dir}")
self._registry.clear()
with self._lock:
previous_packages = dict(self._package_names)
previous_aliases = dict(self._alias_names)
previous_paths = {
key: lp.path for key, lp in self._registry.items() if lp and lp.path
}
try:
configs: Optional[Dict[str, PluginConfig]] = None
try:
configs = {c.key: c for c in PluginConfig.objects.all()}
except Exception:
# DB might not be ready; treat all plugins as untrusted
configs = None
new_registry: Dict[str, LoadedPlugin] = {}
new_packages: Dict[str, str] = {}
new_aliases: Dict[str, str] = {}
for entry in sorted(os.listdir(self.plugins_dir)):
path = os.path.join(self.plugins_dir, entry)
if not os.path.isdir(path):
continue
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
if not (has_pkg or has_pluginpy):
continue
plugin_key = entry.replace(" ", "_").lower()
alias_name = self._resolve_alias_name(entry, path)
if force_reload:
prev_alias = previous_aliases.get(plugin_key)
if prev_alias:
self._unload_alias(prev_alias)
prev_path = previous_paths.get(plugin_key)
if prev_path:
self._unload_path_modules(prev_path)
cfg = configs.get(plugin_key) if configs else None
enabled = bool(cfg and cfg.enabled)
trusted = bool(cfg and (cfg.ever_enabled or cfg.enabled))
manifest, has_manifest = self._read_manifest(path)
legacy = not has_manifest
manifest_name = None
manifest_version = None
manifest_description = None
manifest_author = None
manifest_help_url = None
manifest_fields: List[Dict[str, Any]] = []
manifest_actions: List[Dict[str, Any]] = []
if has_manifest and isinstance(manifest, dict):
manifest_name = manifest.get("name") if isinstance(manifest.get("name"), str) else None
manifest_version = manifest.get("version") if isinstance(manifest.get("version"), str) else None
manifest_description = manifest.get("description") if isinstance(manifest.get("description"), str) else None
manifest_author = manifest.get("author") if isinstance(manifest.get("author"), str) else None
manifest_help_url = manifest.get("help_url") if isinstance(manifest.get("help_url"), str) else None
manifest_fields = self._normalize_fields(manifest.get("fields", []))
manifest_actions = self._normalize_actions(manifest.get("actions", []))
display_name = manifest_name or entry
display_version = (
manifest_version if manifest_version is not None else (cfg.version if cfg else "")
)
display_description = (
manifest_description if manifest_description is not None else (cfg.description if cfg else "")
)
def _make_placeholder() -> LoadedPlugin:
return LoadedPlugin(
key=plugin_key,
name=display_name,
version=display_version,
description=display_description,
author=manifest_author or "",
help_url=manifest_help_url or "",
fields=manifest_fields if has_manifest else [],
actions=manifest_actions if has_manifest else [],
trusted=trusted,
loaded=False,
path=path,
folder_name=entry,
legacy=legacy,
)
if not enabled:
new_registry[plugin_key] = _make_placeholder()
continue
try:
self._load_plugin(plugin_key, path)
lp, package_name = self._load_plugin(
plugin_key,
path,
folder_name=entry,
force_reload=force_reload,
previous_package=previous_packages.get(plugin_key),
)
if lp:
if manifest_name and (not lp.name or lp.name == plugin_key):
lp.name = manifest_name
if manifest_version is not None and not lp.version:
lp.version = manifest_version
if manifest_description is not None and not lp.description:
lp.description = manifest_description
if manifest_author is not None and not lp.author:
lp.author = manifest_author
if manifest_help_url is not None and not lp.help_url:
lp.help_url = manifest_help_url
if manifest_fields and not lp.fields:
lp.fields = manifest_fields
if manifest_actions and not lp.actions:
lp.actions = manifest_actions
lp.trusted = trusted
lp.loaded = True
lp.path = path
lp.folder_name = entry
lp.legacy = legacy
new_registry[plugin_key] = lp
if package_name:
new_packages[plugin_key] = package_name
if alias_name:
new_aliases[plugin_key] = alias_name
else:
new_registry[plugin_key] = _make_placeholder()
except Exception:
logger.exception(f"Failed to load plugin '{plugin_key}' from {path}")
new_registry[plugin_key] = _make_placeholder()
logger.info(f"Discovered {len(self._registry)} plugin(s)")
if force_reload:
# Remove stale modules for plugins that no longer exist
removed_keys = set(previous_packages.keys()) - set(new_packages.keys())
for key in removed_keys:
self._unload_package(previous_packages[key])
prev_alias = previous_aliases.get(key)
if prev_alias:
self._unload_alias(prev_alias)
prev_path = previous_paths.get(key)
if prev_path:
self._unload_path_modules(prev_path)
with self._lock:
self._registry = new_registry
self._package_names = new_packages
self._alias_names = new_aliases
if token > self._last_reload_token:
self._last_reload_token = token
logger.info(f"Discovered {len(new_registry)} plugin(s)")
except FileNotFoundError:
logger.warning(f"Plugins directory not found: {self.plugins_dir}")
# Sync DB records (optional)
if sync_db:
try:
self._sync_db_with_registry()
self._sync_db_with_registry(new_registry if 'new_registry' in locals() else None)
except Exception:
# Defer sync if database is not ready (e.g., first startup before migrate)
logger.exception("Deferring plugin DB sync; database not ready yet")
return self._registry
def _load_plugin(self, key: str, path: str):
def _load_plugin(
self,
key: str,
path: str,
*,
folder_name: str,
force_reload: bool,
previous_package: Optional[str],
) -> tuple[Optional[LoadedPlugin], Optional[str]]:
# Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
if not (has_pkg or has_pluginpy):
logger.debug(f"Skipping {path}: no plugin.py or package")
return
return None, None
candidate_modules = []
if has_pluginpy:
candidate_modules.append(f"{key}.plugin")
if has_pkg:
candidate_modules.append(key)
package_name = self._resolve_package_name(key)
alias_name = self._resolve_alias_name(folder_name, path)
if force_reload and previous_package:
self._unload_package(previous_package)
module = None
plugin_cls = None
last_error = None
for module_name in candidate_modules:
# Ensure a package context exists for plugin.py (even without __init__.py)
if has_pluginpy:
self._ensure_namespace_package(package_name, path, alias=alias_name)
module_name = f"{package_name}.plugin"
plugin_path = os.path.join(path, "plugin.py")
try:
logger.debug(f"Importing plugin module {module_name}")
module = importlib.import_module(module_name)
logger.debug(f"Importing plugin module {module_name} from {plugin_path}")
module = self._load_module_from_path(module_name, plugin_path, is_package=False)
if alias_name:
self._register_alias_module(f"{alias_name}.plugin", module, path)
plugin_cls = getattr(module, "Plugin", None)
if plugin_cls is not None:
break
else:
if plugin_cls is None:
logger.warning(f"Module {module_name} has no Plugin class")
except Exception as e:
last_error = e
logger.exception(f"Error importing module {module_name}")
if plugin_cls is None and has_pkg:
module_name = package_name
init_path = os.path.join(path, "__init__.py")
try:
logger.debug(f"Importing plugin package {module_name} from {init_path}")
module = self._load_module_from_path(module_name, init_path, is_package=True)
self._register_alias_module(alias_name, module, path)
plugin_cls = getattr(module, "Plugin", None)
if plugin_cls is None:
logger.warning(f"Module {module_name} has no Plugin class")
except Exception as e:
last_error = e
@ -111,32 +306,43 @@ class PluginManager:
if plugin_cls is None:
if last_error:
raise last_error
else:
logger.warning(f"No Plugin class found for {key}; skipping")
return
logger.warning(f"No Plugin class found for {key}; skipping")
return None, package_name
instance = plugin_cls()
name = getattr(instance, "name", key)
version = getattr(instance, "version", "")
description = getattr(instance, "description", "")
author = getattr(instance, "author", "")
help_url = getattr(instance, "help_url", "")
fields = getattr(instance, "fields", [])
actions = getattr(instance, "actions", [])
fields = self._normalize_fields(fields)
actions = self._normalize_actions(actions)
self._registry[key] = LoadedPlugin(
lp = LoadedPlugin(
key=key,
name=name,
version=version,
description=description,
author=author or "",
help_url=help_url or "",
module=module,
instance=instance,
fields=fields,
actions=actions,
path=path,
folder_name=folder_name,
)
return lp, package_name
def _sync_db_with_registry(self):
def _sync_db_with_registry(self, registry: Optional[Dict[str, LoadedPlugin]] = None):
if registry is None:
with self._lock:
registry = dict(self._registry)
with transaction.atomic():
for key, lp in self._registry.items():
for key, lp in registry.items():
obj, _ = PluginConfig.objects.get_or_create(
key=key,
defaults={
@ -164,6 +370,8 @@ class PluginManager:
from .models import PluginConfig
plugins: List[Dict[str, Any]] = []
with self._lock:
registry_snapshot = dict(self._registry)
try:
configs = {c.key: c for c in PluginConfig.objects.all()}
except Exception as e:
@ -172,25 +380,33 @@ class PluginManager:
configs = {}
# First, include all discovered plugins
for key, lp in self._registry.items():
for key, lp in registry_snapshot.items():
conf = configs.get(key)
trusted = bool(conf and (conf.ever_enabled or conf.enabled))
logo_url = self._get_logo_url(key, path=lp.path)
plugins.append(
{
"key": key,
"name": lp.name,
"version": lp.version,
"description": lp.description,
"author": getattr(lp, "author", "") or "",
"help_url": getattr(lp, "help_url", "") or "",
"enabled": conf.enabled if conf else False,
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
"missing": False,
"trusted": trusted,
"loaded": bool(lp.loaded),
"legacy": bool(getattr(lp, "legacy", False)),
"logo_url": logo_url,
}
)
# Then, include any DB-only configs (files missing or failed to load)
discovered_keys = set(self._registry.keys())
discovered_keys = set(registry_snapshot.keys())
for key, conf in configs.items():
if key in discovered_keys:
continue
@ -200,19 +416,26 @@ class PluginManager:
"name": conf.name,
"version": conf.version,
"description": conf.description,
"author": "",
"help_url": "",
"enabled": conf.enabled,
"ever_enabled": getattr(conf, "ever_enabled", False),
"fields": [],
"settings": conf.settings or {},
"actions": [],
"missing": True,
"trusted": bool(conf.ever_enabled or conf.enabled),
"loaded": False,
"legacy": False,
"logo_url": self._get_logo_url(key),
}
)
return plugins
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
return self._registry.get(key)
with self._lock:
return self._registry.get(key)
def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]:
cfg = PluginConfig.objects.get(key=key)
@ -223,19 +446,18 @@ class PluginManager:
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
lp = self.get_plugin(key)
if not lp or not lp.instance:
raise ValueError(f"Plugin '{key}' not found")
# Attempt a lightweight re-discovery in case the registry was rebuilt
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
lp = self.get_plugin(key)
if not lp or not lp.instance:
raise ValueError(f"Plugin '{key}' not found")
cfg = PluginConfig.objects.get(key=key)
if not cfg.enabled:
raise PermissionError(f"Plugin '{key}' is disabled")
params = params or {}
# Provide a context object to the plugin
context = {
"settings": cfg.settings or {},
"logger": logger,
"actions": {a.get("id"): a for a in (lp.actions or [])},
}
context = self._build_context(lp, cfg)
# Run either via Celery if plugin provides a delayed method, or inline
run_method = getattr(lp.instance, "run", None)
@ -252,3 +474,286 @@ class PluginManager:
if isinstance(result, dict):
return result
return {"status": "ok", "result": result}
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
lp = self.get_plugin(key)
if not lp or not lp.instance:
return False
try:
cfg = PluginConfig.objects.get(key=key)
except PluginConfig.DoesNotExist:
return False
if not cfg.enabled:
return False
context = self._build_context(lp, cfg)
if reason:
context["reason"] = reason
stop_method = getattr(lp.instance, "stop", None)
if callable(stop_method):
try:
stop_method(context)
return True
except TypeError:
try:
stop_method()
return True
except Exception:
logger.exception("Plugin '%s' stop() failed", key)
return False
except Exception:
logger.exception("Plugin '%s' stop() failed", key)
return False
run_method = getattr(lp.instance, "run", None)
if callable(run_method):
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
if "stop" in actions:
try:
run_method("stop", {}, context)
return True
except Exception:
logger.exception("Plugin '%s' stop action failed", key)
return False
return False
def stop_all_plugins(self, reason: Optional[str] = None) -> int:
stopped = 0
with self._lock:
registry_snapshot = dict(self._registry)
for key in registry_snapshot.keys():
if self.stop_plugin(key, reason=reason):
stopped += 1
return stopped
def _resolve_package_name(self, key: str) -> str:
safe_key = self._safe_module_name(key)
return f"_dispatcharr_plugin_{safe_key}"
def _resolve_alias_name(self, folder_name: str, path: str) -> Optional[str]:
if not self._is_valid_identifier(folder_name):
return None
if self._is_reserved_module_name(folder_name, path):
return None
return folder_name
def _is_valid_identifier(self, name: str) -> bool:
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is not None
def _safe_module_name(self, value: str) -> str:
safe = re.sub(r"[^0-9A-Za-z_]", "_", value)
if not safe or safe[0].isdigit():
safe = f"p_{safe}"
return safe
def _normalize_fields(self, fields: Any) -> List[Dict[str, Any]]:
try:
from .serializers import PluginFieldSerializer
except Exception:
return fields if isinstance(fields, list) else []
if not isinstance(fields, list):
return []
serializer = PluginFieldSerializer(data=fields, many=True)
if serializer.is_valid():
return serializer.validated_data
normalized: List[Dict[str, Any]] = []
for item in fields:
item_ser = PluginFieldSerializer(data=item)
if item_ser.is_valid():
normalized.append(item_ser.validated_data)
else:
logger.warning("Invalid plugin field entry ignored: %s", item_ser.errors)
return normalized
def _normalize_actions(self, actions: Any) -> List[Dict[str, Any]]:
try:
from .serializers import PluginActionSerializer
except Exception:
return actions if isinstance(actions, list) else []
if not isinstance(actions, list):
return []
serializer = PluginActionSerializer(data=actions, many=True)
if serializer.is_valid():
return serializer.validated_data
normalized: List[Dict[str, Any]] = []
for item in actions:
item_ser = PluginActionSerializer(data=item)
if item_ser.is_valid():
normalized.append(item_ser.validated_data)
else:
logger.warning("Invalid plugin action entry ignored: %s", item_ser.errors)
return normalized
def _merge_settings_with_defaults(self, settings: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]:
merged = dict(settings or {})
for field_def in fields or []:
field_id = field_def.get("id")
if not field_id:
continue
if field_id not in merged and "default" in field_def:
merged[field_id] = field_def.get("default")
return merged
def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig) -> Dict[str, Any]:
settings = self._merge_settings_with_defaults(cfg.settings or {}, lp.fields or [])
return {
"settings": settings,
"logger": logger,
"actions": {a.get("id"): a for a in (lp.actions or [])},
}
def _read_manifest(self, path: str) -> tuple[Optional[Dict[str, Any]], bool]:
manifest_path = os.path.join(path, "plugin.json")
if not os.path.isfile(manifest_path):
return None, False
try:
with open(manifest_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
logger.warning("Invalid plugin.json for plugin at %s", path)
return None, False
if not isinstance(data, dict):
logger.warning("plugin.json must be an object for plugin at %s", path)
return None, False
return data, True
def _get_logo_url(self, key: str, *, path: Optional[str] = None) -> Optional[str]:
logo_path = os.path.join(self.plugins_dir, key, "logo.png")
if path:
logo_path = os.path.join(path, "logo.png")
try:
if os.path.isfile(logo_path):
return f"/api/plugins/plugins/{key}/logo/"
except Exception:
return None
return None
def _ensure_namespace_package(self, package_name: str, path: str, *, alias: Optional[str] = None) -> None:
existing = sys.modules.get(package_name)
if existing and getattr(existing, "__path__", None):
return
pkg = types.ModuleType(package_name)
pkg.__path__ = [path]
pkg.__package__ = package_name
sys.modules[package_name] = pkg
self._register_alias_module(alias, pkg, path)
def _register_alias_module(
self,
alias_name: Optional[str],
module: Any,
path: str,
*,
force: bool = False,
) -> None:
if not alias_name:
return
if self._is_reserved_module_name(alias_name, path):
return
if alias_name in sys.modules:
if not force:
return
self._unload_alias(alias_name)
sys.modules[alias_name] = module
def _is_reserved_module_name(self, name: str, path: str) -> bool:
if name in sys.builtin_module_names:
return True
if hasattr(sys, "stdlib_module_names") and name in sys.stdlib_module_names:
return True
existing = sys.modules.get(name)
if existing:
origin = getattr(existing, "__file__", None)
if origin is None:
return True
try:
if not os.path.abspath(origin).startswith(os.path.abspath(path)):
return True
except Exception:
return True
try:
spec = importlib.util.find_spec(name)
except Exception:
spec = None
if spec:
if spec.origin is None:
return True
try:
if not os.path.abspath(spec.origin).startswith(os.path.abspath(path)):
return True
except Exception:
return True
return False
def _load_module_from_path(self, module_name: str, path: str, *, is_package: bool) -> Any:
importlib.invalidate_caches()
spec = importlib.util.spec_from_file_location(
module_name,
path,
submodule_search_locations=[os.path.dirname(path)] if is_package else None,
)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load spec for {module_name} from {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _get_reload_token(self) -> float:
try:
return os.path.getmtime(self._reload_token_path)
except FileNotFoundError:
return 0.0
except Exception:
return 0.0
def _touch_reload_token(self) -> None:
try:
os.makedirs(self.plugins_dir, exist_ok=True)
with open(self._reload_token_path, "a", encoding="utf-8"):
pass
os.utime(self._reload_token_path, None)
except Exception:
logger.debug("Failed to update plugin reload token", exc_info=True)
def _unload_package(self, package_name: str) -> None:
if not package_name:
return
for name in list(sys.modules.keys()):
if name == package_name or name.startswith(f"{package_name}."):
sys.modules.pop(name, None)
def _unload_alias(self, alias_name: str) -> None:
if not alias_name:
return
for name in list(sys.modules.keys()):
if name == alias_name or name.startswith(f"{alias_name}."):
sys.modules.pop(name, None)
def _unload_path_modules(self, path: str) -> None:
if not path:
return
root = os.path.abspath(path)
for name, module in list(sys.modules.items()):
if not module:
continue
mod_path = getattr(module, "__file__", None)
if mod_path:
try:
abs_path = os.path.abspath(mod_path)
if abs_path == root or abs_path.startswith(f"{root}{os.sep}"):
sys.modules.pop(name, None)
continue
except Exception:
pass
mod_paths = getattr(module, "__path__", None)
if mod_paths:
try:
for pkg_path in mod_paths:
abs_pkg = os.path.abspath(pkg_path)
if abs_pkg == root or abs_pkg.startswith(f"{root}{os.sep}"):
sys.modules.pop(name, None)
break
except Exception:
continue

View file

@ -5,15 +5,31 @@ class PluginActionSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
description = serializers.CharField(required=False, allow_blank=True)
confirm = serializers.JSONField(required=False)
button_label = serializers.CharField(required=False, allow_blank=True)
button_variant = serializers.CharField(required=False, allow_blank=True)
button_color = serializers.CharField(required=False, allow_blank=True)
class PluginFieldOptionSerializer(serializers.Serializer):
value = serializers.CharField()
label = serializers.CharField()
class PluginFieldSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
type = serializers.ChoiceField(choices=["string", "number", "boolean", "select"]) # simple types
label = serializers.CharField(required=False, allow_blank=True)
type = serializers.ChoiceField(choices=["string", "number", "boolean", "select", "text", "info"])
default = serializers.JSONField(required=False)
help_text = serializers.CharField(required=False, allow_blank=True)
options = serializers.ListField(child=serializers.DictField(), required=False)
description = serializers.CharField(required=False, allow_blank=True)
placeholder = serializers.CharField(required=False, allow_blank=True)
input_type = serializers.CharField(required=False, allow_blank=True)
min = serializers.FloatField(required=False)
max = serializers.FloatField(required=False)
step = serializers.FloatField(required=False)
value = serializers.CharField(required=False, allow_blank=True)
options = PluginFieldOptionSerializer(many=True, required=False)
class PluginSerializer(serializers.Serializer):
@ -21,8 +37,9 @@ class PluginSerializer(serializers.Serializer):
name = serializers.CharField()
version = serializers.CharField(allow_blank=True)
description = serializers.CharField(allow_blank=True)
author = serializers.CharField(required=False, allow_blank=True)
help_url = serializers.CharField(required=False, allow_blank=True)
enabled = serializers.BooleanField()
fields = PluginFieldSerializer(many=True)
settings = serializers.JSONField()
actions = PluginActionSerializer(many=True)

View file

@ -149,11 +149,15 @@ class ProxyServer:
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
pubsub_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
socket_timeout=60,
socket_connect_timeout=10,
socket_keepalive=True,

View file

@ -101,7 +101,16 @@ class PersistentVODConnection:
redis_host = getattr(settings, 'REDIS_HOST', 'localhost')
redis_port = int(getattr(settings, 'REDIS_PORT', 6379))
redis_db = int(getattr(settings, 'REDIS_DB', 0))
r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True)
redis_password = getattr(settings, 'REDIS_PASSWORD', '')
redis_user = getattr(settings, 'REDIS_USER', '')
r = redis.StrictRedis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
decode_responses=True
)
content_length_key = f"vod_content_length:{self.session_id}"
stored_length = r.get(content_length_key)
if stored_length:

View file

@ -333,7 +333,16 @@ class VODStreamView(View):
redis_host = getattr(settings, 'REDIS_HOST', 'localhost')
redis_port = int(getattr(settings, 'REDIS_PORT', 6379))
redis_db = int(getattr(settings, 'REDIS_DB', 0))
r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True)
redis_password = getattr(settings, 'REDIS_PASSWORD', '')
redis_user = getattr(settings, 'REDIS_USER', '')
r = redis.StrictRedis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
decode_responses=True
)
content_length_key = f"vod_content_length:{session_id}"
r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes
logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}")

View file

@ -55,6 +55,8 @@ class RedisClient:
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
# Use standardized settings
socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5)
@ -68,6 +70,8 @@ class RedisClient:
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
socket_timeout=socket_timeout,
socket_connect_timeout=socket_connect_timeout,
socket_keepalive=socket_keepalive,
@ -148,6 +152,8 @@ class RedisClient:
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
# Use standardized settings but without socket timeouts for PubSub
# Important: socket_timeout is None for PubSub operations
@ -161,6 +167,8 @@ class RedisClient:
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
socket_timeout=None, # Critical: No timeout for PubSub operations
socket_connect_timeout=socket_connect_timeout,
socket_keepalive=socket_keepalive,

View file

@ -40,7 +40,15 @@ def stream_view(request, channel_uuid):
redis_host = getattr(settings, "REDIS_HOST", "localhost")
redis_port = int(getattr(settings, "REDIS_PORT", 6379))
redis_db = int(getattr(settings, "REDIS_DB", "0"))
redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db)
redis_password = getattr(settings, "REDIS_PASSWORD", "")
redis_user = getattr(settings, "REDIS_USER", "")
redis_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None
)
# Retrieve the channel by the provided stream_id.
channel = Channel.objects.get(uuid=channel_uuid)

View file

@ -78,7 +78,15 @@ if __name__ == "__main__":
redis_host = os.environ.get("REDIS_HOST", "localhost")
redis_port = int(os.environ.get("REDIS_PORT", 6379))
redis_db = int(os.environ.get("REDIS_DB", 0))
client = redis.Redis(host=redis_host, port=redis_port, db=redis_db)
redis_password = os.environ.get("REDIS_PASSWORD", "")
redis_user = os.environ.get("REDIS_USER", "")
client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None
)
lock = PersistentLock(client, "lock:example_account", lock_timeout=120)
if lock.acquire():

View file

@ -1,6 +1,7 @@
import os
from pathlib import Path
from datetime import timedelta
from urllib.parse import quote_plus
BASE_DIR = Path(__file__).resolve().parent.parent
@ -8,6 +9,8 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))
REDIS_DB = os.environ.get("REDIS_DB", "0")
REDIS_USER = os.environ.get("REDIS_USER", "")
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
# Set DEBUG to True for development, False for production
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
@ -120,7 +123,12 @@ CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(REDIS_HOST, REDIS_PORT, REDIS_DB)], # Ensure Redis is running
"hosts": ["redis://{redis_auth}{host}:{port}/{db}".format(
redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "",
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB
)], # URL format supports authentication
},
},
}
@ -198,8 +206,19 @@ STATICFILES_DIRS = [
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "accounts.User"
# Build default Redis URL from components for Celery
_default_redis_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
# Build default Redis URL from components for Celery with optional authentication
# Build auth string conditionally with URL encoding for special characters
if REDIS_PASSWORD:
encoded_password = quote_plus(REDIS_PASSWORD)
if REDIS_USER:
encoded_user = quote_plus(REDIS_USER)
redis_auth = f"{encoded_user}:{encoded_password}@"
else:
redis_auth = f":{encoded_password}@"
else:
redis_auth = ""
_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url)
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
@ -270,7 +289,7 @@ SIMPLE_JWT = {
}
# Redis connection settings
REDIS_URL = os.environ.get("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}")
REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url)
REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds
REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds
REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds

View file

@ -1,4 +1,11 @@
# Dispatcharr - Modular Deployment Configuration
# This compose file runs Dispatcharr in modular mode with separate containers
# for web, celery workers, PostgreSQL database, and Redis cache.
services:
# ============================================================================
# Web Service
# ============================================================================
web:
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_web
@ -9,44 +16,67 @@ services:
depends_on:
- db
- redis
# --- Environment Configuration ---
environment:
# Deployment Mode
- DISPATCHARR_ENV=modular
# PostgreSQL Connection
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
# Redis Connection
- REDIS_HOST=redis
- REDIS_PORT=6379
# Redis Authentication (Optional)
# Uncomment and set if your Redis requires authentication:
#- REDIS_PASSWORD=your_strong_redis_password
#- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below
# Logging
- DISPATCHARR_LOG_LEVEL=info
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
# that lack support for newer baseline CPU features
# that lack support for newer baseline CPU features:
#- USE_LEGACY_NUMPY=true
# Process Priority Configuration (Optional)
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
# Negative values require cap_add: SYS_NICE (uncomment below)
# Negative values require cap_add: SYS_NICE (see below)
#- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority)
#
# --- Advanced Configuration ---
# Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0)
#cap_add:
# - SYS_NICE
# Optional for hardware acceleration
# --- Hardware Acceleration (Optional) ---
# Uncomment for GPU access (transcoding acceleration)
#group_add:
# - video
# #- render # Uncomment if your GPU requires it
# #- render # Uncomment if your GPU requires it
#devices:
# - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API)
# NVIDIA GPU Support (requires NVIDIA Container Toolkit)
# Uncomment the following lines for NVIDIA GPU support
# NVidia GPU support (requires NVIDIA Container Toolkit)
#deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
# ============================================================================
# Celery Service - Background task worker
# ============================================================================
celery:
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_celery
@ -58,24 +88,47 @@ services:
- ./data:/data
extra_hosts:
- "host.docker.internal:host-gateway"
entrypoint: ["/app/docker/entrypoint.celery.sh"]
# --- Environment Configuration ---
environment:
# Deployment Mode
- DISPATCHARR_ENV=modular
# PostgreSQL Connection
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
# Redis Connection
- REDIS_HOST=redis
- REDIS_PORT=6379
# Redis Authentication (Optional)
# Uncomment and set if your Redis requires authentication:
#- REDIS_PASSWORD=your_strong_redis_password
#- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below
# Logging
- DISPATCHARR_LOG_LEVEL=info
#- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19)
# Process Priority Configuration (Optional)
#- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19)
# Django Configuration
- DJANGO_SETTINGS_MODULE=dispatcharr.settings
- PYTHONUNBUFFERED=1
# --- Advanced Configuration ---
# Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0)
#cap_add:
# - SYS_NICE
entrypoint: ["/app/docker/entrypoint.celery.sh"]
# ============================================================================
# PostgreSQL
# ============================================================================
db:
image: postgres:17
container_name: dispatcharr_db
@ -93,14 +146,42 @@ services:
timeout: 5s
retries: 5
# ============================================================================
# Redis
# ============================================================================
redis:
image: redis:latest
container_name: dispatcharr_redis
# --- Authentication Configuration (Optional) ---
# By default, Redis runs without authentication.
# Choose ONE of the following options if authentication is required:
# Option 1: Password-only authentication (Redis <6 or default user)
#command: ["redis-server", "--requirepass", "your_strong_redis_password"]
# Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration)
#command: ["redis-server", "/etc/redis/redis.conf"]
#volumes:
# - ./redis.conf:/etc/redis/redis.conf:ro
# --- Health Check Configuration ---
healthcheck:
# Default: No authentication
test: ["CMD", "redis-cli", "ping"]
# If using Option 1 (password-only), uncomment this instead:
#test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"]
# If using Option 2 (Redis 6+ ACL), uncomment this instead:
#test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"]
interval: 5s
timeout: 5s
retries: 5
# ==============================================================================
# Volumes
# ==============================================================================
volumes:
postgres_data:

View file

@ -38,6 +38,8 @@ export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
export REDIS_HOST=${REDIS_HOST:-localhost}
export REDIS_PORT=${REDIS_PORT:-6379}
export REDIS_DB=${REDIS_DB:-0}
export REDIS_PASSWORD=${REDIS_PASSWORD:-}
export REDIS_USER=${REDIS_USER:-}
export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191}
export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri'
export LD_LIBRARY_PATH='/usr/local/lib'
@ -104,7 +106,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT
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
)

View file

@ -1,7 +1,7 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{ ignores: ['dist'] },
@ -30,4 +30,4 @@ export default [
],
},
},
]
];

View file

@ -4,10 +4,24 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/static/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/static/favicon-16x16.png"
/>
<link rel="manifest" href="/static/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dispatcharr</title>

View file

@ -3,8 +3,9 @@ export default {
semi: true, // Add semicolons at the end of statements
singleQuote: true, // Use single quotes instead of double
tabWidth: 2, // Set the indentation width
trailingComma: "es5", // Add trailing commas where valid in ES5
trailingComma: 'es5', // Add trailing commas where valid in ES5
printWidth: 80, // Wrap lines at 80 characters
bracketSpacing: true, // Add spaces inside object braces
arrowParens: "always", // Always include parentheses around arrow function parameters
arrowParens: 'always', // Always include parentheses around arrow function parameters
endOfLine: 'lf', // Enforce LF for all files
};

View file

@ -1 +1,19 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -171,7 +171,7 @@ export default class API {
static async logout() {
return await request(`${host}/api/accounts/auth/logout/`, {
auth: true, // Send JWT token so backend can identify the user
auth: true, // Send JWT token so backend can identify the user
method: 'POST',
});
}
@ -261,15 +261,11 @@ export default class API {
API.lastQueryParams = newParams;
const [response, ids] = await Promise.all([
request(
`${host}/api/channels/channels/?${newParams.toString()}`
),
request(`${host}/api/channels/channels/?${newParams.toString()}`),
API.getAllChannelIds(newParams),
]);
useChannelsTableStore
.getState()
.queryChannels(response, newParams);
useChannelsTableStore.getState().queryChannels(response, newParams);
useChannelsTableStore.getState().setAllQueryIds(ids);
return response;
@ -390,7 +386,8 @@ export default class API {
channelData.channel_number === '' ||
channelData.channel_number === null ||
channelData.channel_number === undefined ||
(typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '')
(typeof channelData.channel_number === 'string' &&
channelData.channel_number.trim() === '')
) {
delete channelData.channel_number;
}
@ -719,7 +716,11 @@ export default class API {
}
}
static async createChannelsFromStreamsAsync(streamIds, channelProfileIds = null, startingChannelNumber = null) {
static async createChannelsFromStreamsAsync(
streamIds,
channelProfileIds = null,
startingChannelNumber = null
) {
try {
const requestBody = {
stream_ids: streamIds,
@ -815,15 +816,11 @@ export default class API {
try {
const [response, ids] = await Promise.all([
request(
`${host}/api/channels/streams/?${params.toString()}`
),
request(`${host}/api/channels/streams/?${params.toString()}`),
API.getAllStreamIds(params),
]);
useStreamsTableStore
.getState()
.queryStreams(response, params);
useStreamsTableStore.getState().queryStreams(response, params);
useStreamsTableStore.getState().setAllQueryIds(ids);
return response;
@ -1179,13 +1176,10 @@ export default class API {
static async getCurrentPrograms(channelIds = null) {
try {
const response = await request(
`${host}/api/epg/current-programs/`,
{
method: 'POST',
body: { channel_ids: channelIds },
}
);
const response = await request(`${host}/api/epg/current-programs/`, {
method: 'POST',
body: { channel_ids: channelIds },
});
return response;
} catch (e) {
@ -1318,9 +1312,15 @@ export default class API {
errorNotification('Failed to retrieve timezones', e);
// Return fallback data instead of throwing
return {
timezones: ['UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific'],
timezones: [
'UTC',
'US/Eastern',
'US/Central',
'US/Mountain',
'US/Pacific',
],
grouped: {},
count: 5
count: 5,
};
}
}
@ -1444,16 +1444,22 @@ export default class API {
static async refreshAccountInfo(profileId) {
try {
const response = await request(`${host}/api/m3u/refresh-account-info/${profileId}/`, {
method: 'POST',
});
const response = await request(
`${host}/api/m3u/refresh-account-info/${profileId}/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
// If it's a structured error response, return it instead of throwing
if (e.body && typeof e.body === 'object') {
return e.body;
}
errorNotification(`Failed to refresh account info for profile ${profileId}`, e);
errorNotification(
`Failed to refresh account info for profile ${profileId}`,
e
);
throw e;
}
}
@ -1580,7 +1586,11 @@ export default class API {
});
// Wait for the task to complete using token for auth
const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token);
const result = await API.waitForBackupTask(
response.task_id,
onProgress,
response.task_token
);
return result;
} catch (e) {
errorNotification('Failed to create backup', e);
@ -1593,13 +1603,10 @@ export default class API {
const formData = new FormData();
formData.append('file', file);
const response = await request(
`${host}/api/backups/upload/`,
{
method: 'POST',
body: formData,
}
);
const response = await request(`${host}/api/backups/upload/`, {
method: 'POST',
body: formData,
});
return response;
} catch (e) {
errorNotification('Failed to upload backup', e);
@ -1622,7 +1629,9 @@ export default class API {
static async getDownloadToken(filename) {
// Get a download token from the server
try {
const response = await request(`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`);
const response = await request(
`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`
);
return response.token;
} catch (e) {
throw e;
@ -1666,7 +1675,11 @@ export default class API {
// Wait for the task to complete using token for auth
// Token-based auth allows status polling even after DB restore invalidates user sessions
const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token);
const result = await API.waitForBackupTask(
response.task_id,
onProgress,
response.task_token
);
return result;
} catch (e) {
errorNotification('Failed to restore backup', e);
@ -1741,17 +1754,27 @@ export default class API {
return response;
} catch (e) {
// Show only the concise error message for plugin import
const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin';
notifications.show({ title: 'Import failed', message: msg, color: 'red' });
const msg =
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed to import plugin';
notifications.show({
title: 'Import failed',
message: msg,
color: 'red',
});
throw e;
}
}
static async deletePlugin(key) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, {
method: 'DELETE',
});
const response = await request(
`${host}/api/plugins/plugins/${key}/delete/`,
{
method: 'DELETE',
}
);
return response;
} catch (e) {
errorNotification('Failed to delete plugin', e);
@ -1770,15 +1793,19 @@ export default class API {
return response?.settings || {};
} catch (e) {
errorNotification('Failed to update plugin settings', e);
throw e;
}
}
static async runPluginAction(key, action, params = {}) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/run/`, {
method: 'POST',
body: { action, params },
});
const response = await request(
`${host}/api/plugins/plugins/${key}/run/`,
{
method: 'POST',
body: { action, params },
}
);
return response;
} catch (e) {
errorNotification('Failed to run plugin action', e);
@ -1787,10 +1814,13 @@ export default class API {
static async setPluginEnabled(key, enabled) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/enabled/`, {
method: 'POST',
body: { enabled },
});
const response = await request(
`${host}/api/plugins/plugins/${key}/enabled/`,
{
method: 'POST',
body: { enabled },
}
);
return response;
} catch (e) {
errorNotification('Failed to update plugin enabled state', e);
@ -1972,7 +2002,7 @@ export default class API {
if (!logoIds || logoIds.length === 0) return [];
const params = new URLSearchParams();
logoIds.forEach(id => params.append('ids', id));
logoIds.forEach((id) => params.append('ids', id));
// Disable pagination for ID-based queries to get all matching logos
params.append('no_pagination', 'true');
@ -2439,10 +2469,13 @@ export default class API {
static async updateRecurringRule(ruleId, payload) {
try {
const response = await request(`${host}/api/channels/recurring-rules/${ruleId}/`, {
method: 'PATCH',
body: payload,
});
const response = await request(
`${host}/api/channels/recurring-rules/${ruleId}/`,
{
method: 'PATCH',
body: payload,
}
);
return response;
} catch (e) {
errorNotification(`Failed to update recurring rule ${ruleId}`, e);
@ -2461,9 +2494,13 @@ export default class API {
static async deleteRecording(id) {
try {
await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' });
await request(`${host}/api/channels/recordings/${id}/`, {
method: 'DELETE',
});
// Optimistically remove locally for instant UI update
try { useChannelsStore.getState().removeRecording(id); } catch {}
try {
useChannelsStore.getState().removeRecording(id);
} catch {}
} catch (e) {
errorNotification(`Failed to delete recording ${id}`, e);
}
@ -2471,9 +2508,12 @@ export default class API {
static async runComskip(recordingId) {
try {
const resp = await request(`${host}/api/channels/recordings/${recordingId}/comskip/`, {
method: 'POST',
});
const resp = await request(
`${host}/api/channels/recordings/${recordingId}/comskip/`,
{
method: 'POST',
}
);
// Refresh recordings list to reflect comskip status when done later
// This endpoint just queues the task; the websocket/refresh will update eventually
return resp;
@ -2511,7 +2551,9 @@ export default class API {
static async deleteSeriesRule(tvgId) {
try {
const encodedTvgId = encodeURIComponent(tvgId);
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, { method: 'DELETE' });
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, {
method: 'DELETE',
});
notifications.show({ title: 'Series rule removed' });
} catch (e) {
errorNotification('Failed to remove series rule', e);
@ -2521,9 +2563,12 @@ export default class API {
static async deleteAllUpcomingRecordings() {
try {
const resp = await request(`${host}/api/channels/recordings/bulk-delete-upcoming/`, {
method: 'POST',
});
const resp = await request(
`${host}/api/channels/recordings/bulk-delete-upcoming/`,
{
method: 'POST',
}
);
notifications.show({ title: `Removed ${resp.removed || 0} upcoming` });
useChannelsStore.getState().fetchRecordings();
return resp;
@ -2544,12 +2589,19 @@ export default class API {
}
}
static async bulkRemoveSeriesRecordings({ tvg_id, title = null, scope = 'title' }) {
static async bulkRemoveSeriesRecordings({
tvg_id,
title = null,
scope = 'title',
}) {
try {
const resp = await request(`${host}/api/channels/series-rules/bulk-remove/`, {
method: 'POST',
body: { tvg_id, title, scope },
});
const resp = await request(
`${host}/api/channels/series-rules/bulk-remove/`,
{
method: 'POST',
body: { tvg_id, title, scope },
}
);
notifications.show({ title: `Removed ${resp.removed || 0} scheduled` });
return resp;
} catch (e) {
@ -2711,13 +2763,10 @@ export default class API {
try {
// Use POST for large ID lists to avoid URL length limitations
if (ids.length > 50) {
const response = await request(
`${host}/api/channels/streams/by-ids/`,
{
method: 'POST',
body: { ids },
}
);
const response = await request(`${host}/api/channels/streams/by-ids/`, {
method: 'POST',
body: { ids },
});
return response;
} else {
// Use GET for small ID lists for backward compatibility
@ -2743,8 +2792,9 @@ export default class API {
return response;
} catch (e) {
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
const isInvalidPage =
e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve movies', e);
@ -2761,8 +2811,9 @@ export default class API {
return response;
} catch (e) {
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
const isInvalidPage =
e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve series', e);
@ -2773,7 +2824,10 @@ export default class API {
static async getAllContent(params = new URLSearchParams()) {
try {
console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`);
console.log(
'Calling getAllContent with URL:',
`${host}/api/vod/all/?${params.toString()}`
);
const response = await request(
`${host}/api/vod/all/?${params.toString()}`
);
@ -2786,8 +2840,9 @@ export default class API {
console.error('Error message:', e.message);
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
const isInvalidPage =
e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve content', e);
@ -2910,9 +2965,8 @@ export default class API {
);
// Update the store with fetched notifications
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore.getState().setNotifications(response.notifications);
return response;
@ -2927,9 +2981,8 @@ export default class API {
const response = await request(`${host}/api/core/notifications/count/`);
// Update the store with the count
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore.getState().setUnreadCount(response.unread_count);
return response;
@ -2961,10 +3014,11 @@ export default class API {
);
// Update the store
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
useNotificationsStore.getState().dismissNotification(response.notification_key);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore
.getState()
.dismissNotification(response.notification_key);
return response;
} catch (e) {
@ -2983,9 +3037,8 @@ export default class API {
);
// Update the store
const { default: useNotificationsStore } = await import(
'./store/notifications'
);
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore.getState().dismissAllNotifications();
return response;

View file

@ -1 +1,19 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -15,4 +15,4 @@ class ErrorBoundary extends React.Component {
}
}
export default ErrorBoundary;
export default ErrorBoundary;

View file

@ -1,17 +1,40 @@
import { NumberInput, Select, Switch, TextInput } from '@mantine/core';
import {
NumberInput,
Select,
Switch,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import React from 'react';
export const Field = ({ field, value, onChange }) => {
const common = { label: field.label, description: field.help_text };
const description = field.help_text ?? field.description ?? field.value;
const common = { label: field.label, description };
const effective = value ?? field.default;
switch (field.type) {
case 'info':
return (
<div>
{field.label && (
<Text fw={600} size="sm">
{field.label}
</Text>
)}
{description && (
<Text size="sm" c="dimmed">
{description}
</Text>
)}
</div>
);
case 'boolean':
return (
<Switch
checked={!!effective}
onChange={(e) => onChange(field.id, e.currentTarget.checked)}
label={field.label}
description={field.help_text}
description={description}
/>
);
case 'number':
@ -19,6 +42,7 @@ export const Field = ({ field, value, onChange }) => {
<NumberInput
value={value ?? field.default ?? 0}
onChange={(v) => onChange(field.id, v)}
placeholder={field.placeholder}
{...common}
/>
);
@ -31,6 +55,16 @@ export const Field = ({ field, value, onChange }) => {
label: o.label,
}))}
onChange={(v) => onChange(field.id, v)}
placeholder={field.placeholder}
{...common}
/>
);
case 'text':
return (
<Textarea
value={value ?? field.default ?? ''}
onChange={(e) => onChange(field.id, e.currentTarget.value)}
placeholder={field.placeholder}
{...common}
/>
);
@ -40,8 +74,10 @@ export const Field = ({ field, value, onChange }) => {
<TextInput
value={value ?? field.default ?? ''}
onChange={(e) => onChange(field.id, e.currentTarget.value)}
type={field.input_type === 'password' ? 'password' : 'text'}
placeholder={field.placeholder}
{...common}
/>
);
}
};
};

View file

@ -1,13 +1,13 @@
import React from "react";
import React from 'react';
import {
CHANNEL_WIDTH,
EXPANDED_PROGRAM_HEIGHT,
HOUR_WIDTH,
PROGRAM_HEIGHT,
} from '../pages/guideUtils.js';
import {Box, Flex, Text} from "@mantine/core";
import {Play} from "lucide-react";
import logo from "../images/logo.png";
import { Box, Flex, Text } from '@mantine/core';
import { Play } from 'lucide-react';
import logo from '../images/logo.png';
const GuideRow = React.memo(({ index, style, data }) => {
const {
@ -36,31 +36,33 @@ const GuideRow = React.memo(({ index, style, data }) => {
: PROGRAM_HEIGHT);
const PlaceholderProgram = () => {
return <>
{Array.from({length: Math.ceil(24 / 2)}).map(
(_, placeholderIndex) => (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
alignItems: 'center',
justifyContent: 'center',
}}
pos='absolute'
left={placeholderIndex * (HOUR_WIDTH * 2)}
top={0}
w={HOUR_WIDTH * 2}
h={rowHeight - 4}
bd={'1px dashed #2D3748'}
bdrs={4}
display={'flex'}
c='#4A5568'
>
<Text size="sm">No program data</Text>
</Box>
)
)}
</>;
}
return (
<>
{Array.from({ length: Math.ceil(24 / 2) }).map(
(_, placeholderIndex) => (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
alignItems: 'center',
justifyContent: 'center',
}}
pos="absolute"
left={placeholderIndex * (HOUR_WIDTH * 2)}
top={0}
w={HOUR_WIDTH * 2}
h={rowHeight - 4}
bd={'1px dashed #2D3748'}
bdrs={4}
display={'flex'}
c="#4A5568"
>
<Text size="sm">No program data</Text>
</Box>
)
)}
</>
);
};
return (
<div
@ -75,7 +77,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
}}
display={'flex'}
h={'100%'}
pos='relative'
pos="relative"
>
<Box
className="channel-logo"
@ -96,7 +98,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
display={'flex'}
left={0}
h={'100%'}
pos='relative'
pos="relative"
onClick={(event) => handleLogoClick(channel, event)}
onMouseEnter={() => setHoveredChannelId(channel.id)}
onMouseLeave={() => setHoveredChannelId(null)}
@ -110,7 +112,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
zIndex: 10,
animation: 'fadeIn 0.2s',
}}
pos='absolute'
pos="absolute"
top={0}
left={0}
right={0}
@ -133,7 +135,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
w={'100%'}
h={'100%'}
p={'4px'}
pos='relative'
pos="relative"
>
<Box
style={{
@ -167,7 +169,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
alignItems: 'center',
justifyContent: 'center',
}}
pos='absolute'
pos="absolute"
bottom={4}
left={'50%'}
p={'2px 8px'}
@ -188,7 +190,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
transition: 'height 0.2s ease',
}}
flex={1}
pos='relative'
pos="relative"
h={'100%'}
pl={0}
>
@ -196,11 +198,13 @@ const GuideRow = React.memo(({ index, style, data }) => {
channelPrograms.map((program) =>
renderProgram(program, undefined, channel)
)
) : <PlaceholderProgram />}
) : (
<PlaceholderProgram />
)}
</Box>
</Box>
</div>
);
});
export default GuideRow;
export default GuideRow;

View file

@ -3,103 +3,102 @@ import { Box, Text } from '@mantine/core';
import { format } from '../utils/dateTimeUtils.js';
import { HOUR_WIDTH } from '../pages/guideUtils.js';
const HourBlock = React.memo(({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => {
const { time, isNewDay } = hourData;
const HourBlock = React.memo(
({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => {
const { time, isNewDay } = hourData;
return (
<Box
key={format(time)}
style={{
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
}}
w={HOUR_WIDTH}
h={'40px'}
pos='relative'
c='#a0aec0'
onClick={(e) => handleTimeClick(time, e)}
>
<Text
size="sm"
style={{ transform: 'none' }}
pos='absolute'
top={8}
left={4}
bdrs={2}
lh={1.2}
ta='left'
return (
<Box
key={format(time)}
style={{
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
}}
w={HOUR_WIDTH}
h={'40px'}
pos="relative"
c="#a0aec0"
onClick={(e) => handleTimeClick(time, e)}
>
<Text
span
size="xs"
display={'block'}
opacity={0.7}
fw={isNewDay ? 600 : 400}
c={isNewDay ? '#3BA882' : undefined}
size="sm"
style={{ transform: 'none' }}
pos="absolute"
top={8}
left={4}
bdrs={2}
lh={1.2}
ta="left"
>
{formatDayLabel(time)}
<Text
span
size="xs"
display={'block'}
opacity={0.7}
fw={isNewDay ? 600 : 400}
c={isNewDay ? '#3BA882' : undefined}
>
{formatDayLabel(time)}
</Text>
{format(time, timeFormat)}
<Text span size="xs" ml={1} opacity={0.7} />
</Text>
{format(time, timeFormat)}
<Text span size="xs" ml={1} opacity={0.7} />
</Text>
<Box
style={{
backgroundColor: '#27272A',
zIndex: 10,
}}
pos='absolute'
left={0}
top={0}
bottom={0}
w={'1px'}
/>
<Box
style={{
backgroundColor: '#27272A',
zIndex: 10,
}}
pos="absolute"
left={0}
top={0}
bottom={0}
w={'1px'}
/>
<Box
style={{ justifyContent: 'space-between' }}
pos='absolute'
bottom={0}
w={'100%'}
display={'flex'}
p={'0 1px'}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
style={{ backgroundColor: '#718096' }}
w={'1px'}
h={'8px'}
pos='absolute'
bottom={0}
left={`${(minute / 60) * 100}%`}
<Box
style={{ justifyContent: 'space-between' }}
pos="absolute"
bottom={0}
w={'100%'}
display={'flex'}
p={'0 1px'}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
style={{ backgroundColor: '#718096' }}
w={'1px'}
h={'8px'}
pos="absolute"
bottom={0}
left={`${(minute / 60) * 100}%`}
/>
))}
</Box>
</Box>
);
}
);
const HourTimeline = React.memo(
({ hourTimeline, timeFormat, formatDayLabel, handleTimeClick }) => {
return (
<>
{hourTimeline.map((hourData) => (
<HourBlock
key={format(hourData.time)}
hourData={hourData}
timeFormat={timeFormat}
formatDayLabel={formatDayLabel}
handleTimeClick={handleTimeClick}
/>
))}
</Box>
</Box>
);
});
const HourTimeline = React.memo(({
hourTimeline,
timeFormat,
formatDayLabel,
handleTimeClick
}) => {
return (
<>
{hourTimeline.map((hourData) => (
<HourBlock
key={format(hourData.time)}
hourData={hourData}
timeFormat={timeFormat}
formatDayLabel={formatDayLabel}
handleTimeClick={handleTimeClick}
/>
))}
</>
);
});
</>
);
}
);
export default HourTimeline;

View file

@ -1,4 +1,4 @@
import { Text, } from '@mantine/core';
import { Text } from '@mantine/core';
// Short preview that triggers the details modal when clicked
const RecordingSynopsis = ({ description, onOpen }) => {
@ -23,4 +23,4 @@ const RecordingSynopsis = ({ description, onOpen }) => {
);
};
export default RecordingSynopsis;
export default RecordingSynopsis;

View file

@ -3,6 +3,9 @@ import { showNotification } from '../../utils/notificationUtils.js';
import { Field } from '../Field.jsx';
import {
ActionIcon,
Anchor,
Box,
Avatar,
Button,
Card,
Divider,
@ -10,8 +13,9 @@ import {
Stack,
Switch,
Text,
UnstyledButton,
} from '@mantine/core';
import { Trash2 } from 'lucide-react';
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
const PluginFieldList = ({ plugin, settings, updateField }) => {
@ -25,7 +29,12 @@ const PluginFieldList = ({ plugin, settings, updateField }) => {
));
};
const PluginActionList = ({ plugin, enabled, running, handlePluginRun }) => {
const PluginActionList = ({
plugin,
enabled,
runningActionId,
handlePluginRun,
}) => {
return plugin.actions.map((action) => (
<Group key={action.id} justify="space-between">
<div>
@ -37,12 +46,16 @@ const PluginActionList = ({ plugin, enabled, running, handlePluginRun }) => {
)}
</div>
<Button
loading={running}
disabled={!enabled}
loading={runningActionId === action.id}
disabled={!enabled || runningActionId === action.id}
onClick={() => handlePluginRun(action)}
size="xs"
variant={action.button_variant || 'filled'}
color={action.button_color}
>
{running ? 'Running…' : 'Run'}
{runningActionId === action.id
? 'Running…'
: action.button_label || 'Run'}
</Button>
</Group>
));
@ -81,18 +94,24 @@ const PluginCard = ({
}) => {
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [runningActionId, setRunningActionId] = useState(null);
const [enabled, setEnabled] = useState(!!plugin.enabled);
const [lastResult, setLastResult] = useState(null);
const [expanded, setExpanded] = useState(!!plugin.enabled);
// Keep local enabled state in sync with props (e.g., after import + enable)
React.useEffect(() => {
setEnabled(!!plugin.enabled);
}, [plugin.enabled]);
React.useEffect(() => {
if (!plugin.enabled) {
setExpanded(false);
}
}, [plugin.enabled]);
// Sync settings if plugin changes identity
React.useEffect(() => {
setSettings(plugin.settings || {});
}, [plugin.key]);
}, [plugin.key, plugin.settings]);
const updateField = (id, val) => {
setSettings((prev) => ({ ...prev, [id]: val }));
@ -101,11 +120,25 @@ const PluginCard = ({
const save = async () => {
setSaving(true);
try {
await onSaveSettings(plugin.key, settings);
const result = await onSaveSettings(plugin.key, settings);
if (result) {
showNotification({
title: 'Saved',
message: `${plugin.name} settings updated`,
color: 'green',
});
} else {
showNotification({
title: `${plugin.name} error`,
message: 'Failed to update settings',
color: 'red',
});
}
} catch (e) {
showNotification({
title: 'Saved',
message: `${plugin.name} settings updated`,
color: 'green',
title: `${plugin.name} error`,
message: e?.message || 'Failed to update settings',
color: 'red',
});
} finally {
setSaving(false);
@ -125,17 +158,21 @@ const PluginCard = ({
return;
}
}
const previous = enabled;
setEnabled(next);
const resp = await onToggleEnabled(plugin.key, next);
if (next && resp?.ever_enabled) {
plugin.ever_enabled = true;
try {
const resp = await onToggleEnabled(plugin.key, next);
if (!resp?.success) {
setEnabled(previous);
return;
}
} catch (e) {
setEnabled(previous);
}
};
};
const handlePluginRun = async (a) => {
setRunning(true);
setLastResult(null);
try {
// Determine if confirmation is required from action metadata or fallback field
const { requireConfirm, confirmTitle, confirmMessage } =
@ -150,6 +187,9 @@ const PluginCard = ({
}
}
setRunningActionId(a.id);
setLastResult(null);
// Save settings before running to ensure backend uses latest values
try {
await onSaveSettings(plugin.key, settings);
@ -175,25 +215,77 @@ const PluginCard = ({
});
}
} finally {
setRunning(false);
setRunningActionId(null);
}
};
const toggleExpanded = () => {
setExpanded((prev) => !prev);
};
return (
<Card
shadow="sm"
radius="md"
withBorder
opacity={!missing && enabled ? 1 : 0.6}
style={{ opacity: !missing && enabled ? 1 : 0.6 }}
>
<Group justify="space-between" mb="xs" align="center">
<div>
<Text fw={600}>{plugin.name}</Text>
<Text size="sm" c="dimmed">
{plugin.description}
</Text>
</div>
<Group gap="xs" align="center">
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<ActionIcon
variant="subtle"
size="sm"
onClick={toggleExpanded}
title={expanded ? 'Collapse settings' : 'Expand settings'}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</ActionIcon>
{plugin.logo_url && (
<Avatar
src={plugin.logo_url}
radius="sm"
size={44}
alt={`${plugin.name} logo`}
/>
)}
<UnstyledButton
onClick={toggleExpanded}
style={{ minWidth: 0, flex: 1, textAlign: 'left' }}
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fw={600}>{plugin.name}</Text>
<Text size="sm" c="dimmed">
{plugin.description}
</Text>
{(plugin.author || plugin.help_url) && (
<Group gap="xs" mt={2}>
{plugin.author && (
<Text size="xs" c="dimmed">
By {plugin.author}
</Text>
)}
{plugin.help_url && (
<Anchor
href={plugin.help_url}
target="_blank"
rel="noreferrer"
size="xs"
onClick={(e) => e.stopPropagation()}
>
Docs
</Anchor>
)}
</Group>
)}
</Box>
</UnstyledButton>
</Group>
<Group gap="xs" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
<ActionIcon
variant="subtle"
color="red"
@ -216,43 +308,61 @@ const PluginCard = ({
</Group>
</Group>
{missing && (
<Text size="sm" c="red">
Missing plugin files. Re-import or delete this entry.
{(missing || plugin.legacy) && (
<Text size="sm" c={missing ? 'red' : 'yellow'}>
{missing
? 'Missing plugin files. Re-import or delete this entry.'
: 'Please update or ask the developer to add plugin.json.'}
</Text>
)}
{!missing && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
settings={settings}
updateField={updateField}
/>
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">
Save Settings
</Button>
</Group>
</Stack>
)}
{!missing && plugin.actions && plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
{expanded &&
!missing &&
enabled &&
plugin.fields &&
plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
enabled={enabled}
running={running}
handlePluginRun={handlePluginRun}
settings={settings}
updateField={updateField}
/>
<PluginActionStatus running={running} lastResult={lastResult} />
<Group>
<Button
loading={saving}
onClick={save}
variant="default"
size="xs"
>
Save Settings
</Button>
</Group>
</Stack>
</>
)}
)}
{expanded &&
!missing &&
enabled &&
plugin.actions &&
plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
plugin={plugin}
enabled={enabled}
runningActionId={runningActionId}
handlePluginRun={handlePluginRun}
/>
<PluginActionStatus
running={!!runningActionId}
lastResult={lastResult}
/>
</Stack>
</>
)}
</Card>
);
};
export default PluginCard;
export default PluginCard;

View file

@ -8,8 +8,8 @@ import {
Stack,
Text,
} from '@mantine/core';
import {Calendar, Play, Star} from "lucide-react";
import React from "react";
import { Calendar, Play, Star } from 'lucide-react';
import React from 'react';
const SeriesCard = ({ series, onClick }) => {
return (
@ -82,4 +82,4 @@ const SeriesCard = ({ series, onClick }) => {
);
};
export default SeriesCard;
export default SeriesCard;

View file

@ -140,4 +140,4 @@ const VODCard = ({ vod, onClick }) => {
);
};
export default VODCard;
export default VODCard;

View file

@ -74,23 +74,33 @@ export default function ProgramRecordingModal({
Just this one
</Button>
<Button variant="light" onClick={() => {
onRecordSeriesAll();
onClose();
}}>
<Button
variant="light"
onClick={() => {
onRecordSeriesAll();
onClose();
}}
>
Every episode
</Button>
<Button variant="light" onClick={() => {
onRecordSeriesNew();
onClose();
}}>
<Button
variant="light"
onClick={() => {
onRecordSeriesNew();
onClose();
}}
>
New episodes only
</Button>
{recording && (
<>
<Button color="orange" variant="light" onClick={handleRemoveRecording}>
<Button
color="orange"
variant="light"
onClick={handleRemoveRecording}
>
Remove this recording
</Button>
<Button color="red" variant="light" onClick={handleRemoveSeries}>

View file

@ -44,7 +44,11 @@ const toIsoIfDate = (value) => {
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);
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;
}
@ -77,7 +81,12 @@ const timeChange = (setter) => (valOrEvent) => {
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
};
const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => {
const RecordingModal = ({
recording = null,
channel = null,
isOpen,
onClose,
}) => {
const channels = useChannelsStore((s) => s.channels);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
@ -93,9 +102,17 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
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,
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'),
@ -126,13 +143,22 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
},
validate: {
channel_id: isNotEmpty('Select a channel'),
days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'),
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 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) {
if (
start.isValid() &&
end.isValid() &&
end.diff(start, 'minute') === 0
) {
return 'End time must differ from start time';
}
return null;
@ -192,7 +218,10 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
return aNum - bNum;
});
return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` }));
return list.map((item) => ({
value: `${item.id}`,
label: item.name || `Channel ${item.id}`,
}));
}, [channels]);
const resetForms = () => {
@ -287,7 +316,8 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
icon={<CircleAlert />}
style={{ paddingBottom: 5, marginBottom: 12 }}
>
Recordings may fail if active streams or overlapping recordings use up all available tuners.
Recordings may fail if active streams or overlapping recordings use up
all available tuners.
</Alert>
<Stack gap="md">
@ -330,14 +360,24 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
key={singleForm.key('start_time')}
label="Start"
valueFormat="MMM D, YYYY h:mm A"
timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }}
timeInputProps={{
format: '12',
withSeconds: false,
amLabel: 'AM',
pmLabel: 'PM',
}}
/>
<DateTimePicker
{...singleForm.getInputProps('end_time')}
key={singleForm.key('end_time')}
label="End"
valueFormat="MMM D, YYYY h:mm A"
timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }}
timeInputProps={{
format: '12',
withSeconds: false,
amLabel: 'AM',
pmLabel: 'PM',
}}
/>
</>
) : (
@ -364,14 +404,19 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
label="Start date"
value={recurringForm.values.start_date}
onChange={(value) =>
recurringForm.setFieldValue('start_date', value || new Date())
recurringForm.setFieldValue(
'start_date',
value || new Date()
)
}
valueFormat="MMM D, YYYY"
/>
<DatePickerInput
label="End date"
value={recurringForm.values.end_date}
onChange={(value) => recurringForm.setFieldValue('end_date', value)}
onChange={(value) =>
recurringForm.setFieldValue('end_date', value)
}
valueFormat="MMM D, YYYY"
minDate={recurringForm.values.start_date || undefined}
/>
@ -382,11 +427,14 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
label="Start time"
value={recurringForm.values.start_time}
onChange={timeChange((val) =>
recurringForm.setFieldValue('start_time', toTimeString(val))
recurringForm.setFieldValue(
'start_time',
toTimeString(val)
)
)}
onBlur={() => recurringForm.validateField('start_time')}
withSeconds={false}
format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
inputMode="numeric"
amLabel="AM"
pmLabel="PM"

View file

@ -2,14 +2,17 @@ import React from 'react';
import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core';
import useChannelsStore from '../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js';
import {
evaluateSeriesRulesByTvgId,
fetchRules,
} from '../../pages/guideUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
export default function SeriesRecordingModal({
opened,
onClose,
rules,
onRulesUpdate
opened,
onClose,
rules,
onRulesUpdate,
}) {
const handleEvaluateNow = async (r) => {
await evaluateSeriesRulesByTvgId(r.tvg_id);
@ -56,35 +59,36 @@ export default function SeriesRecordingModal({
No series rules configured
</Text>
)}
{rules && rules.map((r) => (
<Flex
key={`${r.tvg_id}-${r.mode}`}
justify="space-between"
align="center"
>
<Text size="sm">
{r.title || r.tvg_id} {' '}
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
onClick={() => handleEvaluateNow(r)}
>
Evaluate Now
</Button>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRemoveSeries(r)}
>
Remove this series (scheduled)
</Button>
</Group>
</Flex>
))}
{rules &&
rules.map((r) => (
<Flex
key={`${r.tvg_id}-${r.mode}`}
justify="space-between"
align="center"
>
<Text size="sm">
{r.title || r.tvg_id} {' '}
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
onClick={() => handleEvaluateNow(r)}
>
Evaluate Now
</Button>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRemoveSeries(r)}
>
Remove this series (scheduled)
</Button>
</Group>
</Flex>
))}
</Stack>
</Modal>
);

View file

@ -22,10 +22,14 @@ const getEpgSettingsFromStore = (settings) => {
const epgSettings = settings?.['epg_settings']?.value;
return {
epg_match_mode: epgSettings?.epg_match_mode || 'default',
epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes)
epg_match_ignore_prefixes: Array.isArray(
epgSettings?.epg_match_ignore_prefixes
)
? epgSettings.epg_match_ignore_prefixes
: [],
epg_match_ignore_suffixes: Array.isArray(epgSettings?.epg_match_ignore_suffixes)
epg_match_ignore_suffixes: Array.isArray(
epgSettings?.epg_match_ignore_suffixes
)
? epgSettings.epg_match_ignore_suffixes
: [],
epg_match_ignore_custom: Array.isArray(epgSettings?.epg_match_ignore_custom)
@ -34,11 +38,7 @@ const getEpgSettingsFromStore = (settings) => {
};
};
const EPGMatchModal = ({
opened,
onClose,
selectedChannelIds = [],
}) => {
const EPGMatchModal = ({ opened, onClose, selectedChannelIds = [] }) => {
const settings = useSettingsStore((s) => s.settings);
const [loading, setLoading] = useState(false);
@ -107,9 +107,10 @@ const EPGMatchModal = ({
}
};
const scopeText = selectedChannelIds.length > 0
? `${selectedChannelIds.length} selected channel(s)`
: 'all channels without EPG';
const scopeText =
selectedChannelIds.length > 0
? `${selectedChannelIds.length} selected channel(s)`
: 'all channels without EPG';
return (
<Modal
@ -191,8 +192,8 @@ const EPGMatchModal = ({
/>
<Text size="xs" c="dimmed">
Channel display names are never modified. These settings only affect
the matching algorithm.
Channel display names are never modified. These settings only
affect the matching algorithm.
</Text>
</>
)}

View file

@ -43,7 +43,14 @@ vi.mock('../../../store/settings', () => ({
vi.mock('@mantine/core', () => {
const React = require('react');
const RadioComponent = ({ label, value, checked, description, groupValue, groupOnChange }) => {
const RadioComponent = ({
label,
value,
checked,
description,
groupValue,
groupOnChange,
}) => {
const isChecked = checked !== undefined ? checked : groupValue === value;
const handleChange = groupOnChange || (() => {});
@ -67,17 +74,26 @@ vi.mock('@mantine/core', () => {
const enhancedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// If it's a Stack or other container, recursively enhance its children
if (child.type?.name === 'Stack' || child.props['data-testid'] === 'stack') {
if (
child.type?.name === 'Stack' ||
child.props['data-testid'] === 'stack'
) {
return React.cloneElement(child, {
children: React.Children.map(child.props.children, (nestedChild) => {
if (React.isValidElement(nestedChild) && nestedChild.type === RadioComponent) {
return React.cloneElement(nestedChild, {
groupValue: value,
groupOnChange: onChange,
});
children: React.Children.map(
child.props.children,
(nestedChild) => {
if (
React.isValidElement(nestedChild) &&
nestedChild.type === RadioComponent
) {
return React.cloneElement(nestedChild, {
groupValue: value,
groupOnChange: onChange,
});
}
return nestedChild;
}
return nestedChild;
}),
),
});
}
// If it's a Radio component, inject props directly
@ -157,7 +173,9 @@ describe('EPGMatchModal', () => {
it('should not show advanced fields in default mode', () => {
render(<EPGMatchModal {...defaultProps} />);
expect(screen.queryByLabelText('Ignore Prefixes')).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Ignore Prefixes')
).not.toBeInTheDocument();
});
it('should show advanced fields when advanced mode is selected', async () => {
@ -169,7 +187,9 @@ describe('EPGMatchModal', () => {
await waitFor(() => {
expect(screen.getByLabelText('Ignore Prefixes')).toBeInTheDocument();
expect(screen.getByLabelText('Ignore Suffixes')).toBeInTheDocument();
expect(screen.getByLabelText('Ignore Custom Strings')).toBeInTheDocument();
expect(
screen.getByLabelText('Ignore Custom Strings')
).toBeInTheDocument();
});
});
});
@ -199,7 +219,9 @@ describe('EPGMatchModal', () => {
describe('Form Submission', () => {
it('should save mode and trigger auto-match', async () => {
SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
SettingsUtils.getChangedSettings.mockReturnValue({
epg_match_mode: 'default',
});
SettingsUtils.saveChangedSettings.mockResolvedValue();
API.matchEpg.mockResolvedValue();
@ -220,7 +242,9 @@ describe('EPGMatchModal', () => {
SettingsUtils.getChangedSettings.mockReturnValue({});
API.matchEpg.mockResolvedValue();
render(<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />);
render(
<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />
);
const submitButton = screen.getByText('Start Auto-Match');
fireEvent.click(submitButton);
@ -232,7 +256,9 @@ describe('EPGMatchModal', () => {
it('should handle save errors gracefully', async () => {
const error = new Error('Save failed');
SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
SettingsUtils.getChangedSettings.mockReturnValue({
epg_match_mode: 'default',
});
SettingsUtils.saveChangedSettings.mockRejectedValue(error);
render(<EPGMatchModal {...defaultProps} />);
@ -270,13 +296,23 @@ describe('EPGMatchModal', () => {
describe('UI Text', () => {
it('should show correct text for selected channels', () => {
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[1, 2, 3]} />);
expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument();
render(
<EPGMatchModal {...defaultProps} selectedChannelIds={[1, 2, 3]} />
);
expect(
screen.getByText(
/Match channels to EPG data for 3 selected channel\(s\)/
)
).toBeInTheDocument();
});
it('should show correct text for all channels', () => {
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[]} />);
expect(screen.getByText(/Match channels to EPG data for all channels without EPG/)).toBeInTheDocument();
expect(
screen.getByText(
/Match channels to EPG data for all channels without EPG/
)
).toBeInTheDocument();
});
});
});

View file

@ -13,21 +13,21 @@
/* Active state styles */
.navlink.navlink-active {
color: #FFFFFF;
color: #ffffff;
background-color: #245043;
border: 1px solid #3BA882;
border: 1px solid #3ba882;
}
/* Hover effect */
.navlink:hover {
background-color: #2A2F34; /* Gray hover effect when not active */
border: 1px solid #3D3D42;
background-color: #2a2f34; /* Gray hover effect when not active */
border: 1px solid #3d3d42;
}
/* Hover effect for active state */
.navlink.navlink-active:hover {
background-color: #3A3A40;
border: 1px solid #3BA882;
background-color: #3a3a40;
border: 1px solid #3ba882;
}
/* Collapse condition for justifyContent */

View file

@ -180,7 +180,6 @@ const ChannelTableHeader = ({
}
};
const assignChannels = async () => {
try {
// Call our custom API endpoint

View file

@ -92,7 +92,7 @@ html {
opacity: 0;
}
*:hover>.resizer {
*:hover > .resizer {
opacity: 1;
}
}
@ -104,7 +104,7 @@ html {
/* .table-striped .tbody .tr:nth-child(even), */
.table-striped .tbody .tr-even {
background-color: #27272A;
background-color: #27272a;
}
/* Style for rows with no streams */
@ -138,7 +138,7 @@ html {
/* Always allow text selection in editable elements */
.shift-key-active input,
.shift-key-active textarea,
.shift-key-active [contenteditable="true"],
.shift-key-active [contenteditable='true'],
.shift-key-active .table-input-header input {
user-select: text !important;
-webkit-user-select: text !important;
@ -169,7 +169,7 @@ html {
/* Always allow text selection in inputs even when shift is pressed */
.shift-key-active input,
.shift-key-active textarea,
.shift-key-active [contenteditable="true"],
.shift-key-active [contenteditable='true'],
.shift-key-active select,
.shift-key-active .mantine-Select-input,
.shift-key-active .mantine-MultiSelect-input,

View file

@ -326,22 +326,31 @@ export const REGION_CHOICES = [
export const VOD_TYPES = {
MOVIE: 'movie',
EPISODE: 'episode'
EPISODE: 'episode',
};
export const VOD_FILTERS = {
ALL: 'all',
MOVIES: 'movies',
SERIES: 'series'
SERIES: 'series',
};
export const VOD_SORT_OPTIONS = [
{ value: 'name', label: 'Name' },
{ value: 'year', label: 'Year' },
{ value: 'created_at', label: 'Date Added' },
{ value: 'rating', label: 'Rating' }
{ value: 'rating', label: 'Rating' },
];
export const CONTAINER_EXTENSIONS = [
'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'mpg'
'mp4',
'mkv',
'avi',
'mov',
'wmv',
'flv',
'webm',
'm4v',
'ts',
'mpg',
];

View file

@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
})
}),
};
})();
@ -32,7 +32,9 @@ describe('useLocalStorage', () => {
});
it('should initialize with default value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('defaultValue');
});
@ -40,7 +42,9 @@ describe('useLocalStorage', () => {
it('should initialize with value from localStorage if available', () => {
localStorageMock.setItem('testKey', JSON.stringify('storedValue'));
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('storedValue');
});
@ -52,14 +56,19 @@ describe('useLocalStorage', () => {
result.current[1]('updated');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated'));
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'testKey',
JSON.stringify('updated')
);
expect(result.current[0]).toBe('updated');
});
it('should handle complex objects', () => {
const complexObject = { name: 'test', count: 42, nested: { value: true } };
const { result } = renderHook(() => useLocalStorage('testKey', complexObject));
const { result } = renderHook(() =>
useLocalStorage('testKey', complexObject)
);
act(() => {
result.current[1]({ name: 'updated', count: 100 });
@ -73,7 +82,9 @@ describe('useLocalStorage', () => {
throw new Error('Read error');
});
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalledWith(
@ -102,7 +113,9 @@ describe('useLocalStorage', () => {
it('should handle invalid JSON in localStorage', () => {
localStorageMock.getItem.mockReturnValueOnce('invalid json{');
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
const { result } = renderHook(() =>
useLocalStorage('testKey', 'defaultValue')
);
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalled();

View file

@ -1,6 +1,10 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos';
import {
useLogoSelection,
useChannelLogoSelection,
useLogosById,
} from '../useSmartLogos';
import useLogosStore from '../../store/logos';
// Mock the logos store
@ -13,10 +17,12 @@ describe('useSmartLogos', () => {
});
it('should initialize with empty state', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogos: vi.fn(),
})
);
const { result } = renderHook(() => useLogoSelection());
@ -27,10 +33,12 @@ describe('useSmartLogos', () => {
it('should load logos when ensureLogosLoaded is called', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: mockFetchLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogos: mockFetchLogos,
})
);
const { result } = renderHook(() => useLogoSelection());
@ -43,10 +51,12 @@ describe('useSmartLogos', () => {
it('should not reload logos if already loaded', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' } },
fetchLogos: mockFetchLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: { logo1: { id: 'logo1' } },
fetchLogos: mockFetchLogos,
})
);
const { result } = renderHook(() => useLogoSelection());
@ -62,12 +72,18 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching logos', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: mockFetchLogos,
}));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockFetchLogos = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogos: mockFetchLogos,
})
);
const { result } = renderHook(() => useLogoSelection());
@ -84,10 +100,12 @@ describe('useSmartLogos', () => {
});
it('should indicate hasLogos when logos are present', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
fetchLogos: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
fetchLogos: vi.fn(),
})
);
const { result } = renderHook(() => useLogoSelection());
@ -101,12 +119,14 @@ describe('useSmartLogos', () => {
});
it('should initialize with channel logos state', () => {
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: vi.fn(),
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -117,12 +137,14 @@ describe('useSmartLogos', () => {
it('should load channel logos when ensureLogosLoaded is called', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -135,12 +157,14 @@ describe('useSmartLogos', () => {
it('should not reload if already loaded', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: { logo1: { id: 'logo1' } },
hasLoadedChannelLogos: true,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: { logo1: { id: 'logo1' } },
hasLoadedChannelLogos: true,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -153,12 +177,14 @@ describe('useSmartLogos', () => {
it('should not load if backgroundLoading is true', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: true,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: true,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -170,14 +196,20 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching channel logos', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockFetchChannelLogos = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) =>
selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
})
);
const { result } = renderHook(() => useChannelLogoSelection());
@ -200,10 +232,12 @@ describe('useSmartLogos', () => {
});
it('should initialize with empty logos', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: vi.fn(),
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: vi.fn(),
})
);
const { result } = renderHook(() => useLogosById([]));
@ -214,10 +248,12 @@ describe('useSmartLogos', () => {
it('should fetch missing logos by IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1', 'logo2']));
@ -228,10 +264,12 @@ describe('useSmartLogos', () => {
it('should not fetch logos that are already loaded', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' } },
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: { logo1: { id: 'logo1' } },
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1', 'logo2']));
@ -241,12 +279,18 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching logos by IDs', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockFetchLogosByIds = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1']));
@ -262,10 +306,12 @@ describe('useSmartLogos', () => {
it('should filter out null/undefined IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
renderHook(() => useLogosById(['logo1', null, undefined, 'logo2']));
@ -276,10 +322,12 @@ describe('useSmartLogos', () => {
it('should not refetch the same IDs multiple times', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
useLogosStore.mockImplementation((selector) =>
selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
})
);
const { rerender } = renderHook(() => useLogosById(['logo1']));

View file

@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
})
}),
};
})();
@ -50,7 +50,10 @@ describe('useTablePreferences', () => {
});
it('should initialize headerPinned from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
const { result } = renderHook(() => useTablePreferences());
@ -58,7 +61,10 @@ describe('useTablePreferences', () => {
});
it('should initialize tableSize from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ tableSize: 'compact' })
);
const { result } = renderHook(() => useTablePreferences());
@ -66,7 +72,10 @@ describe('useTablePreferences', () => {
});
it('should initialize both preferences from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })
);
const { result } = renderHook(() => useTablePreferences());
@ -83,7 +92,10 @@ describe('useTablePreferences', () => {
});
it('should prefer new localStorage location over old location', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ tableSize: 'comfortable' })
);
localStorageMock.setItem('table-size', JSON.stringify('compact'));
const { result } = renderHook(() => useTablePreferences());
@ -127,7 +139,10 @@ describe('useTablePreferences', () => {
});
it('should preserve existing preferences when updating headerPinned', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ tableSize: 'compact' })
);
const { result } = renderHook(() => useTablePreferences());
@ -205,7 +220,10 @@ describe('useTablePreferences', () => {
});
it('should preserve existing preferences when updating tableSize', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
const { result } = renderHook(() => useTablePreferences());
@ -320,7 +338,10 @@ describe('useTablePreferences', () => {
});
it('should not update if value is the same', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
localStorageMock.setItem(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
const { result } = renderHook(() => useTablePreferences());
const initialHeaderPinned = result.current.headerPinned;

View file

@ -7,9 +7,9 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #18181b;
@ -18,8 +18,8 @@ body {
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
/* Example scrollbars - optional, to match a dark theme. */
@ -28,7 +28,7 @@ code {
}
::-webkit-scrollbar-track {
background: #3B3C41;
background: #3b3c41;
}
::-webkit-scrollbar-thumb {
@ -45,7 +45,9 @@ code {
}
}
table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Table-td-detail-panel {
table.mrt-table
tr.mantine-Table-tr.mantine-Table-tr-detail-panel
td.mantine-Table-td-detail-panel {
width: 100% !important;
}
@ -71,7 +73,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
/* Create a short vertical bar */
.sash.sash-vertical::before {
content: "";
content: '';
position: absolute;
top: 50%;
left: 50%;
@ -103,7 +105,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
}
/* For elements that specifically need transforms (like draggable elements) */
[data-draggable="true"] {
[data-draggable='true'] {
transform: translate3d(0, 0, 0) !important;
}
@ -137,4 +139,4 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View file

@ -4,6 +4,6 @@ import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
<App />
</React.StrictMode>
);

View file

@ -2,7 +2,7 @@ import useUserAgentsStore from '../store/userAgents';
import M3UsTable from '../components/tables/M3UsTable';
import EPGsTable from '../components/tables/EPGsTable';
import { Box, Stack } from '@mantine/core';
import ErrorBoundary from '../components/ErrorBoundary'
import ErrorBoundary from '../components/ErrorBoundary';
const PageContent = () => {
const error = useUserAgentsStore((state) => state.error);
@ -28,14 +28,14 @@ const PageContent = () => {
</Box>
</Stack>
);
}
};
const M3UPage = () => {
return (
<ErrorBoundary>
<PageContent/>
<PageContent />
</ErrorBoundary>
);
}
};
export default M3UPage;

View file

@ -10,24 +10,23 @@ import {
Title,
useMantineTheme,
} from '@mantine/core';
import {
SquarePlus,
} from 'lucide-react';
import { SquarePlus } from 'lucide-react';
import useChannelsStore from '../store/channels';
import useSettingsStore from '../store/settings';
import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
import {
isAfter,
isBefore,
useTimeHelpers,
} from '../utils/dateTimeUtils.js';
const RecordingDetailsModal = lazy(() =>
import('../components/forms/RecordingDetailsModal'));
import { isAfter, isBefore, useTimeHelpers } from '../utils/dateTimeUtils.js';
const RecordingDetailsModal = lazy(
() => import('../components/forms/RecordingDetailsModal')
);
import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
import RecordingCard from '../components/cards/RecordingCard.jsx';
import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
import {
getPosterUrl,
getRecordingUrl,
getShowVideoUrl,
} from '../utils/cards/RecordingCardUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
@ -113,32 +112,35 @@ const DVRPage = () => {
const now = userNow();
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
if(isAfter(now, s) && isBefore(now, e)) {
if (isAfter(now, s) && isBefore(now, e)) {
// call into child RecordingCard behavior by constructing a URL like there
const channel = channels[rec.channel];
if (!channel) return;
const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode);
const url = getShowVideoUrl(
channel,
useSettingsStore.getState().environment.env_mode
);
useVideoStore.getState().showVideo(url, 'live');
}
}
};
const handleOnWatchRecording = () => {
const url = getRecordingUrl(
detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode);
if(!url) return;
detailsRecording.custom_properties,
useSettingsStore.getState().environment.env_mode
);
if (!url) return;
useVideoStore.getState().showVideo(url, 'vod', {
name:
detailsRecording.custom_properties?.program?.title ||
'Recording',
name: detailsRecording.custom_properties?.program?.title || 'Recording',
logo: {
url: getPosterUrl(
detailsRecording.custom_properties?.poster_logo_id,
undefined,
channels[detailsRecording.channel]?.logo?.cache_url
)
),
},
});
}
};
return (
<Box p={10}>
<Button
@ -170,11 +172,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
{<RecordingList
list={inProgress}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{
<RecordingList
list={inProgress}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>
}
{inProgress.length === 0 && (
<Text size="sm" c="dimmed">
Nothing recording right now.
@ -196,11 +200,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
{<RecordingList
list={upcoming}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{
<RecordingList
list={upcoming}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>
}
{upcoming.length === 0 && (
<Text size="sm" c="dimmed">
No upcoming recordings.
@ -222,11 +228,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
{<RecordingList
list={completed}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{
<RecordingList
list={completed}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>
}
{completed.length === 0 && (
<Text size="sm" c="dimmed">
No completed recordings yet.
@ -286,4 +294,4 @@ const DVRPage = () => {
);
};
export default DVRPage;
export default DVRPage;

View file

@ -7,12 +7,11 @@ import VODLogosTable from '../components/tables/VODLogosTable';
import { showNotification } from '../utils/notificationUtils.js';
const LogosPage = () => {
const logos = useLogosStore(s => s.logos);
const totalCount = useVODLogosStore(s => s.totalCount);
const logos = useLogosStore((s) => s.logos);
const totalCount = useVODLogosStore((s) => s.totalCount);
const [activeTab, setActiveTab] = useState('channel');
const logoCount = activeTab === 'channel'
? Object.keys(logos).length
: totalCount;
const logoCount =
activeTab === 'channel' ? Object.keys(logos).length : totalCount;
const loadChannelLogos = useCallback(async () => {
try {
@ -38,11 +37,7 @@ const LogosPage = () => {
return (
<Box>
{/* Header with title and tabs */}
<Box
style={{ justifyContent: 'center' }}
display={'flex'}
p={'10px 0'}
>
<Box style={{ justifyContent: 'center' }} display={'flex'} p={'10px 0'}>
<Flex
style={{
alignItems: 'center',
@ -58,7 +53,7 @@ const LogosPage = () => {
fz={'20px'}
fw={500}
lh={1}
c='white'
c="white"
mb={0}
lts={'-0.3px'}
>

View file

@ -22,19 +22,24 @@ import {
Text,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
import {
showNotification,
updateNotification,
} from '../utils/notificationUtils.js';
import { usePluginStore } from '../store/plugins.jsx';
import {
deletePluginByKey,
importPlugin,
reloadPlugins,
runPluginAction,
setPluginEnabled,
updatePluginSettings,
} from '../utils/pages/PluginsUtils.js';
import { RefreshCcw } from 'lucide-react';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const PluginCard = React.lazy(() =>
import('../components/cards/PluginCard.jsx'));
const PluginCard = React.lazy(
() => import('../components/cards/PluginCard.jsx')
);
const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
const plugins = usePluginStore((state) => state.plugins);
@ -52,11 +57,13 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
const resp = await setPluginEnabled(key, next);
if (resp?.success) {
usePluginStore.getState().updatePlugin(key, {
const updates = resp?.plugin || {
enabled: next,
ever_enabled: resp?.ever_enabled,
});
};
usePluginStore.getState().updatePlugin(key, updates);
}
return resp;
};
if (loading && plugins.length === 0) {
@ -65,7 +72,7 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
return (
<>
{plugins.length > 0 &&
{plugins.length > 0 && (
<SimpleGrid
cols={2}
spacing="md"
@ -88,13 +95,13 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
</Suspense>
</ErrorBoundary>
</SimpleGrid>
}
)}
{plugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
and reload.
No plugins found. Drop a plugin into <code>/data/plugins</code> and
reload.
</Text>
</Box>
)}
@ -120,7 +127,8 @@ export default function PluginsPage() {
resolve: null,
});
const handleReload = () => {
const handleReload = async () => {
await reloadPlugins();
usePluginStore.getState().invalidatePlugins();
};
@ -212,7 +220,8 @@ export default function PluginsPage() {
if (proceed) {
const resp = await setPluginEnabled(imported.key, true);
if (resp?.success) {
usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true });
const updates = resp?.plugin || { enabled: true, ever_enabled: true };
usePluginStore.getState().updatePlugin(imported.key, updates);
showNotification({
title: imported.name,
@ -250,12 +259,15 @@ export default function PluginsPage() {
};
};
const handleConfirm = useCallback((confirmed) => {
const resolver = confirmConfig.resolve;
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', resolve: null });
if (resolver) resolver(confirmed);
}, [confirmConfig.resolve]);
const handleConfirm = useCallback(
(confirmed) => {
const resolver = confirmConfig.resolve;
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', resolve: null });
if (resolver) resolver(confirmed);
},
[confirmConfig.resolve]
);
return (
<AppShellMain p={16}>

View file

@ -12,12 +12,12 @@ const PageContent = () => {
<UsersTable />
</Box>
);
}
};
const UsersPage = () => {
return (
<ErrorBoundary>
<PageContent/>
<PageContent />
</ErrorBoundary>
);
};

View file

@ -7,10 +7,10 @@ import ChannelsPage from '../Channels';
vi.mock('../../store/auth');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../components/tables/ChannelsTable', () => ({
default: () => <div data-testid="channels-table">ChannelsTable</div>
default: () => <div data-testid="channels-table">ChannelsTable</div>,
}));
vi.mock('../../components/tables/StreamsTable', () => ({
default: () => <div data-testid="streams-table">StreamsTable</div>
default: () => <div data-testid="streams-table">StreamsTable</div>,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,

View file

@ -5,10 +5,10 @@ import useUserAgentsStore from '../../store/userAgents';
vi.mock('../../store/userAgents');
vi.mock('../../components/tables/M3UsTable', () => ({
default: () => <div data-testid="m3us-table">M3UsTable</div>
default: () => <div data-testid="m3us-table">M3UsTable</div>,
}));
vi.mock('../../components/tables/EPGsTable', () => ({
default: () => <div data-testid="epgs-table">EPGsTable</div>
default: () => <div data-testid="epgs-table">EPGsTable</div>,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
@ -30,4 +30,4 @@ describe('ContentSourcesPage', () => {
expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
});
});
});

View file

@ -5,10 +5,10 @@ import useAuthStore from '../../store/auth';
vi.mock('../../store/auth');
vi.mock('../../components/forms/LoginForm', () => ({
default: () => <div data-testid="login-form">LoginForm</div>
default: () => <div data-testid="login-form">LoginForm</div>,
}));
vi.mock('../../components/forms/SuperuserForm', () => ({
default: () => <div data-testid="superuser-form">SuperuserForm</div>
default: () => <div data-testid="superuser-form">SuperuserForm</div>,
}));
vi.mock('@mantine/core', () => ({
Text: ({ children }) => <div>{children}</div>,
@ -18,7 +18,7 @@ describe('Login', () => {
it('renders SuperuserForm when superuser does not exist', async () => {
useAuthStore.mockReturnValue(false);
render(<Login/>);
render(<Login />);
await waitFor(() => {
expect(screen.getByTestId('superuser-form')).toBeInTheDocument();
@ -29,7 +29,7 @@ describe('Login', () => {
it('renders LoginForm when superuser exists', () => {
useAuthStore.mockReturnValue(true);
render(<Login/>);
render(<Login />);
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();

View file

@ -3,7 +3,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import LogosPage from '../Logos';
import useLogosStore from '../../store/logos';
import useVODLogosStore from '../../store/vodLogos';
import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
import {
showNotification,
updateNotification,
} from '../../utils/notificationUtils.js';
vi.mock('../../store/logos');
vi.mock('../../store/vodLogos');
@ -12,16 +15,21 @@ vi.mock('../../utils/notificationUtils.js', () => ({
updateNotification: vi.fn(),
}));
vi.mock('../../components/tables/LogosTable', () => ({
default: () => <div data-testid="logos-table">LogosTable</div>
default: () => <div data-testid="logos-table">LogosTable</div>,
}));
vi.mock('../../components/tables/VODLogosTable', () => ({
default: () => <div data-testid="vod-logos-table">VODLogosTable</div>
default: () => <div data-testid="vod-logos-table">VODLogosTable</div>,
}));
vi.mock('@mantine/core', () => {
const tabsComponent = ({ children, value, onChange }) =>
<div data-testid="tabs" data-value={value} onClick={() => onChange('vod')}>{children}</div>;
const tabsComponent = ({ children, value, onChange }) => (
<div data-testid="tabs" data-value={value} onClick={() => onChange('vod')}>
{children}
</div>
);
tabsComponent.List = ({ children }) => <div>{children}</div>;
tabsComponent.Tab = ({ children, value }) => <button data-value={value}>{children}</button>;
tabsComponent.Tab = ({ children, value }) => (
<button data-value={value}>{children}</button>
);
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
@ -113,7 +121,9 @@ describe('LogosPage', () => {
});
it('shows error notification when fetching logos fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const error = new Error('Failed to fetch');
mockFetchAllLogos.mockRejectedValue(error);

View file

@ -1,10 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import PluginsPage from '../Plugins';
import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
import {
showNotification,
updateNotification,
} from '../../utils/notificationUtils.js';
import {
deletePluginByKey,
importPlugin,
reloadPlugins,
setPluginEnabled,
updatePluginSettings,
} from '../../utils/pages/PluginsUtils';
@ -15,6 +19,7 @@ vi.mock('../../store/plugins');
vi.mock('../../utils/pages/PluginsUtils', () => ({
deletePluginByKey: vi.fn(),
importPlugin: vi.fn(),
reloadPlugins: vi.fn(),
setPluginEnabled: vi.fn(),
updatePluginSettings: vi.fn(),
runPluginAction: vi.fn(),
@ -45,7 +50,16 @@ vi.mock('@mantine/core', async () => {
{children}
</span>
),
Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
Button: ({
children,
onClick,
leftSection,
variant,
color,
loading,
disabled,
fullWidth,
}) => (
<button
onClick={onClick}
disabled={loading || disabled}
@ -71,13 +85,16 @@ vi.mock('@mantine/core', async () => {
),
Divider: ({ my }) => <hr data-my={my} />,
ActionIcon: ({ children, onClick, color, variant, title }) => (
<button onClick={onClick} data-color={color} data-variant={variant} title={title}>
<button
onClick={onClick}
data-color={color}
data-variant={variant}
title={title}
>
{children}
</button>
),
SimpleGrid: ({ children, cols }) => (
<div data-cols={cols}>{children}</div>
),
SimpleGrid: ({ children, cols }) => <div data-cols={cols}>{children}</div>,
Modal: ({ opened, onClose, title, children, size, centered }) =>
opened ? (
<div data-testid="modal" data-size={size} data-centered={centered}>
@ -107,7 +124,9 @@ vi.mock('@mantine/dropzone', () => ({
data-accept={accept}
data-max-size={maxSize}
onClick={() => {
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
onDrop([file]);
}}
>
@ -186,7 +205,11 @@ describe('PluginsPage', () => {
});
it('shows loader when loading and no plugins', () => {
const loadingState = { plugins: [], loading: true, fetchPlugins: vi.fn() };
const loadingState = {
plugins: [],
loading: true,
fetchPlugins: vi.fn(),
};
usePluginStore.mockImplementation((selector) => {
return selector ? selector(loadingState) : loadingState;
});
@ -217,7 +240,9 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Import Plugin');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Import Plugin'
);
});
it('shows dropzone and file input in import modal', () => {
@ -226,7 +251,9 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
expect(screen.getByTestId('dropzone')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Select plugin .zip')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Select plugin .zip')
).toBeInTheDocument();
});
it('closes import modal when close button is clicked', () => {
@ -243,7 +270,11 @@ describe('PluginsPage', () => {
it('handles file upload via dropzone', async () => {
importPlugin.mockResolvedValue({
success: true,
plugin: { key: 'new-plugin', name: 'New Plugin', description: 'New Description' },
plugin: {
key: 'new-plugin',
name: 'New Plugin',
description: 'New Description',
},
});
render(<PluginsPage />);
@ -253,9 +284,9 @@ describe('PluginsPage', () => {
fireEvent.click(dropzone);
await waitFor(() => {
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
expect(uploadButton).not.toBeDisabled();
});
});
@ -277,12 +308,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -303,12 +336,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -338,12 +373,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -370,12 +407,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -385,9 +424,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
@ -415,12 +454,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -430,13 +471,15 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
expect(screen.getByText('Enable third-party plugins?')).toBeInTheDocument();
expect(
screen.getByText('Enable third-party plugins?')
).toBeInTheDocument();
});
});
@ -458,12 +501,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -473,9 +518,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
@ -506,12 +551,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
const file = new File(['content'], 'plugin.zip', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
const uploadButton = screen.getAllByText('Upload').find(btn =>
btn.tagName === 'BUTTON'
);
const uploadButton = screen
.getAllByText('Upload')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@ -521,9 +568,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
const enableButton = screen.getAllByText('Enable').find(btn =>
btn.tagName === 'BUTTON'
);
const enableButton = screen
.getAllByText('Enable')
.find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
@ -554,6 +601,7 @@ describe('PluginsPage', () => {
fireEvent.click(reloadButton);
await waitFor(() => {
expect(reloadPlugins).toHaveBeenCalled();
expect(invalidatePlugins).toHaveBeenCalled();
});
});

View file

@ -5,7 +5,7 @@ import useAuthStore from '../../store/auth';
vi.mock('../../store/auth');
vi.mock('../../components/tables/UsersTable', () => ({
default: () => <div data-testid="users-table">UsersTable</div>
default: () => <div data-testid="users-table">UsersTable</div>,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,

View file

@ -16,7 +16,7 @@ vi.mock('../../components/SeriesModal', () => ({
<div data-testid="series-name">{series?.name}</div>
<button onClick={onClose}>Close</button>
</div>
) : null
) : null,
}));
vi.mock('../../components/VODModal', () => ({
default: ({ opened, vod, onClose }) =>
@ -25,26 +25,30 @@ vi.mock('../../components/VODModal', () => ({
<div data-testid="vod-name">{vod?.name}</div>
<button onClick={onClose}>Close</button>
</div>
) : null
) : null,
}));
vi.mock('../../components/cards/VODCard', () => ({
default: ({ vod, onClick }) => (
<div data-testid="vod-card" onClick={() => onClick(vod)}>
<div>{vod.name}</div>
</div>
)
),
}));
vi.mock('../../components/cards/SeriesCard', () => ({
default: ({ series, onClick }) => (
<div data-testid="series-card" onClick={() => onClick(series)}>
<div>{series.name}</div>
</div>
)
),
}));
vi.mock('@mantine/core', () => {
const gridComponent = ({ children, ...props }) => <div {...props}>{children}</div>;
gridComponent.Col = ({ children, ...props }) => <div {...props}>{children}</div>;
const gridComponent = ({ children, ...props }) => (
<div {...props}>{children}</div>
);
gridComponent.Col = ({ children, ...props }) => (
<div {...props}>{children}</div>
);
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
@ -97,7 +101,9 @@ vi.mock('@mantine/core', () => {
<button onClick={() => onChange(page - 1)} disabled={page === 1}>
Prev
</button>
<span>{page} of {total}</span>
<span>
{page} of {total}
</span>
<button onClick={() => onChange(page + 1)} disabled={page === total}>
Next
</button>
@ -208,9 +214,7 @@ describe('VODsPage', () => {
it('renders series cards for series', async () => {
const stateWithSeries = {
...defaultStoreState,
currentPageContent: [
{ id: 1, name: 'Series 1', contentType: 'series' },
],
currentPageContent: [{ id: 1, name: 'Series 1', contentType: 'series' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithSeries));
@ -224,9 +228,7 @@ describe('VODsPage', () => {
it('opens VOD modal when VOD card is clicked', async () => {
const stateWithMovies = {
...defaultStoreState,
currentPageContent: [
{ id: 1, name: 'Test Movie', contentType: 'movie' },
],
currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithMovies));
@ -262,9 +264,7 @@ describe('VODsPage', () => {
it('closes VOD modal when close button is clicked', async () => {
const stateWithMovies = {
...defaultStoreState,
currentPageContent: [
{ id: 1, name: 'Test Movie', contentType: 'movie' },
],
currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithMovies));
@ -326,9 +326,7 @@ describe('VODsPage', () => {
});
it('updates filters and resets page when category changes', async () => {
getCategoryOptions.mockReturnValue([
{ value: 'action', label: 'Action' },
]);
getCategoryOptions.mockReturnValue([{ value: 'action', label: 'Action' }]);
render(<VODsPage />);
@ -370,9 +368,7 @@ describe('VODsPage', () => {
totalCount: 25,
pageSize: 12,
};
useVODStore.mockImplementation((selector) =>
selector(stateWithPagination)
);
useVODStore.mockImplementation((selector) => selector(stateWithPagination));
render(<VODsPage />);
@ -405,9 +401,7 @@ describe('VODsPage', () => {
pageSize: 12,
currentPage: 1,
};
useVODStore.mockImplementation((selector) =>
selector(stateWithPagination)
);
useVODStore.mockImplementation((selector) => selector(stateWithPagination));
render(<VODsPage />);

View file

@ -63,9 +63,7 @@ describe('guideUtils', () => {
});
it('should use tvg_id from EPG data for regular sources', () => {
const channels = [
{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
};
@ -79,9 +77,7 @@ describe('guideUtils', () => {
});
it('should use channel UUID for dummy EPG sources', () => {
const channels = [
{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
};
@ -113,9 +109,7 @@ describe('guideUtils', () => {
});
it('should fall back to UUID when tvg_id is null', () => {
const channels = [
{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: null, epg_source: 'source-1' },
};
@ -160,7 +154,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)).toHaveLength(1);
expect(result.get(1)[0]).toMatchObject({
@ -185,7 +182,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0]).toHaveProperty('startMs');
expect(result.get(1)[0]).toHaveProperty('endMs');
@ -209,7 +209,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0].isLive).toBe(true);
expect(result.get(1)[0].isPast).toBe(false);
@ -233,7 +236,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0].isLive).toBe(false);
expect(result.get(1)[0].isPast).toBe(true);
@ -252,7 +258,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)).toHaveLength(1);
expect(result.get(2)).toHaveLength(1);
@ -282,7 +291,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
const result = guideUtils.mapProgramsByChannel(
programs,
channelIdByTvgId
);
expect(result.get(1)[0].id).toBe(1);
expect(result.get(1)[1].id).toBe(2);
@ -300,9 +312,16 @@ describe('guideUtils', () => {
const channels = [{ id: 1 }, { id: 2 }];
const programsByChannelId = new Map();
const result = guideUtils.computeRowHeights(channels, programsByChannelId, null);
const result = guideUtils.computeRowHeights(
channels,
programsByChannelId,
null
);
expect(result).toEqual([guideUtils.PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
expect(result).toEqual([
guideUtils.PROGRAM_HEIGHT,
guideUtils.PROGRAM_HEIGHT,
]);
});
it('should return expanded height for channel with expanded program', () => {
@ -312,9 +331,16 @@ describe('guideUtils', () => {
[2, [{ id: 'program-2' }]],
]);
const result = guideUtils.computeRowHeights(channels, programsByChannelId, 'program-1');
const result = guideUtils.computeRowHeights(
channels,
programsByChannelId,
'program-1'
);
expect(result).toEqual([guideUtils.EXPANDED_PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
expect(result).toEqual([
guideUtils.EXPANDED_PROGRAM_HEIGHT,
guideUtils.PROGRAM_HEIGHT,
]);
});
it('should use custom heights when provided', () => {
@ -393,7 +419,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'Channel 2' },
];
const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {});
const result = guideUtils.filterGuideChannels(
channels,
'',
'all',
'all',
{}
);
expect(result).toHaveLength(2);
});
@ -404,7 +436,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'CNN' },
];
const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {});
const result = guideUtils.filterGuideChannels(
channels,
'espn',
'all',
'all',
{}
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('ESPN');
@ -416,7 +454,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'Channel 2', channel_group_id: 2 },
];
const result = guideUtils.filterGuideChannels(channels, '', '1', 'all', {});
const result = guideUtils.filterGuideChannels(
channels,
'',
'1',
'all',
{}
);
expect(result).toHaveLength(1);
expect(result[0].channel_group_id).toBe(1);
@ -436,7 +480,13 @@ describe('guideUtils', () => {
},
};
const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
const result = guideUtils.filterGuideChannels(
channels,
'',
'all',
'profile1',
profiles
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@ -453,7 +503,13 @@ describe('guideUtils', () => {
},
};
const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
const result = guideUtils.filterGuideChannels(
channels,
'',
'all',
'profile1',
profiles
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@ -474,7 +530,13 @@ describe('guideUtils', () => {
},
};
const result = guideUtils.filterGuideChannels(channels, 'espn', '1', 'profile1', profiles);
const result = guideUtils.filterGuideChannels(
channels,
'espn',
'1',
'profile1',
profiles
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@ -491,8 +553,12 @@ describe('guideUtils', () => {
});
it('should return earliest program start', () => {
dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
dateTimeUtils.initializeTime.mockImplementation((time) =>
dayjs.utc(time)
);
dateTimeUtils.isBefore.mockImplementation((a, b) =>
dayjs(a).isBefore(dayjs(b))
);
const programs = [
{ start_time: '2024-01-15T12:00:00Z' },
@ -501,7 +567,10 @@ describe('guideUtils', () => {
];
const defaultStart = dayjs.utc('2024-01-16T00:00:00Z');
const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart);
const result = guideUtils.calculateEarliestProgramStart(
programs,
defaultStart
);
expect(result.hour()).toBe(10);
});
@ -517,8 +586,12 @@ describe('guideUtils', () => {
});
it('should return latest program end', () => {
dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
dateTimeUtils.isAfter.mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b)));
dateTimeUtils.initializeTime.mockImplementation((time) =>
dayjs.utc(time)
);
dateTimeUtils.isAfter.mockImplementation((a, b) =>
dayjs(a).isAfter(dayjs(b))
);
const programs = [
{ end_time: '2024-01-15T12:00:00Z' },
@ -638,8 +711,12 @@ describe('guideUtils', () => {
it('should return "Today" for today', () => {
const today = dayjs();
dateTimeUtils.getNow.mockReturnValue(today);
dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.startOfDay.mockImplementation((time) =>
dayjs(time).startOf('day')
);
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.isSame.mockReturnValueOnce(true);
const result = guideUtils.formatTime(today, 'MM/DD');
@ -651,8 +728,12 @@ describe('guideUtils', () => {
const today = dayjs();
const tomorrow = today.add(1, 'day');
dateTimeUtils.getNow.mockReturnValue(today);
dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.startOfDay.mockImplementation((time) =>
dayjs(time).startOf('day')
);
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true);
const result = guideUtils.formatTime(tomorrow, 'MM/DD');
@ -664,8 +745,12 @@ describe('guideUtils', () => {
const today = dayjs();
const future = today.add(3, 'day');
dateTimeUtils.getNow.mockReturnValue(today);
dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.startOfDay.mockImplementation((time) =>
dayjs(time).startOf('day')
);
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.isSame.mockReturnValue(false);
dateTimeUtils.isBefore.mockReturnValue(true);
dateTimeUtils.format.mockReturnValue('Wednesday');
@ -679,8 +764,12 @@ describe('guideUtils', () => {
const today = dayjs();
const future = today.add(10, 'day');
dateTimeUtils.getNow.mockReturnValue(today);
dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.startOfDay.mockImplementation((time) =>
dayjs(time).startOf('day')
);
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.isSame.mockReturnValue(false);
dateTimeUtils.isBefore.mockReturnValue(false);
dateTimeUtils.format.mockReturnValue('01/25');
@ -695,13 +784,23 @@ describe('guideUtils', () => {
it('should generate hours between start and end', () => {
const start = dayjs('2024-01-15T10:00:00Z');
const end = dayjs('2024-01-15T13:00:00Z');
dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
dateTimeUtils.isBefore.mockImplementation((a, b) =>
dayjs(a).isBefore(dayjs(b))
);
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.startOfDay.mockImplementation((time) =>
dayjs(time).startOf('day')
);
dateTimeUtils.isSame.mockReturnValue(true);
const formatDayLabel = vi.fn((time) => 'Today');
const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
const result = guideUtils.calculateHourTimeline(
start,
end,
formatDayLabel
);
expect(result).toHaveLength(3);
expect(formatDayLabel).toHaveBeenCalledTimes(3);
@ -710,13 +809,25 @@ describe('guideUtils', () => {
it('should mark new day transitions', () => {
const start = dayjs('2024-01-15T23:00:00Z');
const end = dayjs('2024-01-16T02:00:00Z');
dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
dateTimeUtils.isSame.mockImplementation((a, b, unit) => dayjs(a).isSame(dayjs(b), unit));
dateTimeUtils.isBefore.mockImplementation((a, b) =>
dayjs(a).isBefore(dayjs(b))
);
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.startOfDay.mockImplementation((time) =>
dayjs(time).startOf('day')
);
dateTimeUtils.isSame.mockImplementation((a, b, unit) =>
dayjs(a).isSame(dayjs(b), unit)
);
const formatDayLabel = vi.fn((time) => 'Day');
const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
const result = guideUtils.calculateHourTimeline(
start,
end,
formatDayLabel
);
expect(result[0].isNewDay).toBe(true);
});
@ -791,7 +902,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map();
const channelById = new Map();
const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
const result = guideUtils.matchChannelByTvgId(
channelIdByTvgId,
channelById,
'tvg-1'
);
expect(result).toBeNull();
});
@ -801,7 +916,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
const channelById = new Map([[1, channel]]);
const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
const result = guideUtils.matchChannelByTvgId(
channelIdByTvgId,
channelById,
'tvg-1'
);
expect(result).toBe(channel);
});
@ -810,7 +929,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map([['tvg-1', [999]]]);
const channelById = new Map();
const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
const result = guideUtils.matchChannelByTvgId(
channelIdByTvgId,
channelById,
'tvg-1'
);
expect(result).toBeNull();
});
@ -933,7 +1056,9 @@ describe('guideUtils', () => {
start_time: '2024-01-15T10:30:00Z',
};
const start = '2024-01-15T10:00:00Z';
dateTimeUtils.convertToMs.mockImplementation((time) => dayjs(time).valueOf());
dateTimeUtils.convertToMs.mockImplementation((time) =>
dayjs(time).valueOf()
);
const result = guideUtils.calculateLeftScrollPosition(program, start);
@ -965,10 +1090,16 @@ describe('guideUtils', () => {
};
const clickedTime = dayjs('2024-01-15T10:00:00Z');
const start = dayjs('2024-01-15T09:00:00Z');
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.diff.mockReturnValue(60);
const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
const result = guideUtils.calculateScrollPositionByTimeClick(
event,
clickedTime,
start
);
expect(result).toBeGreaterThanOrEqual(0);
});
@ -982,7 +1113,9 @@ describe('guideUtils', () => {
};
const clickedTime = dayjs('2024-01-15T10:00:00Z');
const start = dayjs('2024-01-15T09:00:00Z');
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.diff.mockReturnValue(75);
guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
@ -999,12 +1132,22 @@ describe('guideUtils', () => {
};
const clickedTime = dayjs('2024-01-15T10:00:00Z');
const start = dayjs('2024-01-15T09:00:00Z');
dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
dateTimeUtils.add.mockImplementation((time, amount, unit) =>
dayjs(time).add(amount, unit)
);
dateTimeUtils.diff.mockReturnValue(120);
const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
const result = guideUtils.calculateScrollPositionByTimeClick(
event,
clickedTime,
start
);
expect(dateTimeUtils.add).toHaveBeenCalledWith(expect.anything(), 1, 'hour');
expect(dateTimeUtils.add).toHaveBeenCalledWith(
expect.anything(),
1,
'hour'
);
});
});
@ -1037,9 +1180,7 @@ describe('guideUtils', () => {
1: { id: 1, name: 'Sports' },
2: { id: 2, name: 'News' },
};
const channels = [
{ id: 1, channel_group_id: 1 },
];
const channels = [{ id: 1, channel_group_id: 1 }];
const result = guideUtils.getGroupOptions(channelGroups, channels);

View file

@ -7,22 +7,22 @@
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 8px;
background: linear-gradient(to right, #2D2D2F, #1F1F20);
background: linear-gradient(to right, #2d2d2f, #1f1f20);
/* Default background */
color: #fff;
transition: all 0.2s ease-out;
}
.tv-guide .guide-program-container .guide-program.live {
background: linear-gradient(to right, #3BA882, #245043);
background: linear-gradient(to right, #3ba882, #245043);
}
.tv-guide .guide-program-container .guide-program.live:hover {
background: linear-gradient(to right, #2E9E80, #206E5E);
background: linear-gradient(to right, #2e9e80, #206e5e);
}
.tv-guide .guide-program-container .guide-program.not-live:hover {
background: linear-gradient(to right, #2F3F3A, #1E2926);
background: linear-gradient(to right, #2f3f3a, #1e2926);
}
/* New styles for expanded programs */
@ -33,15 +33,15 @@
}
.tv-guide .guide-program-container .guide-program.expanded.live {
background: linear-gradient(to right, #226F5D, #3BA882);
background: linear-gradient(to right, #226f5d, #3ba882);
}
.tv-guide .guide-program-container .guide-program.expanded.not-live {
background: linear-gradient(to right, #2C3F3A, #206E5E);
background: linear-gradient(to right, #2c3f3a, #206e5e);
}
.tv-guide .guide-program-container .guide-program.expanded.past {
background: linear-gradient(to right, #1F2423, #2F3A37);
background: linear-gradient(to right, #1f2423, #2f3a37);
}
/* Ensure channel logo is always on top */

View file

@ -10,7 +10,7 @@ import {
format,
getNow,
getNowMs,
roundToNearest
roundToNearest,
} from '../utils/dateTimeUtils.js';
import API from '../api.js';
@ -28,7 +28,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
const tvgRecord = channel.epg_data_id
? tvgsById[channel.epg_data_id]
: null;
// For dummy EPG sources, ALWAYS use channel UUID to ensure unique programs per channel
// This prevents multiple channels with the same dummy EPG from showing identical data
let tvgId;
@ -45,7 +45,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
// No EPG data: use channel UUID
tvgId = channel.uuid;
}
if (tvgId) {
const tvgKey = String(tvgId);
if (!map.has(tvgKey)) {
@ -138,19 +138,25 @@ export const fetchPrograms = async () => {
export const sortChannels = (channels) => {
// Include ALL channels, sorted by channel number - don't filter by EPG data
const sortedChannels = Object.values(channels).sort(
(a, b) =>
(a.channel_number || Infinity) - (b.channel_number || Infinity)
(a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity)
);
console.log(`Using all ${sortedChannels.length} available channels`);
return sortedChannels;
}
};
export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => {
export const filterGuideChannels = (
guideChannels,
searchQuery,
selectedGroupId,
selectedProfileId,
profiles
) => {
return guideChannels.filter((channel) => {
// Search filter
if (searchQuery) {
if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase()))
return false;
}
// Channel group filter
@ -162,17 +168,17 @@ export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId,
if (selectedProfileId !== 'all') {
const profileChannels = profiles[selectedProfileId]?.channels || [];
const enabledChannelIds = Array.isArray(profileChannels)
? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
: profiles[selectedProfileId]?.channels instanceof Set
? Array.from(profiles[selectedProfileId].channels)
: [];
? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
: profiles[selectedProfileId]?.channels instanceof Set
? Array.from(profiles[selectedProfileId].channels)
: [];
if (!enabledChannelIds.includes(channel.id)) return false;
}
return true;
});
}
};
export const calculateEarliestProgramStart = (programs, defaultStart) => {
if (!programs.length) return defaultStart;
@ -180,7 +186,7 @@ export const calculateEarliestProgramStart = (programs, defaultStart) => {
const s = initializeTime(p.start_time);
return isBefore(s, acc) ? s : acc;
}, defaultStart);
}
};
export const calculateLatestProgramEnd = (programs, defaultEnd) => {
if (!programs.length) return defaultEnd;
@ -188,17 +194,17 @@ export const calculateLatestProgramEnd = (programs, defaultEnd) => {
const e = initializeTime(p.end_time);
return isAfter(e, acc) ? e : acc;
}, defaultEnd);
}
};
export const calculateStart = (earliestProgramStart, defaultStart) => {
return isBefore(earliestProgramStart, defaultStart)
? earliestProgramStart
: defaultStart;
}
};
export const calculateEnd = (latestProgramEnd, defaultEnd) => {
return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
}
};
export const mapChannelsById = (guideChannels) => {
const map = new Map();
@ -206,7 +212,7 @@ export const mapChannelsById = (guideChannels) => {
map.set(channel.id, channel);
});
return map;
}
};
export const mapRecordingsByProgramId = (recordings) => {
const map = new Map();
@ -217,7 +223,7 @@ export const mapRecordingsByProgramId = (recordings) => {
}
});
return map;
}
};
export const formatTime = (time, dateFormat) => {
const today = startOfDay(getNow());
@ -236,7 +242,7 @@ export const formatTime = (time, dateFormat) => {
// Beyond a week, show month and day
return format(time, dateFormat);
}
}
};
export const calculateHourTimeline = (start, end, formatDayLabel) => {
const hours = [];
@ -262,7 +268,7 @@ export const calculateHourTimeline = (start, end, formatDayLabel) => {
current = add(current, 1, 'hour');
}
return hours;
}
};
export const calculateNowPosition = (now, start, end) => {
if (isBefore(now, start) || isAfter(now, end)) return -1;
@ -286,11 +292,11 @@ export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
}
// Return the first channel that matches this TVG ID
return channelById.get(channelIds[0]) || null;
}
};
export const fetchRules = async () => {
return await API.listSeriesRules();
}
};
export const getRuleByProgram = (rules, program) => {
return (rules || []).find(
@ -298,7 +304,7 @@ export const getRuleByProgram = (rules, program) => {
String(r.tvg_id) === String(program.tvg_id) &&
(!r.title || r.title === program.title)
);
}
};
export const createRecording = async (channel, program) => {
await API.createRecording({
@ -307,7 +313,7 @@ export const createRecording = async (channel, program) => {
end_time: program.end_time,
custom_properties: { program },
});
}
};
export const createSeriesRule = async (program, mode) => {
await API.createSeriesRule({
@ -315,15 +321,14 @@ export const createSeriesRule = async (program, mode) => {
mode,
title: program.title,
});
}
};
export const evaluateSeriesRule = async (program) => {
await API.evaluateSeriesRules(program.tvg_id);
}
};
export const calculateLeftScrollPosition = (program, start) => {
const programStartMs =
program.startMs ?? convertToMs(program.start_time);
const programStartMs = program.startMs ?? convertToMs(program.start_time);
const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
@ -331,9 +336,13 @@ export const calculateLeftScrollPosition = (program, start) => {
export const calculateDesiredScrollPosition = (leftPx) => {
return Math.max(0, leftPx - 20);
}
};
export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
export const calculateScrollPositionByTimeClick = (
event,
clickedTime,
start
) => {
const rect = event.currentTarget.getBoundingClientRect();
const clickPositionX = event.clientX - rect.left;
const percentageAcross = clickPositionX / rect.width;
@ -341,9 +350,10 @@ export const calculateScrollPositionByTimeClick = (event, clickedTime, start) =>
const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
const adjustedTime = (snappedMinute === 60)
? add(clickedTime, 1, 'hour').minute(0)
: clickedTime.minute(snappedMinute);
const adjustedTime =
snappedMinute === 60
? add(clickedTime, 1, 'hour').minute(0)
: clickedTime.minute(snappedMinute);
const snappedOffset = diff(adjustedTime, start, 'minute');
return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
@ -372,7 +382,7 @@ export const getGroupOptions = (channelGroups, guideChannels) => {
});
}
return options;
}
};
export const getProfileOptions = (profiles) => {
const options = [{ value: 'all', label: 'All Profiles' }];
@ -390,12 +400,12 @@ export const getProfileOptions = (profiles) => {
}
return options;
}
};
export const deleteSeriesRuleByTvgId = async (tvg_id) => {
await API.deleteSeriesRule(tvg_id);
}
};
export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
await API.evaluateSeriesRules(tvg_id);
}
};

View file

@ -54,36 +54,50 @@ describe('useAuthStore', () => {
localStorageMock.clear();
// Setup default store mocks
useSettingsStore.mockImplementation((selector) => selector({
fetchSettings: vi.fn().mockResolvedValue(),
}));
useSettingsStore.mockImplementation((selector) =>
selector({
fetchSettings: vi.fn().mockResolvedValue(),
})
);
useChannelsStore.mockImplementation((selector) => selector({
fetchChannels: vi.fn().mockResolvedValue(),
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
}));
useChannelsStore.mockImplementation((selector) =>
selector({
fetchChannels: vi.fn().mockResolvedValue(),
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
})
);
usePlaylistsStore.mockImplementation((selector) => selector({
fetchPlaylists: vi.fn().mockResolvedValue(),
}));
usePlaylistsStore.mockImplementation((selector) =>
selector({
fetchPlaylists: vi.fn().mockResolvedValue(),
})
);
useEPGsStore.mockImplementation((selector) => selector({
fetchEPGs: vi.fn().mockResolvedValue(),
fetchEPGData: vi.fn().mockResolvedValue(),
}));
useEPGsStore.mockImplementation((selector) =>
selector({
fetchEPGs: vi.fn().mockResolvedValue(),
fetchEPGData: vi.fn().mockResolvedValue(),
})
);
useStreamProfilesStore.mockImplementation((selector) => selector({
fetchProfiles: vi.fn().mockResolvedValue(),
}));
useStreamProfilesStore.mockImplementation((selector) =>
selector({
fetchProfiles: vi.fn().mockResolvedValue(),
})
);
useUserAgentsStore.mockImplementation((selector) => selector({
fetchUserAgents: vi.fn().mockResolvedValue(),
}));
useUserAgentsStore.mockImplementation((selector) =>
selector({
fetchUserAgents: vi.fn().mockResolvedValue(),
})
);
useUsersStore.mockImplementation((selector) => selector({
fetchUsers: vi.fn().mockResolvedValue(),
}));
useUsersStore.mockImplementation((selector) =>
selector({
fetchUsers: vi.fn().mockResolvedValue(),
})
);
});
afterEach(() => {
@ -140,13 +154,25 @@ describe('useAuthStore', () => {
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.login({ username: 'testuser', password: 'password' });
await result.current.login({
username: 'testuser',
password: 'password',
});
});
expect(API.login).toHaveBeenCalledWith('testuser', 'password');
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number));
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'accessToken',
mockAccessToken
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'refreshToken',
mockRefreshToken
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'tokenExpiration',
expect.any(Number)
);
});
it('should handle login failure', async () => {
@ -182,7 +208,10 @@ describe('useAuthStore', () => {
expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token');
expect(newToken).toBe(mockNewAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'accessToken',
mockNewAccessToken
);
});
it('should return false if no refresh token exists', async () => {
@ -213,7 +242,9 @@ describe('useAuthStore', () => {
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
'tokenExpiration'
);
});
});
@ -277,7 +308,9 @@ describe('useAuthStore', () => {
expect(result.current.user).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
'tokenExpiration'
);
});
it('should continue logout even if API call fails', async () => {
@ -372,7 +405,6 @@ describe('useAuthStore', () => {
expect(fetchUsers).toHaveBeenCalled();
});
it('should not fetch users for non-admin user', async () => {
const mockUser = {
username: 'reseller',
@ -418,7 +450,9 @@ describe('useAuthStore', () => {
API.me.mockResolvedValue(mockUser);
const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed'));
const fetchChannels = vi
.fn()
.mockRejectedValue(new Error('Fetch failed'));
useChannelsStore.getState = vi.fn(() => ({
fetchChannels,
@ -437,7 +471,11 @@ describe('useAuthStore', () => {
describe('setUser', () => {
it('should update user state', () => {
const { result } = renderHook(() => useAuthStore());
const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN };
const newUser = {
username: 'test',
email: 'test@test.com',
user_level: USER_LEVELS.ADMIN,
};
act(() => {
result.current.setUser(newUser);
@ -494,7 +532,10 @@ describe('useAuthStore', () => {
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.login({ username: 'testuser', password: 'password' });
await result.current.login({
username: 'testuser',
password: 'password',
});
});
expect(result.current.accessToken).toBeNull();
@ -577,7 +618,7 @@ describe('useAuthStore', () => {
// Reset state before the test
useAuthStore.setState({
isInitializing: false,
isInitialized: false
isInitialized: false,
});
API.me.mockRejectedValue(new Error('API error'));
@ -620,7 +661,7 @@ describe('useAuthStore', () => {
// Wait for the background call to complete
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
});
// The background fetchChannels is called synchronously without await

View file

@ -243,7 +243,11 @@ describe('useChannelsStore', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] });
result.current.updateProfile({
id: '1',
name: 'Updated',
channels: [3],
});
});
expect(result.current.profiles['1'].name).toBe('Updated');
@ -254,7 +258,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1' }, '2': { id: '2' } },
profiles: { 1: { id: '1' }, 2: { id: '2' } },
selectedProfileId: '1',
});
});
@ -274,7 +278,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1', channels: new Set([1]) } },
profiles: { 1: { id: '1', channels: new Set([1]) } },
});
});
@ -291,7 +295,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } },
profiles: { 1: { id: '1', channels: new Set([1, 2, 3]) } },
});
});
@ -316,9 +320,7 @@ describe('useChannelsStore', () => {
});
const newStats = {
channels: [
{ channel_id: 'uuid-1', clients: [] },
],
channels: [{ channel_id: 'uuid-1', clients: [] }],
};
act(() => {

View file

@ -40,7 +40,9 @@ describe('useChannelsTableStore', () => {
expect(result.current.channels).toEqual([]);
expect(result.current.pageCount).toBe(0);
expect(result.current.totalCount).toBe(0);
expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]);
expect(result.current.sorting).toEqual([
{ id: 'channel_number', desc: false },
]);
expect(result.current.pagination.pageIndex).toBe(0);
expect(result.current.pagination.pageSize).toBe(50);
expect(result.current.selectedChannelIds).toEqual([]);
@ -187,9 +189,7 @@ describe('useChannelsTableStore', () => {
it('should return empty array for channel without streams', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1' },
];
const mockChannels = [{ id: 1, name: 'Channel 1' }];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
@ -203,9 +203,7 @@ describe('useChannelsTableStore', () => {
it('should return empty array for non-existent channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1', streams: ['stream1'] },
];
const mockChannels = [{ id: 1, name: 'Channel 1', streams: ['stream1'] }];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
@ -343,7 +341,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 };
const updatedChannel = {
id: 2,
name: 'Updated Channel 2',
channel_number: 22,
};
act(() => {
result.current.updateChannel(updatedChannel);
@ -368,7 +370,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 };
const updatedChannel = {
id: 999,
name: 'Non-existent',
channel_number: 999,
};
act(() => {
result.current.updateChannel(updatedChannel);
@ -389,7 +395,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 };
const updatedChannel = {
id: 1,
name: 'Updated Channel 1',
channel_number: 10,
};
act(() => {
result.current.updateChannel(updatedChannel);

View file

@ -65,7 +65,9 @@ describe('useEPGsStore', () => {
});
it('should set loading state while fetching', async () => {
api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
api.getEPGs.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
);
const { result } = renderHook(() => useEPGsStore());
@ -87,7 +89,9 @@ describe('useEPGsStore', () => {
const mockError = new Error('Network error');
api.getEPGs.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
@ -96,7 +100,10 @@ describe('useEPGsStore', () => {
expect(result.current.error).toBe('Failed to load epgs.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch epgs:',
mockError
);
consoleSpy.mockRestore();
});
@ -142,7 +149,9 @@ describe('useEPGsStore', () => {
});
it('should set loading state while fetching', async () => {
api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
api.getEPGData.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
);
const { result } = renderHook(() => useEPGsStore());
@ -163,7 +172,9 @@ describe('useEPGsStore', () => {
const mockError = new Error('API error');
api.getEPGData.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
@ -173,7 +184,10 @@ describe('useEPGsStore', () => {
expect(result.current.error).toBe('Failed to load tvgs.');
expect(result.current.tvgsLoaded).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch tvgs:',
mockError
);
consoleSpy.mockRestore();
});
@ -274,7 +288,9 @@ describe('useEPGsStore', () => {
});
it('should not update state when called with invalid epg (null)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@ -287,13 +303,18 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null);
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPG called with invalid epg:',
null
);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (missing id)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@ -308,13 +329,18 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG);
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPG called with invalid epg:',
invalidEPG
);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (non-object)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@ -327,7 +353,10 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid');
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPG called with invalid epg:',
'invalid'
);
consoleSpy.mockRestore();
});
@ -518,7 +547,9 @@ describe('useEPGsStore', () => {
});
});
expect(result.current.epgs.source1.last_message).toBe('Connection failed');
expect(result.current.epgs.source1.last_message).toBe(
'Connection failed'
);
});
it('should use default error message if error is not provided', () => {
@ -535,7 +566,9 @@ describe('useEPGsStore', () => {
});
it('should not update state when called with invalid data (null)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
@ -547,13 +580,18 @@ describe('useEPGsStore', () => {
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null);
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPGProgress called with invalid data:',
null
);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid data (missing source)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
@ -565,7 +603,10 @@ describe('useEPGsStore', () => {
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 });
expect(consoleSpy).toHaveBeenCalledWith(
'updateEPGProgress called with invalid data:',
{ progress: 50 }
);
consoleSpy.mockRestore();
});
@ -667,7 +708,11 @@ describe('useEPGsStore', () => {
act(() => {
useEPGsStore.setState({
epgs: {
source1: { id: 'source1', status: 'parsing', last_message: 'Processing' },
source1: {
id: 'source1',
status: 'parsing',
last_message: 'Processing',
},
},
});
});

View file

@ -58,7 +58,11 @@ describe('useLogosStore', () => {
it('should add logo to main logos store', () => {
const { result } = renderHook(() => useLogosStore());
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
const newLogo = {
id: 'logo1',
name: 'Logo 1',
url: 'http://example.com/logo1.png',
};
act(() => {
result.current.addLogo(newLogo);
@ -76,7 +80,11 @@ describe('useLogosStore', () => {
useLogosStore.setState({ hasLoadedChannelLogos: true });
});
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
const newLogo = {
id: 'logo1',
name: 'Logo 1',
url: 'http://example.com/logo1.png',
};
act(() => {
result.current.addLogo(newLogo);
@ -89,7 +97,11 @@ describe('useLogosStore', () => {
it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => {
const { result } = renderHook(() => useLogosStore());
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
const newLogo = {
id: 'logo1',
name: 'Logo 1',
url: 'http://example.com/logo1.png',
};
act(() => {
result.current.addLogo(newLogo);
@ -104,8 +116,16 @@ describe('useLogosStore', () => {
it('should update logo in main logos store', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
const originalLogo = {
id: 'logo1',
name: 'Original',
url: 'http://example.com/original.png',
};
const updatedLogo = {
id: 'logo1',
name: 'Updated',
url: 'http://example.com/updated.png',
};
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
@ -121,8 +141,16 @@ describe('useLogosStore', () => {
it('should update logo in channelLogos if it exists there', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
const originalLogo = {
id: 'logo1',
name: 'Original',
url: 'http://example.com/original.png',
};
const updatedLogo = {
id: 'logo1',
name: 'Updated',
url: 'http://example.com/updated.png',
};
act(() => {
useLogosStore.setState({
@ -142,8 +170,16 @@ describe('useLogosStore', () => {
it('should not update channelLogos if logo does not exist there', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
const originalLogo = {
id: 'logo1',
name: 'Original',
url: 'http://example.com/original.png',
};
const updatedLogo = {
id: 'logo1',
name: 'Updated',
url: 'http://example.com/updated.png',
};
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
@ -249,7 +285,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -261,7 +299,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch logos:',
mockError
);
});
consoleSpy.mockRestore();
@ -354,7 +395,9 @@ describe('useLogosStore', () => {
const mockError = new Error('API error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -366,7 +409,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load all logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch all logos:',
mockError
);
});
consoleSpy.mockRestore();
@ -396,7 +442,10 @@ describe('useLogosStore', () => {
logo2: { id: 'logo2', name: 'Used Logo 2' },
});
expect(result.current.isLoading).toBe(false);
expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 });
expect(api.getLogos).toHaveBeenCalledWith({
used: 'true',
page_size: 100,
});
expect(response).toEqual(mockResponse);
});
@ -426,7 +475,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -437,7 +488,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load used logos.');
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch used logos:',
mockError
);
});
consoleSpy.mockRestore();
@ -537,7 +591,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogosByIds.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@ -546,7 +602,10 @@ describe('useLogosStore', () => {
})
).rejects.toThrow('Fetch error');
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch logos by IDs:',
mockError
);
consoleSpy.mockRestore();
});
@ -565,9 +624,7 @@ describe('useLogosStore', () => {
next: null,
};
api.getLogos
.mockResolvedValueOnce(page1)
.mockResolvedValueOnce(page2);
api.getLogos.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);
const { result } = renderHook(() => useLogosStore());
@ -589,7 +646,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
@ -597,7 +656,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Background logo loading failed:',
mockError
);
consoleSpy.mockRestore();
});
@ -665,7 +727,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.backgroundLoadAllLogos();
@ -675,7 +739,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError);
expect(consoleSpy).toHaveBeenCalledWith(
'Background all logos loading failed:',
mockError
);
consoleSpy.mockRestore();
@ -713,7 +780,10 @@ describe('useLogosStore', () => {
});
it('should not start if channelLogos already has many items', async () => {
const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]);
const channelLogos = Array.from({ length: 150 }, (_, i) => [
`logo${i}`,
{ id: `logo${i}` },
]);
const channelLogosObj = Object.fromEntries(channelLogos);
const { result } = renderHook(() => useLogosStore());
@ -747,8 +817,12 @@ describe('useLogosStore', () => {
expect(result.current.hasLoadedChannelLogos).toBe(true);
expect(result.current.backgroundLoading).toBe(false);
expect(Object.keys(result.current.channelLogos).length).toBe(2);
expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...');
expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos');
expect(consoleSpy).toHaveBeenCalledWith(
'Background loading channel logos...'
);
expect(consoleSpy).toHaveBeenCalledWith(
'Background loaded 2 channel logos'
);
consoleSpy.mockRestore();
});
@ -757,8 +831,12 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
@ -766,7 +844,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Background channel logo loading failed:',
mockError
);
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
@ -801,7 +882,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Background error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.startBackgroundLoading();

View file

@ -65,13 +65,17 @@ describe('usePlaylistsStore', () => {
expect(api.getPlaylist).toHaveBeenCalledWith('playlist1');
expect(result.current.playlists).toEqual([mockPlaylist]);
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
expect(result.current.profiles).toEqual({
playlist1: ['profile1', 'profile2'],
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch playlist error', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
api.getPlaylist.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
@ -110,7 +114,9 @@ describe('usePlaylistsStore', () => {
});
it('should handle fetch playlists error', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
api.getPlaylists.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
@ -142,8 +148,16 @@ describe('usePlaylistsStore', () => {
it('should update playlist', () => {
const { result } = renderHook(() => usePlaylistsStore());
const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] };
const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] };
const existingPlaylist = {
id: 'playlist1',
name: 'Old Name',
profiles: ['profile1'],
};
const updatedPlaylist = {
id: 'playlist1',
name: 'New Name',
profiles: ['profile1', 'profile2'],
};
act(() => {
result.current.playlists = [existingPlaylist];
@ -155,7 +169,9 @@ describe('usePlaylistsStore', () => {
});
expect(result.current.playlists).toEqual([updatedPlaylist]);
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
expect(result.current.profiles).toEqual({
playlist1: ['profile1', 'profile2'],
});
});
it('should update profiles', () => {
@ -166,10 +182,16 @@ describe('usePlaylistsStore', () => {
});
act(() => {
result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']);
result.current.updateProfiles('playlist1', [
'profile1',
'profile2',
'profile3',
]);
});
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] });
expect(result.current.profiles).toEqual({
playlist1: ['profile1', 'profile2', 'profile3'],
});
});
it('should remove playlists', () => {
@ -187,7 +209,9 @@ describe('usePlaylistsStore', () => {
result.current.removePlaylists(['playlist1', 'playlist3']);
});
expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]);
expect(result.current.playlists).toEqual([
{ id: 'playlist2', name: 'Playlist 2' },
]);
});
it('should set refresh progress with two parameters', () => {
@ -228,7 +252,11 @@ describe('usePlaylistsStore', () => {
expect(result.current.refreshProgress.account1.action).toBe('initializing');
act(() => {
result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 });
result.current.setRefreshProgress({
account: 'account1',
action: 'refreshing',
progress: 25,
});
});
expect(result.current.refreshProgress.account1.action).toBe('refreshing');

View file

@ -136,7 +136,11 @@ describe('usePluginStore', () => {
result.current.updatePlugin('plugin1', { name: 'Updated Plugin' });
});
expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false });
expect(result.current.plugins[1]).toEqual({
key: 'plugin2',
name: 'Plugin 2',
enabled: false,
});
});
it('should add plugin', () => {
@ -152,7 +156,11 @@ describe('usePluginStore', () => {
it('should add plugin to existing plugins', () => {
const { result } = renderHook(() => usePluginStore());
const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true };
const existingPlugin = {
key: 'plugin1',
name: 'Existing Plugin',
enabled: true,
};
const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false };
act(() => {
@ -192,9 +200,7 @@ describe('usePluginStore', () => {
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
],
plugins: [{ key: 'plugin1', name: 'Plugin 1', enabled: true }],
});
});
@ -208,9 +214,7 @@ describe('usePluginStore', () => {
});
it('should invalidate plugins and refetch', async () => {
const mockPlugins = [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
];
const mockPlugins = [{ key: 'plugin1', name: 'Plugin 1', enabled: true }];
API.getPlugins.mockResolvedValue(mockPlugins);
@ -218,9 +222,7 @@ describe('usePluginStore', () => {
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'old-plugin', name: 'Old Plugin', enabled: false },
],
plugins: [{ key: 'old-plugin', name: 'Old Plugin', enabled: false }],
});
});

View file

@ -190,7 +190,10 @@ describe('useSettingsStore', () => {
result.current.updateSetting({ key: 'setting1', value: 'updated' });
});
expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' });
expect(result.current.settings.setting2).toEqual({
key: 'setting2',
value: 'value2',
});
});
it('should handle empty settings array', async () => {
@ -249,7 +252,10 @@ describe('useSettingsStore', () => {
},
});
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
api.getVersion.mockResolvedValue({
version: '2.0.0',
timestamp: '2024-01-01T00:00:00Z',
});
const { result } = renderHook(() => useSettingsStore());
@ -336,7 +342,10 @@ describe('useSettingsStore', () => {
api.getSettings.mockResolvedValue(mockSettings);
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
api.getVersion.mockResolvedValue({
version: '2.0.0',
timestamp: '2024-01-01T00:00:00Z',
});
const { result } = renderHook(() => useSettingsStore());

View file

@ -47,7 +47,9 @@ describe('useStreamProfilesStore', () => {
const mockError = new Error('Network error');
api.getStreamProfiles.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useStreamProfilesStore());
@ -57,7 +59,10 @@ describe('useStreamProfilesStore', () => {
expect(result.current.error).toBe('Failed to load profiles.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch profiles:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -152,7 +157,11 @@ describe('useStreamProfilesStore', () => {
result.current.updateStreamProfile(updatedProfile);
});
expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 });
expect(result.current.profiles[1]).toEqual({
id: 2,
name: 'Profile 2',
bitrate: 8000,
});
});
it('should not modify profiles when updating non-existent profile', () => {
@ -166,7 +175,11 @@ describe('useStreamProfilesStore', () => {
});
const { result } = renderHook(() => useStreamProfilesStore());
const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 };
const nonExistentProfile = {
id: 999,
name: 'Non-existent',
bitrate: 10000,
};
act(() => {
result.current.updateStreamProfile(nonExistentProfile);
@ -246,9 +259,7 @@ describe('useStreamProfilesStore', () => {
});
it('should handle empty array when removing profiles', () => {
const initialProfiles = [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
];
const initialProfiles = [{ id: 1, name: 'Profile 1', bitrate: 5000 }];
useStreamProfilesStore.setState({
profiles: initialProfiles,

View file

@ -53,7 +53,9 @@ describe('useStreamsStore', () => {
const mockError = new Error('Network error');
api.getStreams.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useStreamsStore());
@ -63,7 +65,10 @@ describe('useStreamsStore', () => {
expect(result.current.error).toBe('Failed to load streams.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch streams:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -131,7 +136,11 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
const updatedStream = {
id: 1,
name: 'Updated Stream',
url: 'http://example.com/updated',
};
act(() => {
result.current.updateStream(updatedStream);
@ -152,13 +161,21 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
const updatedStream = {
id: 1,
name: 'Updated Stream',
url: 'http://example.com/updated',
};
act(() => {
result.current.updateStream(updatedStream);
});
expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' });
expect(result.current.streams[1]).toEqual({
id: 2,
name: 'Stream 2',
url: 'http://example.com/2',
});
});
it('should not modify streams when updating non-existent stream', () => {
@ -172,7 +189,11 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' };
const nonExistentStream = {
id: 999,
name: 'Non-existent',
url: 'http://example.com/999',
};
act(() => {
result.current.updateStream(nonExistentStream);

View file

@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
})
}),
};
})();
@ -58,7 +58,7 @@ describe('useStreamsTableStore', () => {
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
}
},
});
const { result } = renderHook(() => useStreamsTableStore());
@ -74,7 +74,7 @@ describe('useStreamsTableStore', () => {
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
}
},
});
const { result } = renderHook(() => useStreamsTableStore());
@ -112,10 +112,7 @@ describe('useStreamsTableStore', () => {
const mockParams = new URLSearchParams({ page_size: '25' });
act(() => {
result.current.queryStreams(
{ results: [], count: 75 },
mockParams
);
result.current.queryStreams({ results: [], count: 75 }, mockParams);
});
expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
@ -127,10 +124,7 @@ describe('useStreamsTableStore', () => {
const mockParams = new URLSearchParams({ page_size: '50' });
act(() => {
result.current.queryStreams(
{ results: [], count: 0 },
mockParams
);
result.current.queryStreams({ results: [], count: 0 }, mockParams);
});
expect(result.current.streams).toEqual([]);
@ -340,7 +334,9 @@ describe('useStreamsTableStore', () => {
expect(result.current.totalCount).toBe(50);
expect(result.current.pageCount).toBe(2);
expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]);
expect(result.current.sorting).toEqual([
{ id: 'created_at', desc: true },
]);
expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]);
expect(result.current.selectedStreamIds).toEqual([1, 2]);
expect(result.current.lastQueryParams).toBe(mockParams);

View file

@ -103,7 +103,12 @@ describe('useVODStore', () => {
expect(api.getAllContent).toHaveBeenCalled();
expect(result.current.currentPageContent).toEqual([
{ id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' },
{ id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' },
{
id: 2,
name: 'Series 1',
content_type: 'series',
contentType: 'series',
},
]);
expect(result.current.totalCount).toBe(2);
expect(result.current.loading).toBe(false);
@ -171,7 +176,9 @@ describe('useVODStore', () => {
const mockError = new Error('Network error');
api.getAllContent.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -181,7 +188,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load content.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch content:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -189,7 +199,9 @@ describe('useVODStore', () => {
it('should handle invalid response format', async () => {
api.getAllContent.mockResolvedValue({ results: 'not-an-array' });
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -234,7 +246,9 @@ describe('useVODStore', () => {
const mockError = new Error('Not found');
api.getMovieDetails.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -248,7 +262,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load movie details.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch movie details:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -283,7 +300,9 @@ describe('useVODStore', () => {
const mockError = new Error('Provider error');
api.getMovieProviderInfo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -295,8 +314,13 @@ describe('useVODStore', () => {
}
});
expect(result.current.error).toBe('Failed to load movie details from provider.');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError);
expect(result.current.error).toBe(
'Failed to load movie details from provider.'
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch movie details from provider:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -324,7 +348,9 @@ describe('useVODStore', () => {
const mockError = new Error('Providers error');
api.getMovieProviders.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -336,7 +362,10 @@ describe('useVODStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch movie providers:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -361,7 +390,9 @@ describe('useVODStore', () => {
const mockError = new Error('Series providers error');
api.getSeriesProviders.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -373,7 +404,10 @@ describe('useVODStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch series providers:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -428,7 +462,9 @@ describe('useVODStore', () => {
const mockError = new Error('Series not found');
api.getSeriesInfo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -442,7 +478,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load series details.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch series info:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -494,7 +533,9 @@ describe('useVODStore', () => {
const mockError = new Error('Categories error');
api.getVODCategories.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@ -503,7 +544,10 @@ describe('useVODStore', () => {
});
expect(result.current.error).toBe('Failed to load categories.');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch VOD categories:',
mockError
);
consoleErrorSpy.mockRestore();
});

View file

@ -68,7 +68,9 @@ describe('useVideoStore', () => {
const { result } = renderHook(() => useVideoStore());
act(() => {
result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' });
result.current.showVideo('http://example.com/stream.ts', 'vod', {
title: 'Test',
});
});
expect(result.current.isVisible).toBe(true);
@ -121,7 +123,7 @@ describe('useVideoStore', () => {
const metadata = {
title: 'Test Video',
duration: 120,
thumbnailUrl: 'http://example.com/thumb.jpg'
thumbnailUrl: 'http://example.com/thumb.jpg',
};
act(() => {
@ -139,13 +141,21 @@ describe('useVideoStore', () => {
const secondMetadata = { title: 'Second Video' };
act(() => {
result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata);
result.current.showVideo(
'http://example.com/first.mp4',
'vod',
firstMetadata
);
});
expect(result.current.metadata).toEqual(firstMetadata);
act(() => {
result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata);
result.current.showVideo(
'http://example.com/second.mp4',
'vod',
secondMetadata
);
});
expect(result.current.metadata).toEqual(secondMetadata);

View file

@ -47,7 +47,9 @@ describe('useUserAgentsStore', () => {
const mockError = new Error('Network error');
api.getUserAgents.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useUserAgentsStore());
@ -57,7 +59,10 @@ describe('useUserAgentsStore', () => {
expect(result.current.error).toBe('Failed to load userAgents.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch userAgents:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -125,7 +130,11 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
const updatedUserAgent = {
id: 1,
name: 'Chrome Updated',
string: 'Mozilla/5.0 Updated...',
};
act(() => {
result.current.updateUserAgent(updatedUserAgent);
@ -146,13 +155,21 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
const updatedUserAgent = {
id: 1,
name: 'Chrome Updated',
string: 'Mozilla/5.0 Updated...',
};
act(() => {
result.current.updateUserAgent(updatedUserAgent);
});
expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' });
expect(result.current.userAgents[1]).toEqual({
id: 2,
name: 'Firefox',
string: 'Mozilla/5.0...',
});
});
it('should not modify user agents when updating non-existent user agent', () => {
@ -166,7 +183,11 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' };
const nonExistentUserAgent = {
id: 999,
name: 'Non-existent',
string: 'Mozilla/5.0...',
};
act(() => {
result.current.updateUserAgent(nonExistentUserAgent);

View file

@ -47,7 +47,9 @@ describe('useUsersStore', () => {
const mockError = new Error('Network error');
api.getUsers.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useUsersStore());
@ -57,7 +59,10 @@ describe('useUsersStore', () => {
expect(result.current.error).toBe('Failed to load users.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch users:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -125,7 +130,11 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
const updatedUser = {
id: 1,
name: 'Updated User',
email: 'updated@example.com',
};
act(() => {
result.current.updateUser(updatedUser);
@ -146,13 +155,21 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
const updatedUser = {
id: 1,
name: 'Updated User',
email: 'updated@example.com',
};
act(() => {
result.current.updateUser(updatedUser);
});
expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' });
expect(result.current.users[1]).toEqual({
id: 2,
name: 'User 2',
email: 'user2@example.com',
});
});
it('should not modify users when updating non-existent user', () => {
@ -166,7 +183,11 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' };
const nonExistentUser = {
id: 999,
name: 'Non-existent',
email: 'none@example.com',
};
act(() => {
result.current.updateUser(nonExistentUser);
@ -252,7 +273,15 @@ describe('useUsersStore', () => {
result.current.removeUser(2);
});
expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' });
expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' });
expect(result.current.users[0]).toEqual({
id: 1,
name: 'User 1',
email: 'user1@example.com',
});
expect(result.current.users[1]).toEqual({
id: 3,
name: 'User 3',
email: 'user3@example.com',
});
});
});

View file

@ -105,7 +105,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Network error');
api.getVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -119,7 +121,10 @@ describe('useVODLogosStore', () => {
expect(result.current.error).toBe('Failed to load VOD logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch VOD logos:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -205,9 +210,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
expect(result.current.logos).toEqual([
{ id: 2, name: 'Logo 2' },
]);
expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]);
expect(result.current.totalCount).toBe(1);
});
@ -236,9 +239,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
expect(result.current.logos).toEqual([
{ id: 2, name: 'Logo 2' },
]);
expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]);
expect(result.current.totalCount).toBe(1);
});
@ -246,7 +247,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Delete failed');
api.deleteVODLogo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -258,7 +261,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to delete VOD logo:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -290,9 +296,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
3: { id: 3, name: 'Logo 3' },
});
expect(result.current.logos).toEqual([
{ id: 3, name: 'Logo 3' },
]);
expect(result.current.logos).toEqual([{ id: 3, name: 'Logo 3' }]);
expect(result.current.totalCount).toBe(1);
});
@ -300,7 +304,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Bulk delete failed');
api.deleteVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -312,7 +318,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to delete VOD logos:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -348,7 +357,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Cleanup failed');
api.cleanupUnusedVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -360,7 +371,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to cleanup unused VOD logos:',
mockError
);
consoleErrorSpy.mockRestore();
});
@ -517,7 +531,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Failed to fetch count');
api.getVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@ -529,7 +545,10 @@ describe('useVODLogosStore', () => {
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch unused logos count:',
mockError
);
consoleErrorSpy.mockRestore();
});

View file

@ -12,9 +12,15 @@ const reduceChannels = (channels) => {
return acc;
}, {});
return { channelsByUUID, channelsByID };
}
};
const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => {
const showNotificationIfNewChannel = (
currentStats,
oldChannels,
ch,
channelsByUUID,
channels
) => {
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
// Add null checks to prevent accessing properties on undefined
@ -30,7 +36,7 @@ const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByU
}
}
}
}
};
const showNotificationIfNewClient = (currentStats, oldClients, client) => {
// This check prevents the notifications if streams are active on page load
@ -43,9 +49,15 @@ const showNotificationIfNewClient = (currentStats, oldClients, client) => {
});
}
}
}
};
const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => {
const showNotificationIfChannelStopped = (
currentStats,
oldChannels,
newChannels,
channelsByUUID,
channels
) => {
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
for (const uuid in oldChannels) {
@ -70,9 +82,13 @@ const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels
}
}
}
}
};
const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => {
const showNotificationIfClientStopped = (
currentStats,
oldClients,
newClients
) => {
if (currentStats.channels) {
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
@ -84,7 +100,7 @@ const showNotificationIfClientStopped = (currentStats, oldClients, newClients) =
}
}
}
}
};
const useChannelsStore = create((set, get) => ({
channels: [],
@ -228,7 +244,7 @@ const useChannelsStore = create((set, get) => ({
);
return;
}
const { channelsByUUID, updatedChannels } = reduceChannels(channels);
set((state) => ({
@ -383,24 +399,36 @@ const useChannelsStore = create((set, get) => ({
channelsByUUID,
} = state;
const newClients = {};
const newChannels = stats.channels.reduce((acc, ch) => {
acc[ch.channel_id] = ch;
return acc;
}, {});
stats.channels.forEach(ch => {
showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels);
stats.channels.forEach((ch) => {
showNotificationIfNewChannel(
currentStats,
oldChannels,
ch,
channelsByUUID,
channels
);
ch.clients.forEach(client => {
newClients[client.client_id] = client;
showNotificationIfNewClient(currentStats, oldClients, client);
});
ch.clients.forEach((client) => {
newClients[client.client_id] = client;
showNotificationIfNewClient(currentStats, oldClients, client);
});
});
showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels);
showNotificationIfChannelStopped(
currentStats,
oldChannels,
newChannels,
channelsByUUID,
channels
);
showNotificationIfClientStopped(currentStats, oldClients, newClients);
return {
stats,
activeChannels: newChannels,

View file

@ -4,7 +4,8 @@ import api from '../api';
const determineEPGStatus = (data, currentEpg) => {
if (data.status) return data.status;
if (data.action === 'downloading') return 'fetching';
if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing';
if (data.action === 'parsing_channels' || data.action === 'parsing_programs')
return 'parsing';
if (data.progress === 100) return 'success';
return currentEpg?.status || 'idle';
};
@ -118,23 +119,26 @@ const useEPGsStore = create((set) => ({
// Only update epgs object if status or last_message actually changed
// This prevents unnecessary re-renders on every progress update
const lastMessage = data.status === 'error'
? (data.error || 'Unknown error')
: state.epgs[data.source]?.last_message;
const lastMessage =
data.status === 'error'
? data.error || 'Unknown error'
: state.epgs[data.source]?.last_message;
const currentEpg = state.epgs[data.source];
const shouldUpdateEpg = currentEpg &&
(currentEpg.status !== status || currentEpg.last_message !== lastMessage);
const shouldUpdateEpg =
currentEpg &&
(currentEpg.status !== status ||
currentEpg.last_message !== lastMessage);
const epgs = shouldUpdateEpg
? {
...state.epgs,
[data.source]: {
...currentEpg,
status,
last_message: lastMessage,
},
}
...state.epgs,
[data.source]: {
...currentEpg,
status,
last_message: lastMessage,
},
}
: state.epgs;
return { refreshProgress, epgs };

View file

@ -2,10 +2,8 @@ import { create } from 'zustand';
import api from '../api';
const getLogosArray = (response) => {
return Array.isArray(response)
? response
: response.results || [];
}
return Array.isArray(response) ? response : response.results || [];
};
const useLogosStore = create((set, get) => ({
logos: {},

View file

@ -38,4 +38,4 @@ export const usePluginStore = create((set, get) => ({
set({ plugins: [] });
get().fetchPlugins();
},
}));
}));

View file

@ -7,8 +7,7 @@ const useStreamsTableStore = create((set) => ({
sorting: [{ id: 'name', desc: false }],
pagination: {
pageIndex: 0,
pageSize:
JSON.parse(localStorage.getItem('streams-page-size')) || 50,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
},
selectedStreamIds: [],
allQueryIds: [],

View file

@ -14,7 +14,7 @@ const getFetchContentParams = (state) => {
params.append('category', state.filters.category);
}
return params;
}
};
const getMovieDetails = (response, movieId) => {
return {
@ -35,7 +35,7 @@ const getMovieDetails = (response, movieId) => {
imdb_id: response.imdb_id || '',
m3u_account: response.m3u_account || '',
};
}
};
const getMovieDetailsWithProvider = (response, movieId) => {
return {
@ -65,7 +65,7 @@ const getMovieDetailsWithProvider = (response, movieId) => {
video: response.video || {},
audio: response.audio || {},
};
}
};
const getSeriesDetails = (response, seriesId) => {
return {
@ -92,7 +92,7 @@ const getSeriesDetails = (response, seriesId) => {
m3u_account: response.m3u_account || '',
youtube_trailer: response.custom_properties?.youtube_trailer || '',
};
}
};
const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
return {
@ -117,7 +117,7 @@ const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
};
}
};
const useVODStore = create((set, get) => ({
content: {}, // Store for individual content details (when fetching movie/series details)

View file

@ -256,7 +256,7 @@ describe('dateTimeUtils', () => {
const setTimeZone = vi.fn();
useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
useSettingsStore.mockReturnValue({
'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
system_settings: { value: { time_zone: 'America/Los_Angeles' } },
});
renderHook(() => dateTimeUtils.useUserTimeZone());
@ -321,18 +321,25 @@ describe('dateTimeUtils', () => {
});
it('should start with Sunday', () => {
expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ value: 6, label: 'Sun' });
expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({
value: 6,
label: 'Sun',
});
});
it('should include all weekdays', () => {
const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(opt => opt.label);
const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(
(opt) => opt.label
);
expect(labels).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']);
});
});
describe('useDateTimeFormat', () => {
it('should return 12h format and mdy date format by default', () => {
useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
useLocalStorage
.mockReturnValueOnce(['12h', vi.fn()])
.mockReturnValueOnce(['mdy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@ -341,7 +348,9 @@ describe('dateTimeUtils', () => {
});
it('should return 24h format when set', () => {
useLocalStorage.mockReturnValueOnce(['24h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
useLocalStorage
.mockReturnValueOnce(['24h', vi.fn()])
.mockReturnValueOnce(['mdy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@ -349,7 +358,9 @@ describe('dateTimeUtils', () => {
});
it('should return dmy date format when set', () => {
useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['dmy', vi.fn()]);
useLocalStorage
.mockReturnValueOnce(['12h', vi.fn()])
.mockReturnValueOnce(['dmy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@ -428,26 +439,28 @@ describe('dateTimeUtils', () => {
it('should sort by offset then name', () => {
const result = dateTimeUtils.buildTimeZoneOptions();
for (let i = 1; i < result.length; i++) {
expect(result[i].numericOffset).toBeGreaterThanOrEqual(result[i - 1].numericOffset);
expect(result[i].numericOffset).toBeGreaterThanOrEqual(
result[i - 1].numericOffset
);
}
});
it('should include DST information when applicable', () => {
const result = dateTimeUtils.buildTimeZoneOptions();
const dstZone = result.find(opt => opt.label.includes('DST range'));
const dstZone = result.find((opt) => opt.label.includes('DST range'));
expect(dstZone).toBeDefined();
});
it('should add preferred zone if not in list', () => {
const preferredZone = 'Custom/Zone';
const result = dateTimeUtils.buildTimeZoneOptions(preferredZone);
const found = result.find(opt => opt.value === preferredZone);
const found = result.find((opt) => opt.value === preferredZone);
expect(found).toBeDefined();
});
it('should not duplicate existing zones', () => {
const result = dateTimeUtils.buildTimeZoneOptions('UTC');
const utcOptions = result.filter(opt => opt.value === 'UTC');
const utcOptions = result.filter((opt) => opt.value === 'UTC');
expect(utcOptions).toHaveLength(1);
});
});

View file

@ -8,7 +8,9 @@ describe('networkUtils', () => {
expect(networkUtils.IPV4_CIDR_REGEX.test('10.0.0.0/8')).toBe(true);
expect(networkUtils.IPV4_CIDR_REGEX.test('172.16.0.0/12')).toBe(true);
expect(networkUtils.IPV4_CIDR_REGEX.test('0.0.0.0/0')).toBe(true);
expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(true);
expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(
true
);
});
it('should not match invalid IPv4 CIDR notation', () => {
@ -29,7 +31,11 @@ describe('networkUtils', () => {
expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/32')).toBe(true);
expect(networkUtils.IPV6_CIDR_REGEX.test('fe80::/10')).toBe(true);
expect(networkUtils.IPV6_CIDR_REGEX.test('::/0')).toBe(true);
expect(networkUtils.IPV6_CIDR_REGEX.test('2001:0db8:85a3:0000:0000:8a2e:0370:7334/64')).toBe(true);
expect(
networkUtils.IPV6_CIDR_REGEX.test(
'2001:0db8:85a3:0000:0000:8a2e:0370:7334/64'
)
).toBe(true);
});
it('should match compressed IPv6 CIDR notation', () => {
@ -38,7 +44,9 @@ describe('networkUtils', () => {
});
it('should match IPv6 with embedded IPv4', () => {
expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(true);
expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(
true
);
});
it('should not match invalid IPv6 CIDR notation', () => {

View file

@ -74,7 +74,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
notificationObject
);
expect(notifications.update).toHaveBeenCalledTimes(1);
});
@ -82,7 +85,9 @@ describe('notificationUtils', () => {
const mockReturnValue = { success: true };
notifications.update.mockReturnValue(mockReturnValue);
const result = notificationUtils.updateNotification('id', { message: 'test' });
const result = notificationUtils.updateNotification('id', {
message: 'test',
});
expect(result).toBe(mockReturnValue);
});
@ -98,7 +103,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
updateObject
);
});
it('should handle loading to error transition', () => {
@ -112,7 +120,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
updateObject
);
});
it('should handle partial updates', () => {
@ -123,7 +134,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
expect(notifications.update).toHaveBeenCalledWith(
notificationId,
updateObject
);
});
it('should handle empty notification id', () => {
@ -139,7 +153,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(null, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(null, notificationObject);
expect(notifications.update).toHaveBeenCalledWith(
null,
notificationObject
);
});
});
});

View file

@ -89,4 +89,4 @@ export const getSeriesInfo = (customProps) => {
const cp = customProps || {};
const pr = cp.program || {};
return { tvg_id: pr.tvg_id, title: pr.title };
};
};

View file

@ -24,7 +24,7 @@ export const formatTime = (seconds) => {
export const getMovieDisplayTitle = (vodContent) => {
return vodContent.content_name;
}
};
export const getEpisodeDisplayTitle = (metadata) => {
const season = metadata.season_number
@ -34,18 +34,18 @@ export const getEpisodeDisplayTitle = (metadata) => {
? `E${metadata.episode_number.toString().padStart(2, '0')}`
: 'E??';
return `${metadata.series_name} - ${season}${episode}`;
}
};
export const getMovieSubtitle = (metadata) => {
const parts = [];
if (metadata.genre) parts.push(metadata.genre);
// We'll handle rating separately as a badge now
return parts;
}
};
export const getEpisodeSubtitle = (metadata) => {
return [metadata.episode_name || 'Episode'];
}
};
export const calculateProgress = (connection, duration_secs) => {
if (!connection || !duration_secs) {
@ -92,7 +92,7 @@ export const calculateProgress = (connection, duration_secs) => {
currentTime: Math.max(0, currentTime), // Don't go negative
totalTime: totalSeconds,
};
}
};
export const calculateConnectionDuration = (connection) => {
// If duration is provided by API, use it
@ -115,9 +115,12 @@ export const calculateConnectionDuration = (connection) => {
}
return 'Unknown duration';
}
};
export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => {
export const calculateConnectionStartTime = (
connection,
fullDateTimeFormat
) => {
if (connection.connected_at) {
return format(connection.connected_at * 1000, fullDateTimeFormat);
}
@ -136,4 +139,4 @@ export const calculateConnectionStartTime = (connection, fullDateTimeFormat) =>
}
return 'Unknown';
}
};

View file

@ -1,7 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
getConfirmationDetails,
} from '../PluginCardUtils';
import { getConfirmationDetails } from '../PluginCardUtils';
describe('PluginCardUtils', () => {
describe('getConfirmationDetails', () => {
@ -13,7 +11,8 @@ describe('PluginCardUtils', () => {
expect(result).toEqual({
requireConfirm: true,
confirmTitle: 'Run Test Action?',
confirmMessage: 'You\'re about to run "Test Action" from "Test Plugin".',
confirmMessage:
'You\'re about to run "Test Action" from "Test Plugin".',
});
});

View file

@ -156,7 +156,9 @@ describe('RecordingCardUtils', () => {
const channel = { uuid: 'channel-123' };
const result = getShowVideoUrl(channel, 'dev');
expect(result).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/);
expect(result).toMatch(
/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/
);
});
});
@ -208,7 +210,9 @@ describe('RecordingCardUtils', () => {
it('handles bulk remove error gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation();
API.bulkRemoveSeriesRecordings.mockRejectedValue(new Error('Bulk remove failed'));
API.bulkRemoveSeriesRecordings.mockRejectedValue(
new Error('Bulk remove failed')
);
API.deleteSeriesRule.mockResolvedValue();
const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };

View file

@ -14,31 +14,43 @@ describe('StreamConnectionCardUtils', () => {
describe('getBufferingSpeedThreshold', () => {
it('should return parsed buffering_speed from proxy settings', () => {
const proxySetting = {
value: { buffering_speed: 2.5 }
value: { buffering_speed: 2.5 },
};
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5);
expect(
StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)
).toBe(2.5);
});
it('should return 1.0 for invalid JSON', () => {
const proxySetting = { value: { buffering_speed: 'invalid' } };
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
expect(
StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)
).toBe(1.0);
consoleSpy.mockRestore();
});
it('should return 1.0 when buffering_speed is not a number', () => {
const proxySetting = {
value: JSON.stringify({ buffering_speed: 'not a number' })
value: JSON.stringify({ buffering_speed: 'not a number' }),
};
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
expect(
StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)
).toBe(1.0);
});
it('should return 1.0 when proxySetting is null', () => {
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(1.0);
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(
1.0
);
});
it('should return 1.0 when value is missing', () => {
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(1.0);
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(
1.0
);
});
});
@ -60,17 +72,14 @@ describe('StreamConnectionCardUtils', () => {
it('should create map from m3u accounts array', () => {
const m3uAccounts = [
{ id: 1, name: 'Account 1' },
{ id: 2, name: 'Account 2' }
{ id: 2, name: 'Account 2' },
];
const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
expect(result).toEqual({ 1: 'Account 1', 2: 'Account 2' });
});
it('should handle accounts without id', () => {
const m3uAccounts = [
{ name: 'Account 1' },
{ id: 2, name: 'Account 2' }
];
const m3uAccounts = [{ name: 'Account 1' }, { id: 2, name: 'Account 2' }];
const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
expect(result).toEqual({ 2: 'Account 2' });
});
@ -100,7 +109,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream when channelUrl includes stream url', () => {
const streamData = [
{ id: 1, url: 'http://example.com/stream1' },
{ id: 2, url: 'http://example.com/stream2' }
{ id: 2, url: 'http://example.com/stream2' },
];
const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
streamData,
@ -111,7 +120,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream when stream url includes channelUrl', () => {
const streamData = [
{ id: 1, url: 'http://example.com/stream1/playlist.m3u8' }
{ id: 1, url: 'http://example.com/stream1/playlist.m3u8' },
];
const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
streamData,
@ -134,7 +143,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream by id as string', () => {
const streams = [
{ id: 1, name: 'Stream 1' },
{ id: 2, name: 'Stream 2' }
{ id: 2, name: 'Stream 2' },
];
const result = StreamConnectionCardUtils.getSelectedStream(streams, '2');
expect(result).toEqual(streams[1]);
@ -167,11 +176,20 @@ describe('StreamConnectionCardUtils', () => {
dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY, HH:mm:ss');
const accessor = StreamConnectionCardUtils.connectedAccessor(
'MM/DD/YYYY, HH:mm:ss'
);
const result = accessor({ connected_since: 7200 });
expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second');
expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY, HH:mm:ss');
expect(dateTimeUtils.subtract).toHaveBeenCalledWith(
mockNow,
7200,
'second'
);
expect(dateTimeUtils.format).toHaveBeenCalledWith(
mockConnectedTime,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('01/01/2024 10:00:00');
});
@ -181,7 +199,8 @@ describe('StreamConnectionCardUtils', () => {
dateTimeUtils.initializeTime.mockReturnValue(mockTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const accessor =
StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const result = accessor({ connected_at: 1704103200 });
expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000);
@ -189,7 +208,8 @@ describe('StreamConnectionCardUtils', () => {
});
it('should return Unknown when no time data available', () => {
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const accessor =
StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const result = accessor({});
expect(result).toBe('Unknown');
});
@ -202,7 +222,10 @@ describe('StreamConnectionCardUtils', () => {
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connected_since: 9000 });
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(9000, 'seconds');
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
9000,
'seconds'
);
expect(result).toBe('2h 30m');
});
@ -212,7 +235,10 @@ describe('StreamConnectionCardUtils', () => {
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connection_duration: 4500 });
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(4500, 'seconds');
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
4500,
'seconds'
);
expect(result).toBe('1h 15m');
});
@ -226,15 +252,23 @@ describe('StreamConnectionCardUtils', () => {
describe('getLogoUrl', () => {
it('should return cache_url from logos map when logoId exists', () => {
const logos = {
'logo-123': { cache_url: '/api/logos/logo-123/cache/' }
'logo-123': { cache_url: '/api/logos/logo-123/cache/' },
};
const result = StreamConnectionCardUtils.getLogoUrl('logo-123', logos, null);
const result = StreamConnectionCardUtils.getLogoUrl(
'logo-123',
logos,
null
);
expect(result).toBe('/api/logos/logo-123/cache/');
});
it('should fallback to previewedStream logo_url when logoId not in map', () => {
const previewedStream = { logo_url: 'http://example.com/logo.png' };
const result = StreamConnectionCardUtils.getLogoUrl('logo-456', {}, previewedStream);
const result = StreamConnectionCardUtils.getLogoUrl(
'logo-456',
{},
previewedStream
);
expect(result).toBe('http://example.com/logo.png');
});
@ -260,15 +294,18 @@ describe('StreamConnectionCardUtils', () => {
it('should format stream options with account names from map', () => {
const streams = [
{ id: 1, name: 'Stream 1', m3u_account: 100 },
{ id: 2, name: 'Stream 2', m3u_account: 200 }
{ id: 2, name: 'Stream 2', m3u_account: 200 },
];
const accountsMap = { 100: 'Premium Account', 200: 'Basic Account' };
const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
const result = StreamConnectionCardUtils.getStreamOptions(
streams,
accountsMap
);
expect(result).toEqual([
{ value: '1', label: 'Stream 1 [Premium Account]' },
{ value: '2', label: 'Stream 2 [Basic Account]' }
{ value: '2', label: 'Stream 2 [Basic Account]' },
]);
});
@ -284,7 +321,10 @@ describe('StreamConnectionCardUtils', () => {
const streams = [{ id: 5, m3u_account: 100 }];
const accountsMap = { 100: 'Account' };
const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
const result = StreamConnectionCardUtils.getStreamOptions(
streams,
accountsMap
);
expect(result[0].label).toBe('Stream #5 [Account]');
});

View file

@ -96,7 +96,7 @@ describe('VodConnectionCardUtils', () => {
const metadata = {
series_name: 'Breaking Bad',
season_number: 1,
episode_number: 5
episode_number: 5,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Breaking Bad - S01E05');
@ -106,7 +106,7 @@ describe('VodConnectionCardUtils', () => {
const metadata = {
series_name: 'The Office',
season_number: 3,
episode_number: 9
episode_number: 9,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('The Office - S03E09');
@ -115,7 +115,7 @@ describe('VodConnectionCardUtils', () => {
it('should use S?? when season_number is missing', () => {
const metadata = {
series_name: 'Lost',
episode_number: 5
episode_number: 5,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Lost - S??E05');
@ -124,7 +124,7 @@ describe('VodConnectionCardUtils', () => {
it('should use E?? when episode_number is missing', () => {
const metadata = {
series_name: 'Friends',
season_number: 2
season_number: 2,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Friends - S02E??');
@ -167,7 +167,7 @@ describe('VodConnectionCardUtils', () => {
it('should calculate progress from last_seek_percentage', () => {
const connection = {
last_seek_percentage: 50,
last_seek_timestamp: 990 // 10 seconds ago
last_seek_timestamp: 990, // 10 seconds ago
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -179,7 +179,7 @@ describe('VodConnectionCardUtils', () => {
it('should cap currentTime at duration when seeking', () => {
const connection = {
last_seek_percentage: 95,
last_seek_timestamp: 900 // 100 seconds ago
last_seek_timestamp: 900, // 100 seconds ago
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -189,7 +189,7 @@ describe('VodConnectionCardUtils', () => {
it('should fallback to position_seconds when seek data unavailable', () => {
const connection = {
position_seconds: 75
position_seconds: 75,
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -218,7 +218,7 @@ describe('VodConnectionCardUtils', () => {
it('should ensure currentTime is not negative', () => {
const connection = {
last_seek_percentage: 10,
last_seek_timestamp: 2000 // In the future somehow
last_seek_timestamp: 2000, // In the future somehow
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@ -231,9 +231,13 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 30m');
const connection = { duration: 5400 };
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(5400, 'seconds');
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
5400,
'seconds'
);
expect(result).toBe('1h 30m');
});
@ -242,22 +246,28 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
const connection = { client_id: 'vod_900000_abc' };
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(100, 'seconds');
expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
100,
'seconds'
);
expect(result).toBe('45m');
});
it('should return Unknown duration when no data available', () => {
const connection = {};
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(result).toBe('Unknown duration');
});
it('should return Unknown duration when client_id is invalid format', () => {
const connection = { client_id: 'invalid_format' };
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(result).toBe('Unknown duration');
});
@ -267,7 +277,8 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
const connection = { client_id: 'vod_invalid_abc' };
const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
const result =
VodConnectionCardUtils.calculateConnectionDuration(connection);
// If parseInt fails, the code should still handle it
expect(result).toBe('45m'); // or 'Unknown duration' depending on implementation
@ -279,9 +290,15 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00');
const connection = { connected_at: 1705329000 };
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY, HH:mm:ss'
);
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY, HH:mm:ss');
expect(dateTimeUtils.format).toHaveBeenCalledWith(
1705329000000,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('01/15/2024 14:30:00');
});
@ -289,22 +306,34 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
const connection = { client_id: 'vod_1705323600000_abc' };
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY, HH:mm:ss'
);
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY, HH:mm:ss');
expect(dateTimeUtils.format).toHaveBeenCalledWith(
1705323600000,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('01/15/2024 13:00:00');
});
it('should return Unknown when no timestamp data available', () => {
const connection = {};
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY, HH:mm:ss'
);
expect(result).toBe('Unknown');
});
it('should return Unknown when client_id is invalid format', () => {
const connection = { client_id: 'invalid_format' };
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY'
);
expect(result).toBe('Unknown');
});
@ -313,11 +342,13 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
const connection = { client_id: 'vod_notanumber_abc' };
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
const result = VodConnectionCardUtils.calculateConnectionStartTime(
connection,
'MM/DD/YYYY'
);
// If parseInt succeeds on any number, format will be called
expect(result).toBe('01/15/2024 13:00:00'); // or 'Unknown' depending on implementation
});
});
});

View file

@ -40,7 +40,8 @@ export const format = (dateTime, formatStr) =>
export const getNow = () => dayjs();
export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize();
export const toFriendlyDuration = (dateTime, unit) =>
dayjs.duration(dateTime, unit).humanize();
export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
@ -113,7 +114,8 @@ export const useDateTimeFormat = () => {
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
// Full format strings for detailed date-time displays
const fullDateFormat = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const fullDateFormat =
dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss';
const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`;
@ -278,4 +280,4 @@ export const getDefaultTimeZone = () => {
} catch (error) {
return 'UTC';
}
};
};

View file

@ -33,7 +33,7 @@ const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
const st = toUserTime(r.start_time);
return st.isAfter(userNow());
});
}
};
const dedupeByProgram = (filtered) => {
// Deduplicate by program.id if present, else by time+title
@ -62,7 +62,7 @@ const dedupeByProgram = (filtered) => {
deduped.push(r);
}
return deduped;
}
};
export const getUpcomingEpisodes = (
isSeriesGroup,

View file

@ -63,4 +63,4 @@ export const deleteRecurringRuleById = async (ruleId) => {
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
await API.updateRecurringRule(ruleId, { enabled: checked });
};
};

View file

@ -15,7 +15,7 @@ describe('RecordingDetailsModalUtils', () => {
audio_codec: 'AAC',
audio_channels: 2,
sample_rate: 48000,
audio_bitrate: 128
audio_bitrate: 128,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
@ -28,49 +28,45 @@ describe('RecordingDetailsModalUtils', () => {
['Audio Codec', 'AAC'],
['Audio Channels', 2],
['Sample Rate', '48000 Hz'],
['Audio Bitrate', '128 kb/s']
['Audio Bitrate', '128 kb/s'],
]);
});
it('should use width x height when resolution is not present', () => {
const stats = {
width: 1280,
height: 720
height: 720,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Resolution', '1280x720']
]);
expect(result).toEqual([['Resolution', '1280x720']]);
});
it('should prefer resolution over width/height', () => {
const stats = {
resolution: '1920x1080',
width: 1280,
height: 720
height: 720,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Resolution', '1920x1080']
]);
expect(result).toEqual([['Resolution', '1920x1080']]);
});
it('should filter out null values', () => {
const stats = {
video_codec: 'H.264',
resolution: null,
source_fps: 30
source_fps: 30,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264'],
['FPS', 30]
['FPS', 30],
]);
});
@ -78,74 +74,68 @@ describe('RecordingDetailsModalUtils', () => {
const stats = {
video_codec: 'H.264',
source_fps: undefined,
audio_codec: 'AAC'
audio_codec: 'AAC',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264'],
['Audio Codec', 'AAC']
['Audio Codec', 'AAC'],
]);
});
it('should filter out empty strings', () => {
const stats = {
video_codec: '',
audio_codec: 'AAC'
audio_codec: 'AAC',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Audio Codec', 'AAC']
]);
expect(result).toEqual([['Audio Codec', 'AAC']]);
});
it('should handle missing width or height gracefully', () => {
const stats = {
width: 1920,
video_codec: 'H.264'
video_codec: 'H.264',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264']
]);
expect(result).toEqual([['Video Codec', 'H.264']]);
});
it('should format bitrates correctly', () => {
const stats = {
video_bitrate: 2500,
audio_bitrate: 192
audio_bitrate: 192,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Bitrate', '2500 kb/s'],
['Audio Bitrate', '192 kb/s']
['Audio Bitrate', '192 kb/s'],
]);
});
it('should format sample rate correctly', () => {
const stats = {
sample_rate: 44100
sample_rate: 44100,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Sample Rate', '44100 Hz']
]);
expect(result).toEqual([['Sample Rate', '44100 Hz']]);
});
it('should return empty array when no valid stats', () => {
const stats = {
video_codec: null,
resolution: undefined,
source_fps: ''
source_fps: '',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
@ -193,7 +183,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should return rating from program custom_properties', () => {
const customProps = {};
const program = {
custom_properties: { rating: 'TV-14' }
custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@ -204,7 +194,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should prefer customProps rating over program rating', () => {
const customProps = { rating: 'TV-MA' };
const program = {
custom_properties: { rating: 'TV-14' }
custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@ -215,7 +205,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should prefer rating_value over program rating', () => {
const customProps = { rating_value: 'PG-13' };
const program = {
custom_properties: { rating: 'TV-14' }
custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@ -294,16 +284,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' }
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
{
start_time: '2024-01-02T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show2', title: 'Other Show' }
}
}
program: { tvg_id: 'show2', title: 'Other Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -325,16 +315,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2023-12-31T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' }
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
{
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show' }
}
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -358,8 +348,8 @@ describe('RecordingDetailsModalUtils', () => {
custom_properties: {
season: 1,
episode: 5,
program: { tvg_id: 'show1', title: 'Test Show' }
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
{
start_time: '2024-01-02T18:00:00',
@ -367,9 +357,9 @@ describe('RecordingDetailsModalUtils', () => {
custom_properties: {
season: 1,
episode: 5,
program: { tvg_id: 'show1', title: 'Test Show' }
}
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -391,17 +381,17 @@ describe('RecordingDetailsModalUtils', () => {
channel: 'ch1',
custom_properties: {
onscreen_episode: 'S01E05',
program: { tvg_id: 'show1', title: 'Test Show' }
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
{
start_time: '2024-01-02T18:00:00',
channel: 'ch2',
custom_properties: {
onscreen_episode: 's01e05',
program: { tvg_id: 'show1', title: 'Test Show' }
}
}
program: { tvg_id: 'show1', title: 'Test Show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -425,9 +415,9 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
sub_title: 'The Beginning'
}
}
sub_title: 'The Beginning',
},
},
},
{
start_time: '2024-01-02T18:00:00',
@ -436,10 +426,10 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
sub_title: 'The Beginning'
}
}
}
sub_title: 'The Beginning',
},
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -460,16 +450,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
}
program: { tvg_id: 'show1', title: 'Test Show', id: 123 },
},
},
{
start_time: '2024-01-02T18:00:00',
channel: 'ch2',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
}
}
program: { tvg_id: 'show1', title: 'Test Show', id: 123 },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -491,25 +481,25 @@ describe('RecordingDetailsModalUtils', () => {
end_time: '2024-01-03T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 3 }
}
program: { tvg_id: 'show1', title: 'Test Show', id: 3 },
},
},
{
start_time: '2024-01-02T12:00:00',
end_time: '2024-01-02T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
}
program: { tvg_id: 'show1', title: 'Test Show', id: 1 },
},
},
{
start_time: '2024-01-04T12:00:00',
end_time: '2024-01-04T13:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 4 }
}
}
program: { tvg_id: 'show1', title: 'Test Show', id: 4 },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -533,9 +523,9 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
}
}
program: { tvg_id: 'show1', title: 'Test Show', id: 1 },
},
},
};
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -556,9 +546,9 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
program: { tvg_id: 'show1', title: 'test show' }
}
}
program: { tvg_id: 'show1', title: 'test show' },
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -582,9 +572,9 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
custom_properties: { season: 2, episode: 3 }
}
}
custom_properties: { season: 2, episode: 3 },
},
},
},
{
start_time: '2024-01-02T18:00:00',
@ -593,10 +583,10 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
custom_properties: { season: 2, episode: 3 }
}
}
}
custom_properties: { season: 2, episode: 3 },
},
},
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@ -615,8 +605,8 @@ describe('RecordingDetailsModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
channel: 'ch1'
}
channel: 'ch1',
},
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(

View file

@ -6,8 +6,8 @@ import dayjs from 'dayjs';
vi.mock('../../../api.js', () => ({
default: {
updateRecurringRule: vi.fn(),
deleteRecurringRule: vi.fn()
}
deleteRecurringRule: vi.fn(),
},
}));
describe('RecurringRuleModalUtils', () => {
@ -20,7 +20,7 @@ describe('RecurringRuleModalUtils', () => {
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' }
ch3: { id: 3, channel_number: '15', name: 'CBS' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -28,7 +28,7 @@ describe('RecurringRuleModalUtils', () => {
expect(result).toEqual([
{ value: '2', label: 'NBC' },
{ value: '1', label: 'ABC' },
{ value: '3', label: 'CBS' }
{ value: '3', label: 'CBS' },
]);
});
@ -36,7 +36,7 @@ describe('RecurringRuleModalUtils', () => {
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' }
ch3: { id: 3, channel_number: '10', name: 'MBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -44,35 +44,35 @@ describe('RecurringRuleModalUtils', () => {
expect(result).toEqual([
{ value: '2', label: 'ABC' },
{ value: '3', label: 'MBC' },
{ value: '1', label: 'ZBC' }
{ 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' }
ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '1', label: 'ABC' },
{ value: '2', label: 'NBC' }
{ 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: '' }
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' }
{ value: '1', label: 'Channel 1' },
]);
});
@ -96,7 +96,7 @@ describe('RecurringRuleModalUtils', () => {
it('should convert channel id to string value', () => {
const channels = {
ch1: { id: 123, channel_number: '10', name: 'ABC' }
ch1: { id: 123, channel_number: '10', name: 'ABC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -108,7 +108,7 @@ describe('RecurringRuleModalUtils', () => {
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' }
ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@ -131,16 +131,16 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-03T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-04T12:00:00',
custom_properties: { rule: { id: 2 } }
}
custom_properties: { rule: { id: 2 } },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -159,12 +159,12 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2023-12-31T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
}
custom_properties: { rule: { id: 1 } },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -182,16 +182,16 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-04T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-03T12:00:00',
custom_properties: { rule: { id: 1 } }
}
custom_properties: { rule: { id: 1 } },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -211,12 +211,12 @@ describe('RecurringRuleModalUtils', () => {
const recordings = {
rec1: {
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: { id: 1 } }
custom_properties: { rule: { id: 1 } },
},
rec2: {
start_time: '2024-01-03T12:00:00',
custom_properties: { rule: { id: 1 } }
}
custom_properties: { rule: { id: 1 } },
},
};
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -254,8 +254,8 @@ describe('RecurringRuleModalUtils', () => {
it('should handle recordings without custom_properties', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00'
}
start_time: '2024-01-02T12:00:00',
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -272,8 +272,8 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
custom_properties: {}
}
custom_properties: {},
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -290,8 +290,8 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
custom_properties: { rule: null }
}
custom_properties: { rule: null },
},
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@ -315,7 +315,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-01-01',
end_date: '2024-12-31',
rule_name: 'My Rule',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -328,7 +328,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-01-01',
end_date: '2024-12-31',
name: 'My Rule',
enabled: true
enabled: true,
});
});
@ -338,7 +338,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: ['0', '6'],
start_time: '10:00',
end_time: '11:00',
enabled: false
enabled: false,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -351,7 +351,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: false
enabled: false,
});
});
@ -360,7 +360,7 @@ describe('RecurringRuleModalUtils', () => {
channel_id: '5',
start_time: '10:00',
end_time: '11:00',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -373,7 +373,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
@ -385,7 +385,7 @@ describe('RecurringRuleModalUtils', () => {
end_time: '11:00',
start_date: dayjs('2024-06-15'),
end_date: dayjs('2024-12-25'),
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -398,7 +398,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-06-15',
end_date: '2024-12-25',
name: '',
enabled: true
enabled: true,
});
});
@ -410,7 +410,7 @@ describe('RecurringRuleModalUtils', () => {
end_time: '11:00',
start_date: null,
end_date: null,
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -423,7 +423,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
@ -434,7 +434,7 @@ describe('RecurringRuleModalUtils', () => {
start_time: '10:00',
end_time: '11:00',
rule_name: ' Trimmed Name ',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -447,7 +447,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: 'Trimmed Name',
enabled: true
enabled: true,
});
});
@ -457,7 +457,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: [],
start_time: '10:00',
end_time: '11:00',
enabled: true
enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -470,7 +470,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
@ -480,7 +480,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: [],
start_time: '10:00',
end_time: '11:00',
enabled: 'true'
enabled: 'true',
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@ -493,7 +493,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
enabled: true
enabled: true,
});
});
});
@ -518,7 +518,7 @@ describe('RecurringRuleModalUtils', () => {
await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, true);
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
enabled: true
enabled: true,
});
});
@ -526,7 +526,7 @@ describe('RecurringRuleModalUtils', () => {
await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, false);
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
enabled: false
enabled: false,
});
});
});

View file

@ -10,13 +10,13 @@ export const uploadComskipIni = async (file) => {
export const getDvrSettingsFormInitialValues = () => {
return {
'tv_template': '',
'movie_template': '',
'tv_fallback_template': '',
'movie_fallback_template': '',
'comskip_enabled': false,
'comskip_custom_path': '',
'pre_offset_minutes': 0,
'post_offset_minutes': 0,
tv_template: '',
movie_template: '',
tv_fallback_template: '',
movie_fallback_template: '',
comskip_enabled: false,
comskip_custom_path: '',
pre_offset_minutes: 0,
post_offset_minutes: 0,
};
};
};

View file

@ -2,7 +2,8 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
const M3U_EPG_DEFAULTS = '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
const M3U_EPG_DEFAULTS =
'127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
export const getNetworkAccessFormInitialValues = () => {
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
@ -39,4 +40,4 @@ export const getNetworkAccessDefaults = () => {
XC_API: '0.0.0.0/0,::/0',
UI: '0.0.0.0/0,::/0',
};
};
};

View file

@ -15,4 +15,4 @@ export const getProxySettingDefaults = () => {
channel_shutdown_delay: 0,
channel_init_grace_period: 5,
};
};
};

View file

@ -16,4 +16,4 @@ export const getStreamSettingsFormValidation = () => {
default_stream_profile: isNotEmpty('Select a stream profile'),
preferred_region: isNotEmpty('Select a region'),
};
};
};

View file

@ -14,4 +14,4 @@ export const saveTimeZoneSetting = async (tzValue, settings) => {
value: newValue,
});
}
};
};

View file

@ -13,7 +13,7 @@ describe('DvrSettingsFormUtils', () => {
it('should call API.getComskipConfig and return result', async () => {
const mockConfig = {
enabled: true,
custom_path: '/path/to/comskip'
custom_path: '/path/to/comskip',
};
API.getComskipConfig.mockResolvedValue(mockConfig);
@ -27,13 +27,17 @@ describe('DvrSettingsFormUtils', () => {
const error = new Error('API Error');
API.getComskipConfig.mockRejectedValue(error);
await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow('API Error');
await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow(
'API Error'
);
});
});
describe('uploadComskipIni', () => {
it('should call API.uploadComskipIni with file and return result', async () => {
const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
const mockFile = new File(['content'], 'comskip.ini', {
type: 'text/plain',
});
const mockResponse = { success: true };
API.uploadComskipIni.mockResolvedValue(mockResponse);
@ -44,11 +48,15 @@ describe('DvrSettingsFormUtils', () => {
});
it('should handle API errors', async () => {
const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
const mockFile = new File(['content'], 'comskip.ini', {
type: 'text/plain',
});
const error = new Error('Upload failed');
API.uploadComskipIni.mockRejectedValue(error);
await expect(DvrSettingsFormUtils.uploadComskipIni(mockFile)).rejects.toThrow('Upload failed');
await expect(
DvrSettingsFormUtils.uploadComskipIni(mockFile)
).rejects.toThrow('Upload failed');
});
});
@ -57,14 +65,14 @@ describe('DvrSettingsFormUtils', () => {
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
expect(result).toEqual({
'tv_template': '',
'movie_template': '',
'tv_fallback_template': '',
'movie_fallback_template': '',
'comskip_enabled': false,
'comskip_custom_path': '',
'pre_offset_minutes': 0,
'post_offset_minutes': 0,
tv_template: '',
movie_template: '',
tv_fallback_template: '',
movie_fallback_template: '',
comskip_enabled: false,
comskip_custom_path: '',
pre_offset_minutes: 0,
post_offset_minutes: 0,
});
});

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