mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 09:06:06 +00:00
Merge branch 'Dispatcharr:main' into fix/947-Connection-capacity-leak-failed-stream-initialization-permanently-consumes-connection-slot
This commit is contained in:
commit
681ffe1779
118 changed files with 4245 additions and 1850 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
250
Plugins.md
250
Plugins.md
|
|
@ -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 plugin’s `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: Don’t Ask Users for URL/User/Password
|
||||
Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s 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 backend’s 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 don’t know the route name, inspect `apps/*/api_urls.py` or Django’s 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 [
|
|||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,4 @@ class ErrorBoundary extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
export default ErrorBoundary;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -140,4 +140,4 @@ const VODCard = ({ vod, onClick }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default VODCard;
|
||||
export default VODCard;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -180,7 +180,6 @@ const ChannelTableHeader = ({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const assignChannels = async () => {
|
||||
try {
|
||||
// Call our custom API endpoint
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ import App from './App';
|
|||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ const PageContent = () => {
|
|||
<UsersTable />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageContent/>
|
||||
<PageContent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -38,4 +38,4 @@ export const usePluginStore = create((set, get) => ({
|
|||
set({ plugins: [] });
|
||||
get().fetchPlugins();
|
||||
},
|
||||
}));
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,4 +89,4 @@ export const getSeriesInfo = (customProps) => {
|
|||
const cp = customProps || {};
|
||||
const pr = cp.program || {};
|
||||
return { tvg_id: pr.tvg_id, title: pr.title };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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".',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -63,4 +63,4 @@ export const deleteRecurringRuleById = async (ruleId) => {
|
|||
|
||||
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
|
||||
await API.updateRecurringRule(ruleId, { enabled: checked });
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,4 +15,4 @@ export const getProxySettingDefaults = () => {
|
|||
channel_shutdown_delay: 0,
|
||||
channel_init_grace_period: 5,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,4 +16,4 @@ export const getStreamSettingsFormValidation = () => {
|
|||
default_stream_profile: isNotEmpty('Select a stream profile'),
|
||||
preferred_region: isNotEmpty('Select a region'),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,4 +14,4 @@ export const saveTimeZoneSetting = async (tzValue, settings) => {
|
|||
value: newValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue