Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/JCBird1012/1255

This commit is contained in:
SergeantPanda 2026-05-23 21:43:13 -05:00
commit 61c187fc72
18 changed files with 477 additions and 167 deletions

View file

@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state.
## [0.25.1] - 2026-05-23
### Fixed
- **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260)
- **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259)
- **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly.
- **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback.
### Performance
- **M3U playlist URL building moved outside the channel loop.** `generate_m3u` previously rebuilt the query-string suffix (and for XC requests, called `build_absolute_uri_with_port`) on every channel iteration even though both inputs are request-level constants. The XC base URL and both query-string suffixes (`xc_qs_suffix`, `proxy_qs_suffix`) are now computed once before the channel loop; per-channel URL assembly is a single f-string interpolation.
## [0.25.0] - 2026-05-21
### Security
- Updated `Django` 6.0.4 → 6.0.5, resolving the following CVEs:
@ -16,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **About modal.** A `?` button in the sidebar footer opens an About dialog showing the current version, links to Documentation, Discord, GitHub, and Open Collective, a contributors acknowledgment, and a memorial note for Jesse Mann. The button is visible in both expanded and collapsed sidebar states.
- **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option:
- **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut.
- **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this.
@ -48,6 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **Custom SVG icons extracted to `icons.jsx`.** `DiscordIcon` and `GitHubIcon` were moved from `PluginDetailPanel.jsx` into a new shared `frontend/src/components/icons.jsx` module so they can be reused across components without cross-importing from an unrelated file.
- **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations.
- **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected.
- **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs.
@ -102,6 +124,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Dependency updates:
- `Django` 6.0.4 → 6.0.5 (security patch; see Security section)
### Removed
- **`python-gnupg` dependency dropped.** GPG manifest signature verification now calls the `gpg` binary directly via `os.posix_spawn` (see Fixed below). The `python-gnupg` Python library was the only consumer and has been removed from `pyproject.toml`. The `gpg` binary itself is still required on the host (it was always required since `python-gnupg` is just a wrapper around it).
### Fixed
- **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead.
@ -113,6 +139,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `input/http_streamer.py`: set `O_NONBLOCK` on the HTTP-to-pipe relay write-end with an EAGAIN retry loop for the same reason.
- `core/views.py` (`stream_view`): replaced `subprocess.Popen` with `os.posix_spawn`; also fixed a pre-existing indentation bug where the `return StreamingHttpResponse(...)` was accidentally nested inside `stream_generator` (making every successful response return `None` and raise a Django error). Also corrected two `NameError` references to the undefined `stream_id` variable in log messages.
- `apps/connect/handlers/script.py` (`ScriptHandler`): replaced `subprocess.run` with a `_posix_run` helper that uses `os.posix_spawn` + cooperative `select.select` reads + non-blocking `waitpid` polling. Without this fix, any script-type connect integration configured for events fired from a uWSGI worker (e.g. `client_connect`) would deadlock the serving greenlet. Note: `cwd` is no longer set to the script's directory during execution (it inherits the worker's cwd); this was a minor convenience, not a documented guarantee.
- **`POST /api/plugins/repos/plugin-detail/` hung for up to 105 seconds under gevent+uWSGI.** The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures, triggering the same `fork()` atfork deadlock described above. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. `select.select()` drains stdout/stderr cooperatively (gevent-patched) and `os.waitpid(WNOHANG)` with `time.sleep(0.01)` reaps the child without blocking the hub. Results are cached in Redis for 5 minutes per manifest URL so repeat detail fetches skip GPG entirely. The cache is also invalidated per-plugin when the owning repo's hub manifest is refreshed, so a newly released version is visible immediately after a manual hub refresh.
- **XC server sub-path URLs now work correctly.** When a provider serves its XC API from a sub-path (e.g. `http://server/Pluto/gb/player_api.php`), Dispatcharr was stripping the path entirely and hitting the root (`/player_api.php`) instead. `_normalize_url` now preserves sub-path components and only strips any trailing `.php` segment (covering `player_api.php`, `get.php`, `xmltv.php`, and any future endpoint without a maintained list). The same fix is applied to `get_transformed_credentials` in the M3U profile transformation path. (Fixes #1218)
- **M3U filter delete confirmation showed wrong field name and had a typo.** The confirmation dialog for deleting an M3U filter read `filter.type` (always `undefined`) instead of `filter.filter_type`, leaving the "Type:" line blank, and displayed "Patter:" instead of "Pattern:". Both are corrected. — Thanks [@nick4810](https://github.com/nick4810)
- **M3U form FileInput expanded the modal width on long filenames.** Uploading a local M3U file with a long name caused the `FileInput` to expand beyond the modal's layout bounds. The input now clips overflow with `textOverflow: ellipsis`. — Thanks [@nick4810](https://github.com/nick4810)

View file

@ -81,6 +81,7 @@ This is the simplest valid repo manifest - one plugin with enough info to show i
Dispatcharr accepts two top-level shapes:
**Wrapped (supports signing):**
```json
{
"manifest": { "plugins": [...], ... },
@ -89,6 +90,7 @@ Dispatcharr accepts two top-level shapes:
```
**Flat (no signing):**
```json
{
"plugins": [...],
@ -118,36 +120,36 @@ If the name contains any of these, the repo will be rejected on add and skipped
### Top-Level Metadata
| Field | Required | Description |
|-------|----------|-------------|
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
| `plugins` | **Yes** | Array of plugin entry objects. |
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
| `plugins` | **Yes** | Array of plugin entry objects. |
### Plugin Entry Fields
| Field | Required | Description |
|-------|----------|-------------|
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
| `name` | **Yes** | Human-readable display name. |
| `description` | No | Short description shown on the plugin card. |
| `author` | No | Author or organization name. |
| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. |
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. |
| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). |
| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. |
| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. |
| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. |
| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). |
| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. |
| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). |
| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. |
| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. |
| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). |
| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. |
| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. |
| Field | Required | Description |
| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
| `name` | **Yes** | Human-readable display name. |
| `description` | No | Short description shown on the plugin card. |
| `author` | No | Author or organization name. |
| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. |
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. |
| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). |
| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. |
| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. |
| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. |
| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). |
| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. |
| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). |
| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. |
| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. |
| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). |
| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. |
| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. |
Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything.
@ -160,6 +162,7 @@ If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`)
```
This lets you keep plugin entries compact:
```json
{
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
@ -175,6 +178,7 @@ This lets you keep plugin entries compact:
```
**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL:
```
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
```
@ -186,6 +190,7 @@ This lets you keep plugin entries compact:
The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead.
Include a per-plugin manifest if you want to:
- Offer multiple downloadable versions
- Show per-version compatibility ranges
- Display build timestamps and commit links for each version
@ -196,6 +201,7 @@ Include a per-plugin manifest if you want to:
Same as the root manifest - both flat and wrapped formats are accepted:
**Flat (no signing):**
```json
{
"slug": "...",
@ -204,6 +210,7 @@ Same as the root manifest - both flat and wrapped formats are accepted:
```
**Wrapped (supports signing):**
```json
{
"manifest": {
@ -272,33 +279,33 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
### Per-Plugin Manifest Fields
| Field | Required | Description |
|-------|----------|-------------|
| `slug` | No | Plugin identifier (should match the repo entry). |
| `name` | No | Display name. |
| `description` | No | Full description shown in the detail modal. |
| `author` | No | Author/org name shown in the detail modal. |
| `license` | No | SPDX license identifier. |
| `latest_version` | No | Latest version string. |
| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. |
| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. |
| `versions` | No | Array of version objects (newest first recommended). |
| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. |
| Field | Required | Description |
| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `slug` | No | Plugin identifier (should match the repo entry). |
| `name` | No | Display name. |
| `description` | No | Full description shown in the detail modal. |
| `author` | No | Author/org name shown in the detail modal. |
| `license` | No | SPDX license identifier. |
| `latest_version` | No | Latest version string. |
| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. |
| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. |
| `versions` | No | Array of version objects (newest first recommended). |
| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. |
### Version Object Fields
| Field | Required | Description |
|-------|----------|-------------|
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. |
| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. |
| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. |
| `size` | No | Size of this version's zip in kilobytes. Informational only. |
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
| Field | Required | Description |
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. |
| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. |
| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. |
| `size` | No | Size of this version's zip in kilobytes. Informational only. |
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`.
@ -348,6 +355,7 @@ jq -c '.manifest' manifest.json | gpg --armor --detach-sign
```
In code terms:
```python
import json
canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n"
@ -371,11 +379,11 @@ Use the wrapped format so the signature sits alongside the manifest:
### Verification Results
| Result | Meaning | UI Badge |
|--------|---------|----------|
| `true` | Valid signature | Green checkmark |
| `false` | Invalid signature or verification error | Red X |
| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral |
| Result | Meaning | UI Badge |
| ------- | ------------------------------------------------------------------- | --------------- |
| `true` | Valid signature | Green checkmark |
| `false` | Invalid signature or verification error | Red X |
| `null` | Not attempted (no signature, no key, or `gpg` binary not installed) | Gray/neutral |
### Signing Workflow Example
@ -446,6 +454,7 @@ my_plugin-1.0.0.zip
```
Or with a subdirectory:
```
my_plugin-1.0.0.zip
my_plugin/
@ -477,6 +486,7 @@ The plugin is installed **disabled** by default. The user can enable it from the
Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection.
A plugin shows "Update Available" when:
- It is managed (installed from a repo)
- Its `installed_version` differs from `latest_version`
- It was installed from the same repo
@ -488,7 +498,9 @@ A plugin shows "Update Available" when:
A plugin repo manifest is just a JSON file served over HTTPS. Some options:
### GitHub Pages / Raw Content
Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs:
```
https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
```
@ -496,9 +508,11 @@ https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
Use `root_url` pointing to your releases branch/path so version URLs stay relative.
### Static File Server
Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed.
### GitHub Releases
You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch.
---

View file

@ -1,6 +1,7 @@
import logging
import traceback
from celery import shared_task
from django.core.management import call_command
from . import services
@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str):
backup_file = backup_dir / filename
logger.info(f"[RESTORE] Backup file path: {backup_file}")
services.restore_backup(backup_file)
logger.info(f"[RESTORE] Running migrations after restore...")
call_command('migrate', '--noinput', verbosity=1)
logger.info(f"[RESTORE] Task {self.request.id} completed successfully")
return {
"status": "completed",

View file

@ -62,6 +62,9 @@ def backfill_auto_created_by_null(apps, schema_editor):
f"(ambiguous/no streams): {demoted}"
)
with schema_editor.connection.cursor() as cursor:
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
def reverse_auto_created_by_null(apps, schema_editor):
# Forward decisions cannot be cleanly reverted (no record of the
@ -101,6 +104,9 @@ def reverse_backfill_channel_number_nulls(apps, schema_editor):
ch.save(update_fields=["channel_number"])
next_num += 1.0
with schema_editor.connection.cursor() as cursor:
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
class Migration(migrations.Migration):

View file

@ -1361,11 +1361,8 @@ def _evaluate_series_rules_locked(tvg_id, result):
# Notify frontend to refresh
try:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'updates',
{'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}},
)
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]})
except Exception:
pass
@ -1440,11 +1437,8 @@ def reschedule_upcoming_recordings_for_offset_change_impl():
# Notify frontend to refresh
try:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'updates',
{'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "rescheduled": changed}},
)
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "rescheduled": changed})
except Exception:
pass

View file

@ -1,6 +1,5 @@
from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse
import json
from rest_framework.response import Response
from django.urls import reverse
from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream
from apps.channels.utils import format_channel_number
@ -16,7 +15,7 @@ from datetime import datetime, timedelta
import html
import time
from tzlocal import get_localzone
from urllib.parse import urlparse
from urllib.parse import urlencode
import base64
import logging
from django.db.models.functions import Lower
@ -211,7 +210,21 @@ def generate_m3u(request, profile_name=None, user=None):
# This is an XC API request - use XC-style EPG URL
base_url = build_absolute_uri_with_port(request, '')
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
# Build the query-string suffix for stream URLs once - it's the same for every channel
xc_qs = {}
if output_profile_id:
xc_qs['output_profile'] = output_profile_id
if output_format_param:
xc_qs['output_format'] = output_format_param
xc_qs_suffix = f"?{urlencode(xc_qs)}" if xc_qs else ""
else:
# Pre-compute proxy query-string suffix (same for every channel in this request)
proxy_qs = {}
if output_profile_id:
proxy_qs['output_profile'] = output_profile_id
if output_format_param:
proxy_qs['output_format'] = output_format_param
proxy_qs_suffix = f"?{urlencode(proxy_qs)}" if proxy_qs else ""
# Regular request - use standard EPG endpoint
epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint'))
@ -219,7 +232,6 @@ def generate_m3u(request, profile_name=None, user=None):
preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days']
query_params = {k: v for k, v in request.GET.items() if k in preserved_params}
if query_params:
from urllib.parse import urlencode
epg_url = f"{epg_base_url}?{urlencode(query_params)}"
else:
epg_url = epg_base_url
@ -281,9 +293,7 @@ def generate_m3u(request, profile_name=None, user=None):
# Determine the stream URL based on request type
if is_xc_request:
# XC API request - use XC-style stream URL format
base_url = build_absolute_uri_with_port(request, '')
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}"
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}{xc_qs_suffix}"
elif use_direct_urls:
# Try to get the first stream's direct URL
all_streams = channel.streams.all()
@ -297,16 +307,7 @@ def generate_m3u(request, profile_name=None, user=None):
else:
# Standard behavior - use proxy URL
base_stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
qs_parts = {}
if output_profile_id:
qs_parts['output_profile'] = output_profile_id
if output_format_param:
qs_parts['output_format'] = output_format_param
if qs_parts:
from urllib.parse import urlencode
stream_url = f"{base_stream_url}?{urlencode(qs_parts)}"
else:
stream_url = base_stream_url
stream_url = f"{base_stream_url}{proxy_qs_suffix}"
m3u_content += extinf_line + stream_url + "\n"

View file

@ -1,7 +1,6 @@
import hashlib
import ipaddress
import logging
import io
import json
import re
import socket
@ -9,6 +8,7 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, serializers
from drf_spectacular.utils import extend_schema, inline_serializer
from django.core.cache import cache
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.http import FileResponse
@ -335,6 +335,21 @@ def _save_fetched_manifest_to_repo(repo, data, verified):
return None
def _invalidate_plugin_detail_cache(repo_id, manifest_data):
manifest = manifest_data.get("manifest", manifest_data)
root_url = manifest.get("root_url", "").rstrip("/")
keys = []
for p in manifest.get("plugins", []):
url = p.get("manifest_url", "")
if not url:
continue
if root_url and not url.startswith(("http://", "https://")):
url = f"{root_url}/{url}"
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
if keys:
cache.delete_many(keys)
def _unmanage_dropped_slugs(repo, new_manifest_data):
"""After a manifest refresh, clear source_repo on any installed plugins
whose slug is no longer listed in the repo's manifest. Also syncs the
@ -571,6 +586,7 @@ class PluginDeleteAPIView(PluginAuthMixin, APIView):
# ---------------------------------------------------------------------------
MANIFEST_FETCH_TIMEOUT = 15
PLUGIN_DETAIL_CACHE_TTL = 300 # seconds
OFFICIAL_KEY_PATH = os.path.join(
os.path.dirname(__file__), "keys", "dispatcharr-plugins.pub"
@ -589,6 +605,101 @@ def _normalize_pgp_key(text):
return text
def _gpg_run(cmd, input_data=None, timeout=30):
"""
Run a GPG command using os.posix_spawn.
os.posix_spawn skips pthread_atfork handlers, avoiding the indefinite hang
that fork()-based approaches suffer under gevent+uWSGI. select.select()
and time.sleep() are gevent-patched so reads and the waitpid poll yield to
the hub cooperatively.
Returns (returncode, stdout_bytes, stderr_bytes).
"""
import select as _select
import signal as _signal
import time as _time
stdin_r, stdin_w = os.pipe()
stdout_r, stdout_w = os.pipe()
stderr_r, stderr_w = os.pipe()
try:
executable = shutil.which(cmd[0]) or cmd[0]
pid = os.posix_spawn(
executable, cmd, os.environ,
file_actions=[
(os.POSIX_SPAWN_DUP2, stdin_r, 0),
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
(os.POSIX_SPAWN_DUP2, stderr_w, 2),
(os.POSIX_SPAWN_CLOSE, stdin_r),
(os.POSIX_SPAWN_CLOSE, stdin_w),
(os.POSIX_SPAWN_CLOSE, stdout_w),
(os.POSIX_SPAWN_CLOSE, stderr_w),
(os.POSIX_SPAWN_CLOSE, stdout_r),
(os.POSIX_SPAWN_CLOSE, stderr_r),
],
)
except Exception:
for fd in (stdin_r, stdin_w, stdout_r, stdout_w, stderr_r, stderr_w):
try:
os.close(fd)
except OSError:
pass
raise
for fd in (stdin_r, stdout_w, stderr_w):
os.close(fd)
try:
if input_data:
os.write(stdin_w, input_data)
finally:
os.close(stdin_w)
out, err = [], []
done = set()
deadline = _time.monotonic() + timeout
try:
while len(done) < 2:
remaining = deadline - _time.monotonic()
if remaining <= 0:
try:
os.kill(pid, _signal.SIGKILL)
except ProcessLookupError:
pass
break
fds = [fd for fd in (stdout_r, stderr_r) if fd not in done]
readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5))
for fd in readable:
data = os.read(fd, 8192)
if data:
(out if fd == stdout_r else err).append(data)
else:
done.add(fd)
finally:
for fd in (stdout_r, stderr_r):
try:
os.close(fd)
except OSError:
pass
deadline = _time.monotonic() + 5.0
while _time.monotonic() < deadline:
try:
wpid, st = os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
return -1, b"".join(out), b"".join(err)
if wpid == pid:
if os.WIFEXITED(st):
return os.WEXITSTATUS(st), b"".join(out), b"".join(err)
if os.WIFSIGNALED(st):
return -os.WTERMSIG(st), b"".join(out), b"".join(err)
return -1, b"".join(out), b"".join(err)
_time.sleep(0.01)
return -1, b"".join(out), b"".join(err)
def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=None):
"""Verify a detached GPG signature over the canonical manifest JSON.
@ -597,7 +708,7 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
repos). When *None* the bundled official key is used instead.
Returns True if valid, False if invalid/error, None if verification
could not be attempted (no signature, no key, gnupg missing, etc.).
could not be attempted (no signature, no key, gpg binary missing, etc.).
"""
if not signature_armored:
return None
@ -614,18 +725,19 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
logger.debug("No GPG public key available; skipping verification")
return None
try:
import gnupg
except ImportError:
logger.debug("python-gnupg not installed; skipping signature verification")
return None
tmp_home = tempfile.mkdtemp(prefix="gpg_verify_")
try:
gpg = gnupg.GPG(gnupghome=tmp_home)
import_result = gpg.import_keys(key_text)
if not import_result.fingerprints:
logger.warning("Failed to import GPG public key")
key_bytes = key_text.encode("utf-8") if isinstance(key_text, str) else key_text
rc, _, import_stderr = _gpg_run(
["gpg", "--batch", "--no-tty", "--status-fd", "2",
"--homedir", tmp_home, "--import"],
input_data=key_bytes,
)
if logger.isEnabledFor(logging.DEBUG):
for line in import_stderr.decode("utf-8", errors="replace").splitlines():
logger.debug("gpg import: %s", line)
if rc != 0:
logger.warning("GPG key import failed (rc=%d)", rc)
return None
# Must match what the signing script produces: jq -c '.manifest'
@ -639,8 +751,18 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
with open(sig_path, "w") as sf:
sf.write(signature_armored)
verified = gpg.verify_data(sig_path, manifest_bytes)
return bool(verified)
rc, _, verify_stderr = _gpg_run(
["gpg", "--batch", "--no-tty", "--status-fd", "2",
"--homedir", tmp_home, "--verify", sig_path, "-"],
input_data=manifest_bytes,
)
if logger.isEnabledFor(logging.DEBUG):
for line in verify_stderr.decode("utf-8", errors="replace").splitlines():
logger.debug("gpg verify: %s", line)
return rc == 0 and b"VALIDSIG" in verify_stderr
except FileNotFoundError:
logger.debug("gpg binary not found; skipping signature verification")
return None
except Exception:
logger.exception("GPG signature verification error")
return False
@ -881,6 +1003,7 @@ class PluginRepoRefreshAPIView(PluginAuthMixin, APIView):
if err:
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)
_unmanage_dropped_slugs(repo, data)
_invalidate_plugin_detail_cache(repo.id, data)
return Response(PluginRepoSerializer(repo).data)
@ -1017,6 +1140,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
_validate_fetch_url(manifest_url)
except ValueError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
cache_key = f"plugin_detail:{repo_id}:{hashlib.md5(manifest_url.encode()).hexdigest()}"
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
try:
resp = http_requests.get(manifest_url, timeout=MANIFEST_FETCH_TIMEOUT)
resp.raise_for_status()
@ -1045,10 +1174,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
if url_val and not url_val.startswith(("http://", "https://")):
manifest_obj["latest"][url_field] = f"{root_url}/{url_val}"
return Response({
result = {
"manifest": manifest_obj,
"signature_verified": verified,
})
}
cache.set(cache_key, result, PLUGIN_DETAIL_CACHE_TTL)
return Response(result)
except Exception as e:
logger.exception("Failed to fetch plugin manifest from %s", manifest_url)
return Response(

View file

@ -169,8 +169,7 @@ class StreamGenerator:
yield create_ts_packet('error', "Error: Channel is stopping")
return False
# Send PAT+PMT+null so clients recognise a valid TS program and keep
# buffering instead of timing out from missing program info.
# Send null packets to keep the connection alive during initialization.
if time.time() - last_keepalive >= keepalive_interval:
logger.debug(f"[{self.client_id}] Sending keepalive during initialization")
keepalive_data = create_ts_packet('null')

View file

@ -1,6 +1,5 @@
import logging
import re
import struct
from urllib.parse import urlparse
import inspect
@ -58,53 +57,6 @@ def get_client_ip(request):
ip = request.META.get('REMOTE_ADDR')
return ip
def _mpeg_crc32(data):
crc = 0xFFFFFFFF
for b in data:
crc ^= b << 24
for _ in range(8):
if crc & 0x80000000:
crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF
else:
crc = (crc << 1) & 0xFFFFFFFF
return crc
def create_ts_pat_pmt_packets():
"""
Return two valid TS packets: PAT (PID 0x0000) and PMT (PID 0x0100).
Declares program 1 with an H.264 video track at PID 0x0101.
TS clients like VLC need PAT/PMT to recognise a stream as valid; without
them they time out waiting for program info even while receiving null packets.
Returns exactly 376 bytes (2 x 188-byte TS packets).
"""
# PAT section: program 1 mapped to PMT at PID 0x0100
pat_body = bytes([
0x00, 0xB0, 0x0D, # table_id=PAT, section_length=13
0x00, 0x01, # transport_stream_id=1
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
0x00, 0x01, # program_number=1
0xE1, 0x00, # PMT PID=0x0100 (reserved 0b111 | PID)
])
pat_body += struct.pack('>I', _mpeg_crc32(pat_body))
pat_packet = bytes([0x47, 0x40, 0x00, 0x10, 0x00]) + pat_body + bytes([0xFF] * (183 - len(pat_body)))
# PMT section: program 1, H.264 video at PID 0x0101
pmt_body = bytes([
0x02, 0xB0, 0x12, # table_id=PMT, section_length=18
0x00, 0x01, # program_number=1
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
0xE1, 0x01, # PCR_PID=0x0101
0xF0, 0x00, # program_info_length=0
0x1B, 0xE1, 0x01, 0xF0, 0x00, # stream_type=H.264, PID=0x0101
])
pmt_body += struct.pack('>I', _mpeg_crc32(pmt_body))
pmt_packet = bytes([0x47, 0x41, 0x00, 0x10, 0x00]) + pmt_body + bytes([0xFF] * (183 - len(pmt_body)))
return pat_packet + pmt_packet
def create_ts_packet(packet_type='null', message=None):
"""
Create a Transport Stream (TS) packet for various purposes.

View file

@ -627,7 +627,12 @@ def stream_xc(request, username, password, channel_id):
else:
channel = get_object_or_404(Channel, id=channel_id)
force_format = 'fmp4' if extension.lower() == '.mp4' else 'mpegts'
if extension.lower() == '.mp4':
force_format = 'fmp4'
elif extension.lower() == '.ts':
force_format = 'mpegts'
else:
force_format = None
return stream_ts(request._request, str(channel.uuid), user, force_output_format=force_format)

View file

@ -0,0 +1,144 @@
import React from 'react';
import {
Box,
Button,
Divider,
Group,
Modal,
SimpleGrid,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { BookOpen, Github, Heart, Users } from 'lucide-react';
import { DiscordIcon } from './icons.jsx';
import logo from '../images/logo.png';
import useSettingsStore from '../store/settings';
const AboutModal = ({ isOpen, onClose }) => {
const appVersion = useSettingsStore((s) => s.version);
const versionString = `v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`;
return (
<Modal
opened={isOpen}
onClose={onClose}
title="About Dispatcharr"
centered
size="md"
>
<Stack gap="lg">
<Group justify="center" gap="md">
<img src={logo} alt="Dispatcharr" width={56} />
<Stack gap={2}>
<Text fw={700} size="xl">
Dispatcharr
</Text>
<Text size="sm" c="dimmed">
{versionString}
</Text>
</Stack>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<Tooltip label="Visit the Dispatcharr documentation" position="top">
<Button
component="a"
href="https://dispatcharr.github.io/Dispatcharr-Docs/"
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<BookOpen size={15} />}
fullWidth
>
Documentation
</Button>
</Tooltip>
<Tooltip label="Join our Discord community" position="top">
<Button
component="a"
href="https://discord.gg/Sp45V5BcxU"
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<DiscordIcon size={15} />}
fullWidth
>
Discord
</Button>
</Tooltip>
<Tooltip label="View source on GitHub" position="top">
<Button
component="a"
href="https://github.com/Dispatcharr/Dispatcharr"
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<Github size={15} />}
fullWidth
>
GitHub
</Button>
</Tooltip>
<Tooltip
label="Support Dispatcharr on Open Collective"
position="top"
>
<Button
component="a"
href="https://opencollective.com/dispatcharr/contribute"
target="_blank"
rel="noopener noreferrer"
variant="default"
color="pink"
leftSection={<Heart size={15} />}
fullWidth
>
Donate
</Button>
</Tooltip>
</SimpleGrid>
<Divider />
<Stack gap="xs">
<Group gap="xs">
<Users size={16} />
<Text size="sm" fw={500}>
Contributors
</Text>
</Group>
<Text size="sm" c="dimmed">
Dispatcharr is built by the community, for the community. Thank you
to every contributor, tester, and supporter who has helped make this
project what it is.
</Text>
</Stack>
<Tooltip label="Remembering Jesse Mann" position="top" withArrow>
<Box
style={{
background: 'var(--mantine-color-dark-6)',
borderRadius: 'var(--mantine-radius-sm)',
borderLeft: '3px solid var(--mantine-color-pink-5)',
padding: '10px 14px',
cursor: 'default',
}}
>
<Text size="sm" c="dimmed">
In memory of{' '}
<Text span fw={600} c="gray.3">
Jesse Mann
</Text>
.
</Text>
</Box>
</Tooltip>
</Stack>
</Modal>
);
};
export default AboutModal;

View file

@ -23,18 +23,7 @@ import {
} from 'lucide-react';
import { compareVersions } from './pluginUtils.js';
import { formatKB } from '../utils/networkUtils.js';
export const GitHubIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
export const DiscordIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
</svg>
);
import { DiscordIcon, GitHubIcon } from './icons.jsx';
/**
* Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals.

View file

@ -1,7 +1,15 @@
import React, { useRef, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import { Copy, LogOut, ChevronDown, ChevronRight, Heart } from 'lucide-react';
import {
Copy,
LogOut,
ChevronDown,
ChevronRight,
Heart,
HelpCircle,
} from 'lucide-react';
import AboutModal from './AboutModal';
import { getOrderedNavItems } from '../config/navigation';
import {
Avatar,
@ -156,6 +164,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const publicIPRef = useRef(null);
const [userFormOpen, setUserFormOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const closeUserForm = () => setUserFormOpen(false);
@ -372,6 +381,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
</Text>
</Tooltip>
<Group gap="xs" wrap="nowrap">
<Tooltip label="About" position="top">
<ActionIcon
variant="transparent"
color="gray"
onClick={() => setAboutOpen(true)}
>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
<DonateButton />
{isAuthenticated && <NotificationCenter />}
</Group>
@ -389,10 +407,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
>
{isAuthenticated && <NotificationCenter />}
<DonateButton tooltipPosition="right" />
<Tooltip label="About" position="right">
<ActionIcon
variant="transparent"
color="gray"
onClick={() => setAboutOpen(true)}
>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
</Box>
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
<AboutModal isOpen={aboutOpen} onClose={() => setAboutOpen(false)} />
</AppShellNavbar>
);
};

View file

@ -22,6 +22,10 @@ vi.mock('../NotificationCenter', () => ({
),
}));
vi.mock('../AboutModal', () => ({
default: () => null,
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => (
@ -59,6 +63,7 @@ vi.mock('lucide-react', () => ({
Heart: () => <div data-testid="heart-icon" />,
Package: () => <div data-testid="package-icon" />,
Download: () => <div data-testid="download-icon" />,
HelpCircle: () => <div data-testid="help-circle-icon" />,
}));
// Mock UserForm component

View file

@ -439,12 +439,12 @@ export default function BackupManager() {
try {
await API.restoreBackup(selectedBackup.name);
notifications.show({
title: 'Success',
title: 'Restore Complete',
message:
'Backup restored successfully. You may need to refresh the page.',
'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.',
color: 'green',
});
setTimeout(() => window.location.reload(), 2000);
setTimeout(() => window.location.reload(), 4000);
} catch (error) {
notifications.show({
title: 'Error',

View file

@ -0,0 +1,13 @@
import React from 'react';
export const GitHubIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
export const DiscordIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
</svg>
);

View file

@ -37,7 +37,6 @@ dependencies = [
"django-celery-beat>=2.9.0",
"lxml==6.1.0",
"packaging",
"python-gnupg",
]
[build-system]

View file

@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
__version__ = '0.24.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__version__ = '0.25.1' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process