mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
commit
75ed2f3351
177 changed files with 16838 additions and 2810 deletions
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
72
CHANGELOG.md
72
CHANGELOG.md
|
|
@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Add system notifications and update checks
|
||||
-Real-time notifications for system events and alerts
|
||||
-Per-user notification management and dismissal
|
||||
-Update check on startup and every 24 hours to notify users of available versions
|
||||
-Notification center UI component
|
||||
-Automatic cleanup of expired notifications
|
||||
- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click.
|
||||
- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view.
|
||||
- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations.
|
||||
- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including:
|
||||
- `useLocalStorage` hook tests with localStorage mocking and error handling
|
||||
- `useSmartLogos` hook tests for logo loading and management
|
||||
- `useTablePreferences` hook tests for table settings persistence
|
||||
- `useAuthStore` tests for authentication flow and token management
|
||||
- `useChannelsStore` tests for channel data management
|
||||
- `useUserAgentsStore` tests for user agent CRUD operations
|
||||
- `useUsersStore` tests for user management functionality
|
||||
- `useVODLogosStore` tests for VOD logo operations
|
||||
- `useVideoStore` tests for video player state management
|
||||
- `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
|
||||
|
||||
- XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors.
|
||||
- App initialization efficiency: Refactored app initialization to prevent redundant execution across multiple worker processes. Created `dispatcharr.app_initialization` utility module with `should_skip_initialization()` function that prevents custom initialization tasks (backup scheduler sync, developer notifications sync) from running during management commands, in worker processes, or in development servers. This significantly reduces startup overhead in multi-worker deployments (e.g., uWSGI with 10 workers now syncs the scheduler once instead of 10 times). Applied to both `CoreConfig` and `BackupsConfig` apps.
|
||||
- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (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). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default.
|
||||
- Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues.
|
||||
- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes.
|
||||
- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
|
||||
- Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
|
||||
- Modern OpenAPI 3.0 specification compliance
|
||||
- Better auto-generation of request/response schemas
|
||||
- Improved documentation accuracy with serializer introspection
|
||||
- Switched to uv for package management: Migrated from pip to uv (Astral's fast Python package installer) for improved dependency resolution speed and reliability. This includes updates to Docker build processes, installation scripts (debian_install.sh), and project configuration (pyproject.toml) to leverage uv's features like virtual environment management and lockfile generation. - Thanks [@tobimichael96](https://github.com/tobimichael96) for getting it started!
|
||||
- Copy to Clipboard: Refactored `copyToClipboard` utility function to include notification handling internally, eliminating duplicate notification code across the frontend. The function now accepts optional parameters for customizing success/failure messages while providing consistent behavior across all copy operations.
|
||||
|
||||
### Fixed
|
||||
|
||||
- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. (Fixes #915)
|
||||
- Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention.
|
||||
- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892)
|
||||
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
|
||||
- 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
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ from django.http import JsonResponse, HttpResponse
|
|||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import viewsets, status
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
import json
|
||||
from .permissions import IsAdmin, Authenticated
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
|
@ -147,19 +147,15 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
return [IsAuthenticated()]
|
||||
return []
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Authenticate and log in a user",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["username", "password"],
|
||||
properties={
|
||||
"username": openapi.Schema(type=openapi.TYPE_STRING),
|
||||
"password": openapi.Schema(
|
||||
type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD
|
||||
),
|
||||
@extend_schema(
|
||||
description="Authenticate and log in a user",
|
||||
request=inline_serializer(
|
||||
name="LoginRequest",
|
||||
fields={
|
||||
"username": serializers.CharField(),
|
||||
"password": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
responses={200: "Login successful", 400: "Invalid credentials"},
|
||||
)
|
||||
def login(self, request):
|
||||
"""Logs in a user and returns user details"""
|
||||
|
|
@ -209,9 +205,8 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
)
|
||||
return Response({"error": "Invalid credentials"}, status=400)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Log out the current user",
|
||||
responses={200: "Logout successful"},
|
||||
@extend_schema(
|
||||
description="Log out the current user",
|
||||
)
|
||||
def logout(self, request):
|
||||
"""Logs out the authenticated user"""
|
||||
|
|
@ -245,32 +240,31 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
|
||||
return [IsAdmin()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve a list of users",
|
||||
@extend_schema(
|
||||
description="Retrieve a list of users",
|
||||
responses={200: UserSerializer(many=True)},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Retrieve a specific user by ID")
|
||||
@extend_schema(description="Retrieve a specific user by ID")
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Create a new user")
|
||||
@extend_schema(description="Create a new user")
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Update a user")
|
||||
@extend_schema(description="Update a user")
|
||||
def update(self, request, *args, **kwargs):
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Delete a user")
|
||||
@extend_schema(description="Delete a user")
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="get",
|
||||
operation_description="Get active user information",
|
||||
@extend_schema(
|
||||
description="Get active user information",
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_path="me")
|
||||
def me(self, request):
|
||||
|
|
@ -287,34 +281,33 @@ class GroupViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = GroupSerializer
|
||||
permission_classes = [Authenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve a list of groups",
|
||||
@extend_schema(
|
||||
description="Retrieve a list of groups",
|
||||
responses={200: GroupSerializer(many=True)},
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Retrieve a specific group by ID")
|
||||
@extend_schema(description="Retrieve a specific group by ID")
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Create a new group")
|
||||
@extend_schema(description="Create a new group")
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Update a group")
|
||||
@extend_schema(description="Update a group")
|
||||
def update(self, request, *args, **kwargs):
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(operation_description="Delete a group")
|
||||
@extend_schema(description="Delete a group")
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# 🔹 4) Permissions List API
|
||||
@swagger_auto_schema(
|
||||
method="get",
|
||||
operation_description="Retrieve a list of all permissions",
|
||||
@extend_schema(
|
||||
description="Retrieve a list of all permissions",
|
||||
responses={200: PermissionSerializer(many=True)},
|
||||
)
|
||||
@api_view(["GET"])
|
||||
|
|
|
|||
|
|
@ -1,23 +1,8 @@
|
|||
from django.urls import path, include, re_path
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.permissions import AllowAny
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
||||
|
||||
app_name = 'api'
|
||||
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
title="Dispatcharr API",
|
||||
default_version='v1',
|
||||
description="API documentation for Dispatcharr",
|
||||
terms_of_service="https://www.google.com/policies/terms/",
|
||||
contact=openapi.Contact(email="support@dispatcharr.local"),
|
||||
license=openapi.License(name="Unlicense"),
|
||||
),
|
||||
public=True,
|
||||
permission_classes=(AllowAny,),
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')),
|
||||
path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')),
|
||||
|
|
@ -35,8 +20,9 @@ urlpatterns = [
|
|||
|
||||
|
||||
|
||||
# Swagger Documentation api_urls
|
||||
re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
||||
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
# OpenAPI Schema and Documentation (drf-spectacular)
|
||||
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
|
||||
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
|
||||
path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,40 @@
|
|||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.backups"
|
||||
verbose_name = "Backups"
|
||||
|
||||
def ready(self):
|
||||
"""Initialize backup scheduler on app startup."""
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
|
||||
# Skip if this is a management command, worker process, or dev server
|
||||
if should_skip_initialization():
|
||||
return
|
||||
|
||||
logger.debug("Syncing backup scheduler on app startup")
|
||||
self._sync_backup_scheduler()
|
||||
|
||||
def _sync_backup_scheduler(self):
|
||||
"""Sync backup scheduler task to database."""
|
||||
from core.models import CoreSettings
|
||||
from .scheduler import _sync_periodic_task, DEFAULTS
|
||||
try:
|
||||
# Ensure settings exist with defaults if this is a new install
|
||||
CoreSettings.objects.get_or_create(
|
||||
key="backup_settings",
|
||||
defaults={"name": "Backup Settings", "value": DEFAULTS.copy()}
|
||||
)
|
||||
|
||||
# Always sync the periodic task (handles new installs, updates, or missing tasks)
|
||||
logger.debug("Syncing backup scheduler")
|
||||
_sync_periodic_task()
|
||||
except Exception as e:
|
||||
# Log but don't fail startup if there's an issue
|
||||
logger.warning(f"Failed to initialize backup scheduler: {e}")
|
||||
|
|
|
|||
|
|
@ -765,11 +765,12 @@ class BackupSchedulerTestCase(TestCase):
|
|||
|
||||
settings = scheduler.get_schedule_settings()
|
||||
|
||||
self.assertEqual(settings['enabled'], False)
|
||||
# These should match the DEFAULTS in scheduler.py
|
||||
self.assertEqual(settings['enabled'], True)
|
||||
self.assertEqual(settings['frequency'], 'daily')
|
||||
self.assertEqual(settings['time'], '03:00')
|
||||
self.assertEqual(settings['day_of_week'], 0)
|
||||
self.assertEqual(settings['retention_count'], 0)
|
||||
self.assertEqual(settings['retention_count'], 3)
|
||||
self.assertEqual(settings['cron_expression'], '')
|
||||
|
||||
def test_update_schedule_settings_stores_values(self):
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ from rest_framework.views import APIView
|
|||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import serializers
|
||||
from django.shortcuts import get_object_or_404, get_list_or_404
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, F
|
||||
|
|
@ -105,6 +106,7 @@ class StreamFilter(django_filters.FilterSet):
|
|||
m3u_account_is_active = django_filters.BooleanFilter(
|
||||
field_name="m3u_account__is_active"
|
||||
)
|
||||
tvg_id = django_filters.CharFilter(lookup_expr="icontains")
|
||||
|
||||
class Meta:
|
||||
model = Stream
|
||||
|
|
@ -114,6 +116,7 @@ class StreamFilter(django_filters.FilterSet):
|
|||
"m3u_account",
|
||||
"m3u_account_name",
|
||||
"m3u_account_is_active",
|
||||
"tvg_id",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -128,7 +131,7 @@ class StreamViewSet(viewsets.ModelViewSet):
|
|||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = StreamFilter
|
||||
search_fields = ["name", "channel_group__name"]
|
||||
ordering_fields = ["name", "channel_group__name", "m3u_account__name"]
|
||||
ordering_fields = ["name", "channel_group__name", "m3u_account__name", "tvg_id"]
|
||||
ordering = ["-name"]
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
@ -269,17 +272,15 @@ class StreamViewSet(viewsets.ModelViewSet):
|
|||
]
|
||||
})
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["ids"],
|
||||
properties={
|
||||
"ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="List of stream IDs to retrieve"
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
|
||||
request=inline_serializer(
|
||||
name="StreamByIdsRequest",
|
||||
fields={
|
||||
"ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of stream IDs to retrieve"
|
||||
),
|
||||
},
|
||||
),
|
||||
|
|
@ -342,10 +343,9 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
|
|||
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)",
|
||||
responses={200: "Cleanup completed"},
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Delete all channel groups that have no associations (no channels or M3U accounts)",
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="cleanup")
|
||||
def cleanup_unused_groups(self, request):
|
||||
|
|
@ -831,25 +831,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
# Return the response with the list of IDs
|
||||
return Response(list(channel_ids))
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["channel_ids"],
|
||||
properties={
|
||||
"starting_number": openapi.Schema(
|
||||
type=openapi.TYPE_NUMBER,
|
||||
description="Starting channel number to assign (can be decimal)",
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
|
||||
request=inline_serializer(
|
||||
name="AssignChannelsRequest",
|
||||
fields={
|
||||
"starting_number": serializers.FloatField(
|
||||
help_text="Starting channel number to assign (can be decimal)",
|
||||
required=False,
|
||||
),
|
||||
"channel_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="Channel IDs to assign",
|
||||
"channel_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Channel IDs to assign",
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={200: "Channels have been auto-assigned!"},
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="assign")
|
||||
def assign(self, request):
|
||||
|
|
@ -869,33 +866,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
{"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description=(
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description=(
|
||||
"Create a new channel from an existing stream. "
|
||||
"If 'channel_number' is provided, it will be used (if available); "
|
||||
"otherwise, the next available channel number is assigned. "
|
||||
"If 'channel_profile_ids' is provided, the channel will only be added to those profiles. "
|
||||
"Accepts either a single ID or an array of IDs."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["stream_id"],
|
||||
properties={
|
||||
"stream_id": openapi.Schema(
|
||||
type=openapi.TYPE_INTEGER, description="ID of the stream to link"
|
||||
request=inline_serializer(
|
||||
name="FromStreamRequest",
|
||||
fields={
|
||||
"stream_id": serializers.IntegerField(help_text="ID of the stream to link"),
|
||||
"channel_number": serializers.FloatField(
|
||||
help_text="(Optional) Desired channel number. Must not be in use.",
|
||||
required=False,
|
||||
),
|
||||
"channel_number": openapi.Schema(
|
||||
type=openapi.TYPE_NUMBER,
|
||||
description="(Optional) Desired channel number. Must not be in use.",
|
||||
),
|
||||
"name": openapi.Schema(
|
||||
type=openapi.TYPE_STRING, description="Desired channel name"
|
||||
),
|
||||
"channel_profile_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
|
||||
"name": serializers.CharField(help_text="Desired channel name", required=False),
|
||||
"channel_profile_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
|
||||
required=False,
|
||||
),
|
||||
},
|
||||
),
|
||||
|
|
@ -917,18 +909,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
if name is None:
|
||||
name = stream.name
|
||||
|
||||
# Check if client provided a channel_number; if not, auto-assign one.
|
||||
stream_custom_props = stream.custom_properties or {}
|
||||
# Check if client provided a channel_number; if not, use stream_chno or auto-assign
|
||||
channel_number = request.data.get("channel_number")
|
||||
|
||||
if channel_number is None:
|
||||
# Channel number not provided by client, check stream properties or auto-assign
|
||||
if "tvg-chno" in stream_custom_props:
|
||||
channel_number = float(stream_custom_props["tvg-chno"])
|
||||
elif "channel-number" in stream_custom_props:
|
||||
channel_number = float(stream_custom_props["channel-number"])
|
||||
elif "num" in stream_custom_props:
|
||||
channel_number = float(stream_custom_props["num"])
|
||||
# Channel number not provided by client, check stream's channel number or auto-assign
|
||||
if stream.stream_chno is not None:
|
||||
channel_number = stream.stream_chno
|
||||
elif channel_number == 0:
|
||||
# Special case: 0 means ignore provider numbers and auto-assign
|
||||
channel_number = None
|
||||
|
|
@ -949,9 +936,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
if Channel.objects.filter(channel_number=channel_number).exists():
|
||||
channel_number = Channel.get_next_available_channel_number(channel_number)
|
||||
# Get the tvc_guide_stationid from custom properties if it exists
|
||||
tvc_guide_stationid = None
|
||||
if "tvc-guide-stationid" in stream_custom_props:
|
||||
tvc_guide_stationid = stream_custom_props["tvc-guide-stationid"]
|
||||
stream_custom_props = stream.custom_properties or {}
|
||||
tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid")
|
||||
|
||||
channel_data = {
|
||||
"channel_number": channel_number,
|
||||
|
|
@ -1055,34 +1041,31 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description=(
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description=(
|
||||
"Asynchronously bulk create channels from stream IDs. "
|
||||
"Returns a task ID to track progress via WebSocket. "
|
||||
"This is the recommended approach for large bulk operations."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["stream_ids"],
|
||||
properties={
|
||||
"stream_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="List of stream IDs to create channels from"
|
||||
request=inline_serializer(
|
||||
name="FromStreamBulkRequest",
|
||||
fields={
|
||||
"stream_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of stream IDs to create channels from"
|
||||
),
|
||||
"channel_profile_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
|
||||
"channel_profile_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
|
||||
required=False,
|
||||
),
|
||||
"starting_channel_number": openapi.Schema(
|
||||
type=openapi.TYPE_INTEGER,
|
||||
description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number"
|
||||
"starting_channel_number": serializers.IntegerField(
|
||||
help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number",
|
||||
required=False,
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={202: "Task started successfully"},
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="from-stream/bulk")
|
||||
def from_stream_bulk(self, request):
|
||||
|
|
@ -1122,20 +1105,19 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
# ─────────────────────────────────────────────────────────
|
||||
# 6) EPG Fuzzy Matching
|
||||
# ─────────────────────────────────────────────────────────
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'channel_ids': openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||
description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.'
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
|
||||
request=inline_serializer(
|
||||
name="MatchEpgRequest",
|
||||
fields={
|
||||
'channel_ids': serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.',
|
||||
required=False,
|
||||
)
|
||||
}
|
||||
),
|
||||
responses={202: "EPG matching task initiated"},
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="match-epg")
|
||||
def match_epg(self, request):
|
||||
|
|
@ -1156,10 +1138,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
{"message": message}, status=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Try to auto-match this specific channel with EPG data.",
|
||||
responses={200: "EPG matching completed", 202: "EPG matching task initiated"},
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Try to auto-match this specific channel with EPG data.",
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_path="match-epg")
|
||||
def match_channel_epg(self, request, pk=None):
|
||||
|
|
@ -1186,16 +1167,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
# ─────────────────────────────────────────────────────────
|
||||
# 7) Set EPG and Refresh
|
||||
# ─────────────────────────────────────────────────────────
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Set EPG data for a channel and refresh program data",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["epg_data_id"],
|
||||
properties={
|
||||
"epg_data_id": openapi.Schema(
|
||||
type=openapi.TYPE_INTEGER, description="EPG data ID to link"
|
||||
)
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Set EPG data for a channel and refresh program data",
|
||||
request=inline_serializer(
|
||||
name="SetEpgRequest",
|
||||
fields={
|
||||
"epg_data_id": serializers.IntegerField(help_text="EPG data ID to link")
|
||||
},
|
||||
),
|
||||
responses={200: "EPG data linked and refresh triggered"},
|
||||
|
|
@ -1251,28 +1229,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=400)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description=(
|
||||
@extend_schema(
|
||||
description=(
|
||||
"Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). "
|
||||
"The channel will receive the next whole number after the target channel, and all subsequent "
|
||||
"channels will be renumbered accordingly."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"insert_after_id": openapi.Schema(
|
||||
type=openapi.TYPE_INTEGER,
|
||||
description="ID of the channel to insert after. Use null to move to the beginning.",
|
||||
nullable=True,
|
||||
request=inline_serializer(
|
||||
name="ReorderChannelRequest",
|
||||
fields={
|
||||
"insert_after_id": serializers.IntegerField(
|
||||
help_text="ID of the channel to insert after. Use null to move to the beginning.",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: "Channel reordered successfully",
|
||||
404: "Channel not found",
|
||||
400: "Invalid request",
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_path="reorder")
|
||||
def reorder(self, request, pk=None):
|
||||
|
|
@ -1340,25 +1312,23 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Associate multiple channels with EPG data without triggering a full refresh",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"associations": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"channel_id": openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||
"epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||
@extend_schema(
|
||||
methods=["POST"],
|
||||
description="Associate multiple channels with EPG data without triggering a full refresh",
|
||||
request=inline_serializer(
|
||||
name="BatchSetEpgRequest",
|
||||
fields={
|
||||
"associations": serializers.ListField(
|
||||
child=inline_serializer(
|
||||
name="EpgAssociation",
|
||||
fields={
|
||||
"channel_id": serializers.IntegerField(),
|
||||
"epg_data_id": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={200: "EPG data linked for multiple channels"},
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="batch-set-epg")
|
||||
def batch_set_epg(self, request):
|
||||
|
|
@ -1456,20 +1426,17 @@ class BulkDeleteStreamsAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Bulk delete streams by ID",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["stream_ids"],
|
||||
properties={
|
||||
"stream_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="Stream IDs to delete",
|
||||
@extend_schema(
|
||||
description="Bulk delete streams by ID",
|
||||
request=inline_serializer(
|
||||
name="BulkDeleteStreamsRequest",
|
||||
fields={
|
||||
"stream_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Stream IDs to delete",
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={204: "Streams deleted"},
|
||||
)
|
||||
def delete(self, request, *args, **kwargs):
|
||||
stream_ids = request.data.get("stream_ids", [])
|
||||
|
|
@ -1492,20 +1459,17 @@ class BulkDeleteChannelsAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Bulk delete channels by ID",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["channel_ids"],
|
||||
properties={
|
||||
"channel_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="Channel IDs to delete",
|
||||
@extend_schema(
|
||||
description="Bulk delete channels by ID",
|
||||
request=inline_serializer(
|
||||
name="BulkDeleteChannelsRequest",
|
||||
fields={
|
||||
"channel_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Channel IDs to delete",
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={204: "Channels deleted"},
|
||||
)
|
||||
def delete(self, request):
|
||||
channel_ids = request.data.get("channel_ids", [])
|
||||
|
|
@ -1527,20 +1491,17 @@ class BulkDeleteLogosAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Bulk delete logos by ID",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["logo_ids"],
|
||||
properties={
|
||||
"logo_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="Logo IDs to delete",
|
||||
@extend_schema(
|
||||
description="Bulk delete logos by ID",
|
||||
request=inline_serializer(
|
||||
name="BulkDeleteLogosRequest",
|
||||
fields={
|
||||
"logo_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Logo IDs to delete",
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={204: "Logos deleted"},
|
||||
)
|
||||
def delete(self, request):
|
||||
logo_ids = request.data.get("logo_ids", [])
|
||||
|
|
@ -1597,19 +1558,18 @@ class CleanupUnusedLogosAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Delete all channel logos that are not used by any channels",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"delete_files": openapi.Schema(
|
||||
type=openapi.TYPE_BOOLEAN,
|
||||
description="Whether to delete local logo files from disk",
|
||||
default=False
|
||||
@extend_schema(
|
||||
description="Delete all channel logos that are not used by any channels",
|
||||
request=inline_serializer(
|
||||
name="CleanupUnusedLogosRequest",
|
||||
fields={
|
||||
"delete_files": serializers.BooleanField(
|
||||
help_text="Whether to delete local logo files from disk",
|
||||
default=False,
|
||||
required=False,
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={200: "Cleanup completed"},
|
||||
)
|
||||
def post(self, request):
|
||||
"""Delete all channel logos with no channel associations"""
|
||||
|
|
@ -2019,29 +1979,9 @@ class BulkUpdateChannelMembershipAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
|
||||
request_body=BulkChannelProfileMembershipSerializer,
|
||||
responses={
|
||||
200: openapi.Response(
|
||||
description="Channels updated successfully",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"status": openapi.Schema(type=openapi.TYPE_STRING, example="success"),
|
||||
"updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"),
|
||||
"created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"),
|
||||
"invalid_channels": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||
description="List of channel IDs that don't exist"
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
400: "Invalid request data",
|
||||
404: "Profile not found",
|
||||
},
|
||||
@extend_schema(
|
||||
description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
|
||||
request=BulkChannelProfileMembershipSerializer,
|
||||
)
|
||||
def patch(self, request, profile_id):
|
||||
"""Bulk enable or disable channels for a specific profile"""
|
||||
|
|
@ -2406,71 +2346,24 @@ class SeriesRulesAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary="List all series rules",
|
||||
operation_description="Retrieve all configured DVR series recording rules.",
|
||||
responses={
|
||||
200: openapi.Response(
|
||||
description="List of series rules",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'rules': openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
|
||||
'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'),
|
||||
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
|
||||
},
|
||||
),
|
||||
description='List of series recording rules'
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
@extend_schema(
|
||||
summary="List all series rules",
|
||||
description="Retrieve all configured DVR series recording rules.",
|
||||
)
|
||||
def get(self, request):
|
||||
return Response({"rules": CoreSettings.get_dvr_series_rules()})
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary="Create or update a series rule",
|
||||
operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=['tvg_id'],
|
||||
properties={
|
||||
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
|
||||
'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'),
|
||||
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
|
||||
@extend_schema(
|
||||
summary="Create or update a series rule",
|
||||
description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
|
||||
request=inline_serializer(
|
||||
name="SeriesRuleRequest",
|
||||
fields={
|
||||
'tvg_id': serializers.CharField(help_text='Channel TVG ID'),
|
||||
'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'),
|
||||
'title': serializers.CharField(help_text='Series title', required=False),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: openapi.Response(
|
||||
description="Series rule created/updated successfully",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
|
||||
'rules': openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
'mode': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
'title': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
},
|
||||
),
|
||||
description='Updated list of all rules'
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
data = request.data or {}
|
||||
|
|
@ -2504,35 +2397,12 @@ class DeleteSeriesRuleAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary="Delete a series rule",
|
||||
operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
|
||||
manual_parameters=[
|
||||
openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'),
|
||||
@extend_schema(
|
||||
summary="Delete a series rule",
|
||||
description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
|
||||
parameters=[
|
||||
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
|
||||
],
|
||||
responses={
|
||||
200: openapi.Response(
|
||||
description="Series rule deleted successfully",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
|
||||
'rules': openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
'mode': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
'title': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
},
|
||||
),
|
||||
description='Updated list of all rules'
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
def delete(self, request, tvg_id):
|
||||
tvg_id = unquote(str(tvg_id or ""))
|
||||
|
|
@ -2548,26 +2418,15 @@ class EvaluateSeriesRulesAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary="Evaluate series rules",
|
||||
operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'),
|
||||
@extend_schema(
|
||||
summary="Evaluate series rules",
|
||||
description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
|
||||
request=inline_serializer(
|
||||
name="EvaluateSeriesRulesRequest",
|
||||
fields={
|
||||
"tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: openapi.Response(
|
||||
description="Evaluation completed successfully",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
tvg_id = request.data.get("tvg_id")
|
||||
|
|
@ -2590,31 +2449,17 @@ class BulkRemoveSeriesRecordingsAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary="Bulk remove scheduled recordings for a series",
|
||||
operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=['tvg_id'],
|
||||
properties={
|
||||
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'),
|
||||
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'),
|
||||
'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'),
|
||||
@extend_schema(
|
||||
summary="Bulk remove scheduled recordings for a series",
|
||||
description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
|
||||
request=inline_serializer(
|
||||
name="BulkRemoveSeriesRecordingsRequest",
|
||||
fields={
|
||||
"tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"),
|
||||
"title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"),
|
||||
"scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: openapi.Response(
|
||||
description="Recordings removed successfully",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
|
||||
'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'),
|
||||
},
|
||||
),
|
||||
),
|
||||
400: openapi.Response(description="Bad request (missing tvg_id)"),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
from django.utils import timezone
|
||||
|
|
|
|||
205
apps/channels/migrations/0033_stream_id_stream_chno.py
Normal file
205
apps/channels/migrations/0033_stream_id_stream_chno.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Generated by Django - Add stream_id and channel_number fields with data migration
|
||||
|
||||
from django.db import migrations, models
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def populate_fields_and_rehash(apps, schema_editor):
|
||||
"""
|
||||
Populate stream_id and stream_chno from custom_properties for XC account streams,
|
||||
populate stream_chno from tvg-chno for standard M3U accounts,
|
||||
then rehash XC streams using stable hash keys.
|
||||
"""
|
||||
Stream = apps.get_model('dispatcharr_channels', 'Stream')
|
||||
M3UAccount = apps.get_model('m3u', 'M3UAccount')
|
||||
CoreSettings = apps.get_model('core', 'CoreSettings')
|
||||
|
||||
# Get hash keys from settings
|
||||
try:
|
||||
stream_settings = CoreSettings.objects.get(key='stream_settings')
|
||||
hash_key_str = stream_settings.value.get('m3u_hash_key', '') if stream_settings.value else ''
|
||||
keys = [k.strip() for k in hash_key_str.split(',') if k.strip()] if hash_key_str else []
|
||||
except CoreSettings.DoesNotExist:
|
||||
keys = []
|
||||
|
||||
logger.info(f"Using hash keys: {keys}")
|
||||
|
||||
# Get XC account IDs
|
||||
xc_account_ids = set(
|
||||
M3UAccount.objects.filter(account_type='XC').values_list('id', flat=True)
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(xc_account_ids)} XC accounts")
|
||||
|
||||
# Track hash collisions for XC streams
|
||||
hash_map = {} # new_hash -> stream_id
|
||||
duplicates_to_delete = []
|
||||
|
||||
# Process all streams in batches
|
||||
batch_size = 1000
|
||||
processed = 0
|
||||
updated = 0
|
||||
|
||||
total_count = Stream.objects.count()
|
||||
logger.info(f"Processing {total_count} total streams")
|
||||
|
||||
streams_to_update = []
|
||||
|
||||
for stream in Stream.objects.select_related('channel_group', 'm3u_account').iterator(chunk_size=batch_size):
|
||||
processed += 1
|
||||
needs_update = False
|
||||
|
||||
custom_props = stream.custom_properties or {}
|
||||
is_xc = stream.m3u_account_id in xc_account_ids if stream.m3u_account_id else False
|
||||
|
||||
# Extract stream_id (XC accounts only)
|
||||
if is_xc and isinstance(custom_props, dict):
|
||||
provider_stream_id = custom_props.get('stream_id')
|
||||
if provider_stream_id:
|
||||
try:
|
||||
stream.stream_id = int(provider_stream_id)
|
||||
needs_update = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Extract stream_chno
|
||||
channel_num = None
|
||||
if isinstance(custom_props, dict):
|
||||
if is_xc:
|
||||
# XC accounts use 'num'
|
||||
channel_num = custom_props.get('num')
|
||||
else:
|
||||
# Standard M3U accounts use 'tvg-chno' or 'channel-number' (case insensitive check)
|
||||
for key in ['tvg-chno', 'TVG-CHNO', 'tvg-Chno', 'Tvg-Chno', 'channel-number', 'Channel-Number', 'CHANNEL-NUMBER']:
|
||||
if key in custom_props:
|
||||
channel_num = custom_props.get(key)
|
||||
break
|
||||
|
||||
if channel_num is not None:
|
||||
try:
|
||||
stream.stream_chno = float(channel_num)
|
||||
needs_update = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Rehash XC streams only when 'url' is in hash keys (otherwise hash wouldn't change)
|
||||
if is_xc and stream.stream_id and keys and 'url' in keys:
|
||||
# For XC accounts, use stream_id instead of url when 'url' is in the hash keys
|
||||
# This ensures credential/URL changes don't break stream identity
|
||||
effective_url = stream.stream_id
|
||||
|
||||
# Get group name
|
||||
group_name = stream.channel_group.name if stream.channel_group else None
|
||||
|
||||
# Build hash parts
|
||||
stream_parts = {
|
||||
"name": stream.name,
|
||||
"url": effective_url,
|
||||
"tvg_id": stream.tvg_id,
|
||||
"m3u_id": stream.m3u_account_id,
|
||||
"group": group_name
|
||||
}
|
||||
hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts}
|
||||
|
||||
# When using stream_id instead of URL, we MUST include m3u_id to prevent
|
||||
# collisions across different XC accounts (stream_id is only unique per account)
|
||||
if 'm3u_id' not in hash_parts:
|
||||
hash_parts['m3u_id'] = stream.m3u_account_id
|
||||
|
||||
# Generate hash
|
||||
serialized_obj = json.dumps(hash_parts, sort_keys=True)
|
||||
new_hash = hashlib.sha256(serialized_obj.encode()).hexdigest()
|
||||
|
||||
# Check for collisions
|
||||
if new_hash in hash_map:
|
||||
# Duplicate - mark for deletion (keep the first one)
|
||||
duplicates_to_delete.append(stream.id)
|
||||
continue
|
||||
|
||||
hash_map[new_hash] = stream.id
|
||||
stream.stream_hash = new_hash
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
streams_to_update.append(stream)
|
||||
updated += 1
|
||||
|
||||
# Bulk update in batches
|
||||
if len(streams_to_update) >= batch_size:
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['stream_id', 'stream_chno', 'stream_hash'],
|
||||
batch_size=500
|
||||
)
|
||||
logger.info(f"Updated batch: {processed}/{total_count} streams processed")
|
||||
streams_to_update = []
|
||||
|
||||
# Final batch
|
||||
if streams_to_update:
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['stream_id', 'stream_chno', 'stream_hash'],
|
||||
batch_size=500
|
||||
)
|
||||
|
||||
# Delete duplicates if any
|
||||
if duplicates_to_delete:
|
||||
logger.warning(f"Deleting {len(duplicates_to_delete)} duplicate streams due to hash collisions")
|
||||
Stream.objects.filter(id__in=duplicates_to_delete).delete()
|
||||
|
||||
logger.info(f"Migration complete: {updated} streams updated, {len(duplicates_to_delete)} duplicates removed")
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration - clear fields but don't attempt to reverse hash changes.
|
||||
"""
|
||||
Stream = apps.get_model('dispatcharr_channels', 'Stream')
|
||||
Stream.objects.all().update(stream_id=None, stream_chno=None)
|
||||
logger.info("Cleared stream_id and stream_chno fields. Note: stream hashes were not reverted.")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0032_channel_is_adult_stream_is_adult'),
|
||||
('m3u', '0018_add_profile_custom_properties'),
|
||||
('core', '0020_change_coresettings_value_to_jsonfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Schema changes - add fields WITHOUT indexes first
|
||||
migrations.AddField(
|
||||
model_name='stream',
|
||||
name='stream_id',
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stream',
|
||||
name='stream_chno',
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
# Data migration (may delete duplicates, which would conflict with pending index creation)
|
||||
migrations.RunPython(populate_fields_and_rehash, reverse_migration),
|
||||
# Add indexes AFTER data migration completes
|
||||
migrations.AddIndex(
|
||||
model_name='stream',
|
||||
index=models.Index(fields=['stream_id'], name='dispatcharr_stream_id_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='stream',
|
||||
index=models.Index(fields=['stream_chno'], name='dispatcharr_stream_chno_idx'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 5.2.9 on 2026-02-01 03:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0033_stream_id_stream_chno'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='stream',
|
||||
name='dispatcharr_stream_id_idx',
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='stream',
|
||||
name='dispatcharr_stream_chno_idx',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stream',
|
||||
name='stream_chno',
|
||||
field=models.FloatField(blank=True, db_index=True, help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stream',
|
||||
name='stream_id',
|
||||
field=models.IntegerField(blank=True, db_index=True, help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -106,6 +106,19 @@ class Stream(models.Model):
|
|||
)
|
||||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
stream_id = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Provider stream ID (e.g., XC stream_id) for stable identity across credential changes"
|
||||
)
|
||||
stream_chno = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1"
|
||||
)
|
||||
|
||||
# Stream statistics fields
|
||||
stream_stats = models.JSONField(
|
||||
null=True,
|
||||
|
|
@ -129,14 +142,27 @@ class Stream(models.Model):
|
|||
return self.name or self.url or f"Stream ID {self.id}"
|
||||
|
||||
@classmethod
|
||||
def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None):
|
||||
def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None,
|
||||
account_type=None, stream_id=None):
|
||||
if keys is None:
|
||||
keys = CoreSettings.get_m3u_hash_key().split(",")
|
||||
|
||||
stream_parts = {"name": name, "url": url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group}
|
||||
# For XC accounts, use stream_id instead of url when 'url' is in the hash keys
|
||||
# This ensures credential/URL changes don't break stream identity
|
||||
effective_url = url
|
||||
use_stream_id = account_type == 'XC' and stream_id and 'url' in keys
|
||||
if use_stream_id:
|
||||
effective_url = stream_id
|
||||
|
||||
stream_parts = {"name": name, "url": effective_url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group}
|
||||
|
||||
hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts}
|
||||
|
||||
# When using stream_id instead of URL, we MUST include m3u_id to prevent
|
||||
# collisions across different XC accounts (stream_id is only unique per account)
|
||||
if use_stream_id and 'm3u_id' not in hash_parts:
|
||||
hash_parts['m3u_id'] = m3u_id
|
||||
|
||||
# Serialize and hash the dictionary
|
||||
serialized_obj = json.dumps(
|
||||
hash_parts, sort_keys=True
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
read_only_fields = ["is_custom", "m3u_account", "stream_hash"]
|
||||
read_only_fields = ["is_custom", "m3u_account", "stream_hash", "stream_id", "stream_chno"]
|
||||
|
||||
class Meta:
|
||||
model = Stream
|
||||
|
|
@ -127,6 +127,8 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
"stream_hash",
|
||||
"stream_stats",
|
||||
"stream_stats_updated_at",
|
||||
"stream_id",
|
||||
"stream_chno",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ COMMON_EXTRANEOUS_WORDS = [
|
|||
def normalize_name(name: str) -> str:
|
||||
"""
|
||||
A more aggressive normalization that:
|
||||
- Removes user-configured prefixes/suffixes/custom strings (only if mode is 'advanced')
|
||||
- Lowercases
|
||||
- Removes bracketed/parenthesized text
|
||||
- Removes punctuation
|
||||
|
|
@ -148,7 +149,76 @@ def normalize_name(name: str) -> str:
|
|||
if not name:
|
||||
return ""
|
||||
|
||||
norm = name.lower()
|
||||
# Load user-configured EPG matching rules (fail gracefully)
|
||||
prefixes = []
|
||||
suffixes = []
|
||||
custom_strings = []
|
||||
|
||||
try:
|
||||
from core.models import CoreSettings
|
||||
settings = CoreSettings.get_epg_settings()
|
||||
|
||||
# Check if user has enabled advanced mode
|
||||
mode = settings.get("epg_match_mode", "default")
|
||||
|
||||
# Only use custom settings if mode is 'advanced'
|
||||
if mode == "advanced":
|
||||
prefixes = settings.get("epg_match_ignore_prefixes", [])
|
||||
suffixes = settings.get("epg_match_ignore_suffixes", [])
|
||||
custom_strings = settings.get("epg_match_ignore_custom", [])
|
||||
|
||||
# Ensure we have lists
|
||||
if not isinstance(prefixes, list):
|
||||
prefixes = []
|
||||
if not isinstance(suffixes, list):
|
||||
suffixes = []
|
||||
if not isinstance(custom_strings, list):
|
||||
custom_strings = []
|
||||
|
||||
except Exception as e:
|
||||
# Settings unavailable or error - continue with empty lists (graceful degradation)
|
||||
logger.debug(f"Could not load EPG matching settings: {e}")
|
||||
prefixes = []
|
||||
suffixes = []
|
||||
custom_strings = []
|
||||
|
||||
result = name
|
||||
|
||||
# Step 1: Remove prefixes (from START only - exact string match)
|
||||
for prefix in prefixes:
|
||||
# Skip empty or non-string entries
|
||||
if not prefix or not isinstance(prefix, str):
|
||||
continue
|
||||
# Exact match at start
|
||||
if result.startswith(prefix):
|
||||
result = result[len(prefix):]
|
||||
break # Only remove first matching prefix
|
||||
|
||||
# Step 2: Remove suffixes (from END only - exact string match)
|
||||
for suffix in suffixes:
|
||||
# Skip empty or non-string entries
|
||||
if not suffix or not isinstance(suffix, str):
|
||||
continue
|
||||
# Exact match at end
|
||||
if result.endswith(suffix):
|
||||
result = result[:-len(suffix)]
|
||||
break # Only remove first matching suffix
|
||||
|
||||
# Step 3: Remove custom strings (from ANYWHERE - exact string match)
|
||||
for custom in custom_strings:
|
||||
# Skip empty or non-string entries
|
||||
if not custom or not isinstance(custom, str):
|
||||
continue
|
||||
try:
|
||||
# Exact string removal (replace with empty string)
|
||||
result = result.replace(custom, "")
|
||||
except Exception as e:
|
||||
# If removal fails for any reason, skip this entry
|
||||
logger.debug(f"Failed to remove custom string '{custom}': {e}")
|
||||
continue
|
||||
|
||||
# Step 4: Existing normalization logic (unchanged)
|
||||
norm = result.lower()
|
||||
norm = re.sub(r"\[.*?\]", "", norm)
|
||||
|
||||
# Extract and preserve important call signs from parentheses before removing them
|
||||
|
|
@ -2629,13 +2699,9 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None
|
|||
channel_number = None
|
||||
|
||||
if starting_channel_number is None:
|
||||
# Mode 1: Use provider numbers when available
|
||||
if "tvg-chno" in stream_custom_props:
|
||||
channel_number = float(stream_custom_props["tvg-chno"])
|
||||
elif "channel-number" in stream_custom_props:
|
||||
channel_number = float(stream_custom_props["channel-number"])
|
||||
elif "num" in stream_custom_props:
|
||||
channel_number = float(stream_custom_props["num"])
|
||||
# Mode 1: Use provider numbers when available (from stream_chno field)
|
||||
if stream.stream_chno is not None:
|
||||
channel_number = stream.stream_chno
|
||||
|
||||
# For modes 2 and 3 (starting_channel_number == 0 or specific number),
|
||||
# ignore provider numbers and use sequential assignment
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import logging, os
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.decorators import action
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import EPGSource, ProgramData, EPGData # Added ProgramData
|
||||
|
|
@ -122,8 +122,8 @@ class EPGGridAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
|
||||
@extend_schema(
|
||||
description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
|
||||
responses={200: ProgramDataSerializer(many=True)},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
|
|
@ -371,9 +371,8 @@ class EPGImportAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Triggers an EPG data import",
|
||||
responses={202: "EPG data import initiated"},
|
||||
@extend_schema(
|
||||
description="Triggers an EPG data import",
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
logger.info("EPGImportAPIView: Received request to import EPG data.")
|
||||
|
|
@ -435,20 +434,20 @@ class CurrentProgramsAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Get currently playing programs for specified channels or all channels",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'channel_ids': openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||
description="Array of channel IDs. If null or omitted, returns all channels with current programs.",
|
||||
nullable=True,
|
||||
)
|
||||
@extend_schema(
|
||||
description="Get currently playing programs for specified channels or all channels",
|
||||
request=inline_serializer(
|
||||
name="CurrentProgramsRequest",
|
||||
fields={
|
||||
"channel_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.",
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))},
|
||||
responses={200: ProgramDataSerializer(many=True)},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Get channel IDs from request body
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ from rest_framework.views import APIView
|
|||
from apps.accounts.permissions import Authenticated, permission_classes_by_action
|
||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||
import logging
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db import models
|
||||
from apps.channels.models import Channel, ChannelProfile, Stream
|
||||
|
|
@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
|
|||
class DiscoverAPIView(APIView):
|
||||
"""Returns device discovery information"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve HDHomeRun device discovery information",
|
||||
responses={200: openapi.Response("HDHR Discovery JSON")},
|
||||
@extend_schema(
|
||||
description="Retrieve HDHomeRun device discovery information",
|
||||
)
|
||||
def get(self, request, profile=None):
|
||||
uri_parts = ["hdhr"]
|
||||
|
|
@ -100,9 +99,8 @@ class DiscoverAPIView(APIView):
|
|||
class LineupAPIView(APIView):
|
||||
"""Returns available channel lineup"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve the available channel lineup",
|
||||
responses={200: openapi.Response("Channel Lineup JSON")},
|
||||
@extend_schema(
|
||||
description="Retrieve the available channel lineup",
|
||||
)
|
||||
def get(self, request, profile=None):
|
||||
if profile is not None:
|
||||
|
|
@ -141,9 +139,8 @@ class LineupAPIView(APIView):
|
|||
class LineupStatusAPIView(APIView):
|
||||
"""Returns the current status of the HDHR lineup"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve the HDHomeRun lineup status",
|
||||
responses={200: openapi.Response("Lineup Status JSON")},
|
||||
@extend_schema(
|
||||
description="Retrieve the HDHomeRun lineup status",
|
||||
)
|
||||
def get(self, request, profile=None):
|
||||
data = {
|
||||
|
|
@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView):
|
|||
class HDHRDeviceXMLAPIView(APIView):
|
||||
"""Returns HDHomeRun device configuration in XML"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve the HDHomeRun device XML configuration",
|
||||
responses={200: openapi.Response("HDHR Device XML")},
|
||||
@extend_schema(
|
||||
description="Retrieve the HDHomeRun device XML configuration",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ from rest_framework.response import Response
|
|||
from rest_framework.views import APIView
|
||||
from apps.accounts.permissions import Authenticated, permission_classes_by_action
|
||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.channels.models import Channel
|
||||
from .models import HDHRDevice
|
||||
|
|
@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
|
|||
class DiscoverAPIView(APIView):
|
||||
"""Returns device discovery information"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve HDHomeRun device discovery information",
|
||||
responses={200: openapi.Response("HDHR Discovery JSON")},
|
||||
@extend_schema(
|
||||
description="Retrieve HDHomeRun device discovery information",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
|
|
@ -81,9 +80,8 @@ class DiscoverAPIView(APIView):
|
|||
class LineupAPIView(APIView):
|
||||
"""Returns available channel lineup"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve the available channel lineup",
|
||||
responses={200: openapi.Response("Channel Lineup JSON")},
|
||||
@extend_schema(
|
||||
description="Retrieve the available channel lineup",
|
||||
)
|
||||
def get(self, request):
|
||||
channels = Channel.objects.all().order_by("channel_number")
|
||||
|
|
@ -102,9 +100,8 @@ class LineupAPIView(APIView):
|
|||
class LineupStatusAPIView(APIView):
|
||||
"""Returns the current status of the HDHR lineup"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve the HDHomeRun lineup status",
|
||||
responses={200: openapi.Response("Lineup Status JSON")},
|
||||
@extend_schema(
|
||||
description="Retrieve the HDHomeRun lineup status",
|
||||
)
|
||||
def get(self, request):
|
||||
data = {
|
||||
|
|
@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView):
|
|||
class HDHRDeviceXMLAPIView(APIView):
|
||||
"""Returns HDHomeRun device configuration in XML"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve the HDHomeRun device XML configuration",
|
||||
responses={200: openapi.Response("HDHR Device XML")},
|
||||
@extend_schema(
|
||||
description="Retrieve the HDHomeRun device XML configuration",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ from apps.accounts.permissions import (
|
|||
permission_classes_by_action,
|
||||
permission_classes_by_method,
|
||||
)
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
from django.core.cache import cache
|
||||
|
|
@ -357,9 +357,8 @@ class RefreshM3UAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Triggers a refresh of all active M3U accounts",
|
||||
responses={202: "M3U refresh initiated"},
|
||||
@extend_schema(
|
||||
description="Triggers a refresh of all active M3U accounts",
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
refresh_m3u_accounts.delay()
|
||||
|
|
@ -380,9 +379,8 @@ class RefreshSingleM3UAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Triggers a refresh of a single M3U account",
|
||||
responses={202: "M3U account refresh initiated"},
|
||||
@extend_schema(
|
||||
description="Triggers a refresh of a single M3U account",
|
||||
)
|
||||
def post(self, request, account_id, format=None):
|
||||
refresh_single_m3u_account.delay(account_id)
|
||||
|
|
@ -406,9 +404,8 @@ class RefreshAccountInfoAPIView(APIView):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Triggers a refresh of account information for a specific M3U profile",
|
||||
responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"},
|
||||
@extend_schema(
|
||||
description="Triggers a refresh of account information for a specific M3U profile",
|
||||
)
|
||||
def post(self, request, profile_id, format=None):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -741,6 +741,7 @@ def collect_xc_streams(account_id, enabled_groups):
|
|||
"group-title": group_info["name"],
|
||||
# Preserve all XC stream properties as custom attributes
|
||||
"stream_id": str(stream.get("stream_id", "")),
|
||||
"num": stream.get("num"),
|
||||
"category_id": category_id,
|
||||
"stream_type": stream.get("stream_type", ""),
|
||||
"added": stream.get("added", ""),
|
||||
|
|
@ -749,7 +750,7 @@ def collect_xc_streams(account_id, enabled_groups):
|
|||
# Include any other properties that might be present
|
||||
**{k: str(v) for k, v in stream.items() if k not in [
|
||||
"name", "stream_id", "epg_channel_id", "stream_icon",
|
||||
"category_id", "stream_type", "added", "is_adult", "custom_sid"
|
||||
"category_id", "stream_type", "added", "is_adult", "custom_sid", "num"
|
||||
] and v is not None}
|
||||
}
|
||||
}
|
||||
|
|
@ -817,13 +818,28 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
|
||||
for stream in streams:
|
||||
name = stream["name"]
|
||||
raw_stream_id = stream.get("stream_id", "")
|
||||
provider_stream_id = None
|
||||
if raw_stream_id:
|
||||
try:
|
||||
provider_stream_id = int(raw_stream_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
url = xc_client.get_stream_url(stream["stream_id"])
|
||||
tvg_id = stream.get("epg_channel_id", "")
|
||||
tvg_logo = stream.get("stream_icon", "")
|
||||
group_title = group_name
|
||||
stream_chno = stream.get("num")
|
||||
# Convert stream_chno to float if valid, otherwise None
|
||||
if stream_chno is not None:
|
||||
try:
|
||||
stream_chno = float(stream_chno)
|
||||
except (ValueError, TypeError):
|
||||
stream_chno = None
|
||||
|
||||
stream_hash = Stream.generate_hash_key(
|
||||
name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title
|
||||
name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title,
|
||||
account_type='XC', stream_id=provider_stream_id
|
||||
)
|
||||
stream_props = {
|
||||
"name": name,
|
||||
|
|
@ -836,6 +852,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
"custom_properties": stream,
|
||||
"is_adult": int(stream.get("is_adult", 0)) == 1,
|
||||
"is_stale": False,
|
||||
"stream_id": provider_stream_id,
|
||||
"stream_chno": stream_chno,
|
||||
}
|
||||
|
||||
if stream_hash not in stream_hashes:
|
||||
|
|
@ -850,7 +868,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
existing_streams = {
|
||||
s.stream_hash: s
|
||||
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account'
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -864,7 +882,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
obj.logo_url != stream_props["logo_url"] or
|
||||
obj.tvg_id != stream_props["tvg_id"] or
|
||||
obj.custom_properties != stream_props["custom_properties"] or
|
||||
obj.is_adult != stream_props["is_adult"]
|
||||
obj.is_adult != stream_props["is_adult"] or
|
||||
obj.stream_id != stream_props["stream_id"] or
|
||||
obj.stream_chno != stream_props["stream_chno"]
|
||||
)
|
||||
|
||||
if changed:
|
||||
|
|
@ -900,7 +920,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
# Simplified bulk update for better performance
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
|
||||
batch_size=150 # Smaller batch size for XC processing
|
||||
)
|
||||
|
||||
|
|
@ -1003,7 +1023,42 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
)
|
||||
continue
|
||||
|
||||
stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title)
|
||||
# Determine provider-specific fields first
|
||||
provider_stream_id = None
|
||||
channel_num = None
|
||||
account_type_for_hash = None
|
||||
|
||||
if account.account_type == M3UAccount.Types.XC:
|
||||
account_type_for_hash = 'XC'
|
||||
raw_stream_id = stream_info["attributes"].get("stream_id", "")
|
||||
if raw_stream_id:
|
||||
try:
|
||||
provider_stream_id = int(raw_stream_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
raw_num = stream_info["attributes"].get("num")
|
||||
if raw_num is not None:
|
||||
try:
|
||||
channel_num = float(raw_num)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
# For standard M3U accounts, check for tvg-chno or channel-number
|
||||
tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "tvg-chno", None)
|
||||
if tvg_chno is None:
|
||||
tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "channel-number", None)
|
||||
if tvg_chno is not None:
|
||||
try:
|
||||
channel_num = float(tvg_chno)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Generate hash once with all parameters
|
||||
stream_hash = Stream.generate_hash_key(
|
||||
name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title,
|
||||
account_type=account_type_for_hash, stream_id=provider_stream_id
|
||||
)
|
||||
|
||||
stream_props = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
|
|
@ -1015,6 +1070,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
"custom_properties": stream_info["attributes"],
|
||||
"is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1,
|
||||
"is_stale": False,
|
||||
"stream_id": provider_stream_id,
|
||||
"stream_chno": channel_num,
|
||||
}
|
||||
|
||||
if stream_hash not in stream_hashes:
|
||||
|
|
@ -1026,7 +1083,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
existing_streams = {
|
||||
s.stream_hash: s
|
||||
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account'
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1040,7 +1097,9 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
obj.logo_url != stream_props["logo_url"] or
|
||||
obj.tvg_id != stream_props["tvg_id"] or
|
||||
obj.custom_properties != stream_props["custom_properties"] or
|
||||
obj.is_adult != stream_props["is_adult"]
|
||||
obj.is_adult != stream_props["is_adult"] or
|
||||
obj.stream_id != stream_props["stream_id"] or
|
||||
obj.stream_chno != stream_props["stream_chno"]
|
||||
)
|
||||
|
||||
# Always update last_seen
|
||||
|
|
@ -1054,6 +1113,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
obj.tvg_id = stream_props["tvg_id"]
|
||||
obj.custom_properties = stream_props["custom_properties"]
|
||||
obj.is_adult = stream_props["is_adult"]
|
||||
obj.stream_id = stream_props["stream_id"]
|
||||
obj.stream_chno = stream_props["stream_chno"]
|
||||
obj.updated_at = timezone.now()
|
||||
|
||||
# Always mark as not stale since we saw it in this refresh
|
||||
|
|
@ -1076,7 +1137,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
# Update all streams in a single bulk operation
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
|
||||
batch_size=200
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -1265,36 +1326,14 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
) as xc_client:
|
||||
logger.info(f"XCClient instance created successfully")
|
||||
|
||||
# Authenticate with detailed error handling
|
||||
# Queue async profile refresh task to run in background
|
||||
# This prevents any delay in the main refresh process
|
||||
try:
|
||||
logger.debug(f"Authenticating with XC server {server_url}")
|
||||
auth_result = xc_client.authenticate()
|
||||
logger.debug(f"Authentication response: {auth_result}")
|
||||
|
||||
# Queue async profile refresh task to run in background
|
||||
# This prevents any delay in the main refresh process
|
||||
try:
|
||||
logger.info(f"Queueing background profile refresh for account {account.name}")
|
||||
refresh_account_profiles.delay(account.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to queue profile refresh task: {str(e)}")
|
||||
# Don't fail the main refresh if profile refresh can't be queued
|
||||
|
||||
logger.info(f"Queueing background profile refresh for account {account.name}")
|
||||
refresh_account_profiles.delay(account.id)
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to authenticate with XC server: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
account_id,
|
||||
"processing_groups",
|
||||
100,
|
||||
status="error",
|
||||
error=error_msg,
|
||||
)
|
||||
release_task_lock("refresh_m3u_account_groups", account_id)
|
||||
return error_msg, None
|
||||
logger.warning(f"Failed to queue profile refresh task: {str(e)}")
|
||||
# Don't fail the main refresh if profile refresh can't be queued
|
||||
|
||||
# Get categories with detailed error handling
|
||||
try:
|
||||
|
|
@ -1334,7 +1373,19 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
|
|||
"xc_id": cat_id,
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to get categories from XC server: {str(e)}"
|
||||
# Determine if this is an authentication error or category retrieval error
|
||||
error_str = str(e).lower()
|
||||
# Check for authentication-related keywords or HTTP status codes commonly used for auth failures
|
||||
is_auth_error = any(keyword in error_str for keyword in [
|
||||
'auth', 'credential', 'login', 'unauthorized', 'forbidden',
|
||||
'401', '403', '512', '513' # HTTP status codes: 401 Unauthorized, 403 Forbidden, 512-513 (non-standard auth failure)
|
||||
])
|
||||
|
||||
if is_auth_error:
|
||||
error_msg = f"Failed to authenticate with XC server: {str(e)}"
|
||||
else:
|
||||
error_msg = f"Failed to get categories from XC server: {str(e)}"
|
||||
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from django.test import TestCase, Client
|
||||
from django.urls import reverse
|
||||
from apps.channels.models import Channel, ChannelGroup
|
||||
from apps.epg.models import EPGData, EPGSource
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class OutputM3UTest(TestCase):
|
||||
def setUp(self):
|
||||
|
|
@ -37,3 +40,106 @@ class OutputM3UTest(TestCase):
|
|||
|
||||
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
|
||||
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())
|
||||
|
||||
|
||||
class OutputEPGXMLEscapingTest(TestCase):
|
||||
"""Test XML escaping of channel_id attributes in EPG generation"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.group = ChannelGroup.objects.create(name="Test Group")
|
||||
|
||||
def test_channel_id_with_ampersand(self):
|
||||
"""Test channel ID with ampersand is properly escaped"""
|
||||
channel = Channel.objects.create(
|
||||
channel_number=1.0,
|
||||
name="Test Channel",
|
||||
tvg_id="News & Sports",
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.content.decode()
|
||||
|
||||
# Should contain escaped ampersand
|
||||
self.assertIn('id="News & Sports"', content)
|
||||
self.assertNotIn('id="News & Sports"', content)
|
||||
|
||||
# Verify XML is parseable
|
||||
try:
|
||||
ET.fromstring(content)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG is not valid XML: {e}")
|
||||
|
||||
def test_channel_id_with_angle_brackets(self):
|
||||
"""Test channel ID with < and > characters"""
|
||||
channel = Channel.objects.create(
|
||||
channel_number=2.0,
|
||||
name="HD Channel",
|
||||
tvg_id="Channel <HD>",
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
|
||||
content = response.content.decode()
|
||||
self.assertIn('id="Channel <HD>"', content)
|
||||
|
||||
try:
|
||||
ET.fromstring(content)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG with < > is not valid XML: {e}")
|
||||
|
||||
def test_channel_id_with_all_special_chars(self):
|
||||
"""Test channel ID with all XML special characters"""
|
||||
channel = Channel.objects.create(
|
||||
channel_number=3.0,
|
||||
name="Complex Channel",
|
||||
tvg_id='Test & "Special" <Chars>',
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
|
||||
content = response.content.decode()
|
||||
self.assertIn('id="Test & "Special" <Chars>"', content)
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(content)
|
||||
# Verify we can find the channel with correct ID in parsed tree
|
||||
channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" <Chars>"]')
|
||||
self.assertIsNotNone(channel_elem)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
|
||||
|
||||
def test_program_channel_attribute_escaping(self):
|
||||
"""Test that programme elements also have escaped channel attributes"""
|
||||
epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy")
|
||||
epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source)
|
||||
channel = Channel.objects.create(
|
||||
channel_number=4.0,
|
||||
name="Program Test",
|
||||
tvg_id="News & Sports",
|
||||
epg_data=epg_data,
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
|
||||
content = response.content.decode()
|
||||
|
||||
# Check programme elements have escaped channel attributes
|
||||
self.assertIn('channel="News & Sports"', content)
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(content)
|
||||
programmes = tree.findall('.//programme[@channel="News & Sports"]')
|
||||
self.assertGreater(len(programmes), 0)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG with programme elements is not valid XML: {e}")
|
||||
|
|
|
|||
|
|
@ -1203,7 +1203,7 @@ def generate_dummy_epg(
|
|||
|
||||
# Create program entry with escaped channel name
|
||||
xml_lines.append(
|
||||
f' <programme start="{start_str}" stop="{stop_str}" channel="{program["channel_id"]}">'
|
||||
f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(program["channel_id"])}">'
|
||||
)
|
||||
xml_lines.append(f" <title>{html.escape(program['title'])}</title>")
|
||||
xml_lines.append(f" <desc>{html.escape(program['description'])}</desc>")
|
||||
|
|
@ -1448,7 +1448,7 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
else:
|
||||
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id]))
|
||||
display_name = channel.name
|
||||
xml_lines.append(f' <channel id="{channel_id}">')
|
||||
xml_lines.append(f' <channel id="{html.escape(channel_id)}">')
|
||||
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
|
||||
xml_lines.append(f' <icon src="{html.escape(tvg_logo)}" />')
|
||||
xml_lines.append(" </channel>")
|
||||
|
|
@ -1523,7 +1523,7 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
|
||||
|
||||
# Create program entry with escaped channel name
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">\n'
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
|
||||
yield f" <title>{html.escape(program['title'])}</title>\n"
|
||||
yield f" <desc>{html.escape(program['description'])}</desc>\n"
|
||||
|
||||
|
|
@ -1572,7 +1572,7 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z")
|
||||
stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
|
||||
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">\n'
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
|
||||
yield f" <title>{html.escape(program['title'])}</title>\n"
|
||||
yield f" <desc>{html.escape(program['description'])}</desc>\n"
|
||||
|
||||
|
|
@ -1634,7 +1634,7 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
|
||||
stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
|
||||
|
||||
program_xml = [f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">']
|
||||
program_xml = [f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">']
|
||||
program_xml.append(f' <title>{html.escape(prog.title)}</title>')
|
||||
|
||||
# Add subtitle if available
|
||||
|
|
@ -2307,18 +2307,22 @@ def xc_get_epg(request, user, short=False):
|
|||
# Has stored programs, use them
|
||||
if short == False:
|
||||
programs = channel.epg_data.programs.filter(
|
||||
start_time__gte=django_timezone.now()
|
||||
end_time__gt=django_timezone.now()
|
||||
).order_by('start_time')
|
||||
else:
|
||||
programs = channel.epg_data.programs.all().order_by('start_time')[:limit]
|
||||
programs = channel.epg_data.programs.filter(
|
||||
end_time__gt=django_timezone.now()
|
||||
).order_by('start_time')[:limit]
|
||||
else:
|
||||
# Regular EPG with stored programs
|
||||
if short == False:
|
||||
programs = channel.epg_data.programs.filter(
|
||||
start_time__gte=django_timezone.now()
|
||||
end_time__gt=django_timezone.now()
|
||||
).order_by('start_time')
|
||||
else:
|
||||
programs = channel.epg_data.programs.all().order_by('start_time')[:limit]
|
||||
programs = channel.epg_data.programs.filter(
|
||||
end_time__gt=django_timezone.now()
|
||||
).order_by('start_time')[:limit]
|
||||
else:
|
||||
# No EPG data assigned, generate default dummy
|
||||
programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .api_views import (
|
|||
UserAgentViewSet,
|
||||
StreamProfileViewSet,
|
||||
CoreSettingsViewSet,
|
||||
SystemNotificationViewSet,
|
||||
environment,
|
||||
version,
|
||||
rehash_streams_endpoint,
|
||||
|
|
@ -17,6 +18,7 @@ router = DefaultRouter()
|
|||
router.register(r'useragents', UserAgentViewSet, basename='useragent')
|
||||
router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile')
|
||||
router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
|
||||
router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification')
|
||||
urlpatterns = [
|
||||
path('settings/env/', environment, name='token_refresh'),
|
||||
path('version/', version, name='version'),
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@
|
|||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
from django.db import models
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from .models import (
|
||||
UserAgent,
|
||||
StreamProfile,
|
||||
|
|
@ -241,10 +242,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
|||
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="get",
|
||||
operation_description="Endpoint for environment details",
|
||||
responses={200: "Environment variables"},
|
||||
@extend_schema(
|
||||
description="Endpoint for environment details",
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([Authenticated])
|
||||
|
|
@ -314,10 +313,8 @@ def environment(request):
|
|||
)
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="get",
|
||||
operation_description="Get application version information",
|
||||
responses={200: "Version information"},
|
||||
@extend_schema(
|
||||
description="Get application version information",
|
||||
)
|
||||
|
||||
@api_view(["GET"])
|
||||
|
|
@ -333,10 +330,8 @@ def version(request):
|
|||
)
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Trigger rehashing of all streams",
|
||||
responses={200: "Rehash task started"},
|
||||
@extend_schema(
|
||||
description="Trigger rehashing of all streams",
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([Authenticated])
|
||||
|
|
@ -383,9 +378,8 @@ class TimezoneListView(APIView):
|
|||
def get_permissions(self):
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Get list of all supported timezones",
|
||||
responses={200: openapi.Response('List of timezones with grouping by region')}
|
||||
@extend_schema(
|
||||
description="Get list of all supported timezones",
|
||||
)
|
||||
def get(self, request):
|
||||
import pytz
|
||||
|
|
@ -473,3 +467,159 @@ def get_system_events(request):
|
|||
return Response({
|
||||
'error': 'Failed to fetch system events'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# System Notifications API
|
||||
# ─────────────────────────────
|
||||
from .models import SystemNotification, NotificationDismissal
|
||||
from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer
|
||||
from django.utils import timezone as dj_timezone
|
||||
|
||||
|
||||
class SystemNotificationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for system notifications.
|
||||
Users can view active notifications and dismiss them.
|
||||
Admins can create and manage notifications.
|
||||
"""
|
||||
serializer_class = SystemNotificationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return notifications based on user permissions.
|
||||
Filter out expired and dismissed notifications for regular users.
|
||||
Evaluate conditions for developer notifications.
|
||||
"""
|
||||
from core.developer_notifications import evaluate_conditions
|
||||
from django.core.cache import cache
|
||||
|
||||
user = self.request.user
|
||||
now = dj_timezone.now()
|
||||
|
||||
queryset = SystemNotification.objects.filter(is_active=True)
|
||||
|
||||
# Filter out expired notifications
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
|
||||
)
|
||||
|
||||
# Filter admin-only notifications for non-admins
|
||||
if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10:
|
||||
queryset = queryset.filter(admin_only=False)
|
||||
|
||||
# For developer notifications, evaluate conditions
|
||||
# Cache the evaluation per notification to avoid repeated condition checks
|
||||
notifications_to_exclude = []
|
||||
developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER)
|
||||
|
||||
for notification in developer_notifications:
|
||||
action_data = notification.action_data or {}
|
||||
conditions = action_data.get('condition', [])
|
||||
|
||||
if not conditions:
|
||||
continue
|
||||
|
||||
# Cache key based on notification ID and current settings
|
||||
# Cache for 5 minutes to balance freshness with performance
|
||||
cache_key = f'dev_notif_condition_{notification.id}_{user.id}'
|
||||
should_show = cache.get(cache_key)
|
||||
|
||||
if should_show is None:
|
||||
should_show = evaluate_conditions(conditions, user)
|
||||
cache.set(cache_key, should_show, timeout=300) # 5 minutes
|
||||
|
||||
if not should_show:
|
||||
notifications_to_exclude.append(notification.id)
|
||||
|
||||
if notifications_to_exclude:
|
||||
queryset = queryset.exclude(id__in=notifications_to_exclude)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
return context
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
List all active notifications for the current user.
|
||||
Optionally filter by dismissed status.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Optional: filter out already dismissed notifications
|
||||
include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true'
|
||||
if not include_dismissed:
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
queryset = queryset.exclude(id__in=dismissed_ids)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response({
|
||||
'notifications': serializer.data,
|
||||
'count': len(serializer.data),
|
||||
'unread_count': queryset.count()
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='dismiss')
|
||||
def dismiss(self, request, pk=None):
|
||||
"""Dismiss a notification for the current user."""
|
||||
notification = self.get_object()
|
||||
action_taken = request.data.get('action_taken', None)
|
||||
|
||||
dismissal, created = NotificationDismissal.objects.get_or_create(
|
||||
user=request.user,
|
||||
notification=notification,
|
||||
defaults={'action_taken': action_taken}
|
||||
)
|
||||
|
||||
if not created and action_taken:
|
||||
dismissal.action_taken = action_taken
|
||||
dismissal.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Notification dismissed',
|
||||
'notification_key': notification.notification_key
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='dismiss-all')
|
||||
def dismiss_all(self, request):
|
||||
"""Dismiss all notifications for the current user."""
|
||||
notifications = self.get_queryset()
|
||||
|
||||
# Get notifications not yet dismissed
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
to_dismiss = notifications.exclude(id__in=dismissed_ids)
|
||||
|
||||
# Create dismissals for all
|
||||
dismissals = [
|
||||
NotificationDismissal(user=request.user, notification=n)
|
||||
for n in to_dismiss
|
||||
]
|
||||
NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'dismissed_count': len(dismissals)
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='count')
|
||||
def unread_count(self, request):
|
||||
"""Get count of unread notifications."""
|
||||
queryset = self.get_queryset()
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
unread_count = queryset.exclude(id__in=dismissed_ids).count()
|
||||
|
||||
return Response({
|
||||
'unread_count': unread_count
|
||||
})
|
||||
|
||||
|
|
|
|||
38
core/apps.py
38
core/apps.py
|
|
@ -1,6 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
import os, logging
|
||||
import logging
|
||||
|
||||
# Define TRACE level (5 is below DEBUG which is 10)
|
||||
TRACE = 5
|
||||
|
|
@ -15,6 +15,7 @@ def trace(self, message, *args, **kwargs):
|
|||
# Add the trace method to the Logger class
|
||||
logging.Logger.trace = trace
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
|
|
@ -22,3 +23,38 @@ class CoreConfig(AppConfig):
|
|||
def ready(self):
|
||||
# Import signals to ensure they get registered
|
||||
import core.signals
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
|
||||
# Sync developer notifications and check for version updates on startup
|
||||
# Only run in the main process (not in management commands, migrations, or workers)
|
||||
if should_skip_initialization():
|
||||
return
|
||||
|
||||
self._sync_developer_notifications()
|
||||
self._check_version_update()
|
||||
|
||||
def _sync_developer_notifications(self):
|
||||
"""Sync developer notifications from JSON file to database."""
|
||||
from django.db import connection
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
try:
|
||||
from core.developer_notifications import sync_developer_notifications
|
||||
sync_developer_notifications()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync developer notifications on startup: {e}")
|
||||
|
||||
def _check_version_update(self):
|
||||
"""Check for version updates on startup."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from core.tasks import check_for_version_update
|
||||
check_for_version_update.delay()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check for version updates on startup: {e}")
|
||||
|
|
|
|||
412
core/developer_notifications.py
Normal file
412
core/developer_notifications.py
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
"""
|
||||
Developer Notification Sync Service
|
||||
|
||||
Handles syncing developer-defined notifications from the JSON file to the database.
|
||||
This ensures users receive important notifications from the development team
|
||||
about recommended settings, security updates, and other announcements.
|
||||
|
||||
JSON Schema (see fixtures/developer_notifications.json):
|
||||
{
|
||||
"id": str, # REQUIRED - Unique identifier (notification_key)
|
||||
"title": str, # REQUIRED - Notification heading
|
||||
"message": str, # REQUIRED - Notification body text
|
||||
|
||||
"notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info')
|
||||
"priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal')
|
||||
"min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null)
|
||||
"max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null)
|
||||
"created_at": str, # OPTIONAL - ISO timestamp for tracking
|
||||
"expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null)
|
||||
"condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: [])
|
||||
"user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all')
|
||||
"action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access")
|
||||
"action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set)
|
||||
}
|
||||
|
||||
Condition Checks:
|
||||
Conditions are function names from CONDITION_CHECKS registry that evaluate
|
||||
whether a notification should be shown. All conditions must pass (AND logic).
|
||||
|
||||
Available conditions:
|
||||
- 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere
|
||||
|
||||
To add new conditions:
|
||||
1. Define a function: check_your_condition(user) -> bool
|
||||
2. Add to CONDITION_CHECKS registry
|
||||
3. Reference in JSON: "condition": ["your_condition"]
|
||||
|
||||
Sync Behavior:
|
||||
- Runs on startup (see apps.py)
|
||||
- Runs when relevant settings change (see signals.py)
|
||||
- Adds new notifications if in version range and not expired
|
||||
- Updates existing notifications with latest data
|
||||
- Removes notifications that are:
|
||||
* No longer in JSON file
|
||||
* Out of current version range
|
||||
* Past expiration date
|
||||
- Sends websocket event to refresh frontend
|
||||
- Cache invalidated when triggering settings change
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
from version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to developer notifications JSON file
|
||||
NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json'
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Condition Checks
|
||||
# ─────────────────────────────
|
||||
# Each condition function receives (user) and returns True if the notification should show
|
||||
|
||||
def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool:
|
||||
"""
|
||||
Check if network access settings for a specific endpoint are insecure (allow all).
|
||||
|
||||
Args:
|
||||
user: The user object (unused but required for condition check signature)
|
||||
endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API')
|
||||
|
||||
Returns:
|
||||
True if the notification should show (insecure settings detected)
|
||||
"""
|
||||
from core.models import CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
try:
|
||||
network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {})
|
||||
|
||||
# Empty settings are secure (defaults to local network only)
|
||||
if not network_settings:
|
||||
return False
|
||||
|
||||
# Get the specific endpoint's allowed networks (stored as comma-separated string)
|
||||
allowed_networks_str = network_settings.get(endpoint, '')
|
||||
if not allowed_networks_str:
|
||||
return False
|
||||
|
||||
# Parse comma-separated network addresses
|
||||
allowed_networks = [net.strip() for net in allowed_networks_str.split(',')]
|
||||
|
||||
# Check if settings allow access from anywhere (insecure)
|
||||
if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks:
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Registry of all available condition checks
|
||||
CONDITION_CHECKS: dict[str, Callable] = {
|
||||
'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'),
|
||||
# Add more conditions here as needed
|
||||
# 'transcode_not_configured': check_transcode_not_configured,
|
||||
# 'no_backup_configured': check_no_backup_configured,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Version Utilities
|
||||
# ─────────────────────────────
|
||||
|
||||
def parse_version(version_str: str | None) -> version.Version | None:
|
||||
"""Parse a version string, returning None if invalid or empty."""
|
||||
if not version_str:
|
||||
return None
|
||||
try:
|
||||
return version.parse(version_str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_version_in_range(
|
||||
current_version: str,
|
||||
min_version: str | None,
|
||||
max_version: str | None
|
||||
) -> bool:
|
||||
"""Check if current version is within the specified range."""
|
||||
current = parse_version(current_version)
|
||||
if not current:
|
||||
return True # If we can't parse version, show notification
|
||||
|
||||
min_ver = parse_version(min_version)
|
||||
max_ver = parse_version(max_version)
|
||||
|
||||
if min_ver and current < min_ver:
|
||||
return False
|
||||
if max_ver and current > max_ver:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Notification Evaluation
|
||||
# ─────────────────────────────
|
||||
|
||||
def evaluate_conditions(conditions: list[str] | str | None, user) -> bool:
|
||||
"""
|
||||
Evaluate notification conditions for a user.
|
||||
All conditions must pass (AND logic).
|
||||
"""
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
# Normalize to list
|
||||
if isinstance(conditions, str):
|
||||
conditions = [conditions]
|
||||
|
||||
for condition in conditions:
|
||||
if condition not in CONDITION_CHECKS:
|
||||
logger.warning(f"Unknown condition: {condition}")
|
||||
continue
|
||||
|
||||
try:
|
||||
if not CONDITION_CHECKS[condition](user):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error evaluating condition {condition}: {e}")
|
||||
# On error, skip this condition (fail open)
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def should_show_notification(notification_data: dict, user) -> bool:
|
||||
"""
|
||||
Determine if a notification should be shown to a specific user.
|
||||
Checks version range, user level, and conditions.
|
||||
"""
|
||||
# Check version range
|
||||
if not is_version_in_range(
|
||||
__version__,
|
||||
notification_data.get('min_version'),
|
||||
notification_data.get('max_version')
|
||||
):
|
||||
return False
|
||||
|
||||
# Check user level
|
||||
user_level = notification_data.get('user_level', 'all')
|
||||
if user_level == 'admin' and not getattr(user, 'is_superuser', False):
|
||||
return False
|
||||
|
||||
# Check conditions
|
||||
conditions = notification_data.get('condition', [])
|
||||
if not evaluate_conditions(conditions, user):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Sync Service
|
||||
# ─────────────────────────────
|
||||
|
||||
def load_developer_notifications() -> list[dict]:
|
||||
"""Load notifications from the JSON file."""
|
||||
if not NOTIFICATIONS_FILE.exists():
|
||||
logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}")
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get('notifications', [])
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error parsing developer notifications JSON: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading developer notifications: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def sync_developer_notifications() -> dict[str, int]:
|
||||
"""
|
||||
Sync developer notifications from JSON file to database.
|
||||
|
||||
- Adds new notifications that don't exist in the DB
|
||||
- Removes DB notifications that are no longer in the JSON file
|
||||
- Updates existing notifications if they've changed
|
||||
|
||||
Returns a dict with counts of added, updated, and removed notifications.
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
|
||||
results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0}
|
||||
|
||||
notifications = load_developer_notifications()
|
||||
json_notification_keys = set()
|
||||
notifications_to_remove = set() # Track notifications to remove (out of range or expired)
|
||||
|
||||
for notif_data in notifications:
|
||||
notification_id = notif_data.get('id')
|
||||
if not notification_id:
|
||||
logger.warning("Notification missing 'id' field, skipping")
|
||||
results['skipped'] += 1
|
||||
continue
|
||||
|
||||
json_notification_keys.add(notification_id)
|
||||
|
||||
# Check version constraints (only add if current version is in range)
|
||||
if not is_version_in_range(
|
||||
__version__,
|
||||
notif_data.get('min_version'),
|
||||
notif_data.get('max_version')
|
||||
):
|
||||
logger.debug(f"Notification {notification_id} not in version range, marking for removal")
|
||||
results['skipped'] += 1
|
||||
notifications_to_remove.add(notification_id)
|
||||
continue
|
||||
|
||||
# Parse expires_at if provided
|
||||
expires_at = None
|
||||
if notif_data.get('expires_at'):
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(
|
||||
notif_data['expires_at'].replace('Z', '+00:00')
|
||||
)
|
||||
# Skip if already expired and mark for removal
|
||||
if expires_at < timezone.now():
|
||||
logger.debug(f"Notification {notification_id} has expired, marking for removal")
|
||||
results['skipped'] += 1
|
||||
notifications_to_remove.add(notification_id)
|
||||
continue
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid expires_at for {notification_id}: {e}")
|
||||
|
||||
# Map notification_type from JSON to model choices
|
||||
type_mapping = {
|
||||
'version_update': SystemNotification.NotificationType.VERSION_UPDATE,
|
||||
'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION,
|
||||
'announcement': SystemNotification.NotificationType.ANNOUNCEMENT,
|
||||
'warning': SystemNotification.NotificationType.WARNING,
|
||||
'info': SystemNotification.NotificationType.INFO,
|
||||
}
|
||||
notification_type = type_mapping.get(
|
||||
notif_data.get('notification_type', 'info'),
|
||||
SystemNotification.NotificationType.INFO
|
||||
)
|
||||
|
||||
# Map priority
|
||||
priority_mapping = {
|
||||
'low': SystemNotification.Priority.LOW,
|
||||
'normal': SystemNotification.Priority.NORMAL,
|
||||
'high': SystemNotification.Priority.HIGH,
|
||||
'critical': SystemNotification.Priority.CRITICAL,
|
||||
}
|
||||
priority = priority_mapping.get(
|
||||
notif_data.get('priority', 'normal'),
|
||||
SystemNotification.Priority.NORMAL
|
||||
)
|
||||
|
||||
# Prepare action_data
|
||||
action_data = {
|
||||
'action_url': notif_data.get('action_url'),
|
||||
'action_text': notif_data.get('action_text'),
|
||||
'condition': notif_data.get('condition', []),
|
||||
'min_version': notif_data.get('min_version'),
|
||||
'max_version': notif_data.get('max_version'),
|
||||
'user_level': notif_data.get('user_level', 'all'),
|
||||
}
|
||||
|
||||
# Determine if admin-only based on user_level
|
||||
admin_only = notif_data.get('user_level', 'all') == 'admin'
|
||||
|
||||
# Create or update the notification
|
||||
notification, created = SystemNotification.objects.update_or_create(
|
||||
notification_key=notification_id,
|
||||
defaults={
|
||||
'notification_type': notification_type,
|
||||
'priority': priority,
|
||||
'source': SystemNotification.Source.DEVELOPER,
|
||||
'title': notif_data.get('title', 'Notification'),
|
||||
'message': notif_data.get('message', ''),
|
||||
'action_data': action_data,
|
||||
'is_active': True,
|
||||
'admin_only': admin_only,
|
||||
'expires_at': expires_at,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.info(f"Added developer notification: {notification_id}")
|
||||
results['added'] += 1
|
||||
else:
|
||||
logger.debug(f"Updated developer notification: {notification_id}")
|
||||
results['updated'] += 1
|
||||
|
||||
# Remove developer notifications that are:
|
||||
# - No longer in the JSON file, OR
|
||||
# - Out of version range for the current version, OR
|
||||
# - Expired
|
||||
removed_count, _ = SystemNotification.objects.filter(
|
||||
source=SystemNotification.Source.DEVELOPER
|
||||
).filter(
|
||||
models.Q(notification_key__in=notifications_to_remove) |
|
||||
~models.Q(notification_key__in=json_notification_keys)
|
||||
).delete()
|
||||
|
||||
if removed_count:
|
||||
logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)")
|
||||
results['removed'] = removed_count
|
||||
|
||||
logger.info(
|
||||
f"Developer notification sync complete: "
|
||||
f"{results['added']} added, {results['updated']} updated, "
|
||||
f"{results['removed']} removed, {results['skipped']} skipped"
|
||||
)
|
||||
|
||||
# Send websocket notification to frontend to refresh notifications
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
'type': 'notifications_cleared',
|
||||
})
|
||||
logger.debug("Sent websocket notification for notifications refresh")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send websocket update: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_user_developer_notifications(user) -> list:
|
||||
"""
|
||||
Get all developer notifications that should be shown to a specific user.
|
||||
Evaluates conditions and user_level for each notification.
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
|
||||
# Get all active developer notifications
|
||||
notifications = SystemNotification.objects.filter(
|
||||
source=SystemNotification.Source.DEVELOPER,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Filter by admin_only based on user
|
||||
if not getattr(user, 'is_superuser', False):
|
||||
notifications = notifications.filter(admin_only=False)
|
||||
|
||||
# Filter by conditions
|
||||
result = []
|
||||
for notification in notifications:
|
||||
action_data = notification.action_data or {}
|
||||
|
||||
# Evaluate conditions
|
||||
conditions = action_data.get('condition', [])
|
||||
if evaluate_conditions(conditions, user):
|
||||
result.append(notification)
|
||||
|
||||
return result
|
||||
43
core/fixtures/developer_notifications.json
Normal file
43
core/fixtures/developer_notifications.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"_schema_documentation": {
|
||||
"description": "Developer notification definitions. Each notification is evaluated at sync time.",
|
||||
"fields": {
|
||||
"id": "[REQUIRED] Unique identifier (notification_key in database).",
|
||||
"notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'",
|
||||
"priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'",
|
||||
"title": "[REQUIRED] Notification title/heading.",
|
||||
"message": "[REQUIRED] Detailed notification message body.",
|
||||
"min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'",
|
||||
"max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'",
|
||||
"created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.",
|
||||
"expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'",
|
||||
"condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']",
|
||||
"user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'",
|
||||
"action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'",
|
||||
"action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'"
|
||||
},
|
||||
"notes": [
|
||||
"Notifications are synced from this file to database on startup and when relevant settings change",
|
||||
"Out-of-range versions are automatically removed from database",
|
||||
"Expired notifications are automatically removed from database",
|
||||
"Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)"
|
||||
]
|
||||
},
|
||||
"notifications": [
|
||||
{
|
||||
"id": "network_security_m3u_epg",
|
||||
"notification_type": "warning",
|
||||
"priority": "high",
|
||||
"title": "Network Access Security Warning",
|
||||
"message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.",
|
||||
"min_version": null,
|
||||
"max_version": null,
|
||||
"created_at": "2026-02-02T00:00:00Z",
|
||||
"expires_at": null,
|
||||
"condition": ["m3u_epg_network_insecure"],
|
||||
"user_level": "admin",
|
||||
"action_url": "/settings#network-access",
|
||||
"action_text": "Review Settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 5.2.9 on 2026-02-02 20:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_change_coresettings_value_to_jsonfield'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemNotification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('notification_key', models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)),
|
||||
('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)),
|
||||
('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('message', models.TextField()),
|
||||
('action_data', models.JSONField(blank=True, default=dict)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('admin_only', models.BooleanField(default=False)),
|
||||
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-priority', '-created_at'],
|
||||
'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationDismissal',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('dismissed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('action_taken', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)),
|
||||
('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')],
|
||||
'unique_together': {('user', 'notification')},
|
||||
},
|
||||
),
|
||||
]
|
||||
174
core/models.py
174
core/models.py
|
|
@ -155,6 +155,7 @@ BACKUP_SETTINGS_KEY = "backup_settings"
|
|||
PROXY_SETTINGS_KEY = "proxy_settings"
|
||||
NETWORK_ACCESS_KEY = "network_access"
|
||||
SYSTEM_SETTINGS_KEY = "system_settings"
|
||||
EPG_SETTINGS_KEY = "epg_settings"
|
||||
|
||||
|
||||
class CoreSettings(models.Model):
|
||||
|
|
@ -227,6 +228,29 @@ class CoreSettings(models.Model):
|
|||
def get_auto_import_mapped_files(cls):
|
||||
return cls.get_stream_settings().get("auto_import_mapped_files")
|
||||
|
||||
# EPG Settings
|
||||
@classmethod
|
||||
def get_epg_settings(cls):
|
||||
"""Get all EPG-related settings."""
|
||||
return cls._get_group(EPG_SETTINGS_KEY, {
|
||||
"epg_match_mode": "default",
|
||||
"epg_match_ignore_prefixes": [],
|
||||
"epg_match_ignore_suffixes": [],
|
||||
"epg_match_ignore_custom": [],
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def get_epg_match_ignore_prefixes(cls):
|
||||
return cls.get_epg_settings().get("epg_match_ignore_prefixes", [])
|
||||
|
||||
@classmethod
|
||||
def get_epg_match_ignore_suffixes(cls):
|
||||
return cls.get_epg_settings().get("epg_match_ignore_suffixes", [])
|
||||
|
||||
@classmethod
|
||||
def get_epg_match_ignore_custom(cls):
|
||||
return cls.get_epg_settings().get("epg_match_ignore_custom", [])
|
||||
|
||||
# DVR Settings
|
||||
@classmethod
|
||||
def get_dvr_settings(cls):
|
||||
|
|
@ -370,3 +394,153 @@ class SystemEvent(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}"
|
||||
|
||||
|
||||
class SystemNotification(models.Model):
|
||||
"""
|
||||
Stores system notifications that users can view and dismiss.
|
||||
Used for version updates, recommended settings, announcements, etc.
|
||||
"""
|
||||
class NotificationType(models.TextChoices):
|
||||
VERSION_UPDATE = 'version_update', 'Version Update Available'
|
||||
SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change'
|
||||
ANNOUNCEMENT = 'announcement', 'System Announcement'
|
||||
WARNING = 'warning', 'Warning'
|
||||
INFO = 'info', 'Information'
|
||||
|
||||
class Priority(models.TextChoices):
|
||||
LOW = 'low', 'Low'
|
||||
NORMAL = 'normal', 'Normal'
|
||||
HIGH = 'high', 'High'
|
||||
CRITICAL = 'critical', 'Critical'
|
||||
|
||||
class Source(models.TextChoices):
|
||||
SYSTEM = 'system', 'System Generated'
|
||||
DEVELOPER = 'developer', 'Developer Notification'
|
||||
|
||||
# Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer')
|
||||
# This allows deduplication and targeted dismissals
|
||||
notification_key = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=NotificationType.choices,
|
||||
default=NotificationType.INFO,
|
||||
db_index=True
|
||||
)
|
||||
priority = models.CharField(
|
||||
max_length=20,
|
||||
choices=Priority.choices,
|
||||
default=Priority.NORMAL
|
||||
)
|
||||
|
||||
# Source of the notification (system-generated vs developer-defined)
|
||||
source = models.CharField(
|
||||
max_length=20,
|
||||
choices=Source.choices,
|
||||
default=Source.SYSTEM,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
|
||||
# Optional action data (e.g., setting key/value for recommendations, release URL for versions)
|
||||
action_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Whether this notification is currently active
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
|
||||
# Admin-only notifications require admin privileges to view
|
||||
admin_only = models.BooleanField(default=False)
|
||||
|
||||
# Auto-expire after this date (null = never expires)
|
||||
expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-priority', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['is_active', '-created_at']),
|
||||
models.Index(fields=['notification_type', 'is_active']),
|
||||
models.Index(fields=['source', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.notification_type}] {self.title}"
|
||||
|
||||
@classmethod
|
||||
def create_version_notification(cls, version, release_url=None, release_notes=None):
|
||||
"""Create or update a version update notification. Returns (notification, created) tuple."""
|
||||
key = f"version-{version}"
|
||||
notification, created = cls.objects.update_or_create(
|
||||
notification_key=key,
|
||||
defaults={
|
||||
'notification_type': cls.NotificationType.VERSION_UPDATE,
|
||||
'priority': cls.Priority.HIGH,
|
||||
'title': f'Version {version} Available',
|
||||
'message': f'A new version of Dispatcharr ({version}) is available.',
|
||||
'action_data': {
|
||||
'version': version,
|
||||
'release_url': release_url,
|
||||
'release_notes': release_notes,
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
return notification, created
|
||||
|
||||
@classmethod
|
||||
def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None):
|
||||
"""Create a setting recommendation notification. Returns (notification, created) tuple."""
|
||||
key = f"setting-{setting_key}"
|
||||
notification, created = cls.objects.update_or_create(
|
||||
notification_key=key,
|
||||
defaults={
|
||||
'notification_type': cls.NotificationType.SETTING_RECOMMENDATION,
|
||||
'priority': cls.Priority.NORMAL,
|
||||
'title': f'Recommended Setting: {setting_key}',
|
||||
'message': reason,
|
||||
'action_data': {
|
||||
'setting_key': setting_key,
|
||||
'recommended_value': recommended_value,
|
||||
'current_value': current_value,
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
return notification, created
|
||||
|
||||
|
||||
class NotificationDismissal(models.Model):
|
||||
"""
|
||||
Tracks which users have dismissed which notifications.
|
||||
Allows users to dismiss notifications once without seeing them again.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='dismissed_notifications'
|
||||
)
|
||||
notification = models.ForeignKey(
|
||||
SystemNotification,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='dismissals'
|
||||
)
|
||||
dismissed_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Optional: track if user accepted/applied the recommendation
|
||||
action_taken = models.CharField(max_length=50, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'notification']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'notification']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} dismissed {self.notification.notification_key}"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,12 @@ class CoreSettingsSerializer(serializers.ModelSerializer):
|
|||
}
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
result = super().update(instance, validated_data)
|
||||
|
||||
# Note: Cache invalidation and notification sync is handled by post_save signal
|
||||
# in core/signals.py to ensure it happens even if settings are updated elsewhere
|
||||
|
||||
return result
|
||||
|
||||
class ProxySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for proxy settings stored as JSON in CoreSettings"""
|
||||
|
|
@ -98,3 +103,45 @@ class ProxySettingsSerializer(serializers.Serializer):
|
|||
if value < 0 or value > 60:
|
||||
raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds")
|
||||
return value
|
||||
|
||||
|
||||
class SystemNotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for system notifications."""
|
||||
is_dismissed = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
from .models import SystemNotification
|
||||
model = SystemNotification
|
||||
fields = [
|
||||
'id',
|
||||
'notification_key',
|
||||
'notification_type',
|
||||
'priority',
|
||||
'title',
|
||||
'message',
|
||||
'action_data',
|
||||
'is_active',
|
||||
'admin_only',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
'is_dismissed',
|
||||
'source',
|
||||
]
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
def get_is_dismissed(self, obj):
|
||||
"""Check if the current user has dismissed this notification."""
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
return obj.dismissals.filter(user=request.user).exists()
|
||||
return False
|
||||
|
||||
|
||||
class NotificationDismissalSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification dismissals."""
|
||||
|
||||
class Meta:
|
||||
from .models import NotificationDismissal
|
||||
model = NotificationDismissal
|
||||
fields = ['id', 'notification', 'dismissed_at', 'action_taken']
|
||||
read_only_fields = ['dismissed_at']
|
||||
|
|
|
|||
|
|
@ -1,9 +1,39 @@
|
|||
from django.db.models.signals import pre_delete
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import StreamProfile
|
||||
from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
@receiver(pre_delete, sender=StreamProfile)
|
||||
def prevent_deletion_if_locked(sender, instance, **kwargs):
|
||||
if instance.locked:
|
||||
raise ValidationError("This profile is locked and cannot be deleted.")
|
||||
|
||||
@receiver(post_save, sender=CoreSettings)
|
||||
def handle_network_access_update(sender, instance, **kwargs):
|
||||
"""Invalidate cache and sync notifications when network access settings change."""
|
||||
if instance.key == NETWORK_ACCESS_KEY:
|
||||
from django.core.cache import cache
|
||||
from core.developer_notifications import sync_developer_notifications
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Invalidate all notification condition caches
|
||||
try:
|
||||
cache.delete_pattern('dev_notif_condition_*')
|
||||
logger.info("Invalidated notification condition cache due to network access settings update")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete cache pattern: {e}")
|
||||
# Fallback: try to clear entire cache (if delete_pattern not supported)
|
||||
try:
|
||||
cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-sync developer notifications to re-evaluate conditions
|
||||
# (websocket notification is sent by sync_developer_notifications)
|
||||
try:
|
||||
sync_developer_notifications()
|
||||
logger.info("Re-synced developer notifications after network access settings update")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync developer notifications: {e}")
|
||||
|
|
|
|||
482
core/tasks.py
482
core/tasks.py
|
|
@ -480,15 +480,18 @@ def rehash_streams(keys):
|
|||
|
||||
try:
|
||||
batch_size = 1000
|
||||
queryset = Stream.objects.all()
|
||||
|
||||
# Track statistics
|
||||
total_processed = 0
|
||||
duplicates_merged = 0
|
||||
# hash_keys maps new_hash -> stream_id for streams we've already processed
|
||||
hash_keys = {}
|
||||
# Track IDs of streams that have been deleted to avoid stale references
|
||||
deleted_stream_ids = set()
|
||||
|
||||
total_records = queryset.count()
|
||||
logger.info(f"Starting rehash of {total_records} streams with keys: {keys}")
|
||||
# Get initial count for progress reporting
|
||||
initial_total_records = Stream.objects.count()
|
||||
logger.info(f"Starting rehash of {initial_total_records} streams with keys: {keys}")
|
||||
|
||||
# Send initial WebSocket update
|
||||
send_websocket_update(
|
||||
|
|
@ -499,103 +502,115 @@ def rehash_streams(keys):
|
|||
"type": "stream_rehash",
|
||||
"action": "starting",
|
||||
"progress": 0,
|
||||
"total_records": total_records,
|
||||
"message": f"Starting rehash of {total_records} streams"
|
||||
"total_records": initial_total_records,
|
||||
"message": f"Starting rehash of {initial_total_records} streams"
|
||||
}
|
||||
)
|
||||
|
||||
for start in range(0, total_records, batch_size):
|
||||
# Use ID-based pagination to handle deletions correctly
|
||||
# This ensures we don't skip records when items are deleted
|
||||
last_processed_id = 0
|
||||
batch_number = 0
|
||||
|
||||
while True:
|
||||
batch_number += 1
|
||||
batch_processed = 0
|
||||
batch_duplicates = 0
|
||||
|
||||
with transaction.atomic():
|
||||
batch = queryset[start:start + batch_size]
|
||||
# Fetch batch by ID ordering, using select_for_update to lock records
|
||||
# This prevents race conditions and ensures we process each record exactly once
|
||||
batch = list(
|
||||
Stream.objects.filter(id__gt=last_processed_id)
|
||||
.select_for_update(skip_locked=True, of=('self',))
|
||||
.select_related('channel_group', 'm3u_account')
|
||||
.order_by('id')[:batch_size]
|
||||
)
|
||||
|
||||
if not batch:
|
||||
# No more records to process
|
||||
break
|
||||
|
||||
for obj in batch:
|
||||
# Generate new hash
|
||||
# Update the last processed ID for next batch
|
||||
last_processed_id = obj.id
|
||||
|
||||
# Generate new hash - handle XC accounts differently
|
||||
group_name = obj.channel_group.name if obj.channel_group else None
|
||||
new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys, m3u_id=obj.m3u_account_id, group=group_name)
|
||||
account_type = obj.m3u_account.account_type if obj.m3u_account else None
|
||||
stream_id_val = obj.stream_id if hasattr(obj, 'stream_id') else None
|
||||
|
||||
# Check if this hash already exists in our tracking dict or in database
|
||||
new_hash = Stream.generate_hash_key(
|
||||
obj.name, obj.url, obj.tvg_id, keys,
|
||||
m3u_id=obj.m3u_account_id, group=group_name,
|
||||
account_type=account_type, stream_id=stream_id_val
|
||||
)
|
||||
|
||||
# Check if this hash already exists in our tracking dict
|
||||
if new_hash in hash_keys:
|
||||
# Found duplicate in current batch - merge the streams
|
||||
existing_stream_id = hash_keys[new_hash]
|
||||
existing_stream = Stream.objects.get(id=existing_stream_id)
|
||||
|
||||
# Move any channel relationships from duplicate to existing stream
|
||||
# Handle potential unique constraint violations
|
||||
for channel_stream in ChannelStream.objects.filter(stream_id=obj.id):
|
||||
# Check if this channel already has a relationship with the target stream
|
||||
existing_relationship = ChannelStream.objects.filter(
|
||||
channel_id=channel_stream.channel_id,
|
||||
stream_id=existing_stream_id
|
||||
).first()
|
||||
# Verify the target stream still exists and hasn't been deleted
|
||||
if existing_stream_id in deleted_stream_ids:
|
||||
# The target was deleted, so this stream becomes the new canonical one
|
||||
obj.stream_hash = new_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[new_hash] = obj.id
|
||||
batch_processed += 1
|
||||
continue
|
||||
|
||||
if existing_relationship:
|
||||
# Relationship already exists, just delete the duplicate
|
||||
channel_stream.delete()
|
||||
else:
|
||||
# Safe to update the relationship
|
||||
channel_stream.stream_id = existing_stream_id
|
||||
channel_stream.save()
|
||||
try:
|
||||
existing_stream = Stream.objects.get(id=existing_stream_id)
|
||||
except Stream.DoesNotExist:
|
||||
# Target stream was deleted externally, make this the canonical one
|
||||
deleted_stream_ids.add(existing_stream_id)
|
||||
obj.stream_hash = new_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[new_hash] = obj.id
|
||||
batch_processed += 1
|
||||
continue
|
||||
|
||||
# Update the existing stream with the most recent data
|
||||
if obj.updated_at > existing_stream.updated_at:
|
||||
existing_stream.name = obj.name
|
||||
existing_stream.url = obj.url
|
||||
existing_stream.logo_url = obj.logo_url
|
||||
existing_stream.tvg_id = obj.tvg_id
|
||||
existing_stream.m3u_account = obj.m3u_account
|
||||
existing_stream.channel_group = obj.channel_group
|
||||
existing_stream.custom_properties = obj.custom_properties
|
||||
existing_stream.last_seen = obj.last_seen
|
||||
existing_stream.updated_at = obj.updated_at
|
||||
existing_stream.save()
|
||||
# Determine which stream to keep based on channel ordering
|
||||
stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj)
|
||||
|
||||
# Delete the duplicate
|
||||
obj.delete()
|
||||
# Move channel relationships from the stream being deleted to the one being kept
|
||||
_merge_stream_relationships(stream_to_delete, stream_to_keep)
|
||||
|
||||
# Delete the duplicate FIRST to free up the unique hash constraint
|
||||
deleted_stream_ids.add(stream_to_delete.id)
|
||||
stream_to_delete.delete()
|
||||
batch_duplicates += 1
|
||||
|
||||
# Now safely set the hash on the kept stream (after deletion freed it up)
|
||||
if stream_to_keep.stream_hash != new_hash:
|
||||
stream_to_keep.stream_hash = new_hash
|
||||
stream_to_keep.save(update_fields=['stream_hash'])
|
||||
|
||||
# Update hash_keys to point to the kept stream
|
||||
hash_keys[new_hash] = stream_to_keep.id
|
||||
else:
|
||||
# Check if hash already exists in database (from previous batches or existing data)
|
||||
# Check if hash already exists in database (from streams not yet processed)
|
||||
existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first()
|
||||
if existing_stream:
|
||||
# Found duplicate in database - merge the streams
|
||||
# Move any channel relationships from duplicate to existing stream
|
||||
# Handle potential unique constraint violations
|
||||
for channel_stream in ChannelStream.objects.filter(stream_id=obj.id):
|
||||
# Check if this channel already has a relationship with the target stream
|
||||
existing_relationship = ChannelStream.objects.filter(
|
||||
channel_id=channel_stream.channel_id,
|
||||
stream_id=existing_stream.id
|
||||
).first()
|
||||
# Found duplicate in database - determine which to keep based on channel ordering
|
||||
stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj)
|
||||
|
||||
if existing_relationship:
|
||||
# Relationship already exists, just delete the duplicate
|
||||
channel_stream.delete()
|
||||
else:
|
||||
# Safe to update the relationship
|
||||
channel_stream.stream_id = existing_stream.id
|
||||
channel_stream.save()
|
||||
# Move channel relationships from the stream being deleted to the one being kept
|
||||
_merge_stream_relationships(stream_to_delete, stream_to_keep)
|
||||
|
||||
# Update the existing stream with the most recent data
|
||||
if obj.updated_at > existing_stream.updated_at:
|
||||
existing_stream.name = obj.name
|
||||
existing_stream.url = obj.url
|
||||
existing_stream.logo_url = obj.logo_url
|
||||
existing_stream.tvg_id = obj.tvg_id
|
||||
existing_stream.m3u_account = obj.m3u_account
|
||||
existing_stream.channel_group = obj.channel_group
|
||||
existing_stream.custom_properties = obj.custom_properties
|
||||
existing_stream.last_seen = obj.last_seen
|
||||
existing_stream.updated_at = obj.updated_at
|
||||
existing_stream.save()
|
||||
|
||||
# Delete the duplicate
|
||||
obj.delete()
|
||||
# Delete the duplicate FIRST to free up the unique hash constraint
|
||||
deleted_stream_ids.add(stream_to_delete.id)
|
||||
stream_to_delete.delete()
|
||||
batch_duplicates += 1
|
||||
hash_keys[new_hash] = existing_stream.id
|
||||
|
||||
# Now safely set the hash on the kept stream (after deletion freed it up)
|
||||
if stream_to_keep.stream_hash != new_hash:
|
||||
stream_to_keep.stream_hash = new_hash
|
||||
stream_to_keep.save(update_fields=['stream_hash'])
|
||||
|
||||
hash_keys[new_hash] = stream_to_keep.id
|
||||
else:
|
||||
# Update hash for this stream
|
||||
# No duplicate - update hash for this stream
|
||||
obj.stream_hash = new_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[new_hash] = obj.id
|
||||
|
|
@ -605,10 +620,9 @@ def rehash_streams(keys):
|
|||
total_processed += batch_processed
|
||||
duplicates_merged += batch_duplicates
|
||||
|
||||
# Calculate progress percentage
|
||||
progress_percent = int((total_processed / total_records) * 100)
|
||||
current_batch = start // batch_size + 1
|
||||
total_batches = (total_records // batch_size) + 1
|
||||
# Calculate progress percentage based on initial count
|
||||
# Cap at 99% until we're actually done to avoid showing 100% prematurely
|
||||
progress_percent = min(99, int((total_processed / max(initial_total_records, 1)) * 100))
|
||||
|
||||
# Send progress update via WebSocket
|
||||
send_websocket_update(
|
||||
|
|
@ -619,15 +633,14 @@ def rehash_streams(keys):
|
|||
"type": "stream_rehash",
|
||||
"action": "processing",
|
||||
"progress": progress_percent,
|
||||
"batch": current_batch,
|
||||
"total_batches": total_batches,
|
||||
"batch": batch_number,
|
||||
"processed": total_processed,
|
||||
"duplicates_merged": duplicates_merged,
|
||||
"message": f"Processed batch {current_batch}/{total_batches}: {batch_processed} streams, {batch_duplicates} duplicates merged"
|
||||
"message": f"Processed batch {batch_number}: {batch_processed} streams, {batch_duplicates} duplicates merged"
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Rehashed batch {current_batch}/{total_batches}: "
|
||||
logger.info(f"Rehashed batch {batch_number}: "
|
||||
f"{batch_processed} processed, {batch_duplicates} duplicates merged")
|
||||
|
||||
logger.info(f"Rehashing complete: {total_processed} streams processed, "
|
||||
|
|
@ -654,7 +667,7 @@ def rehash_streams(keys):
|
|||
return f"Successfully rehashed {total_processed} streams"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during stream rehash: {e}")
|
||||
logger.error(f"Error during stream rehash: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
# Always release all acquired M3U locks
|
||||
|
|
@ -663,6 +676,83 @@ def rehash_streams(keys):
|
|||
logger.info(f"Released M3U task locks for {len(acquired_locks)} accounts")
|
||||
|
||||
|
||||
def _merge_stream_relationships(source_stream, target_stream):
|
||||
"""
|
||||
Move channel relationships from source_stream to target_stream.
|
||||
Handles unique constraint violations by preserving existing relationships.
|
||||
Preserves the best ordering when merging relationships.
|
||||
"""
|
||||
for channel_stream in ChannelStream.objects.filter(stream_id=source_stream.id):
|
||||
# Check if this channel already has a relationship with the target stream
|
||||
existing_relationship = ChannelStream.objects.filter(
|
||||
channel_id=channel_stream.channel_id,
|
||||
stream_id=target_stream.id
|
||||
).first()
|
||||
|
||||
if existing_relationship:
|
||||
# Relationship already exists - keep the one with better ordering (lower order value)
|
||||
if channel_stream.order < existing_relationship.order:
|
||||
existing_relationship.order = channel_stream.order
|
||||
existing_relationship.save(update_fields=['order'])
|
||||
# Delete the duplicate relationship
|
||||
channel_stream.delete()
|
||||
else:
|
||||
# Safe to update the relationship
|
||||
channel_stream.stream_id = target_stream.id
|
||||
channel_stream.save()
|
||||
|
||||
|
||||
def _get_best_channel_order(stream):
|
||||
"""
|
||||
Get the best (lowest) channel order for a stream.
|
||||
Returns None if stream has no channel relationships.
|
||||
Lower order value = better/higher position in the channel list.
|
||||
"""
|
||||
best_order = ChannelStream.objects.filter(stream_id=stream.id).order_by('order').values_list('order', flat=True).first()
|
||||
return best_order
|
||||
|
||||
|
||||
def _determine_stream_to_keep(stream_a, stream_b):
|
||||
"""
|
||||
Determine which stream should be kept when merging duplicates.
|
||||
|
||||
Priority:
|
||||
1. Stream with better (lower) channel order wins
|
||||
2. If both have same order or neither has channel relationships,
|
||||
keep the one with more recent updated_at
|
||||
3. If still tied, keep the one with the lower ID (more stable)
|
||||
|
||||
Returns: (stream_to_keep, stream_to_delete)
|
||||
"""
|
||||
order_a = _get_best_channel_order(stream_a)
|
||||
order_b = _get_best_channel_order(stream_b)
|
||||
|
||||
# If one has channel relationships and the other doesn't, keep the one with relationships
|
||||
if order_a is not None and order_b is None:
|
||||
return (stream_a, stream_b)
|
||||
if order_b is not None and order_a is None:
|
||||
return (stream_b, stream_a)
|
||||
|
||||
# If both have channel relationships, keep the one with better (lower) order
|
||||
if order_a is not None and order_b is not None:
|
||||
if order_a < order_b:
|
||||
return (stream_a, stream_b)
|
||||
elif order_b < order_a:
|
||||
return (stream_b, stream_a)
|
||||
# Same order, fall through to other criteria
|
||||
|
||||
# Neither has relationships, or same order - use updated_at
|
||||
if stream_a.updated_at > stream_b.updated_at:
|
||||
return (stream_a, stream_b)
|
||||
elif stream_b.updated_at > stream_a.updated_at:
|
||||
return (stream_b, stream_a)
|
||||
|
||||
# Same updated_at - keep lower ID for stability
|
||||
if stream_a.id < stream_b.id:
|
||||
return (stream_a, stream_b)
|
||||
return (stream_b, stream_a)
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_vod_persistent_connections():
|
||||
"""Clean up stale VOD persistent connections"""
|
||||
|
|
@ -675,3 +765,227 @@ def cleanup_vod_persistent_connections():
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during VOD persistent connection cleanup: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_for_version_update():
|
||||
"""
|
||||
Check for new Dispatcharr versions on GitHub and create a notification if available.
|
||||
This task should be run periodically (e.g., daily) via Celery Beat.
|
||||
|
||||
For dev builds (identified by __timestamp__), checks for stable releases only.
|
||||
For production builds, checks for stable releases.
|
||||
|
||||
Note: Dev builds are container images from the dev branch and don't have GitHub releases.
|
||||
This checks if a stable release is available so dev users know when to upgrade.
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
from packaging import version as pkg_version
|
||||
from version import __version__, __timestamp__
|
||||
from core.models import SystemNotification
|
||||
from core.utils import send_websocket_notification
|
||||
|
||||
try:
|
||||
is_dev_build = __timestamp__ is not None
|
||||
DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'}
|
||||
if is_dev_build:
|
||||
# Check Docker Hub for newer dev builds
|
||||
docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev"
|
||||
|
||||
response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}")
|
||||
return
|
||||
|
||||
dev_tag_data = response.json()
|
||||
docker_last_updated = dev_tag_data.get("last_updated")
|
||||
|
||||
if not docker_last_updated:
|
||||
logger.warning("No last_updated timestamp found in Docker Hub response")
|
||||
return
|
||||
|
||||
# Parse timestamps for comparison
|
||||
local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
|
||||
docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00'))
|
||||
|
||||
# Calculate difference in minutes
|
||||
diff_minutes = (docker_dt - local_dt).total_seconds() / 60
|
||||
|
||||
# Threshold to account for build/push time differences
|
||||
THRESHOLD_MINUTES = 10
|
||||
|
||||
if diff_minutes > THRESHOLD_MINUTES:
|
||||
logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)")
|
||||
|
||||
# Delete any old version update notifications (both dev and stable, in case user switched)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Deleted {deleted_count} old dev build notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
# Create notification for new dev build
|
||||
notification, created = SystemNotification.objects.get_or_create(
|
||||
notification_key=f'version-dev-{docker_last_updated}',
|
||||
defaults={
|
||||
'notification_type': 'version_update',
|
||||
'title': 'New Dev Build Available',
|
||||
'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)',
|
||||
'priority': 'medium',
|
||||
'action_data': {
|
||||
'current_version': __version__,
|
||||
'current_timestamp': __timestamp__,
|
||||
'docker_updated': docker_last_updated,
|
||||
'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags'
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
# Only send WebSocket for newly created notifications
|
||||
send_websocket_notification(notification)
|
||||
logger.info(f"New dev build notification created and sent via WebSocket")
|
||||
else:
|
||||
logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})")
|
||||
|
||||
# Delete all version update notifications when up to date (both dev and stable)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted {deleted_count} outdated dev build notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Production build - check GitHub for stable releases
|
||||
github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest"
|
||||
headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS}
|
||||
response = requests.get(
|
||||
github_api_url,
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Failed to check for updates: HTTP {response.status_code}")
|
||||
return
|
||||
|
||||
release_data = response.json()
|
||||
latest_version = release_data.get("tag_name", "").lstrip("v")
|
||||
release_url = release_data.get("html_url", "")
|
||||
|
||||
if not latest_version:
|
||||
logger.warning("No version tag found in GitHub release")
|
||||
return
|
||||
|
||||
# Compare versions
|
||||
current = pkg_version.parse(__version__)
|
||||
latest = pkg_version.parse(latest_version)
|
||||
if latest > current:
|
||||
logger.info(f"New stable version available: {latest_version} (current: {__version__})")
|
||||
|
||||
# Delete any old version update notifications (superseded by this one)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).exclude(
|
||||
notification_key=f"version-{latest_version}"
|
||||
).delete()[0]
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Deleted {deleted_count} old version notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
# Create or update the notification for the new version
|
||||
notification, created = SystemNotification.create_version_notification(
|
||||
version=latest_version,
|
||||
release_url=release_url,
|
||||
)
|
||||
|
||||
if created:
|
||||
# Only send WebSocket for newly created notifications
|
||||
send_websocket_notification(notification)
|
||||
logger.info(f"New version notification created and sent via WebSocket")
|
||||
else:
|
||||
logger.debug(f"Dispatcharr is up to date (v{__version__})")
|
||||
|
||||
# Delete ALL version update notifications when up to date (no longer needed)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted {deleted_count} outdated version notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Network error checking for updates: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for version updates: {e}")
|
||||
|
||||
|
||||
def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None):
|
||||
"""
|
||||
Create a setting recommendation notification.
|
||||
This is a helper function that can be called from anywhere in the codebase.
|
||||
|
||||
Args:
|
||||
setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout')
|
||||
recommended_value: The recommended value for the setting
|
||||
reason: Why this setting is recommended
|
||||
current_value: The current value (optional)
|
||||
|
||||
Returns:
|
||||
The created SystemNotification instance
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
from core.utils import send_websocket_notification
|
||||
|
||||
notification, created = SystemNotification.create_setting_recommendation(
|
||||
setting_key=setting_key,
|
||||
recommended_value=recommended_value,
|
||||
reason=reason,
|
||||
current_value=current_value
|
||||
)
|
||||
|
||||
# Only send via WebSocket for newly created notifications
|
||||
if created:
|
||||
send_websocket_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -437,3 +445,76 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
|
|||
except Exception as e:
|
||||
# Don't let event logging break the main application
|
||||
logger.error(f"Failed to log system event {event_type}: {e}")
|
||||
|
||||
|
||||
def send_websocket_notification(notification):
|
||||
"""
|
||||
Send a system notification to all connected WebSocket clients.
|
||||
|
||||
Args:
|
||||
notification: A SystemNotification model instance or dict with notification data
|
||||
|
||||
Example:
|
||||
from core.models import SystemNotification
|
||||
notification = SystemNotification.create_version_notification('0.19.0', 'https://...')
|
||||
send_websocket_notification(notification)
|
||||
"""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
# Convert model instance to dict if needed
|
||||
if hasattr(notification, 'id'):
|
||||
notification_data = {
|
||||
'id': notification.id,
|
||||
'notification_key': notification.notification_key,
|
||||
'notification_type': notification.notification_type,
|
||||
'priority': notification.priority,
|
||||
'title': notification.title,
|
||||
'message': notification.message,
|
||||
'action_data': notification.action_data,
|
||||
'is_active': notification.is_active,
|
||||
'admin_only': notification.admin_only,
|
||||
'created_at': notification.created_at.isoformat() if notification.created_at else None,
|
||||
}
|
||||
else:
|
||||
notification_data = notification
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
'type': 'system_notification',
|
||||
'notification': notification_data,
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send WebSocket notification: {e}")
|
||||
|
||||
|
||||
def send_notification_dismissed(notification_key):
|
||||
"""
|
||||
Notify all connected clients that a notification was dismissed.
|
||||
Useful for syncing dismissal state across multiple browser tabs/sessions.
|
||||
|
||||
Args:
|
||||
notification_key: The unique key of the dismissed notification
|
||||
"""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
'type': 'notification_dismissed',
|
||||
'notification_key': notification_key,
|
||||
}
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification dismissed event: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -160,15 +160,28 @@ EOSU
|
|||
# 6) Setup Python Environment
|
||||
##############################################################################
|
||||
|
||||
install_uv() {
|
||||
echo ">>> Installing UV package manager..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
|
||||
setup_python_env() {
|
||||
echo ">>> Setting up Python virtual environment..."
|
||||
echo ">>> Setting up Python virtual environment with UV..."
|
||||
# Install UV globally first
|
||||
install_uv
|
||||
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
cd "$APP_DIR"
|
||||
$PYTHON_BIN -m venv env
|
||||
source env/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install gunicorn
|
||||
export PATH="\$HOME/.local/bin:\$PATH"
|
||||
# Install UV for the dispatch user if not already available
|
||||
if ! command -v uv &> /dev/null; then
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="\$HOME/.local/bin:\$PATH"
|
||||
fi
|
||||
# Create venv and install dependencies using UV
|
||||
uv venv env --python $PYTHON_BIN
|
||||
uv sync --python env/bin/python --no-install-project --no-dev
|
||||
EOSU
|
||||
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
|
||||
}
|
||||
|
|
|
|||
52
dispatcharr/app_initialization.py
Normal file
52
dispatcharr/app_initialization.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Utilities for managing app initialization across multiple processes."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import psutil
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_worker_process():
|
||||
"""Check if this process is a worker spawned by uwsgi/gunicorn."""
|
||||
try:
|
||||
parent = psutil.Process(os.getppid())
|
||||
parent_name = parent.name()
|
||||
return parent_name in ['uwsgi', 'gunicorn']
|
||||
except Exception:
|
||||
# If we can't determine, assume it's not a worker (safe default)
|
||||
return False
|
||||
|
||||
|
||||
def should_skip_initialization():
|
||||
"""
|
||||
Determine if app initialization should be skipped in this process.
|
||||
|
||||
Returns True if:
|
||||
- A management command is being run (migrate, celery, shell, etc.)
|
||||
- The development server (daphne) is running
|
||||
- This is a worker process (not the master)
|
||||
|
||||
This prevents redundant initialization across multiple worker processes.
|
||||
"""
|
||||
# Skip management commands and background services
|
||||
skip_commands = [
|
||||
'celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell',
|
||||
'collectstatic', 'loaddata'
|
||||
]
|
||||
if any(cmd in sys.argv for cmd in skip_commands):
|
||||
logger.debug(f"Skipping initialization due to command: {sys.argv}")
|
||||
return True
|
||||
|
||||
# Skip daphne development server (single process, no need to guard)
|
||||
if 'daphne' in sys.argv[0] if sys.argv else False:
|
||||
logger.debug(f"Skipping initialization in daphne development server. Command: {sys.argv}")
|
||||
return True
|
||||
|
||||
# Skip if this is a worker process spawned by uwsgi/gunicorn
|
||||
if _is_worker_process():
|
||||
logger.debug(f"Skipping initialization in worker process. Command: {sys.argv}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -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":
|
||||
|
|
@ -21,6 +24,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|||
INSTALLED_APPS = [
|
||||
"apps.api",
|
||||
"apps.accounts",
|
||||
"apps.backups.apps.BackupsConfig",
|
||||
"apps.channels.apps.ChannelsConfig",
|
||||
"apps.dashboard",
|
||||
"apps.epg",
|
||||
|
|
@ -32,7 +36,7 @@ INSTALLED_APPS = [
|
|||
"apps.vod.apps.VODConfig",
|
||||
"core",
|
||||
"daphne",
|
||||
"drf_yasg",
|
||||
"drf_spectacular",
|
||||
"channels",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
|
|
@ -119,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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -151,7 +160,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
"rest_framework.renderers.BrowsableAPIRenderer",
|
||||
|
|
@ -162,10 +171,22 @@ REST_FRAMEWORK = {
|
|||
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
"SECURITY_DEFINITIONS": {
|
||||
"Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}
|
||||
}
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Dispatcharr API",
|
||||
"DESCRIPTION": "API documentation for Dispatcharr",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"SECURITY": [{"BearerAuth": []}],
|
||||
"COMPONENTS": {
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
|
@ -185,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)
|
||||
|
||||
|
|
@ -224,6 +256,11 @@ CELERY_BEAT_SCHEDULE = {
|
|||
"task": "apps.channels.tasks.maintain_recurring_recordings",
|
||||
"schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead
|
||||
},
|
||||
# Check for version updates daily
|
||||
"check-version-updates": {
|
||||
"task": "core.tasks.check_for_version_update",
|
||||
"schedule": 86400.0, # Once every 24 hours
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
|
@ -252,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
|
||||
|
|
|
|||
|
|
@ -3,35 +3,20 @@ from django.urls import path, include, re_path
|
|||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.generic import TemplateView, RedirectView
|
||||
from rest_framework import permissions
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
from .routing import websocket_urlpatterns
|
||||
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
|
||||
from apps.proxy.ts_proxy.views import stream_xc
|
||||
from apps.output.views import xc_movie_stream, xc_series_stream
|
||||
|
||||
# Define schema_view for Swagger
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
title="Dispatcharr API",
|
||||
default_version="v1",
|
||||
description="API documentation for Dispatcharr",
|
||||
terms_of_service="https://www.google.com/policies/terms/",
|
||||
contact=openapi.Contact(email="contact@dispatcharr.local"),
|
||||
license=openapi.License(name="Creative Commons by-nc-sa"),
|
||||
),
|
||||
public=True,
|
||||
permission_classes=(permissions.AllowAny,),
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# API Routes
|
||||
path("api/", include(("apps.api.urls", "api"), namespace="api")),
|
||||
path("api", RedirectView.as_view(url="/api/", permanent=True)),
|
||||
# Admin
|
||||
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
|
||||
path("admin/", admin.site.urls),
|
||||
# Swagger redirects (Swagger UI is served at /api/swagger/)
|
||||
path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)),
|
||||
path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)),
|
||||
path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)),
|
||||
path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)),
|
||||
# Outputs
|
||||
path("output", RedirectView.as_view(url="/output/", permanent=True)),
|
||||
path("output/", include(("apps.output.urls", "output"), namespace="output")),
|
||||
|
|
@ -67,12 +52,9 @@ urlpatterns = [
|
|||
xc_series_stream,
|
||||
name="xc_series_stream",
|
||||
),
|
||||
|
||||
re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
|
||||
# ReDoc UI
|
||||
path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
|
||||
# Optionally, serve the raw Swagger JSON
|
||||
path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"),
|
||||
# Admin
|
||||
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
|
||||
path("admin/", admin.site.urls),
|
||||
|
||||
# VOD proxy is now handled by the main proxy URLs above
|
||||
# Catch-all routes should always be last
|
||||
|
|
|
|||
|
|
@ -43,11 +43,19 @@ def network_access_allowed(request, settings_key):
|
|||
network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value
|
||||
except CoreSettings.DoesNotExist:
|
||||
network_access = {}
|
||||
local_cidrs = ["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"]
|
||||
# Set defaults based on endpoint type
|
||||
if settings_key == "M3U_EPG":
|
||||
# M3U/EPG endpoints: local IPv4 and IPv6 only by default
|
||||
default_cidrs = local_cidrs
|
||||
else:
|
||||
# Other endpoints: allow all by default
|
||||
default_cidrs = ["0.0.0.0/0", "::/0"]
|
||||
|
||||
cidrs = (
|
||||
network_access[settings_key].split(",")
|
||||
if settings_key in network_access
|
||||
else ["0.0.0.0/0", "::/0"]
|
||||
else default_cidrs
|
||||
)
|
||||
|
||||
network_allowed = False
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ WORKDIR /app
|
|||
COPY . /app
|
||||
# Copy nginx configuration
|
||||
COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default
|
||||
# Fix line endings and make entrypoint scripts executable
|
||||
RUN for f in /app/docker/entrypoint*.sh; do \
|
||||
if [ -f "$f" ]; then \
|
||||
sed -i 's/\r$//' "$f" && chmod +x "$f"; \
|
||||
fi; \
|
||||
done
|
||||
# Clean out existing frontend folder
|
||||
RUN rm -rf /app/frontend
|
||||
# Copy built frontend assets
|
||||
|
|
|
|||
|
|
@ -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,68 +16,121 @@ 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
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- 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)
|
||||
#- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low 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
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- web
|
||||
volumes:
|
||||
- ../:/app
|
||||
- ./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_HOST=redis
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
command: >
|
||||
bash -c "
|
||||
cd /app &&
|
||||
nice -n 5 celery -A dispatcharr worker -l info
|
||||
"
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# ============================================================================
|
||||
# PostgreSQL
|
||||
# ============================================================================
|
||||
db:
|
||||
image: postgres:14
|
||||
image: postgres:17
|
||||
container_name: dispatcharr_db
|
||||
ports:
|
||||
- "5436:5432"
|
||||
|
|
@ -80,10 +140,48 @@ services:
|
|||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatcharr"]
|
||||
interval: 5s
|
||||
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:
|
||||
|
|
|
|||
29
docker/entrypoint.celery.sh
Normal file
29
docker/entrypoint.celery.sh
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
source /dispatcharrpy/bin/activate
|
||||
|
||||
# Wait for Django secret key
|
||||
echo 'Waiting for Django secret key...'
|
||||
while [ ! -f /data/jwt ]; do sleep 1; done
|
||||
export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)"
|
||||
|
||||
# Wait for migrations to complete (check that NO unapplied migrations remain)
|
||||
echo 'Waiting for migrations to complete...'
|
||||
until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do
|
||||
echo 'Migrations not ready yet, waiting...'
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Start Celery
|
||||
echo 'Migrations complete, starting Celery...'
|
||||
celery -A dispatcharr beat -l info &
|
||||
|
||||
# Default to nice level 5 (lower priority) - safe for unprivileged containers
|
||||
# Negative values require SYS_NICE capability
|
||||
NICE_LEVEL="${CELERY_NICE_LEVEL:-5}"
|
||||
if [ "$NICE_LEVEL" -lt 0 ] 2>/dev/null; then
|
||||
echo "Warning: CELERY_NICE_LEVEL=$NICE_LEVEL is negative, requires SYS_NICE capability"
|
||||
fi
|
||||
nice -n "$NICE_LEVEL" celery -A dispatcharr worker -l info --autoscale=6,1
|
||||
|
|
@ -27,18 +27,6 @@ echo_with_timestamp() {
|
|||
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# --- NumPy version switching for legacy hardware ---
|
||||
if [ "$USE_LEGACY_NUMPY" = "true" ]; then
|
||||
# Check if NumPy was compiled with baseline support
|
||||
if /dispatcharrpy/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then
|
||||
echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
|
||||
/dispatcharrpy/bin/pip install --no-cache-dir --force-reinstall --no-deps /opt/numpy-*.whl
|
||||
echo_with_timestamp "✅ Legacy NumPy installed"
|
||||
else
|
||||
echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set PostgreSQL environment variables
|
||||
export POSTGRES_DB=${POSTGRES_DB:-dispatcharr}
|
||||
export POSTGRES_USER=${POSTGRES_USER:-dispatch}
|
||||
|
|
@ -48,7 +36,10 @@ export POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
|||
export PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)
|
||||
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'
|
||||
|
|
@ -66,7 +57,7 @@ PY
|
|||
mv -f "$tmpfile" "$SECRET_FILE" || { echo "move failed"; rm -f "$tmpfile"; exit 1; }
|
||||
umask $old_umask
|
||||
fi
|
||||
export DJANGO_SECRET_KEY="$(cat "$SECRET_FILE")"
|
||||
export DJANGO_SECRET_KEY="$(tr -d '\r\n' < "$SECRET_FILE")"
|
||||
|
||||
# Process priority configuration
|
||||
# UWSGI_NICE_LEVEL: Absolute nice value for uWSGI/streaming (default: 0 = normal priority)
|
||||
|
|
@ -115,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_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
|
||||
)
|
||||
|
|
@ -150,25 +141,74 @@ fi
|
|||
# Run init scripts
|
||||
echo "Starting user setup..."
|
||||
. /app/docker/init/01-user-setup.sh
|
||||
|
||||
# Initialize PostgreSQL (script handles modular vs internal mode internally)
|
||||
echo "Setting up PostgreSQL..."
|
||||
. /app/docker/init/02-postgres.sh
|
||||
|
||||
echo "Starting init process..."
|
||||
. /app/docker/init/03-init-dispatcharr.sh
|
||||
|
||||
# Start PostgreSQL
|
||||
echo "Starting Postgres..."
|
||||
su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
|
||||
# Wait for PostgreSQL to be ready
|
||||
until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
|
||||
echo_with_timestamp "Waiting for PostgreSQL to be ready..."
|
||||
sleep 1
|
||||
done
|
||||
postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
|
||||
echo "✅ Postgres started with PID $postgres_pid"
|
||||
pids+=("$postgres_pid")
|
||||
# Start PostgreSQL if NOT in modular mode (using external database)
|
||||
if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
|
||||
echo "Starting Postgres..."
|
||||
su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
|
||||
# Wait for PostgreSQL to be ready
|
||||
until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
|
||||
echo_with_timestamp "Waiting for PostgreSQL to be ready..."
|
||||
sleep 1
|
||||
done
|
||||
postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
|
||||
echo "✅ Postgres started with PID $postgres_pid"
|
||||
pids+=("$postgres_pid")
|
||||
else
|
||||
echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}"
|
||||
# Wait for external PostgreSQL to be ready using Python (no pg_isready needed)
|
||||
echo_with_timestamp "Waiting for external PostgreSQL to be ready..."
|
||||
until python3 -c "
|
||||
import socket
|
||||
import sys
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
s.connect(('${POSTGRES_HOST}', ${POSTGRES_PORT}))
|
||||
s.close()
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; do
|
||||
echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..."
|
||||
sleep 1
|
||||
done
|
||||
echo "✅ External PostgreSQL is ready"
|
||||
|
||||
# Ensure database encoding is UTF8
|
||||
. /app/docker/init/02-postgres.sh
|
||||
# Check PostgreSQL version compatibility
|
||||
check_external_postgres_version || exit 1
|
||||
fi
|
||||
|
||||
# Wait for Redis to be ready (modular mode uses external Redis)
|
||||
if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
|
||||
echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}"
|
||||
echo_with_timestamp "Waiting for external Redis to be ready..."
|
||||
until python3 -c "
|
||||
import socket
|
||||
import sys
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
s.connect(('${REDIS_HOST}', ${REDIS_PORT}))
|
||||
s.close()
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; do
|
||||
echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
|
||||
sleep 1
|
||||
done
|
||||
echo "✅ External Redis is ready"
|
||||
fi
|
||||
|
||||
# Ensure database encoding is UTF8 (handles both internal and external databases)
|
||||
ensure_utf8_encoding
|
||||
|
||||
if [[ "$DISPATCHARR_ENV" = "dev" ]]; then
|
||||
|
|
@ -186,6 +226,19 @@ else
|
|||
pids+=("$nginx_pid")
|
||||
fi
|
||||
|
||||
|
||||
# --- NumPy version switching for legacy hardware ---
|
||||
if [ "$USE_LEGACY_NUMPY" = "true" ]; then
|
||||
# Check if NumPy was compiled with baseline support
|
||||
if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then
|
||||
echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
|
||||
uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl
|
||||
echo_with_timestamp "✅ Legacy NumPy installed"
|
||||
else
|
||||
echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run Django commands as non-root user to prevent permission issues
|
||||
su - $POSTGRES_USER -c "cd /app && python manage.py migrate --noinput"
|
||||
su - $POSTGRES_USER -c "cd /app && python manage.py collectstatic --noinput"
|
||||
|
|
@ -197,6 +250,9 @@ if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then
|
|||
elif [ "$DISPATCHARR_DEBUG" = "true" ]; then
|
||||
echo "🚀 Starting uwsgi in debug mode..."
|
||||
uwsgi_file="/app/docker/uwsgi.debug.ini"
|
||||
elif [ "$DISPATCHARR_ENV" = "modular" ]; then
|
||||
echo "🚀 Starting uwsgi in modular mode..."
|
||||
uwsgi_file="/app/docker/uwsgi.modular.ini"
|
||||
else
|
||||
echo "🚀 Starting uwsgi in production mode..."
|
||||
uwsgi_file="/app/docker/uwsgi.ini"
|
||||
|
|
@ -214,7 +270,7 @@ fi
|
|||
# Users can override via UWSGI_NICE_LEVEL environment variable in docker-compose
|
||||
# Start with nice as root, then use setpriv to drop privileges to dispatch user
|
||||
# This preserves both the nice value and environment variables
|
||||
nice -n $UWSGI_NICE_LEVEL su - "$POSTGRES_USER" -c "cd /app && exec /dispatcharrpy/bin/uwsgi $uwsgi_args" & uwsgi_pid=$!
|
||||
nice -n $UWSGI_NICE_LEVEL su - "$POSTGRES_USER" -c "cd /app && exec $VIRTUAL_ENV/bin/uwsgi $uwsgi_args" & uwsgi_pid=$!
|
||||
echo "✅ uwsgi started with PID $uwsgi_pid (nice $UWSGI_NICE_LEVEL)"
|
||||
pids+=("$uwsgi_pid")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,123 +1,127 @@
|
|||
#!/bin/bash
|
||||
# Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove
|
||||
# some time in the future.
|
||||
if [ -e "/data/postgresql.conf" ]; then
|
||||
echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..."
|
||||
|
||||
# Create a temporary directory outside of /data
|
||||
mkdir -p /tmp/postgres_migration
|
||||
# Skip internal PostgreSQL setup in modular mode (using external database)
|
||||
if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
|
||||
|
||||
# Move the PostgreSQL files to the temporary directory
|
||||
mv /data/* /tmp/postgres_migration/
|
||||
# Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove
|
||||
# some time in the future.
|
||||
if [ -e "/data/postgresql.conf" ]; then
|
||||
echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..."
|
||||
|
||||
# Create the target directory
|
||||
mkdir -p $POSTGRES_DIR
|
||||
# Create a temporary directory outside of /data
|
||||
mkdir -p /tmp/postgres_migration
|
||||
|
||||
# Move the files from temporary directory to the final location
|
||||
mv /tmp/postgres_migration/* $POSTGRES_DIR/
|
||||
# Move the PostgreSQL files to the temporary directory
|
||||
mv /data/* /tmp/postgres_migration/
|
||||
|
||||
# Clean up the temporary directory
|
||||
rmdir /tmp/postgres_migration
|
||||
# Create the target directory
|
||||
mkdir -p $POSTGRES_DIR
|
||||
|
||||
# Set proper ownership and permissions for PostgreSQL data directory
|
||||
chown -R postgres:postgres $POSTGRES_DIR
|
||||
chmod 700 $POSTGRES_DIR
|
||||
# Move the files from temporary directory to the final location
|
||||
mv /tmp/postgres_migration/* $POSTGRES_DIR/
|
||||
|
||||
echo "Migration completed successfully."
|
||||
fi
|
||||
# Clean up the temporary directory
|
||||
rmdir /tmp/postgres_migration
|
||||
|
||||
PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION"
|
||||
# Set proper ownership and permissions for PostgreSQL data directory
|
||||
chown -R postgres:postgres $POSTGRES_DIR
|
||||
chmod 700 $POSTGRES_DIR
|
||||
|
||||
# Detect current version from data directory, if present
|
||||
if [ -f "$PG_VERSION_FILE" ]; then
|
||||
CURRENT_VERSION=$(cat "$PG_VERSION_FILE")
|
||||
else
|
||||
CURRENT_VERSION=""
|
||||
fi
|
||||
echo "Migration completed successfully."
|
||||
fi
|
||||
|
||||
# Only run upgrade if current version is set and not the target
|
||||
if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then
|
||||
echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..."
|
||||
# Set binary paths for upgrade if needed
|
||||
OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin"
|
||||
NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
|
||||
PG_INSTALLED_BY_SCRIPT=0
|
||||
if [ ! -d "$OLD_BINDIR" ]; then
|
||||
echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..."
|
||||
apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting."
|
||||
exit 1
|
||||
PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION"
|
||||
|
||||
# Detect current version from data directory, if present
|
||||
if [ -f "$PG_VERSION_FILE" ]; then
|
||||
CURRENT_VERSION=$(cat "$PG_VERSION_FILE")
|
||||
else
|
||||
CURRENT_VERSION=""
|
||||
fi
|
||||
|
||||
# Only run upgrade if current version is set and not the target
|
||||
if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then
|
||||
echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..."
|
||||
# Set binary paths for upgrade if needed
|
||||
OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin"
|
||||
NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
|
||||
PG_INSTALLED_BY_SCRIPT=0
|
||||
if [ ! -d "$OLD_BINDIR" ]; then
|
||||
echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..."
|
||||
apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
PG_INSTALLED_BY_SCRIPT=1
|
||||
fi
|
||||
|
||||
# Prepare new data directory
|
||||
NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION"
|
||||
|
||||
# Remove new data directory if it already exists (from a failed/partial upgrade)
|
||||
if [ -d "$NEW_POSTGRES_DIR" ]; then
|
||||
echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues."
|
||||
rm -rf "$NEW_POSTGRES_DIR"
|
||||
fi
|
||||
|
||||
mkdir -p "$NEW_POSTGRES_DIR"
|
||||
chown -R postgres:postgres "$NEW_POSTGRES_DIR"
|
||||
chmod 700 "$NEW_POSTGRES_DIR"
|
||||
|
||||
# Initialize new data directory
|
||||
echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..."
|
||||
su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR"
|
||||
echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..."
|
||||
# Run pg_upgrade
|
||||
su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR"
|
||||
|
||||
# Move old data directory for backup, move new into place
|
||||
mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)"
|
||||
mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR"
|
||||
|
||||
echo "Upgrade complete. Old data directory backed up."
|
||||
|
||||
# Uninstall PostgreSQL if we installed it just for upgrade
|
||||
if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then
|
||||
echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..."
|
||||
apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
|
||||
apt autoremove -y
|
||||
fi
|
||||
PG_INSTALLED_BY_SCRIPT=1
|
||||
fi
|
||||
|
||||
# Prepare new data directory
|
||||
NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION"
|
||||
# Initialize PostgreSQL database
|
||||
if [ -z "$(ls -A $POSTGRES_DIR)" ]; then
|
||||
echo "Initializing PostgreSQL database..."
|
||||
mkdir -p $POSTGRES_DIR
|
||||
chown -R postgres:postgres $POSTGRES_DIR
|
||||
chmod 700 $POSTGRES_DIR
|
||||
|
||||
# Remove new data directory if it already exists (from a failed/partial upgrade)
|
||||
if [ -d "$NEW_POSTGRES_DIR" ]; then
|
||||
echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues."
|
||||
rm -rf "$NEW_POSTGRES_DIR"
|
||||
fi
|
||||
# Initialize PostgreSQL
|
||||
su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}"
|
||||
# Configure PostgreSQL
|
||||
echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf"
|
||||
echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf"
|
||||
|
||||
mkdir -p "$NEW_POSTGRES_DIR"
|
||||
chown -R postgres:postgres "$NEW_POSTGRES_DIR"
|
||||
chmod 700 "$NEW_POSTGRES_DIR"
|
||||
# Start PostgreSQL
|
||||
echo "Starting Postgres..."
|
||||
su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
|
||||
# Wait for PostgreSQL to be ready
|
||||
until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
|
||||
echo "Waiting for PostgreSQL to be ready..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Initialize new data directory
|
||||
echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..."
|
||||
su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR"
|
||||
echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..."
|
||||
# Run pg_upgrade
|
||||
su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR"
|
||||
postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
|
||||
|
||||
# Move old data directory for backup, move new into place
|
||||
mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)"
|
||||
mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR"
|
||||
|
||||
echo "Upgrade complete. Old data directory backed up."
|
||||
|
||||
# Uninstall PostgreSQL if we installed it just for upgrade
|
||||
if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then
|
||||
echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..."
|
||||
apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
|
||||
apt autoremove -y
|
||||
fi
|
||||
fi
|
||||
|
||||
# Initialize PostgreSQL database
|
||||
if [ -z "$(ls -A $POSTGRES_DIR)" ]; then
|
||||
echo "Initializing PostgreSQL database..."
|
||||
mkdir -p $POSTGRES_DIR
|
||||
chown -R postgres:postgres $POSTGRES_DIR
|
||||
chmod 700 $POSTGRES_DIR
|
||||
|
||||
# Initialize PostgreSQL
|
||||
su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}"
|
||||
# Configure PostgreSQL
|
||||
echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf"
|
||||
echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf"
|
||||
|
||||
# Start PostgreSQL
|
||||
echo "Starting Postgres..."
|
||||
su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
|
||||
# Wait for PostgreSQL to be ready
|
||||
until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
|
||||
echo "Waiting for PostgreSQL to be ready..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
|
||||
|
||||
# Setup database if needed
|
||||
if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then
|
||||
# Create PostgreSQL database
|
||||
echo "Creating PostgreSQL database..."
|
||||
su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}"
|
||||
# Create user, set ownership, and grant privileges
|
||||
echo "Creating PostgreSQL user..."
|
||||
su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" <<EOF
|
||||
# Setup database if needed
|
||||
if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then
|
||||
# Create PostgreSQL database
|
||||
echo "Creating PostgreSQL database..."
|
||||
su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}"
|
||||
# Create user, set ownership, and grant privileges
|
||||
echo "Creating PostgreSQL user..."
|
||||
su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" <<EOF
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$POSTGRES_USER') THEN
|
||||
|
|
@ -126,41 +130,119 @@ BEGIN
|
|||
END
|
||||
\$\$;
|
||||
EOF
|
||||
echo "Setting PostgreSQL user privileges..."
|
||||
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO $POSTGRES_USER;\""
|
||||
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO $POSTGRES_USER;\""
|
||||
# Finished setting up PosgresSQL database
|
||||
echo "PostgreSQL database setup complete."
|
||||
echo "Setting PostgreSQL user privileges..."
|
||||
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO $POSTGRES_USER;\""
|
||||
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO $POSTGRES_USER;\""
|
||||
# Finished setting up PosgresSQL database
|
||||
echo "PostgreSQL database setup complete."
|
||||
fi
|
||||
|
||||
kill $postgres_pid
|
||||
while kill -0 $postgres_pid; do
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
kill $postgres_pid
|
||||
while kill -0 $postgres_pid; do
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
fi # End of DISPATCHARR_ENV != modular check
|
||||
|
||||
ensure_utf8_encoding() {
|
||||
# Check encoding of existing database
|
||||
CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | tr -d ' ')
|
||||
# Supports both internal (Unix socket) and external (TCP) PostgreSQL
|
||||
echo "Checking database encoding..."
|
||||
|
||||
if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
|
||||
# External database: use TCP connection with password
|
||||
CURRENT_ENCODING=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();" 2>/dev/null | tr -d ' ')
|
||||
else
|
||||
# Internal database: use Unix socket as postgres user
|
||||
CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();\"" | tr -d ' ')
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_ENCODING" != "UTF8" ]; then
|
||||
echo "Database $POSTGRES_DB encoding is $CURRENT_ENCODING, converting to UTF8..."
|
||||
DUMP_FILE="/tmp/${POSTGRES_DB}_utf8_dump_$(date +%s).sql"
|
||||
# Dump database (include permissions and ownership)
|
||||
su - postgres -c "pg_dump -p ${POSTGRES_PORT} $POSTGRES_DB > $DUMP_FILE"
|
||||
# Drop and recreate database with UTF8 encoding using template0
|
||||
su - postgres -c "dropdb -p ${POSTGRES_PORT} $POSTGRES_DB"
|
||||
# Recreate database with UTF8 encoding
|
||||
su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 ${POSTGRES_DB}"
|
||||
|
||||
|
||||
# Restore data
|
||||
su - postgres -c "psql -p ${POSTGRES_PORT} -d $POSTGRES_DB < $DUMP_FILE"
|
||||
#configure_db
|
||||
|
||||
if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
|
||||
# External database: use TCP connection with password
|
||||
# Dump database (include permissions and ownership)
|
||||
PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" > "$DUMP_FILE" || { echo "Dump failed"; return 1; }
|
||||
# Drop and recreate database with UTF8 encoding using template0
|
||||
PGPASSWORD="$POSTGRES_PASSWORD" dropdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" || { echo "Drop failed"; return 1; }
|
||||
# Recreate database with UTF8 encoding
|
||||
PGPASSWORD="$POSTGRES_PASSWORD" createdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" --encoding=UTF8 --template=template0 "$POSTGRES_DB" || { echo "Create failed"; return 1; }
|
||||
# Restore data
|
||||
PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$DUMP_FILE" || { echo "Restore failed"; return 1; }
|
||||
else
|
||||
# Internal database: use Unix socket as postgres user
|
||||
# Dump database (include permissions and ownership)
|
||||
su - postgres -c "pg_dump -p ${POSTGRES_PORT} ${POSTGRES_DB}" > "$DUMP_FILE" || { echo "Dump failed"; return 1; }
|
||||
# Drop and recreate database with UTF8 encoding using template0
|
||||
su - postgres -c "dropdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" || { echo "Drop failed"; return 1; }
|
||||
# Recreate database with UTF8 encoding and correct owner
|
||||
su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 --owner=${POSTGRES_USER} ${POSTGRES_DB}" || { echo "Create failed"; return 1; }
|
||||
# Restore data
|
||||
cat "$DUMP_FILE" | su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" || { echo "Restore failed"; return 1; }
|
||||
fi
|
||||
|
||||
rm -f "$DUMP_FILE"
|
||||
echo "Database $POSTGRES_DB converted to UTF8 and permissions set."
|
||||
echo "✅ Database $POSTGRES_DB converted to UTF8."
|
||||
else
|
||||
echo "✅ Database encoding is UTF8"
|
||||
fi
|
||||
}
|
||||
|
||||
check_external_postgres_version() {
|
||||
# Only check for modular deployments
|
||||
if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "🔍 Checking external PostgreSQL version compatibility..."
|
||||
|
||||
# Get minimum required version from base image (set in entrypoint.sh)
|
||||
# PG_VERSION is from DispatcharrBase
|
||||
MIN_REQUIRED_VERSION=$PG_VERSION
|
||||
|
||||
# Query external PostgreSQL version
|
||||
EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "postgres" -tAc "SHOW server_version;" 2>/dev/null | grep -oE '^[0-9]+')
|
||||
|
||||
if [ -z "$EXTERNAL_VERSION" ]; then
|
||||
echo "❌ ERROR: Unable to determine external PostgreSQL version"
|
||||
echo " Could not connect to database at ${POSTGRES_HOST}:${POSTGRES_PORT}"
|
||||
echo " Please verify your database connection settings."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Compare versions
|
||||
if [[ "$EXTERNAL_VERSION" -lt "$MIN_REQUIRED_VERSION" ]]; then
|
||||
# FAIL: Version too old
|
||||
echo ""
|
||||
echo "❌ ERROR: PostgreSQL version mismatch"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " External Database: PostgreSQL $EXTERNAL_VERSION"
|
||||
echo " Required Version: PostgreSQL $MIN_REQUIRED_VERSION or higher"
|
||||
echo ""
|
||||
echo " Your external PostgreSQL database is too old for Dispatcharr."
|
||||
echo " Please upgrade to PostgreSQL $MIN_REQUIRED_VERSION or higher."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
return 1
|
||||
|
||||
elif [[ "$EXTERNAL_VERSION" -eq "$MIN_REQUIRED_VERSION" ]]; then
|
||||
# MATCH: Exact version match
|
||||
echo "✅ PostgreSQL version check passed"
|
||||
echo " External Database: PostgreSQL $EXTERNAL_VERSION (matches target version)"
|
||||
|
||||
else
|
||||
# HIGHER: Newer version
|
||||
echo "✅ PostgreSQL version check passed"
|
||||
echo " External Database: PostgreSQL $EXTERNAL_VERSION"
|
||||
echo " Target Version: PostgreSQL $MIN_REQUIRED_VERSION"
|
||||
echo " ℹ️ Your database is newer than the target version."
|
||||
echo " PostgreSQL version should be compatible with Dispatcharr."
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ fi
|
|||
|
||||
# Install frontend dependencies
|
||||
cd /app/frontend && npm install
|
||||
# Install pip dependencies
|
||||
cd /app && pip install -r requirements.txt
|
||||
# Install Python dependencies using UV
|
||||
cd /app && uv sync --python $UV_PROJECT_ENVIRONMENT/bin/python --no-install-project --no-dev
|
||||
|
||||
# Install debugpy for remote debugging
|
||||
if [ "$DISPATCHARR_DEBUG" = "true" ]; then
|
||||
echo "=== setting up debugpy ==="
|
||||
pip install debugpy
|
||||
uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python debugpy
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ server {
|
|||
}
|
||||
|
||||
# admin disabled when not in dev mode
|
||||
location /admin {
|
||||
location ~ ^/admin/?$ {
|
||||
return 301 /login;
|
||||
}
|
||||
|
||||
|
|
|
|||
59
docker/uwsgi.modular.ini
Normal file
59
docker/uwsgi.modular.ini
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
[uwsgi]
|
||||
; Modular deployment mode - external PostgreSQL, Redis, and Celery
|
||||
; Remove file creation commands since we're not logging to files anymore
|
||||
; exec-pre = mkdir -p /data/logs
|
||||
; exec-pre = touch /data/logs/uwsgi.log
|
||||
; exec-pre = chmod 666 /data/logs/uwsgi.log
|
||||
|
||||
; First run Redis availability check script once
|
||||
exec-pre = python /app/scripts/wait_for_redis.py
|
||||
|
||||
; Start Daphne for WebSocket support (required for real-time features)
|
||||
; Redis and Celery run in separate containers in modular mode
|
||||
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
|
||||
|
||||
# Core settings
|
||||
chdir = /app
|
||||
module = dispatcharr.wsgi:application
|
||||
virtualenv = /dispatcharrpy
|
||||
master = true
|
||||
env = DJANGO_SETTINGS_MODULE=dispatcharr.settings
|
||||
env = USE_NGINX_ACCEL=true
|
||||
socket = /app/uwsgi.sock
|
||||
chmod-socket = 777
|
||||
vacuum = true
|
||||
die-on-term = true
|
||||
static-map = /static=/app/static
|
||||
|
||||
# Worker management
|
||||
workers = 4
|
||||
|
||||
# Optimize for streaming
|
||||
http = 0.0.0.0:5656
|
||||
http-keepalive = 1
|
||||
buffer-size = 65536 # Increase buffer for large payloads
|
||||
post-buffering = 4096 # Reduce buffering for real-time streaming
|
||||
http-timeout = 600 # Prevent disconnects from long streams
|
||||
socket-timeout = 600 # Prevent write timeouts when client buffers
|
||||
lazy-apps = true # Improve memory efficiency
|
||||
|
||||
# Async mode (use gevent for high concurrency)
|
||||
gevent = 400 # Each unused greenlet costs ~2-4KB of memory
|
||||
# Higher values have minimal performance impact when idle, but provide capacity for traffic spikes
|
||||
# If memory usage becomes an issue, reduce this value
|
||||
|
||||
# Performance tuning
|
||||
thunder-lock = true
|
||||
log-4xx = true
|
||||
log-5xx = true
|
||||
disable-logging = false
|
||||
|
||||
# Logging configuration
|
||||
# Enable console logging (stdout)
|
||||
log-master = true
|
||||
# Enable strftime formatting for timestamps
|
||||
logformat-strftime = true
|
||||
log-date = %%Y-%%m-%%d %%H:%%M:%%S,000
|
||||
# Use formatted time with environment variable for log level
|
||||
log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms
|
||||
log-buffering = 1024 # Add buffer size limit for logging
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -700,6 +700,17 @@ export const WebsocketProvider = ({ children }) => {
|
|||
withCloseButton: true, // Allow manual close
|
||||
loading: false, // Remove loading indicator
|
||||
});
|
||||
// Requery streams and channels after rehash completes
|
||||
try {
|
||||
await API.requeryChannels();
|
||||
await API.requeryStreams();
|
||||
await useChannelsStore.getState().fetchChannels();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error refreshing channels/streams after rehash:',
|
||||
error
|
||||
);
|
||||
}
|
||||
} else if (parsedEvent.data.action === 'blocked') {
|
||||
// Handle blocked rehash attempt
|
||||
notifications.show({
|
||||
|
|
@ -834,6 +845,59 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'system_notification': {
|
||||
// Handle real-time system notifications (version updates, setting recommendations, etc.)
|
||||
const notificationData = parsedEvent.data.notification;
|
||||
if (notificationData) {
|
||||
// Import and update the notifications store
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore
|
||||
.getState()
|
||||
.addNotification(notificationData);
|
||||
|
||||
// Show a toast notification for high priority items
|
||||
if (
|
||||
notificationData.priority === 'high' ||
|
||||
notificationData.priority === 'critical'
|
||||
) {
|
||||
const color =
|
||||
notificationData.notification_type === 'version_update'
|
||||
? 'green'
|
||||
: notificationData.notification_type === 'warning'
|
||||
? 'orange'
|
||||
: 'blue';
|
||||
|
||||
notifications.show({
|
||||
title: notificationData.title,
|
||||
message: notificationData.message,
|
||||
color,
|
||||
autoClose: 10000,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notification_dismissed': {
|
||||
// Handle notification dismissed from another session
|
||||
const { notification_key } = parsedEvent.data;
|
||||
if (notification_key) {
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore
|
||||
.getState()
|
||||
.dismissNotification(notification_key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notifications_cleared': {
|
||||
// Handle bulk notification clearing (e.g., when version is updated)
|
||||
API.getNotifications();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unknown websocket event type: ${parsedEvent.data?.type}`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -2890,4 +2945,105 @@ export default class API {
|
|||
errorNotification('Failed to retrieve system events', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────
|
||||
// System Notifications
|
||||
// ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all active notifications for the current user
|
||||
* @param {boolean} includeDismissed - Whether to include already dismissed notifications
|
||||
*/
|
||||
static async getNotifications(includeDismissed = false) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (includeDismissed) {
|
||||
params.append('include_dismissed', 'true');
|
||||
}
|
||||
const response = await request(
|
||||
`${host}/api/core/notifications/?${params.toString()}`
|
||||
);
|
||||
|
||||
// Update the store with fetched notifications
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore.getState().setNotifications(response.notifications);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve notifications', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get unread notification count
|
||||
static async getNotificationCount() {
|
||||
try {
|
||||
const response = await request(`${host}/api/core/notifications/count/`);
|
||||
|
||||
// Update the store with the count
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore.getState().setUnreadCount(response.unread_count);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
// Silent fail for count - not critical
|
||||
console.error('Failed to get notification count:', e);
|
||||
return { unread_count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a specific notification
|
||||
* @param {number} notificationId - The notification ID to dismiss
|
||||
* @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored')
|
||||
*/
|
||||
static async dismissNotification(notificationId, actionTaken = null) {
|
||||
try {
|
||||
const body = {};
|
||||
if (actionTaken) {
|
||||
body.action_taken = actionTaken;
|
||||
}
|
||||
|
||||
const response = await request(
|
||||
`${host}/api/core/notifications/${notificationId}/dismiss/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
// Update the store
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore
|
||||
.getState()
|
||||
.dismissNotification(response.notification_key);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to dismiss notification', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss all notifications
|
||||
static async dismissAllNotifications() {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/core/notifications/dismiss-all/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
|
||||
// Update the store
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore.getState().dismissAllNotifications();
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to dismiss all notifications', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
429
frontend/src/components/NotificationCenter.jsx
Normal file
429
frontend/src/components/NotificationCenter.jsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Indicator,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
Megaphone,
|
||||
X,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import useNotificationsStore from '../store/notifications';
|
||||
import API from '../api';
|
||||
|
||||
// Get icon for notification type
|
||||
const getNotificationIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'version_update':
|
||||
return <Download size={16} />;
|
||||
case 'setting_recommendation':
|
||||
return <Settings size={16} />;
|
||||
case 'announcement':
|
||||
return <Megaphone size={16} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle size={16} />;
|
||||
case 'info':
|
||||
default:
|
||||
return <Info size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get color for notification priority
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return 'red';
|
||||
case 'high':
|
||||
return 'orange';
|
||||
case 'normal':
|
||||
return 'blue';
|
||||
case 'low':
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
// Get color for notification type
|
||||
const getTypeColor = (type) => {
|
||||
switch (type) {
|
||||
case 'version_update':
|
||||
return 'green';
|
||||
case 'setting_recommendation':
|
||||
return 'blue';
|
||||
case 'announcement':
|
||||
return 'violet';
|
||||
case 'warning':
|
||||
return 'orange';
|
||||
case 'info':
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
// Individual notification item component
|
||||
const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
const navigate = useNavigate();
|
||||
const typeColor = getTypeColor(notification.notification_type);
|
||||
const priorityColor = getPriorityColor(notification.priority);
|
||||
const isDismissed = notification.is_dismissed;
|
||||
|
||||
const handleDismiss = (e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(notification.id, 'dismissed');
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
// Handle action_url from action_data
|
||||
const actionUrl = notification.action_data?.action_url;
|
||||
const releaseUrl = notification.action_data?.release_url;
|
||||
|
||||
if (actionUrl) {
|
||||
// Internal navigation
|
||||
onClose(); // Close the popover
|
||||
navigate(actionUrl);
|
||||
} else if (releaseUrl) {
|
||||
// External link
|
||||
window.open(releaseUrl, '_blank');
|
||||
}
|
||||
|
||||
if (onAction) {
|
||||
onAction(notification);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderLeft: `3px solid ${theme.colors[priorityColor][5]}`,
|
||||
backgroundColor:
|
||||
notification.priority === 'critical'
|
||||
? theme.colors.red[9] + '10'
|
||||
: undefined,
|
||||
opacity: isDismissed ? 0.6 : 1,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Dismiss button for non-setting notifications (only if not already dismissed) */}
|
||||
{notification.notification_type !== 'setting_recommendation' &&
|
||||
!isDismissed && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
style={{ position: 'absolute', top: 8, right: 8 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||
<ThemeIcon color={typeColor} variant="light" size="md" radius="xl">
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group gap="xs" mb={4}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
{isDismissed && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Dismissed
|
||||
</Badge>
|
||||
)}
|
||||
{notification.priority === 'high' ||
|
||||
notification.priority === 'critical' ? (
|
||||
<Badge size="xs" color={priorityColor} variant="filled">
|
||||
{notification.priority}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" lineClamp={5}>
|
||||
{notification.message}
|
||||
</Text>
|
||||
|
||||
{/* Action buttons for specific notification types */}
|
||||
{notification.notification_type === 'version_update' &&
|
||||
notification.action_data?.release_url && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
mt="xs"
|
||||
leftSection={<ExternalLink size={12} />}
|
||||
onClick={handleAction}
|
||||
>
|
||||
View Release
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Generic action button for notifications with action_url/action_text */}
|
||||
{notification.action_data?.action_url &&
|
||||
notification.action_data?.action_text && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={typeColor}
|
||||
mt="xs"
|
||||
rightSection={<ArrowRight size={12} />}
|
||||
onClick={handleAction}
|
||||
>
|
||||
{notification.action_data.action_text}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{notification.notification_type === 'setting_recommendation' &&
|
||||
!notification.action_data?.action_url && (
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
onDismiss(notification.id, 'applied');
|
||||
// Navigate to settings or apply the setting
|
||||
if (onAction) onAction(notification);
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs" ta="right">
|
||||
{new Date(notification.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Main notification center component with bell icon and popover
|
||||
const NotificationCenter = ({ onSettingAction }) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [showDismissed, setShowDismissed] = useState(false);
|
||||
|
||||
const notifications = useNotificationsStore((s) => s.notifications);
|
||||
const unreadCount = useNotificationsStore((s) => s.unreadCount);
|
||||
const getUnreadNotifications = useNotificationsStore(
|
||||
(s) => s.getUnreadNotifications
|
||||
);
|
||||
|
||||
// Fetch notifications on mount and periodically
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
await API.getNotifications(showDismissed);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error);
|
||||
}
|
||||
}, [showDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
|
||||
// Refresh notifications every 5 minutes
|
||||
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const handleDismiss = async (notificationId, actionTaken = null) => {
|
||||
try {
|
||||
await API.dismissNotification(notificationId, actionTaken);
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissAll = async () => {
|
||||
try {
|
||||
await API.dismissAllNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss all notifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (notification) => {
|
||||
if (
|
||||
notification.notification_type === 'setting_recommendation' &&
|
||||
onSettingAction
|
||||
) {
|
||||
onSettingAction(notification);
|
||||
}
|
||||
};
|
||||
|
||||
const unreadNotifications = getUnreadNotifications();
|
||||
const displayedNotifications = showDismissed
|
||||
? notifications
|
||||
: unreadNotifications;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={380}
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Indicator
|
||||
color="red"
|
||||
size={16}
|
||||
label={unreadCount > 9 ? '9+' : unreadCount}
|
||||
disabled={unreadCount === 0}
|
||||
offset={4}
|
||||
processing={unreadCount > 0}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={20} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p={0}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" p="sm" pb="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Notifications
|
||||
</Text>
|
||||
{unreadCount > 0 && (
|
||||
<Badge size="sm" color="blue" variant="light">
|
||||
{unreadCount} new
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Tooltip
|
||||
label={showDismissed ? 'Hide dismissed' : 'Show dismissed'}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setShowDismissed((prev) => !prev)}
|
||||
>
|
||||
{showDismissed ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{unreadCount > 0 && (
|
||||
<Tooltip label="Mark all as read">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleDismissAll}
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Notification list */}
|
||||
<ScrollArea.Autosize mah={400} type="auto" offsetScrollbars>
|
||||
{displayedNotifications.length === 0 ? (
|
||||
<Box p="lg" ta="center">
|
||||
<ThemeIcon
|
||||
color="gray"
|
||||
variant="light"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
mb="sm"
|
||||
>
|
||||
<Check size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{showDismissed
|
||||
? 'No dismissed notifications'
|
||||
: 'All caught up!'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{showDismissed
|
||||
? 'Dismissed notifications appear here'
|
||||
: 'No new notifications'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap="xs" p="xs">
|
||||
{displayedNotifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onDismiss={handleDismiss}
|
||||
onAction={handleAction}
|
||||
onClose={() => setOpened(false)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{/* Footer with info text */}
|
||||
{!showDismissed &&
|
||||
notifications.length > unreadNotifications.length && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box p="xs" ta="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{notifications.length - unreadNotifications.length} dismissed
|
||||
notification
|
||||
{notifications.length - unreadNotifications.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -287,13 +287,9 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
|
||||
const handleCopyEpisodeLink = async (episode) => {
|
||||
const streamUrl = getEpisodeStreamUrl(episode);
|
||||
const success = await copyToClipboard(streamUrl);
|
||||
notifications.show({
|
||||
title: success ? 'Link Copied!' : 'Copy Failed',
|
||||
message: success
|
||||
? 'Episode link copied to clipboard'
|
||||
: 'Failed to copy link to clipboard',
|
||||
color: success ? 'green' : 'red',
|
||||
await copyToClipboard(streamUrl, {
|
||||
successTitle: 'Link Copied!',
|
||||
successMessage: 'Episode link copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import useSettingsStore from '../store/settings';
|
|||
import useAuthStore from '../store/auth';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import UserForm from './forms/User';
|
||||
import NotificationCenter from './NotificationCenter';
|
||||
|
||||
const NavLink = ({ item, isActive, collapsed }) => {
|
||||
return (
|
||||
|
|
@ -144,16 +145,10 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
// No need to fetch them again here - just use the store values
|
||||
|
||||
const copyPublicIP = async () => {
|
||||
const success = await copyToClipboard(environment.public_ip);
|
||||
if (success) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Public IP copied to clipboard',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
console.error('Failed to copy public IP to clipboard');
|
||||
}
|
||||
await copyToClipboard(environment.public_ip, {
|
||||
successTitle: 'Success',
|
||||
successMessage: 'Public IP copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
const onLogout = async () => {
|
||||
|
|
@ -238,7 +233,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
}}
|
||||
>
|
||||
{isAuthenticated && (
|
||||
<Group>
|
||||
<Stack gap="sm">
|
||||
{!collapsed && (
|
||||
<TextInput
|
||||
label="Public IP"
|
||||
|
|
@ -268,34 +263,54 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Avatar src="" radius="xl" />
|
||||
{!collapsed && authUser && (
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
gap="xs"
|
||||
style={{ justifyContent: 'space-between', width: '100%' }}
|
||||
>
|
||||
<UnstyledButton onClick={() => setUserFormOpen(true)}>
|
||||
{authUser.first_name || authUser.username}
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap="xs">
|
||||
<Avatar src="" radius="xl" />
|
||||
<UnstyledButton onClick={() => setUserFormOpen(true)}>
|
||||
{authUser.first_name || authUser.username}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<ActionIcon variant="transparent" color="white" size="sm">
|
||||
<LogOut onClick={logout} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{collapsed && (
|
||||
<Group gap="xs">
|
||||
<Avatar src="" radius="xl" />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Version is always shown when sidebar is expanded, regardless of auth status */}
|
||||
{/* Version and Notification */}
|
||||
{!collapsed && (
|
||||
<Text size="xs" style={{ padding: '0 16px 16px' }} c="dimmed">
|
||||
v{appVersion?.version || '0.0.0'}
|
||||
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
|
||||
</Text>
|
||||
<Group
|
||||
gap="xs"
|
||||
style={{ padding: '0 16px 16px', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{appVersion?.version || '0.0.0'}
|
||||
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
|
||||
</Text>
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
</Group>
|
||||
)}
|
||||
{collapsed && isAuthenticated && (
|
||||
<Box
|
||||
style={{
|
||||
padding: '0 16px 16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<NotificationCenter />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
|
||||
|
|
|
|||
|
|
@ -268,13 +268,9 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
const handleCopyLink = async () => {
|
||||
const streamUrl = getStreamUrl();
|
||||
if (!streamUrl) return;
|
||||
const success = await copyToClipboard(streamUrl);
|
||||
notifications.show({
|
||||
title: success ? 'Link Copied!' : 'Copy Failed',
|
||||
message: success
|
||||
? 'Stream link copied to clipboard'
|
||||
: 'Failed to copy link to clipboard',
|
||||
color: success ? 'green' : 'red',
|
||||
await copyToClipboard(streamUrl, {
|
||||
successTitle: 'Link Copied!',
|
||||
successMessage: 'Stream link copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ConfirmationDialog from '../../ConfirmationDialog.jsx';
|
|||
import {
|
||||
getNetworkAccessFormInitialValues,
|
||||
getNetworkAccessFormValidation,
|
||||
getNetworkAccessDefaults,
|
||||
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
|
||||
|
||||
const NetworkAccessForm = React.memo(({ active }) => {
|
||||
|
|
@ -37,14 +38,23 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
|
||||
useEffect(() => {
|
||||
const networkAccessSettings = settings['network_access']?.value || {};
|
||||
// M3U/EPG endpoints default to local networks only
|
||||
const m3uEpgDefaults =
|
||||
'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';
|
||||
networkAccessForm.setValues(
|
||||
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||
acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
|
||||
const defaultValue =
|
||||
key === 'M3U_EPG' ? m3uEpgDefaults : '0.0.0.0/0,::/0';
|
||||
acc[key] = networkAccessSettings[key] || defaultValue;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
const resetNetworkAccessToDefaults = () => {
|
||||
networkAccessForm.setValues(getNetworkAccessDefaults());
|
||||
};
|
||||
|
||||
const onNetworkAccessSubmit = async () => {
|
||||
setSaved(false);
|
||||
setNetworkAccessError(null);
|
||||
|
|
@ -120,7 +130,14 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
/>
|
||||
))}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={resetNetworkAccessToDefaults}
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={networkAccessForm.submitting}
|
||||
|
|
|
|||
214
frontend/src/components/modals/EPGMatchModal.jsx
Normal file
214
frontend/src/components/modals/EPGMatchModal.jsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TagsInput,
|
||||
Group,
|
||||
Button,
|
||||
Loader,
|
||||
Radio,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import API from '../../api';
|
||||
import {
|
||||
getChangedSettings,
|
||||
saveChangedSettings,
|
||||
} from '../../utils/pages/SettingsUtils';
|
||||
|
||||
// Extract EPG settings directly without parsing all settings
|
||||
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
|
||||
)
|
||||
? epgSettings.epg_match_ignore_prefixes
|
||||
: [],
|
||||
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)
|
||||
? epgSettings.epg_match_ignore_custom
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const EPGMatchModal = ({ opened, onClose, selectedChannelIds = [] }) => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settingsMode, setSettingsMode] = useState('default');
|
||||
|
||||
// Compute form values directly from settings - memoized for performance
|
||||
const storedValues = useMemo(
|
||||
() => getEpgSettingsFromStore(settings),
|
||||
[settings]
|
||||
);
|
||||
|
||||
// Local form state
|
||||
const [formValues, setFormValues] = useState(storedValues);
|
||||
|
||||
// Track previous opened state to detect transitions
|
||||
const prevOpened = useRef(false);
|
||||
|
||||
// Reset to stored values and mode only when modal opens (not on storedValues changes)
|
||||
useEffect(() => {
|
||||
// Only reset when transitioning from closed to open
|
||||
if (opened && !prevOpened.current) {
|
||||
setFormValues(storedValues);
|
||||
setSettingsMode(storedValues.epg_match_mode);
|
||||
}
|
||||
prevOpened.current = opened;
|
||||
}, [opened, storedValues]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Save mode and settings (backend will ignore custom settings if mode is 'default')
|
||||
const settingsToSave = {
|
||||
...formValues,
|
||||
epg_match_mode: settingsMode,
|
||||
};
|
||||
const changedSettings = getChangedSettings(settingsToSave, settings);
|
||||
if (Object.keys(changedSettings).length > 0) {
|
||||
await saveChangedSettings(settings, changedSettings);
|
||||
}
|
||||
|
||||
// Then trigger auto-match
|
||||
if (selectedChannelIds.length > 0) {
|
||||
await API.matchEpg(selectedChannelIds);
|
||||
notifications.show({
|
||||
title: `EPG matching started for ${selectedChannelIds.length} selected channel(s)`,
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await API.matchEpg();
|
||||
notifications.show({
|
||||
title: 'EPG matching started for all channels without EPG',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error during auto-match:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error.message || 'Failed to start EPG matching',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scopeText =
|
||||
selectedChannelIds.length > 0
|
||||
? `${selectedChannelIds.length} selected channel(s)`
|
||||
: 'all channels without EPG';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="EPG Match Settings"
|
||||
size="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Match channels to EPG data for {scopeText}.
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={settingsMode}
|
||||
onChange={setSettingsMode}
|
||||
label="Matching Mode"
|
||||
>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio
|
||||
value="default"
|
||||
label="Use default settings"
|
||||
description="Recommended for most users. Handles standard channel name variations automatically."
|
||||
/>
|
||||
<Radio
|
||||
value="advanced"
|
||||
label="Configure advanced options"
|
||||
description="Use if channels aren't matching correctly. Add custom prefixes, suffixes, or strings to ignore."
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{settingsMode === 'advanced' && (
|
||||
<>
|
||||
<TagsInput
|
||||
label="Ignore Prefixes"
|
||||
description="Removed from START of channel names (e.g., Prime:, Sling:, US:)"
|
||||
placeholder="Type and press Enter"
|
||||
value={formValues.epg_match_ignore_prefixes}
|
||||
onChange={(value) =>
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
epg_match_ignore_prefixes: value,
|
||||
}))
|
||||
}
|
||||
splitChars={[]}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<TagsInput
|
||||
label="Ignore Suffixes"
|
||||
description="Removed from END of channel names (e.g., HD, 4K, +1)"
|
||||
placeholder="Type and press Enter"
|
||||
value={formValues.epg_match_ignore_suffixes}
|
||||
onChange={(value) =>
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
epg_match_ignore_suffixes: value,
|
||||
}))
|
||||
}
|
||||
splitChars={[]}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<TagsInput
|
||||
label="Ignore Custom Strings"
|
||||
description="Removed from ANYWHERE in channel names (e.g., 24/7, LIVE)"
|
||||
placeholder="Type and press Enter"
|
||||
value={formValues.epg_match_ignore_custom}
|
||||
onChange={(value) =>
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
epg_match_ignore_custom: value,
|
||||
}))
|
||||
}
|
||||
splitChars={[]}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Channel display names are never modified. These settings only
|
||||
affect the matching algorithm.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={loading}>
|
||||
{loading ? <Loader size="xs" /> : 'Start Auto-Match'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGMatchModal;
|
||||
318
frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
Normal file
318
frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import EPGMatchModal from '../EPGMatchModal';
|
||||
import * as SettingsUtils from '../../../utils/pages/SettingsUtils';
|
||||
import API from '../../../api';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
matchEpg: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/pages/SettingsUtils', () => ({
|
||||
getChangedSettings: vi.fn(),
|
||||
saveChangedSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../store/settings', () => ({
|
||||
default: vi.fn((selector) => {
|
||||
const mockState = {
|
||||
settings: {
|
||||
epg_settings: {
|
||||
value: {
|
||||
epg_match_mode: 'default',
|
||||
epg_match_ignore_prefixes: [],
|
||||
epg_match_ignore_suffixes: [],
|
||||
epg_match_ignore_custom: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/core', () => {
|
||||
const React = require('react');
|
||||
|
||||
const RadioComponent = ({
|
||||
label,
|
||||
value,
|
||||
checked,
|
||||
description,
|
||||
groupValue,
|
||||
groupOnChange,
|
||||
}) => {
|
||||
const isChecked = checked !== undefined ? checked : groupValue === value;
|
||||
const handleChange = groupOnChange || (() => {});
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={value}
|
||||
checked={isChecked}
|
||||
onChange={() => handleChange(value)}
|
||||
aria-label={label}
|
||||
/>
|
||||
{label}
|
||||
{description && <span>{description}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
RadioComponent.Group = ({ children, value, onChange, label }) => {
|
||||
// Clone children and inject group props
|
||||
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'
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
return nestedChild;
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
// If it's a Radio component, inject props directly
|
||||
if (child.type === RadioComponent) {
|
||||
return React.cloneElement(child, {
|
||||
groupValue: value,
|
||||
groupOnChange: onChange,
|
||||
});
|
||||
}
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
return (
|
||||
<div role="radiogroup" aria-label={label}>
|
||||
{label && <span>{label}</span>}
|
||||
{enhancedChildren}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
Modal: ({ children, opened, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
|
||||
Radio: RadioComponent,
|
||||
TagsInput: ({ label, value, onChange, ...props }) => (
|
||||
<div>
|
||||
<label htmlFor={label}>{label}</label>
|
||||
<input
|
||||
id={label}
|
||||
aria-label={label}
|
||||
value={value?.join(',') || ''}
|
||||
onChange={(e) => onChange(e.target.value.split(',').filter(Boolean))}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, loading, ...props }) => (
|
||||
<button onClick={onClick} disabled={loading} {...props}>
|
||||
{loading ? 'Loading...' : children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div data-testid="group">{children}</div>,
|
||||
Loader: () => <div data-testid="loader">Loading...</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
};
|
||||
});
|
||||
|
||||
describe('EPGMatchModal', () => {
|
||||
const defaultProps = {
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
selectedChannelIds: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the modal when opened', () => {
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
expect(screen.getByText('EPG Match Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show default mode selected by default', () => {
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
const defaultRadio = screen.getByLabelText('Use default settings');
|
||||
expect(defaultRadio).toBeChecked();
|
||||
});
|
||||
|
||||
it('should not show advanced fields in default mode', () => {
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
expect(
|
||||
screen.queryByLabelText('Ignore Prefixes')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show advanced fields when advanced mode is selected', async () => {
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
const advancedRadio = screen.getByLabelText('Configure advanced options');
|
||||
|
||||
fireEvent.click(advancedRadio);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Ignore Prefixes')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Ignore Suffixes')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText('Ignore Custom Strings')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mode Switching', () => {
|
||||
it('should allow switching between default and advanced modes', async () => {
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
|
||||
const defaultRadio = screen.getByLabelText('Use default settings');
|
||||
const advancedRadio = screen.getByLabelText('Configure advanced options');
|
||||
|
||||
expect(defaultRadio).toBeChecked();
|
||||
|
||||
fireEvent.click(advancedRadio);
|
||||
await waitFor(() => {
|
||||
expect(advancedRadio).toBeChecked();
|
||||
expect(defaultRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(defaultRadio);
|
||||
await waitFor(() => {
|
||||
expect(defaultRadio).toBeChecked();
|
||||
expect(advancedRadio).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should save mode and trigger auto-match', async () => {
|
||||
SettingsUtils.getChangedSettings.mockReturnValue({
|
||||
epg_match_mode: 'default',
|
||||
});
|
||||
SettingsUtils.saveChangedSettings.mockResolvedValue();
|
||||
API.matchEpg.mockResolvedValue();
|
||||
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByText('Start Auto-Match');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
|
||||
expect(API.matchEpg).toHaveBeenCalled();
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass selectedChannelIds to matchEpg when provided', async () => {
|
||||
const selectedIds = [1, 2, 3];
|
||||
SettingsUtils.getChangedSettings.mockReturnValue({});
|
||||
API.matchEpg.mockResolvedValue();
|
||||
|
||||
render(
|
||||
<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />
|
||||
);
|
||||
|
||||
const submitButton = screen.getByText('Start Auto-Match');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.matchEpg).toHaveBeenCalledWith(selectedIds);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle save errors gracefully', async () => {
|
||||
const error = new Error('Save failed');
|
||||
SettingsUtils.getChangedSettings.mockReturnValue({
|
||||
epg_match_mode: 'default',
|
||||
});
|
||||
SettingsUtils.saveChangedSettings.mockRejectedValue(error);
|
||||
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByText('Start Auto-Match');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings Persistence', () => {
|
||||
it('should include epg_match_mode in settings to save', async () => {
|
||||
SettingsUtils.getChangedSettings.mockImplementation((values) => values);
|
||||
SettingsUtils.saveChangedSettings.mockResolvedValue();
|
||||
API.matchEpg.mockResolvedValue();
|
||||
|
||||
render(<EPGMatchModal {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByText('Start Auto-Match');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
epg_match_mode: 'default',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -379,13 +379,9 @@ const ChannelStreams = ({ channel, isExpanded }) => {
|
|||
style={{ cursor: 'pointer' }}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const success = await copyToClipboard(stream.url);
|
||||
notifications.show({
|
||||
title: success ? 'URL Copied' : 'Copy Failed',
|
||||
message: success
|
||||
? 'Stream URL copied to clipboard'
|
||||
: 'Failed to copy URL to clipboard',
|
||||
color: success ? 'green' : 'red',
|
||||
await copyToClipboard(stream.url, {
|
||||
successTitle: 'URL Copied',
|
||||
successMessage: 'Stream URL copied to clipboard',
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -727,14 +727,6 @@ const ChannelsTable = ({ onReady }) => {
|
|||
setRecordingModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = async (textToCopy, ref) => {
|
||||
const success = await copyToClipboard(textToCopy);
|
||||
notifications.show({
|
||||
title: success ? 'Copied!' : 'Copy Failed',
|
||||
message: success ? undefined : 'Failed to copy to clipboard',
|
||||
color: success ? 'green' : 'red',
|
||||
});
|
||||
};
|
||||
// Build URLs with parameters
|
||||
const buildM3UUrl = () => {
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -759,35 +751,23 @@ const ChannelsTable = ({ onReady }) => {
|
|||
};
|
||||
// Example copy URLs
|
||||
const copyM3UUrl = async () => {
|
||||
const success = await copyToClipboard(buildM3UUrl());
|
||||
notifications.show({
|
||||
title: success ? 'M3U URL Copied!' : 'Copy Failed',
|
||||
message: success
|
||||
? 'The M3U URL has been copied to your clipboard.'
|
||||
: 'Failed to copy M3U URL to clipboard',
|
||||
color: success ? 'green' : 'red',
|
||||
await copyToClipboard(buildM3UUrl(), {
|
||||
successTitle: 'M3U URL Copied!',
|
||||
successMessage: 'The M3U URL has been copied to your clipboard.',
|
||||
});
|
||||
};
|
||||
|
||||
const copyEPGUrl = async () => {
|
||||
const success = await copyToClipboard(buildEPGUrl());
|
||||
notifications.show({
|
||||
title: success ? 'EPG URL Copied!' : 'Copy Failed',
|
||||
message: success
|
||||
? 'The EPG URL has been copied to your clipboard.'
|
||||
: 'Failed to copy EPG URL to clipboard',
|
||||
color: success ? 'green' : 'red',
|
||||
await copyToClipboard(buildEPGUrl(), {
|
||||
successTitle: 'EPG URL Copied!',
|
||||
successMessage: 'The EPG URL has been copied to your clipboard.',
|
||||
});
|
||||
};
|
||||
|
||||
const copyHDHRUrl = async () => {
|
||||
const success = await copyToClipboard(hdhrUrl);
|
||||
notifications.show({
|
||||
title: success ? 'HDHR URL Copied!' : 'Copy Failed',
|
||||
message: success
|
||||
? 'The HDHR URL has been copied to your clipboard.'
|
||||
: 'Failed to copy HDHR URL to clipboard',
|
||||
color: success ? 'green' : 'red',
|
||||
await copyToClipboard(hdhrUrl, {
|
||||
successTitle: 'HDHR URL Copied!',
|
||||
successMessage: 'The HDHR URL has been copied to your clipboard.',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import GroupManager from '../../forms/GroupManager';
|
|||
import ConfirmationDialog from '../../ConfirmationDialog';
|
||||
import useWarningsStore from '../../../store/warnings';
|
||||
import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal';
|
||||
import EPGMatchModal from '../../modals/EPGMatchModal';
|
||||
|
||||
const CreateProfilePopover = React.memo(() => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
|
@ -121,6 +122,7 @@ const ChannelTableHeader = ({
|
|||
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
|
||||
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
|
||||
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
|
||||
const [epgMatchModalOpen, setEpgMatchModalOpen] = useState(false);
|
||||
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] =
|
||||
useState(false);
|
||||
const [profileToDelete, setProfileToDelete] = useState(null);
|
||||
|
|
@ -178,26 +180,6 @@ const ChannelTableHeader = ({
|
|||
}
|
||||
};
|
||||
|
||||
const matchEpg = async () => {
|
||||
try {
|
||||
// Hit our new endpoint that triggers the fuzzy matching Celery task
|
||||
// If channels are selected, only match those; otherwise match all
|
||||
if (selectedTableIds.length > 0) {
|
||||
await API.matchEpg(selectedTableIds);
|
||||
notifications.show({
|
||||
title: `EPG matching task started for ${selectedTableIds.length} selected channel(s)!`,
|
||||
});
|
||||
} else {
|
||||
await API.matchEpg();
|
||||
notifications.show({
|
||||
title: 'EPG matching task started for all channels without EPG!',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assignChannels = async () => {
|
||||
try {
|
||||
// Call our custom API endpoint
|
||||
|
|
@ -421,7 +403,7 @@ const ChannelTableHeader = ({
|
|||
<Menu.Item
|
||||
leftSection={<Binary size={18} />}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
onClick={matchEpg}
|
||||
onClick={() => setEpgMatchModalOpen(true)}
|
||||
>
|
||||
<Text size="xs">
|
||||
{selectedTableIds.length > 0
|
||||
|
|
@ -465,6 +447,12 @@ const ChannelTableHeader = ({
|
|||
onClose={() => setGroupManagerOpen(false)}
|
||||
/>
|
||||
|
||||
<EPGMatchModal
|
||||
opened={epgMatchModalOpen}
|
||||
onClose={() => setEpgMatchModalOpen(false)}
|
||||
selectedChannelIds={selectedTableIds}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteProfileOpen}
|
||||
onClose={() => setConfirmDeleteProfileOpen(false)}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const useTable = ({
|
|||
state = [],
|
||||
columnSizing,
|
||||
setColumnSizing,
|
||||
onColumnVisibilityChange,
|
||||
...options
|
||||
}) => {
|
||||
const [selectedTableIds, setSelectedTableIds] = useState([]);
|
||||
|
|
@ -98,12 +99,13 @@ const useTable = ({
|
|||
},
|
||||
...options,
|
||||
state: {
|
||||
...options.state,
|
||||
...state,
|
||||
selectedTableIds,
|
||||
...(columnSizing && { columnSizing }),
|
||||
},
|
||||
onStateChange: options.onStateChange,
|
||||
...(setColumnSizing && { onColumnSizingChange: setColumnSizing }),
|
||||
...(onColumnVisibilityChange && { onColumnVisibilityChange }),
|
||||
getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(),
|
||||
enableColumnResizing: true,
|
||||
columnResizeMode: 'onChange',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import {
|
|||
Filter,
|
||||
Square,
|
||||
SquareCheck,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
TextInput,
|
||||
|
|
@ -242,6 +245,70 @@ const StreamsTable = ({ onReady }) => {
|
|||
'streams-table-column-sizing',
|
||||
{}
|
||||
);
|
||||
|
||||
// Column visibility - persisted to localStorage
|
||||
// Default visible: name, group, m3u
|
||||
// Default hidden: tvg_id, stats
|
||||
const DEFAULT_COLUMN_VISIBILITY = {
|
||||
actions: true,
|
||||
select: true,
|
||||
name: true,
|
||||
group: true,
|
||||
m3u: true,
|
||||
tvg_id: false,
|
||||
stats: false,
|
||||
};
|
||||
|
||||
const [storedColumnVisibility, setStoredColumnVisibility] = useLocalStorage(
|
||||
'streams-table-column-visibility',
|
||||
null // Use null as default to detect fresh install
|
||||
);
|
||||
|
||||
// Merge defaults with stored values, ensuring all columns have values
|
||||
// - Fresh install (null): use defaults
|
||||
// - Existing users: merge settings with defaults for any new columns
|
||||
const columnVisibility = useMemo(() => {
|
||||
if (!storedColumnVisibility || typeof storedColumnVisibility !== 'object') {
|
||||
return DEFAULT_COLUMN_VISIBILITY;
|
||||
}
|
||||
// Merge: start with defaults, overlay stored values only for keys that exist in defaults
|
||||
const merged = { ...DEFAULT_COLUMN_VISIBILITY };
|
||||
for (const key of Object.keys(DEFAULT_COLUMN_VISIBILITY)) {
|
||||
if (
|
||||
key in storedColumnVisibility &&
|
||||
typeof storedColumnVisibility[key] === 'boolean'
|
||||
) {
|
||||
merged[key] = storedColumnVisibility[key];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [storedColumnVisibility]);
|
||||
|
||||
const setColumnVisibility = (newValue) => {
|
||||
if (typeof newValue === 'function') {
|
||||
setStoredColumnVisibility((prev) => {
|
||||
const prevMerged =
|
||||
prev && typeof prev === 'object'
|
||||
? { ...DEFAULT_COLUMN_VISIBILITY, ...prev }
|
||||
: DEFAULT_COLUMN_VISIBILITY;
|
||||
return newValue(prevMerged);
|
||||
});
|
||||
} else {
|
||||
setStoredColumnVisibility(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColumnVisibility = (columnId) => {
|
||||
setColumnVisibility((prev) => ({
|
||||
...prev,
|
||||
[columnId]: !prev[columnId],
|
||||
}));
|
||||
};
|
||||
|
||||
const resetColumnVisibility = () => {
|
||||
setStoredColumnVisibility(DEFAULT_COLUMN_VISIBILITY);
|
||||
};
|
||||
|
||||
const debouncedFilters = useDebounce(filters, 500, () => {
|
||||
// Reset to first page whenever filters change to avoid "Invalid page" errors
|
||||
setPagination({
|
||||
|
|
@ -272,6 +339,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const videoIsVisible = useVideoStore((s) => s.isVisible);
|
||||
|
||||
const data = useStreamsTableStore((s) => s.streams);
|
||||
const pageCount = useStreamsTableStore((s) => s.pageCount);
|
||||
|
|
@ -304,16 +372,19 @@ const StreamsTable = ({ onReady }) => {
|
|||
{
|
||||
id: 'actions',
|
||||
size: columnSizing.actions || 75,
|
||||
minSize: 65,
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
size: columnSizing.select || 30,
|
||||
minSize: 30,
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
grow: true,
|
||||
size: columnSizing.name || 200,
|
||||
minSize: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<Tooltip label={getValue()} openDelay={500}>
|
||||
<Box
|
||||
|
|
@ -336,6 +407,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
? channelGroups[row.channel_group].name
|
||||
: '',
|
||||
size: columnSizing.group || 150,
|
||||
minSize: 75,
|
||||
cell: ({ getValue }) => (
|
||||
<Tooltip label={getValue()} openDelay={500}>
|
||||
<Box
|
||||
|
|
@ -354,6 +426,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
header: 'M3U',
|
||||
id: 'm3u',
|
||||
size: columnSizing.m3u || 150,
|
||||
minSize: 75,
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
cell: ({ getValue }) => (
|
||||
|
|
@ -370,6 +443,103 @@ const StreamsTable = ({ onReady }) => {
|
|||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'TVG-ID',
|
||||
id: 'tvg_id',
|
||||
accessorKey: 'tvg_id',
|
||||
size: columnSizing.tvg_id || 120,
|
||||
minSize: 75,
|
||||
cell: ({ getValue }) => (
|
||||
<Tooltip label={getValue()} openDelay={500}>
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getValue()}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Stats',
|
||||
id: 'stats',
|
||||
accessorKey: 'stream_stats',
|
||||
size: columnSizing.stats || 120,
|
||||
minSize: 75,
|
||||
cell: ({ getValue }) => {
|
||||
const stats = getValue();
|
||||
if (!stats)
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
-
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Build compact display (resolution + video codec)
|
||||
const parts = [];
|
||||
if (stats.resolution) {
|
||||
// Convert "1920x1080" to "1080p" format
|
||||
const height = stats.resolution.split('x')[1];
|
||||
if (height) parts.push(`${height}p`);
|
||||
}
|
||||
if (stats.video_codec) {
|
||||
parts.push(stats.video_codec.toUpperCase());
|
||||
}
|
||||
const compactDisplay = parts.length > 0 ? parts.join(' ') : '-';
|
||||
|
||||
// Build tooltip content with friendly labels
|
||||
const tooltipLines = [];
|
||||
if (stats.resolution)
|
||||
tooltipLines.push(`Resolution: ${stats.resolution}`);
|
||||
if (stats.video_codec)
|
||||
tooltipLines.push(
|
||||
`Video Codec: ${stats.video_codec.toUpperCase()}`
|
||||
);
|
||||
if (stats.video_bitrate)
|
||||
tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`);
|
||||
if (stats.source_fps)
|
||||
tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`);
|
||||
if (stats.audio_codec)
|
||||
tooltipLines.push(
|
||||
`Audio Codec: ${stats.audio_codec.toUpperCase()}`
|
||||
);
|
||||
if (stats.audio_channels)
|
||||
tooltipLines.push(`Audio Channels: ${stats.audio_channels}`);
|
||||
if (stats.audio_bitrate)
|
||||
tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`);
|
||||
|
||||
const tooltipContent =
|
||||
tooltipLines.length > 0
|
||||
? tooltipLines.join('\n')
|
||||
: 'No source info available';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
<Text size="xs" style={{ whiteSpace: 'pre-line' }}>
|
||||
{tooltipContent}
|
||||
</Text>
|
||||
}
|
||||
openDelay={500}
|
||||
multiline
|
||||
w={220}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="xs">{compactDisplay}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[channelGroups, playlists, columnSizing]
|
||||
);
|
||||
|
|
@ -427,6 +597,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
name: 'name',
|
||||
group: 'channel_group__name',
|
||||
m3u: 'm3u_account__name',
|
||||
tvg_id: 'tvg_id',
|
||||
};
|
||||
const sortField = fieldMapping[columnId] || columnId;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
|
|
@ -773,8 +944,6 @@ const StreamsTable = ({ onReady }) => {
|
|||
channel_profile_ids: channelProfileIds,
|
||||
});
|
||||
await API.requeryChannels();
|
||||
// const fetchLogos = useChannelsStore.getState().fetchLogos;
|
||||
// fetchLogos();
|
||||
};
|
||||
|
||||
// Handle confirming the single channel numbering modal
|
||||
|
|
@ -969,6 +1138,57 @@ const StreamsTable = ({ onReady }) => {
|
|||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
case 'tvg_id':
|
||||
return (
|
||||
<Flex align="center" style={{ width: '100%', flex: 1 }}>
|
||||
<TextInput
|
||||
name="tvg_id"
|
||||
placeholder="TVG-ID"
|
||||
value={filters.tvg_id || ''}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={handleFilterChange}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
leftSection={<Search size={14} opacity={0.5} />}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={React.createElement(sortingIcon, {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onSortingChange('tvg_id');
|
||||
},
|
||||
size: 14,
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case 'stats':
|
||||
return (
|
||||
<Flex align="center" style={{ width: '100%', flex: 1 }}>
|
||||
<div
|
||||
className="table-input-header"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 75,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
color: '#cfcfcf',
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '100%' }}>Stats</span>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1008,6 +1228,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
sorting,
|
||||
columnSizing,
|
||||
setColumnSizing,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
|
|
@ -1016,11 +1237,14 @@ const StreamsTable = ({ onReady }) => {
|
|||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
},
|
||||
headerCellRenderFns: {
|
||||
name: renderHeaderCell,
|
||||
group: renderHeaderCell,
|
||||
m3u: renderHeaderCell,
|
||||
tvg_id: renderHeaderCell,
|
||||
stats: renderHeaderCell,
|
||||
},
|
||||
bodyCellRenderFns: {
|
||||
actions: renderBodyCell,
|
||||
|
|
@ -1043,6 +1267,16 @@ const StreamsTable = ({ onReady }) => {
|
|||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Refetch data when video player closes to update stream stats
|
||||
const prevVideoVisible = useRef(false);
|
||||
useEffect(() => {
|
||||
if (prevVideoVisible.current && !videoIsVisible) {
|
||||
// Video was closed, refetch to get updated stream stats
|
||||
fetchData({ showLoader: false });
|
||||
}
|
||||
prevVideoVisible.current = videoIsVisible;
|
||||
}, [videoIsVisible, fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
Object.keys(channelGroups).length > 0 ||
|
||||
|
|
@ -1289,6 +1523,87 @@ const StreamsTable = ({ onReady }) => {
|
|||
Delete
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<Tooltip label="Table Settings" openDelay={500}>
|
||||
<ActionIcon variant="default" size={30}>
|
||||
<EllipsisVertical size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Toggle Columns</Menu.Label>
|
||||
<Menu.Item
|
||||
onClick={() => toggleColumnVisibility('name')}
|
||||
leftSection={
|
||||
columnVisibility.name !== false ? (
|
||||
<Eye size={18} />
|
||||
) : (
|
||||
<EyeOff size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="xs">Name</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => toggleColumnVisibility('group')}
|
||||
leftSection={
|
||||
columnVisibility.group !== false ? (
|
||||
<Eye size={18} />
|
||||
) : (
|
||||
<EyeOff size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="xs">Group</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => toggleColumnVisibility('m3u')}
|
||||
leftSection={
|
||||
columnVisibility.m3u !== false ? (
|
||||
<Eye size={18} />
|
||||
) : (
|
||||
<EyeOff size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="xs">M3U</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => toggleColumnVisibility('tvg_id')}
|
||||
leftSection={
|
||||
columnVisibility.tvg_id !== false ? (
|
||||
<Eye size={18} />
|
||||
) : (
|
||||
<EyeOff size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="xs">TVG-ID</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => toggleColumnVisibility('stats')}
|
||||
leftSection={
|
||||
columnVisibility.stats !== false ? (
|
||||
<Eye size={18} />
|
||||
) : (
|
||||
<EyeOff size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="xs">Stats</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={resetColumnVisibility}
|
||||
leftSection={<RotateCcw size={18} />}
|
||||
>
|
||||
<Text size="xs">Reset to Default</Text>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
123
frontend/src/hooks/__tests__/useLocalStorage.test.jsx
Normal file
123
frontend/src/hooks/__tests__/useLocalStorage.test.jsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useLocalStorage from '../useLocalStorage';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
// Mock console.error to avoid cluttering test output
|
||||
global.console.error = vi.fn();
|
||||
|
||||
describe('useLocalStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with default value when localStorage is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLocalStorage('testKey', 'defaultValue')
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe('defaultValue');
|
||||
});
|
||||
|
||||
it('should initialize with value from localStorage if available', () => {
|
||||
localStorageMock.setItem('testKey', JSON.stringify('storedValue'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLocalStorage('testKey', 'defaultValue')
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe('storedValue');
|
||||
});
|
||||
|
||||
it('should update localStorage when value changes', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('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)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ name: 'updated', count: 100 });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ name: 'updated', count: 100 });
|
||||
});
|
||||
|
||||
it('should handle errors when reading from localStorage', () => {
|
||||
localStorageMock.getItem.mockImplementationOnce(() => {
|
||||
throw new Error('Read error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLocalStorage('testKey', 'defaultValue')
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe('defaultValue');
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Error reading key "testKey":',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when writing to localStorage', () => {
|
||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('Write error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('updated');
|
||||
});
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Error saving setting: testKey:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce('invalid json{');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLocalStorage('testKey', 'defaultValue')
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe('defaultValue');
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
345
frontend/src/hooks/__tests__/useSmartLogos.test.jsx
Normal file
345
frontend/src/hooks/__tests__/useSmartLogos.test.jsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useLogoSelection,
|
||||
useChannelLogoSelection,
|
||||
useLogosById,
|
||||
} from '../useSmartLogos';
|
||||
import useLogosStore from '../../store/logos';
|
||||
|
||||
// Mock the logos store
|
||||
vi.mock('../../store/logos');
|
||||
|
||||
describe('useSmartLogos', () => {
|
||||
describe('useLogoSelection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with empty state', () => {
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: {},
|
||||
fetchLogos: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
expect(result.current.logos).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.hasLogos).toBe(false);
|
||||
});
|
||||
|
||||
it('should load logos when ensureLogosLoaded is called', async () => {
|
||||
const mockFetchLogos = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: {},
|
||||
fetchLogos: mockFetchLogos,
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchLogos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not reload logos if already loaded', async () => {
|
||||
const mockFetchLogos = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: { logo1: { id: 'logo1' } },
|
||||
fetchLogos: mockFetchLogos,
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchLogos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load logos for selection:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should indicate hasLogos when logos are present', () => {
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
|
||||
fetchLogos: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
expect(result.current.hasLogos).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useChannelLogoSelection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with channel logos state', () => {
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
channelLogos: {},
|
||||
hasLoadedChannelLogos: false,
|
||||
backgroundLoading: false,
|
||||
fetchChannelAssignableLogos: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
expect(result.current.logos).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.hasLogos).toBe(false);
|
||||
});
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchChannelLogos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchChannelLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchChannelLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load channel logos:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogosById', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with empty logos', () => {
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useLogosById([]));
|
||||
|
||||
expect(result.current.logos).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.missingLogos).toBe(0);
|
||||
});
|
||||
|
||||
it('should fetch missing logos by IDs', async () => {
|
||||
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
})
|
||||
);
|
||||
|
||||
renderHook(() => useLogosById(['logo1', 'logo2']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
renderHook(() => useLogosById(['logo1', 'logo2']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo2']);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
renderHook(() => useLogosById(['logo1']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load logos by IDs:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should filter out null/undefined IDs', async () => {
|
||||
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
})
|
||||
);
|
||||
|
||||
renderHook(() => useLogosById(['logo1', null, undefined, 'logo2']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not refetch the same IDs multiple times', async () => {
|
||||
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) =>
|
||||
selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
})
|
||||
);
|
||||
|
||||
const { rerender } = renderHook(() => useLogosById(['logo1']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
411
frontend/src/hooks/__tests__/useTablePreferences.test.jsx
Normal file
411
frontend/src/hooks/__tests__/useTablePreferences.test.jsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import useTablePreferences from '../useTablePreferences';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
describe('useTablePreferences', () => {
|
||||
let consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on console.error
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
localStorageMock.clear();
|
||||
|
||||
// Mock window.addEventListener and removeEventListener
|
||||
vi.spyOn(window, 'addEventListener');
|
||||
vi.spyOn(window, 'removeEventListener');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with default values when localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(false);
|
||||
expect(result.current.tableSize).toBe('default');
|
||||
});
|
||||
|
||||
it('should initialize headerPinned from localStorage', () => {
|
||||
localStorageMock.setItem(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should initialize tableSize from localStorage', () => {
|
||||
localStorageMock.setItem(
|
||||
'table-preferences',
|
||||
JSON.stringify({ tableSize: 'compact' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should initialize both preferences from localStorage', () => {
|
||||
localStorageMock.setItem(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(result.current.tableSize).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('should migrate tableSize from old localStorage location', () => {
|
||||
localStorageMock.setItem('table-size', JSON.stringify('compact'));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should prefer new localStorage location over old location', () => {
|
||||
localStorageMock.setItem(
|
||||
'table-preferences',
|
||||
JSON.stringify({ tableSize: 'comfortable' })
|
||||
);
|
||||
localStorageMock.setItem('table-size', JSON.stringify('compact'));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.tableSize).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON in localStorage gracefully', () => {
|
||||
localStorageMock.setItem('table-preferences', 'invalid json');
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(false);
|
||||
expect(result.current.tableSize).toBe('default');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setHeaderPinned', () => {
|
||||
it('should update headerPinned state', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should persist headerPinned to localStorage', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing preferences when updating headerPinned', () => {
|
||||
localStorageMock.setItem(
|
||||
'table-preferences',
|
||||
JSON.stringify({ tableSize: 'compact' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ tableSize: 'compact', headerPinned: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom event when updating headerPinned', () => {
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'table-preferences-changed',
|
||||
detail: { headerPinned: true },
|
||||
})
|
||||
);
|
||||
|
||||
dispatchEventSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle localStorage errors gracefully', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
|
||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving headerPinned to localStorage:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTableSize', () => {
|
||||
it('should update tableSize state', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should persist tableSize to localStorage', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('comfortable');
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ tableSize: 'comfortable' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing preferences when updating tableSize', () => {
|
||||
localStorageMock.setItem(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true, tableSize: 'compact' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom event when updating tableSize', () => {
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'table-preferences-changed',
|
||||
detail: { tableSize: 'compact' },
|
||||
})
|
||||
);
|
||||
|
||||
dispatchEventSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle localStorage errors gracefully', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
|
||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving tableSize to localStorage:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Listeners', () => {
|
||||
it('should register event listener on mount', () => {
|
||||
renderHook(() => useTablePreferences());
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledWith(
|
||||
'table-preferences-changed',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove event listener on unmount', () => {
|
||||
const { unmount } = renderHook(() => useTablePreferences());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
'table-preferences-changed',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('should update headerPinned from custom event', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should update tableSize from custom event', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { tableSize: 'compact' },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should update both preferences from custom event', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true, tableSize: 'comfortable' },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(result.current.tableSize).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('should not update if value is the same', () => {
|
||||
localStorageMock.setItem(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
const initialHeaderPinned = result.current.headerPinned;
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(initialHeaderPinned);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should handle complete workflow', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
// Update headerPinned
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true })
|
||||
);
|
||||
|
||||
// Update tableSize
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true, tableSize: 'compact' })
|
||||
);
|
||||
|
||||
// Verify both preferences are maintained
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should sync changes across multiple hook instances via events', () => {
|
||||
const { result: result1 } = renderHook(() => useTablePreferences());
|
||||
const { result: result2 } = renderHook(() => useTablePreferences());
|
||||
|
||||
// Update from first instance
|
||||
act(() => {
|
||||
result1.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
// Second instance should receive the event
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result2.current.headerPinned).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const useLocalStorage = (key, defaultValue) => {
|
||||
const localKey = key;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -79,19 +79,19 @@ const PageContent = () => {
|
|||
defaultSizes={allotmentSizes}
|
||||
h={'100%'}
|
||||
w={'100%'}
|
||||
miw={'600px'}
|
||||
miw={'625px'}
|
||||
className="custom-allotment"
|
||||
minSize={100}
|
||||
onChange={handleSplitChange}
|
||||
onResize={handleResize}
|
||||
>
|
||||
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
|
||||
<Box miw={'600px'}>
|
||||
<Box miw={'625px'}>
|
||||
<ChannelsTable onReady={handleChannelsReady} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
|
||||
<Box miw={'600px'}>
|
||||
<Box miw={'625px'}>
|
||||
<StreamsTable onReady={handleStreamsReady} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { Suspense, useState } from 'react';
|
||||
import React, { Suspense, useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControl,
|
||||
|
|
@ -7,48 +8,64 @@ import {
|
|||
Box,
|
||||
Center,
|
||||
Text,
|
||||
Loader
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
const UserAgentsTable = React.lazy(() =>
|
||||
import('../components/tables/UserAgentsTable.jsx'));
|
||||
const StreamProfilesTable = React.lazy(() =>
|
||||
import('../components/tables/StreamProfilesTable.jsx'));
|
||||
const BackupManager = React.lazy(() =>
|
||||
import('../components/backups/BackupManager.jsx'));
|
||||
const UserAgentsTable = React.lazy(
|
||||
() => import('../components/tables/UserAgentsTable.jsx')
|
||||
);
|
||||
const StreamProfilesTable = React.lazy(
|
||||
() => import('../components/tables/StreamProfilesTable.jsx')
|
||||
);
|
||||
const BackupManager = React.lazy(
|
||||
() => import('../components/backups/BackupManager.jsx')
|
||||
);
|
||||
import useAuthStore from '../store/auth';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
const NetworkAccessForm = React.lazy(() =>
|
||||
import('../components/forms/settings/NetworkAccessForm.jsx'));
|
||||
const ProxySettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/ProxySettingsForm.jsx'));
|
||||
const StreamSettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/StreamSettingsForm.jsx'));
|
||||
const DvrSettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/DvrSettingsForm.jsx'));
|
||||
const SystemSettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/SystemSettingsForm.jsx'));
|
||||
const NetworkAccessForm = React.lazy(
|
||||
() => import('../components/forms/settings/NetworkAccessForm.jsx')
|
||||
);
|
||||
const ProxySettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/ProxySettingsForm.jsx')
|
||||
);
|
||||
const StreamSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/StreamSettingsForm.jsx')
|
||||
);
|
||||
const DvrSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/DvrSettingsForm.jsx')
|
||||
);
|
||||
const SystemSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/SystemSettingsForm.jsx')
|
||||
);
|
||||
|
||||
const SettingsPage = () => {
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const location = useLocation();
|
||||
|
||||
const [accordianValue, setAccordianValue] = useState(null);
|
||||
const [accordianValue, setAccordianValue] = useState('ui-settings');
|
||||
|
||||
// Handle hash navigation to open specific accordion
|
||||
useEffect(() => {
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) {
|
||||
setAccordianValue(hash);
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
return (
|
||||
<Center p={10}>
|
||||
<Box w={'100%'} maw={800}>
|
||||
<Accordion
|
||||
variant="separated"
|
||||
defaultValue="ui-settings"
|
||||
value={accordianValue}
|
||||
onChange={setAccordianValue}
|
||||
miw={400}
|
||||
>
|
||||
<AccordionItem value="ui-settings">
|
||||
<AccordionControl>UI Settings</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<UiSettingsForm
|
||||
active={accordianValue === 'ui-settings'} />
|
||||
<UiSettingsForm active={accordianValue === 'ui-settings'} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
|
|
@ -60,7 +77,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<DvrSettingsForm
|
||||
active={accordianValue === 'dvr-settings'} />
|
||||
active={accordianValue === 'dvr-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -72,7 +90,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<StreamSettingsForm
|
||||
active={accordianValue === 'stream-settings'} />
|
||||
active={accordianValue === 'stream-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -84,7 +103,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<SystemSettingsForm
|
||||
active={accordianValue === 'system-settings'} />
|
||||
active={accordianValue === 'system-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -96,7 +116,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<UserAgentsTable
|
||||
active={accordianValue === 'user-agents'} />
|
||||
active={accordianValue === 'user-agents'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -108,7 +129,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<StreamProfilesTable
|
||||
active={accordianValue === 'stream-profiles'} />
|
||||
active={accordianValue === 'stream-profiles'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -127,7 +149,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<NetworkAccessForm
|
||||
active={accordianValue === 'network-access'} />
|
||||
active={accordianValue === 'network-access'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -141,7 +164,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<ProxySettingsForm
|
||||
active={accordianValue === 'proxy-settings'} />
|
||||
active={accordianValue === 'proxy-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
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