diff --git a/CHANGELOG.md b/CHANGELOG.md index 054d160b..3464dd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Plugin_repo.md b/Plugin_repo.md index 6df4dec5..1e02efb7 100644 --- a/Plugin_repo.md +++ b/Plugin_repo.md @@ -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. --- diff --git a/apps/backups/tasks.py b/apps/backups/tasks.py index f531fef8..a169b7db 100644 --- a/apps/backups/tasks.py +++ b/apps/backups/tasks.py @@ -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", diff --git a/apps/channels/migrations/0037_auto_sync_overhaul.py b/apps/channels/migrations/0037_auto_sync_overhaul.py index d93a1789..86f34762 100644 --- a/apps/channels/migrations/0037_auto_sync_overhaul.py +++ b/apps/channels/migrations/0037_auto_sync_overhaul.py @@ -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): diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 91748652..598ca297 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -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 diff --git a/apps/output/views.py b/apps/output/views.py index 089df38a..1a73923d 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -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" diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 8f7a2c71..80ab591a 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -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( diff --git a/apps/proxy/live_proxy/output/ts/generator.py b/apps/proxy/live_proxy/output/ts/generator.py index 68ddacca..250a9b3a 100644 --- a/apps/proxy/live_proxy/output/ts/generator.py +++ b/apps/proxy/live_proxy/output/ts/generator.py @@ -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') diff --git a/apps/proxy/live_proxy/utils.py b/apps/proxy/live_proxy/utils.py index 5177f1ce..c83ce3ba 100644 --- a/apps/proxy/live_proxy/utils.py +++ b/apps/proxy/live_proxy/utils.py @@ -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. diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index bf39f6a9..d882bfca 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -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) diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx new file mode 100644 index 00000000..55342cb3 --- /dev/null +++ b/frontend/src/components/AboutModal.jsx @@ -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 ( + + + + Dispatcharr + + + Dispatcharr + + + {versionString} + + + + + + + + + + + + + + + + + + + + + + + + + + + + Contributors + + + + 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. + + + + + + + In memory of{' '} + + Jesse Mann + + . + + + + + + ); +}; + +export default AboutModal; diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx index 602e0852..27d553a9 100644 --- a/frontend/src/components/PluginDetailPanel.jsx +++ b/frontend/src/components/PluginDetailPanel.jsx @@ -23,18 +23,7 @@ import { } from 'lucide-react'; import { compareVersions } from './pluginUtils.js'; import { formatKB } from '../utils/networkUtils.js'; - -export const GitHubIcon = ({ size = 16 }) => ( - - - -); - -export const DiscordIcon = ({ size = 16 }) => ( - - - -); +import { DiscordIcon, GitHubIcon } from './icons.jsx'; /** * Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals. diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index f5332d3a..c17f02e4 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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 }) => { + + setAboutOpen(true)} + > + + + {isAuthenticated && } @@ -389,10 +407,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { > {isAuthenticated && } + + setAboutOpen(true)} + > + + + )} + setAboutOpen(false)} /> ); }; diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx index 478078c7..d46010a7 100644 --- a/frontend/src/components/__tests__/Sidebar.test.jsx +++ b/frontend/src/components/__tests__/Sidebar.test.jsx @@ -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: () =>
, Package: () =>
, Download: () =>
, + HelpCircle: () =>
, })); // Mock UserForm component diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index 02b7d15d..c58755b2 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -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', diff --git a/frontend/src/components/icons.jsx b/frontend/src/components/icons.jsx new file mode 100644 index 00000000..540f4e68 --- /dev/null +++ b/frontend/src/components/icons.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export const GitHubIcon = ({ size = 16 }) => ( + + + +); + +export const DiscordIcon = ({ size = 16 }) => ( + + + +); diff --git a/pyproject.toml b/pyproject.toml index 8542216c..db7efc78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", - "python-gnupg", ] [build-system] diff --git a/version.py b/version.py index ae571bbd..5f17fe8a 100644 --- a/version.py +++ b/version.py @@ -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