Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/northernpowerhouse/984

This commit is contained in:
SergeantPanda 2026-04-24 11:05:19 -05:00
commit a85ea2cf01
357 changed files with 65281 additions and 12937 deletions

15
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: dispatcharr # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

29
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,29 @@
## Description
<!-- What does this PR do? Be specific. -->
## Related Issue
<!-- Non-trivial changes should have a linked issue. If this is a small/obvious fix, you may leave this blank. -->
Closes #
## How was it tested?
<!-- What did you actually run to verify this works? Be specific — "it works" is not sufficient. -->
## Checklist
Please check every item before marking this PR as ready for review. Unchecked items will be flagged during review.
- [ ] I have read the [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md) in full
- [ ] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)
- [ ] I understand — line by line — every change in this PR and can explain it if asked
- [ ] This PR targets the `dev` branch
- [ ] Backend: migrations are included if any models were changed
- [ ] Backend: new API endpoints appear correctly in the OpenAPI schema
- [ ] Frontend: ESLint and Prettier pass cleanly (`npm run lint`, `npm run format`)
- [ ] Tests are included for new functionality
- [ ] Existing tests still pass
- [ ] No `console.log`, `print()`, debug statements, or commented-out code is left in the diff
- [ ] I have not reformatted or refactored code outside the scope of this change

View file

@ -28,7 +28,7 @@ jobs:
branch_tag: ${{ steps.meta.outputs.branch_tag }}
timestamp: ${{ steps.timestamp.outputs.timestamp }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -74,7 +74,7 @@ jobs:
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -85,17 +85,17 @@ jobs:
git config user.email "actions@github.com"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -103,7 +103,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}
@ -124,7 +124,7 @@ jobs:
build_version=DispatcharrBase version: ${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }}
- name: Build and push Docker base image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
file: ./docker/DispatcharrBase
@ -149,17 +149,17 @@ jobs:
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}

View file

@ -25,7 +25,7 @@ jobs:
timestamp: ${{ steps.timestamp.outputs.timestamp }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -83,7 +83,7 @@ jobs:
runs-on: ${{ matrix.runner }}
# no per-job outputs here; shared metadata comes from the `prepare` job
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -103,17 +103,18 @@ jobs:
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -121,7 +122,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}
@ -142,7 +143,7 @@ jobs:
build_version=Dispatcharr version: ${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@ -155,8 +156,8 @@ jobs:
tags: |
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }}
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }}
${{ github.event_name != 'pull_request' && format('docker.io/{0}/{1}:{2}-{3}', secrets.DOCKERHUB_ORGANIZATION, needs.prepare.outputs.repo_name, needs.prepare.outputs.branch_tag, matrix.platform) || '' }}
${{ github.event_name != 'pull_request' && format('docker.io/{0}/{1}:{2}-{3}-{4}', secrets.DOCKERHUB_ORGANIZATION, needs.prepare.outputs.repo_name, needs.prepare.outputs.version, needs.prepare.outputs.timestamp, matrix.platform) || '' }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
REPO_OWNER=${{ needs.prepare.outputs.repo_owner }}
@ -174,17 +175,17 @@ jobs:
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -201,13 +202,15 @@ jobs:
echo "Creating multi-arch manifest for ${OWNER}/${REPO}"
# branch tag (e.g. latest or dev)
# ghcr.io: both the branch tag (e.g. dev) and the version+timestamp tag
# point to the same manifest by using multiple --tag flags in one call,
# which prevents orphaned untagged images on each new build
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \
--annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
@ -217,9 +220,10 @@ jobs:
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \
--tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \
ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64
# version + timestamp tag
# Docker Hub: same single call with both tags
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
@ -234,40 +238,6 @@ jobs:
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \
ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-arm64
# also create Docker Hub manifests using the same username
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-arm64
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64

View file

@ -15,13 +15,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -42,7 +42,7 @@ jobs:
run: echo "REPO_NAME=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: docker
file: docker/Dockerfile

View file

@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4

View file

@ -27,7 +27,7 @@ jobs:
repo_name: ${{ steps.meta.outputs.repo_name }}
timestamp: ${{ steps.timestamp.outputs.timestamp }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -83,7 +83,7 @@ jobs:
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -95,17 +95,17 @@ jobs:
git config user.email "actions@github.com"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -113,7 +113,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}
@ -134,7 +134,7 @@ jobs:
build_version=Dispatcharr version: ${{ needs.prepare.outputs.new_version }} Build date: ${{ needs.prepare.outputs.timestamp }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
push: true
@ -157,17 +157,17 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -228,7 +228,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.prepare.outputs.new_version }}
name: Release v${{ needs.prepare.outputs.new_version }}

1
.gitignore vendored
View file

@ -20,4 +20,5 @@ uwsgi.sock
package-lock.json
models
.idea
.vite/
uv.lock

View file

@ -7,6 +7,502 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name.
- **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545)
- **EPG channel name truncation**: EPG sources that include long `<display-name>` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134)
- **Channel start/client connect notifications suppressed on first stats poll after page load**: after logging in or navigating to the Stats page, the notification logic was not firing for any connections present in the first stats response, even for streams that had started after the page loaded. Replaced the flag-based approach with a `pageLoadTime` module-level constant compared against each client's `connected_at` Unix timestamp from Redis; connections that pre-date the page load are filtered out, while genuinely new ones fire immediately. `get_basic_channel_info` now also includes `connected_at` in each client entry so this check works for the Stats page's API poll path as well as the WebSocket path.
### Added
- **VOD start/stop notifications**: the frontend now shows a toast notification when a VOD stream starts or stops. `vod_started` and `vod_stopped` WebSocket events are fired from the backend when a new provider connection is opened or the last active stream on a session ends. The 1-second delayed-cleanup window is used as a settle period before firing `vod_stopped`, so seek reconnects that re-establish `active_streams` within that window suppress the notification. A `vod_stats` WebSocket push is sent alongside every event to keep the Stats page connection table in sync in real time. `vod_start` and `vod_stop` system events are also written to the system event log for each transition, and are now selectable as integration trigger events (webhook/script) in the Connect page.
- **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo)
- **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv)
- **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv)
- **Editable default M3U profile patterns**: the default profile in the M3U profiles editor now exposes Search Pattern and Replace Pattern fields, allowing users to apply a URL transformation to every stream in the playlist (useful for replacing a local IP address, for example). A warning alert explains the fallback behaviour. A "Reset to Defaults" button restores the pass-through patterns (`^(.*)$` / `$1`). The live regex demonstration panel is also shown for the default profile. The backend serializer and `transform_url` were updated accordingly: `search_pattern` and `replace_pattern` are now permitted fields when updating a default profile, and `transform_url` uses `regex.subn()` to detect a genuine non-matches, logging a `WARNING` when the pattern does not match any part of the URL.
### Performance
- **Channel table performance**:
- Removed unused Zustand store subscriptions (`channels`, `selectedProfileChannels`, `selectedProfileChannelIds`) and an unused `channelIds` array subscription from `ChannelsTable` to reduce unnecessary re-renders on unrelated store updates. Cleaned up associated dead code and unused imports.
- Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization.
- Fixed `getChannelStreams` store selector to return a stable empty-array reference instead of creating a new `[]` on every call for channels without streams, preventing unnecessary re-renders via the `shallow` comparator.
- Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator.
- Stream reorder in `ChannelTableStreams` now completes in a single PATCH request instead of three. Previously dragging a stream to a new position triggered a PATCH, then `requeryStreams()` (re-fetching all streams), then `requeryChannels()` (re-fetching the entire paginated channel list with all embedded stream objects). The reorder now uses a dedicated `API.reorderChannelStreams()` path that issues only the PATCH, then updates the store in-place by reordering the existing stream objects without any network round-trips. On failure, `requeryChannels()` is called to restore correct state.
- Fixed N+1 `UPDATE` queries in the stream-order write path. `ChannelSerializer.update()` and the bulk-edit view were calling `ChannelStream.save(update_fields=["order"])` once per stream whose position changed. Both now collect all modified `ChannelStream` objects and issue a single `ChannelStream.objects.bulk_update(…, ["order"])` call. Also removed an accidental `print(normalized_ids)` debug statement left in the serializer.
- Applied the same lightweight store-update approach from stream reorder to stream removal. `removeStream` previously called `API.updateChannel` (which internally triggered `requeryStreams`), then also explicitly called `requeryChannels()` and `requeryStreams()`. Removal now calls `API.reorderChannelStreams` with the remaining stream list (optimistic local `setData` first), matching the one-request pattern used by drag reorder.
- `removeStream` in `ChannelTableStreams` was capturing `data` and `channel` in its closure, causing the `columns` `useMemo` to recreate the entire column array (and new TanStack table instance) on every reorder or remove. Both values are now read through refs (`channelRef`, `dataRef`) so `removeStream` has no dependencies and is stable for the lifetime of the component. Removed `removeStream` and `playlists` from the `columns` dep array.
- `DraggableRow` (stream rows inside the expanded channel) is now wrapped in `React.memo` with a comparator on `row.original` identity and `index`, so rows whose stream data and position haven't changed are skipped entirely during re-renders caused by a sibling row moving.
- Removed dead code in the `name` column cell: `playlists[stream.m3u_account]?.name` was indexing an array by an integer ID, which always returns `undefined`, the value was immediately overridden by `m3uAccountsMap`. The dead access and its fallback variable are gone; `m3uAccountsMap` is now the sole lookup.
- Removed spurious `state: { data }` from the `useReactTable` call. `data` is a root-level TanStack Table option, not a controlled-state entry; passing it in `state` was a no-op but misleading.
- Adding streams to a channel (per-row "Add to Channel" button and bulk "Add selected to Channel") now completes in a single PATCH request instead of three. Previously each operation called `API.updateChannel` (which internally triggered `requeryStreams`), then `requeryChannels()`. Both paths now use a dedicated `API.addStreamsToChannel()` method that issues only the PATCH, merges the existing channel streams with the newly added stream objects locally, and updates the store in-place, no `requeryStreams` or `requeryChannels` round-trips.
- Removed an unnecessary `requeryStreams()` call from `API.createChannelFromStream()`. Stream data does not change when a channel is created from it; the caller already calls `requeryChannels()` to display the new channel.
- Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138)
- Eliminated per-tick DB queries from the channel stats system. Previously `get_basic_channel_info()` in `channel_status.py` issued a `Stream.objects.filter()` and an `M3UAccountProfile.objects.filter()` on every stats tick for each active channel to resolve display names. Channel name and stream name are now written into the Redis metadata hash at channel-init time (via `initialize_channel`) and read back directly during stats collection, zero DB queries per tick. `m3u_profile_name` is now resolved on the frontend from the already-loaded playlists store rather than being pushed from the backend.
- Eliminated a redundant `Channel` DB lookup inside `ChannelService.initialize_channel()`. `views.py` already fetches the `Channel` object via `get_stream_object()` before calling `initialize_channel()`; `channel.name` is now passed as an optional `channel_name` parameter, so the service uses the caller-supplied value and only falls back to a DB query when the name is not provided (e.g. stream-preview paths). A matching `stream_name` parameter was added for the same reason; the `stream_name` DB query is skipped entirely on normal channel start since a channel name is always available.
- Eliminated a redundant `Stream` DB lookup on stream switches. `get_stream_info_for_switch()` already fetches the `Stream` object to build the URL; it now includes `stream_name` in its return dict. `change_stream_url()` captures that value and passes it through to `_update_channel_metadata()`, which skips its own `Stream.objects.filter()` when the name is already known.
### Changed
- **Column resizing in `CustomTable`**: column widths are now propagated to body cells via CSS custom properties (`--header-{id}-size`) injected on the table wrapper, rather than reading `column.getSize()` directly in each cell's React style. This decouples body-cell widths from React renders so that memoized rows (which skip re-renders for performance) still reflect resize changes instantly via CSS cascade.
- Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state.
- Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows.
- Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths.
- Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed.
- **Improved channel start/client connect notifications**: when a channel starts streaming, the redundant separate "new channel" and "new client" toasts are now combined into a single **"Channel started streaming"** notification. All connect/disconnect notifications display both the channel name and the user identity (username + IP, or IP alone) on separate lines. A module-level `pageLoadTime` timestamp compared against each client's `connected_at` field from Redis filters out pre-existing connections on page load, while connections that genuinely start after the page loads (even if they arrive in the very first stats poll) correctly fire notifications.
- **Client timing fields consolidated to `connected_at`**: `get_basic_channel_info` was sending only a pre-computed `connected_since` elapsed-seconds value (no raw timestamp), while `get_detailed_channel_info` was sending `connected_at` alongside a redundant `connection_duration`. Both paths now emit only `connected_at` (Unix timestamp). The frontend derives connection display time and duration from that single value at render time, which also fixes the "timestamp jumping" seen on the Stats page, the connected-at wall-clock time is now stable across polls instead of being recomputed each response.
- **Duration tooltip on Stats page connection table**: the hover tooltip on the Duration column now shows a human-readable breakdown instead of a raw seconds count. Seconds only under a minute, minutes and seconds under an hour, hours and minutes under a day, and days and hours beyond that (e.g. `2 hours, 15 minutes`).
## [0.23.0] - 2026-04-17
### Security
- Set `DEFAULT_PERMISSION_CLASSES` to `IsAdmin` in the DRF configuration. All viewsets and function-based views that require non-admin or unauthenticated access were explicitly annotated: proxy streaming endpoints (`stream_ts`, `stream_xc`, `stream_vod`, `head_vod`, `stream_xc_movie`, `stream_xc_episode`) use `@permission_classes([AllowAny])` (access is controlled by the per-stream-type network allow-list inside the view body); the `UserAgentViewSet`, `StreamProfileViewSet`, `CoreSettingsViewSet`, and `ProxySettingsViewSet` gained `get_permissions()` methods mapping read actions to `IsStandardUser` and write actions to `IsAdmin`; and `AuthViewSet.logout` was updated to return `[Authenticated()]`.
- Fixed missing `network_access_allowed` checks in the VOD proxy. `stream_vod`, `head_vod`, `stream_xc_movie`, and `stream_xc_episode` were not checking the `STREAMS` network policy, unlike the equivalent TS proxy endpoints.
- Explicitly marked the HDHomeRun discovery endpoints (`DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, `HDHRDeviceXMLAPIView`) and the version endpoint with `permission_classes = [AllowAny]` to document their intentionally public access now that the global default is `IsAdmin`.
- Fixed path traversal vulnerability in file uploads. The M3U account upload (`apps/m3u/api_views.py`), logo upload (`apps/channels/api_views.py`), and backup upload (`apps/backups/api_views.py`) all used the uploaded filename directly without sanitization. `os.path.join()` discards all preceding components when it encounters an absolute path segment, and `pathlib`'s `/` operator behaves identically; a relative `../` sequence also escapes via OS path resolution at `open()` time. All three upload paths now strip directory components via `Path(name).name` and validate the resolved path remains within the intended upload directory. Exploiting any of these required admin credentials.
- Prevented users from setting `xc_password` (and other admin-managed keys) on their own account via the `PATCH /api/accounts/users/me/` endpoint.
- Hardened the HLS proxy `change_stream` endpoint by converting it from a plain Django view to a DRF `@api_view` with `@permission_classes([IsAdmin])`, ensuring the endpoint actually enforces admin-only access. The previous decorator arrangement (`@csrf_exempt` + `@permission_classes`) had no effect on a plain Django view.
- Added rate limiting to the login endpoint (`POST /api/accounts/token/`) using DRF's built-in throttling. A `LoginRateThrottle` (3 requests/minute per IP, sliding window) is applied to the `TokenObtainPairView`. Repeated failed attempts from the same IP receive `429 Too Many Requests`.
- Extended rate limiting to the session-auth login alias (`POST /api/accounts/auth/login/`). It now delegates entirely to `TokenObtainPairView`, inheriting its throttle, network access check, and audit logging, and returns JWT tokens instead of a session cookie (the session-based response was unusable since `SessionAuthentication` is not in `DEFAULT_AUTHENTICATION_CLASSES`). Both endpoints share the same `"login"` throttle scope, so attempts across either path count against the same per-IP limit.
- Removed `CORS_ALLOW_CREDENTIALS = True` from CORS configuration. Dispatcharr authenticates via JWT `Authorization` headers and API keys — not cookies — so credentials are never sent cross-origin by browsers. The setting was also redundant: browsers reject `Access-Control-Allow-Credentials: true` when `Access-Control-Allow-Origin` is a wildcard (`*`), so it had no effect in practice.
- Updated frontend npm dependencies to resolve 6 audit vulnerabilities (6 high):
- Updated `@xmldom/xmldom` 0.8.11 → 0.8.12, resolving **high** XML injection via unsafe CDATA serialization allowing attacker-controlled markup insertion ([GHSA-wh4c-j3r5-mjhp](https://github.com/advisories/GHSA-wh4c-j3r5-mjhp))
- Updated `lodash` 4.17.23 → 4.18.1, resolving **high** Code Injection via `_.template` imports key names ([GHSA-r5fr-rjxr-66jc](https://github.com/advisories/GHSA-r5fr-rjxr-66jc)) and **high** Prototype Pollution via array path bypass in `_.unset` and `_.omit` ([GHSA-f23m-r3pf-42rh](https://github.com/advisories/GHSA-f23m-r3pf-42rh))
- Updated `vite` 7.3.1 → 7.3.2, resolving **high** Path Traversal in optimized deps `.map` handling ([GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9)), **high** `server.fs.deny` bypass with queries ([GHSA-v2wj-q39q-566r](https://github.com/advisories/GHSA-v2wj-q39q-566r)), and **high** Arbitrary File Read via dev server WebSocket ([GHSA-p9ff-h696-f583](https://github.com/advisories/GHSA-p9ff-h696-f583))
- Updated `Django` 6.0.3 → 6.0.4, resolving the following CVEs:
- **CVE-2026-33033**: Potential DoS via `MultiPartParser` through crafted multipart uploads.
- **CVE-2026-33034**: SGI requests with a missing or understated `Content-Length` header could bypass the `DATA_UPLOAD_MAX_MEMORY_SIZE` limit.
- **CVE-2026-4292**: Privilege abuse in `ModelAdmin.list_editable`.
- **CVE-2026-3902**: ASGI header spoofing via underscore/hyphen conflation.
- **CVE-2026-4277**: Privilege abuse in `GenericInlineModelAdmin`.
### Added
- **EPG historical data window**: the EPG XML output and XC EPG API now support a `prev_days` URL parameter (e.g. `&prev_days=3`) to include past programs in the EPG response. This allows third-party players that request historical program schedules to receive the data they need. The EPG URL builder in the Channels page exposes "Days forward" and "Days back" controls. Per-user defaults for both values (`epg_days` / `epg_prev_days`) can be configured in the User settings modal and are applied automatically when no URL parameter is present. (Closes #1154)
- **Plugin Hub**: administrators can now browse, install, and update plugins directly from remote repositories via a new Plugin Hub page in Settings. (Closes #393) — Thanks [@sethwv](https://github.com/sethwv)
- Install plugins directly from the hub: the release zip is downloaded, SHA256 integrity is verified, and the plugin is installed atomically.
- Update managed plugins when a newer version is available from their source repo. Version compatibility constraints (`min_dispatcharr_version` / `max_dispatcharr_version`) are enforced at install time.
- Browse available plugins from all enabled repos with name, description, version, author, and icon.
- Plugins installed from a repo are tracked as "managed": source repo, slug, installed version, prerelease flag, and deprecated status are all persisted and surfaced in the UI.
- Add plugin repositories by manifest URL. The official Dispatcharr Plugins repository is pre-configured; third-party repos are supported by supplying an optional GPG public key.
- Manifest signatures are verified via GPG; the official repo uses a bundled public key. Signature status is displayed per-repo.
- Preview a repository URL before adding it - validates the manifest and reports plugin count and signature status without saving anything.
- Configurable automatic manifest refresh interval (in hours; 0 to disable) runs as a Celery background task.
### Removed
- Removed dead `VODConnectionManager` class (`apps/proxy/vod_proxy/connection_manager.py`) and its associated helpers, which had been superseded by `MultiWorkerVODConnectionManager`. All active code already used the multi-worker implementation. Removed the unused `VODConnectionManager` import from `vod_proxy/views.py`, the unscheduled `cleanup_vod_connections` task from `apps/proxy/tasks.py`, and the unscheduled `cleanup_vod_persistent_connections` task from `core/tasks.py`.
- Removed dead VOD URL routes: `VODPlaylistView` (playlist generation), `VODPositionView` (position tracking), and the class-based `VODStatsView` (replaced by the existing function-based `vod_stats` view).
- Removed dead `updateVODPosition()` API method from `frontend/src/api.js`, which called the now-removed position tracking endpoint.
### Fixed
- Fixed TV Guide "Record One" always scheduling the recording on the first channel that matched the program's `tvg_id`, rather than the channel the user actually selected. When multiple channels share the same EPG source, the intended channel was silently ignored. The selected channel object is now passed explicitly through the click handler chain to `recordOne`, bypassing the `findChannelByTvgId` fallback lookup entirely. (Fixes #1140) — Thanks [@fezster](https://github.com/fezster)
- Graceful container shutdown: `docker stop` no longer results in exit 137 (SIGKILL). The entrypoint now explicitly stops all child processes — including uWSGI workers, Celery, Daphne, and Redis, which are spawned as uWSGI `attach-daemon` children and were previously invisible to the signal handler. A polling loop replaces the old fixed `sleep`, exiting as soon as all processes have stopped (up to an 8-second ceiling before force-stopping). PostgreSQL is stopped using `pg_ctl stop -m immediate` as a fallback rather than SIGKILL to avoid data corruption. Process names are now recorded at startup and displayed correctly in crash diagnostics. The unexpected-exit diagnostic block is now suppressed on normal `docker stop` shutdowns. — Thanks [@Shokkstokk](https://github.com/Shokkstokk) for the initial fix!
- Fixed two race conditions in the VOD proxy that caused the `profile_connections` counter to go permanently negative, allowing connections beyond the configured profile limit. (1) `_decrement_profile_connections()` used a GET-before-DECR guard: two concurrent decrements could both read the same positive value, both pass the guard, and both fire, driving the counter below zero. Replaced with an unconditional `DECR` followed by a clamp-to-zero if the result is negative. (2) The `stream_generator` decremented `active_streams` and then checked `has_active_streams()` in two separate Redis round-trips without locking. A concurrent generator on another worker could read `active_streams=0` in the window between those two calls and also decrement the profile counter, producing a double-decrement. A new `decrement_active_streams_and_check()` method performs both operations under a single distributed lock, and a `profile_decremented` flag guards all four call sites in the generator so the profile counter is only ever decremented once per stream. (Closes #1125) — Thanks [@firestaerter3](https://github.com/firestaerter3)
- Fixed a provider TCP connection leak in the VOD proxy `stream_generator`. When a stream ended via an unhandled exception path that reached the `finally` block without any of the three exception handlers having run (e.g. an error raised before the first `yield`), the `finally` block decremented counters but never called `redis_connection.cleanup()`. The upstream `requests.Response` and `requests.Session` were left open until garbage collection. The `finally` block now starts a `delayed_cleanup` daemon thread (matching the 1-second delay used by the normal-completion and `GeneratorExit` paths) so that seeking clients have time to reconnect and increment `active_streams` before `cleanup()` checks whether it is safe to close the connection.
- Fixed manual stream selection from the Stats page not enforcing M3U profile connection limits in multi-worker deployments. When a non-owning worker handled the `change_stream` request it correctly packaged `stream_id` and `m3u_profile_id` into the Redis pubsub message, but the owning worker's pubsub handler only consumed `url` and `user_agent` silently dropping both IDs before calling `stream_manager.update_url()`. Because `update_url` only calls `update_stream_profile()` when a `stream_id` is provided, the `profile_connections` counter was never updated after the switch, causing subsequent capacity checks to see incorrect counts and bypass the full-profile guard. The handler now extracts `stream_id` and `m3u_profile_id` from the event and forwards them to `update_url()`. The bug did not affect single-worker / dev-mode deployments because the owning worker handles those requests directly without pubsub.
- Fixed the `next_stream` rotation endpoint applying the same class of bug: `get_stream_info_for_switch()` was called and returned `m3u_profile_id`, but the result was dropped when forwarding to `ChannelService.change_stream_url()`, so `update_stream_profile()` was never called and `profile_connections` counters were not updated after an automatic stream rotation.
- Fixed stream switch metadata (`url`, `user_agent`, `stream_id`, `m3u_profile`) being written to Redis before the switch was confirmed to succeed. If the switch failed, URL unchanged or exception during teardown, Redis described a URL not actually in use. Metadata is now written only after `update_url()` returns `True`; on failure the owner writes `stream_manager.url` back as the ground truth. The non-owner no longer pre-writes metadata at all, all needed info is carried in the pubsub payload and written by the owner after confirmation.
- Fixed the Stats page "Active Stream" dropdown not updating when a stream switch occurs. The card was matching the active stream by comparing the URL stored in Redis against stream URLs from the database, which failed silently when the stored URL was a transformed/rewritten value that didn't substring-match the original. The dropdown now matches by `stream_id` (the authoritative value already present in the stats payload) and re-runs only when `stream_id` changes, so the normal polling interval drives updates with no extra renders.
- Fixed the XC Password field in the User modal being editable by standard users despite the backend (`PATCH /api/accounts/users/me/`) stripping `xc_password` from `custom_properties` for non-admin users, causing the change to silently revert on save. The field and its generate button are now disabled with an explanatory description when the current user is not an administrator.
- Fixed live stream hiccups caused by nginx buffering TS proxy data to disk. The `/proxy/` location block used `proxy_buffering off` and `proxy_read/send_timeout` directives, which are silently ignored when the upstream is `uwsgi_pass` (a different directive family). nginx was therefore defaulting to `uwsgi_buffering on`, spooling stream data through temp files on disk. Replaced with the correct `uwsgi_buffering off`, `uwsgi_read_timeout 300s`, and `uwsgi_send_timeout 300s` directives so stream data flows directly from uWSGI to the client socket without intermediate disk I/O.
- Fixed the logo cache endpoint (`/api/channels/logos/{id}/cache/`) holding a uWSGI greenlet indefinitely when fetching from a slow or dripping remote server. The previous implementation used `StreamingHttpResponse(iter_content())` with only a per-chunk read timeout; a server that drips data just fast enough to reset the per-read timer could hold the greenlet open forever. Replaced with an eager read loop enforcing a hard total-download deadline (10 s) and a size cap (5 MB). Also fixed a race condition in the existing negative-cache logic: the failure entry for a URL was cleared immediately upon receiving HTTP 200, before the body was read. A concurrent greenlet seeing no failure entry during a slow download that ultimately timed out would also attempt the fetch, defeating the cache. The entry is now cleared only after the full body has been successfully received.
- Fixed uploading a local M3U file with no expiration date set sending the string `"null"` as the `exp_date` field in the `FormData` request, causing a 400 validation error from the API. Null/undefined values are now skipped when building the `FormData` body, matching the behaviour already present in the update path.
- Fixed `PATCH /api/channels/channels/edit/bulk/` returning a 500 error when the request body included a `streams` list. The bulk edit handler was iterating `validated_data` directly and calling `setattr(channel, "streams", value)`, which Django prohibits on ManyToMany fields. Also added an `@extend_schema` decorator so the Swagger UI correctly documents the endpoint as accepting a JSON array and shows the `streams` field. (Fixes #883)
- Fixed several incorrect or incomplete OpenAPI (`@extend_schema`) schemas across the API:
- `POST /api/epg/import/` — request body was undocumented; now correctly shows the `id` field. Description updated from "import" to "refresh" to match frontend and backend terminology.
- `DELETE /api/channels/logos/bulk-delete/``delete_files` boolean was missing from the documented request body.
- `POST /api/channels/channels/batch-set-epg/``epg_data_id` inside each association object was not marked `allow_null`/`required=False`, even though passing `null` is the correct way to remove an EPG link.
- `PUT /api/connect/integrations/{id}/subscriptions/set/` — endpoint had no `@extend_schema` at all; now documents that the request body is a JSON array of subscription objects.
### Changed
- **Output bitrate DB persistence**: the `ffmpeg_output_bitrate` stat is no longer written to the database on every FFmpeg stats tick (~2/second). Instead, a local exponential moving average (EMA, α=0.1) accumulates readings continuously. The first 10 samples (~5 seconds) are discarded as warmup to avoid polluting the average with FFmpeg's unstable ramp-up values. After warmup, the smoothed value is flushed to the database at most once every 30 seconds, and a final flush occurs when the stream stops but only if the EMA has been seeded (i.e. the stream ran past warmup). Streams that stop during warmup leave the existing database value untouched, preserving previously accurate measurements when channel-hopping.
- Performance: `generate_m3u`, `generate_epg`, and `xc_get_live_streams` now use `select_related('channel_group', 'logo')` (or `select_related('logo')` for EPG) on every Channel queryset in `apps/output/views.py`. Previously each channel in the loop triggered a separate database query for its `logo` and `channel_group` foreign keys; with the JOIN-based prefetch this is reduced to a single query per request. On deployments with ~2 000 channels, `xc_get_live_streams` response time drops from ~2.54 s to ~250450 ms. (Closes #1127) — Thanks [@xBOBxSAGETx](https://github.com/xBOBxSAGETx)
- Performance: `generate_epg` now uses `select_related('epg_data__epg_source')` on all EPG channel querysets, eliminating N+1 database queries for `EPGSource` traversal per channel (~15 s improvement on ~2000-channel deployments; total EPG generation time dropped from ~87 s to ~72 s in benchmarks).
- Performance: `xc_get_epg` now uses `select_related('epg_data__epg_source')` on all three channel fetch paths. Previously each request triggered 2 additional queries to resolve `channel.epg_data` and `channel.epg_data.epg_source`.
- Performance: `generate_m3u` now uses `prefetch_related` for streams when `?direct=true` is requested, eliminating N+1 stream queries (one per channel) on that code path.
- Performance: `EPGGridAPIView` (`apps/epg/api_views.py`) now uses `select_related('epg_data__epg_source')` on the `channels_with_custom_dummy` queryset, eliminating 2 extra queries per channel (for `epg_data` and `epg_source`) in the dummy EPG generation loop.
- Performance: `generate_epg` now issues a single cross-channel `ProgramData` bulk query. `.values()` returns plain dicts, bypassing per-row Django model instantiation. Results are consumed in independent 5000-row keyset-paginated chunks. Combined with the `select_related` improvements above, EPG generation time on large deployments is significantly reduced.
- Performance: `xc_get_live_streams` no longer calls `ChannelGroup.objects.get_or_create(name="Default Group")` once per null-group channel; replaced with a lazy-initialised closure that executes at most one query regardless of how many ungrouped channels are present.
- AIO containers now connect to the internal PostgreSQL instance via a Unix domain socket instead of TCP loopback. Users who have `POSTGRES_HOST` explicitly set to `localhost` or `127.0.0.1` in their compose file are automatically migrated to the socket path; any other explicit value (external host/IP) is left untouched. — Thanks [@JCBird1012](https://github.com/JCBird1012)
- Improved the EPG response cache key. Previously it was based on the raw query string and username, meaning a user default of `epg_days=7` and an explicit `&days=7` URL parameter produced different cache entries for identical output. The key is now built from all resolved effective parameter values (`days`, `prev_days`, `cachedlogos`, `tvg_id_source`) so semantically equivalent requests always share the same cache entry.
- Improved the HDHR, M3U, and EPG URL builder popovers in the Channels table: each popover now opens with a brief intro sentence describing its purpose. Toggle switches were refactored to use Mantine's native `label` and `description` props (replacing the previous manual `Group`/`Stack`/`Text` layout), giving each switch a properly styled description line beneath its label. Switch alignment was also corrected. Toggles now appear on the left with the label and description stacked to the right, consistent with standard Mantine form layout.
- Redesigned the User settings modal with a tabbed layout: **Account** (username, email, name, password), **Permissions** (user level, stream limit, channel profiles, mature content filter - admin only), **EPG Defaults** (days forward/back), and **API & XC** (XC password, API key management). Fields are now logically grouped rather than split across two ad-hoc columns.
- EPG channel scanning now automatically removes stale `EPGData` entries. tvg-ids that were present in a previous scan but are no longer found in the upstream source, provided they are not mapped to any channel. This prevents unbounded database bloat over time. Entries mapped to at least one channel are always preserved.
- Rewrote the M3U line parser as an `iter_m3u_entries` generator that owns the full per-entry state machine. Intermediate directive lines between `#EXTINF` and the stream URL are now handled correctly rather than corrupting the pending entry or being silently misassigned. A `#EXTINF` with no following URL is discarded with a warning instead of carrying over a `url`-less entry into batch processing. Attribute keys are normalised to lowercase during parsing (provider attribute names remain case-insensitive end-to-end). The `#EXTINF` attribute regex is pre-compiled at module load, and attribute lookups use O(1) `dict.get()` instead of linear scans — approximately 10% faster parsing on large M3U files.
- Added support for the `#EXTGRP` directive in M3U files. When a `group-title` attribute is absent from the `#EXTINF` line, the value from a following `#EXTGRP:` line is used as the group. An explicit `group-title` attribute always takes priority. (Closes #1088)
- Added accumulation of `#EXTVLCOPT` directives per entry. Options are stored as a list under `vlc_opts` inside the stream's `custom_properties`, available for downstream use (e.g. passing VLC-specific options to the player). This is for a planned future enhancement and can also be utlized with the API.
- M3U stream name parsing now uses the comma text (the canonical display title per the base `#EXTINF` spec) as the primary stream name, falling back to `tvc-guide-title`, then `tvg-name`, rather than preferring `tvg-name` first. Providers that use `tvg-name` as an EPG key and put the human-readable title after the comma will now display the correct name. Providers that duplicate the same value in both fields are unaffected. (Fixes #1081)
- FloatingVideo player: the native video controls (timeline, play/pause, volume) are now hidden by default when a live stream starts and only appear when the user hovers over the player.
- Enhanced Swagger UI authorization dialog: registered a custom `OpenApiAuthenticationExtension` for `ApiKeyAuthentication` so drf-spectacular now generates an `ApiKeyAuth (apiKey)` entry alongside `jwtAuth`. Both entries include descriptive text linking to the relevant endpoints (`/api/accounts/token/`, `/api/accounts/api-keys/generate/`, `/api/accounts/api-keys/revoke/`).
- Refactored frontend form components (`AccountInfoModal`, `AssignChannelNumbers`, `Channel`, `ChannelBatch`, `ChannelGroup`, `Connection`, `CronBuilder`, `DummyEPG`, and `EPG`) to extract business logic into dedicated utility modules under `src/utils/forms/`. Each extracted module is covered by unit tests. Mantine compound component references (`Table.Tbody`, `Popover.Target`, `Accordion.Item`, etc.) have been updated to use flat named imports. — Thanks [@nick4810](https://github.com/nick4810)
- Improved the EPG BOM fix from v0.22.1: replaced the `lstrip(b'\xef\xbb\xbf')` / `startswith` approach with `start.find(b'<?xml')`, which locates the XML declaration regardless of any leading bytes BOM, whitespace, or other encoding markers without needing to know what those bytes are.
- Dependency updates:
- `Django` 6.0.3 → 6.0.4 (security patch; see Security section)
- `djangorestframework` 3.16.1 → 3.17.1
- `requests` 2.33.0 → 2.33.1
- `gevent` 25.9.1 → 26.4.0
- `rapidfuzz` 3.14.3 → 3.14.5
- `sentence-transformers` 5.3.0 → 5.4.0
- `lxml` 6.0.2 → 6.0.3
- Added `python-gnupg` for GPG signature verification of official and third-party plugin repository manifests.
## [0.22.1] - 2026-04-05
### Fixed
- Fixed EPG sources that emit a UTF-8 BOM (e.g. ErsatzTV, EPGShare, WebGrab+Plus) parsing 0 channels and 0 programmes after the HTML entity fix introduced in v0.22.0. `bytes.lstrip()` only strips ASCII whitespace, leaving the three BOM bytes (`EF BB BF`) in place, so `stripped.startswith(b'<?xml')` returned `False`. The function fell through to the no-declaration branch and prepended the HTML entity DOCTYPE block _before_ the BOM and XML declaration, producing invalid XML that lxml silently discarded under `recover=True`. Fixed by stripping the BOM explicitly before the whitespace strip: `start.lstrip(b'\xef\xbb\xbf').lstrip()`. BOM-free files are unaffected. (Closes #1173) — Thanks [@dwot](https://github.com/dwot) for the fix!
## [0.22.0] - 2026-04-01
### Security
- Updated `requests` 2.32.5 → 2.33.0, resolving the following CVE:
- **CVE-2026-25645** (moderate): Insecure temp file reuse in `extract_zipped_paths()` utility function.
- Updated frontend npm dependencies to resolve 4 audit vulnerabilities (2 moderate, 2 high):
- Updated `brace-expansion` 5.0.2 → 5.0.5, resolving **moderate** zero-step sequence causing process hang and memory exhaustion ([GHSA-f886-m6hf-6m8v](https://github.com/advisories/GHSA-f886-m6hf-6m8v))
- Updated `flatted` 3.4.1 → 3.4.2, resolving **high** Prototype Pollution via `parse()` in NodeJS flatted ([GHSA-rf6f-7fwh-wjgh](https://github.com/advisories/GHSA-rf6f-7fwh-wjgh))
- Updated `picomatch` 4.0.3 → 4.0.4, resolving **high** method injection in POSIX character classes causing incorrect glob matching ([GHSA-3v7f-55p6-f55p](https://github.com/advisories/GHSA-3v7f-55p6-f55p)) and a ReDoS vulnerability via extglob quantifiers ([GHSA-c2c7-rcm5-vvqj](https://github.com/advisories/GHSA-c2c7-rcm5-vvqj))
- Updated `yaml` 1.10.2 → 1.10.3, resolving **moderate** stack overflow via deeply nested YAML collections ([GHSA-48c2-rrv3-qjmp](https://github.com/advisories/GHSA-48c2-rrv3-qjmp))
### Added
- Connection cards on the Stats page now show the **username** of the connected user. For live channel connections a new User column appears between IP Address and Connected; for VOD connections the username is shown inline next to the IP address in the Client summary row. The username is resolved from the user store using the `user_id` stored in Redis client metadata. (Closes #766, Closes #586)
- `ip_address` and `user_id` were not included in the client info returned by `get_detailed_channel_info()` despite being available in the Redis hash. Both fields are now extracted and returned. `user_id` is now also included in the VOD stats response.
- Web UI stream preview now sends an `Authorization: Bearer` header with each mpegts.js request, identifying the logged-in user. Live channel previews initiated from the web UI now appear on the Stats page with the correct username rather than as unknown user.
- `client_connect` and `client_disconnect` system events now include the **username** of the connected user. The username is stored alongside the client metadata in Redis and included in the event payload for `log_system_event` calls (making it available to webhook and script integrations).
- Donate button added to the sidebar footer. A heart icon links to the project's Open Collective page, visible in both expanded and collapsed states. Hovering shows a "Support Dispatcharr" tooltip. The version string is also now clickable to copy it to the clipboard.
- User stream limits: administrators can now set a maximum number of concurrent streams per user account. When a user reaches their limit, the system can automatically terminate an existing stream to free a slot based on configurable rules. Limit enforcement applies to both live channels and VOD. (Closes #544)
- Each user account has a new **Stream Limit** field (0 = unlimited) configurable from the user edit form in Settings → Users.
- Global enforcement behaviour is configurable in Settings → User Limits:
- **Terminate on Limit Exceeded**: automatically stop an existing stream when the user's limit is reached (vs. rejecting the new connection).
- **Terminate Oldest**: prefer terminating the oldest stream when freeing a slot; disable to prefer the newest.
- **Prioritize Single-Client Channels**: prefer terminating streams on channels that only this user is watching.
- **Ignore Same-Channel Connections**: count multiple connections to the same live channel as one stream toward the limit. Same-channel reconnects are always allowed through. When this is enabled and a channel must be freed, all connections to the chosen channel are terminated together so that the unique-channel count actually decreases. VOD is explicitly excluded from this bypass since VOD connections are not shared upstream.
- TLS and mutual TLS (mTLS) support for Redis and PostgreSQL connections in modular deployments. Supports encrypted connections, server certificate verification (Redis: on/off; PostgreSQL: verify-full, verify-ca, require), CA certificate configuration, and client certificate authentication. Configured via environment variables in the docker compose file. Includes startup validation for certificate paths and TLS/URL scheme conflicts, and a read-only Connection Security panel in System Settings. (Closes #950) — Thanks [@CodeBormen](https://github.com/CodeBormen)
- Status filter for M3U group and VOD category filter modals: A new **All / Enabled / Disabled** segmented control is now shown alongside the text search input in the Live, VOD - Movies, and VOD - Series tabs of the M3U Group Filter modal. The status filter works in combination with the text search and also scopes the "Select Visible" / "Deselect Visible" buttons so they only act on the currently visible subset. (Closes #312)
### Changed
- M3U Profile form (XC accounts): added a **Simple / Advanced** mode toggle for credential-based URL rewriting. In Simple mode users enter just a new username and password; the search and replace patterns are built automatically from the account's current credentials. In Advanced mode the full regex fields are shown as before. The selected mode is saved to `custom_properties.xcMode` and auto-detected on existing profiles (a profile whose search pattern matches the account's current `username/password` is recognised as Simple automatically). The Live Regex Demonstration panel is hidden in Simple mode.
- XtreamCodes VOD endpoints (`/movie/` and `/series/`) no longer redirect clients to a UUID-based proxy URL. Requests are now handled directly in the proxy layer via `stream_xc_movie` and `stream_xc_episode`, which call `stream_vod()` internally. The original XC path is preserved for the client throughout the stream.
- `CustomTable` column layout now supports flexible (`grow`) columns alongside fixed-width ones:
- Column definitions accept a `grow` property (boolean or number) to opt into flex layout. A numeric value sets the flex-grow weight, allowing relative sizing between grow columns (e.g. `grow: 2` gives a column twice the share of spare space as `grow: 1`).
- `maxSize` is now respected on grow columns, capping how wide they expand via `maxWidth`.
- The wrapper's `minWidth` calculation now uses `minSize` (not TanStack's 150px default) for grow columns, preventing the table from overflowing its container when columns would otherwise be sized larger than available space.
- Dependency updates:
- `requests` 2.32.5 → 2.33.0 (security patch; see Security section)
- `celery` 5.6.2 → 5.6.3
- `torch` 2.10.0+cpu → 2.11.0+cpu
- `sentence-transformers` 5.2.3 → 5.3.0
- `yt-dlp` 2026.3.13 → 2026.3.17
- Docker base image cleanup: removed `python-is-python3`, `python3-pip`, and `streamlink` from the apt package list in `DispatcharrBase`. `python3-pip` and `streamlink` were pulling outdated system Python packages (e.g. `requests 2.31.0`, `cryptography 41.0.7`, `lxml 5.2.1`) into the system Python's site-packages despite the app running entirely in the uv-managed venv at `/dispatcharrpy`. `streamlink` is already installed in the venv via `pyproject.toml`. `python-is-python3` is unnecessary as `PATH` resolves bare `python` to the venv binary.
- M3U table **Max Streams** column now reflects the combined limit across all active profiles. When a playlist has multiple active profiles, the column displays their summed total (or ∞ if any profile is unlimited) and a hover tooltip lists each profile's individual limit by name. (Closes #816)
- Toggling an M3U profile's active state now immediately updates the playlist store (including the `playlists` array), so the **Max Streams** total in the M3U table reflects the change without a page reload.
- M3U account form: **Max Streams** field changed from a plain text input to a number input with increment/decrement controls, consistent with other integer fields.
- M3U account form: removed unused `useMantineTheme` import and `theme` variable.
- Moved `guideUtils.js` from `frontend/src/pages/` to `frontend/src/utils/` to be consistent with other utility modules (e.g. `networkUtils.js`). Updated all imports across `GuideRow.jsx`, `HourTimeline.jsx`, `ProgramDetailModal.jsx`, `RecordingCardUtils.js`, `Guide.jsx`, and related test files.
- Frontend cleanup: removed unused imports from `M3UGroupFilter`, `LiveGroupFilter`, and `VODCategoryFilter` (`Yup`, `M3UProfiles`, several unused Mantine components, dead `OptionWithTooltip` component, duplicate lucide-react imports, and `Divider` in `VODCategoryFilter`). No behaviour changes.
- Network Access settings: leaving a field blank no longer shows a validation error. The default CIDR range for that field is saved automatically and a "Defaults Restored" warning is displayed listing which fields were reset. (Closes #726)
### Fixed
- M3U profile URL rewriting now uses the `regex` module instead of `re` across all URL transform code paths (`url_utils.transform_url`, `core/views.py`, `vod_proxy/_transform_url`, `tasks.get_transformed_credentials`, and the WebSocket live-preview handler in `consumers.py`). The `regex` module natively accepts JavaScript/PCRE-style named capture groups (`(?<name>...)`) without any conversion, eliminating the root cause of patterns that matched in the frontend live preview but failed on the backend with a `re.error`. As a further improvement, `regex` also supports variable-length lookbehind assertions (e.g. `(?<=a+)`), which `re` rejects with an error; patterns using these will now work correctly on the backend as well. Replace-pattern JS tokens are still normalised before calling `regex.sub`: `$<name>``\g<name>` and `$1`/`$2`/… → `\1`/`\2`/… (Python replacement syntax). Also fixed a bug in the WebSocket preview handler where a pattern error was incorrectly returning the search pattern string as the preview output instead of the original URL. (Fixes #1005)
- Web UI stream preview (`FloatingVideo`) was calling `mpegts.createPlayer()` with all `Config` options (e.g. `enableWorker`, `liveSync`, `headers`) merged into the first `MediaDataSource` argument. mpegts.js only reads `Config` from the optional second argument; unrecognised fields in the first are silently ignored. As a result all player configuration was effectively the library defaults — worker offloading was disabled, latency management had no effect, and the `Authorization: Bearer` header (required for user identification) was never sent. Fixed by splitting into the correct two-argument call. Both `liveBufferLatencyChasing` and `liveSync` have been disabled, eliminating playback-rate fluctuations that caused audible stuttering on live streams. SourceBuffer cleanup thresholds were also relaxed from 10s/5s to 120s/60s to prevent frequent SourceBuffer pauses.
- HTML named entities in XMLTV EPG files are now correctly preserved during lxml parsing. Some EPG providers (particularly French and other European sources) use HTML named entities like `&eacute;`, `&icirc;`, `&uuml;` in channel names, program titles, and metadata. These are not valid XML entities — lxml 6.0.2 with `recover=True` silently drops them, causing characters to go missing (e.g., "Chaîne Télé" becomes "Chane Tl"). This is now fixed by injecting an XML `<!DOCTYPE tv [...]>` internal subset declaring all 252 HTML 4 named entities directly into the byte stream that lxml reads, using a lightweight in-memory wrapper (`_PrependStream`) with zero disk I/O. libxml2 resolves the entities during its normal C-level parse pass — no Python-level preprocessing or temporary files are involved. The DOCTYPE block (~8 KB) is built once at module load from Python's stdlib `html.entities.name2codepoint` and reused for every parse. Files that already declare their own `<!DOCTYPE>` are passed through unchanged. (Closes #1095) — Thanks [@CodeBormen](https://github.com/CodeBormen) for helping with this!
- Duplicate recordings created when EPG sources refresh and re-evaluate series rules (Fixes #940) — Thanks [@CodeBormen](https://github.com/CodeBormen):
- **Program ID instability**: `parse_programs_for_source()` deletes and recreates all `ProgramData` rows with new auto-increment IDs on every EPG refresh. The dedup set used these IDs, so it never matched after a refresh. Deduplication now uses a stable `(tvg_id, start_time, end_time)` composite key sourced from `Recording.custom_properties.program`.
- **Secondary guard using wrong times**: The DB guard compared unadjusted program times against offset-adjusted `Recording.start_time`/`end_time`, so it never matched when any DVR pre/post offset was configured. It now queries `custom_properties__program__start_time/end_time` (the original, unadjusted program times stored at recording creation).
- **No concurrency guard**: Each EPG source refresh fired `evaluate_series_rules.delay()` independently. Concurrent tasks loaded the dedup set before others committed, allowing races. Evaluation is now serialized with `acquire_task_lock` (reusing the existing EPG task pattern). Gracefully degrades if Redis is unavailable — the primary and secondary dedup guards still protect.
- EPG refresh tasks (`refresh_epg_data`) were being killed mid-transaction on large EPG sources. The `soft_time_limit=1700s` introduced in v0.21.0 raised `SoftTimeLimitExceeded`, a subclass of `Exception`, which was swallowed by the existing `except Exception` handler in `parse_programs_for_source`, leaving the database in a partial state with no logged error. `soft_time_limit` has been removed from `refresh_epg_data` and `time_limit` raised to 14400s (4 hours) as a true last-resort ceiling; the existing `TaskLockRenewer` daemon thread continues to renew the Redis lock every 120s for legitimately long-running tasks.
## [0.21.1] - 2026-03-18
### Fixed
- Docker container initialization fixes for PUID/PGID handling — Thanks [@CodeBormen](https://github.com/CodeBormen):
- Backups failing on previous installations where `/data/backups` already existed: `/data/backups` was missing from the `DATA_DIRS` list in the init script, causing the PUID/PGID ownership migration to skip the directory and leave it with incorrect permissions.
- Container startup failure on upgrade when data directories reside on external mounts (NFS, SMB/CIFS, FUSE): `chown` failures under `set -e` were crashing the container, breaking setups that worked fine on the previous image. Failures are now collected per-directory and reported as a consolidated warning; the container continues to start and Django reports at runtime if it cannot write a specific directory.
- Upgrading users running as UID 102 (the internal PostgreSQL system user) instead of the expected UID 1000: the PUID/PGID auto-detect introduced in v0.21.0 read ownership from `/data/db`, which was UID 102 in pre-PUID images, causing Django, file creation, and comskip to all run as the wrong user. PUID/PGID now default to 1000 (matching the original Django UID) rather than auto-detecting from data directory ownership.
## [0.21.0] - 2026-03-17
### Security
- Updated frontend npm dependencies to resolve 1 high-severity vulnerability:
- Updated `flatted` to 3.4.1, resolving **high** unbounded recursion DoS in the `parse()` revive phase ([GHSA-25h7-pfq9-p65f](https://github.com/advisories/GHSA-25h7-pfq9-p65f))
- Updated `Django` to 6.0.3 and `django-celery-beat` to 2.9.0, resolving new security vulnerabilities:
- [CVE-2026-25673](https://www.cve.org/CVERecord?id=CVE-2026-25673): Potential denial-of-service vulnerability in URLField via Unicode normalization on Windows (March 3, 2026)
- [CVE-2026-25674](https://www.cve.org/CVERecord?id=CVE-2026-25674): Potential incorrect permissions on newly created file system objects (March 3, 2026)
### Added
- Configurable sidebar navigation ordering and visibility — Thanks [@jcasimir](https://github.com/jcasimir)
- Sidebar nav items can be reordered via drag-and-drop in Settings → UI Settings → Navigation.
- Individual nav items can be hidden from the sidebar using the eye toggle. Hiding an item preserves its position in the order.
- A "Reset to Default" button restores the role-appropriate default order and clears all hidden items.
- Order and visibility are saved per-user with optimistic updates and automatic rollback on failure. Changes appear in the sidebar immediately without a page reload.
- Admin users see a grouped navigation: flat items (`Channels`, `VODs`, `M3U & EPG Manager`, `TV Guide`, `DVR`, `Stats`, `Plugins`) plus collapsible `Integrations` (Connections, Logs) and `System` (Users, Logo Manager, Settings) groups. The `System` group cannot be hidden.
- Non-admin users see `Channels`, `TV Guide`, and `Settings`, with the `Settings` item not hideable.
- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components, and for settings form components `DvrSettingsForm`, `NetworkAccessForm`, `ProxySettingsForm`, `StreamSettingsForm`, `SystemSettingsForm`, `UiSettingsForm`. — Thanks [@nick4810](https://github.com/nick4810)
- Unit tests for DVR port resolution (`build_dvr_candidates`) and selective Redis flush behavior in modular mode. — Thanks [@CodeBormen](https://github.com/CodeBormen)
- Floating video player improvements
- **Title display**: The channel, stream, or VOD title is now shown in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.
- **Persistent state**: Size, position, volume level, and mute state are now saved across sessions using a single `dispatcharr-player-prefs` localStorage key. Size and position are restored on next open (clamped to the current viewport); volume and mute are restored when the player initialises.
- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)".
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
- "Next Highest Channel" numbering mode when creating channels from streams: A new `Next Highest` option is available alongside `Provider`, `Auto`, and `Custom` when creating channels from the Streams table. Selecting it assigns channel numbers starting one above the current highest channel number; the next available number is fetched from the backend at selection time. (Closes #1000) — Thanks [@JCBird1012](https://github.com/JCBird1012)
- TV Guide program cards now display richer metadata — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Season/episode badges** (e.g. `S12E06`) extracted from EPG `<episode-num>` elements, `onscreen` episode strings (e.g. `S12 E6`, `S3E21`, `S8 E8 P2/2`), and as a last-resort fallback from description text patterns at parse time (3-tier pipeline). (Closes #1065)
- **Episode subtitle** shown below the program title on guide cards; falls back to the short description when no subtitle is available.
- **Status badges**: `LIVE`, `NEW`, `PREMIERE`, and `FINALE` surfaced from EPG flags on both compact cards and the detail modal.
- **Program detail modal**: Clicking any guide program opens a modal with full program details — poster/icon image, season/episode, subtitle, duration, categories, cast/director/writer credits, content rating, star ratings, production date, original air date, video quality, and external links to IMDb and TMDB where available. Detail data is fetched from a new `GET /api/epg/programs/{id}/` endpoint backed by the new `ProgramDetailSerializer`. Dummy/placeholder programs skip the fetch.
- **Real-time progress bars**: Currently-airing programs show a green progress bar on their guide card that updates every second via direct DOM manipulation (no React re-renders).
- **Channel name tooltip**: Hovering the channel logo column shows the channel name.
- Sort icons added to the Group and EPG column headers in the Channels table, and to the Group column header in the Streams table. Clicking a sort icon cycles through ascending/descending/unsorted states. EPG sorting required a backend change (`epg_data__name` added to `ChannelViewSet.ordering_fields`); Group sorting was already supported by the API in both tables. (Closes #854) — Thanks [@CodeBormen](https://github.com/CodeBormen)
- DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Stop Recording**: A new Stop button (distinct from Cancel) cleanly ends an in-progress recording early and keeps the partial file available for playback. The API returns immediately; stream teardown and task revocation happen in a background thread to prevent 504 timeouts. When multiple recordings run simultaneously, stopping one only terminates that recording's proxy client by ID, leaving all others unaffected. (Closes #454)
- **Extend Recording**: In-progress recordings can be extended by 15, 30, or 60 minutes without interrupting the stream.
- **Inline metadata editing**: Title and description can now be edited directly in the recording details modal.
- **Refresh artwork button**: Manually re-run poster resolution on demand from the recording card.
- **Multi-source poster resolution**: Added pipeline querying EPG, VOD, TMDB, OMDb, TVMaze, and iTunes for richer recording artwork.
- **Series rules for currently-airing episodes**: Series rules now capture currently-airing episodes in addition to future scheduled ones. (Closes #473)
- **Search and filter controls**: Added search and filter controls to the recordings list.
- **Stream generator throttling**: Cached the `ProxyServer` singleton reference per client and throttled Redis resource checks (1 s) and non-owner health checks (2 s), eliminating 3+ Redis round-trips per stream loop iteration.
- **Automatic crash recovery on worker restart**: A `worker_ready` Celery signal now fires `recover_recordings_on_startup` automatically when the worker starts, so recordings stuck in "recording" status are recovered without manual intervention.
- Account expiration tracking and notifications for M3U profiles
- A new `exp_date` field on `M3UAccountProfile` stores the account expiration date as a proper `DateTimeField`. For Xtream Codes accounts the field is auto-synced from `custom_properties.user_info.exp_date` on every save (supports both Unix timestamps and ISO date strings). For non-XC M3U accounts the date can be entered manually via the account or profile form.
- The M3U accounts table now shows an **Expiration** column displaying the earliest expiration date across all profiles for that account (color-coded: red = expired, orange = expiring soon, green = OK). Hovering the cell shows a tooltip with per-profile expiration details including inactive-profile labels.
- A daily Celery Beat task (`check_xc_account_expirations`) checks all active profiles with an expiration date and manages system notifications: a normal-priority warning is raised for profiles expiring within 7 days; a high-priority alert is raised once the profile has already expired. Warning and expired notifications use separate keys so dismissing the 7-day warning does not suppress the expiration alert.
- Notifications are also updated immediately when a profile is saved: if the expiration date is cleared or pushed beyond the 7-day window, any existing warning/expired notifications are deleted; if the date falls within the window or is already past, the matching notification is updated in place.
- Non-XC accounts expose a `DateTimePicker` on both the M3U account form and the profile form.
### Changed
- Dependency updates:
- `Django` 5.2.11 → 6.0.3 (security patch + major version upgrade; see Security section)
- `django-celery-beat` ≥2.8.1 → ≥2.9.0 (adds explicit Django 6.0 support)
- When selecting an EPG source for a channel, the EPG source dropdown now only lists enabled (active) EPGs, sorted alphabetically.
- Channels page default splitter ratio changed from 50/50 to 60/40 (channels/streams) so all channel action buttons are visible without scrolling on 1080p displays.
- Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810)
- `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`).
- `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component.
- `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`).
- `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`).
- `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs.
- `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting.
- Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element.
- EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data.
### Fixed
- Single-stream channel creation modal not opening correctly when clicking the channel-creation button on an individual stream row in the Streams table. — Thanks [@JCBird1012](https://github.com/JCBird1012)
- DVR series rule creation failing with a 500 error when the stored `series_rules` data contained corrupted (non-dict) entries. Added type guards on the getter, setter, and generic settings serializer to filter invalid entries on read and write. Hardened the EPG ignore list getters (`prefixes`, `suffixes`, `custom`) with the same pattern. Frontend settings parse and save now validate `series_rules` with `Array.isArray()`, matching the existing EPG field pattern, preventing corrupted data from being round-tripped back to the database. (Fixes #1059) — Thanks [@CodeBormen](https://github.com/CodeBormen)
- TS proxy clients stuck indefinitely in keepalive mode when a stream fails and never recovers (Fixes #1102, #1103) — Thanks [@cmcpherson274](https://github.com/cmcpherson274) & [@CodeBormen](https://github.com/CodeBormen)
- **Keepalive duration cap**: Non-owner worker clients sending keepalive packets to hold a connection open during failover can now be held at most `MAX_KEEPALIVE_DURATION` seconds (default 300 s). If no real stream data has been received within that window, the client is disconnected with a warning log. The timer resets each time real data resumes, so independent stalls do not accumulate.
- **`last_active` tracking**: `last_active` is now updated on every keepalive packet and on every real data chunk, so clients actively waiting during a failover are not incorrectly evicted as ghost clients by the heartbeat thread. The heartbeat thread now only refreshes the Redis TTL rather than updating `last_active`, ensuring the ghost-detection check reflects true client activity rather than heartbeat activity.
- **Buffer reset on stream transition**: A new `reset_buffer_position()` method on `StreamBuffer` clears the in-memory write buffer and partial-packet accumulator when switching between FFmpeg processes. Without this, a partial 188-byte TS packet from the dying FFmpeg process was being prepended to the first bytes from the new FFmpeg process, producing a corrupted TS packet boundary that broke audio decoder sync on the client side. Redis-stored chunks already consumed by clients are unaffected.
- **`add_chunk()` locking hardened**: The lock scope in `add_chunk()` was expanded to cover the entire partial-packet merge and write-buffer accumulation phase, preventing a race condition between `add_chunk()` and the new `reset_buffer_position()` call.
- uWSGI segfaults caused by mixing threading and gevent concurrency models. The dev and debug uWSGI configs had `threads` and `enable-threads = true` set alongside gevent, which triggers segmentation faults particularly on ARM64/Python 3.13. Removed those options to match the already-correct production config. — Thanks [@jcasimir](https://github.com/jcasimir)
- `Stream.last_seen` and `ChannelGroupM3UAccount.last_seen` model defaults now use `django.utils.timezone.now` instead of `datetime.datetime.now`, eliminating spurious `RuntimeWarning: DateTimeField received a naive datetime` warnings emitted during test database creation and on new record creation when `USE_TZ=True`.
- EPG programme parsing crash when an XMLTV source contains programme titles exceeding 255 characters. Previously, a single oversized title would cause the entire `bulk_create` batch to fail with a database truncation error, silently dropping all programmes in that batch. Titles are now truncated to 255 characters before being saved. (Fixes #1039)
- Container startup failure when `PUID`/`PGID` is set, caused by `/data/db` ownership conflicts between the `postgres` system user (UID 102) and the configured PUID/PGID. PostgreSQL now runs as the PUID/PGID user in AIO mode, eliminating all `chown`-to-UID-102 operations and unifying `/data` ownership. (Fixes #1078) — Thanks [@CodeBormen](https://github.com/CodeBormen)
- Existing installations where PUID/PGID differs from the current `/data/db` owner are migrated automatically on first startup; a sentinel file prevents redundant recursive `chown` on subsequent boots.
- PUID/PGID auto-detected from existing data ownership when not explicitly set, avoiding cross-UID `chown` failures on restricted filesystems (NFS `root_squash`, CIFS).
- PUID/PGID validated as positive non-zero integers before any user/group operations.
- UID collisions with the `postgres` system user (e.g. PUID=102) are now handled gracefully.
- Ensured proper variable quoting in the /docker/ directory to guard from inappropriate input
- Floating video player bug fixes
- **Resize stuck after releasing mouse outside window**: The `mouseup` event is not delivered when the pointer leaves the viewport, leaving the `mousemove` listener active indefinitely. Fixed by checking `event.buttons === 0` at the top of `handleResizeMove`; when no button is held the resize session is torn down immediately.
- **Drag stuck after releasing mouse outside window**: Same root cause as the resize bug. Fixed by detecting `event.buttons === 0` in the `onDrag` handler and dispatching a synthetic `mouseup` event so react-draggable cleanly ends the drag session.
- **Player draggable off screen**: The player could be dragged off any edge, making the header (and drag handle) unreachable. The player is now fully bounded: left and top edges are clamped to `x ≥ 0` / `y ≥ 0` so the header is always reachable, and right/bottom edges are clamped to the viewport. Size and position are also re-clamped automatically when the browser window is resized, with proportional scale-down if the saved size exceeds the new viewport.
- Double error notification when saving user preferences: `API.updateMe` was catching errors internally and displaying a notification before re-throwing, causing callers to display a second notification for the same failure.
- Navigation preference saves from concurrent sessions could overwrite each other due to a double-merge race: the frontend was pre-merging `custom_properties` before sending, then the backend merged again against the DB value, causing the second session's write to silently drop keys set by the first. The frontend now sends only the delta; the backend merges authoritatively against the stored value.
- Stale nav item IDs (e.g. from a previous nav structure) are now scrubbed from `navOrder` and `hiddenNav` on the next preference save, preventing unbounded growth of the `custom_properties` JSON field.
- Version update notification persisting after upgrading to the notified version (e.g. "v0.20.2 available" shown while already running v0.20.2). Root cause: `check_for_version_update.delay()` was called from `AppConfig.ready()`, which fires inside Celery prefork pool subprocesses before the broker connection is established, causing the dispatch to fail silently with no log output. Fixed by moving the startup dispatch to the `worker_ready` signal in `celery.py` (consistent with the existing `recover_recordings_on_startup` pattern), and deleting the stale `version-{current_version}` notification at the top of the production check path so it is cleared even when GitHub is unreachable. A WebSocket update is sent immediately on deletion so the frontend badge clears without waiting for the API response.
- VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run.
- XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format `<account name> - <stream_id>` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream.
- 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) — Thanks [@nickgerrer](https://github.com/nickgerrer)
- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]``{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek)
- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek)
- Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding.
- PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`.
- `PATH` in the Celery worker, Celery Beat, and Daphne systemd service files extended to include `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`, fixing failures where background tasks could not locate `ffmpeg` or `ffprobe`.
- `is_adult` field parsing now guards against invalid values (e.g. the string `"None"`) that providers may send instead of a valid integer, preventing a `ValueError` crash during M3U/XC stream refresh. A new `parse_is_adult()` helper wraps the cast in a `try/except`, returning `False` for anything that cannot be interpreted as `1`. (Fixes #1061) — Thanks [@JCBird1012](https://github.com/JCBird1012)
- M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012)
- Celery worker memory leak during M3U/XC refresh causing 2080 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return)
- Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out)
- Added `CELERY_WORKER_MAX_MEMORY_PER_CHILD = 512 MB` as a safety net against pymalloc arena fragmentation
- EPG output was filtering programs using `start_time__gte=now` when the `days` parameter was specified, which caused currently-airing programs (started before the request time but not yet ended) to be omitted from the XML output. This produced a gap in clients' guides immediately after an EPG refresh, lasting until the next program started. Fixed by changing the filter to `end_time__gte=now` so any program that has not yet finished is included.
- TS proxy connection slot leaks and TOCTOU races in stream initialization (Fixes #947) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`→check→`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity. — Thanks [@patchy8736](https://github.com/patchy8736)
- **Leak on URL generation failure**: `generate_stream_url()` called `get_stream()` (which `INCR`s the counter) but had no cleanup path if subsequent DB lookups or URL construction failed. The post-`get_stream()` block is now wrapped in a `try/except` that calls `release_stream()` on any error.
- **Leak on retry-loop timeout**: the retry loop in `stream_ts()` called a bare `get_stream()` on the first failure to classify the error reason. If a slot was available, this `INCR`'d the counter and set Redis keys that were never released when the loop timed out. A `release_stream()` call is now issued before returning 503.
- **Leak on `initialize_channel()` failure**: when `initialize_channel()` returned `False`, the connection slot allocated by the preceding `get_stream()` was never released. A `connection_allocated` flag now tracks whether this request performed the `INCR` (fresh initialization vs. joining an existing channel), and `release_stream()` is called guarded by that flag to prevent incorrect decrements when attaching to an already-running channel.
- **Safety net for unexpected exceptions**: the outer `except` in `stream_ts()` now checks `connection_allocated` and calls `release_stream()` as a last-resort cleanup for any unhandled exception that escapes before the channel is handed off to the stream lifecycle.
- **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls. — Thanks [@patchy8736](https://github.com/patchy8736)
- **`update_stream_profile()` uses a Redis pipeline** for the old-profile DECR + key update + new-profile INCR sequence, preventing counter drift if the process crashes between operations.
- **`stream_generator._cleanup()`** now falls back to `Stream.objects.get()` when the channel UUID resolves to a preview flow rather than a normal channel, rather than silently skipping the slot release.
- **VOD `cleanup_persistent_connection()`** fallback DECR is now conditional: it only decrements the profile counter when the connection tracking key had already expired by TTL (i.e., `remove_connection()` would have skipped the DECR), preventing double-decrements when the key is still present.
- Ghost clients and channels stuck in `INITIALIZING` state in the TS proxy (Fixes #695, #669) — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **`INITIALIZING` added to cleanup grace period monitoring**: channels stuck in `INITIALIZING` are now surfaced by the cleanup task and torn down, preventing indefinite hangs when stream startup fails.
- **Orphaned channel cleanup validates client SET entries**: the cleanup task now cross-checks client SET members against actual metadata hashes; ghost SET entries are removed and the channel is torn down cleanly when no real clients remain.
- **Stats page self-heals**: ghost client SET entries are detected and removed when reading channel stats, preventing stale entries from inflating the active-client count.
- **`remove_ghost_clients()` extracted to `ClientManager`**: ghost-detection logic is now a single authoritative helper, callable with an optional pre-fetched `client_ids` set to eliminate a redundant Redis `SMEMBERS` round-trip when the caller already holds the set.
- **Ownership TTL fallback**: the error-state writer now triggers via ownership check _or_ state guard fallback, so a channel stuck in a pre-active state is correctly marked `ERROR` even when the ownership TTL expired during retries.
- **Missing `SOURCE_BITRATE` / `FFMPEG_BITRATE` metadata constants** added to `ChannelMetadataField`, preventing `AttributeError` on detailed channel stats reads.
- TS proxy client stream lag recovery now only bumps clients forward when their next required chunk has genuinely expired from Redis (TTL), rather than unconditionally jumping if they fell more than 50 chunks behind. Clients are repositioned to the oldest available chunk (minimum data loss) using an atomic server-side Lua binary search, falling back to near the buffer head if nothing is available.
- TS proxy streams dying after 30200 seconds in multi-worker uWSGI/Celery deployments, caused by three interrelated bugs. (Fixes #992, #980) - Thanks [@PFalko](https://github.com/PFalko)
- **Double ProxyServer instantiation**: `ProxyConfig.ready()` called `TSProxyServer()` directly while `TSProxyConfig.ready()` also called `TSProxyServer.get_instance()`, creating two instances per worker — each with its own cleanup thread. The orphaned thread could not extend ownership because it had no entries in `stream_managers`. Fixed by using `TSProxyServer.get_instance()` in `ProxyConfig.ready()`.
- **`flushdb()` on every Redis client init**: `RedisClient.get_client()` called `client.flushdb()` whenever `_client` was `None`. Celery autoscale (`--autoscale=6,1`) spawning new workers mid-stream triggered this path, nuking all Redis keys including active ownership keys, client records, and channel metadata. Removed the `flushdb()` call entirely.
- **No recovery from expired ownership**: `get_channel_owner()` called `redis.get()` twice inside a lambda (TOCTOU race — key could expire between calls); `extend_ownership()` silently returned `False` on expiry with no re-acquisition; and the non-owner cleanup path unconditionally killed streams even when the worker held the `stream_manager`. Fixed with a single `GET` in `get_channel_owner()`, re-acquisition via atomic `SET NX EX` in `extend_ownership()`, and a re-acquisition attempt with client-aware cleanup deferral in the cleanup thread.
- `get_instance()` deadlock: if `ProxyServer()` raised an exception during singleton construction, `_instance` was left permanently as the `_INITIALIZING` sentinel, causing all subsequent `get_instance()` callers to spin in an infinite `gevent.sleep()` loop. Construction is now wrapped in `try/except`; on failure `_instance` resets to `None` so the next call can retry.
- Non-atomic ownership acquisition in `try_acquire_ownership()`: replaced the separate `setnx()` + `expire()` calls with a single atomic `SET NX EX`, eliminating the race window where a process crash between the two calls could leave an ownership key with no TTL (permanent ownership lock).
- DVR bug fixes — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Duplicate recording execution**: `run_recording.apply_async(countdown=...)` exceeded Redis' default `visibility_timeout` (3600 s) for recordings scheduled more than one hour out, causing Redis to redeliver the task to multiple workers simultaneously and producing corrupted output files. Replaced `apply_async` with `ClockedSchedule` + `PeriodicTask` for database-backed one-shot scheduling that survives restarts and upgrades without the redelivery race. `run_recording` also now exits immediately if the recording is already in progress, completed, or stopped. `revoke_task()` cleans up both the `PeriodicTask` and its orphaned `ClockedSchedule` on execution. (Fixes #940, #641)
- **Stream reconnection resilience**: Recordings now survive transient network drops with automatic reconnection retrying up to 5 times and appending to the existing file. DB operations use exponential-backoff retry for transient database errors throughout the recording lifecycle.
- **Crash recovery pipeline**: On worker restart, recordings stuck in "recording" status have their segments concatenated and remuxed. Remux sanity checks reject MKV output that is less than 50% the size of a previous MKV (duplicate-task overwrite) or less than 10% of the source TS (corrupt first attempt); the source `.ts` is preserved for manual recovery on all failure paths. (Fixes #619, #624)
- **Output file collision**: Fixed collision when multiple tasks targeted the same filename.
- **WebSocket deadlock**: `send_websocket_update()` was deadlocking the gevent event loop, causing one recording's WebSocket events to block all other simultaneous recordings.
- **DVR client isolation**: Stop and Cancel operations now identify the target client by recording ID (via `User-Agent: Dispatcharr-DVR/recording-{id}`), ensuring only the correct proxy client is torn down and never affecting other recordings on the same channel.
- **Accidental stream termination on delete**: `destroy()` now only calls `_stop_dvr_clients()` for in-progress recordings, preventing stream termination when deleting a completed recording.
- **Recording card logos**: Logos were not displaying due to a channel summary API shape mismatch.
- **Logo fetch negative cache**: Added negative cache for failed remote logo fetches so dead CDNs no longer block Daphne workers on repeated requests.
- **Artwork fuzzy-match sanitisation**: Poster artwork fuzzy-matching against external APIs (TMDB, OMDb, etc.) was producing incorrect results for channels with names like "USA A&E SD\*"; channel-name strings are now sanitised before querying external sources.
- **Series modal "No upcoming episodes"**: Fixed due to a missing `_group_count` merge and an incorrect time filter.
- **Series rule cleanup**: Deleting a series rule left orphaned recordings and stale Guide indicators; rule deletion now cleans up all associated recordings. Orphaned recordings with no parent rule are also cleaned up automatically. (Fixes #1041)
- **Series rule timezone calculation**: Recurring rules silently dropped scheduled recordings for users in UTC-negative timezones after 4 pm local time. (Fixes #1042)
- **Recording modal TDZ crash**: Modal crashed on load in production bundles due to a Temporal Dead Zone error — editing state was referenced before its declaration in the minified bundle.
- **Description textarea focus loss**: The description textarea lost focus immediately when opened because the inline editing component was remounting on every render.
- **WebSocket-driven refresh**: Replaced all manual `fetchRecordings()` polling calls with debounced WebSocket-driven refresh so the recordings list stays up to date without redundant API requests.
- **comskip exit code handling**: comskip treated exit code 1 ("no commercials found") as a fatal error, causing post-processing to fail on clean recordings. Exit code 1 is now recognised as a successful no-op.
- **Differentiated WebSocket notification events**: `recording_stopped`, `recording_cancelled` (in-progress cancel), and `recording_deleted` with a `was_in_progress` flag now allow the frontend to display distinct "Recording stopped", "Recording cancelled", and "Recording deleted" toasts.
- **Duplicate series rule evaluation race**: Creating a series rule fired `evaluate_series_rules.delay()` in the API view while the frontend immediately called the synchronous evaluate endpoint, racing to create duplicate recordings for the same program. Removed the redundant async call from the API; the frontend's explicit evaluate call is now the sole evaluation path.
- **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card.
- **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently.
- Modular mode deployment hardening — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. (Fixes #1045)
- **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`.
- **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode — clearing stale stream locks, proxy metadata, and server-state keys — while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed.
- **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present.
- **Stale environment variables after container restart**: `/etc/profile.d/dispatcharr.sh` was only written on the first container run; restarts with changed env vars (e.g. a rotated `POSTGRES_PASSWORD`) retained stale values. The file is now truncated and rewritten on every startup. `/etc/environment` entries are likewise updated rather than skipped when a key already exists. All exported values are now quoted to prevent breakage from special characters.
- **Celery entrypoint startup timeouts**: The JWT key wait and migration wait loops had no timeout, leaving the Celery worker hanging indefinitely if the web container was stuck. Each loop now times out (120 s for JWT, 300 s for migrations) and exits with a clear diagnostic message. The migration readiness check is also replaced from a fragile `showmigrations | grep` pattern to `migrate --check`, which exits cleanly on both unapplied migrations and connection errors.
- **Service startup ordering**: `depends_on` entries for `db` and `redis` in `docker-compose.yml` upgraded from plain name-link ordering to `condition: service_healthy`, ensuring containers wait for actual readiness signals before starting.
- **`host.docker.internal` resolution on Linux**: Added `extra_hosts: host.docker.internal:host-gateway` to the web service in `docker-compose.yml` so Linux hosts resolve `host.docker.internal` the same way Docker Desktop does on macOS/Windows.
## [0.20.2] - 2026-03-03
### Security
- Updated frontend npm dependencies to resolve 2 high-severity vulnerabilities:
- Updated `minimatch` to ≥10.2.3, resolving **high** ReDoS via matchOne() combinatorial backtracking with multiple non-adjacent GLOBSTAR segments ([GHSA-7r86-cg39-jmmj](https://github.com/advisories/GHSA-7r86-cg39-jmmj))
- Updated `rollup` to ≥4.58.1, resolving **high** Arbitrary File Write via Path Traversal ([GHSA-mw96-cpmx-2vgc](https://github.com/advisories/GHSA-mw96-cpmx-2vgc))
### Fixed
- EPG filter regression in channel table (introduced in 0.20.0 channel store refactor): The EPG filter dropdown was showing all EPG sources regardless of whether they had any channels assigned, and the "No EPG" option was never displayed. Fixed by annotating EPGSource records with a `has_channels` flag (via a lightweight `EXISTS` subquery) so only active EPG sources with at least one channel assigned appear as filter options. "No EPG" now appears only when at least one channel globally has no EPG assigned; this is determined by a second `EXISTS` query embedded directly in the paginated channel response (`has_unassigned_epg_channels`), avoiding any additional network requests.
- Stale stream rows missing hover effect: Stale streams in the streams table had no hover color change, unlike channels with no streams assigned. Converted the inline `backgroundColor` style to a CSS class (`stale-stream-row`) so the `:hover` rule can apply correctly. Applied the same fix to the channel-streams sub-table, where the teal expanded-row background caused the semi-transparent red tint to visually mismatch; the sub-table now uses a pre-blended solid color via `color-mix()` to match the appearance of stale rows in the main streams table.
- Channel table onboarding shown when filter returns zero results: The channel store refactor changed to loading only channel IDs instead of full channel objects, leaving `Object.keys(channels).length` always `0` and incorrectly triggering the onboarding state on any empty filter. Fixed by checking `channelIds.length` instead.
- TV Guide scrolls to position 0 when a filter yields no results: Applying any filter that temporarily empties the channel list (e.g. switching directly between two channel groups, or typing a search query that matches nothing) caused the guide to show a blank/empty view with no programs visible. The `VariableSizeList` unmounts when `filteredChannels` becomes empty, destroying its DOM node and resetting `scrollLeft` to 0. On remount the scroll position was never restored because `initialScrollComplete` was still `true`. Fixed by saving the user's current scroll position when the channel list empties mid-transition, then restoring it once new channels have loaded. On first load the guide still scrolls to the current time as before.
- `debian_install.sh` regressions after `uv` migration on clean/minimal Debian installs: fixed pip-less venv (`ensurepip`), missing `gunicorn` for the systemd unit, and inconsistent `DJANGO_SECRET_KEY` availability (now persisted to `.env` via `EnvironmentFile`). Docker unaffected. - Thanks [@marcinolek](https://github.com/marcinolek)
## [0.20.1] - 2026-02-26
### Fixed
- Login form disabled after token expiry: The login button was permanently rendered as disabled ("Logging you in...") on page load after a session expired, preventing users from logging back in. A regression in v0.20.0 caused `LoginForm` to check `if (user)` to detect an already-authenticated reload, but the Zustand auth store initializes `user` as a truthy empty object `{ username: '', email: '', user_level: '' }`, so the loading state was set immediately on every mount. Reverted to pre-regression behavior. (Fixes #1029)
## [0.20.0] - 2026-02-26
### Security
- Updated Django 5.2.9 → 5.2.11, resolving the following CVEs:
- **CVE-2025-13473** (low): Username enumeration via timing difference in mod_wsgi authentication handler.
- **CVE-2025-14550** (moderate): Potential denial-of-service via repeated headers on ASGI requests.
- **CVE-2026-1207** (high): Potential SQL injection via raster lookups on PostGIS.
- **CVE-2026-1285** (moderate): Potential denial-of-service in `django.utils.text.Truncator` HTML methods via inputs with large numbers of unmatched HTML end tags.
- **CVE-2026-1287** (high): Potential SQL injection in column aliases via control characters in `FilteredRelation`.
- **CVE-2026-1312** (high): Potential SQL injection via `QuerySet.order_by()` and `FilteredRelation` when using column aliases containing periods.
- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high):
- Updated `ajv` 6.12.6 → 6.14.0, resolving a **moderate** ReDoS vulnerability when using the `$data` option ([GHSA-2g4f-4pwh-qvx6](https://github.com/advisories/GHSA-2g4f-4pwh-qvx6))
- Enforced `minimatch` ≥10.2.2 via npm overrides, resolving **high** ReDoS via repeated wildcards with non-matching literal patterns ([GHSA-3ppc-4f35-3m26](https://github.com/advisories/GHSA-3ppc-4f35-3m26)) affecting `minimatch`, `@eslint/config-array`, `@eslint/eslintrc`, and `eslint`
### Added
- API key authentication: Added support for API key-based authentication as an alternative to JWT tokens. Users can generate and revoke their own personal API key from their profile page, enabling programmatic access for scripts, automations, and third-party integrations without exposing account credentials. Keys authenticate via the `Authorization: ApiKey <key>` header or the `X-API-Key: <key>` header. Admin users can additionally generate and revoke keys on behalf of any user.
- Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations.
- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942)
- Event-driven webhooks and script execution (Integrations): Added new Integrations feature that enables event-driven execution of custom scripts and webhooks in response to system events. (Closes #203)
- **Supported event types**: channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect)
- **Event data delivery**: available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads
- **Plugin support**: plugins can subscribe to events by specifying an `events` array in their action definitions
- **Connection testing**: test endpoint with dummy payloads for validation before going live
- **Custom HTTP headers**: webhook connections support configurable key/value header pairs
- **Per-event Jinja2 payload templates**: each enabled event can have its own template rendered with the full event payload as context; rendered output is sent as JSON (with `Content-Type: application/json` set automatically) if valid, or as a raw string body otherwise
- **Tabbed connection form**: Settings, Event Triggers, and Payload Templates organized into separate tabs for clarity
- Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165)
- Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups:
- **Fixed Start Number** (default): Start at a specified number and increment sequentially
- **Use Provider Number**: Use channel numbers from the M3U source (tvg-chno), with configurable fallback if provider number is missing
- **Next Available**: Auto-assign starting from 1, skipping all used channel numbers
Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433)
- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd)
- `series_relation` foreign key on `M3UEpisodeRelation`: episode relations now carry a direct FK to their parent `M3USeriesRelation`. This enables correct CASCADE deletion (removing a series relation automatically removes its episode relations), precise per-provider scoping during stale-stream cleanup.
- Streamer accounts attempting to log into the web UI now receive a clear notification explaining they cannot access the UI but their stream URLs still work. Previously the login button would silently stop with no feedback.
### Changed
- Dependency updates:
- `Django` 5.2.9 → 5.2.11 (security patch; see Security section)
- `celery` 5.6.0 → 5.6.2
- `psutil` 7.1.3 → 7.2.2
- `torch` 2.9.1+cpu → 2.10.0+cpu
- `sentence-transformers` 5.2.0 → 5.2.3
- `ajv` 6.12.6 → 6.14.0 (security patch; see Security section)
- `minimatch` enforced ≥10.2.2 via npm overrides (security patch; see Security section)
- `react` / `react-dom` 19.2.3 → 19.2.4
- `react-router-dom` / `react-router` 7.12.0 → 7.13.0
- `react-hook-form` 7.70.0 → 7.71.2
- `react-draggable` 4.4.6 → 4.5.0
- `@tanstack/react-table` 8.21.2 → 8.21.3
- `video.js` 8.23.4 → 8.23.7
- `vite` 7.3.0 → 7.3.1
- `zustand` 5.0.9 → 5.0.11
- `allotment` 1.20.4 → 1.20.5
- `prettier` 3.7.4 → 3.8.1
- `@swc/wasm` 1.15.7 → 1.15.11
- `@testing-library/react` 16.3.1 → 16.3.2
- `@types/react` 19.2.7 → 19.2.14
- `@vitejs/plugin-react-swc` 4.2.2 → 4.2.3
- Channel store optimization: Refactored frontend channel loading to only fetch channel IDs on initial login (matching the streams store pattern), instead of loading full channel objects upfront. Full channel data is fetched lazily as needed. This dramatically reduces login time and initial page load when large channel libraries are present.
- DVR scheduling: Channel selector now displays the channel number alongside the channel name when scheduling a recording.
- TV Guide performance improvements: Optimized the TV Guide with horizontal culling for off-screen program rows (only rendering visible programs), throttled now-line position updates, and improved scroll performance. Reduces unnecessary DOM work and improves responsiveness with large EPG datasets.
- Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity.
- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible.
- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839)
- XC API `get_series` now includes `tmdb_id` and `imdb_id` fields, matching `get_vod_streams`. Clients that use TMDB enrichment (e.g. Chillio) can now resolve clean series titles and poster images. - Thanks [@firestaerter3](https://github.com/firestaerter3)
- Stats page "Now Playing" EPG lookup updated to use `channel_uuids` directly (the proxy stats already key active channels by UUID), removing the need for a UUID→integer ID conversion step introduced alongside the lazy channel-fetch refactor. Stream preview sessions (which use a content hash rather than a UUID as their channel ID) are now filtered out before any API call is made, preventing a backend `ValidationError` on both the `current-programs` and `by-uuids` endpoints when a stream preview is active on the Stats page.
### Fixed
- Fixed admin permission checks inconsistently using `is_superuser`/`is_staff` instead of `user_level>=10`, causing API-created admin accounts to intermittently see the setup page, lose access to backup endpoints, and miss admin-only notifications. `manage.py createsuperuser` now also correctly sets `user_level=10`. (Fixes #954) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Channel table group filter sort order: The group dropdown in the channel table is now sorted alphabetically.
- DVR one-time recording scheduling: Fixed a bug where scheduling a one-time recording for a future program caused the recording to start immediately instead of at the scheduled time.
- XC API `added` field type inconsistencies: `get_live_streams` and `get_vod_info` now return the `added` field as a string (e.g., `"1708300800"`) instead of an integer, fixing compatibility with XC clients that have strict JSON serializers (such as Jellyfin's Xtream Library plugin). (Closes #978)
- Stream Profile form User-Agent not populating when editing: The User-Agent field was not correctly loaded from the existing profile when opening the edit modal. (Fixes #650)
- VOD proxy connection counter leak on client disconnect: Fixed a connection leak in the VOD proxy where connection counters were not properly decremented when clients disconnected, causing the connection pool to lose track of available connections. The multi-worker connection manager now correctly handles client disconnection events across all proxy configurations. Includes three key fixes: (1) Replaced GET-check-INCR race condition with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams; (2) Decrement profile counter directly in stream generator exit paths instead of deferring to daemon thread cleanup; (3) Decrement profile counter on create_connection() failure to release reserved slots. (Fixes #962, #971, #451, #533) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- XC profile refresh credential extraction with sub-paths: Fixed credential extraction in `get_transformed_credentials()` to use negative indices anchored to the known tail structure instead of hardcoded indices that broke when server URLs contained sub-paths (e.g., `http://server.com/portal/a/`). This ensures XC accounts with sub-paths in their server URLs work correctly for profile refreshes. (Fixes #945) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique.
- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- VOD episode UUID regeneration on every refresh: a pre-emptive `Episode.objects.delete()` in `refresh_series_episodes` ran before `batch_process_episodes`, defeating its update-in-place logic and forcing all episodes to be recreated with new UUIDs on every refresh. Clients (Jellyfin, Emby, Plex, etc.) with cached episode paths received 500 errors until a full library rescan. Removing the delete allows episodes to be updated in place with stable UUIDs. (Fixes #785, #985, #820) - Thanks [@znake-oil](https://github.com/znake-oil)
- VOD stale episode stream cleanup scoped incorrectly per provider: when a provider removed a stream from a series, `batch_process_episodes` could delete episode relations belonging to a different provider version of the same series (e.g. EN vs ES) that had deduped to the same `Series` object via TMDB/IMDB ID. Cleanup is now scoped to the specific `M3USeriesRelation` that was queried.
## [0.19.0] - 2026-02-10
### Added
@ -80,6 +576,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths.
- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action.
- Plugin enable/disable toggles now update immediately without requiring a full page refresh.
- M3U/EPG tasks downloading endlessly for large files: Fixed the root cause where the Redis task lock (300s TTL) expired during long downloads, allowing Celery Beat to start competing duplicate tasks that never completed. Added a `TaskLockRenewer` daemon thread that periodically extends the lock TTL while a task is actively working, applied to all long-running task paths (M3U refresh, M3U group refresh, EPG refresh, EPG program parsing). Also adds an HTTP timeout to M3U download requests, streams M3U downloads directly to a temp file on disk instead of accumulating the entire file in memory, and adds Celery task time limits as a safety net against runaway tasks. (Fixes #861) - Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.18.1] - 2026-01-27

245
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,245 @@
# Contributing to Dispatcharr
Thank you for your interest in contributing. Dispatcharr is a complex, production-oriented platform and we hold contributions to a high standard. Please read this guide in full before opening a pull request — it will save everyone time.
---
## Table of Contents
- [Before You Start](#before-you-start)
- [Project Overview](#project-overview)
- [Setting Up the Development Environment](#setting-up-the-development-environment)
- [Code Standards](#code-standards)
- [Writing Tests](#writing-tests)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [On AI-Assisted Code](#on-ai-assisted-code)
- [What We Will Decline](#what-we-will-decline)
- [Contributor License Agreement](#contributor-license-agreement)
---
## Before You Start
**Open an issue before writing code.** If you want to add a feature or fix a non-trivial bug, open a GitHub issue first. This lets us tell you whether it aligns with the project's direction, whether it's already being worked on, and how it should be approached — before you invest time writing code.
For small, obvious bug fixes (a typo, an off-by-one error, a missing validation) you can go straight to a PR.
---
## Project Overview
Understanding the architecture is a prerequisite for contributing. If you are not familiar with the following, take time to learn them before submitting changes:
| Layer | Technology |
| ----------- | ------------------------------------------------- |
| Backend | Python 3.13, Django 5, Django REST Framework |
| Async tasks | Celery 5 with Redis broker |
| Real-time | Django Channels (WebSockets), Redis channel layer |
| Database | PostgreSQL 17 |
| Frontend | React 19, Vite, Mantine UI, Zustand |
| API docs | drf-spectacular (OpenAPI) |
| Packaging | `uv`, `pyproject.toml`, Hatchling |
| Deployment | Docker, Nginx, uWSGI/Daphne |
### Key Django Apps
| App | Responsibility |
| --------------- | ----------------------------------------------------- |
| `apps/channels` | Core channel management |
| `apps/proxy` | Stream proxying, client management, failover |
| `apps/epg` | EPG ingestion, matching, XMLTV output |
| `apps/m3u` | M3U playlist parsing and management |
| `apps/output` | M3U, Xtream Codes, XMLTV export |
| `apps/hdhr` | HDHomeRun device emulation |
| `apps/vod` | VOD library with TMDB/IMDB metadata |
| `apps/ffmpeg` | FFmpeg stream profile management |
| `apps/plugins` | Plugin/event-hook system |
| `apps/accounts` | Auth, permissions, API keys |
| `core/` | Shared tasks, scheduling, utilities, Xtream Codes API |
Before touching any app, read its models, serializers, and views end-to-end so you understand what already exists.
---
## Setting Up the Development Environment
### Prerequisites
- Docker and Docker Compose
- Node.js 24+ (for frontend)
- Python 3.13+ and [`uv`](https://docs.astral.sh/uv/)
### Backend (Docker — recommended)
```bash
# Full stack (modular mode with separate containers)
docker compose -f docker/docker-compose.dev.yml up
# Or run Django directly against a local Redis/Postgres
uv sync
uv run python manage.py migrate
uv run python manage.py runserver
```
### Frontend
```bash
cd frontend
npm install
npm run dev # dev server with HMR (proxies API to Django)
npm run build # production build
npm run test # run Vitest test suite
```
The Vite dev server is configured to proxy `/api/` requests to the Django backend.
### Environment Variables
Copy the relevant `docker-compose.*.yml` as a reference for required environment variables. Key ones:
| Variable | Purpose |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| `POSTGRES_*` | Database connection |
| `REDIS_*` | Redis broker / channel layer |
| `DJANGO_SECRET_KEY` | Django secret (auto-generated in Docker) |
| `DISPATCHARR_DEBUG` | Enables Django debug mode **and** starts debugpy for remote debugging (attach on port 5678) |
| `DISPATCHARR_LOG_LEVEL` | Log verbosity |
---
## Code Standards
### Backend (Python/Django)
- Follow [PEP 8](https://peps.python.org/pep-0008/). Use 4-space indentation.
- Follow Django conventions: fat models, thin views, business logic out of serializers.
- New API endpoints must use Django REST Framework. Include serializers — do not return raw dicts from views.
- New endpoints must be registered in the appropriate `api_urls.py` and must appear correctly in the OpenAPI schema (check via drf-spectacular).
- Database changes require a migration: `uv run python manage.py makemigrations <app>`. Migrations must be included in your PR.
- Celery tasks belong in `tasks.py` of the relevant app, or `core/tasks.py` for shared tasks. Tasks must be idempotent where possible.
- Do not introduce new top-level dependencies without discussion. Add them to `pyproject.toml` with a justification in your PR description.
### Frontend (React/JavaScript)
- Code must pass ESLint without errors: `npm run lint`
- Code must be formatted with Prettier: `npm run format`
- Use existing Mantine UI components. Do not introduce new UI libraries.
- State management uses Zustand. New global state belongs in a store under `frontend/src/store/`. Do not use React Context for app-level state.
- API calls belong in `frontend/src/api.js`. Do not make `fetch`/`axios` calls directly from components.
- Components should be functional. Avoid class components.
### General
- Do not leave debug logging, `console.log`, `print()`, or commented-out code in your PR.
- Do not reformat or refactor code outside the scope of your change. Noise in diffs makes review harder.
- Keep commits focused. One logical change per commit.
---
## Writing Tests
Untested code is significantly less likely to be merged.
### Backend
- Use Django's `TestCase` for unit/integration tests.
- Test files live at `apps/<app>/tests/`.
- Run the test suite with: `uv run python manage.py test`
### Frontend
- Use Vitest and React Testing Library.
- Test files live alongside what they test in `__tests__/` directories.
- Run with: `npm run test`
- Every new store should have a test file under `frontend/src/store/__tests__/`.
- Every new page should have a test file under `frontend/src/pages/__tests__/`.
---
## Submitting a Pull Request
### PR Checklist
Before opening your PR, verify each of the following yourself:
- [ ] I have read this entire document
- [ ] I opened (or was assigned to) a GitHub issue for this change before writing code
- [ ] I understand — line by line — every change in this PR
- [ ] Backend: migrations are included if models changed
- [ ] Backend: new endpoints are documented in the OpenAPI schema
- [ ] Frontend: ESLint and Prettier pass cleanly
- [ ] Tests are included for new functionality
- [ ] Existing tests still pass
- [ ] No debug artifacts are left in the code
- [ ] My PR targets the `dev` branch (or the branch specified in the issue)
### PR Description
A good PR description answers:
1. **What** does this change do?
2. **Why** is this change needed? (link to the issue)
3. **How** does it work? Describe any non-obvious technical decisions.
4. **How was it tested?** What did you run to verify this works?
One-line PR descriptions like _"fixed bug"_ or _"added feature"_ will be closed and asked to resubmit.
### Review Process
Maintainers review PRs as time allows. To keep the process moving:
- Respond to review comments promptly. Stale PRs (no activity for 30 days) may be closed.
- Do not force-push to a branch under review without flagging it in a comment.
- Keep your branch up to date with `dev` by rebasing, not merging `dev` into your branch.
---
## On AI-Assisted Code
We are aware that AI coding tools are capable of generating plausible-looking code quickly. We do not prohibit their use, but we require the following:
**You must understand every line of code you submit.**
AI tools frequently produce code that:
- Duplicates logic that already exists elsewhere in the codebase
- Ignores the established patterns for how the project is structured
- Introduces subtle bugs that are invisible without domain knowledge
- Passes superficial review but breaks edge cases in production
If you cannot explain, during code review, why a particular line of code is written the way it is — including the tradeoffs involved — the PR will not be merged. There are no exceptions.
Using an AI tool to help you understand the codebase, generate a first draft, or write boilerplate is fine. Submitting code you have not read and do not understand is not.
---
## What We Will Decline
To save your time and ours, the following types of PRs will be closed without extended review:
- **Undiscussed feature additions.** If there is no linked issue where the feature was agreed upon, we will close the PR and ask you to open one.
- **Large, unfocused diffs.** A PR that touches 20 files across 5 apps to "improve code quality" is almost never reviewable. Scope your changes.
- **Dependency bumps without justification.** Don't open a PR just to bump a library version unless you have identified a specific bug or security issue it resolves.
- **Cosmetic/style-only changes.** Reformatting files, renaming variables for preference, or reorganizing imports with no functional change.
- **Duplicate work.** Check open PRs and issues before starting. If someone is already working on it, coordinate with them.
- **Code the author cannot explain.** See [On AI-Assisted Code](#on-ai-assisted-code).
---
## Contributor License Agreement
By submitting a pull request to this repository, you agree to the following terms:
- You authored the contribution and have the right to submit it.
- You grant the Dispatcharr project a perpetual, worldwide, irrevocable, royalty-free license to use, reproduce, modify, distribute, sublicense, and relicense your contribution as part of this project, under any license the project maintainers choose, now or in the future.
- You retain your own copyright in your contribution — this is a license grant, not a transfer of ownership.
This ensures you always retain the right to use your own contribution elsewhere, while the project isn't blocked from making licensing decisions by the need to track down every past contributor.
By checking the CLA checkbox in the pull request checklist, you confirm that you have read and agree to these terms.
---
## Questions
If you are unsure whether a contribution is a good fit, join the [Discord](https://discord.gg/Sp45V5BcxU) and start a conversation, or comment on the relevant issue. We would rather have a five-minute conversation upfront than a 30-comment review thread on a PR that ultimately doesn't get merged.

1098
LICENSE

File diff suppressed because it is too large Load diff

618
Plugin_repo.md Normal file
View file

@ -0,0 +1,618 @@
# Dispatcharr Plugin Repository Specification
How to create and host a plugin repository that Dispatcharr can consume.
For writing plugins themselves, see [Plugins.md](Plugins.md).
---
## Overview
Dispatcharr discovers plugins from remote repositories using a two-level manifest system:
1. **Repo manifest** - a JSON file listing all plugins in the repo with basic metadata.
2. **Per-plugin manifest** (optional) - a JSON file per plugin with full version history, checksums, and compatibility info.
Users add a repo by its manifest URL. Dispatcharr fetches and caches the repo manifest periodically (default: every 6 hours, configurable). The UI displays all plugins from enabled repos in a browsable store.
---
## Repo Manifest
The repo manifest is the entry point. Dispatcharr fetches this URL and caches the response.
### Minimal Example (no signing)
```json
{
"registry_name": "My Plugin Repo",
"plugins": [
{
"slug": "my_plugin",
"name": "My Plugin",
"description": "Does something useful",
"author": "Your Name",
"latest_version": "1.0.0",
"latest_url": "https://example.com/releases/my_plugin-1.0.0.zip"
}
]
}
```
This is the simplest valid repo manifest - one plugin with enough info to show in the store and install.
### Full Example (with signing)
```json
{
"manifest": {
"registry_name": "My Plugin Repo",
"registry_url": "https://github.com/myorg/my-plugins",
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
"plugins": [
{
"slug": "weather_display",
"name": "Weather Display",
"description": "Shows weather info on the dashboard",
"author": "Acme Labs",
"maintainers": ["alice", "bob"],
"license": "MIT",
"deprecated": false,
"repo_url": "https://github.com/acmelabs/dispatcharr-weather",
"discord_thread": "https://discord.com/channels/123456/789012",
"latest_version": "1.2.5",
"last_updated": "2025-01-20T15:30:00Z",
"manifest_url": "plugins/weather_display/manifest.json",
"latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip",
"latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"latest_size": 142,
"icon_url": "plugins/weather_display/logo.png",
"min_dispatcharr_version": "2.5.0",
"max_dispatcharr_version": null
}
]
},
"signature": "-----BEGIN PGP SIGNATURE-----\n..."
}
```
### Accepted Formats
Dispatcharr accepts two top-level shapes:
**Wrapped (supports signing):**
```json
{
"manifest": { "plugins": [...], ... },
"signature": "..."
}
```
**Flat (no signing):**
```json
{
"plugins": [...],
"registry_name": "...",
"root_url": "..."
}
```
The wrapped format is required for signing. If you don't need signing, the flat format works and is simpler.
### Name Restrictions
`registry_name` is required. Dispatcharr rejects repos that are missing it.
Third-party repos must not use names that could be confused with an official Dispatcharr repo. The following words are blocked in `registry_name` (case-insensitive):
- "official"
- "dispatcharr plugins"
- "dispatcharr repo"
- "dispatcharr official"
If the name contains any of these, the repo will be rejected on add and skipped during refresh.
---
## Repo Manifest Fields
### 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. |
### 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. |
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.
### URL Resolution
If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) does not start with `http://` or `https://`, it is treated as relative and resolved as:
```
{root_url}/{field_value}
```
This lets you keep plugin entries compact:
```json
{
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
"plugins": [
{
"slug": "my_plugin",
"latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip",
"icon_url": "plugins/my_plugin/logo.png",
"manifest_url": "plugins/my_plugin/manifest.json"
}
]
}
```
**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
```
---
## Per-Plugin Manifest (Optional)
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
- Provide detailed author/license info beyond what's in the repo manifest
### Accepted Formats
Same as the root manifest - both flat and wrapped formats are accepted:
**Flat (no signing):**
```json
{
"slug": "...",
"versions": [...]
}
```
**Wrapped (supports signing):**
```json
{
"manifest": {
"slug": "...",
"versions": [...]
},
"signature": "-----BEGIN PGP SIGNATURE-----\n..."
}
```
Use the wrapped format if you want to GPG-sign the per-plugin manifest.
### Example
```json
{
"slug": "weather_display",
"name": "Weather Display",
"description": "Shows weather information on the Dispatcharr dashboard",
"author": "Acme Labs",
"license": "MIT",
"latest_version": "1.2.5",
"registry_name": "Acme Labs Plugins",
"registry_url": "https://github.com/acmelabs/dispatcharr-plugins",
"versions": [
{
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 142,
"build_timestamp": "2025-01-20T15:30:00Z",
"commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648",
"commit_sha_short": "4e8f1b1",
"min_dispatcharr_version": "2.5.0",
"max_dispatcharr_version": null
},
{
"version": "1.2.5-rc.1",
"url": "releases/weather_display-1.2.5-rc.1.zip",
"checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"size": 141,
"prerelease": true,
"build_timestamp": "2025-01-18T09:00:00Z",
"min_dispatcharr_version": "2.5.0"
},
{
"version": "1.2.4",
"url": "releases/weather_display-1.2.4.zip",
"checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6",
"size": 138,
"build_timestamp": "2025-01-15T10:00:00Z",
"min_dispatcharr_version": "2.4.0"
}
],
"latest": {
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"latest_url": "releases/weather_display-latest.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 142,
"build_timestamp": "2025-01-20T15:30:00Z",
"min_dispatcharr_version": "2.5.0"
}
}
```
### 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. |
### 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. |
Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`.
---
## Without a Per-Plugin Manifest
If you omit `manifest_url` from a plugin entry, the store still works. When a user clicks "More Info", the UI builds a detail view from the repo-level fields:
- `description`, `author`, `license` from the plugin entry
- A single version entry built from `latest_version`, `latest_url`, `latest_sha256`, `min_dispatcharr_version`, `max_dispatcharr_version`, and `last_updated`
This is the simplest path for third-party repos that only publish one version at a time. You lose version history and per-version release dates, but install, update detection, and everything else works the same.
---
## Signing
Signing your repo manifest lets Dispatcharr verify it hasn't been tampered with. Signing is **optional** - unsigned repos work fine but show an "unverified" badge in the UI.
### How It Works
1. You generate a GPG keypair.
2. You sign the manifest JSON and include the detached signature in the response.
3. When adding the repo in Dispatcharr, the user pastes your public key.
4. Dispatcharr verifies the signature on every manifest fetch.
### Key Format
Standard PGP/GPG armored keys:
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBG...
...
-----END PGP PUBLIC KEY BLOCK-----
```
### Signing Convention
The signature is computed over the **canonical JSON** representation of the `manifest` object (not the entire response), plus a trailing newline:
```bash
# Canonical format: compact JSON (no spaces) + trailing newline
jq -c '.manifest' manifest.json | gpg --armor --detach-sign
```
In code terms:
```python
import json
canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n"
```
> **Important:** The signing input must be `json.dumps(obj, separators=(",", ":")) + "\n"` - compact JSON with no whitespace, followed by exactly one newline. Any difference (pretty-printing, trailing spaces, key ordering changes) will cause verification to fail.
### Manifest Structure for Signing
Use the wrapped format so the signature sits alongside the manifest:
```json
{
"manifest": {
"registry_name": "...",
"plugins": [...]
},
"signature": "-----BEGIN PGP SIGNATURE-----\n...\n-----END PGP SIGNATURE-----"
}
```
### 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 |
### Signing Workflow Example
```bash
# Generate a keypair (one-time)
gpg --gen-key
# Export your public key (give this to repo users)
gpg --armor --export "your@email.com" > my-repo.pub
# Build your manifest
cat > manifest.json << 'EOF'
{
"manifest": {
"registry_name": "My Repo",
"root_url": "https://example.com/releases",
"plugins": [
{
"slug": "my_plugin",
"name": "My Plugin",
"latest_version": "1.0.0",
"latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip"
}
]
}
}
EOF
# Sign the manifest object (canonical JSON + newline)
jq -c '.manifest' manifest.json | gpg --armor --detach-sign > manifest.sig
# Combine into final output
jq --arg sig "$(cat manifest.sig)" '.signature = $sig' manifest.json > signed_manifest.json
```
### Third-Party Key Management
When a user adds your repo URL, they can paste your public key. Dispatcharr stores the key per-repo and uses it for verification. Users can update the key at any time from the repo management UI.
If you don't provide a key and the repo is not the official Dispatcharr repo, signature verification is skipped (result: `null`).
---
## Release Zip Format
Each plugin release is a `.zip` archive.
### Requirements
- Must contain a `plugin.py` with a `Plugin` class, **or** a Python package with `__init__.py` exporting a `Plugin` class.
- Files can be at the top level of the zip or inside a single subdirectory.
- Optionally include `plugin.json` for metadata discovery without code execution.
- Optionally include `logo.png` for the plugin icon.
### Size Limits
- Maximum 2000 files per archive.
- Maximum total size: 200 MB (configurable via `MAX_PLUGIN_IMPORT_BYTES` setting).
### Recommended Structure
```
my_plugin-1.0.0.zip
plugin.py
plugin.json
logo.png
(any other files your plugin needs)
```
Or with a subdirectory:
```
my_plugin-1.0.0.zip
my_plugin/
plugin.py
plugin.json
logo.png
utils.py
```
---
## Install Flow
When a user installs a plugin from the store:
1. **Version compatibility check** - if `min_dispatcharr_version` or `max_dispatcharr_version` is set, the running Dispatcharr version is compared. Install is blocked if out of range.
2. **Download** - the zip is streamed from `download_url` (max 200 MB).
3. **SHA256 integrity check** - if `sha256` was provided, the download is hashed and compared. Mismatch blocks the install.
4. **Extraction** - the zip is extracted to a temp directory, validated, then moved to `/data/plugins/{plugin_key}/`. If the plugin already exists, the old version is backed up and restored on failure (atomic rollback).
5. **Registration** - a `PluginConfig` record is created or updated, linking the plugin to its source repo and slug.
6. **Discovery reload** - the plugin loader re-scans all plugin directories.
The plugin is installed **disabled** by default. The user can enable it from the post-install dialog or the My Plugins page.
---
## Update Detection
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
---
## Hosting Options
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
```
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.
---
## Refresh Behavior
- Manifests are refreshed automatically at a configurable interval (default: 6 hours, setting: `refresh_interval_hours`, 0 = disabled).
- Users can force a refresh from the repo management UI.
- A new repo is refreshed immediately when added.
- On refresh, if a plugin's `slug` disappears from the manifest, its `PluginConfig` is unlinked from the repo (becomes "unmanaged") but the installed files are not deleted.
---
## Checklist: Publishing a Plugin Repo
### Minimum Viable Repo
- [ ] Host a JSON file at a stable, public URL
- [ ] Set `registry_name` (required, must not sound official)
- [ ] Include at least one plugin entry with `slug`, `name`, and `latest_version`
- [ ] Host a downloadable `.zip` for each plugin and set `latest_url`
- [ ] Share the manifest URL with users
### Recommended
- [ ] Set `root_url` so plugin URLs can be relative
- [ ] Include `description`, `author`, and `icon_url` per plugin
- [ ] Include `latest_sha256` for integrity verification
- [ ] Include `license` (SPDX identifier)
- [ ] Include `last_updated` timestamps
- [ ] Add a per-plugin `manifest_url` with version history
- [ ] Include `sha256` in every version object
- [ ] Include `min_dispatcharr_version` where applicable
- [ ] Include `plugin.json` in each release zip
### Optional
- [ ] Sign your manifest with GPG and publish your public key
- [ ] Set `registry_url` to enable automatic icon fallback
- [ ] Set `max_dispatcharr_version` if a plugin is incompatible with newer releases
---
## Quick Reference: Repo Manifest Schema
```json
{
"manifest": {
"registry_name": "string (required)",
"registry_url": "string (optional)",
"root_url": "string (optional)",
"plugins": [
{
"slug": "string (required)",
"name": "string (required)",
"description": "string",
"author": "string",
"maintainers": ["string"],
"license": "string (SPDX)",
"deprecated": "boolean",
"repo_url": "string (URL)",
"discord_thread": "string (URL)",
"latest_version": "string (semver)",
"last_updated": "string (ISO 8601)",
"manifest_url": "string (URL or relative path)",
"latest_url": "string (URL or relative path)",
"latest_sha256": "string (64-char hex)",
"latest_md5": "string",
"latest_size": "number (KB)",
"icon_url": "string (URL or relative path)",
"min_dispatcharr_version": "string (semver)",
"max_dispatcharr_version": "string (semver) or null"
}
]
},
"signature": "string (armored PGP signature, optional)"
}
```
## Quick Reference: Per-Plugin Manifest Schema
```json
{
"slug": "string",
"name": "string",
"description": "string",
"author": "string",
"license": "string (SPDX)",
"latest_version": "string (semver)",
"registry_name": "string",
"registry_url": "string (URL)",
"versions": [
{
"version": "string (required)",
"url": "string (required, URL or relative path)",
"checksum_sha256": "string (64-char hex)",
"size": "number (KB)",
"prerelease": "boolean",
"build_timestamp": "string (ISO 8601)",
"commit_sha": "string",
"commit_sha_short": "string",
"min_dispatcharr_version": "string (semver)",
"max_dispatcharr_version": "string (semver) or null"
}
],
"latest": {
"version": "string",
"url": "string",
"latest_url": "string (stable symlink URL)",
"checksum_sha256": "string",
"size": "number (KB)",
"build_timestamp": "string",
"min_dispatcharr_version": "string",
"max_dispatcharr_version": "string or null"
}
}
```

View file

@ -8,7 +8,7 @@
## 📖 What is Dispatcharr?
Dispatcharr is an **open-source powerhouse** for managing IPTV streams, EPG data, and VOD content with elegance and control.\
Dispatcharr (pronounced like "dispatcher") is an **open-source powerhouse** for managing IPTV streams, EPG data, and VOD content with elegance and control.\
Born from necessity and built with passion, it started as a personal project by **[OkinawaBoss](https://github.com/OkinawaBoss)** and evolved with contributions from legends like **[dekzter](https://github.com/dekzter)**, **[SergeantPanda](https://github.com/SergeantPanda)** and **Bucatini**.
> Think of Dispatcharr as the \*arr family's IPTV cousin — simple, smart, and designed for streamers who want reliability and flexibility.
@ -118,12 +118,9 @@ If you are running a Debian-based OS, use the `debian_install.sh` script. For ot
## 🤝 Want to Contribute?
We welcome **PRs, issues, ideas, and suggestions**!\
Here's how you can join the party:
We welcome **PRs, issues, ideas, and suggestions**!
- Follow our coding style and best practices.
- Be respectful, helpful, and open-minded.
- Respect the **CC BY-NC-SA license**.
- Prior to contributing, please read the [CONTRIBUTING.md](https://github.com/Dispatcharr/Dispatcharr/blob/main/CONTRIBUTING.md)
> Whether it's writing docs, squashing bugs, or building new features, your contribution matters! 🙋
@ -140,7 +137,6 @@ Here's how you can join the party:
- 👥 **Enhanced User Management** — Customizable XC API output per user account
- 🔄 **Output Stream Profiles** — Different clients with different stream profiles (bandwidth control, quality tiers)
- 🔌 **Fallback Videos** — Automatic fallback content when channels are unavailable
- 📡 **Webhooks** — Event-driven integrations and automations
---
@ -150,18 +146,6 @@ A huge thank you to all the incredible open-source projects and libraries that p
---
## ⚖️ License
> Dispatcharr is licensed under **CC BY-NC-SA 4.0**:
- **BY**: Give credit where credit's due.
- **NC**: No commercial use.
- **SA**: Share alike if you remix.
For full license details, see [LICENSE](https://creativecommons.org/licenses/by-nc-sa/4.0/).
---
## ✉️ Connect With Us
Have a question? Want to suggest a feature? Just want to say hi?\
@ -169,4 +153,27 @@ Have a question? Want to suggest a feature? Just want to say hi?\
---
## 💖 Support Dispatcharr
[![Dispatcharr Open Collective](https://opencollective.com/dispatcharr/tiers/badge.svg)](https://opencollective.com/dispatcharr/contribute)
Open Collective provides a transparent way for anyone who finds value in Dispatcharr to support things like:
• Infrastructure costs (Domains, Servers, etc.)
• Apple Developer Program and Google Play Developer accounts
• Helping contributors dedicate more time to improving the project
Support is completely optional, and Dispatcharr will always remain free and open-source.
[Contribute here](https://opencollective.com/dispatcharr/contribute)
---
## ⚖️ License & Legal
Dispatcharr is licensed under **GNU AGPL v3.0**: For full license details, see [LICENSE](https://www.gnu.org/licenses/agpl-3.0.html).
Dispatcharr is a trademark of the Dispatcharr project. Use of the Dispatcharr name or logo requires permission from the maintainers.
---
### 🚀 _Happy Streaming! The Dispatcharr Team_

View file

@ -7,7 +7,7 @@ from .models import User
class CustomUserAdmin(UserAdmin):
fieldsets = (
(None, {'fields': ('username', 'password', 'avatar_config', 'groups')}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}),
('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)

View file

@ -4,6 +4,7 @@ from .api_views import (
AuthViewSet,
UserViewSet,
GroupViewSet,
APIKeyViewSet,
TokenObtainPairView,
TokenRefreshView,
list_permissions,
@ -17,6 +18,7 @@ app_name = "accounts"
router = DefaultRouter()
router.register(r"users", UserViewSet, basename="user")
router.register(r"groups", GroupViewSet, basename="group")
router.register(r"api-keys", APIKeyViewSet, basename="api-key")
# 🔹 Custom Authentication Endpoints
auth_view = AuthViewSet.as_view({"post": "login"})

View file

@ -1,13 +1,16 @@
from django.contrib.auth import authenticate, login, logout
import logging
from django.contrib.auth.models import Group, Permission
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.response import Response
from rest_framework import viewsets, status, serializers
from rest_framework.throttling import AnonRateThrottle
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
import json
import secrets
from .permissions import IsAdmin, Authenticated
from dispatcharr.utils import network_access_allowed
@ -15,16 +18,24 @@ from .models import User
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
logger = logging.getLogger(__name__)
class LoginRateThrottle(AnonRateThrottle):
scope = "login"
class TokenObtainPairView(TokenObtainPairView):
throttle_classes = [LoginRateThrottle]
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
# Log blocked login attempt due to network restrictions
from core.utils import log_system_event
username = request.data.get("username", 'unknown')
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}")
log_system_event(
event_type='login_failed',
user=username,
@ -43,6 +54,7 @@ class TokenObtainPairView(TokenObtainPairView):
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
try:
logger.debug(f"Attempting JWT login for user={username}")
response = super().post(request, *args, **kwargs)
# If login was successful, update last_login and log success
@ -61,6 +73,7 @@ class TokenObtainPairView(TokenObtainPairView):
client_ip=client_ip,
user_agent=user_agent,
)
logger.info(f"Login success: user={username} ip={client_ip}")
except User.DoesNotExist:
pass # User doesn't exist, but login somehow succeeded
else:
@ -72,6 +85,7 @@ class TokenObtainPairView(TokenObtainPairView):
user_agent=user_agent,
reason='Invalid credentials',
)
logger.info(f"Login failed: user={username} ip={client_ip}")
return response
@ -84,6 +98,7 @@ class TokenObtainPairView(TokenObtainPairView):
user_agent=user_agent,
reason=f'Authentication error: {str(e)[:100]}',
)
logger.error(f"Login error for user={username}: {e}")
raise # Re-raise the exception to maintain normal error flow
@ -95,6 +110,7 @@ class TokenRefreshView(TokenRefreshView):
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}")
log_system_event(
event_type='login_failed',
user='token_refresh',
@ -109,8 +125,8 @@ class TokenRefreshView(TokenRefreshView):
@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists.
def initialize_superuser(request):
# If a superuser already exists, always indicate that
if User.objects.filter(is_superuser=True).exists():
# If an admin-level user already exists, the system is configured
if User.objects.filter(user_level__gte=10).exists():
return JsonResponse({"superuser_exists": True})
if request.method == "POST":
@ -143,12 +159,11 @@ class AuthViewSet(viewsets.ViewSet):
Login doesn't require auth, but logout does
"""
if self.action == 'logout':
from rest_framework.permissions import IsAuthenticated
return [IsAuthenticated()]
return [Authenticated()]
return []
@extend_schema(
description="Authenticate and log in a user",
description="Alias for POST /api/accounts/token/ — returns JWT access and refresh tokens.",
request=inline_serializer(
name="LoginRequest",
fields={
@ -158,52 +173,10 @@ class AuthViewSet(viewsets.ViewSet):
),
)
def login(self, request):
"""Logs in a user and returns user details"""
username = request.data.get("username")
password = request.data.get("password")
user = authenticate(request, username=username, password=password)
# Get client info for logging
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
if user:
login(request, user)
# Update last_login timestamp
from django.utils import timezone
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
# Log successful login
log_system_event(
event_type='login_success',
user=username,
client_ip=client_ip,
user_agent=user_agent,
)
return Response(
{
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"groups": list(user.groups.values_list("name", flat=True)),
},
}
)
# Log failed login attempt
log_system_event(
event_type='login_failed',
user=username or 'unknown',
client_ip=client_ip,
user_agent=user_agent,
reason='Invalid credentials',
)
return Response({"error": "Invalid credentials"}, status=400)
"""Delegates to TokenObtainPairView (JWT login). Throttling, logging, and
network access checks are handled there."""
view = TokenObtainPairView.as_view()
return view(request._request)
@extend_schema(
description="Log out the current user",
@ -222,6 +195,7 @@ class AuthViewSet(viewsets.ViewSet):
client_ip=client_ip,
user_agent=user_agent,
)
logger.info(f"Logout: user={username} ip={client_ip}")
logout(request)
return Response({"message": "Logout successful"})
@ -264,11 +238,31 @@ class UserViewSet(viewsets.ModelViewSet):
return super().destroy(request, *args, **kwargs)
@extend_schema(
description="Get active user information",
description="Get or update active user information. PATCH updates custom_properties with merge semantics.",
methods=["GET", "PATCH"],
)
@action(detail=False, methods=["get"], url_path="me")
@action(detail=False, methods=["get", "patch"], url_path="me")
def me(self, request):
user = request.user
if request.method == "PATCH":
ALLOWED_FIELDS = {"custom_properties", "first_name", "last_name", "email", "password"}
disallowed = set(request.data.keys()) - ALLOWED_FIELDS
for key in disallowed:
request.data.pop(key, None)
# Strip admin-managed keys from custom_properties so users cannot
# set their own XC credentials via this endpoint.
ADMIN_ONLY_PROPS = {"xc_password"}
cp = request.data.get("custom_properties")
if isinstance(cp, dict):
for key in ADMIN_ONLY_PROPS:
cp.pop(key, None)
serializer = UserSerializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
serializer = UserSerializer(user)
return Response(serializer.data)
@ -305,6 +299,59 @@ class GroupViewSet(viewsets.ModelViewSet):
return super().destroy(request, *args, **kwargs)
# API Key management
class APIKeyViewSet(viewsets.ViewSet):
permission_classes = [Authenticated]
def list(self, request):
user = request.user
return Response({"key": user.api_key})
@action(detail=False, methods=["post"], url_path="generate")
def generate(self, request):
target_user = request.user
user_id = request.data.get("user_id")
if user_id:
from .permissions import IsAdmin
if not IsAdmin().has_permission(request, self):
return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN)
try:
target_user = User.objects.get(id=int(user_id))
except Exception:
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
raw = secrets.token_urlsafe(40)
target_user.api_key = raw
target_user.save(update_fields=["api_key"])
user_data = UserSerializer(target_user).data
return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED)
@action(detail=False, methods=["post"], url_path="revoke")
def revoke(self, request):
target_user = request.user
user_id = request.data.get("user_id")
if user_id:
from .permissions import IsAdmin
if not IsAdmin().has_permission(request, self):
return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN)
try:
target_user = User.objects.get(id=int(user_id))
except Exception:
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
target_user.api_key = None
target_user.save(update_fields=["api_key"])
return Response({"success": True})
# 🔹 4) Permissions List API
@extend_schema(
description="Retrieve a list of all permissions",

View file

@ -0,0 +1,86 @@
from rest_framework import authentication
from rest_framework import exceptions
from django.conf import settings
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from .models import User
class JWTAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "rest_framework_simplejwt.authentication.JWTAuthentication"
name = "jwtAuth"
def get_security_definition(self, auto_schema):
return {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": (
"JWT Bearer authentication.\n\n"
"Obtain a token pair via `POST /api/accounts/token/` using your username and password, "
"then paste the **access token** here — Swagger adds the `Bearer ` prefix automatically.\n\n"
"Access tokens expire after 30 minutes. Refresh using `POST /api/accounts/token/refresh/`."
),
}
class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "apps.accounts.authentication.ApiKeyAuthentication"
name = "ApiKeyAuth"
def get_security_definition(self, auto_schema):
return {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": (
"API key authentication.\n\n"
"Pass your personal API key in the `X-API-Key` request header. "
"Keys can be generated via `POST /api/accounts/api-keys/generate/` "
"and revoked via `POST /api/accounts/api-keys/revoke/`."
),
}
class ApiKeyAuthentication(authentication.BaseAuthentication):
"""
Accepts header `Authorization: ApiKey <key>` or `X-API-Key: <key>`.
"""
keyword = "ApiKey"
def authenticate(self, request):
# Check X-API-Key header first
raw_key = request.META.get("HTTP_X_API_KEY")
if not raw_key:
auth = authentication.get_authorization_header(request).split()
if not auth:
return None
if len(auth) != 2:
return None
scheme = auth[0].decode().lower()
if scheme != self.keyword.lower():
return None
raw_key = auth[1].decode()
if not raw_key:
return None
if not raw_key:
return None
try:
user = User.objects.get(api_key=raw_key)
except User.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid API key")
if not user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return (user, None)
def authenticate_header(self, request):
return self.keyword

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-02-21 18:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_alter_user_custom_properties'),
]
operations = [
migrations.AddField(
model_name='user',
name='api_key',
field=models.CharField(blank=True, db_index=True, max_length=200, null=True),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.11 on 2026-02-26 19:24
import apps.accounts.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_user_api_key'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', apps.accounts.models.CustomUserManager()),
],
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-03-19 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_alter_user_managers'),
]
operations = [
migrations.AddField(
model_name='user',
name='stream_limit',
field=models.IntegerField(default=0),
),
]

View file

@ -1,9 +1,16 @@
# apps/accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import AbstractUser, Permission, UserManager
class CustomUserManager(UserManager):
def create_superuser(self, username, email=None, password=None, **extra_fields):
extra_fields.setdefault('user_level', 10)
return super().create_superuser(username, email, password, **extra_fields)
class User(AbstractUser):
objects = CustomUserManager()
"""
Custom user model for Dispatcharr.
Inherits from Django's AbstractUser to add additional fields if needed.
@ -22,6 +29,8 @@ class User(AbstractUser):
)
user_level = models.IntegerField(default=UserLevel.STREAMER)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
stream_limit = models.IntegerField(default=0)
def __str__(self):
return self.username

View file

@ -1,9 +1,32 @@
import json
from rest_framework import serializers
from django.contrib.auth.models import Group, Permission
from .models import User
from apps.channels.models import ChannelProfile
# Valid navigation item IDs for validation
VALID_NAV_ITEM_IDS = {
'channels', 'vods', 'sources', 'guide', 'dvr',
'stats', 'plugins', 'integrations', 'system', 'settings'
}
MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit
def validate_nav_array(value, field_name):
"""Validate that a value is an array of valid nav item ID strings."""
if not isinstance(value, list):
raise serializers.ValidationError(f"{field_name} must be an array")
if len(value) > 50:
raise serializers.ValidationError(f"{field_name} exceeds maximum length of 50 items")
for item in value:
if not isinstance(item, str):
raise serializers.ValidationError(f"{field_name} items must be strings")
if item not in VALID_NAV_ITEM_IDS:
raise serializers.ValidationError(f"'{item}' is not a valid navigation item ID")
# 🔹 Fix for Permission serialization
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
@ -24,23 +47,25 @@ class GroupSerializer(serializers.ModelSerializer):
# 🔹 Fix for User serialization
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
password = serializers.CharField(write_only=True, required=False)
channel_profiles = serializers.PrimaryKeyRelatedField(
queryset=ChannelProfile.objects.all(), many=True, required=False
)
api_key = serializers.CharField(read_only=True, allow_null=True)
class Meta:
model = User
fields = [
"id",
"username",
"api_key",
"email",
"user_level",
"password",
"channel_profiles",
"custom_properties",
"avatar_config",
"is_active",
"stream_limit",
"is_staff",
"is_superuser",
"last_login",
@ -49,12 +74,37 @@ class UserSerializer(serializers.ModelSerializer):
"last_name",
]
def validate_custom_properties(self, value):
"""Validate custom_properties structure and size."""
if value is None:
return {}
if not isinstance(value, dict):
raise serializers.ValidationError("custom_properties must be a dictionary")
# Size limit check
try:
if len(json.dumps(value)) > MAX_CUSTOM_PROPS_SIZE:
raise serializers.ValidationError(
f"custom_properties exceeds maximum size of {MAX_CUSTOM_PROPS_SIZE} bytes"
)
except (TypeError, ValueError):
raise serializers.ValidationError("custom_properties contains non-serializable data")
# Validate navOrder if present
if 'navOrder' in value:
validate_nav_array(value['navOrder'], 'navOrder')
# Validate hiddenNav if present
if 'hiddenNav' in value:
validate_nav_array(value['hiddenNav'], 'hiddenNav')
return value
def create(self, validated_data):
channel_profiles = validated_data.pop("channel_profiles", [])
user = User(**validated_data)
user.set_password(validated_data["password"])
user.is_active = True
user.save()
user.channel_profiles.set(channel_profiles)
@ -65,6 +115,22 @@ class UserSerializer(serializers.ModelSerializer):
password = validated_data.pop("password", None)
channel_profiles = validated_data.pop("channel_profiles", None)
# Merge custom_properties instead of replacing (prevents data loss)
# Strip null values — sending null for a key omits it rather than overwriting with null
custom_properties = validated_data.pop("custom_properties", None)
if custom_properties is not None:
existing = instance.custom_properties or {}
cleaned = {k: v for k, v in custom_properties.items() if v is not None}
merged = {**existing, **cleaned}
# Scrub stale nav IDs so the DB self-heals on next save
for nav_field in ('navOrder', 'hiddenNav'):
if nav_field in merged and isinstance(merged[nav_field], list):
merged[nav_field] = [
item for item in merged[nav_field]
if item in VALID_NAV_ITEM_IDS
]
instance.custom_properties = merged
for attr, value in validated_data.items():
setattr(instance, attr, value)

72
apps/accounts/tests.py Normal file
View file

@ -0,0 +1,72 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
User = get_user_model()
class InitializeSuperuserTests(TestCase):
"""Tests for the initialize_superuser endpoint"""
def setUp(self):
self.client = APIClient()
self.url = "/api/accounts/initialize-superuser/"
def test_returns_true_when_superuser_exists(self):
"""Superuser with is_superuser=True should be detected"""
User.objects.create_superuser(
username="admin", password="testpass123", user_level=10
)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
def test_returns_true_when_admin_level_user_exists(self):
"""User with user_level=10 but is_superuser=False should be detected"""
user = User.objects.create_user(username="admin", password="testpass123")
user.user_level = 10
user.is_superuser = False
user.save()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
def test_returns_false_when_no_admin_exists(self):
"""No admin or superuser should return false"""
# Create a non-admin user
User.objects.create_user(username="regular", password="testpass123")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["superuser_exists"])
def test_returns_false_when_no_users_exist(self):
"""Empty database should return false"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["superuser_exists"])
def test_create_superuser_when_none_exists(self):
"""POST should create superuser when none exists"""
response = self.client.post(
self.url,
{"username": "newadmin", "password": "testpass123", "email": "admin@test.com"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists())
def test_cannot_create_superuser_when_admin_exists(self):
"""POST should fail when an admin-level user already exists"""
user = User.objects.create_user(username="existing", password="testpass123")
user.user_level = 10
user.save()
response = self.client.post(
self.url,
{"username": "newadmin", "password": "testpass123"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["superuser_exists"])
# Should NOT have created a new user
self.assertFalse(User.objects.filter(username="newadmin").exists())

View file

@ -13,6 +13,7 @@ urlpatterns = [
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),

View file

@ -9,9 +9,11 @@ from django.conf import settings
from django.http import HttpResponse, StreamingHttpResponse, Http404
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, parser_classes
from rest_framework.permissions import IsAdminUser, AllowAny
from rest_framework.permissions import AllowAny
from apps.accounts.permissions import IsAdmin
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from core.utils import safe_upload_path
from . import services
from .tasks import create_backup_task, restore_backup_task
@ -33,7 +35,7 @@ def _verify_task_token(task_id: str, token: str) -> bool:
@api_view(["GET"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
def list_backups(request):
"""List all available backup files."""
try:
@ -47,7 +49,7 @@ def list_backups(request):
@api_view(["POST"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
def create_backup(request):
"""Create a new backup (async via Celery)."""
try:
@ -86,7 +88,7 @@ def backup_status(request, task_id):
)
else:
# Fall back to admin auth check
if not request.user.is_authenticated or not request.user.is_staff:
if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10:
return Response(
{"detail": "Authentication required"},
status=status.HTTP_401_UNAUTHORIZED,
@ -124,7 +126,7 @@ def backup_status(request, task_id):
@api_view(["GET"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
def get_download_token(request, filename):
"""Get a signed token for downloading a backup file."""
try:
@ -168,7 +170,7 @@ def download_backup(request, filename):
)
else:
# Fall back to admin auth check
if not request.user.is_authenticated or not request.user.is_staff:
if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10:
return Response(
{"detail": "Authentication required"},
status=status.HTTP_401_UNAUTHORIZED,
@ -230,7 +232,7 @@ def download_backup(request, filename):
@api_view(["DELETE"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
def delete_backup(request, filename):
"""Delete a backup file."""
try:
@ -253,7 +255,7 @@ def delete_backup(request, filename):
@api_view(["POST"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
@parser_classes([MultiPartParser, FormParser])
def upload_backup(request):
"""Upload a backup file for restoration."""
@ -266,10 +268,18 @@ def upload_backup(request):
try:
backup_dir = services.get_backup_dir()
filename = uploaded.name or "uploaded-backup.zip"
# Sanitize filename: strip directory components to prevent path traversal
filename = Path(uploaded.name or "uploaded-backup.zip").name
if not filename:
filename = "uploaded-backup.zip"
try:
safe_upload_path(filename, str(backup_dir))
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
# Ensure unique filename
backup_file = backup_dir / filename
backup_file = (backup_dir / filename).resolve()
counter = 1
while backup_file.exists():
name_parts = filename.rsplit(".", 1)
@ -299,7 +309,7 @@ def upload_backup(request):
@api_view(["POST"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
def restore_backup(request, filename):
"""Restore from a backup file (async via Celery). WARNING: This will flush the database!"""
try:
@ -332,7 +342,7 @@ def restore_backup(request, filename):
@api_view(["GET"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
def get_schedule(request):
"""Get backup schedule settings."""
try:
@ -346,7 +356,7 @@ def get_schedule(request):
@api_view(["PUT"])
@permission_classes([IsAdminUser])
@permission_classes([IsAdmin])
def update_schedule(request):
"""Update backup schedule settings."""
try:

View file

@ -1,9 +1,13 @@
import json
import logging
from django_celery_beat.models import PeriodicTask, CrontabSchedule
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings
from core.scheduling import (
create_or_update_periodic_task,
delete_periodic_task,
)
logger = logging.getLogger(__name__)
@ -105,98 +109,25 @@ def _sync_periodic_task() -> None:
settings = get_schedule_settings()
if not settings["enabled"]:
# Delete the task if it exists
task = PeriodicTask.objects.filter(name=BACKUP_SCHEDULE_TASK_NAME).first()
if task:
old_crontab = task.crontab
task.delete()
_cleanup_orphaned_crontab(old_crontab)
delete_periodic_task(BACKUP_SCHEDULE_TASK_NAME)
logger.info("Backup schedule disabled, removed periodic task")
return
# Get old crontab before creating new one
old_crontab = None
try:
old_task = PeriodicTask.objects.get(name=BACKUP_SCHEDULE_TASK_NAME)
old_crontab = old_task.crontab
except PeriodicTask.DoesNotExist:
pass
# Check if using cron expression (advanced mode)
if settings["cron_expression"]:
# Parse cron expression: "minute hour day month weekday"
try:
parts = settings["cron_expression"].split()
if len(parts) != 5:
raise ValueError("Cron expression must have 5 parts: minute hour day month weekday")
minute, hour, day_of_month, month_of_year, day_of_week = parts
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=day_of_week,
day_of_month=day_of_month,
month_of_year=month_of_year,
timezone=CoreSettings.get_system_time_zone(),
)
except Exception as e:
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
raise ValueError(f"Invalid cron expression: {e}")
cron_expr = settings["cron_expression"]
else:
# Use simple frequency-based scheduling
# Parse time
# Build a cron expression from simple frequency settings
hour, minute = settings["time"].split(":")
# Build crontab based on frequency
system_tz = CoreSettings.get_system_time_zone()
if settings["frequency"] == "daily":
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week="*",
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
cron_expr = f"{minute} {hour} * * *"
else: # weekly
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=str(settings["day_of_week"]),
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
cron_expr = f"{minute} {hour} * * {settings['day_of_week']}"
# Create or update the periodic task
task, created = PeriodicTask.objects.update_or_create(
name=BACKUP_SCHEDULE_TASK_NAME,
defaults={
"task": "apps.backups.tasks.scheduled_backup_task",
"crontab": crontab,
"enabled": True,
"kwargs": json.dumps({"retention_count": settings["retention_count"]}),
},
create_or_update_periodic_task(
task_name=BACKUP_SCHEDULE_TASK_NAME,
celery_task_path="apps.backups.tasks.scheduled_backup_task",
kwargs={"retention_count": settings["retention_count"]},
cron_expression=cron_expr,
enabled=True,
)
# Clean up old crontab if it changed and is orphaned
if old_crontab and old_crontab.id != crontab.id:
_cleanup_orphaned_crontab(old_crontab)
action = "Created" if created else "Updated"
logger.info(f"{action} backup schedule: {settings['frequency']} at {settings['time']}")
def _cleanup_orphaned_crontab(crontab_schedule):
"""Delete old CrontabSchedule if no other tasks are using it."""
if crontab_schedule is None:
return
# Check if any other tasks are using this crontab
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
logger.debug(f"CrontabSchedule {crontab_schedule.id} still in use, not deleting")
return
logger.debug(f"Cleaning up orphaned CrontabSchedule: {crontab_schedule.id}")
crontab_schedule.delete()

View file

@ -28,10 +28,36 @@ def _is_postgresql() -> bool:
def _get_pg_env() -> dict:
"""Get environment variables for PostgreSQL commands."""
"""Get environment variables for PostgreSQL commands.
Includes PGPASSWORD for password auth and PGSSL* variables for TLS.
Reads TLS config from DATABASES['default']['OPTIONS'], which is
populated by settings.py when POSTGRES_SSL=true.
"""
db_config = settings.DATABASES["default"]
env = os.environ.copy()
env["PGPASSWORD"] = db_config.get("PASSWORD", "")
password = db_config.get("PASSWORD", "")
if password:
env["PGPASSWORD"] = password
else:
env.pop("PGPASSWORD", None)
# Propagate TLS configuration from Django OPTIONS to libpq env vars.
options = db_config.get("OPTIONS", {})
_ssl_env_map = {
"sslmode": "PGSSLMODE",
"sslrootcert": "PGSSLROOTCERT",
"sslcert": "PGSSLCERT",
"sslkey": "PGSSLKEY",
}
# Always strip inherited PGSSL* vars first, then set only what is explicitly configured
for opt_key, env_key in _ssl_env_map.items():
env.pop(env_key, None)
value = options.get(opt_key)
if value:
env[env_key] = value
return env

View file

@ -15,6 +15,96 @@ from . import services
User = get_user_model()
class PgEnvTlsTestCase(TestCase):
"""Test that _get_pg_env includes TLS and password env vars correctly."""
databases = []
@patch('apps.backups.services.settings')
def test_pg_env_includes_ssl_vars_when_tls_enabled(self, mock_settings):
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "testpass",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {
"sslmode": "verify-full",
"sslrootcert": "/certs/ca.crt",
"sslcert": "/certs/client.crt",
"sslkey": "/certs/client.key",
},
}
}
env = services._get_pg_env()
self.assertEqual(env["PGSSLMODE"], "verify-full")
self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt")
self.assertEqual(env["PGSSLCERT"], "/certs/client.crt")
self.assertEqual(env["PGSSLKEY"], "/certs/client.key")
self.assertEqual(env["PGPASSWORD"], "testpass")
@patch('apps.backups.services.settings')
def test_pg_env_no_ssl_vars_when_tls_disabled(self, mock_settings):
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "testpass",
"HOST": "localhost",
"PORT": 5432,
}
}
env = services._get_pg_env()
self.assertNotIn("PGSSLMODE", env)
self.assertNotIn("PGSSLROOTCERT", env)
self.assertNotIn("PGSSLCERT", env)
self.assertNotIn("PGSSLKEY", env)
self.assertEqual(env["PGPASSWORD"], "testpass")
@patch('apps.backups.services.settings')
def test_pg_env_no_password_when_empty(self, mock_settings):
"""Cert-only auth: PGPASSWORD must not be set when password is empty."""
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {"sslmode": "verify-full"},
}
}
env = services._get_pg_env()
self.assertNotIn("PGPASSWORD", env)
self.assertEqual(env["PGSSLMODE"], "verify-full")
@patch('apps.backups.services.settings')
def test_pg_env_partial_ssl_options(self, mock_settings):
"""Server-only TLS: only sslmode and CA cert, no client cert/key."""
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "pass",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {
"sslmode": "verify-ca",
"sslrootcert": "/certs/ca.crt",
},
}
}
env = services._get_pg_env()
self.assertEqual(env["PGSSLMODE"], "verify-ca")
self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt")
self.assertNotIn("PGSSLCERT", env)
self.assertNotIn("PGSSLKEY", env)
class BackupServicesTestCase(TestCase):
"""Test cases for backup services"""
@ -735,6 +825,94 @@ class BackupAPITestCase(TestCase):
self.assertIn('frequency', data['detail'])
class BackupAdminPermissionTestCase(TestCase):
"""Test that backup endpoints use user_level (not is_staff/is_superuser) for admin checks.
This validates the IsAdminUser -> IsAdmin permission change.
API-created admins have user_level=10 but is_staff=False and is_superuser=False.
"""
def setUp(self):
self.client = APIClient()
# API-created admin: user_level=10 but NOT is_staff or is_superuser
self.api_admin = User.objects.create_user(
username='api_admin',
email='apiadmin@example.com',
password='testpass123'
)
self.api_admin.user_level = 10
self.api_admin.is_staff = False
self.api_admin.is_superuser = False
self.api_admin.save()
# User with is_staff=True but low user_level (should NOT have access)
self.staff_user = User.objects.create_user(
username='staffuser',
email='staff@example.com',
password='testpass123'
)
self.staff_user.is_staff = True
self.staff_user.user_level = 1
self.staff_user.save()
self.temp_backup_dir = tempfile.mkdtemp()
def get_auth_header(self, user):
refresh = RefreshToken.for_user(user)
return f'Bearer {str(refresh.access_token)}'
def tearDown(self):
import shutil
if Path(self.temp_backup_dir).exists():
shutil.rmtree(self.temp_backup_dir)
@patch('apps.backups.services.list_backups')
def test_api_created_admin_can_list_backups(self, mock_list_backups):
"""API-created admin (user_level=10, is_staff=False) should access backup endpoints"""
mock_list_backups.return_value = []
auth_header = self.get_auth_header(self.api_admin)
response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 200)
def test_staff_user_without_user_level_cannot_list_backups(self):
"""User with is_staff=True but user_level < 10 should NOT access backup endpoints"""
auth_header = self.get_auth_header(self.staff_user)
response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header)
self.assertIn(response.status_code, [401, 403])
@patch('apps.backups.tasks.create_backup_task.delay')
def test_api_created_admin_can_create_backup(self, mock_create_task):
"""API-created admin should be able to create backups"""
mock_task = MagicMock()
mock_task.id = 'test-task-id'
mock_create_task.return_value = mock_task
auth_header = self.get_auth_header(self.api_admin)
response = self.client.post('/api/backups/create/', HTTP_AUTHORIZATION=auth_header)
self.assertEqual(response.status_code, 202)
@patch('apps.backups.services.get_backup_dir')
def test_api_created_admin_can_delete_backup(self, mock_get_backup_dir):
"""API-created admin should be able to delete backups"""
backup_dir = Path(self.temp_backup_dir)
mock_get_backup_dir.return_value = backup_dir
backup_file = backup_dir / "test-backup.zip"
backup_file.write_text("test content")
auth_header = self.get_auth_header(self.api_admin)
response = self.client.delete(
'/api/backups/test-backup.zip/delete/',
HTTP_AUTHORIZATION=auth_header
)
self.assertEqual(response.status_code, 204)
class BackupSchedulerTestCase(TestCase):
"""Test cases for backup scheduler"""

View file

@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Count, F
from django.db.models import Q
import os, json, requests, logging, mimetypes
import os, json, requests, logging, mimetypes, threading, time
from datetime import timedelta
from django.utils.http import http_date
from urllib.parse import unquote
from apps.accounts.permissions import (
@ -23,12 +24,13 @@ from apps.accounts.permissions import (
)
from core.models import UserAgent, CoreSettings
from core.utils import RedisClient
from core.utils import RedisClient, safe_upload_path
from .models import (
Stream,
Channel,
ChannelGroup,
ChannelStream,
Logo,
ChannelProfile,
ChannelProfileMembership,
@ -48,7 +50,6 @@ from .serializers import (
)
from .tasks import (
match_epg_channels,
evaluate_series_rules,
evaluate_series_rules_impl,
match_single_channel_epg,
match_selected_channels_epg,
@ -61,7 +62,7 @@ from rest_framework.filters import SearchFilter, OrderingFilter
from apps.epg.models import EPGData
from apps.vod.models import Movie, Series
from django.db.models import Q
from django.http import StreamingHttpResponse, FileResponse, Http404
from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404
from django.utils import timezone
import mimetypes
from django.conf import settings
@ -72,6 +73,12 @@ from rest_framework.pagination import PageNumberPagination
logger = logging.getLogger(__name__)
# Negative cache for remote logo URLs that failed to fetch.
# Prevents repeated blocking requests to unreachable hosts (e.g., dead CDNs)
# from exhausting Daphne workers. Keyed by URL, value is expiry timestamp.
_logo_fetch_failures = {}
_LOGO_FAIL_TTL = 300 # seconds
class OrInFilter(django_filters.Filter):
"""
@ -413,6 +420,13 @@ class ChannelPagination(PageNumberPagination):
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):
from django.db.models import Exists, OuterRef
has_unassigned = Channel.objects.filter(epg_data__isnull=True).exists()
response = super().get_paginated_response(data)
response.data['has_unassigned_epg_channels'] = has_unassigned
return response
class EPGFilter(django_filters.Filter):
"""
@ -461,7 +475,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ChannelFilter
search_fields = ["name", "channel_group__name"]
ordering_fields = ["channel_number", "name", "channel_group__name"]
ordering_fields = ["channel_number", "name", "channel_group__name", "epg_data__name"]
ordering = ["-channel_number"]
def create(self, request, *args, **kwargs):
@ -571,6 +585,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
channel_profile_id = self.request.query_params.get("channel_profile_id")
show_disabled_param = self.request.query_params.get("show_disabled", None)
only_streamless = self.request.query_params.get("only_streamless", None)
only_stale = self.request.query_params.get("only_stale", None)
if channel_profile_id:
try:
@ -590,6 +605,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
if only_streamless:
q_filters &= Q(streams__isnull=True)
if only_stale:
# Filter channels that have at least one related stream marked as stale
q_filters &= Q(streams__is_stale=True)
if self.request.user.user_level < 10:
filters["user_level__lte"] = self.request.user.user_level
@ -613,6 +631,53 @@ class ChannelViewSet(viewsets.ModelViewSet):
context["include_streams"] = include_streams
return context
@extend_schema(
methods=["PATCH"],
description=(
"Bulk edit multiple channels in a single request. "
"Accepts a JSON array of channel update objects. Each object must include `id` (the channel's primary key). "
"All other fields are optional and support partial updates. "
"The `streams` field accepts a list of stream IDs and will replace the channel's current stream assignments. "
"All updates are validated before any changes are applied and executed in a single database transaction."
),
request=inline_serializer(
name="ChannelBulkEditRequest",
fields={
"id": serializers.IntegerField(help_text="ID of the channel to update (required)."),
"name": serializers.CharField(required=False),
"channel_number": serializers.FloatField(required=False),
"channel_group_id": serializers.IntegerField(required=False, allow_null=True),
"streams": serializers.ListField(
child=serializers.IntegerField(),
required=False,
help_text="List of stream IDs to assign to this channel (replaces existing assignments).",
),
"stream_profile_id": serializers.IntegerField(required=False, allow_null=True),
"logo_id": serializers.IntegerField(required=False, allow_null=True),
"tvg_id": serializers.CharField(required=False, allow_blank=True),
"tvc_guide_stationid": serializers.CharField(required=False, allow_blank=True),
"epg_data_id": serializers.IntegerField(required=False, allow_null=True),
"user_level": serializers.IntegerField(required=False),
"is_adult": serializers.BooleanField(required=False),
},
many=True,
),
responses={
200: inline_serializer(
name="ChannelBulkEditResponse",
fields={
"message": serializers.CharField(),
"channels": ChannelSerializer(many=True),
},
),
400: inline_serializer(
name="ChannelBulkEditErrorResponse",
fields={
"errors": serializers.ListField(child=serializers.DictField()),
},
),
},
)
@action(detail=False, methods=["patch"], url_path="edit/bulk")
def edit_bulk(self, request):
"""
@ -692,19 +757,24 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Apply all updates in a transaction
with transaction.atomic():
streams_updates = []
for channel, validated_data in validated_updates:
# Pop streams before setattr loop — M2M fields can't be set via setattr
streams = validated_data.pop("streams", None)
if streams is not None:
streams_updates.append((channel, streams))
for key, value in validated_data.items():
setattr(channel, key, value)
# Single bulk_update query instead of individual saves
channels_to_update = [channel for channel, _ in validated_updates]
if channels_to_update:
# Collect all unique field names from all updates
# Collect all unique field names from all updates (streams already popped)
all_fields = set()
for _, validated_data in validated_updates:
all_fields.update(validated_data.keys())
# Only call bulk_update if there are fields to update
# Only call bulk_update if there are non-M2M fields to update
if all_fields:
Channel.objects.bulk_update(
channels_to_update,
@ -712,6 +782,36 @@ class ChannelViewSet(viewsets.ModelViewSet):
batch_size=100
)
# Handle streams M2M updates separately
for channel, streams in streams_updates:
normalized_ids = [
stream.id if hasattr(stream, "id") else stream for stream in streams
]
current_links = {
cs.stream_id: cs for cs in channel.channelstream_set.all()
}
existing_ids = set(current_links.keys())
new_ids = set(normalized_ids)
to_remove = existing_ids - new_ids
if to_remove:
channel.channelstream_set.filter(stream_id__in=to_remove).delete()
to_update = []
for order, stream_id in enumerate(normalized_ids):
if stream_id in current_links:
cs = current_links[stream_id]
if cs.order != order:
cs.order = order
to_update.append(cs)
else:
ChannelStream.objects.create(
channel=channel, stream_id=stream_id, order=order
)
if to_update:
ChannelStream.objects.bulk_update(to_update, ["order"])
# Return the updated objects (already in memory)
serialized_channels = ChannelSerializer(
[channel for channel, _ in validated_updates],
@ -724,6 +824,104 @@ class ChannelViewSet(viewsets.ModelViewSet):
"channels": serialized_channels
})
@extend_schema(
methods=["POST"],
description=(
"Bulk rename channel names using a regex find/replace executed server-side. "
"Accepts JavaScript-style named groups (e.g., (?<name>...)) and converts them to Python syntax. "
"Supports flags: 'i' (IGNORECASE). Replacement tokens like $1, $& and $<name> are translated to Python."
),
request=inline_serializer(
name="BulkRegexRenameRequest",
fields={
"channel_ids": serializers.ListField(child=serializers.IntegerField()),
"find": serializers.CharField(),
"replace": serializers.CharField(required=False, allow_blank=True),
"flags": serializers.CharField(required=False, allow_blank=True),
},
),
)
@action(detail=False, methods=["post"], url_path="edit/bulk-regex")
def bulk_regex_rename(self, request):
"""
Efficiently apply a regex find/replace to the `name` field of multiple channels.
"""
import regex as re
channel_ids = request.data.get("channel_ids", [])
pattern = request.data.get("find", "")
replace = request.data.get("replace", "")
flags_str = request.data.get("flags", "") or ""
if not isinstance(channel_ids, list) or len(channel_ids) == 0:
return Response({"error": "channel_ids must be a non-empty list"}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(pattern, str) or pattern.strip() == "":
return Response({"error": "find (regex pattern) is required"}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(replace, str):
return Response({"error": "replace must be a string"}, status=status.HTTP_400_BAD_REQUEST)
# Convert JS-style named groups to Python (?<name>...) -> (?P<name>...)
try:
converted_pattern = re.sub(r"\(\?<([^>]+)>", r"(?P<\1>", pattern)
except Exception as e:
return Response({"error": f"Failed to normalize pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST)
# Compile flags
re_flags = 0
if "i" in flags_str:
re_flags |= re.IGNORECASE
# Note: 'g' (global) is the default behavior of re.sub; no action needed.
# Translate common JS replacement tokens to Python
def translate_js_replacement(rep: str) -> str:
# $$ -> $
rep = rep.replace("$$", "$")
# $& -> \g<0>
rep = rep.replace("$&", r"\g<0>")
# $<name> -> \g<name>
rep = re.sub(r"\$<([A-Za-z_][A-Za-z0-9_]*)>", r"\\g<\1>", rep)
# $1 -> \g<1>, $2 -> \g<2>, etc.
rep = re.sub(r"\$(\d+)", r"\\g<\1>", rep)
return rep
try:
replacement_py = translate_js_replacement(replace)
compiled = re.compile(converted_pattern, flags=re_flags)
except Exception as e:
return Response({"error": f"Invalid regex pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST)
# Fetch channels in one query
channels = list(Channel.objects.filter(id__in=channel_ids))
if not channels:
return Response({"error": "No matching channels found for provided IDs"}, status=status.HTTP_404_NOT_FOUND)
changed = []
for ch in channels:
current = ch.name or ""
try:
new_name = compiled.sub(replacement_py, current)
except Exception as e:
# Skip problematic replacements but continue processing others
logger.warning(f"Regex replacement failed for channel {ch.id}: {e}")
continue
# Only update if name actually changes and remains non-empty
if new_name != current and new_name.strip():
ch.name = new_name
changed.append(ch)
# Apply updates in bulk
updated_count = 0
if changed:
with transaction.atomic():
Channel.objects.bulk_update(changed, fields=["name"], batch_size=100)
updated_count = len(changed)
return Response({
"success": True,
"updated_count": updated_count,
}, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], url_path="set-names-from-epg")
def set_names_from_epg(self, request):
"""
@ -831,6 +1029,50 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Return the response with the list of IDs
return Response(list(channel_ids))
@action(detail=False, methods=["get"], url_path="summary")
def summary(self, request, *args, **kwargs):
"""Return a lightweight list of channels with only the fields needed by the TV Guide."""
queryset = self.filter_queryset(self.get_queryset())
data = list(
queryset.values(
"id",
"name",
"logo_id",
"channel_number",
"uuid",
"epg_data_id",
"channel_group_id",
)
)
return Response(data)
@extend_schema(
methods=["POST"],
description="Retrieve channels by a list of UUIDs using POST to avoid URL length limitations",
request=inline_serializer(
name="ChannelByUUIDsRequest",
fields={
"uuids": serializers.ListField(
child=serializers.CharField(),
help_text="List of channel UUIDs to retrieve",
)
},
),
responses={200: ChannelSerializer(many=True)},
)
@action(detail=False, methods=["post"], url_path="by-uuids")
def get_by_uuids(self, request, *args, **kwargs):
uuids = request.data.get("uuids", [])
if not isinstance(uuids, list):
return Response(
{"error": "uuids must be a list of strings"},
status=status.HTTP_400_BAD_REQUEST,
)
channels = Channel.objects.filter(uuid__in=uuids)
serializer = self.get_serializer(channels, many=True)
return Response(serializer.data)
@extend_schema(
methods=["POST"],
description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
@ -919,6 +1161,10 @@ class ChannelViewSet(viewsets.ModelViewSet):
elif channel_number == 0:
# Special case: 0 means ignore provider numbers and auto-assign
channel_number = None
elif channel_number == -1:
# Special case: -1 means assign the number after the current highest
highest = Channel.objects.order_by('-channel_number').values_list('channel_number', flat=True).first()
channel_number = (int(highest) + 1) if highest is not None else 1
if channel_number is None:
# Still None, auto-assign the next available channel number
@ -1323,7 +1569,11 @@ class ChannelViewSet(viewsets.ModelViewSet):
name="EpgAssociation",
fields={
"channel_id": serializers.IntegerField(),
"epg_data_id": serializers.IntegerField(),
"epg_data_id": serializers.IntegerField(
required=False,
allow_null=True,
help_text="EPG data ID to link. Pass null to remove EPG linkage.",
),
},
),
)
@ -1499,7 +1749,12 @@ class BulkDeleteLogosAPIView(APIView):
"logo_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Logo IDs to delete",
)
),
"delete_files": serializers.BooleanField(
required=False,
default=False,
help_text="Whether to also delete local logo files from disk.",
),
},
),
)
@ -1738,10 +1993,13 @@ class LogoViewSet(viewsets.ModelViewSet):
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
)
file_name = file.name
file_path = os.path.join("/data/logos", file_name)
# Sanitize filename: strip directory components to prevent path traversal
try:
file_path = safe_upload_path(file.name, "/data/logos")
except ValueError:
return Response({"error": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
os.makedirs("/data/logos", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
@ -1761,7 +2019,7 @@ class LogoViewSet(viewsets.ModelViewSet):
# Get custom name from request data, fallback to filename
custom_name = request.data.get('name', '').strip()
logo_name = custom_name if custom_name else file_name
logo_name = custom_name if custom_name else os.path.basename(file_path)
logo, _ = Logo.objects.get_or_create(
url=file_path,
@ -1803,6 +2061,12 @@ class LogoViewSet(viewsets.ModelViewSet):
return response
else: # Remote image
# Skip URLs that recently failed to avoid blocking workers
# on unreachable hosts (e.g., dead CDNs referenced by old recordings).
fail_expiry = _logo_fetch_failures.get(logo_url)
if fail_expiry and time.monotonic() < fail_expiry:
raise Http404("Remote image temporarily unavailable")
try:
# Get the default user agent
try:
@ -1813,14 +2077,39 @@ class LogoViewSet(viewsets.ModelViewSet):
# Fallback to hardcoded if default not found
user_agent = 'Dispatcharr/1.0'
# Add proper timeouts to prevent hanging
# Hard total timeout (connect + full download) prevents a slow
# server dripping bytes from holding a greenlet indefinitely.
_LOGO_TOTAL_TIMEOUT = 10 # seconds
_LOGO_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
remote_response = requests.get(
logo_url,
stream=True,
timeout=(3, 5), # (connect_timeout, read_timeout)
timeout=(3, 5), # (connect_timeout, read_timeout per chunk)
headers={'User-Agent': user_agent}
)
if remote_response.status_code == 200:
# Eagerly read the full image with a total time + size cap
# so the greenlet is released quickly.
chunks = []
total = 0
deadline = time.monotonic() + _LOGO_TOTAL_TIMEOUT
for chunk in remote_response.iter_content(chunk_size=8192):
total += len(chunk)
if total > _LOGO_MAX_BYTES:
remote_response.close()
raise Http404("Remote image too large")
if time.monotonic() > deadline:
remote_response.close()
now = time.monotonic()
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
raise Http404("Remote image fetch timed out")
chunks.append(chunk)
body = b"".join(chunks)
# Full read succeeded, clear any previous failure entry
_logo_fetch_failures.pop(logo_url, None)
# Try to get content type from response headers first
content_type = remote_response.headers.get("Content-Type")
@ -1832,26 +2121,32 @@ class LogoViewSet(viewsets.ModelViewSet):
if not content_type:
content_type = "image/jpeg"
response = StreamingHttpResponse(
remote_response.iter_content(chunk_size=8192),
response = HttpResponse(
body,
content_type=content_type,
)
if(remote_response.headers.get("Cache-Control")):
response["Content-Length"] = str(len(body))
if remote_response.headers.get("Cache-Control"):
response["Cache-Control"] = remote_response.headers.get("Cache-Control")
if(remote_response.headers.get("Last-Modified")):
if remote_response.headers.get("Last-Modified"):
response["Last-Modified"] = remote_response.headers.get("Last-Modified")
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)
)
return response
# Non-200 response — cache the failure and evict stale entries
now = time.monotonic()
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
if len(_logo_fetch_failures) > 256:
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
_logo_fetch_failures.pop(k, None)
raise Http404("Remote image not found")
except requests.exceptions.Timeout:
logger.warning(f"Timeout fetching logo from {logo_url}")
raise Http404("Logo request timed out")
except requests.exceptions.ConnectionError:
logger.warning(f"Connection error fetching logo from {logo_url}")
raise Http404("Unable to connect to logo server")
except requests.RequestException as e:
now = time.monotonic()
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
if len(_logo_fetch_failures) > 256:
for k in [k for k, v in _logo_fetch_failures.items() if v <= now]:
_logo_fetch_failures.pop(k, None)
logger.warning(f"Error fetching logo from {logo_url}: {e}")
raise Http404("Error fetching remote image")
@ -2095,6 +2390,54 @@ class RecurringRecordingRuleViewSet(viewsets.ModelViewSet):
logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}")
def _stop_dvr_clients(channel_uuid, recording_id=None):
"""Stop DVR recording clients for a channel.
If recording_id is provided, only the client whose User-Agent contains that
recording ID is stopped (safe for simultaneous recordings on the same channel).
If recording_id is None, all Dispatcharr-DVR clients for the channel are stopped
(used by destroy() when deleting a recording whose task_id is unknown).
Returns the number of DVR clients stopped.
"""
from core.utils import RedisClient
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.services.channel_service import ChannelService
r = RedisClient.get_client()
if not r:
return 0
client_set_key = RedisKeys.clients(channel_uuid)
client_ids = r.smembers(client_set_key) or []
stopped = 0
for raw_id in client_ids:
try:
cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
ua = r.hget(meta_key, "user_agent")
ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "")
if not (ua_s and "Dispatcharr-DVR" in ua_s):
continue
# When a recording_id is specified, only stop the client for that recording.
# Each run_recording task connects with User-Agent "Dispatcharr-DVR/recording-{id}",
# so we can safely target just this recording without affecting others on the channel.
if recording_id is not None and f"recording-{recording_id}" not in ua_s:
continue
try:
ChannelService.stop_client(channel_uuid, cid)
stopped += 1
except Exception as inner_e:
logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}")
except Exception as inner:
logger.debug(f"Error while checking client metadata: {inner}")
# Do not call ChannelService.stop_channel() here.
# Stopping the channel proxy would terminate the source connection which may
# be shared with other recordings on the same channel. The TS proxy server
# already detects when client count reaches zero and tears down the channel
# cleanly on its own (with the configured shutdown delay).
return stopped
class RecordingViewSet(viewsets.ModelViewSet):
queryset = Recording.objects.all()
serializer_class = RecordingSerializer
@ -2189,12 +2532,265 @@ class RecordingViewSet(viewsets.ModelViewSet):
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
return response
@action(detail=True, methods=["post"], url_path="stop")
def stop(self, request, pk=None):
"""Stop a recording early while retaining the partial content for playback."""
instance = self.get_object()
cp = instance.custom_properties or {}
current_status = cp.get("status", "")
# Reject stop on recordings that are already in a terminal state.
# Without this guard, stop() would overwrite "completed" or
# "interrupted" with "stopped", losing the original outcome.
terminal = {"completed", "interrupted", "failed"}
if current_status in terminal:
return Response(
{"success": False, "error": f"Recording is already {current_status}"},
status=status.HTTP_409_CONFLICT,
)
# Mark as stopped in the DB first so run_recording detects it.
# This is the only operation that MUST be synchronous — run_recording reads
# the status field to decide whether the stream disconnection was deliberate.
cp["status"] = "stopped"
cp["stopped_at"] = str(timezone.now())
instance.custom_properties = cp
instance.save(update_fields=["custom_properties"])
# Send the WebSocket notification before returning the response.
# send_websocket_update is gevent-safe (offloads async_to_sync to a
# real OS thread when monkey-patching is active).
channel_uuid = str(instance.channel.uuid)
recording_id = instance.id
task_id = instance.task_id
channel_name = instance.channel.name
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_stopped",
"channel": channel_name,
})
except Exception:
pass
# DVR client teardown and task revocation are deferred to a daemon thread
# because they have occasional slow paths (Redis timeouts, Celery control
# broadcasts) that would otherwise add 5-15 s to the HTTP response time.
def _background_stop():
try:
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
if stopped:
logger.info(
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} (recording stopped early)"
)
except Exception as e:
logger.debug(f"Unable to stop DVR clients for stopped recording: {e}")
try:
from apps.channels.signals import revoke_task
revoke_task(task_id)
except Exception as e:
logger.debug(f"Unable to revoke task for stopped recording: {e}")
try:
from django.db import connection as _conn
_conn.close()
except Exception:
pass
threading.Thread(target=_background_stop, daemon=True).start()
return Response({"success": True, "status": "stopped"})
@action(detail=True, methods=["post"], url_path="extend")
def extend(self, request, pk=None):
"""Extend an in-progress recording's end_time without interrupting the stream.
The running task re-reads end_time every ~2 s and adjusts its deadline
dynamically. The pre_save signal skips task revocation while the
recording status is 'recording'.
"""
instance = self.get_object()
cp = instance.custom_properties or {}
if cp.get("status") in ("completed", "stopped", "interrupted"):
return Response(
{"success": False, "error": "Recording has already finished"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
extra_minutes = int(request.data.get("extra_minutes", 0))
except (TypeError, ValueError):
extra_minutes = 0
if extra_minutes <= 0:
return Response(
{"success": False, "error": "extra_minutes must be a positive integer"},
status=status.HTTP_400_BAD_REQUEST,
)
new_end_time = instance.end_time + timedelta(minutes=extra_minutes)
# Use queryset .update() to bypass pre_save/post_save signals.
# This avoids the pre_save signal revoking the scheduled/running
# Celery task. The running task's 2-second polling loop re-reads
# end_time from the DB and extends its deadline dynamically.
# If the task hasn't started yet (still in Beat's queue), it will
# read the updated end_time from the DB on its first poll cycle.
Recording.objects.filter(pk=instance.pk).update(end_time=new_end_time)
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_extended",
"recording_id": instance.id,
"new_end_time": new_end_time.isoformat(),
"extra_minutes": extra_minutes,
"channel": instance.channel.name,
})
except Exception:
pass
return Response({"success": True, "new_end_time": new_end_time.isoformat()})
@action(detail=True, methods=["post"], url_path="refresh-artwork")
def refresh_artwork(self, request, pk=None):
"""Re-run the poster resolution pipeline for this recording.
Useful when a recording fell back to a channel logo or default logo
because external sources were temporarily unavailable.
"""
instance = self.get_object()
def _background_refresh(rec_id):
try:
from .tasks import _resolve_poster_for_program
from .models import Recording
from core.utils import send_websocket_update
from django.db import close_old_connections
rec = Recording.objects.select_related("channel").get(id=rec_id)
cp = rec.custom_properties or {}
program = cp.get("program") or {}
poster_logo_id, poster_url = _resolve_poster_for_program(
rec.channel.name, program, channel_logo_id=rec.channel.logo_id,
)
# Refresh and merge to avoid overwriting concurrent changes.
# Only upgrade — never replace a real poster with a channel logo fallback.
rec.refresh_from_db()
fresh_cp = rec.custom_properties or {}
updated = False
is_channel_logo_fallback = (
poster_logo_id == rec.channel.logo_id
and not poster_url
)
if program and program.get("id"):
fresh_cp["program"] = program
updated = True
if not is_channel_logo_fallback:
if poster_logo_id and fresh_cp.get("poster_logo_id") != poster_logo_id:
fresh_cp["poster_logo_id"] = poster_logo_id
updated = True
if poster_url and fresh_cp.get("poster_url") != poster_url:
fresh_cp["poster_url"] = poster_url
updated = True
if updated:
rec.custom_properties = fresh_cp
rec.save(update_fields=["custom_properties"])
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_updated",
"recording_id": rec_id,
})
except Exception as e:
logger.debug(f"refresh-artwork background failed for {rec_id}: {e}")
finally:
close_old_connections()
t = threading.Thread(target=_background_refresh, args=(instance.id,), daemon=True)
t.start()
return Response({"success": True, "message": "Artwork refresh started"})
@action(detail=True, methods=["post"], url_path="update-metadata")
def update_metadata(self, request, pk=None):
"""Update user-editable recording metadata (title, description).
Sets user_edited flag to prevent EPG auto-enrichment from overwriting
the user's changes on subsequent task runs.
"""
instance = self.get_object()
title = request.data.get("title")
description = request.data.get("description")
if title is None and description is None:
return Response(
{"success": False, "error": "No fields to update"},
status=status.HTTP_400_BAD_REQUEST,
)
# Strip whitespace; treat blank strings as "no change"
clean_title = str(title).strip() if title is not None else None
clean_desc = str(description).strip() if description is not None else None
if not clean_title and not clean_desc:
return Response(
{"success": False, "error": "Title and description cannot be blank"},
status=status.HTTP_400_BAD_REQUEST,
)
cp = instance.custom_properties or {}
program = cp.get("program") or {}
if clean_title:
program["title"] = clean_title
if clean_desc:
program["description"] = clean_desc
program["user_edited"] = True
cp["program"] = program
instance.custom_properties = cp
instance.save(update_fields=["custom_properties"])
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_updated",
"recording_id": instance.id,
})
except Exception:
pass
return Response({"success": True})
def destroy(self, request, *args, **kwargs):
"""Delete the Recording and ensure any active DVR client connection is closed.
Also removes the associated file(s) from disk if present.
Operation order matters for correctness:
1. Delete the DB record first run_recording's cancellation guard
(Recording.objects.filter(id=...).exists()) will now return False,
preventing it from saving 'interrupted' status or sending
recording_ended after the stream is torn down.
2. Send recording_cancelled WebSocket immediately so the frontend
removes the card without waiting for the slow DVR client teardown.
3. Spawn a background thread to stop the DVR client and delete files.
This mirrors the stop() endpoint's approach and avoids the 5-15 s
delay that _stop_dvr_clients() can introduce.
"""
instance = self.get_object()
recording_id = instance.pk
channel_name = instance.channel.name
# Attempt to close the DVR client connection for this channel if active
try:
@ -2209,14 +2805,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
client_set_key = RedisKeys.clients(channel_uuid)
client_ids = r.smembers(client_set_key) or []
stopped = 0
for raw_id in client_ids:
for cid in client_ids:
try:
cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
ua = r.hget(meta_key, "user_agent")
ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "")
# Identify DVR recording client by its user agent
if ua_s and "Dispatcharr-DVR" in ua_s:
if ua and "Dispatcharr-DVR" in ua:
try:
ChannelService.stop_client(channel_uuid, cid)
stopped += 1
@ -2242,19 +2836,28 @@ class RecordingViewSet(viewsets.ModelViewSet):
# Capture paths before deletion
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
file_path = cp.get("file_path")
temp_ts_path = cp.get("_temp_file_path")
channel_uuid = str(instance.channel.uuid)
# Perform DB delete first, then try to remove files
# 1. Delete the DB record (also fires post_delete → revoke_task_on_delete)
response = super().destroy(request, *args, **kwargs)
# Notify frontends to refresh recordings
# 2. Notify frontends immediately
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"})
send_websocket_update('updates', 'update', {
"success": True,
"type": "recording_cancelled",
"recording_id": recording_id,
"channel": channel_name,
"was_in_progress": rec_status == "recording",
})
except Exception:
pass
# 3. Defer slow teardown to a background thread
library_dir = '/data'
allowed_roots = ['/data/', library_dir.rstrip('/') + '/']
@ -2268,8 +2871,32 @@ class RecordingViewSet(viewsets.ModelViewSet):
except Exception as ex:
logger.warning(f"Failed to delete recording artifact {path}: {ex}")
_safe_remove(file_path)
_safe_remove(temp_ts_path)
def _background_cancel():
# Only stop the DVR client if the recording was actively streaming.
# Stopping for completed/upcoming recordings would kill an unrelated
# in-progress recording on the same channel.
if rec_status == "recording":
try:
stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id)
if stopped:
logger.info(
f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation"
)
except Exception as e:
logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}")
# Best-effort file cleanup in case run_recording already exited
# before the DB delete.
_safe_remove(file_path)
_safe_remove(temp_ts_path)
try:
from django.db import connection as _conn
_conn.close()
except Exception:
pass
threading.Thread(target=_background_cancel, daemon=True).start()
return response
@ -2382,11 +3009,9 @@ class SeriesRulesAPIView(APIView):
else:
rules.append({"tvg_id": tvg_id, "mode": mode, "title": title})
CoreSettings.set_dvr_series_rules(rules)
# Evaluate immediately for this tvg_id (async)
try:
evaluate_series_rules.delay(tvg_id)
except Exception:
pass
# Note: frontend calls the evaluate endpoint explicitly after creating
# the rule, so do NOT fire evaluate_series_rules.delay() here to
# avoid a race that creates duplicate recordings.
return Response({"success": True, "rules": rules})
@ -2399,16 +3024,44 @@ class DeleteSeriesRuleAPIView(APIView):
@extend_schema(
summary="Delete a series rule",
description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
description="Remove a series recording rule by TVG ID and clean up future scheduled recordings.",
parameters=[
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
],
)
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
CoreSettings.set_dvr_series_rules(rules)
return Response({"success": True, "rules": rules})
# Find the rule before removing to retain the title for cleanup
rules = CoreSettings.get_dvr_series_rules()
deleted_rule = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None)
remaining = [r for r in rules if str(r.get("tvg_id")) != tvg_id]
CoreSettings.set_dvr_series_rules(remaining)
# Delete only FUTURE recordings — preserve previously recorded episodes
removed = 0
if deleted_rule:
from .models import Recording
qs = Recording.objects.filter(
start_time__gte=timezone.now(),
custom_properties__program__tvg_id=tvg_id,
)
title = deleted_rule.get("title")
if title:
qs = qs.filter(custom_properties__program__title=title)
removed = qs.count()
qs.delete()
# Notify frontend to refresh recordings list
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
"success": True, "type": "recordings_refreshed", "removed": removed,
})
except Exception:
pass
return Response({"success": True, "rules": remaining, "removed": removed})
class EvaluateSeriesRulesAPIView(APIView):

View file

@ -1,7 +1,7 @@
# Generated by Django 5.1.6 on 2025-03-19 16:33
import datetime
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.db import migrations, models
@ -22,7 +22,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='stream',
name='last_seen',
field=models.DateTimeField(db_index=True, default=datetime.datetime.now),
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='channel',

View file

@ -1,6 +1,6 @@
# Generated by Django 5.2.9 on 2026-01-09 18:19
import datetime
import django.utils.timezone
from django.db import migrations, models
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='channelgroupm3uaccount',
name='last_seen',
field=models.DateTimeField(db_index=True, default=datetime.datetime.now, help_text='Last time this group was seen in the M3U source during a refresh'),
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Last time this group was seen in the M3U source during a refresh'),
),
migrations.AddField(
model_name='stream',

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0.4 on 2026-04-21 14:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0034_remove_stream_dispatcharr_stream_id_idx_and_more'),
]
operations = [
migrations.AlterField(
model_name='channel',
name='name',
field=models.CharField(max_length=512),
),
migrations.AlterField(
model_name='stream',
name='name',
field=models.CharField(default='Default Stream', max_length=512),
),
]

View file

@ -3,9 +3,11 @@ from django.core.exceptions import ValidationError
from django.conf import settings
from core.models import StreamProfile, CoreSettings
from core.utils import RedisClient
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.constants import ChannelMetadataField
import logging
import uuid
from datetime import datetime
from django.utils import timezone
import hashlib
import json
from apps.epg.models import EPGData
@ -54,7 +56,7 @@ class Stream(models.Model):
Represents a single stream (e.g. from an M3U source or custom URL).
"""
name = models.CharField(max_length=255, default="Default Stream")
name = models.CharField(max_length=512, default="Default Stream")
url = models.URLField(max_length=4096, blank=True, null=True)
m3u_account = models.ForeignKey(
M3UAccount,
@ -93,7 +95,7 @@ class Stream(models.Model):
help_text="Unique hash for this stream from the M3U account",
db_index=True,
)
last_seen = models.DateTimeField(db_index=True, default=datetime.now)
last_seen = models.DateTimeField(db_index=True, default=timezone.now)
is_stale = models.BooleanField(
default=False,
db_index=True,
@ -202,9 +204,12 @@ class Stream(models.Model):
return stream_profile
def get_stream(self):
def get_stream(self, requester=None):
"""
Finds an available stream for the requested channel and returns the selected stream and profile.
Finds an available profile for this stream and reserves a connection slot.
Returns:
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason)
"""
redis_client = RedisClient.get_client()
profile_id = redis_client.get(f"stream_profile:{self.id}")
@ -226,33 +231,32 @@ class Stream(models.Model):
if profile.is_active == False:
continue
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
# Atomic slot reservation: INCR first, check, rollback if over capacity
if profile.max_streams == 0:
reserved = True
else:
profile_connections_key = f"profile_connections:{profile.id}"
new_count = redis_client.incr(profile_connections_key)
if new_count <= profile.max_streams:
reserved = True
else:
redis_client.decr(profile_connections_key)
reserved = False
# Check if profile has available slots (or unlimited connections)
if profile.max_streams == 0 or current_connections < profile.max_streams:
# Start a new stream
if reserved:
redis_client.set(f"channel_stream:{self.id}", self.id)
redis_client.set(
f"stream_profile:{self.id}", profile.id
) # Store only the matched profile
redis_client.set(f"stream_profile:{self.id}", profile.id)
return self.id, profile.id, None
# Increment connection count for profiles with limits
if profile.max_streams > 0:
redis_client.incr(profile_connections_key)
return (
self.id,
profile.id,
None,
) # Return newly assigned stream and matched profile
# 4. No available streams
return None, None, None
return None, None, "All active M3U profiles have reached maximum connection limits"
def release_stream(self):
"""
Called when a stream is finished to release the lock.
Returns:
bool: True if stream was successfully released, False if
no profile info could be found for cleanup.
"""
redis_client = RedisClient.get_client()
@ -260,14 +264,17 @@ class Stream(models.Model):
# Get the matched profile for cleanup
profile_id = redis_client.get(f"stream_profile:{stream_id}")
if not profile_id:
logger.debug("Invalid profile ID pulled from stream index")
return
logger.debug(
f"Stream {stream_id}: no profile found in "
f"stream_profile:{stream_id}"
)
return False
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
profile_id = int(profile_id)
logger.debug(
f"Found profile ID {profile_id} associated with stream {stream_id}"
f"Stream {stream_id}: found profile_id={profile_id}"
)
profile_connections_key = f"profile_connections:{profile_id}"
@ -277,6 +284,8 @@ class Stream(models.Model):
if current_count > 0:
redis_client.decr(profile_connections_key)
return True
class ChannelManager(models.Manager):
def active(self):
@ -285,7 +294,7 @@ class ChannelManager(models.Manager):
class Channel(models.Model):
channel_number = models.FloatField(db_index=True)
name = models.CharField(max_length=255)
name = models.CharField(max_length=512)
logo = models.ForeignKey(
"Logo",
on_delete=models.SET_NULL,
@ -391,7 +400,177 @@ class Channel(models.Model):
return stream_profile
def get_stream(self):
def _pick_channel_to_preempt(
self,
profile_id,
requester_level,
redis_client,
exclude_channel_ids=None,
cooldown_seconds=30,
):
"""
Pick the lowest-impact channel to terminate on the given profile.
Returns: Optional[int] channel_id to preempt
"""
exclude_channel_ids = set(exclude_channel_ids or [])
candidates = []
# 1) Try to get active channel IDs for this profile from an index set if available
ch_set_key = f"ts_proxy:profile:{profile_id}:channels"
try:
ch_ids = { (int(x) if not isinstance(x, int) else x) for x in (redis_client.smembers(ch_set_key) or set()) }
except Exception:
ch_ids = set()
logger.debug("Candidate channels for preemption:")
logger.debug(ch_ids)
# 2) Fallback: scan metadata keys and filter by m3u_profile == profile_id
if not ch_ids:
cursor = 0
pattern = "ts_proxy:channel:*:metadata"
while True:
cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=500)
if keys:
# Prefer HGET m3u_profile if metadata is a hash
pipe = redis_client.pipeline()
for k in keys:
pipe.hget(k, "m3u_profile")
prof_vals = pipe.execute()
for k, prof_val in zip(keys, prof_vals):
try:
pid = int(prof_val) if prof_val is not None else None
except Exception:
pid = None
if pid == profile_id:
parts = k.split(":") # ts_proxy:channel:{id}:metadata
if len(parts) >= 4:
try:
ch_ids.add(int(parts[2]))
except Exception:
pass
if cursor == 0:
break
logger.debug("Candidate channels for preemption:")
logger.debug(ch_ids)
if not ch_ids:
return None
# 3) Score candidates
for ch_id in ch_ids:
if ch_id in exclude_channel_ids:
continue
# Skip if recently preempted
last_preempt_key = f"ts_proxy:channel:{ch_id}:last_preempt"
try:
last_preempt = float(redis_client.get(last_preempt_key) or 0.0)
except Exception:
last_preempt = 0.0
if last_preempt and (time.time() - last_preempt) < cooldown_seconds:
continue
# Clients and their levels
clients_key = f"ts_proxy:channel:{ch_id}:clients"
member_ids = list(redis_client.smembers(clients_key) or [])
viewer_count = len(member_ids)
max_viewer_level = 0
if viewer_count:
pipe = redis_client.pipeline()
for cid in member_ids:
pipe.hget(f"ts_proxy:channel:{ch_id}:clients:{cid}", "user_level")
levels_raw = pipe.execute()
levels = []
for lv in levels_raw:
try:
levels.append(int(lv or 0))
except Exception:
levels.append(0)
max_viewer_level = max(levels or [0])
# Only preempt if requester strictly outranks this channel's viewers
if requester_level <= max_viewer_level:
continue
# Metadata (protected/recording/started_at_ts)
meta_key = f"ts_proxy:channel:{ch_id}:metadata"
try:
protected, recording, started_at_ts = redis_client.hmget(
meta_key, "protected", "recording", "started_at_ts"
)
except Exception:
protected = recording = started_at_ts = None
protected = str(protected or "0") in ("1", "true", "True")
recording = str(recording or "0") in ("1", "true", "True")
if protected or recording:
continue
try:
started_at_ts = float(started_at_ts) if started_at_ts is not None else None
except Exception:
started_at_ts = None
if started_at_ts is None:
started_at_ts = time.time() # treat unknown as newest
# Score: lower is safer to terminate
has_viewers = 1 if viewer_count > 0 else 0
score = (has_viewers, max_viewer_level, viewer_count, started_at_ts)
candidates.append((score, ch_id))
logger.debug("Candidate channels after scoring:")
logger.debug(candidates)
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
victim_id = candidates[0][1]
# Mark preempt timestamp to avoid thrashing
try:
redis_client.set(f"ts_proxy:channel:{victim_id}:last_preempt", str(time.time()), ex=3600)
except Exception:
pass
return victim_id
def _check_and_reserve_profile_slot(self, profile, redis_client):
"""
Atomically check and reserve a connection slot for the given profile.
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
condition where separate GET + check + INCR operations could allow
concurrent requests to both pass the capacity check.
For profiles with max_streams=0 (unlimited), no reservation is needed.
Args:
profile: M3UAccountProfile instance
redis_client: Redis client instance
Returns:
tuple: (reserved: bool, current_count: int)
"""
if profile.max_streams == 0:
return (True, 0)
profile_connections_key = f"profile_connections:{profile.id}"
# Atomically increment first — this is a single Redis command
new_count = redis_client.incr(profile_connections_key)
if new_count <= profile.max_streams:
return (True, new_count)
# Over capacity — roll back the increment
redis_client.decr(profile_connections_key)
return (False, new_count - 1)
def get_stream(self, requester=None):
"""
Finds an available stream for the requested channel and returns the selected stream and profile.
@ -456,34 +635,38 @@ class Channel(models.Model):
for profile in profiles:
has_active_profiles = True
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(
redis_client.get(profile_connections_key) or 0
# Atomically check and reserve a slot (INCR-first pattern)
reserved, current_count = self._check_and_reserve_profile_slot(
profile, redis_client
)
# Check if profile has available slots (or unlimited connections)
if (
profile.max_streams == 0
or current_connections < profile.max_streams
):
# Start a new stream
if reserved:
# Slot reserved — assign stream to this channel
redis_client.set(f"channel_stream:{self.id}", stream.id)
redis_client.set(f"stream_profile:{stream.id}", profile.id)
# Increment connection count for profiles with limits
if profile.max_streams > 0:
redis_client.incr(profile_connections_key)
return (
stream.id,
profile.id,
None,
) # Return newly assigned stream and matched profile
else:
# At capacity: try to preempt a lower-impact channel on this profile
victim_channel_id = self._pick_channel_to_preempt(
profile_id=profile.id,
requester_level=requester.user_level if requester else 100,
redis_client=redis_client,
exclude_channel_ids=None,
)
if victim_channel_id:
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
# return self.id, profile.id, victim_channel_id
# This profile is at max connections
has_streams_but_maxed_out = True
logger.debug(
f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}"
f"Profile {profile.id} at max connections: "
f"{current_count}/{profile.max_streams}"
)
# No available streams - determine specific reason
@ -499,32 +682,93 @@ class Channel(models.Model):
def release_stream(self):
"""
Called when a stream is finished to release the lock.
Returns:
bool: True if stream was successfully released, False if
no stream/profile info could be found for cleanup.
"""
redis_client = RedisClient.get_client()
stream_id = redis_client.get(f"channel_stream:{self.id}")
if not stream_id:
logger.debug("Invalid stream ID pulled from channel index")
return
# Primary key missing — try metadata hash fallback.
# The proxy may have already cleaned up channel_stream/stream_profile
# keys, but the metadata hash can still have the stream_id and profile.
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
meta_stream_id = redis_client.hget(
metadata_key, ChannelMetadataField.STREAM_ID
)
meta_profile_id = redis_client.hget(
metadata_key, ChannelMetadataField.M3U_PROFILE
)
if meta_stream_id and meta_profile_id:
stream_id = int(meta_stream_id)
profile_id = int(meta_profile_id)
logger.debug(
f"Channel {self.uuid}: recovered stream_id={stream_id}, "
f"profile_id={profile_id} from metadata fallback"
)
# Clean up any remaining keys
redis_client.delete(f"channel_stream:{self.id}")
redis_client.delete(f"stream_profile:{stream_id}")
# Clear metadata fields so duplicate release_stream() calls
# won't find them and DECR again
redis_client.hdel(
metadata_key,
ChannelMetadataField.STREAM_ID,
ChannelMetadataField.M3U_PROFILE,
)
profile_connections_key = f"profile_connections:{profile_id}"
current_count = int(
redis_client.get(profile_connections_key) or 0
)
if current_count > 0:
redis_client.decr(profile_connections_key)
return True
logger.debug(
f"Channel {self.uuid}: no stream info found in primary keys "
f"or metadata fallback"
)
return False
redis_client.delete(f"channel_stream:{self.id}") # Remove active stream
stream_id = int(stream_id)
logger.debug(
f"Found stream ID {stream_id} associated with channel stream {self.id}"
f"Channel {self.uuid}: found stream_id={stream_id} for "
f"channel_stream:{self.id}"
)
# Get the matched profile for cleanup
profile_id = redis_client.get(f"stream_profile:{stream_id}")
if not profile_id:
logger.debug("Invalid profile ID pulled from stream index")
return
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
profile_id = int(profile_id)
if profile_id:
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
profile_id = int(profile_id)
else:
# stream_profile key missing — try metadata hash fallback
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
meta_profile_id = redis_client.hget(
metadata_key, ChannelMetadataField.M3U_PROFILE
)
if meta_profile_id:
profile_id = int(meta_profile_id)
logger.debug(
f"Channel {self.uuid}: recovered profile_id={profile_id} "
f"from metadata fallback (stream_profile:{stream_id} was missing)"
)
else:
logger.warning(
f"Channel {self.uuid}: no profile found for "
f"stream_profile:{stream_id} or in metadata fallback"
)
return False
logger.debug(
f"Found profile ID {profile_id} associated with stream {stream_id}"
f"Channel {self.uuid}: found profile_id={profile_id} for "
f"stream {stream_id}"
)
profile_connections_key = f"profile_connections:{profile_id}"
@ -534,6 +778,18 @@ class Channel(models.Model):
if current_count > 0:
redis_client.decr(profile_connections_key)
# Clear metadata fields so duplicate release_stream() calls
# (e.g. from _clean_redis_keys or ChannelService.stop_channel)
# won't find them via fallback and DECR again
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
redis_client.hdel(
metadata_key,
ChannelMetadataField.STREAM_ID,
ChannelMetadataField.M3U_PROFILE,
)
return True
def update_stream_profile(self, new_profile_id):
"""
Updates the profile for the current stream and adjusts connection counts.
@ -566,18 +822,18 @@ class Channel(models.Model):
if current_profile_id == new_profile_id:
return True
# Decrement connection count for old profile
# Use pipeline for atomic profile switch to prevent counter drift
# if an exception occurs between DECR and INCR
old_profile_connections_key = f"profile_connections:{current_profile_id}"
old_count = int(redis_client.get(old_profile_connections_key) or 0)
if old_count > 0:
redis_client.decr(old_profile_connections_key)
# Update the profile mapping
redis_client.set(f"stream_profile:{stream_id}", new_profile_id)
# Increment connection count for new profile
new_profile_connections_key = f"profile_connections:{new_profile_id}"
redis_client.incr(new_profile_connections_key)
old_count = int(redis_client.get(old_profile_connections_key) or 0)
pipe = redis_client.pipeline()
if old_count > 0:
pipe.decr(old_profile_connections_key)
pipe.set(f"stream_profile:{stream_id}", new_profile_id)
pipe.incr(new_profile_connections_key)
pipe.execute()
logger.info(
f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}"
)
@ -632,7 +888,7 @@ class ChannelGroupM3UAccount(models.Model):
help_text='Starting channel number for auto-created channels in this group'
)
last_seen = models.DateTimeField(
default=datetime.now,
default=timezone.now,
db_index=True,
help_text='Last time this group was seen in the M3U source during a refresh'
)

View file

@ -365,7 +365,6 @@ class ChannelSerializer(serializers.ModelSerializer):
normalized_ids = [
stream.id if hasattr(stream, "id") else stream for stream in streams
]
print(normalized_ids)
# Get current mapping of stream_id -> ChannelStream
current_links = {
@ -382,17 +381,21 @@ class ChannelSerializer(serializers.ModelSerializer):
instance.channelstream_set.filter(stream_id__in=to_remove).delete()
# Update or create with new order
to_update = []
for order, stream_id in enumerate(normalized_ids):
if stream_id in current_links:
cs = current_links[stream_id]
if cs.order != order:
cs.order = order
cs.save(update_fields=["order"])
to_update.append(cs)
else:
ChannelStream.objects.create(
channel=instance, stream_id=stream_id, order=order
)
if to_update:
ChannelStream.objects.bulk_update(to_update, ["order"])
return instance
def validate_channel_number(self, value):

View file

@ -2,14 +2,15 @@
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.timezone import now, is_aware, make_aware
from celery.result import AsyncResult
from django_celery_beat.models import ClockedSchedule, PeriodicTask
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording
from apps.m3u.models import M3UAccount
from apps.epg.tasks import parse_programs_for_tvg_id
import logging, requests, time
import json
import logging
from .tasks import run_recording, prefetch_recording_artwork
from django.utils.timezone import now, is_aware, make_aware
from datetime import timedelta
logger = logging.getLogger(__name__)
@ -85,18 +86,76 @@ def create_profile_memberships(sender, instance, created, **kwargs):
for channel in channels
])
def schedule_recording_task(instance):
eta = instance.start_time
# Pass recording_id first so task can persist metadata to the correct row
task = run_recording.apply_async(
args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)],
eta=eta
def _dvr_task_name(recording_id):
"""Predictable PeriodicTask name for a DVR recording."""
return f"dvr-recording-{recording_id}"
def schedule_recording_task(instance, eta=None):
"""Schedule a recording task via ClockedSchedule + one-off PeriodicTask.
The task is stored in the database and dispatched by Celery Beat at the
scheduled time with no countdown. This avoids the Redis visibility_timeout
redelivery bug that caused duplicate recordings when using apply_async
with long countdowns.
"""
if eta is None:
eta = instance.start_time
if eta is not None and not is_aware(eta):
eta = make_aware(eta)
# Clamp to now so Beat dispatches immediately for past/current start times
if eta <= now():
eta = now()
task_args = [
instance.id,
instance.channel_id,
str(instance.start_time),
str(instance.end_time),
]
clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=eta)
task_name = _dvr_task_name(instance.id)
PeriodicTask.objects.update_or_create(
name=task_name,
defaults={
"task": "apps.channels.tasks.run_recording",
"clocked": clocked,
"args": json.dumps(task_args),
"one_off": True,
"enabled": True,
"interval": None,
"crontab": None,
"solar": None,
},
)
return task.id
return task_name
def revoke_task(task_id):
if task_id:
"""Cancel a pending recording task.
task_id is normally a PeriodicTask name (e.g. "dvr-recording-42").
For backwards compatibility with legacy Celery async-result UUIDs,
falls back to AsyncResult.revoke().
"""
if not task_id:
return
# Primary path: delete the PeriodicTask and clean up its ClockedSchedule
try:
pt = PeriodicTask.objects.get(name=task_id)
old_clocked = pt.clocked
pt.delete()
if old_clocked and not PeriodicTask.objects.filter(clocked=old_clocked).exists():
old_clocked.delete()
return
except PeriodicTask.DoesNotExist:
pass
# Fallback for legacy Celery task UUIDs
try:
AsyncResult(task_id).revoke()
except Exception:
pass
@receiver(pre_save, sender=Recording)
def revoke_old_task_on_update(sender, instance, **kwargs):
@ -109,6 +168,12 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
old.end_time != instance.end_time or
old.channel_id != instance.channel_id
):
# Do NOT revoke while the recording is actively streaming.
# run_recording re-reads end_time from the DB every ~2 s and extends
# its internal deadline dynamically — revoking here would kill the task.
old_status = (old.custom_properties or {}).get("status", "")
if old_status == "recording":
return
revoke_task(old.task_id)
instance.task_id = None
except Recording.DoesNotExist:
@ -117,32 +182,50 @@ def revoke_old_task_on_update(sender, instance, **kwargs):
@receiver(post_save, sender=Recording)
def schedule_task_on_save(sender, instance, created, **kwargs):
try:
# Skip processing for internal field-only saves (metadata updates,
# task_id assignment, end_time extensions) to prevent re-entrant
# artwork dispatch and redundant recording_updated WS events.
update_fields = kwargs.get('update_fields')
if not created and update_fields is not None and set(update_fields) <= {'custom_properties', 'task_id', 'end_time'}:
return
if not instance.task_id:
start_time = instance.start_time
end_time = instance.end_time
# Make both datetimes aware (in UTC)
# Make datetimes aware (in UTC)
if not is_aware(start_time):
print("Start time was not aware, making aware")
start_time = make_aware(start_time)
if end_time and not is_aware(end_time):
end_time = make_aware(end_time)
current_time = now()
# Debug log
print(f"Start time: {start_time}, Now: {current_time}")
# Optionally allow slight fudge factor (1 second) to ensure scheduling happens
if start_time > current_time - timedelta(seconds=1):
print("Scheduling recording task!")
task_id = schedule_recording_task(instance)
# Future recording — schedule at start_time
logger.info(f"Recording {instance.id}: scheduling task at {start_time}")
task_id = schedule_recording_task(instance, eta=start_time)
instance.task_id = task_id
instance.save(update_fields=['task_id'])
elif end_time and end_time > current_time:
# Currently-playing — start immediately (e.g. series rule for in-progress program)
logger.info(f"Recording {instance.id}: start_time in past but end_time still future, scheduling immediately")
task_id = schedule_recording_task(instance, eta=current_time)
instance.task_id = task_id
instance.save(update_fields=['task_id'])
else:
print("Start time is in the past. Not scheduling.")
# Kick off poster/artwork prefetch to enrich Upcoming cards
try:
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
except Exception as e:
print("Error scheduling artwork prefetch:", e)
logger.info(f"Recording {instance.id}: start_time and end_time both in past, not scheduling")
# Kick off poster/artwork prefetch to enrich Upcoming cards.
# Skip when the recording is already active or finished — run_recording
# handles its own poster resolution, and scheduling artwork prefetch
# while the task is running causes a race that can overwrite status.
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
if rec_status not in ("recording", "completed", "stopped", "interrupted"):
try:
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
except Exception as e:
print("Error scheduling artwork prefetch:", e)
except Exception as e:
import traceback
print("Error in post_save signal:", e)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,278 @@
"""Tests for DVR retry logic.
Covers:
- _db_retry(): exponential backoff, max retries, connection reset
- Final metadata save retry in run_recording post-processing
- Initial TS proxy connection retry (per-base retry on retriable errors)
- recover_recordings_on_startup DB retry wrappers
"""
from datetime import timedelta
from unittest.mock import MagicMock, patch, call
from django.db import OperationalError
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel, Recording
from apps.channels.tasks import _db_retry
# ---------------------------------------------------------------------------
# _db_retry unit tests
# ---------------------------------------------------------------------------
class DbRetryTests(TestCase):
"""Tests for the _db_retry() exponential backoff helper."""
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_succeeds_on_first_attempt(self, _close, _sleep):
"""No retry needed when fn succeeds immediately."""
result = _db_retry(lambda: "ok", max_retries=3)
self.assertEqual(result, "ok")
_sleep.assert_not_called()
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_retries_on_operational_error_then_succeeds(self, mock_close, mock_sleep):
"""Retry succeeds on second attempt after OperationalError."""
call_count = {"n": 0}
def flaky():
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("connection reset")
return "recovered"
result = _db_retry(flaky, max_retries=3, base_interval=1)
self.assertEqual(result, "recovered")
self.assertEqual(call_count["n"], 2)
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_raises_after_max_retries_exhausted(self, mock_close, mock_sleep):
"""Raises OperationalError after all retries fail."""
def always_fail():
raise OperationalError("db gone")
with self.assertRaises(OperationalError):
_db_retry(always_fail, max_retries=3, base_interval=1)
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_exponential_backoff_timing(self, mock_close, mock_sleep):
"""Sleep durations follow exponential backoff: 1s, 2s, 4s."""
call_count = {"n": 0}
def fail_twice():
call_count["n"] += 1
if call_count["n"] <= 2:
raise OperationalError("retry me")
return "done"
_db_retry(fail_twice, max_retries=3, base_interval=1)
mock_sleep.assert_has_calls([call(1), call(2)])
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_close_old_connections_called_between_retries(self, mock_close, mock_sleep):
"""Stale DB connections are reset before each retry attempt."""
call_count = {"n": 0}
def fail_once():
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("stale conn")
return "ok"
_db_retry(fail_once, max_retries=3)
mock_close.assert_called_once()
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_non_operational_error_not_retried(self, mock_close, mock_sleep):
"""Non-OperationalError exceptions propagate immediately."""
def raise_value_error():
raise ValueError("not a DB error")
with self.assertRaises(ValueError):
_db_retry(raise_value_error, max_retries=3)
mock_sleep.assert_not_called()
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_returns_fn_return_value(self, mock_close, mock_sleep):
"""Return value of fn() is passed through."""
result = _db_retry(lambda: {"key": "value"}, max_retries=3)
self.assertEqual(result, {"key": "value"})
@patch("apps.channels.tasks.time.sleep")
@patch("apps.channels.tasks.close_old_connections")
def test_single_retry_allowed(self, mock_close, mock_sleep):
"""max_retries=1 means no retry — fail immediately."""
with self.assertRaises(OperationalError):
_db_retry(
lambda: (_ for _ in ()).throw(OperationalError("fail")),
max_retries=1,
)
mock_sleep.assert_not_called()
# ---------------------------------------------------------------------------
# Final metadata save retry integration tests
# ---------------------------------------------------------------------------
class FinalMetadataSaveRetryTests(TestCase):
"""The final recording metadata save must retry on transient DB errors."""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=95, name="Retry Test Channel"
)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_metadata_save_uses_db_retry(self, _ws):
"""Verify recording metadata is saved via _db_retry (retries on OperationalError)."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
# Directly call _db_retry to save metadata as run_recording does
cp = rec.custom_properties.copy()
cp["status"] = "completed"
cp["ended_at"] = str(now)
cp["bytes_written"] = 1024
def _save():
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_db_retry(_save, max_retries=3, base_interval=1, label="test save")
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], "completed")
self.assertEqual(rec.custom_properties["bytes_written"], 1024)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_metadata_survives_transient_save_failure(self, _ws):
"""Simulate OperationalError on first save, success on retry."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
cp = {"status": "completed", "bytes_written": 2048}
call_count = {"n": 0}
_real_save = rec.save
def patched_save(**kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("connection reset by peer")
return _real_save(**kwargs)
with patch.object(rec, "save", side_effect=patched_save):
with patch("apps.channels.tasks.time.sleep"):
with patch("apps.channels.tasks.close_old_connections"):
def _save():
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_db_retry(_save, max_retries=3, base_interval=1, label="test")
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], "completed")
# ---------------------------------------------------------------------------
# Initial connection retry tests
# ---------------------------------------------------------------------------
class InitialConnectionRetryTests(TestCase):
"""Verify that the DVR task's reconnection logic retries the same
base URL before falling back to the next candidate."""
def test_reconnect_max_constant_exists_in_run_recording(self):
"""run_recording must define a max-reconnect limit to prevent
infinite retries on the same broken base URL."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
# The reconnection counter pattern must be present
self.assertIn("reconnect", source.lower(),
"run_recording must contain reconnection logic")
# ---------------------------------------------------------------------------
# recover_recordings_on_startup retry tests
# ---------------------------------------------------------------------------
class RecoveryRetryTests(TestCase):
"""DB operations in recover_recordings_on_startup must use _db_retry."""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=97, name="Recovery Retry Channel"
)
@patch("apps.channels.tasks.run_recording.apply_async")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_recovery_save_retries_on_operational_error(self, _ws, mock_async):
"""Recovery status update uses _db_retry — survives one OperationalError."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=30),
end_time=now + timedelta(minutes=30),
custom_properties={},
)
# Simulate what recovery does: mark interrupted, then save with retry
cp = rec.custom_properties or {}
cp["status"] = "interrupted"
cp["interrupted_reason"] = "server_restarted"
rec.custom_properties = cp
call_count = {"n": 0}
_real_save = Recording.save
def patched_save(self_rec, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
raise OperationalError("db temporarily unavailable")
return _real_save(self_rec, **kwargs)
with patch.object(Recording, "save", patched_save):
with patch("apps.channels.tasks.time.sleep"):
with patch("apps.channels.tasks.close_old_connections"):
_db_retry(
lambda: rec.save(update_fields=["custom_properties"]),
max_retries=3,
label="test recovery",
)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("status"), "interrupted")
self.assertEqual(rec.custom_properties.get("interrupted_reason"), "server_restarted")
def test_db_retry_fetches_recording_list(self):
"""_db_retry correctly returns query results for recording list fetch."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=30),
end_time=now + timedelta(minutes=30),
custom_properties={},
)
result = _db_retry(
lambda: list(Recording.objects.filter(
start_time__lte=now, end_time__gt=now
)),
label="test query",
)
self.assertGreaterEqual(len(result), 1)
ids = [r.id for r in result]
self.assertIn(rec.id, ids)

View file

@ -0,0 +1,59 @@
import os
from django.test import SimpleTestCase
from unittest.mock import patch
from apps.channels.tasks import build_dvr_candidates
class DVRPortResolutionTests(SimpleTestCase):
"""
Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT
environment variable instead of hardcoding port 9191.
"""
@patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True)
def test_default_port_uses_9191(self):
"""Without DISPATCHARR_PORT set, candidates default to 9191."""
candidates = build_dvr_candidates()
self.assertIn('http://web:9191', candidates)
self.assertIn('http://localhost:9191', candidates)
@patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True)
def test_custom_port_reflected_in_candidates(self):
"""DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references."""
candidates = build_dvr_candidates()
self.assertIn('http://web:8080', candidates)
self.assertIn('http://localhost:8080', candidates)
self.assertNotIn('http://web:9191', candidates)
self.assertNotIn('http://localhost:9191', candidates)
@patch.dict(os.environ, {
'DISPATCHARR_PORT': '7777',
'DISPATCHARR_ENV': 'dev',
'REDIS_HOST': 'redis',
}, clear=True)
def test_dev_mode_includes_5656_and_custom_port(self):
"""Dev mode includes both uwsgi internal port (5656) and custom port."""
candidates = build_dvr_candidates()
self.assertIn('http://127.0.0.1:5656', candidates)
self.assertIn('http://127.0.0.1:7777', candidates)
@patch.dict(os.environ, {
'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234',
'REDIS_HOST': 'redis',
}, clear=True)
def test_explicit_override_is_first(self):
"""DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate."""
candidates = build_dvr_candidates()
self.assertEqual(candidates[0], 'http://custom:1234')
@patch.dict(os.environ, {
'DISPATCHARR_PORT': '3000',
'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000',
'REDIS_HOST': 'redis',
}, clear=True)
def test_internal_api_base_overrides_web_fallback(self):
"""DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default."""
candidates = build_dvr_candidates()
self.assertIn('http://myhost:4000', candidates)
self.assertNotIn('http://web:3000', candidates)

View file

@ -0,0 +1,180 @@
"""Tests for the _match_epg_program_by_timeslot() helper in tasks.py.
Covers:
- Exact time-slot match returns program dict
- 80% overlap threshold: at boundary, above, and below
- Multiple overlapping programs: dominant vs. evenly split
- Edge cases: None inputs, zero-duration recording, no EPG data
- Returned dict structure (id, title, sub_title, description)
"""
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel
from apps.epg.models import EPGSource, EPGData, ProgramData
from apps.channels.tasks import _match_epg_program_by_timeslot
class EpgMatchingSetupMixin:
"""Shared setup for EPG matching tests."""
def setUp(self):
self.source = EPGSource.objects.create(name="Test Source")
self.epg = EPGData.objects.create(
tvg_id="test.channel", name="Test Channel EPG", epg_source=self.source,
)
self.channel = Channel.objects.create(
channel_number=50, name="EPG Match Channel", epg_data=self.epg,
)
self.base = timezone.now().replace(second=0, microsecond=0)
def _prog(self, offset_min, duration_min, title="Test Show", **kwargs):
"""Create a ProgramData starting offset_min from self.base."""
start = self.base + timedelta(minutes=offset_min)
end = start + timedelta(minutes=duration_min)
return ProgramData.objects.create(
epg=self.epg, start_time=start, end_time=end, title=title, **kwargs,
)
class ExactMatchTests(EpgMatchingSetupMixin, TestCase):
"""Recording window exactly matches an EPG program."""
def test_exact_match_returns_program_dict(self):
prog = self._prog(0, 60, title="News at 9", sub_title="Top Stories",
description="Evening news broadcast")
result = _match_epg_program_by_timeslot(
self.epg, prog.start_time, prog.end_time,
)
self.assertIsNotNone(result)
self.assertEqual(result["id"], prog.id)
self.assertEqual(result["title"], "News at 9")
self.assertEqual(result["sub_title"], "Top Stories")
self.assertEqual(result["description"], "Evening news broadcast")
def test_missing_optional_fields_returned_as_empty_strings(self):
prog = self._prog(0, 30, title="Minimal Show")
result = _match_epg_program_by_timeslot(
self.epg, prog.start_time, prog.end_time,
)
self.assertIsNotNone(result)
self.assertEqual(result["sub_title"], "")
self.assertEqual(result["description"], "")
class OverlapThresholdTests(EpgMatchingSetupMixin, TestCase):
"""80% overlap threshold boundary tests."""
def test_exactly_80_percent_overlap_returns_match(self):
"""Program covers exactly 80% of the recording window."""
# Program: 0-60min, Recording: 0-75min → overlap = 60/75 = 80%
prog = self._prog(0, 60, title="Borderline Show")
rec_start = self.base
rec_end = self.base + timedelta(minutes=75)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Borderline Show")
def test_below_80_percent_returns_none(self):
"""Program covers 79% of the recording — below threshold."""
# Program: 0-60min, Recording: 0-76min → overlap = 60/76 ≈ 78.9%
prog = self._prog(0, 60, title="Too Short")
rec_start = self.base
rec_end = self.base + timedelta(minutes=76)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNone(result)
def test_above_80_percent_returns_match(self):
"""Program covers 90% of the recording."""
# Program: 0-60min, Recording: 0-66min → overlap = 60/66 ≈ 90.9%
prog = self._prog(0, 60, title="Good Match")
rec_start = self.base
rec_end = self.base + timedelta(minutes=66)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Good Match")
class MultipleProgramTests(EpgMatchingSetupMixin, TestCase):
"""Recording spans multiple EPG programs."""
def test_dominant_program_returned(self):
"""Recording spans 2 programs; one covers 85%, the other 15%."""
# Show A: 0-60min, Show B: 60-120min
# Recording: 9-69min → A overlap=51/60=85%, B overlap=9/60=15%
self._prog(0, 60, title="Show A")
self._prog(60, 60, title="Show B")
rec_start = self.base + timedelta(minutes=9)
rec_end = self.base + timedelta(minutes=69)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Show A")
def test_evenly_split_returns_none(self):
"""Recording spans 2 equal programs — neither reaches 80%."""
# Show A: 0-60min, Show B: 60-120min
# Recording: 30-90min → each covers 50%
self._prog(0, 60, title="Show A")
self._prog(60, 60, title="Show B")
rec_start = self.base + timedelta(minutes=30)
rec_end = self.base + timedelta(minutes=90)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNone(result)
def test_three_programs_one_dominant(self):
"""Recording spans 3 programs; middle one is dominant."""
# A: 0-30min, B: 30-90min, C: 90-120min
# Recording: 25-95min (70min window) → B overlap=60/70≈85.7%
self._prog(0, 30, title="Show A")
self._prog(30, 60, title="Show B")
self._prog(90, 30, title="Show C")
rec_start = self.base + timedelta(minutes=25)
rec_end = self.base + timedelta(minutes=95)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Show B")
class EdgeCaseTests(EpgMatchingSetupMixin, TestCase):
"""Edge cases and error handling."""
def test_none_epg_data_returns_none(self):
result = _match_epg_program_by_timeslot(None, self.base, self.base + timedelta(hours=1))
self.assertIsNone(result)
def test_none_start_time_returns_none(self):
result = _match_epg_program_by_timeslot(self.epg, None, self.base + timedelta(hours=1))
self.assertIsNone(result)
def test_none_end_time_returns_none(self):
result = _match_epg_program_by_timeslot(self.epg, self.base, None)
self.assertIsNone(result)
def test_zero_duration_returns_none(self):
"""Recording with start == end should return None."""
result = _match_epg_program_by_timeslot(self.epg, self.base, self.base)
self.assertIsNone(result)
def test_negative_duration_returns_none(self):
"""Recording with end before start should return None."""
result = _match_epg_program_by_timeslot(
self.epg, self.base + timedelta(hours=1), self.base,
)
self.assertIsNone(result)
def test_no_overlapping_programs_returns_none(self):
"""No EPG programs in the recording window."""
self._prog(0, 60, title="Earlier Show")
rec_start = self.base + timedelta(hours=5)
rec_end = rec_start + timedelta(hours=1)
result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end)
self.assertIsNone(result)
def test_empty_epg_no_programs_returns_none(self):
"""EPGData exists but has no programs."""
result = _match_epg_program_by_timeslot(
self.epg, self.base, self.base + timedelta(hours=1),
)
self.assertIsNone(result)

View file

@ -0,0 +1,235 @@
"""Tests for the Extend In-Progress Recording feature.
Covers:
- extend() API endpoint (happy path and validation)
- pre_save signal guard: end_time change must NOT revoke a live recording
- pre_save signal guard: end_time change MUST still revoke an upcoming recording
- TOCTOU edge cases (extend on a completed/stopped/nonexistent recording)
"""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording
from apps.channels.api_views import RecordingViewSet
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="extend_test_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
# ---------------------------------------------------------------------------
# Extend endpoint tests
# ---------------------------------------------------------------------------
class ExtendEndpointTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/extend/"""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=88, name="Extend Test Channel"
)
self.user = _make_admin()
self.factory = APIRequestFactory()
def _extend(self, rec, extra_minutes):
request = self.factory.post(
f"/api/channels/recordings/{rec.id}/extend/",
{"extra_minutes": extra_minutes},
format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "extend"})
return view(request, pk=rec.id)
def _make_rec(self, status="recording"):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": status},
)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_updates_end_time_in_db(self, _ws):
rec = self._make_rec()
original_end = rec.end_time
response = self._extend(rec, 30)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data.get("success"))
rec.refresh_from_db()
expected = original_end + timedelta(minutes=30)
delta = abs((rec.end_time - expected).total_seconds())
self.assertLess(delta, 1, "end_time was not extended by the correct amount")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_stacks_multiple_extensions(self, _ws):
"""Calling extend() twice adds both increments."""
rec = self._make_rec()
original_end = rec.end_time
self._extend(rec, 15)
self._extend(rec, 30)
rec.refresh_from_db()
expected = original_end + timedelta(minutes=45)
delta = abs((rec.end_time - expected).total_seconds())
self.assertLess(delta, 1)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_does_not_clear_task_id(self, _ws):
"""The running Celery task must survive the DB save."""
rec = self._make_rec()
rec.task_id = "dvr-recording-999"
rec.save(update_fields=["task_id"])
self._extend(rec, 30)
rec.refresh_from_db()
self.assertEqual(rec.task_id, "dvr-recording-999")
def test_extend_returns_400_if_finished(self):
"""Cannot extend a completed, stopped, or interrupted recording."""
for bad_status in ("completed", "stopped", "interrupted"):
with self.subTest(status=bad_status):
rec = self._make_rec(status=bad_status)
response = self._extend(rec, 30)
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_succeeds_before_task_sets_status(self, _ws):
"""Extend must work when status is empty (task hasn't started yet)."""
rec = self._make_rec(status="")
response = self._extend(rec, 15)
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
expected = rec.end_time # already extended
self.assertTrue(response.data.get("success"))
@patch("apps.channels.signals.revoke_task")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_extend_bypasses_signals_no_revoke(self, _ws, mock_revoke):
"""Extend uses .update() to bypass pre_save — revoke_task must never fire."""
rec = self._make_rec(status="")
rec.task_id = "dvr-recording-500"
rec.save(update_fields=["task_id"])
self._extend(rec, 15)
self._extend(rec, 30)
mock_revoke.assert_not_called()
rec.refresh_from_db()
self.assertEqual(rec.task_id, "dvr-recording-500")
def test_extend_returns_400_for_zero_minutes(self):
response = self._extend(self._make_rec(), 0)
self.assertEqual(response.status_code, 400)
def test_extend_returns_400_for_negative_minutes(self):
response = self._extend(self._make_rec(), -15)
self.assertEqual(response.status_code, 400)
def test_extend_returns_400_for_non_numeric_minutes(self):
rec = self._make_rec()
request = self.factory.post(
f"/api/channels/recordings/{rec.id}/extend/",
{"extra_minutes": "lots"},
format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "extend"})
response = view(request, pk=rec.id)
self.assertEqual(response.status_code, 400)
def test_extend_returns_404_for_nonexistent_recording(self):
request = self.factory.post(
"/api/channels/recordings/999999/extend/",
{"extra_minutes": 30},
format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "extend"})
response = view(request, pk=999999)
self.assertEqual(response.status_code, 404)
# ---------------------------------------------------------------------------
# pre_save signal guard tests
# ---------------------------------------------------------------------------
class PreSaveExtendGuardTests(TestCase):
"""The pre_save signal must NOT revoke a live recording when end_time changes,
but MUST still revoke a scheduled (upcoming) recording as before."""
def setUp(self):
self.channel = Channel.objects.create(
channel_number=77, name="Signal Guard Channel"
)
def _make_rec(self, status="", task_id="dvr-recording-42"):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
task_id=task_id,
custom_properties={"status": status} if status else {},
)
@patch("apps.channels.signals.revoke_task")
def test_end_time_change_does_not_revoke_live_recording(self, mock_revoke):
"""When status='recording', extending end_time must not call revoke_task."""
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
mock_revoke.assert_not_called()
@patch("apps.channels.signals.revoke_task")
def test_task_id_preserved_after_extend_on_live_recording(self, mock_revoke):
"""task_id must not be cleared for a live recording's end_time change."""
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
original_task_id = rec.task_id
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
rec.refresh_from_db()
self.assertEqual(rec.task_id, original_task_id)
@patch("apps.channels.signals.revoke_task")
def test_end_time_change_still_revokes_upcoming_recording(self, mock_revoke):
"""The guard must NOT apply to upcoming recordings — existing behavior preserved."""
rec = self._make_rec(status="", task_id="dvr-recording-77")
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
mock_revoke.assert_called_once_with("dvr-recording-77")
@patch("apps.channels.signals.revoke_task")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_pre_save_guard_reads_db_status_not_memory_status(self, _ws, mock_revoke):
"""pre_save reads status from DB (old object), not from the instance being saved."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
task_id="dvr-recording-66",
custom_properties={"status": "recording"},
)
# Simulate: DB status changes to 'completed' behind the instance's back
Recording.objects.filter(pk=rec.pk).update(
custom_properties={"status": "completed"}
)
rec.end_time = rec.end_time + timedelta(minutes=30)
rec.save(update_fields=["end_time"])
# revoke_task should be called because DB status is "completed", not "recording"
mock_revoke.assert_called_once_with("dvr-recording-66")

View file

@ -0,0 +1,379 @@
"""Tests for recording metadata endpoints and logo proxy negative cache.
Covers:
- update_metadata endpoint: title/description, user_edited flag, validation
- refresh_artwork endpoint: returns immediately, background thread behavior
- Logo proxy negative cache: cache hit/miss, expiry, eviction, success clears
"""
import time as time_mod
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording, Logo
from apps.channels.api_views import RecordingViewSet, LogoViewSet
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="metadata_test_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
# ---------------------------------------------------------------------------
# update_metadata endpoint
# ---------------------------------------------------------------------------
class UpdateMetadataTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/update-metadata/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=70, name="Meta Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _update(self, rec, data):
request = self.factory.post(
f"/api/channels/recordings/{rec.id}/update-metadata/",
data, format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "update_metadata"})
return view(request, pk=rec.id)
def _make_rec(self, custom_properties=None):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties=custom_properties or {},
)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_title_only(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": "My Show"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["title"], "My Show")
self.assertTrue(program["user_edited"])
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_description_only(self, _ws):
rec = self._make_rec({"program": {"title": "Existing Title"}})
response = self._update(rec, {"description": "A great episode"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["description"], "A great episode")
self.assertEqual(program["title"], "Existing Title")
self.assertTrue(program["user_edited"])
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_both_fields(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": "New Title", "description": "New Desc"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["title"], "New Title")
self.assertEqual(program["description"], "New Desc")
self.assertTrue(program["user_edited"])
def test_no_fields_returns_400(self):
rec = self._make_rec()
response = self._update(rec, {})
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_whitespace_trimmed(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": " Padded Title "})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["program"]["title"], "Padded Title")
def test_whitespace_only_title_returns_400(self):
"""Whitespace-only title and description should be rejected."""
rec = self._make_rec()
response = self._update(rec, {"title": " ", "description": " "})
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
def test_whitespace_only_title_with_valid_description(self):
"""Whitespace-only title is ignored; valid description is accepted."""
rec = self._make_rec({"program": {"title": "Original"}})
with patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None):
response = self._update(rec, {"title": " ", "description": "Valid desc"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
# Title should remain unchanged since the whitespace-only value is not applied
self.assertEqual(rec.custom_properties["program"]["title"], "Original")
self.assertEqual(rec.custom_properties["program"]["description"], "Valid desc")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_creates_program_dict_when_absent(self, _ws):
"""Recording with no program dict gets one created."""
rec = self._make_rec({"status": "completed"})
response = self._update(rec, {"title": "Brand New"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertIn("program", rec.custom_properties)
self.assertEqual(rec.custom_properties["program"]["title"], "Brand New")
def test_returns_404_for_nonexistent(self):
request = self.factory.post(
"/api/channels/recordings/99999/update-metadata/",
{"title": "Ghost"}, format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "update_metadata"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_sends_websocket_event(self, mock_ws):
rec = self._make_rec()
self._update(rec, {"title": "WS Test"})
mock_ws.assert_called_once()
payload = mock_ws.call_args[0][2]
self.assertEqual(payload["type"], "recording_updated")
self.assertEqual(payload["recording_id"], rec.id)
@patch("core.utils.send_websocket_update", side_effect=Exception("WS down"))
def test_ws_failure_does_not_fail_request(self, _ws):
"""WebSocket errors are silenced — the save still succeeds."""
rec = self._make_rec()
response = self._update(rec, {"title": "Resilient"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["program"]["title"], "Resilient")
# ---------------------------------------------------------------------------
# refresh_artwork endpoint
# ---------------------------------------------------------------------------
class RefreshArtworkTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/refresh-artwork/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=71, name="Artwork Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _refresh(self, rec):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
return view(request, pk=rec.id)
def _make_rec(self, custom_properties=None):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties=custom_properties or {},
)
@patch("threading.Thread")
def test_returns_200_immediately(self, mock_thread):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
response = self._refresh(rec)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data.get("success"))
@patch("threading.Thread")
def test_spawns_background_thread(self, mock_thread):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
self._refresh(rec)
mock_thread.assert_called_once()
self.assertTrue(mock_thread.call_args[1].get("daemon", False))
mock_thread.return_value.start.assert_called_once()
def test_returns_404_for_nonexistent(self):
request = self.factory.post("/api/channels/recordings/99999/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("django.db.close_old_connections")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_no_downgrade_to_channel_logo(self, _ws, _close):
"""When the pipeline returns the channel's own logo, existing poster is preserved."""
logo = Logo.objects.create(name="Channel Logo", url="https://example.com/ch.png")
self.channel.logo = logo
self.channel.save()
rec = self._make_rec({
"poster_logo_id": 999, # existing real poster
"poster_url": "https://tmdb.com/real-poster.jpg",
})
with patch("apps.channels.tasks._resolve_poster_for_program",
return_value=(logo.id, None)):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
# Run synchronously by intercepting the thread
captured_fn = None
def capture_thread(*args, **kwargs):
nonlocal captured_fn
captured_fn = kwargs.get("target") or args[0]
mock = MagicMock()
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
return mock
with patch("threading.Thread", side_effect=capture_thread):
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
view(request, pk=rec.id)
rec.refresh_from_db()
# Existing poster should be preserved — not downgraded to channel logo
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 999)
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/real-poster.jpg")
@patch("django.db.close_old_connections")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_upgrade_from_no_poster(self, _ws, _close):
"""When a recording has no poster and the pipeline finds one, it gets updated."""
rec = self._make_rec({"program": {"title": "Some Show", "id": 42}})
with patch("apps.channels.tasks._resolve_poster_for_program",
return_value=(555, "https://tmdb.com/new-poster.jpg")):
captured_fn = None
def capture_thread(*args, **kwargs):
nonlocal captured_fn
captured_fn = kwargs.get("target") or args[0]
mock = MagicMock()
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
return mock
with patch("threading.Thread", side_effect=capture_thread):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
view(request, pk=rec.id)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 555)
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/new-poster.jpg")
# ---------------------------------------------------------------------------
# Logo proxy negative cache
# ---------------------------------------------------------------------------
class LogoNegativeCacheTests(TestCase):
"""Tests for the _logo_fetch_failures negative cache in LogoViewSet.cache()."""
def setUp(self):
from apps.channels import api_views
self._failures = api_views._logo_fetch_failures
self._failures.clear()
self.factory = APIRequestFactory()
self.user = _make_admin()
def _fetch_logo(self, logo):
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
force_authenticate(request, user=self.user)
view = LogoViewSet.as_view({"get": "cache"})
return view(request, pk=logo.id)
def test_failed_url_cached_on_non_200(self):
"""Non-200 response adds URL to negative cache."""
logo = Logo.objects.create(name="Dead Logo", url="https://dead-cdn.com/logo.png")
mock_resp = MagicMock(status_code=404)
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
self.assertIn("https://dead-cdn.com/logo.png", self._failures)
def test_cached_failure_returns_404_immediately(self):
"""Subsequent request for a cached-failed URL returns 404 without making a request."""
logo = Logo.objects.create(name="Cached Fail", url="https://cached-fail.com/logo.png")
self._failures["https://cached-fail.com/logo.png"] = time_mod.monotonic() + 300
with patch("apps.channels.api_views.requests.get") as mock_get:
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
mock_get.assert_not_called()
def test_expired_cache_entry_allows_retry(self):
"""After TTL expires, a new request is made."""
logo = Logo.objects.create(name="Expired", url="https://expired.com/logo.png")
self._failures["https://expired.com/logo.png"] = time_mod.monotonic() - 1 # already expired
mock_resp = MagicMock(status_code=200)
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.iter_content = MagicMock(return_value=[b"img"])
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 200)
def test_success_clears_previous_failure(self):
"""A successful fetch removes the URL from the failure cache."""
url = "https://recovered.com/logo.png"
logo = Logo.objects.create(name="Recovered", url=url)
self._failures[url] = time_mod.monotonic() - 1 # expired
mock_resp = MagicMock(status_code=200)
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.iter_content = MagicMock(return_value=[b"img"])
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
self._fetch_logo(logo)
self.assertNotIn(url, self._failures)
def test_request_exception_cached(self):
"""Network errors are cached the same as non-200 responses."""
import requests
logo = Logo.objects.create(name="Timeout", url="https://timeout.com/logo.png")
with patch("apps.channels.api_views.requests.get", side_effect=requests.Timeout("timed out")), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
self.assertIn("https://timeout.com/logo.png", self._failures)
def test_eviction_when_cache_exceeds_256(self):
"""Stale entries are evicted when the cache grows past 256."""
now = time_mod.monotonic()
# Fill with 257 expired entries
for i in range(257):
self._failures[f"https://old-{i}.com/x.png"] = now - 1 # already expired
logo = Logo.objects.create(name="Trigger", url="https://trigger-evict.com/logo.png")
import requests
with patch("apps.channels.api_views.requests.get", side_effect=requests.ConnectionError("fail")), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
self._fetch_logo(logo)
# Expired entries should be evicted
old_entries = [k for k in self._failures if k.startswith("https://old-")]
self.assertEqual(len(old_entries), 0)
# New failure entry should exist
self.assertIn("https://trigger-evict.com/logo.png", self._failures)

View file

@ -0,0 +1,524 @@
"""Tests for recent DVR fixes.
Covers:
1. Collision avoidance: _build_output_paths checks both .mkv and .ts files
2. Logo guard: _resolve_poster_for_program skips external APIs when title channel name
3. Recording status lifecycle: status transitions visible via API
4. Concat flags: error-tolerant ffmpeg flags used for segment concatenation
5. Recovery skip-list: "recording" status NOT in terminal skip list
"""
import os
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="dvr_fixes_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
def _make_channel(name="Test Channel", number=100):
return Channel.objects.create(channel_number=number, name=name)
def _make_recording(channel, **overrides):
now = timezone.now()
defaults = {
"channel": channel,
"start_time": now - timedelta(hours=1),
"end_time": now + timedelta(hours=1),
"custom_properties": {},
}
defaults.update(overrides)
return Recording.objects.create(**defaults)
# =========================================================================
# 1. Collision avoidance — _build_output_paths
# =========================================================================
class CollisionAvoidanceTests(TestCase):
"""_build_output_paths must increment the filename counter when
EITHER the .mkv OR the .ts file already exists with size > 0."""
def _call(self, channel, program, start, end):
from apps.channels.tasks import _build_output_paths
return _build_output_paths(channel, program, start, end)
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_no_collision_when_nothing_exists(self, _tv, _fb):
"""Fresh path — no files exist, counter stays at 1."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
raise OSError("No such file")
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
# Should NOT have a _2 suffix
self.assertNotIn("_2", final)
self.assertTrue(final.endswith(".mkv"))
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb):
"""Pre-restart scenario: MKV is 0-byte placeholder, TS has real data.
The old code only checked MKV size, so it would reuse the path.
The fix also checks TS, so it must increment."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
if "_2" in path:
raise OSError("No such file")
result = MagicMock()
if path.endswith('.mkv'):
result.st_size = 0 # MKV is 0-byte placeholder
elif path.endswith('.ts'):
result.st_size = 5000000 # TS has real data from pre-restart
else:
result.st_size = 0
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
# Must have incremented to _2
self.assertIn("_2", final, "Should increment counter when TS file has data")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_collision_when_mkv_has_data(self, _tv, _fb):
"""Standard collision: MKV file has data, should increment."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
if "_2" in path:
raise OSError("No such file")
result = MagicMock()
if path.endswith('.mkv'):
result.st_size = 1000000 # MKV has data
else:
result.st_size = 0
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertIn("_2", final, "Should increment counter when MKV file has data")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_no_collision_when_both_zero_bytes(self, _tv, _fb):
"""Both MKV and TS exist but are 0 bytes — no collision."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
result = MagicMock()
result.st_size = 0 # All files empty
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertNotIn("_2", final, "Should NOT increment when all files are empty")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_collision_increments_to_3_when_2_also_occupied(self, _tv, _fb):
"""When both base and _2 are occupied, should go to _3."""
ch = MagicMock(name="TestCh")
ch.name = "TestCh"
program = {"title": "My Show"}
now = timezone.now()
def mock_stat(path):
if "_3" in path:
raise OSError("No such file")
result = MagicMock()
if path.endswith('.ts'):
result.st_size = 5000000
else:
result.st_size = 0
return result
with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied")
# =========================================================================
# 2. Logo guard — _resolve_poster_for_program
# =========================================================================
class LogoGuardTests(TestCase):
"""When the program title matches the channel name, external API
searches (VOD, TMDB, OMDb, TVMaze, iTunes) must be skipped."""
def _call(self, channel_name, program, channel_logo_id=None):
from apps.channels.tasks import _resolve_poster_for_program
return _resolve_poster_for_program(channel_name, program, channel_logo_id)
@patch("apps.channels.tasks.requests.get")
def test_channel_name_as_title_skips_external_apis(self, mock_get):
"""Title = 'USA A&E SD*', channel = 'USA A&E SD*' → no external calls."""
program = {"title": "USA A&E SD*"}
logo_id, url = self._call("USA A&E SD*", program, channel_logo_id=42)
# Should NOT have called any external APIs
mock_get.assert_not_called()
# Should fall back to channel logo
self.assertEqual(logo_id, 42)
self.assertIsNone(url)
@patch("apps.channels.tasks.requests.get")
def test_channel_name_normalized_match(self, mock_get):
"""Title = 'fox news', channel = 'FOX-News*' → normalized match, skip APIs."""
program = {"title": "fox news"}
logo_id, url = self._call("FOX-News*", program, channel_logo_id=99)
mock_get.assert_not_called()
self.assertEqual(logo_id, 99)
@patch("apps.channels.tasks.requests.get")
def test_real_title_still_searched(self, mock_get):
"""Title = 'Breaking Bad' on channel 'AMC' → should try external APIs."""
# Mock TVMaze returning a result
mock_resp = MagicMock(ok=True, status_code=200)
mock_resp.json.return_value = {
"image": {"original": "https://tvmaze.com/breaking-bad.jpg"}
}
mock_get.return_value = mock_resp
program = {"title": "Breaking Bad"}
logo_id, url = self._call("AMC", program)
# Should have made at least one external API call
self.assertTrue(mock_get.called, "Should search external APIs for real titles")
self.assertIsNotNone(url)
@patch("apps.channels.tasks.requests.get")
def test_no_title_skips_to_channel_logo(self, mock_get):
"""No title at all → falls through to channel logo, no API calls."""
program = {}
logo_id, url = self._call("SomeChannel", program, channel_logo_id=55)
mock_get.assert_not_called()
self.assertEqual(logo_id, 55)
@patch("apps.channels.tasks.requests.get")
def test_epg_image_still_used_even_when_title_is_channel_name(self, mock_get):
"""Even when title = channel name, Stage 1 (EPG images) should still work."""
from apps.epg.models import ProgramData, EPGSource, EPGData
# Create an EPG source + EPGData entry + program with an icon URL
epg_source = EPGSource.objects.create(source_type="xmltv", name="Test EPG")
epg_data = EPGData.objects.create(tvg_id="test.ch", epg_source=epg_source)
prog = ProgramData.objects.create(
epg=epg_data,
title="Test Channel HD",
start_time=timezone.now() - timedelta(hours=1),
end_time=timezone.now() + timedelta(hours=1),
custom_properties={"icon": "https://epg-cdn.com/test-icon.png"},
)
program = {"title": "Test Channel HD", "id": prog.id}
# Mock _validate_url to return True for the icon URL
with patch("apps.channels.tasks._validate_url", return_value=True):
logo_id, url = self._call("Test Channel HD", program, channel_logo_id=10)
# EPG icon should still be used (Stage 1 doesn't depend on title guard)
self.assertEqual(url, "https://epg-cdn.com/test-icon.png")
mock_get.assert_not_called()
# =========================================================================
# 3. Recording status lifecycle via API
# =========================================================================
class RecordingStatusLifecycleTests(TestCase):
"""Verify recording status transitions and that terminal recordings
are properly filterable (supports the red-dot fix in guideUtils)."""
def setUp(self):
self.channel = _make_channel("Status Test Channel", 200)
self.user = _make_admin()
self.factory = APIRequestFactory()
def _list_recordings(self):
from apps.channels.api_views import RecordingViewSet
request = self.factory.get("/api/channels/recordings/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"get": "list"})
return view(request)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_stopped_recording_has_terminal_status(self, _ws):
"""After stop, custom_properties.status = 'stopped'."""
from apps.channels.api_views import RecordingViewSet
rec = _make_recording(self.channel, custom_properties={
"status": "recording",
"program": {"id": 1, "title": "Live Show"},
})
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "stop"})
with patch("apps.channels.signals.revoke_task"):
response = view(request, pk=rec.id)
self.assertIn(response.status_code, [200, 204])
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("status"), "stopped")
def test_listing_includes_status_in_custom_properties(self):
"""API listing returns custom_properties with status field."""
_make_recording(self.channel, custom_properties={
"status": "recording",
"program": {"id": 1, "title": "Recording Show"},
})
_make_recording(self.channel, custom_properties={
"status": "stopped",
"program": {"id": 2, "title": "Stopped Show"},
})
response = self._list_recordings()
self.assertEqual(response.status_code, 200)
statuses = [r["custom_properties"].get("status") for r in response.data]
self.assertIn("recording", statuses)
self.assertIn("stopped", statuses)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_delete_recording_removes_from_listing(self, _ws):
"""Deleting a recording removes it from the listing entirely."""
from apps.channels.api_views import RecordingViewSet
rec = _make_recording(self.channel, custom_properties={
"status": "stopped",
"program": {"id": 3, "title": "To Delete"},
})
rec_id = rec.id
request = self.factory.delete(f"/api/channels/recordings/{rec_id}/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"delete": "destroy"})
with patch("apps.channels.signals.revoke_task"):
response = view(request, pk=rec_id)
self.assertIn(response.status_code, [200, 204])
self.assertFalse(Recording.objects.filter(id=rec_id).exists())
# =========================================================================
# 4. Concat flags — error-tolerant ffmpeg
# =========================================================================
class ConcatFlagsTests(TestCase):
"""Verify that the finalize phase uses error-tolerant ffmpeg flags
when concatenating pre-restart segments."""
def test_concat_command_includes_error_tolerant_flags(self):
"""Inspect the source code to confirm error-tolerant flags are present.
This is a static analysis test no ffmpeg execution needed."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
# The concat subprocess.run call must include these flags
self.assertIn("+genpts+igndts+discardcorrupt", source,
"Concat must use +genpts+igndts+discardcorrupt fflags")
self.assertIn("ignore_err", source,
"Concat must use -err_detect ignore_err")
self.assertIn("-f", source)
self.assertIn("concat", source)
def test_concat_goes_directly_to_mkv(self):
"""Concat must produce MKV directly (not intermediate .ts) to
preserve timestamp boundaries and avoid playback freeze at splice."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
# Must contain reset_timestamps for proper segment boundary handling
self.assertIn("reset_timestamps", source,
"Concat must use -reset_timestamps 1 for seamless seeking")
# Must write directly to final_path (MKV), not an intermediate .ts
self.assertIn("_concat_did_remux", source,
"Concat path must set flag to skip separate remux step")
def test_segment_time_metadata_present(self):
"""Verify concat uses -segment_time_metadata for boundary awareness."""
import inspect
from apps.channels.tasks import run_recording
source = inspect.getsource(run_recording)
self.assertIn("segment_time_metadata", source,
"Concat must use -segment_time_metadata 1 for segment boundary handling")
# =========================================================================
# 5. Recovery skip-list
# =========================================================================
class RecoverySkipListTests(TestCase):
"""Verify that the recovery function does NOT skip 'recording' status,
since that's the exact status recordings have when the server crashes."""
def test_recording_status_not_in_skip_list(self):
"""Inspect recover_recordings_on_startup to ensure 'recording' is
NOT treated as a terminal/skip state."""
import inspect
from apps.channels.tasks import recover_recordings_on_startup
source = inspect.getsource(recover_recordings_on_startup)
# Find the skip condition line
# It should be: if current_status in ("completed", "stopped"):
# NOT: if current_status in ("completed", "stopped", "recording"):
lines = source.split('\n')
skip_line = None
for line in lines:
if 'current_status in' in line and ('completed' in line or 'stopped' in line):
skip_line = line.strip()
break
self.assertIsNotNone(skip_line, "Should find the skip-list condition")
self.assertNotIn('"recording"', skip_line,
"Skip list must NOT contain 'recording'"
"that's the status of crashed mid-stream recordings that need recovery")
@patch("core.utils.RedisClient")
@patch("apps.channels.tasks.run_recording")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_recovery_processes_recording_status(self, _ws, mock_run, mock_redis_cls):
"""A recording with status='recording' should be recovered, not skipped."""
mock_redis_conn = MagicMock()
mock_redis_conn.set.return_value = True # Acquire lock
mock_redis_cls.get_client.return_value = mock_redis_conn
channel = _make_channel("Recovery Test", 300)
now = timezone.now()
rec = _make_recording(channel, custom_properties={
"status": "recording",
"program": {"title": "Crashed Show"},
}, end_time=now + timedelta(hours=2))
from apps.channels.tasks import recover_recordings_on_startup
with patch("apps.channels.signals.revoke_task"):
result = recover_recordings_on_startup()
# The recording should have been dispatched for recovery
self.assertTrue(mock_run.apply_async.called,
"Recording with status='recording' should be dispatched for recovery")
@patch("core.utils.RedisClient")
@patch("apps.channels.tasks.run_recording")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_recovery_skips_stopped_recordings(self, _ws, mock_run, mock_redis_cls):
"""A recording with status='stopped' should be skipped by recovery."""
mock_redis_conn = MagicMock()
mock_redis_conn.set.return_value = True
mock_redis_cls.get_client.return_value = mock_redis_conn
channel = _make_channel("Recovery Skip Test", 301)
now = timezone.now()
rec = _make_recording(channel, custom_properties={
"status": "stopped",
"program": {"title": "Finished Show"},
}, end_time=now + timedelta(hours=2))
from apps.channels.tasks import recover_recordings_on_startup
with patch("apps.channels.signals.revoke_task"):
recover_recordings_on_startup()
# Should NOT have dispatched a recovery task
mock_run.apply_async.assert_not_called()
# =========================================================================
# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId)
# =========================================================================
class MapRecordingsByProgramIdTests(TestCase):
"""These test the BACKEND side — confirming that recording status
is preserved in the API response so the frontend can filter on it.
The actual frontend filtering is covered by frontend/src/pages/__tests__/DVR.test.jsx
and the guideUtils code, but we verify the data contract here."""
def test_recording_custom_properties_status_persisted(self):
"""Recording status in custom_properties survives save/load cycle."""
channel = _make_channel("Red Dot Test", 400)
rec = _make_recording(channel, custom_properties={
"status": "stopped",
"program": {"id": 42, "title": "A Show"},
})
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], "stopped")
def test_terminal_statuses_are_well_defined(self):
"""Verify the terminal status set matches what the frontend uses."""
# These are the statuses that should NOT show a red dot in the Guide
terminal = {"stopped", "completed", "interrupted", "failed"}
channel = _make_channel("Terminal Status Test", 410)
# Verify each status is a valid recording status
for status in terminal:
rec = _make_recording(channel, custom_properties={
"status": status,
"program": {"id": 100, "title": "Test"},
})
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["status"], status)

View file

@ -0,0 +1,585 @@
"""Tests for DVR recording scheduling with ClockedSchedule.
Uses ClockedSchedule instead of apply_async with countdown because Redis
visibility_timeout (default 3600s) causes task redelivery for long countdowns,
leading to duplicate recordings.
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from django_celery_beat.models import ClockedSchedule, PeriodicTask
from apps.channels.models import Channel, Recording
from apps.channels.signals import (
schedule_recording_task,
revoke_task,
_dvr_task_name,
)
class ScheduleRecordingTaskTests(TestCase):
"""Tests for schedule_recording_task()."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.run_recording")
def test_future_recording_creates_periodic_task(self, mock_run_recording):
"""Recordings in the future create a ClockedSchedule + PeriodicTask."""
future_time = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future_time,
end_time=future_time + timedelta(hours=1),
)
task_id = schedule_recording_task(rec, eta=future_time)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
pt = PeriodicTask.objects.get(name=expected_name)
self.assertTrue(pt.one_off)
self.assertTrue(pt.enabled)
self.assertEqual(pt.task, "apps.channels.tasks.run_recording")
self.assertIsNotNone(pt.clocked)
# apply_async should not have been called
mock_run_recording.apply_async.assert_not_called()
@patch("apps.channels.signals.run_recording")
def test_immediate_recording_creates_periodic_task(self, mock_run_recording):
"""Recordings starting now also use ClockedSchedule for consistency."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now + timedelta(hours=1),
)
task_id = schedule_recording_task(rec, eta=now)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
self.assertTrue(PeriodicTask.objects.filter(name=expected_name).exists())
@patch("apps.channels.signals.run_recording")
def test_past_start_time_clamps_to_now(self, mock_run_recording):
"""Recordings with past start_time get clamped to now."""
past_time = timezone.now() - timedelta(minutes=5)
rec = Recording.objects.create(
channel=self.channel,
start_time=past_time,
end_time=timezone.now() + timedelta(hours=1),
)
task_id = schedule_recording_task(rec, eta=past_time)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
pt = PeriodicTask.objects.get(name=expected_name)
# Clocked time should be >= now
self.assertGreaterEqual(pt.clocked.clocked_time, past_time)
@patch("apps.channels.signals.run_recording")
def test_reschedule_updates_existing_periodic_task(self, mock_run_recording):
"""Calling schedule_recording_task twice updates the existing PeriodicTask."""
future_time = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future_time,
end_time=future_time + timedelta(hours=1),
)
schedule_recording_task(rec, eta=future_time)
# Reschedule with a different time
new_eta = future_time + timedelta(hours=1)
schedule_recording_task(rec, eta=new_eta)
# Should still be exactly one PeriodicTask
task_name = f"dvr-recording-{rec.id}"
self.assertEqual(PeriodicTask.objects.filter(name=task_name).count(), 1)
@patch("apps.channels.signals.run_recording")
def test_naive_eta_is_made_aware(self, mock_run_recording):
"""A naive (timezone-unaware) eta is made timezone-aware."""
from datetime import datetime
naive_eta = datetime(2030, 6, 15, 14, 0, 0)
rec = Recording.objects.create(
channel=self.channel,
start_time=timezone.now() + timedelta(hours=1),
end_time=timezone.now() + timedelta(hours=2),
)
task_id = schedule_recording_task(rec, eta=naive_eta)
expected_name = f"dvr-recording-{rec.id}"
self.assertEqual(task_id, expected_name)
pt = PeriodicTask.objects.get(name=expected_name)
self.assertTrue(timezone.is_aware(pt.clocked.clocked_time))
class RevokeTaskTests(TestCase):
"""Tests for revoke_task()."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
def test_revoke_deletes_periodic_task_and_clocked_schedule(self):
"""revoke_task deletes the PeriodicTask and orphaned ClockedSchedule."""
eta = timezone.now() + timedelta(hours=5)
clocked = ClockedSchedule.objects.create(clocked_time=eta)
PeriodicTask.objects.create(
name="dvr-recording-10",
task="apps.channels.tasks.run_recording",
clocked=clocked,
one_off=True,
enabled=True,
)
revoke_task("dvr-recording-10")
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
self.assertFalse(ClockedSchedule.objects.filter(id=clocked.id).exists())
def test_revoke_keeps_shared_clocked_schedule(self):
"""ClockedSchedule is kept if another PeriodicTask still references it."""
eta = timezone.now() + timedelta(hours=5)
clocked = ClockedSchedule.objects.create(clocked_time=eta)
PeriodicTask.objects.create(
name="dvr-recording-10",
task="apps.channels.tasks.run_recording",
clocked=clocked,
one_off=True,
)
PeriodicTask.objects.create(
name="dvr-recording-11",
task="apps.channels.tasks.run_recording",
clocked=clocked,
one_off=True,
)
revoke_task("dvr-recording-10")
self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists())
self.assertTrue(ClockedSchedule.objects.filter(id=clocked.id).exists())
@patch("apps.channels.signals.AsyncResult")
def test_revoke_falls_back_to_async_result_for_legacy_ids(self, mock_async_result):
"""revoke_task falls back to AsyncResult.revoke() for old-style UUIDs."""
revoke_task("550e8400-e29b-41d4-a716-446655440000")
mock_async_result.assert_called_once_with("550e8400-e29b-41d4-a716-446655440000")
mock_async_result.return_value.revoke.assert_called_once()
def test_revoke_none_is_noop(self):
"""revoke_task(None) does nothing."""
revoke_task(None) # Should not raise
def test_revoke_empty_string_is_noop(self):
"""revoke_task('') does nothing."""
revoke_task("") # Should not raise
class DvrTaskNameTests(TestCase):
"""Tests for the naming convention helper."""
def test_task_name_format(self):
self.assertEqual(_dvr_task_name(42), "dvr-recording-42")
def test_task_name_fits_in_charfield(self):
name = _dvr_task_name(999999999)
self.assertLessEqual(len(name), 255)
class SignalIntegrationTests(TestCase):
"""Integration tests for the post_save / post_delete signal handlers."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_save_creates_periodic_task_for_future_recording(self, mock_artwork):
"""Saving a future Recording creates a PeriodicTask via post_save signal."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
)
rec.refresh_from_db()
task_name = f"dvr-recording-{rec.id}"
self.assertEqual(rec.task_id, task_name)
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_delete_removes_periodic_task(self, mock_artwork):
"""Deleting a Recording removes its PeriodicTask."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
)
rec.refresh_from_db()
task_name = rec.task_id
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
rec.delete()
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_bulk_delete_cleans_up_all_periodic_tasks(self, mock_artwork):
"""Bulk deleting recordings cleans up all their PeriodicTasks."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec_ids = []
for i in range(5):
rec = Recording.objects.create(
channel=self.channel,
start_time=future + timedelta(hours=i),
end_time=future + timedelta(hours=i + 1),
)
rec_ids.append(rec.id)
for rid in rec_ids:
self.assertTrue(
PeriodicTask.objects.filter(name=f"dvr-recording-{rid}").exists()
)
Recording.objects.filter(channel=self.channel).delete()
self.assertEqual(
PeriodicTask.objects.filter(name__startswith="dvr-recording-").count(), 0
)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_save_schedules_currently_playing_recording(self, mock_artwork):
"""A recording with past start_time but future end_time schedules immediately."""
mock_artwork.apply_async.return_value = MagicMock()
past_start = timezone.now() - timedelta(minutes=30)
future_end = timezone.now() + timedelta(minutes=30)
rec = Recording.objects.create(
channel=self.channel,
start_time=past_start,
end_time=future_end,
)
rec.refresh_from_db()
task_name = f"dvr-recording-{rec.id}"
self.assertEqual(rec.task_id, task_name)
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_post_save_skips_fully_past_recording(self, mock_artwork):
"""A recording with both start_time and end_time in the past is not scheduled."""
mock_artwork.apply_async.return_value = MagicMock()
past_start = timezone.now() - timedelta(hours=2)
past_end = timezone.now() - timedelta(hours=1)
rec = Recording.objects.create(
channel=self.channel,
start_time=past_start,
end_time=past_end,
)
rec.refresh_from_db()
self.assertIsNone(rec.task_id)
self.assertFalse(
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_pre_save_revokes_on_time_change(self, mock_artwork):
"""Changing a recording's start_time revokes the old task and creates a new one."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
)
rec.refresh_from_db()
old_task_name = rec.task_id
self.assertTrue(PeriodicTask.objects.filter(name=old_task_name).exists())
# Change the start time — pre_save clears task_id, post_save reschedules
new_future = future + timedelta(hours=3)
rec.start_time = new_future
rec.end_time = new_future + timedelta(hours=1)
rec.save()
rec.refresh_from_db()
# Old PeriodicTask should be deleted; new one should exist
self.assertIsNotNone(rec.task_id)
self.assertTrue(
PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists()
)
class IdempotencyGuardTests(TestCase):
"""Tests for the idempotency guard in run_recording()."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
@patch("apps.channels.tasks.get_channel_layer")
def test_skips_if_already_recording(self, mock_layer):
"""run_recording returns early if status is already 'recording'."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording", "started_at": str(now)},
)
from apps.channels.tasks import run_recording as run_rec_task
result = run_rec_task(rec.id, self.channel.id, str(now), str(now + timedelta(hours=1)))
self.assertIsNone(result)
# get_channel_layer should not have been called (returned before)
mock_layer.assert_not_called()
@patch("apps.channels.tasks.get_channel_layer")
def test_skips_if_already_completed(self, mock_layer):
"""run_recording returns early if status is already 'completed'."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=2),
end_time=now - timedelta(hours=1),
custom_properties={"status": "completed"},
)
from apps.channels.tasks import run_recording as run_rec_task
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
self.assertIsNone(result)
mock_layer.assert_not_called()
@patch("apps.channels.tasks.get_channel_layer")
def test_skips_if_already_stopped(self, mock_layer):
"""run_recording returns early if status is already 'stopped' (user stopped it early)."""
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": "stopped", "stopped_at": str(now)},
)
from apps.channels.tasks import run_recording as run_rec_task
result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time))
self.assertIsNone(result)
mock_layer.assert_not_called()
class ArtworkPrefetchSignalGuardTests(TestCase):
"""Tests that the post_save signal does not schedule artwork prefetch when
the recording is in an active or terminal state."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_not_scheduled_when_status_recording(self, mock_artwork):
"""post_save must NOT schedule artwork prefetch when status='recording'
to prevent a race that overwrites the running task's status updates."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={"status": "recording"},
)
# Simulate a save that run_recording itself might do mid-recording
rec.custom_properties = {"status": "recording", "file_path": "/data/recordings/test.mkv"}
rec.save(update_fields=["custom_properties"])
# apply_async was not called for the "recording" save
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_not_scheduled_when_status_completed(self, mock_artwork):
"""post_save must NOT schedule artwork prefetch when status='completed'."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={"status": "completed"},
)
rec.custom_properties = {"status": "completed"}
rec.save(update_fields=["custom_properties"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_not_scheduled_when_status_stopped(self, mock_artwork):
"""post_save must NOT schedule artwork prefetch when status='stopped'."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={"status": "stopped"},
)
rec.custom_properties = {"status": "stopped"}
rec.save(update_fields=["custom_properties"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_artwork_prefetch_scheduled_for_new_upcoming_recording(self, mock_artwork):
"""post_save SHOULD schedule artwork prefetch for a newly created upcoming recording."""
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={}, # no status yet — should trigger prefetch
)
self.assertTrue(mock_artwork.apply_async.called)
class DestroyDvrClientIsolationTests(TestCase):
"""Tests that deleting a recording only stops DVR clients when the
recording is actively streaming never for completed/upcoming recordings
that could share a channel with an unrelated in-progress recording."""
def setUp(self):
from django.contrib.auth import get_user_model
from rest_framework.test import APIRequestFactory, force_authenticate
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
User = get_user_model()
self.user = User.objects.create_user(
username="dvr_test_admin", password="pass",
user_level=User.UserLevel.ADMIN,
)
self.factory = APIRequestFactory()
self.force_authenticate = force_authenticate
def _delete_recording(self, rec):
from apps.channels.api_views import RecordingViewSet
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
self.force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"delete": "destroy"})
return view(request, pk=rec.id)
@patch("apps.channels.api_views._stop_dvr_clients")
def test_destroy_completed_recording_does_not_stop_dvr_clients(self, mock_stop):
"""Deleting a completed recording must NOT call _stop_dvr_clients."""
rec = Recording.objects.create(
channel=self.channel,
start_time=timezone.now() - timedelta(hours=2),
end_time=timezone.now() - timedelta(hours=1),
custom_properties={"status": "completed", "file_path": "/data/recordings/test.mkv"},
)
self._delete_recording(rec)
mock_stop.assert_not_called()
@patch("apps.channels.api_views._stop_dvr_clients")
def test_destroy_upcoming_recording_does_not_stop_dvr_clients(self, mock_stop):
"""Deleting an upcoming (scheduled) recording must NOT call _stop_dvr_clients."""
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={},
)
self._delete_recording(rec)
mock_stop.assert_not_called()
@patch("apps.channels.api_views._stop_dvr_clients")
def test_destroy_active_recording_does_stop_dvr_clients(self, mock_stop):
"""Deleting an in-progress recording MUST call _stop_dvr_clients."""
mock_stop.return_value = 1
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=5),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
self._delete_recording(rec)
mock_stop.assert_called_once_with(str(self.channel.uuid), recording_id=rec.id)
class PeriodicTaskCleanupOnExecutionTests(TestCase):
"""Tests for PeriodicTask cleanup when run_recording starts."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=1, name="Test Channel")
def tearDown(self):
PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete()
ClockedSchedule.objects.all().delete()
@patch("apps.channels.signals.prefetch_recording_artwork")
@patch("apps.channels.tasks.get_channel_layer")
def test_periodic_task_cleaned_up_on_execution(self, mock_layer, mock_artwork):
"""When run_recording executes, it deletes its own PeriodicTask."""
mock_layer.return_value = MagicMock()
mock_artwork.apply_async.return_value = MagicMock()
future = timezone.now() + timedelta(hours=2)
rec = Recording.objects.create(
channel=self.channel,
start_time=future,
end_time=future + timedelta(hours=1),
custom_properties={},
)
# post_save signal should have created the PeriodicTask
task_name = f"dvr-recording-{rec.id}"
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
pt = PeriodicTask.objects.get(name=task_name)
clocked_id = pt.clocked_id
from apps.channels.tasks import run_recording as run_rec_task
# This will proceed past guards, clean up the PeriodicTask, then
# eventually fail on the actual stream connection (expected)
try:
run_rec_task(rec.id, self.channel.id, str(future), str(future + timedelta(hours=1)))
except Exception:
pass
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
self.assertFalse(ClockedSchedule.objects.filter(id=clocked_id).exists())

View file

@ -0,0 +1,356 @@
"""Tests for the DVR Stop/Cancel feature set.
Covers:
- stop() endpoint
- destroy() was_in_progress field in recording_cancelled WebSocket event
- signals.py update_fields re-entrancy guard
- run_recording race guard before status write
- _stop_dvr_clients() DVR client isolation
"""
from datetime import timedelta
from unittest.mock import MagicMock, AsyncMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording
from apps.channels.api_views import RecordingViewSet, _stop_dvr_clients
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="stop_test_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
def _async_channel_layer_mock():
layer = MagicMock()
layer.group_send = AsyncMock()
return layer
class StopEndpointTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/stop/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=99, name="Stop Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _stop(self, rec):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "stop"})
return view(request, pk=rec.id)
def _make_rec(self, status="recording"):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties={"status": status},
)
@patch("core.utils.send_websocket_update")
@patch("threading.Thread")
def test_stop_writes_status_to_db_before_returning(self, mock_thread, mock_ws):
"""DB write is synchronous — run_recording polls for this."""
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
response = self._stop(rec)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data.get("success"))
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("status"), "stopped")
@patch("core.utils.send_websocket_update")
@patch("threading.Thread")
def test_stop_writes_stopped_at_timestamp(self, mock_thread, mock_ws):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
self._stop(rec)
rec.refresh_from_db()
self.assertIn("stopped_at", rec.custom_properties)
def test_stop_calls_stop_dvr_clients_in_background(self):
"""stop() spawns a background thread whose target calls _stop_dvr_clients."""
rec = self._make_rec()
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop, \
patch("core.utils.send_websocket_update"), \
patch("threading.Thread") as mock_thread:
mock_thread.return_value.start = MagicMock()
self._stop(rec)
# Verify a daemon thread was spawned
mock_thread.assert_called_once()
thread_kwargs = mock_thread.call_args[1]
self.assertTrue(thread_kwargs.get("daemon"), "Thread must be daemon")
# Execute the captured target with DB connection close patched out
target = thread_kwargs["target"]
with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop2, \
patch("apps.channels.signals.revoke_task", side_effect=Exception("skip")), \
patch("django.db.connection") as mock_conn:
target()
self.assertTrue(mock_stop2.called)
args, kwargs = mock_stop2.call_args
actual_rec_id = kwargs.get("recording_id") or (args[1] if len(args) > 1 else None)
self.assertEqual(actual_rec_id, rec.id)
def test_stop_returns_404_for_nonexistent(self):
request = self.factory.post("/api/channels/recordings/99999/stop/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "stop"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("core.utils.send_websocket_update")
@patch("threading.Thread")
def test_stop_idempotent_on_already_stopped(self, mock_thread, mock_ws):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec(status="stopped")
self.assertEqual(self._stop(rec).status_code, 200)
class CancelDestroyWasInProgressTests(TestCase):
"""was_in_progress field in the recording_cancelled WebSocket event."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=98, name="Cancel Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _delete(self, rec):
request = self.factory.delete(f"/api/channels/recordings/{rec.id}/")
force_authenticate(request, user=self.user)
return RecordingViewSet.as_view({"delete": "destroy"})(request, pk=rec.id)
@patch("apps.channels.api_views._stop_dvr_clients", return_value=1)
@patch("core.utils.send_websocket_update")
def test_in_progress_sends_was_in_progress_true(self, mock_ws, _):
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=10),
end_time=now + timedelta(hours=1),
custom_properties={"status": "recording"},
)
self._delete(rec)
payload = mock_ws.call_args[0][2]
self.assertEqual(payload["type"], "recording_cancelled")
self.assertTrue(payload["was_in_progress"])
@patch("core.utils.send_websocket_update")
def test_completed_sends_was_in_progress_false(self, mock_ws):
rec = Recording.objects.create(
channel=self.channel,
start_time=timezone.now() - timedelta(hours=2),
end_time=timezone.now() - timedelta(hours=1),
custom_properties={"status": "completed"},
)
self._delete(rec)
self.assertFalse(mock_ws.call_args[0][2]["was_in_progress"])
class SignalUpdateFieldsReentrancyGuardTests(TestCase):
"""update_fields guard in schedule_task_on_save prevents redundant WS events."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=97, name="Signal Guard Channel")
def _create_upcoming(self):
future = timezone.now() + timedelta(hours=2)
return Recording.objects.create(
channel=self.channel, start_time=future,
end_time=future + timedelta(hours=1), custom_properties={},
)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_custom_properties_save_skips_artwork(self, mock_artwork):
rec = self._create_upcoming()
mock_artwork.reset_mock()
rec.custom_properties = {"poster_url": "https://example.com/p.jpg"}
rec.save(update_fields=["custom_properties"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_task_id_save_skips_artwork(self, mock_artwork):
rec = self._create_upcoming()
mock_artwork.reset_mock()
rec.task_id = "dvr-recording-999"
rec.save(update_fields=["task_id"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_combined_metadata_save_skips_artwork(self, mock_artwork):
rec = self._create_upcoming()
mock_artwork.reset_mock()
rec.task_id = "dvr-recording-1000"
rec.custom_properties = {"poster_url": "x"}
rec.save(update_fields=["custom_properties", "task_id"])
mock_artwork.apply_async.assert_not_called()
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_creation_dispatches_artwork(self, mock_artwork):
mock_artwork.apply_async.return_value = MagicMock()
self._create_upcoming()
self.assertTrue(mock_artwork.apply_async.called)
@patch("apps.channels.signals.prefetch_recording_artwork")
def test_scheduling_field_update_dispatches_artwork(self, mock_artwork):
"""save(update_fields=['start_time']) is not a metadata save — dispatch runs."""
mock_artwork.apply_async.return_value = MagicMock()
rec = self._create_upcoming()
mock_artwork.reset_mock()
future = timezone.now() + timedelta(hours=3)
rec.start_time = future
rec.end_time = future + timedelta(hours=1)
rec.save(update_fields=["start_time", "end_time"])
mock_artwork.apply_async.assert_called()
class RunRecordingRaceGuardTests(TestCase):
"""Race guard: stop() fires between idempotency check and status write."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=96, name="Race Guard Channel")
def test_race_guard_exits_when_stopped_at_db_read(self):
"""If Recording.objects.get() shows 'stopped', the task must exit
without writing 'recording' to the DB."""
from apps.channels.tasks import run_recording as run_rec
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=1),
end_time=now + timedelta(hours=1),
custom_properties={},
)
mock_layer = _async_channel_layer_mock()
original_get = Recording.objects.get
def patched_get(*args, **kwargs):
obj = original_get(*args, **kwargs)
if kwargs.get("id") == rec.id or (args and args[0] == rec.id):
obj.custom_properties = {"status": "stopped"}
return obj
with patch("apps.channels.tasks.get_channel_layer", return_value=mock_layer), \
patch("core.utils.log_system_event", side_effect=Exception("skip")), \
patch.object(Recording.objects, "get", side_effect=patched_get):
result = run_rec(
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
)
self.assertIsNone(result)
rec.refresh_from_db()
self.assertNotEqual(
rec.custom_properties.get("status"), "recording",
"Race guard failed: task overwrote 'stopped' with 'recording'",
)
def test_idempotency_guard_catches_stopped_before_channel_layer(self):
"""When status='stopped' at the idempotency check, get_channel_layer is never called."""
from apps.channels.tasks import run_recording as run_rec
now = timezone.now()
rec = Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(minutes=5),
end_time=now + timedelta(hours=1),
custom_properties={"status": "stopped"},
)
with patch("apps.channels.tasks.get_channel_layer") as mock_get_layer:
result = run_rec(
rec.id, self.channel.id, str(rec.start_time), str(rec.end_time),
)
self.assertIsNone(result)
mock_get_layer.assert_not_called()
class StopDvrClientsTests(TestCase):
"""_stop_dvr_clients() DVR client isolation."""
def setUp(self):
self.channel = Channel.objects.create(channel_number=95, name="DVR Clients Channel")
self._redis = "core.utils.RedisClient"
self._sc = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_client"
self._sch = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_channel"
def _mock_redis(self, client_ids, ua_map):
r = MagicMock()
r.smembers.return_value = {c.encode() for c in client_ids}
def hget_side(key, field):
ks = key if isinstance(key, str) else key.decode("utf-8", errors="replace")
for cid, ua in ua_map.items():
if cid in ks:
return ua.encode() if isinstance(ua, str) else ua
return b""
r.hget.side_effect = hget_side
return r
def test_returns_zero_when_redis_none(self):
with patch(self._redis) as rc:
rc.get_client.return_value = None
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
def test_stops_only_matching_client_when_recording_id_given(self):
r = self._mock_redis(
["client-a", "client-b"],
{"client-a": "Dispatcharr-DVR/recording-42",
"client-b": "Dispatcharr-DVR/recording-99"},
)
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
result = _stop_dvr_clients(str(self.channel.uuid), recording_id=42)
self.assertEqual(result, 1)
stopped = [c[0][1] for c in sc.call_args_list]
self.assertIn("client-a", stopped)
self.assertNotIn("client-b", stopped)
def test_stops_all_dvr_clients_without_recording_id(self):
r = self._mock_redis(
["client-a", "client-b"],
{"client-a": "Dispatcharr-DVR/recording-42",
"client-b": "Dispatcharr-DVR/recording-99"},
)
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
result = _stop_dvr_clients(str(self.channel.uuid))
self.assertEqual(result, 2)
def test_skips_non_dvr_clients(self):
r = self._mock_redis(
["viewer", "dvr-client"],
{"viewer": "Mozilla/5.0", "dvr-client": "Dispatcharr-DVR/recording-1"},
)
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
result = _stop_dvr_clients(str(self.channel.uuid))
self.assertEqual(result, 1)
stopped = [c[0][1] for c in sc.call_args_list]
self.assertNotIn("viewer", stopped)
def test_returns_zero_for_empty_channel(self):
r = MagicMock()
r.smembers.return_value = set()
with patch(self._redis) as rc, patch(self._sc) as sc:
rc.get_client.return_value = r
self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0)
sc.assert_not_called()
def test_never_calls_stop_channel(self):
"""Must not stop the whole channel proxy — only individual clients."""
r = self._mock_redis(["dvr-1"], {"dvr-1": "Dispatcharr-DVR/recording-1"})
with patch(self._redis) as rc, patch(self._sc), patch(self._sch) as sch:
rc.get_client.return_value = r
_stop_dvr_clients(str(self.channel.uuid))
sch.assert_not_called()

View file

@ -0,0 +1,718 @@
"""Tests for series rule evaluation deduplication.
Unit tests verify the dedup logic in evaluate_series_rules_impl.
Integration tests exercise the full path: EPG refresh series rule
evaluation Recording creation post_save signal chain.
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel, Recording
from apps.epg.models import EPGSource, EPGData, ProgramData
from core.models import CoreSettings
def _set_series_rules(rules):
"""Helper to store series rules in CoreSettings."""
CoreSettings.set_dvr_series_rules(rules)
def _set_dvr_offsets(pre_min=0, post_min=0):
"""Helper to store DVR pre/post offsets."""
CoreSettings._update_group("dvr_settings", "DVR Settings", {
"pre_offset_minutes": pre_min,
"post_offset_minutes": post_min,
})
class SeriesRuleDedupBaseTestCase(TestCase):
"""Shared setup for series rule dedup tests."""
def setUp(self):
self.now = timezone.now()
self.epg_source = EPGSource.objects.create(
name="Test EPG", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test.channel.1",
name="Test Channel EPG",
epg_source=self.epg_source,
)
self.channel = Channel.objects.create(
channel_number=1, name="Test Channel", epg_data=self.epg
)
_set_series_rules([{
"tvg_id": "test.channel.1",
"mode": "all",
"title": "Test Show",
}])
_set_dvr_offsets(pre_min=0, post_min=0)
def _create_program(self, hours_from_now=1, title="Test Show",
sub_title="Episode 1", tvg_id="test.channel.1"):
"""Create a ProgramData at the given offset."""
start = self.now + timedelta(hours=hours_from_now)
end = start + timedelta(hours=1)
return ProgramData.objects.create(
epg=self.epg,
tvg_id=tvg_id,
start_time=start,
end_time=end,
title=title,
sub_title=sub_title,
)
def _simulate_epg_refresh(self, programs_data):
"""Delete all ProgramData and recreate with new IDs (simulates EPG refresh)."""
ProgramData.objects.filter(epg=self.epg).delete()
new_programs = []
for data in programs_data:
prog = ProgramData.objects.create(epg=self.epg, **data)
new_programs.append(prog)
return new_programs
def _program_data_for_refresh(self, prog):
"""Build the dict needed by _simulate_epg_refresh from a ProgramData."""
return {
"tvg_id": prog.tvg_id,
"start_time": prog.start_time,
"end_time": prog.end_time,
"title": prog.title,
"sub_title": prog.sub_title,
}
# ---------------------------------------------------------------------------
# Unit tests: dedup logic in evaluate_series_rules_impl
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class ProgramIdStabilityTests(SeriesRuleDedupBaseTestCase):
"""Verify dedup works after EPG refresh changes ProgramData IDs."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_no_duplicate_after_epg_refresh(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Same program should not be recorded twice after EPG refresh."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
old_id = prog.id
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
new_programs = self._simulate_epg_refresh(
[self._program_data_for_refresh(prog)]
)
self.assertNotEqual(old_id, new_programs[0].id)
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result2["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_no_duplicate_with_offsets_after_refresh(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Dedup works when DVR offsets shift Recording times away from program times."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=5)
prog = self._create_program(hours_from_now=2)
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
rec = Recording.objects.first()
self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5))
self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=5))
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_different_episodes_still_recorded(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Different episodes on the same channel should each get a recording."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2, sub_title="Episode 1")
self._create_program(hours_from_now=4, sub_title="Episode 2")
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 2)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_new_episode_after_refresh_is_recorded(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""A genuinely new episode appearing after EPG refresh should be recorded."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self._simulate_epg_refresh([
self._program_data_for_refresh(prog),
{
"tvg_id": "test.channel.1",
"start_time": prog.end_time,
"end_time": prog.end_time + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 2",
},
])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
self.assertEqual(result2["scheduled"], 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_multiple_epg_refreshes_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Multiple consecutive EPG refreshes should not accumulate duplicates."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
for _ in range(5):
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class ConcurrencyGuardTests(SeriesRuleDedupBaseTestCase):
"""Verify the task lock prevents concurrent evaluation."""
def test_lock_acquired_and_released(self, mock_schedule, mock_artwork):
"""evaluate_series_rules_impl acquires and releases the task lock."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock", return_value=True) as mock_lock, \
patch("apps.channels.tasks.release_task_lock") as mock_release:
evaluate_series_rules_impl()
mock_lock.assert_called_once_with('evaluate_series_rules', 'all')
mock_release.assert_called_once_with('evaluate_series_rules', 'all')
def test_skips_when_lock_held(self, mock_schedule, mock_artwork):
"""Returns early with skip reason when lock is already held."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock", return_value=False):
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 0)
self.assertTrue(
any(d.get("reason") == "concurrent evaluation in progress"
for d in result["details"]),
)
self.assertEqual(Recording.objects.count(), 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_lock_released_on_exception(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Lock is released even if the inner implementation raises."""
from apps.channels.tasks import evaluate_series_rules_impl
with patch("apps.channels.tasks._evaluate_series_rules_locked",
side_effect=RuntimeError("test error")):
with self.assertRaises(RuntimeError):
evaluate_series_rules_impl()
mock_release.assert_called_once_with('evaluate_series_rules', 'all')
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class SecondaryGuardTests(SeriesRuleDedupBaseTestCase):
"""Verify the secondary DB guard uses stable program attributes."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_secondary_guard_catches_duplicate_with_offsets(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Secondary guard works with stale program IDs and DVR offsets."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=10, post_min=10)
prog = self._create_program(hours_from_now=2)
# Pre-existing recording with a stale program ID (from previous EPG refresh)
Recording.objects.create(
channel=self.channel,
start_time=prog.start_time - timedelta(minutes=10),
end_time=prog.end_time + timedelta(minutes=10),
custom_properties={
"program": {
"id": 99999,
"tvg_id": prog.tvg_id,
"title": prog.title,
"start_time": prog.start_time.isoformat(),
"end_time": prog.end_time.isoformat(),
}
},
)
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result["scheduled"], 0)
# ---------------------------------------------------------------------------
# Integration tests: full path from EPG refresh through recording creation
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class IntegrationEPGRefreshTests(SeriesRuleDedupBaseTestCase):
"""End-to-end tests simulating the EPG refresh → evaluate → record flow.
These exercise the full signal chain: evaluate_series_rules_impl creates
a Recording, the post_save signal fires schedule_recording_task, and
subsequent evaluations (after EPG refresh) must not create duplicates.
"""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_single_episode_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Simulate: create rule → evaluate → EPG refresh → re-evaluate.
The full recording lifecycle must result in exactly 1 recording.
"""
from apps.channels.tasks import evaluate_series_rules_impl
# Initial EPG data
prog = self._create_program(hours_from_now=2, sub_title="Pilot")
# First evaluation creates the recording
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
# Verify the recording was created with correct program metadata
rec = Recording.objects.first()
self.assertEqual(rec.custom_properties["program"]["tvg_id"], "test.channel.1")
self.assertEqual(rec.custom_properties["program"]["title"], "Test Show")
self.assertEqual(
rec.custom_properties["program"]["start_time"],
prog.start_time.isoformat()
)
# Verify the post_save signal scheduled a task
mock_schedule.assert_called()
initial_schedule_count = mock_schedule.call_count
# Simulate EPG refresh (programs get new DB IDs)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
# Re-evaluate after refresh (this is what EPG refresh triggers)
result2 = evaluate_series_rules_impl()
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
# No additional task scheduling should have occurred
self.assertEqual(mock_schedule.call_count, initial_schedule_count)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_with_offsets_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Full flow with DVR offsets: recording times differ from program times."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=10)
prog = self._create_program(hours_from_now=3, sub_title="Episode 1")
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
rec = Recording.objects.first()
# Verify offset-adjusted recording times
self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5))
self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=10))
# Verify original (unadjusted) program times in custom_properties
self.assertEqual(
rec.custom_properties["program"]["start_time"],
prog.start_time.isoformat()
)
self.assertEqual(
rec.custom_properties["program"]["end_time"],
prog.end_time.isoformat()
)
# EPG refresh + re-evaluate
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result2["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_multiple_episodes_across_refreshes(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""New episodes appear across multiple EPG refreshes; each recorded once."""
from apps.channels.tasks import evaluate_series_rules_impl
ep1 = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh adds episode 2 alongside episode 1
ep1_data = self._program_data_for_refresh(ep1)
ep2_start = ep1.end_time
ep2_data = {
"tvg_id": "test.channel.1",
"start_time": ep2_start,
"end_time": ep2_start + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 2",
}
self._simulate_epg_refresh([ep1_data, ep2_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
# Another EPG refresh adds episode 3
ep3_start = ep2_start + timedelta(hours=1)
ep3_data = {
"tvg_id": "test.channel.1",
"start_time": ep3_start,
"end_time": ep3_start + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 3",
}
self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 3)
# Final EPG refresh with no new episodes — count must stay at 3
self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 3)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_multiple_series_rules(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Multiple series rules on different channels, each evaluated correctly."""
from apps.channels.tasks import evaluate_series_rules_impl
# Second channel with its own EPG
epg2 = EPGData.objects.create(
tvg_id="test.channel.2",
name="Channel 2 EPG",
epg_source=self.epg_source,
)
channel2 = Channel.objects.create(
channel_number=2, name="Test Channel 2", epg_data=epg2
)
_set_series_rules([
{"tvg_id": "test.channel.1", "mode": "all", "title": "Show A"},
{"tvg_id": "test.channel.2", "mode": "all", "title": "Show B"},
])
# Programs on both channels
start1 = self.now + timedelta(hours=2)
prog1 = ProgramData.objects.create(
epg=self.epg, tvg_id="test.channel.1",
start_time=start1, end_time=start1 + timedelta(hours=1),
title="Show A", sub_title="Episode 1",
)
start2 = self.now + timedelta(hours=3)
prog2 = ProgramData.objects.create(
epg=epg2, tvg_id="test.channel.2",
start_time=start2, end_time=start2 + timedelta(hours=1),
title="Show B", sub_title="Episode 1",
)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
self.assertEqual(Recording.objects.filter(channel=self.channel).count(), 1)
self.assertEqual(Recording.objects.filter(channel=channel2).count(), 1)
# EPG refresh for both channels
ProgramData.objects.filter(epg=self.epg).delete()
ProgramData.objects.filter(epg=epg2).delete()
ProgramData.objects.create(
epg=self.epg, tvg_id="test.channel.1",
start_time=start1, end_time=start1 + timedelta(hours=1),
title="Show A", sub_title="Episode 1",
)
ProgramData.objects.create(
epg=epg2, tvg_id="test.channel.2",
start_time=start2, end_time=start2 + timedelta(hours=1),
title="Show B", sub_title="Episode 1",
)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2,
"No duplicates across multiple series rules after EPG refresh")
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_rapid_epg_refreshes_simulate_user_report(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Reproduce the user-reported scenario: series rule + multiple EPG refreshes
causing count to balloon from 6 to 25 and 5 simultaneous recordings.
Simulates 6 episodes with 5 EPG refreshes (each assigning new ProgramData IDs).
"""
from apps.channels.tasks import evaluate_series_rules_impl
# Create 6 episodes (the user had "next of 6")
episodes = []
for i in range(6):
start = self.now + timedelta(hours=2 + i * 2)
episodes.append({
"tvg_id": "test.channel.1",
"start_time": start,
"end_time": start + timedelta(hours=1),
"title": "Test Show",
"sub_title": f"Episode {i + 1}",
})
# Create initial ProgramData
for ep in episodes:
ProgramData.objects.create(epg=self.epg, **ep)
# First evaluation: should create exactly 6 recordings
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 6)
# Simulate 5 EPG refreshes (the user saw count balloon to 25)
for refresh_num in range(5):
self._simulate_epg_refresh(episodes)
result = evaluate_series_rules_impl()
self.assertEqual(
Recording.objects.count(), 6,
f"After EPG refresh #{refresh_num + 1}, expected 6 recordings "
f"but got {Recording.objects.count()}"
)
self.assertEqual(result["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_recording_survives_program_removal_and_readd(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Program temporarily disappears from EPG then reappears — no duplicate."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh removes the program entirely
self._simulate_epg_refresh([])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Existing recording preserved when program disappears from EPG")
# EPG refresh adds the program back (new ID)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"No duplicate when program reappears with new ID")
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_celery_task_wrapper_calls_impl(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""The @shared_task evaluate_series_rules delegates to _impl correctly."""
from apps.channels.tasks import evaluate_series_rules
self._create_program(hours_from_now=2)
result = evaluate_series_rules()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
# Call again (simulating a second EPG refresh trigger)
result2 = evaluate_series_rules()
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_tvg_id_scoped_evaluation(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Scoped evaluation (tvg_id parameter) still prevents duplicates."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
result1 = evaluate_series_rules_impl(tvg_id="test.channel.1")
self.assertEqual(result1["scheduled"], 1)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl(tvg_id="test.channel.1")
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_offset_change_between_refreshes(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Changing DVR offsets between EPG refreshes doesn't create duplicates.
Even though Recording.start_time/end_time change when offsets change,
the dedup key uses the original program times from custom_properties.
"""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=5)
prog = self._create_program(hours_from_now=2)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
rec = Recording.objects.first()
original_start = rec.start_time
original_end = rec.end_time
# Change offsets
_set_dvr_offsets(pre_min=10, post_min=15)
# EPG refresh
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Changing offsets between refreshes should not create duplicates")
self.assertEqual(result["scheduled"], 0)
# ---------------------------------------------------------------------------
# Edge case tests: Redis unavailability, non-series recordings, robustness
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class RedisUnavailabilityTests(SeriesRuleDedupBaseTestCase):
"""Verify evaluation works when Redis is unavailable (lock cannot be acquired)."""
def test_proceeds_when_redis_down(self, mock_schedule, mock_artwork):
"""Evaluation succeeds (with dedup guards) when Redis raises on lock acquire."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
def test_dedup_still_works_without_lock(self, mock_schedule, mock_artwork):
"""Dedup guards prevent duplicates even when the lock is unavailable."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
# First call: Redis down, proceeds without lock
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
# Second call: Redis still down
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Dedup guards prevent duplicates even without lock")
self.assertEqual(result["scheduled"], 0)
def test_lock_not_released_when_not_acquired(self, mock_schedule, mock_artwork):
"""release_task_lock is not called if acquire raised an exception."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")), \
patch("apps.channels.tasks.release_task_lock") as mock_release:
evaluate_series_rules_impl()
mock_release.assert_not_called()
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class NonSeriesRecordingTests(SeriesRuleDedupBaseTestCase):
"""Verify non-series recordings don't interfere with series rule dedup."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_manual_recording_without_program_data_ignored(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings without custom_properties.program are skipped by dedup key builder."""
from apps.channels.tasks import evaluate_series_rules_impl
# Manual recording with no program metadata
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties={},
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_recurring_rule_recording_does_not_interfere(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings from recurring rules (custom_properties.rule) don't block series rules."""
from apps.channels.tasks import evaluate_series_rules_impl
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties={"rule": {"id": 1, "name": "Daily News"}},
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_recording_with_null_custom_properties_ignored(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings with None custom_properties don't crash the dedup key builder."""
from apps.channels.tasks import evaluate_series_rules_impl
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties=None,
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)

View file

@ -0,0 +1,385 @@
"""Tests for ghost client detection and cleanup.
Covers:
- ClientManager.remove_ghost_clients() pipelined EXISTS logic
- channel_status detailed stats path removes ghost clients from Redis SET
- channel_status basic stats path removes ghost clients and corrects count
- _check_orphaned_metadata() validates client SET entries and cleans up
channels where all clients are ghosts
"""
from unittest.mock import MagicMock, patch, PropertyMock
from django.test import TestCase
from apps.proxy.ts_proxy.client_manager import ClientManager
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
from apps.proxy.ts_proxy.redis_keys import RedisKeys
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
CHANNEL_ID = "00000000-0000-0000-0000-000000000001"
def _make_proxy_server(redis_client=None):
"""Create a minimal mock ProxyServer with a redis_client."""
server = MagicMock()
server.redis_client = redis_client or MagicMock()
server.stream_managers = {}
server.client_managers = {}
server.worker_id = "test-worker-1"
return server
def _metadata_for_channel(state="active"):
"""Return a plausible channel metadata dict (bytes keys/values)."""
return {
ChannelMetadataField.STATE.encode(): state.encode(),
ChannelMetadataField.URL.encode(): b"http://example.com/stream",
ChannelMetadataField.STREAM_PROFILE.encode(): b"default",
ChannelMetadataField.OWNER.encode(): b"test-worker-1",
ChannelMetadataField.INIT_TIME.encode(): b"1773500000.0",
}
# ---------------------------------------------------------------------------
# Unit tests for ClientManager.remove_ghost_clients()
# ---------------------------------------------------------------------------
class RemoveGhostClientsTests(TestCase):
"""Directly exercises the static method that all callers rely on."""
def test_ghost_removed_and_returned(self):
"""Client ID in SET with no metadata hash should be SREM'd."""
redis = MagicMock()
redis.smembers.return_value = {b"ghost_001"}
pipe = MagicMock()
redis.pipeline.return_value = pipe
pipe.execute.return_value = [False] # EXISTS → False
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(result, [b"ghost_001"])
redis.srem.assert_called_once()
def test_live_client_preserved(self):
"""Client with valid metadata hash should NOT be removed."""
redis = MagicMock()
redis.smembers.return_value = {b"live_001"}
pipe = MagicMock()
redis.pipeline.return_value = pipe
pipe.execute.return_value = [True] # EXISTS → True
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(result, [])
redis.srem.assert_not_called()
def test_mixed_ghost_and_live(self):
"""Only ghost clients should be removed; live ones preserved."""
redis = MagicMock()
redis.smembers.return_value = {b"ghost_001", b"live_001"}
pipe = MagicMock()
redis.pipeline.return_value = pipe
# Order matches list(smembers), which is non-deterministic —
# map both IDs so the test is stable regardless of iteration order.
client_id_list = list(redis.smembers.return_value)
def exists_results():
return [
b"ghost_001" not in cid.decode() == False
for cid in client_id_list
]
# Simpler: mock based on key content
def pipe_exists(key):
pass # just enqueued; results come from execute()
pipe.exists.side_effect = pipe_exists
pipe.execute.return_value = [
"live" in cid.decode() for cid in client_id_list
]
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(len(result), 1)
self.assertTrue(any(b"ghost" in cid for cid in result))
redis.srem.assert_called_once()
def test_empty_set_returns_empty(self):
"""No clients means nothing to clean."""
redis = MagicMock()
redis.smembers.return_value = set()
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
self.assertEqual(result, [])
redis.pipeline.assert_not_called()
def test_pre_fetched_client_ids_skips_smembers(self):
"""When client_ids is passed, SMEMBERS should not be called."""
redis = MagicMock()
pipe = MagicMock()
redis.pipeline.return_value = pipe
pipe.execute.return_value = [False]
pre_fetched = {b"ghost_001"}
result = ClientManager.remove_ghost_clients(
redis, CHANNEL_ID, client_ids=pre_fetched
)
redis.smembers.assert_not_called()
self.assertEqual(len(result), 1)
# ---------------------------------------------------------------------------
# Detailed stats path: exercises get_detailed_channel_info()
# ---------------------------------------------------------------------------
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
class DetailedStatsGhostClientTests(TestCase):
"""get_detailed_channel_info() should remove ghost clients whose metadata
hash has expired from the Redis client SET."""
def _setup_redis(self, mock_proxy_cls, client_ids, hgetall_side_effect):
"""Wire up a mock ProxyServer with controlled Redis responses."""
redis = MagicMock()
server = _make_proxy_server(redis)
mock_proxy_cls.get_instance.return_value = server
redis.hgetall.side_effect = hgetall_side_effect
redis.smembers.return_value = client_ids
# buffer_index, ttl, exists all need safe defaults
redis.get.return_value = b"10"
redis.ttl.return_value = 300
redis.exists.return_value = True
return redis
def test_ghost_client_removed_from_set(self, mock_proxy_cls):
"""Ghost client should be SREM'd and excluded from result."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
def hgetall_side_effect(key):
if "clients:" in key:
return {} # ghost — metadata expired
return _metadata_for_channel()
redis = self._setup_redis(
mock_proxy_cls, {b"ghost_001"}, hgetall_side_effect
)
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
self.assertEqual(result['client_count'], 0)
self.assertEqual(len(result['clients']), 0)
redis.srem.assert_called_once()
def test_live_client_preserved(self, mock_proxy_cls):
"""Client with valid metadata should appear in results."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
def hgetall_side_effect(key):
if "clients:" in key:
return {
b'user_agent': b'VLC/3.0',
b'worker_id': b'test-worker-1',
b'connected_at': b'1773500000.0',
}
return _metadata_for_channel()
redis = self._setup_redis(
mock_proxy_cls, {b"live_001"}, hgetall_side_effect
)
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
self.assertEqual(result['client_count'], 1)
self.assertEqual(len(result['clients']), 1)
redis.srem.assert_not_called()
def test_mixed_ghost_and_live(self, mock_proxy_cls):
"""Only ghost clients should be removed; live ones preserved."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
def hgetall_side_effect(key):
if "clients:" in key:
if "ghost" in key:
return {}
return {
b'user_agent': b'VLC/3.0',
b'worker_id': b'test-worker-1',
}
return _metadata_for_channel()
redis = self._setup_redis(
mock_proxy_cls, {b"ghost_001", b"live_001"}, hgetall_side_effect
)
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
self.assertEqual(result['client_count'], 1)
self.assertEqual(len(result['clients']), 1)
redis.srem.assert_called_once()
# ---------------------------------------------------------------------------
# Basic stats path: exercises get_basic_channel_info()
# ---------------------------------------------------------------------------
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
class BasicStatsGhostClientTests(TestCase):
"""get_basic_channel_info() should call remove_ghost_clients(), skip
ghosts from display, and correct client_count."""
def _setup_redis(self, mock_proxy_cls, client_ids, ghost_ids):
"""Wire up mock ProxyServer. ghost_ids controls which EXISTS return False."""
redis = MagicMock()
server = _make_proxy_server(redis)
mock_proxy_cls.get_instance.return_value = server
redis.hgetall.return_value = _metadata_for_channel()
redis.get.return_value = b"10" # buffer_index
redis.scard.return_value = len(client_ids)
redis.smembers.return_value = client_ids
redis.hget.return_value = None # individual field lookups
# Pipeline for remove_ghost_clients
pipe = MagicMock()
redis.pipeline.return_value = pipe
client_id_list = list(client_ids)
pipe.execute.return_value = [
cid not in ghost_ids for cid in client_id_list
]
return redis
def test_ghost_removed_and_count_corrected(self, mock_proxy_cls):
"""Ghost client should be cleaned and client_count decremented."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
redis = self._setup_redis(
mock_proxy_cls,
client_ids={b"ghost_001"},
ghost_ids={b"ghost_001"},
)
result = ChannelStatus.get_basic_channel_info(CHANNEL_ID)
self.assertIsNotNone(result)
self.assertEqual(result['client_count'], 0)
redis.srem.assert_called_once()
def test_live_client_count_preserved(self, mock_proxy_cls):
"""Live clients should be counted correctly."""
from apps.proxy.ts_proxy.channel_status import ChannelStatus
redis = self._setup_redis(
mock_proxy_cls,
client_ids={b"live_001"},
ghost_ids=set(),
)
result = ChannelStatus.get_basic_channel_info(CHANNEL_ID)
self.assertIsNotNone(result)
self.assertEqual(result['client_count'], 1)
redis.srem.assert_not_called()
# ---------------------------------------------------------------------------
# Orphaned channel cleanup: exercises _check_orphaned_metadata()
# ---------------------------------------------------------------------------
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
class OrphanedChannelGhostValidationTests(TestCase):
"""_check_orphaned_metadata() should validate client SET entries when
owner is dead and client_count > 0. If all clients are ghosts, it
should clean up the channel."""
def _make_server_for_orphan_check(self, mock_proxy_cls, channel_id,
client_ids, ghost_ids, owner="dead-worker"):
"""Build a mock ProxyServer whose Redis state simulates an orphaned channel."""
redis = MagicMock()
server = _make_proxy_server(redis)
mock_proxy_cls.get_instance.return_value = server
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = _metadata_for_channel()
metadata[ChannelMetadataField.OWNER.encode()] = owner.encode()
# scan returns the one channel metadata key
redis.scan.return_value = (0, [metadata_key.encode()])
redis.hgetall.return_value = metadata
redis.scard.return_value = len(client_ids)
redis.smembers.return_value = client_ids
# Owner heartbeat is dead
redis.exists.side_effect = lambda key: (
False if "heartbeat" in key else True
)
# Pipeline for remove_ghost_clients
pipe = MagicMock()
redis.pipeline.return_value = pipe
client_id_list = list(client_ids)
pipe.execute.return_value = [
cid not in ghost_ids for cid in client_id_list
]
return server, redis
def test_all_ghosts_triggers_cleanup(self, mock_proxy_cls):
"""When all clients are ghosts, channel should be cleaned up."""
from apps.proxy.ts_proxy.server import ProxyServer
channel_id = "00000000-0000-0000-0000-000000000005"
server, redis = self._make_server_for_orphan_check(
mock_proxy_cls, channel_id,
client_ids={b"ghost_001", b"ghost_002"},
ghost_ids={b"ghost_001", b"ghost_002"},
)
# Call the real method on a real-ish ProxyServer
# The method lives on the server instance, so invoke it directly.
# We need to call _check_orphaned_metadata on the actual server mock,
# but it's a MagicMock. Instead, test via remove_ghost_clients directly
# and verify the cleanup decision logic.
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
real_count = max(0, len({b"ghost_001", b"ghost_002"}) - len(stale_ids))
self.assertEqual(len(stale_ids), 2)
self.assertEqual(real_count, 0)
redis.srem.assert_called_once()
def test_mixed_preserves_live_clients(self, mock_proxy_cls):
"""When some clients are live, real_count should be > 0."""
channel_id = "00000000-0000-0000-0000-000000000006"
server, redis = self._make_server_for_orphan_check(
mock_proxy_cls, channel_id,
client_ids={b"ghost_001", b"live_001"},
ghost_ids={b"ghost_001"},
)
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
real_count = max(0, 2 - len(stale_ids))
self.assertEqual(len(stale_ids), 1)
self.assertEqual(real_count, 1)
def test_no_ghosts_no_cleanup(self, mock_proxy_cls):
"""When all clients are live, no SREM should be called."""
channel_id = "00000000-0000-0000-0000-000000000007"
server, redis = self._make_server_for_orphan_check(
mock_proxy_cls, channel_id,
client_ids={b"live_001"},
ghost_ids=set(),
)
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
self.assertEqual(len(stale_ids), 0)
redis.srem.assert_not_called()

View file

@ -0,0 +1,231 @@
"""Tests for stuck INITIALIZING state fix.
Covers:
- stream_manager.run() finally block: ownership check + state guard fallback
- ChannelState.PRE_ACTIVE contains the correct states
- INITIALIZING is included in the cleanup task grace period check
"""
import time
import threading
from unittest.mock import MagicMock, patch
from django.test import TestCase
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.stream_manager import StreamManager
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
CHANNEL_ID = "00000000-0000-0000-0000-000000000001"
def _make_stream_manager(tried_stream_ids=None, max_retries=3):
"""Build a StreamManager via __new__ (bypasses __init__) with the
minimum attributes required by the run() finally block."""
sm = StreamManager.__new__(StreamManager)
sm.channel_id = CHANNEL_ID
sm.worker_id = "worker-1"
sm.max_retries = max_retries
sm.tried_stream_ids = tried_stream_ids if tried_stream_ids is not None else set()
sm.running = False # while-loop exits immediately
sm.connected = False
sm.transcode_process_active = False
sm._buffer_check_timers = []
sm.url = "http://example.com/stream"
sm.url_switching = False
sm.url_switch_start_time = 0
sm.url_switch_timeout = 30
sm.stop_requested = False
sm.stopping = False
sm.socket = None
sm.transcode_process = None
sm.current_response = None
sm.current_session = None
sm.current_stream_id = None
buffer = MagicMock()
buffer.redis_client = MagicMock()
buffer.channel_id = CHANNEL_ID
sm.buffer = buffer
return sm
def _run_finally_block(sm, owner_value, current_state):
"""Invoke StreamManager.run() so its finally block executes against real code.
Patches threading.Thread and ConfigHelper so the try-block is inert
(self.running=False makes the while-loop exit immediately).
Returns True if the finally block wrote ERROR to Redis.
"""
redis = sm.buffer.redis_client
# Mock the owner key GET — the finally block calls redis.get(owner_key)
def get_side_effect(key):
if "owner" in key:
return owner_value
return None
redis.get.side_effect = get_side_effect
# Mock hget for state field lookup in the PRE_ACTIVE guard
if current_state is not None:
redis.hget.return_value = current_state.encode('utf-8')
else:
redis.hget.return_value = None
# Reset hset so we can detect whether ERROR was written
redis.hset.reset_mock()
redis.setex.reset_mock()
with patch.object(threading, 'Thread', return_value=MagicMock()):
with patch('apps.proxy.ts_proxy.stream_manager.ConfigHelper') as mock_cfg:
mock_cfg.max_stream_switches.return_value = 0
mock_cfg.max_retries.return_value = sm.max_retries
sm.run()
# Check if hset was called with ERROR state
if redis.hset.called:
mapping = redis.hset.call_args[1].get('mapping', {})
return mapping.get(ChannelMetadataField.STATE) == ChannelState.ERROR
return False
# ---------------------------------------------------------------------------
# stream_manager.run() finally block: ownership + state guard behavior
# ---------------------------------------------------------------------------
class StreamManagerFinallyBlockTests(TestCase):
"""The run() finally block writes ERROR if the worker is still the owner
(normal case) OR if ownership expired and the channel is still in a
pre-active state (no new owner has taken over)."""
# --- Owner still valid: always write ERROR ---
def test_owner_writes_error_regardless_of_state(self):
"""When we're still the owner, always write ERROR."""
sm = _make_stream_manager()
owner = sm.worker_id.encode('utf-8')
self.assertTrue(_run_finally_block(sm, owner, ChannelState.ACTIVE))
def test_owner_writes_error_on_initializing(self):
"""Owner + INITIALIZING = write ERROR."""
sm = _make_stream_manager()
owner = sm.worker_id.encode('utf-8')
self.assertTrue(_run_finally_block(sm, owner, ChannelState.INITIALIZING))
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
self.assertEqual(mapping[ChannelMetadataField.STATE], ChannelState.ERROR)
# --- Ownership expired, no new owner: use state guard ---
def test_no_owner_initializing_writes_error(self):
"""Ownership expired + INITIALIZING = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.INITIALIZING))
def test_no_owner_connecting_writes_error(self):
"""Ownership expired + CONNECTING = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.CONNECTING))
def test_no_owner_buffering_writes_error(self):
"""Ownership expired + BUFFERING = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.BUFFERING))
def test_no_owner_waiting_for_clients_writes_error(self):
"""Ownership expired + WAITING_FOR_CLIENTS = write ERROR."""
sm = _make_stream_manager()
self.assertTrue(_run_finally_block(sm, None, ChannelState.WAITING_FOR_CLIENTS))
def test_no_owner_active_does_not_write(self):
"""Ownership expired + ACTIVE = do NOT write ERROR."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, None, ChannelState.ACTIVE))
def test_no_owner_error_does_not_write(self):
"""Ownership expired + already ERROR = do NOT write again."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, None, ChannelState.ERROR))
def test_no_owner_no_state_does_not_write(self):
"""Ownership expired + no state metadata = do NOT write."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, None, None))
# --- New owner took over: never clobber ---
def test_new_owner_initializing_does_not_write(self):
"""Another worker owns the channel — do NOT clobber."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.INITIALIZING))
def test_new_owner_active_does_not_write(self):
"""Another worker owns the channel and is ACTIVE — do NOT write."""
sm = _make_stream_manager()
self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.ACTIVE))
# --- Stopping key and error messages ---
def test_stopping_key_set_on_error_update(self):
"""When ERROR is written, stopping key must also be set."""
sm = _make_stream_manager()
_run_finally_block(sm, None, ChannelState.INITIALIZING)
sm.buffer.redis_client.setex.assert_called_once()
args = sm.buffer.redis_client.setex.call_args[0]
self.assertIn("stopping", args[0])
self.assertEqual(args[1], 60)
def test_error_message_includes_stream_count(self):
"""When multiple streams were tried, error message reflects that."""
sm = _make_stream_manager(tried_stream_ids={1, 2, 3})
_run_finally_block(sm, None, ChannelState.INITIALIZING)
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE]
self.assertIn("3 stream options failed", error_msg)
def test_error_message_with_no_streams_tried(self):
"""When no alternate streams were tried, shows retry count."""
sm = _make_stream_manager(tried_stream_ids=set(), max_retries=5)
_run_finally_block(sm, None, ChannelState.INITIALIZING)
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE]
self.assertIn("5", error_msg)
# ---------------------------------------------------------------------------
# ChannelState.PRE_ACTIVE: verify contents and immutability
# ---------------------------------------------------------------------------
class PreActiveStateTests(TestCase):
"""Verify PRE_ACTIVE contains the correct states and is immutable."""
def test_initializing_in_pre_active(self):
self.assertIn(ChannelState.INITIALIZING, ChannelState.PRE_ACTIVE)
def test_connecting_in_pre_active(self):
self.assertIn(ChannelState.CONNECTING, ChannelState.PRE_ACTIVE)
def test_buffering_in_pre_active(self):
self.assertIn(ChannelState.BUFFERING, ChannelState.PRE_ACTIVE)
def test_waiting_for_clients_in_pre_active(self):
self.assertIn(ChannelState.WAITING_FOR_CLIENTS, ChannelState.PRE_ACTIVE)
def test_active_not_in_pre_active(self):
self.assertNotIn(ChannelState.ACTIVE, ChannelState.PRE_ACTIVE)
def test_error_not_in_pre_active(self):
self.assertNotIn(ChannelState.ERROR, ChannelState.PRE_ACTIVE)
def test_pre_active_is_frozenset(self):
self.assertIsInstance(ChannelState.PRE_ACTIVE, frozenset)

View file

@ -0,0 +1,331 @@
"""Tests for ts_proxy keepalive and stats-update behavior.
Covers:
- stream_generator._should_send_keepalive() owner vs non-owner worker paths
- stream_generator._should_send_keepalive() Redis last_data health check
- client_manager._do_stats_update() error handling and WebSocket dispatch
- client_manager.remove_client() non-blocking stats update
- Keepalive/DVR-timeout timing invariants
"""
import threading
import time
from unittest.mock import MagicMock, patch
from django.test import TestCase
# ---------------------------------------------------------------------------
# _should_send_keepalive: owner worker path
# ---------------------------------------------------------------------------
class OwnerWorkerKeepaliveTests(TestCase):
"""Owner worker has a stream_manager; keepalive logic uses it directly."""
def _make_generator(self, healthy, at_buffer_head, consecutive_empty):
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
gen = StreamGenerator.__new__(StreamGenerator)
gen.channel_id = "00000000-0000-0000-0000-000000000001"
gen.client_id = "test-client"
buffer = MagicMock()
buffer.index = 10 if at_buffer_head else 100
gen.local_index = 10
gen.buffer = buffer
stream_manager = MagicMock()
stream_manager.healthy = healthy
gen.stream_manager = stream_manager
gen.consecutive_empty = consecutive_empty
return gen
def test_owner_healthy_returns_false(self):
"""Owner worker, healthy stream -> no keepalive."""
gen = self._make_generator(healthy=True, at_buffer_head=True, consecutive_empty=10)
self.assertFalse(gen._should_send_keepalive(gen.local_index))
def test_owner_unhealthy_at_head_returns_true(self):
"""Owner worker, unhealthy stream, at buffer head -> send keepalive."""
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=10)
self.assertTrue(gen._should_send_keepalive(gen.local_index))
def test_owner_unhealthy_not_at_head_returns_false(self):
"""Owner worker, unhealthy stream, but NOT at buffer head -> no keepalive."""
gen = self._make_generator(healthy=False, at_buffer_head=False, consecutive_empty=10)
self.assertFalse(gen._should_send_keepalive(gen.local_index))
def test_owner_insufficient_consecutive_empty_returns_false(self):
"""Owner worker, unhealthy, at head but consecutive_empty < 5 -> no keepalive."""
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=3)
self.assertFalse(gen._should_send_keepalive(gen.local_index))
def test_owner_exactly_5_consecutive_empty_returns_true(self):
"""consecutive_empty == 5 is the minimum threshold."""
gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=5)
self.assertTrue(gen._should_send_keepalive(gen.local_index))
# ---------------------------------------------------------------------------
# _should_send_keepalive: non-owner worker path
# ---------------------------------------------------------------------------
class NonOwnerWorkerKeepaliveTests(TestCase):
"""Non-owner worker has stream_manager=None; health determined from Redis."""
def _make_generator(self, consecutive_empty=10):
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
gen = StreamGenerator.__new__(StreamGenerator)
gen.channel_id = "00000000-0000-0000-0000-000000000002"
gen.client_id = "test-client-nonowner"
buffer = MagicMock()
buffer.index = 10
gen.local_index = 10
gen.buffer = buffer
gen.stream_manager = None # non-owner worker
gen.consecutive_empty = consecutive_empty
# Attributes added by health-check throttling (set in __init__)
gen._last_health_check_time = 0.0
gen._last_health_check_result = False
gen._health_check_interval = 2.0
gen.proxy_server = None
return gen
def _mock_proxy_server(self, last_data_value):
"""Return a mock ProxyServer with a redis_client pre-configured."""
server = MagicMock()
redis_client = MagicMock()
server.redis_client = redis_client
redis_client.get.return_value = last_data_value
return server
def test_non_owner_fresh_data_returns_false(self):
"""Non-owner, last_data < 10s ago -> stream healthy -> no keepalive."""
gen = self._make_generator()
fresh_ts = str(time.time() - 2.0).encode()
server = self._mock_proxy_server(fresh_ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result, "Fresh data should NOT trigger keepalive")
def test_non_owner_stale_data_returns_true(self):
"""Non-owner, last_data >= 10s ago -> stream unhealthy -> send keepalive."""
gen = self._make_generator()
stale_ts = str(time.time() - 12.0).encode()
server = self._mock_proxy_server(stale_ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertTrue(result, "Stale data (12s) should trigger keepalive")
def test_non_owner_exactly_at_timeout_returns_true(self):
"""Data age exactly equal to CONNECTION_TIMEOUT (10s) -> send keepalive."""
gen = self._make_generator()
ts = str(time.time() - 10.0).encode()
server = self._mock_proxy_server(ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertTrue(result, "Data at exactly timeout threshold should trigger keepalive")
def test_non_owner_no_redis_key_returns_true(self):
"""Non-owner, last_data key missing from Redis -> assume unhealthy."""
gen = self._make_generator()
server = self._mock_proxy_server(None)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertTrue(result, "Missing last_data key should trigger keepalive")
def test_non_owner_redis_client_none_returns_false(self):
"""Non-owner, redis_client is None (disconnected) -> conservative, no keepalive."""
gen = self._make_generator()
server = MagicMock()
server.redis_client = None
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result, "No redis_client -> conservative, no keepalive")
def test_non_owner_redis_exception_returns_false(self):
"""Non-owner, Redis raises an exception -> conservative, no keepalive."""
gen = self._make_generator()
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.side_effect = Exception("Redis error")
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result, "Redis error -> conservative, no keepalive")
def test_non_owner_not_at_buffer_head_returns_false(self):
"""Non-owner, NOT at buffer head -> no keepalive regardless of Redis."""
gen = self._make_generator()
gen.buffer.index = 100 # far ahead of local_index=10
server = self._mock_proxy_server(None)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result)
def test_non_owner_insufficient_consecutive_empty_returns_false(self):
"""Non-owner, at head, but consecutive_empty < 5 -> no keepalive."""
gen = self._make_generator(consecutive_empty=2)
stale_ts = str(time.time() - 30.0).encode()
server = self._mock_proxy_server(stale_ts)
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
MockPS.get_instance.return_value = server
result = gen._should_send_keepalive(gen.local_index)
self.assertFalse(result)
# ---------------------------------------------------------------------------
# _do_stats_update: error handling and WebSocket dispatch
# ---------------------------------------------------------------------------
class DoStatsUpdateTests(TestCase):
"""_do_stats_update runs the actual Redis scan + WebSocket call."""
def _make_client_manager(self):
from apps.proxy.ts_proxy.client_manager import ClientManager
cm = ClientManager.__new__(ClientManager)
cm.channel_id = "00000000-0000-0000-0000-000000000004"
cm._heartbeat_running = False
return cm
def test_do_stats_update_calls_send_websocket_update(self):
"""_do_stats_update must call send_websocket_update with channel_stats."""
cm = self._make_client_manager()
mock_redis = MagicMock()
mock_redis.scan.return_value = (0, [])
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update") as mock_ws, \
patch("redis.Redis.from_url", return_value=mock_redis):
cm._do_stats_update()
mock_ws.assert_called_once()
event_type = mock_ws.call_args[0][1]
self.assertEqual(event_type, "update")
payload = mock_ws.call_args[0][2]
self.assertEqual(payload["type"], "channel_stats")
def test_do_stats_update_does_not_raise_on_redis_error(self):
"""Redis failure must be swallowed (logged), not propagated."""
cm = self._make_client_manager()
with patch("redis.Redis.from_url", side_effect=Exception("Redis down")):
try:
cm._do_stats_update()
except Exception as e:
self.fail(f"_do_stats_update raised an exception: {e}")
def test_do_stats_update_scans_channel_client_keys(self):
"""Must scan for ts_proxy:channel:*:clients pattern."""
cm = self._make_client_manager()
mock_redis = MagicMock()
mock_redis.scan.return_value = (0, [])
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update"), \
patch("redis.Redis.from_url", return_value=mock_redis):
cm._do_stats_update()
scan_call = mock_redis.scan.call_args
self.assertIn("ts_proxy:channel:*:clients", str(scan_call))
# ---------------------------------------------------------------------------
# Integration: remove_client must not block on WebSocket
# ---------------------------------------------------------------------------
class ClientRemoveIntegrationTests(TestCase):
"""When remove_client() fires, _trigger_stats_update must not block."""
def test_remove_client_does_not_block_on_websocket(self):
"""remove_client() must return quickly even if WebSocket is slow."""
from apps.proxy.ts_proxy.client_manager import ClientManager
cm = ClientManager.__new__(ClientManager)
cm.channel_id = "00000000-0000-0000-0000-000000000005"
cm._heartbeat_running = False
cm.clients = {"test-client-1"}
cm.last_heartbeat_time = {"test-client-1": time.time()}
cm.last_active_time = time.time()
cm.client_set_key = f"ts_proxy:channel:{cm.channel_id}:clients"
cm.client_ttl = 60
cm.worker_id = "worker-1"
cm.proxy_server = MagicMock()
cm.proxy_server.am_i_owner.return_value = False
cm.lock = threading.Lock()
mock_redis = MagicMock()
mock_redis.hgetall.return_value = {b"ip_address": b"127.0.0.1"}
mock_redis.scard.return_value = 1
cm.redis_client = mock_redis
slow_ws_called = threading.Event()
def slow_websocket(*args, **kwargs):
time.sleep(2.0)
slow_ws_called.set()
start = time.time()
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update", side_effect=slow_websocket):
cm.remove_client("test-client-1")
elapsed = time.time() - start
self.assertLess(elapsed, 1.0,
f"remove_client() blocked for {elapsed:.2f}s waiting for WebSocket "
f"(should dispatch to background thread and return immediately)")
# ---------------------------------------------------------------------------
# DVR timeout threshold vs keepalive timing
# ---------------------------------------------------------------------------
class KeepaliveTimingTests(TestCase):
"""Verify that keepalive threshold gives sufficient margin before DVR timeout."""
def test_keepalive_threshold_less_than_dvr_timeout(self):
"""CONNECTION_TIMEOUT (keepalive trigger) must be < DVR read timeout (15s)."""
from apps.proxy.config import TSConfig as Config
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
dvr_read_timeout = 15 # hard-coded in run_recording: timeout=(10, 15)
self.assertLess(
connection_timeout,
dvr_read_timeout,
f"CONNECTION_TIMEOUT ({connection_timeout}s) must be < DVR timeout ({dvr_read_timeout}s) "
f"so keepalives fire before DVR times out",
)
def test_keepalive_interval_is_short(self):
"""KEEPALIVE_INTERVAL must be short enough to send multiple keepalives in the gap."""
from apps.proxy.config import TSConfig as Config
interval = getattr(Config, "KEEPALIVE_INTERVAL", 0.5)
connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10)
remaining_window = 15 - connection_timeout
self.assertGreater(
remaining_window / interval,
3,
f"KEEPALIVE_INTERVAL ({interval}s) is too long: only "
f"{remaining_window/interval:.1f} keepalives would fit in the "
f"{remaining_window}s window before DVR timeout",
)

View file

@ -0,0 +1,195 @@
"""
Unit tests for the keepalive duration cap in StreamGenerator._stream_data_generator.
Verifies that a client held in keepalive mode is disconnected after
MAX_KEEPALIVE_DURATION seconds, and that the timer resets when real data resumes.
"""
import time
from unittest.mock import MagicMock, patch, call
from django.test import TestCase
def _make_generator(consecutive_empty=10, local_index=10, buffer_index=10):
"""Minimal StreamGenerator stub for testing _stream_data_generator logic."""
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
gen = StreamGenerator.__new__(StreamGenerator)
gen.channel_id = "00000000-0000-0000-0000-000000000099"
gen.client_id = "test-client-duration"
gen.consecutive_empty = consecutive_empty
gen.empty_reads = 0
gen.local_index = local_index
gen.bytes_sent = 0
gen.chunks_sent = 0
gen.last_yield_time = time.time()
gen.stream_start_time = time.time()
gen.last_stats_time = time.time()
gen.last_stats_bytes = 0
gen.current_rate = 0.0
gen.last_ttl_refresh = time.time()
gen.ttl_refresh_interval = 3
gen.is_owner_worker = False
gen.stream_manager = None
gen._last_health_check_time = 0.0
gen._last_health_check_result = False
gen._health_check_interval = 2.0
gen.proxy_server = None
buffer = MagicMock()
buffer.index = buffer_index
buffer.get_optimized_client_data.return_value = ([], local_index)
buffer.find_oldest_available_chunk.return_value = None
gen.buffer = buffer
return gen
class KeepaliveDurationCapTests(TestCase):
"""MAX_KEEPALIVE_DURATION cap disconnects clients stuck in keepalive mode."""
def _run_generator_to_break(self, gen, max_iterations=20):
"""Drive _stream_data_generator until it breaks or hits iteration limit."""
iterations = 0
for _ in gen._stream_data_generator():
iterations += 1
if iterations >= max_iterations:
break
return iterations
def test_cap_fires_after_max_duration_exceeded(self):
"""Generator exits when keepalive has run longer than MAX_KEEPALIVE_DURATION."""
gen = _make_generator()
with patch.object(gen, '_check_resources', return_value=True), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent') as mock_gevent, \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
MockConfig.MAX_KEEPALIVE_DURATION = 30
# First call: keepalive_start_time not yet set (returns current)
# Second call: inside the cap check — simulate time elapsed > 30s
mock_time.time.side_effect = [
1000.0, # keepalive_start_time assignment
1031.0, # cap check: 31s elapsed > 30s limit
]
packets = list(gen._stream_data_generator())
# No packets should be yielded — cap fires before yield
self.assertEqual(len(packets), 0)
def test_cap_does_not_fire_before_max_duration(self):
"""Generator yields keepalive packets while within MAX_KEEPALIVE_DURATION."""
gen = _make_generator()
call_count = 0
def time_side_effect():
nonlocal call_count
call_count += 1
# keepalive_start_time set at t=1000; cap checks always see <30s elapsed
if call_count == 1:
return 1000.0 # keepalive_start_time
return 1010.0 # always 10s elapsed — under the 30s cap
with patch.object(gen, '_check_resources', side_effect=[True, True, False]), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
MockConfig.MAX_KEEPALIVE_DURATION = 30
mock_time.time.side_effect = time_side_effect
packets = list(gen._stream_data_generator())
# Two iterations with _check_resources=True should yield two keepalive packets
self.assertGreater(len(packets), 0)
def test_timer_resets_when_real_data_resumes(self):
"""keepalive_start_time is cleared to None when real chunks are received."""
gen = _make_generator()
chunk = b'\x47' * 188
real_chunks = ([chunk], gen.local_index + 1)
no_chunks = ([], gen.local_index)
# Sequence: no data (keepalive), then real data, then stop
gen.buffer.get_optimized_client_data.side_effect = [
no_chunks, # iteration 1: keepalive
real_chunks, # iteration 2: real data — should reset timer
no_chunks, # iteration 3: keepalive again — timer restarts fresh
]
captured_start_times = []
original_gen = gen
with patch.object(gen, '_check_resources', side_effect=[True, True, True, False]), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch.object(gen, '_process_chunks', return_value=iter([chunk])), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
MockConfig.MAX_KEEPALIVE_DURATION = 300
mock_time.time.return_value = 1000.0
list(gen._stream_data_generator())
# Test passes if no exception and generator completes normally —
# if the timer were NOT reset, the second keepalive block would
# carry over the old start time rather than starting fresh.
def test_cap_uses_config_value(self):
"""Cap threshold reads MAX_KEEPALIVE_DURATION from Config, not a hardcoded value."""
gen = _make_generator()
with patch.object(gen, '_check_resources', return_value=True), \
patch.object(gen, '_should_send_keepalive', return_value=True), \
patch.object(gen, '_is_ghost_client', return_value=False), \
patch.object(gen, '_is_timeout', return_value=False), \
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
MockPS.get_instance.return_value = None
MockConfig.KEEPALIVE_INTERVAL = 0
# Set a custom cap of 60s
MockConfig.MAX_KEEPALIVE_DURATION = 60
mock_time.time.side_effect = [
1000.0, # keepalive_start_time
1050.0, # cap check: 50s elapsed — under 60s, should NOT fire
1000.0, # last_yield_time update
1070.0, # cap check on next iteration: 70s elapsed — fires
]
packets = list(gen._stream_data_generator())
# First iteration: 50s < 60s cap — one keepalive yielded
# Second iteration: 70s > 60s cap — generator exits
self.assertEqual(len(packets), 1)

View file

@ -0,0 +1,169 @@
"""Tests for the _validate_url() helper in tasks.py.
Covers:
- Rejection of None, empty, and non-string inputs
- Non-HTTP URLs pass through without network requests
- HTTP(S) URLs validated via HEAD request (2xx/3xx pass, 4xx/5xx fail)
- Network errors (timeout, connection) treated as failures
- Per-worker result cache: hits, expiry, eviction
"""
from unittest.mock import patch, MagicMock
from django.test import TestCase
from apps.channels.tasks import _validate_url, _url_validation_cache, _URL_CACHE_TTL
class ValidateUrlInputTests(TestCase):
"""Input validation — no network requests should be made."""
def setUp(self):
_url_validation_cache.clear()
def test_none_returns_false(self):
self.assertFalse(_validate_url(None))
def test_empty_string_returns_false(self):
self.assertFalse(_validate_url(""))
def test_non_string_returns_false(self):
self.assertFalse(_validate_url(123))
self.assertFalse(_validate_url(["http://example.com"]))
@patch("apps.channels.tasks.requests.head")
def test_non_http_url_returns_true_without_request(self, mock_head):
"""file:// and other non-HTTP schemes skip validation."""
self.assertTrue(_validate_url("file:///local/path.jpg"))
self.assertTrue(_validate_url("/data/images/poster.jpg"))
mock_head.assert_not_called()
class ValidateUrlNetworkTests(TestCase):
"""HTTP(S) URL validation via HEAD request."""
def setUp(self):
_url_validation_cache.clear()
@patch("apps.channels.tasks.requests.head")
def test_200_returns_true(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
self.assertTrue(_validate_url("https://example.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_302_redirect_returns_true(self, mock_head):
mock_head.return_value = MagicMock(status_code=302)
self.assertTrue(_validate_url("https://example.com/redirect"))
@patch("apps.channels.tasks.requests.head")
def test_404_returns_false(self, mock_head):
mock_head.return_value = MagicMock(status_code=404)
self.assertFalse(_validate_url("https://dead-cdn.com/missing.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_500_returns_false(self, mock_head):
mock_head.return_value = MagicMock(status_code=500)
self.assertFalse(_validate_url("https://broken.com/error"))
@patch("apps.channels.tasks.requests.head")
def test_timeout_returns_false(self, mock_head):
import requests
mock_head.side_effect = requests.Timeout("timed out")
self.assertFalse(_validate_url("https://slow-cdn.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_connection_error_returns_false(self, mock_head):
import requests
mock_head.side_effect = requests.ConnectionError("refused")
self.assertFalse(_validate_url("https://unreachable.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_custom_timeout_passed_to_head(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
_validate_url("https://example.com/img.jpg", timeout=10)
mock_head.assert_called_once_with(
"https://example.com/img.jpg", timeout=10, allow_redirects=True
)
@patch("apps.channels.tasks.requests.get")
@patch("apps.channels.tasks.requests.head")
def test_405_falls_back_to_get(self, mock_head, mock_get):
"""When HEAD returns 405, fall back to a ranged GET request."""
mock_head.return_value = MagicMock(status_code=405)
mock_resp = MagicMock(status_code=200)
mock_get.return_value = mock_resp
self.assertTrue(_validate_url("https://no-head.com/poster.jpg"))
mock_get.assert_called_once()
mock_resp.close.assert_called_once()
@patch("apps.channels.tasks.requests.get")
@patch("apps.channels.tasks.requests.head")
def test_405_fallback_get_also_fails(self, mock_head, mock_get):
"""When HEAD returns 405 and GET also fails, return False."""
mock_head.return_value = MagicMock(status_code=405)
mock_get.return_value = MagicMock(status_code=403)
self.assertFalse(_validate_url("https://blocked.com/poster.jpg"))
class ValidateUrlCacheTests(TestCase):
"""Per-worker result caching."""
def setUp(self):
_url_validation_cache.clear()
@patch("apps.channels.tasks.requests.head")
def test_cache_hit_avoids_second_request(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
url = "https://cached.com/poster.jpg"
self.assertTrue(_validate_url(url))
self.assertTrue(_validate_url(url))
mock_head.assert_called_once()
@patch("apps.channels.tasks.requests.head")
def test_cache_hit_returns_false_for_failed_url(self, mock_head):
mock_head.return_value = MagicMock(status_code=404)
url = "https://dead.com/missing.jpg"
self.assertFalse(_validate_url(url))
self.assertFalse(_validate_url(url))
mock_head.assert_called_once()
@patch("apps.channels.tasks.time.monotonic")
@patch("apps.channels.tasks.requests.head")
def test_cache_expiry_triggers_new_request(self, mock_head, mock_time):
"""After TTL expires, a new HEAD request is made."""
mock_head.return_value = MagicMock(status_code=200)
url = "https://expiring.com/poster.jpg"
mock_time.return_value = 1000.0
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 1)
# Within TTL — cache hit
mock_time.return_value = 1000.0 + _URL_CACHE_TTL - 1
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 1)
# Past TTL — new request
mock_time.return_value = 1000.0 + _URL_CACHE_TTL + 1
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 2)
@patch("apps.channels.tasks.time.monotonic")
@patch("apps.channels.tasks.requests.head")
def test_eviction_when_cache_exceeds_limit(self, mock_head, mock_time):
"""Expired entries are evicted when cache grows past 512 entries."""
mock_head.return_value = MagicMock(status_code=200)
# Fill cache with 513 entries at time 0
mock_time.return_value = 0.0
for i in range(513):
_url_validation_cache[f"https://fill-{i}.com/img.jpg"] = (True, 0.0)
# Advance past TTL and add one more — triggers eviction
mock_time.return_value = _URL_CACHE_TTL + 1
_validate_url("https://trigger-eviction.com/img.jpg")
# All 513 old entries expired and should be evicted
remaining = [k for k in _url_validation_cache if k.startswith("https://fill-")]
self.assertEqual(len(remaining), 0)
# The new entry should remain
self.assertIn("https://trigger-eviction.com/img.jpg", _url_validation_cache)

0
apps/connect/__init__.py Normal file
View file

17
apps/connect/api_urls.py Normal file
View file

@ -0,0 +1,17 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .api_views import (
IntegrationViewSet,
EventSubscriptionViewSet,
DeliveryLogViewSet,
)
app_name = 'connect'
router = DefaultRouter()
router.register(r'integrations', IntegrationViewSet, basename='integration')
router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription')
router.register(r'logs', DeliveryLogViewSet, basename='delivery-log')
urlpatterns = []
urlpatterns += router.urls

226
apps/connect/api_views.py Normal file
View file

@ -0,0 +1,226 @@
from rest_framework import viewsets, status, serializers
from rest_framework.pagination import PageNumberPagination
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.response import Response
from rest_framework.decorators import action
from django.utils import timezone
from drf_spectacular.utils import extend_schema, inline_serializer
from .models import Integration, EventSubscription, DeliveryLog
from .serializers import (
IntegrationSerializer,
EventSubscriptionSerializer,
DeliveryLogSerializer,
)
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
IsAdmin,
)
from .handlers.webhook import WebhookHandler
from .handlers.script import ScriptHandler
class IntegrationViewSet(viewsets.ModelViewSet):
queryset = Integration.objects.all()
serializer_class = IntegrationSerializer
def get_permissions(self):
try:
perms = permission_classes_by_action[self.action]
except KeyError:
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
perms = getattr(self, "permission_classes", [Authenticated])
return [perm() for perm in perms]
@action(detail=True, methods=["get"], url_path="subscriptions")
def list_subscriptions(self, request, pk=None):
qs = EventSubscription.objects.filter(integration_id=pk)
serializer = EventSubscriptionSerializer(qs, many=True)
return Response(serializer.data)
@extend_schema(
methods=["PUT"],
description=(
"Replace the integration's event subscriptions with the provided list. "
"Accepts a JSON array of subscription objects. "
"Existing subscriptions not in the list will be deleted. "
"The 'payload_template' field is only relevant for webhook integrations."
),
request=inline_serializer(
name="SetSubscriptionsRequest",
fields={
"event": serializers.CharField(help_text="Event name (e.g. 'channel_start')."),
"enabled": serializers.BooleanField(required=False, default=True),
"payload_template": serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="Custom payload template (webhook integrations only)."),
},
many=True,
),
responses={200: inline_serializer(
name="SetSubscriptionsResponse",
fields={
"event": serializers.CharField(),
"enabled": serializers.BooleanField(),
"payload_template": serializers.CharField(allow_null=True),
},
many=True,
)},
)
@action(detail=True, methods=["put"], url_path=r"subscriptions/set")
def set_subscriptions(self, request, pk=None):
"""
Replace the integration's subscriptions with the provided list.
Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...]
Any existing subscriptions not in the list will be deleted; missing ones will be created/updated.
"""
try:
integration = Integration.objects.get(pk=pk)
except Integration.DoesNotExist:
return Response(
{"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND
)
data = request.data
if not isinstance(data, list):
return Response(
{"detail": "Expected a list of subscriptions"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate incoming items using serializer (without integration field)
# We'll attach the integration explicitly
valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES)
incoming = []
for item in data:
if not isinstance(item, dict):
return Response(
{"detail": "Each subscription must be an object"},
status=status.HTTP_400_BAD_REQUEST,
)
event = item.get("event")
if event not in valid_events:
return Response(
{"detail": f"Invalid event: {event}"},
status=status.HTTP_400_BAD_REQUEST,
)
# Only accept payload_template when the integration is a webhook
payload_template = item.get("payload_template") if integration.type == "webhook" else None
incoming.append(
{
"event": event,
"enabled": bool(item.get("enabled", True)),
"payload_template": payload_template,
}
)
incoming_events = {s["event"] for s in incoming}
# Delete subscriptions that are no longer present
EventSubscription.objects.filter(integration=integration).exclude(
event__in=incoming_events
).delete()
# Upsert incoming subscriptions
updated = []
for sub in incoming:
obj, _created = EventSubscription.objects.update_or_create(
integration=integration,
event=sub["event"],
defaults={
"enabled": sub["enabled"],
"payload_template": sub.get("payload_template"),
},
)
updated.append(obj)
serializer = EventSubscriptionSerializer(updated, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin])
def test(self, request, pk=None):
"""
Execute a saved integration (connect) with a dummy payload to verify configuration.
"""
try:
integration = Integration.objects.get(pk=pk)
except Integration.DoesNotExist:
return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND)
# Build a dummy payload similar to system events
now = timezone.now().isoformat()
dummy_payload = {
"event": "test",
"timestamp": now,
"channel_name": "Test Channel",
"stream_name": "Test Stream",
"stream_url": "http://example.com/stream.m3u8",
"channel_url": "http://example.com/stream.m3u8",
"provider_name": "Test Provider",
"profile_used": "Default",
"test": True,
}
# Choose handler based on saved type
if integration.type == "webhook":
handler = WebhookHandler(integration, None, dummy_payload)
elif integration.type == "script":
handler = ScriptHandler(integration, None, dummy_payload)
else:
return Response(
{"success": False, "error": f"Unsupported integration type: {integration.type}"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = handler.execute()
return Response(
{
"success": bool(result.get("success")),
"type": integration.type,
"request_payload": dummy_payload,
"result": result,
},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{
"success": False,
"type": integration.type,
"request_payload": dummy_payload,
"error": str(e),
},
status=status.HTTP_502_BAD_GATEWAY,
)
class EventSubscriptionViewSet(viewsets.ModelViewSet):
queryset = EventSubscription.objects.all()
serializer_class = EventSubscriptionSerializer
class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet):
queryset = DeliveryLog.objects.all().order_by("-created_at")
serializer_class = DeliveryLogSerializer
filter_backends = [DjangoFilterBackend]
# Support server-side pagination with page_size query param
class ConnectLogsPagination(PageNumberPagination):
page_size = 50
page_size_query_param = "page_size"
max_page_size = 250
pagination_class = ConnectLogsPagination
def get_queryset(self):
qs = super().get_queryset()
# Optional filters: integration id and type
integration_id = self.request.query_params.get("integration")
if integration_id:
qs = qs.filter(subscription__integration_id=integration_id)
integration_type = self.request.query_params.get("type")
if integration_type:
qs = qs.filter(subscription__integration__type=integration_type)
return qs

8
apps/connect/apps.py Normal file
View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class ConnectConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.connect'
verbose_name = "Connect Integrations"
label = 'dispatcharr_connect'

View file

View file

View file

@ -0,0 +1,12 @@
# connect/handlers/base.py
import abc
class IntegrationHandler(abc.ABC):
def __init__(self, integration, subscription, payload):
self.integration = integration
self.subscription = subscription
self.payload = payload
@abc.abstractmethod
def execute(self):
pass

View file

@ -0,0 +1,81 @@
# connect/handlers/script.py
import os
import stat
import subprocess
from django.conf import settings
from .base import IntegrationHandler
def _is_path_allowed(real_path: str) -> bool:
# Ensure path is within one of the allowed directories
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
base_abs = os.path.abspath(base) + os.sep
if real_path.startswith(base_abs):
return True
return False
class ScriptHandler(IntegrationHandler):
def execute(self):
raw_path = self.integration.config.get("path")
if not raw_path:
raise ValueError("Missing 'path' in integration config")
# Resolve and validate path
real_path = os.path.abspath(os.path.realpath(raw_path))
if not os.path.exists(real_path):
raise FileNotFoundError(f"Script not found: {real_path}")
if not _is_path_allowed(real_path):
raise PermissionError(
f"Script path '{real_path}' not within allowed directories: "
f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}"
)
if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True):
if not os.access(real_path, os.X_OK):
raise PermissionError(f"Script is not executable: {real_path}")
if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True):
st = os.stat(real_path)
if st.st_mode & stat.S_IWOTH:
raise PermissionError(
f"Refusing to execute world-writable script: {real_path}"
)
# Build a sanitized minimal environment; avoid inheriting secrets
env = {
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
for key, value in (self.payload or {}).items():
env_key = f"DISPATCHARR_{str(key).upper()}"
env[env_key] = "" if value is None else str(value)
# Run with a timeout to prevent hanging scripts
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
result = subprocess.run(
[real_path],
capture_output=True,
text=True,
env=env,
timeout=timeout,
cwd=os.path.dirname(real_path) or None,
)
# Truncate outputs to avoid excessive memory/logging
stdout = result.stdout or ""
stderr = result.stderr or ""
if len(stdout) > max_out:
stdout = stdout[:max_out] + "... [truncated]"
if len(stderr) > max_out:
stderr = stderr[:max_out] + "... [truncated]"
return {
"exit_code": result.returncode,
"stdout": stdout,
"stderr": stderr,
"success": result.returncode == 0,
}

View file

@ -0,0 +1,21 @@
# connect/handlers/webhook.py
import requests, json, logging
from .base import IntegrationHandler
logger = logging.getLogger(__name__)
class WebhookHandler(IntegrationHandler):
def execute(self):
url = self.integration.config.get("url")
headers = self.integration.config.get("headers", {})
logger.info(self.payload)
try:
parsed = json.loads(self.payload)
headers["Content-Type"] = "application/json"
except Exception:
pass
response = requests.post(url, data=self.payload, headers=headers, timeout=10)
return {"status_code": response.status_code, "body": response.text, "success": response.ok}

View file

@ -0,0 +1,52 @@
# Generated by Django 5.2.9 on 2026-01-27 21:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='EventSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)),
('enabled', models.BooleanField(default=True)),
('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)),
],
),
migrations.CreateModel(
name='Integration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)),
('config', models.JSONField(default=dict)),
('enabled', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='DeliveryLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)),
('request_payload', models.JSONField(blank=True, default=dict)),
('response_payload', models.JSONField(blank=True, default=dict)),
('error_message', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')),
],
),
migrations.AddField(
model_name='eventsubscription',
name='integration',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-02-26 19:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_connect', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='eventsubscription',
name='event',
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-23 23:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_connect', '0002_alter_eventsubscription_event'),
]
operations = [
migrations.AlterField(
model_name='eventsubscription',
name='event',
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], max_length=100),
),
]

View file

49
apps/connect/models.py Normal file
View file

@ -0,0 +1,49 @@
from django.db import models
SUPPORTED_EVENTS = {
"channel_start": "Channel Started",
"channel_stop": "Channel Stopped",
"channel_reconnect": "Channel Reconnected",
"channel_error": "Channel Error",
"channel_failover": "Channel Failover",
"stream_switch": "Stream Switch",
"recording_start": "Recording Started",
"recording_end": "Recording Ended",
"epg_refresh": "EPG Refreshed",
"m3u_refresh": "M3U Refreshed",
"client_connect": "Client Connected",
"client_disconnect": "Client Disconnected",
"login_failed": "Login Failed",
"epg_blocked": "EPG Blocked",
"m3u_blocked": "M3U Blocked",
"vod_start": "VOD Started",
"vod_stop": "VOD Stopped",
}
class Integration(models.Model):
TYPE_CHOICES = [
("webhook", "Webhook"),
("api", "API"),
("script", "Custom Script"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=50, choices=TYPE_CHOICES)
config = models.JSONField(default=dict)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class EventSubscription(models.Model):
EVENT_CHOICES = list(SUPPORTED_EVENTS.items())
event = models.CharField(max_length=100, choices=EVENT_CHOICES)
integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions")
enabled = models.BooleanField(default=True)
payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload")
class DeliveryLog(models.Model):
subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs")
status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")])
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)

View file

@ -0,0 +1,68 @@
from rest_framework import serializers
from .models import Integration, EventSubscription, DeliveryLog
import os
class EventSubscriptionSerializer(serializers.ModelSerializer):
class Meta:
model = EventSubscription
fields = [
"id",
"event",
"enabled",
"payload_template",
"integration",
]
class IntegrationSerializer(serializers.ModelSerializer):
subscriptions = EventSubscriptionSerializer(many=True, read_only=True)
class Meta:
model = Integration
fields = [
"id",
"name",
"type",
"config",
"enabled",
"created_at",
"subscriptions",
]
def validate(self, attrs):
type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None)
config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {})
if type == "script":
path = (config or {}).get("path")
if not path or not isinstance(path, str):
raise serializers.ValidationError({"config": "Script config must include a 'path' string"})
real_path = os.path.abspath(os.path.realpath(path))
if not os.path.exists(real_path):
raise serializers.ValidationError({"config": f"Script path does not exist: {path}"})
elif type == "webhook":
url = (config or {}).get("url")
if not url or not isinstance(url, str):
raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"})
else:
raise serializers.ValidationError({"type": "Unsupported integration type"})
return attrs
class DeliveryLogSerializer(serializers.ModelSerializer):
subscription = EventSubscriptionSerializer(read_only=True)
class Meta:
model = DeliveryLog
fields = [
"id",
"subscription",
"status",
"request_payload",
"response_payload",
"error_message",
"created_at",
]

116
apps/connect/utils.py Normal file
View file

@ -0,0 +1,116 @@
# connect/utils.py
import logging, json
from django.template import Template, Context
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
from .handlers.webhook import WebhookHandler
from .handlers.script import ScriptHandler
from apps.plugins.loader import PluginManager
logger = logging.getLogger(__name__)
HANDLERS = {
"webhook": WebhookHandler,
"script": ScriptHandler,
}
def trigger_event(event_name, payload):
if event_name not in SUPPORTED_EVENTS:
logger.debug(f"Unsupported event '{event_name}' - skipping")
return
logger.debug(
f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}"
)
subscriptions = EventSubscription.objects.filter(
event=event_name, enabled=True
).select_related("integration")
count = subscriptions.count()
logger.info(f"Found {count} connect subscription(s) for event '{event_name}'")
# First, fetch all subscriptions and trigger
for sub in subscriptions:
integration = sub.integration
if not integration.enabled:
logger.debug(
f"Skipping disabled integration id={integration.id} name={integration.name}"
)
continue
# apply optional payload template (only for webhook integrations)
# If the rendered template is valid JSON, use that object as the payload.
# Otherwise, pass the rendered string as-is.
final_payload = payload
if integration.type == 'webhook' and sub.payload_template:
try:
template = Template(sub.payload_template)
final_payload = template.render(Context(payload)).strip()
except Exception as e:
logger.error(
f"Payload template render failed for subscription id={sub.id}: {e}"
)
final_payload = payload
handler_cls = HANDLERS.get(integration.type)
if not handler_cls:
DeliveryLog.objects.create(
subscription=sub,
status="failed",
request_payload=final_payload,
error_message=f"No handler for integration type '{integration.type}'",
)
logger.error(
f"No handler for integration type '{integration.type}' (integration id={integration.id})"
)
continue
handler = handler_cls(integration, sub, final_payload)
logger.debug(
f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}"
)
try:
result = handler.execute()
DeliveryLog.objects.create(
subscription=sub,
status="success" if result.get("success") else "failed",
request_payload=final_payload,
response_payload=result,
)
logger.info(
f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'"
)
except Exception as e:
DeliveryLog.objects.create(
subscription=sub,
status="failed",
request_payload=final_payload,
error_message=str(e),
)
logger.error(
f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}"
)
pm = PluginManager.get()
pm.discover_plugins(sync_db=False, use_cache=True)
plugins = pm.list_plugins()
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
for plugin in plugins:
if not plugin["enabled"]:
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
continue
logger.debug(json.dumps(plugin))
for action in plugin["actions"]:
if "events" in action and event_name in action["events"]:
key = plugin["key"]
params = {"event": event_name, "payload": payload}
action_name = action.get("label") or action.get("id")
action_id = action.get("id")
logger.debug(
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
)
if action_id:
pm.run_action(key, action_id, params)

View file

@ -10,13 +10,14 @@ from django.db.models import Q
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from datetime import timedelta
from .models import EPGSource, ProgramData, EPGData # Added ProgramData
from .models import EPGSource, ProgramData, EPGData
from .serializers import (
ProgramDataSerializer,
ProgramDetailSerializer,
EPGSourceSerializer,
EPGDataSerializer,
ProgramSearchResultSerializer,
) # Updated serializer
)
from .tasks import refresh_epg_data
from apps.accounts.permissions import (
Authenticated,
@ -35,7 +36,9 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
API endpoint that allows EPG sources to be viewed or edited.
"""
queryset = EPGSource.objects.all()
queryset = EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).all()
serializer_class = EPGSourceSerializer
def get_permissions(self):
@ -44,6 +47,17 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
def get_queryset(self):
from django.db.models import Exists, OuterRef
from apps.channels.models import Channel
return EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).annotate(
has_channels=Exists(
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
)
)
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG sources.")
return super().list(request, *args, **kwargs)
@ -98,7 +112,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
class ProgramViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG programs"""
queryset = ProgramData.objects.all()
queryset = ProgramData.objects.select_related("epg").all()
serializer_class = ProgramDataSerializer
def get_permissions(self):
@ -107,6 +121,16 @@ class ProgramViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
def get_serializer_class(self):
if self.action == 'retrieve':
return ProgramDetailSerializer
return ProgramDataSerializer
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return Response(serializer.data)
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG programs.")
return super().list(request, *args, **kwargs)
@ -139,17 +163,10 @@ class EPGGridAPIView(APIView):
f"EPGGridAPIView: Querying programs between {one_hour_ago} and {twenty_four_hours_later}."
)
# Use select_related to prefetch EPGData and include programs from the last hour
programs = ProgramData.objects.select_related("epg").filter(
# Programs that end after one hour ago (includes recently ended programs)
programs = ProgramData.objects.filter(
end_time__gt=one_hour_ago,
# AND start before the end time window
start_time__lt=twenty_four_hours_later,
)
count = programs.count()
logger.debug(
f"EPGGridAPIView: Found {count} program(s), including recently ended, currently running, and upcoming shows."
)
# Generate dummy programs for channels that have no EPG data OR dummy EPG sources
from apps.channels.models import Channel
@ -162,7 +179,7 @@ class EPGGridAPIView(APIView):
# Get channels with custom dummy EPG sources (generate on-demand with patterns)
channels_with_custom_dummy = Channel.objects.filter(
epg_data__epg_source__source_type='dummy'
).distinct()
).select_related('epg_data__epg_source').distinct()
# Log what we found
without_count = channels_without_epg.count()
@ -184,8 +201,33 @@ class EPGGridAPIView(APIView):
f"EPGGridAPIView: Found {without_count} channels needing standard dummy, {custom_count} needing custom dummy EPG."
)
# Serialize the regular programs
serialized_programs = ProgramDataSerializer(programs, many=True).data
# Serialize the regular programs using .values() to bypass DRF overhead
programs_qs = programs.values(
'id', 'start_time', 'end_time', 'title', 'sub_title',
'description', 'tvg_id', 'custom_properties',
)
serialized_programs = []
for p in programs_qs:
cp = p['custom_properties'] or {}
premiere_text = cp.get('premiere_text', '')
serialized_programs.append({
'id': p['id'],
'start_time': p['start_time'],
'end_time': p['end_time'],
'title': p['title'],
'sub_title': p['sub_title'],
'description': p['description'],
'tvg_id': p['tvg_id'],
'season': cp.get('season'),
'episode': cp.get('episode'),
'is_new': bool(cp.get('new')),
'is_live': bool(cp.get('live')),
'is_premiere': bool(cp.get('premiere')),
'is_finale': bool(premiere_text and 'finale' in premiere_text.lower()),
})
logger.debug(
f"EPGGridAPIView: Found {len(serialized_programs)} program(s), including recently ended, currently running, and upcoming shows."
)
# Humorous program descriptions based on time of day - same as in output/views.py
time_descriptions = {
@ -277,6 +319,7 @@ class EPGGridAPIView(APIView):
logger.debug(f"Generated {len(generated)} custom dummy programs for {channel.name}")
# Convert generated programs to API format
for program in generated:
prog_custom = program.get('custom_properties') or {}
dummy_program = {
"id": f"dummy-custom-{channel.id}-{program['start_time'].hour}",
"epg": {"tvg_id": dummy_tvg_id, "name": channel.name},
@ -285,8 +328,14 @@ class EPGGridAPIView(APIView):
"title": program['title'],
"description": program['description'],
"tvg_id": dummy_tvg_id,
"sub_title": None,
"custom_properties": None,
"sub_title": program.get('sub_title'),
"custom_properties": prog_custom if prog_custom else None,
"season": None,
"episode": None,
"is_new": prog_custom.get('new', False),
"is_live": bool(prog_custom.get('live')),
"is_premiere": False,
"is_finale": False,
}
dummy_programs.append(dummy_program)
else:
@ -344,6 +393,12 @@ class EPGGridAPIView(APIView):
"tvg_id": dummy_tvg_id,
"sub_title": None,
"custom_properties": None,
"season": None,
"episode": None,
"is_new": False,
"is_live": False,
"is_premiere": False,
"is_finale": False,
}
dummy_programs.append(dummy_program)
@ -376,7 +431,13 @@ class EPGImportAPIView(APIView):
return [Authenticated()]
@extend_schema(
description="Triggers an EPG data import",
description="Triggers an EPG data refresh for the given source.",
request=inline_serializer(
name="EPGImportRequest",
fields={
"id": serializers.IntegerField(help_text="ID of the EPG source to refresh."),
},
),
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
@ -398,7 +459,7 @@ class EPGImportAPIView(APIView):
refresh_epg_data.delay(epg_id) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
return Response(
{"success": True, "message": "EPG data import initiated."},
{"success": True, "message": "EPG data refresh initiated."},
status=status.HTTP_202_ACCEPTED,
)
@ -443,43 +504,32 @@ class CurrentProgramsAPIView(APIView):
request=inline_serializer(
name="CurrentProgramsRequest",
fields={
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
"channel_uuids": serializers.ListField(
child=serializers.CharField(),
required=False,
allow_null=True,
help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.",
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
),
},
),
responses={200: ProgramDataSerializer(many=True)},
)
def post(self, request, format=None):
# Get channel IDs from request body
channel_ids = request.data.get('channel_ids', None)
# Import Channel model
from apps.channels.models import Channel
# Build query for channels with EPG data
query = Channel.objects.filter(epg_data__isnull=False)
# Filter by specific channel IDs if provided
if channel_ids is not None:
if not isinstance(channel_ids, list):
channel_uuids = request.data.get('channel_uuids', None)
if channel_uuids is not None:
if not isinstance(channel_uuids, list):
return Response(
{"error": "channel_ids must be an array of integers or null"},
{"error": "channel_uuids must be an array of strings or null"},
status=status.HTTP_400_BAD_REQUEST
)
try:
channel_ids = [int(id) for id in channel_ids]
except (ValueError, TypeError):
return Response(
{"error": "channel_ids must contain valid integers"},
status=status.HTTP_400_BAD_REQUEST
)
query = query.filter(id__in=channel_ids)
query = query.filter(uuid__in=channel_uuids)
# Get channels with EPG data
channels = query.select_related('epg_data')
@ -492,16 +542,15 @@ class CurrentProgramsAPIView(APIView):
for channel in channels:
# Query for current program
program = ProgramData.objects.filter(
program = ProgramData.objects.select_related("epg").filter(
epg=channel.epg_data,
start_time__lte=now,
end_time__gt=now
).first()
if program:
# Serialize program and add channel_id for easy mapping
program_data = ProgramDataSerializer(program).data
program_data['channel_id'] = channel.id
program_data['channel_uuid'] = str(channel.uuid)
current_programs.append(program_data)
@ -517,7 +566,7 @@ import re as regex_module
def _build_q_object(field_name, term, use_regex=False, whole_words=False):
"""
Build a single Q object for a search term.
Args:
field_name: Django ORM field name
term: Search term
@ -527,7 +576,7 @@ def _build_q_object(field_name, term, use_regex=False, whole_words=False):
term = term.strip()
if not term:
return Q()
if use_regex:
# Use Django's __iregex (case-insensitive regex)
return Q(**{f'{field_name}__iregex': term})
@ -559,27 +608,27 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False)
Supports mixed operators evaluated left-to-right: "sports AND football OR basketball"
Supports nested groups: "(A OR B) AND (C OR D)"
"""
def parse_expression(expr):
"""Recursively parse expression with parentheses support"""
expr = expr.strip()
# Handle parentheses by recursively processing innermost groups
while '(' in expr:
paren_start = expr.rfind('(')
paren_end = expr.find(')', paren_start)
if paren_end == -1:
return Q() # Mismatched parentheses
# Recursively parse the group
group_expr = expr[paren_start + 1:paren_end]
group_result = parse_expression(group_expr)
# Replace group with placeholder to avoid re-parsing
# We build up results as we go
before = expr[:paren_start]
after = expr[paren_end + 1:]
# For now, we need to handle this differently - evaluate left to right
# Extract operators around the group
if before:
@ -595,7 +644,7 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False)
else:
operator = None
before = None
if after:
after = after.lstrip()
if after.upper().startswith('AND '):
@ -606,24 +655,24 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False)
operator = ' OR ' if not operator else operator
else:
after = None
# Reconstruct without parentheses for simpler processing
parts = [before, after]
expr = ' AND '.join(p for p in parts if p)
# Tokenize on " AND " and " OR " boundaries (no parentheses now)
tokens = []
operators = []
remaining = expr
while remaining:
and_pos = remaining.upper().find(' AND ')
or_pos = remaining.upper().find(' OR ')
if and_pos == -1 and or_pos == -1:
tokens.append(remaining.strip())
break
if and_pos == -1:
pos, op, op_len = or_pos, '|', 4
elif or_pos == -1:
@ -632,16 +681,16 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False)
pos, op, op_len = and_pos, '&', 5
else:
pos, op, op_len = or_pos, '|', 4
token = remaining[:pos].strip()
if token:
tokens.append(token)
operators.append(op)
remaining = remaining[pos + op_len:]
if not tokens:
return Q()
# Build Q chain
result = _build_q_object(field_name, tokens[0], use_regex, whole_words)
for i, op in enumerate(operators):
@ -650,9 +699,9 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False)
result = result & next_q
else:
result = result | next_q
return result
return parse_expression(raw_value)
@ -737,8 +786,8 @@ class ProgramSearchAPIView(APIView):
""",
parameters=[
OpenApiParameter(
'title',
OpenApiTypes.STR,
'title',
OpenApiTypes.STR,
description='Title search query. Supports AND/OR operators and parentheses: `(Newcastle OR NEW) AND (Villa OR AST)`. Space-separated terms default to AND.',
examples=[
'football',
@ -750,8 +799,8 @@ class ProgramSearchAPIView(APIView):
OpenApiParameter('title_regex', OpenApiTypes.BOOL, description='Enable regex matching for title (case-insensitive). Example: `^The` matches titles starting with "The".'),
OpenApiParameter('title_whole_words', OpenApiTypes.BOOL, description='Match whole words only in title. Prevents "NEW" from matching "News".'),
OpenApiParameter(
'description',
OpenApiTypes.STR,
'description',
OpenApiTypes.STR,
description='Description search query. Same syntax and features as title search.'
),
OpenApiParameter('description_regex', OpenApiTypes.BOOL, description='Enable regex matching for description (case-insensitive).'),

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-21 14:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0021_epgsource_priority'),
]
operations = [
migrations.AlterField(
model_name='epgdata',
name='name',
field=models.CharField(max_length=512),
),
]

View file

@ -137,7 +137,7 @@ class EPGData(models.Model):
# Removed the Channel foreign key. We now just store the original tvg_id
# and a name (which might simply be the tvg_id if no real channel exists).
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=255)
name = models.CharField(max_length=512)
icon_url = models.URLField(max_length=500, null=True, blank=True)
epg_source = models.ForeignKey(
EPGSource,

View file

@ -5,6 +5,7 @@ from apps.channels.models import Channel, Stream
class EPGSourceSerializer(serializers.ModelSerializer):
epg_data_count = serializers.SerializerMethodField()
has_channels = serializers.BooleanField(read_only=True, default=False)
read_only_fields = ['created_at', 'updated_at']
url = serializers.CharField(
required=False,
@ -12,6 +13,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
allow_null=True,
validators=[validate_flexible_url]
)
cron_expression = serializers.CharField(required=False, allow_blank=True, default='')
class Meta:
model = EPGSource
@ -24,24 +26,134 @@ class EPGSourceSerializer(serializers.ModelSerializer):
'is_active',
'file_path',
'refresh_interval',
'cron_expression',
'priority',
'status',
'last_message',
'created_at',
'updated_at',
'custom_properties',
'epg_data_count'
'epg_data_count',
'has_channels',
]
def get_epg_data_count(self, obj):
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
return obj.epgs.count()
def to_representation(self, instance):
data = super().to_representation(instance)
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
# But first check if we have a transient _cron_expression (from create/update before signal runs)
cron_expr = ''
if hasattr(instance, '_cron_expression'):
cron_expr = instance._cron_expression
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
data['cron_expression'] = cron_expr
return data
def update(self, instance, validated_data):
# Pop cron_expression before it reaches model fields
# If not present (partial update), preserve the existing cron from the PeriodicTask
if 'cron_expression' in validated_data:
cron_expr = validated_data.pop('cron_expression')
else:
cron_expr = ''
if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
instance._cron_expression = cron_expr
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
def create(self, validated_data):
cron_expr = validated_data.pop('cron_expression', '')
instance = EPGSource(**validated_data)
instance._cron_expression = cron_expr
instance.save()
return instance
class ProgramDataSerializer(serializers.ModelSerializer):
class Meta:
model = ProgramData
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description', 'tvg_id']
def to_representation(self, obj):
data = super().to_representation(obj)
cp = obj.custom_properties or {}
data['season'] = cp.get('season')
data['episode'] = cp.get('episode')
data['is_new'] = bool(cp.get('new'))
data['is_live'] = bool(cp.get('live'))
data['is_premiere'] = bool(cp.get('premiere'))
premiere_text = cp.get('premiere_text', '')
data['is_finale'] = bool(premiere_text and 'finale' in premiere_text.lower())
return data
class ProgramDetailSerializer(ProgramDataSerializer):
"""Rich serializer for program detail view — extends slim serializer with full custom_properties."""
def to_representation(self, obj):
data = super().to_representation(obj)
cp = obj.custom_properties or {}
# Categories
data['categories'] = cp.get('categories') or []
# Content rating
data['rating'] = cp.get('rating')
data['rating_system'] = cp.get('rating_system')
# Star ratings
data['star_ratings'] = cp.get('star_ratings') or []
# Credits — flatten from XMLTV structure
credits = cp.get('credits') or {}
data['credits'] = {
'actors': credits.get('actor') or [],
'directors': credits.get('director') or [],
'writers': credits.get('writer') or [],
'producers': credits.get('producer') or [],
'presenters': credits.get('presenter') or [],
}
# Video/audio quality
video = cp.get('video') or {}
data['video_quality'] = video.get('quality')
data['aspect_ratio'] = video.get('aspect')
audio = cp.get('audio') or {}
data['stereo'] = audio.get('stereo')
# Previously shown (rerun)
data['is_previously_shown'] = bool(cp.get('previously_shown'))
# Geographic/language
data['country'] = cp.get('country')
data['language'] = cp.get('language')
# Dates
data['production_date'] = cp.get('date')
previously_shown = cp.get('previously_shown_details') or {}
data['original_air_date'] = previously_shown.get('start')
# External IDs
data['imdb_id'] = cp.get('imdb.com_id')
data['tmdb_id'] = cp.get('themoviedb.org_id')
data['tvdb_id'] = cp.get('thetvdb.com_id')
# Images
data['icon'] = cp.get('icon')
data['images'] = cp.get('images') or []
return data
class EPGDataSerializer(serializers.ModelSerializer):
"""
Only returns the tvg_id and the 'name' field from EPGData.

View file

@ -2,7 +2,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import EPGSource, EPGData
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
from core.utils import is_protected_path, send_websocket_update
import json
import logging
@ -70,10 +70,11 @@ def create_dummy_epg_data(sender, instance, created, **kwargs):
logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})")
@receiver(post_save, sender=EPGSource)
def create_or_update_refresh_task(sender, instance, **kwargs):
def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs):
"""
Create or update a Celery Beat periodic task when an EPGSource is created/updated.
Skip creating tasks for dummy EPG sources as they don't need refreshing.
Supports both interval-based and cron-based scheduling via the shared utility.
"""
# Skip task creation for dummy EPGs
if instance.source_type == 'dummy':
@ -83,39 +84,48 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
instance.refresh_task.save(update_fields=['enabled'])
return
# Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message
# updates from the refresh task itself). We only need to reschedule when schedule-relevant
# fields change or when _cron_expression was explicitly set by the serializer.
SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'}
if (
not created
and update_fields is not None
and not (set(update_fields) & SCHEDULE_FIELDS)
and not hasattr(instance, '_cron_expression')
):
return
task_name = f"epg_source-refresh-{instance.id}"
interval, _ = IntervalSchedule.objects.get_or_create(
every=int(instance.refresh_interval),
period=IntervalSchedule.HOURS
should_be_enabled = instance.is_active
# Read cron_expression from transient attribute set by the serializer.
# If not set (e.g. save came from a task updating status/last_message),
# preserve the existing crontab so we don't accidentally revert to interval.
if hasattr(instance, "_cron_expression"):
cron_expr = instance._cron_expression
else:
cron_expr = ""
try:
existing_task = instance.refresh_task
if existing_task and existing_task.crontab:
ct = existing_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
except Exception:
pass
task = create_or_update_periodic_task(
task_name=task_name,
celery_task_path="apps.epg.tasks.refresh_epg_data",
kwargs={"source_id": instance.id},
interval_hours=int(instance.refresh_interval),
cron_expression=cron_expr,
enabled=should_be_enabled,
)
task, created = PeriodicTask.objects.get_or_create(name=task_name, defaults={
"interval": interval,
"task": "apps.epg.tasks.refresh_epg_data",
"kwargs": json.dumps({"source_id": instance.id}),
"enabled": instance.refresh_interval != 0 and instance.is_active,
})
update_fields = []
if created:
task.interval = interval
if task.interval != interval:
task.interval = interval
update_fields.append("interval")
# Check both refresh_interval and is_active to determine if task should be enabled
should_be_enabled = instance.refresh_interval != 0 and instance.is_active
if task.enabled != should_be_enabled:
task.enabled = should_be_enabled
update_fields.append("enabled")
if update_fields:
task.save(update_fields=update_fields)
if instance.refresh_task != task:
instance.refresh_task = task
instance.save(update_fields=["refresh_task"]) # Fixed field name
instance.save(update_fields=["refresh_task"])
@receiver(post_delete, sender=EPGSource)
def delete_refresh_task(sender, instance, **kwargs):

View file

@ -2,6 +2,7 @@
import logging
import gzip
import html.entities
import os
import uuid
import requests
@ -15,7 +16,7 @@ import zipfile
from celery import shared_task
from django.conf import settings
from django.db import transaction
from django.db import connection, transaction
from django.utils import timezone
from apps.channels.models import Channel
from core.models import UserAgent, CoreSettings
@ -24,10 +25,107 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from .models import EPGSource, EPGData, ProgramData
from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event
from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event
logger = logging.getLogger(__name__)
# DOCTYPE internal subset for XMLTV files. Declares all 252 HTML 4 named
# entities so lxml/libxml2 can resolve references like &eacute; correctly
# instead of silently dropping them in recovery mode.
# The 5 XML-predefined entities (amp, lt, gt, quot, apos) are always
# recognised by the XML spec and must not be redeclared.
_XML_ENTITIES = frozenset({'amp', 'lt', 'gt', 'quot', 'apos'})
def _build_html_entity_doctype() -> bytes:
"""Build a DOCTYPE internal subset declaring all HTML 4 named entities."""
lines = [b'<!DOCTYPE tv [\n']
for name, codepoint in sorted(html.entities.name2codepoint.items()):
if name not in _XML_ENTITIES:
# Numeric character references are always valid XML regardless of codepoint.
lines.append(f'<!ENTITY {name} "&#x{codepoint:X};">\n'.encode('ascii'))
lines.append(b']>\n')
return b''.join(lines)
_HTML_ENTITY_DOCTYPE = _build_html_entity_doctype()
class _PrependStream:
"""Wraps an open binary file and prepends a bytes prefix to its content.
Used by _open_xmltv_file to inject a DOCTYPE entity block before the
file content reaches lxml's iterparse, with zero disk I/O.
"""
__slots__ = ('_prefix', '_prefix_pos', '_file')
def __init__(self, prefix: bytes, file_obj):
self._prefix = prefix
self._prefix_pos = 0
self._file = file_obj
def read(self, size=-1):
prefix_len = len(self._prefix)
if self._prefix_pos >= prefix_len:
return self._file.read(size)
remaining = prefix_len - self._prefix_pos
if size < 0:
chunk = self._prefix[self._prefix_pos:] + self._file.read()
self._prefix_pos = prefix_len
return chunk
if size <= remaining:
chunk = self._prefix[self._prefix_pos:self._prefix_pos + size]
self._prefix_pos += size
return chunk
chunk = self._prefix[self._prefix_pos:]
self._prefix_pos = prefix_len
return chunk + self._file.read(size - remaining)
def close(self):
self._file.close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def _open_xmltv_file(file_path: str):
"""Open an XMLTV file for lxml iterparse, injecting an HTML entity DOCTYPE.
Prepends a <!DOCTYPE tv [...]> block that declares all 252 HTML 4 named
entities so lxml/libxml2 resolves references like &eacute; correctly
instead of silently dropping them in recovery mode. This involves zero
disk I/O the DOCTYPE is streamed in-memory before the file content.
If the file already contains a <!DOCTYPE> declaration the file is returned
unchanged; a second DOCTYPE would be invalid XML.
The caller is responsible for closing the returned object.
"""
f = open(file_path, 'rb')
start = f.read(512)
# Do not inject if the file already declares a DOCTYPE.
if b'<!DOCTYPE' in start or b'<!doctype' in start.lower():
f.seek(0)
return f
# Insert the DOCTYPE after the XML declaration if one is present.
xml_pos = start.find(b'<?xml')
if xml_pos >= 0:
decl_end = start.find(b'?>', xml_pos)
if decl_end >= 0:
xml_decl = start[:decl_end + 2]
f.seek(decl_end + 2)
return _PrependStream(xml_decl + b'\n' + _HTML_ENTITY_DOCTYPE, f)
# No XML declaration — insert DOCTYPE at the very start of the file.
f.seek(0)
return _PrependStream(_HTML_ENTITY_DOCTYPE, f)
def validate_icon_url_fast(icon_url, max_length=None):
"""
@ -146,12 +244,15 @@ def refresh_all_epg_data():
return "EPG data refreshed."
@shared_task
@shared_task(time_limit=14400)
def refresh_epg_data(source_id):
if not acquire_task_lock('refresh_epg_data', source_id):
logger.debug(f"EPG refresh for {source_id} already running")
return
lock_renewer = TaskLockRenewer('refresh_epg_data', source_id)
lock_renewer.start()
source = None
try:
# Try to get the EPG source
@ -168,6 +269,7 @@ def refresh_epg_data(source_id):
logger.info(f"No orphaned task found for EPG source {source_id}")
# Release the lock and exit
lock_renewer.stop()
release_task_lock('refresh_epg_data', source_id)
# Force garbage collection before exit
gc.collect()
@ -176,6 +278,7 @@ def refresh_epg_data(source_id):
# The source exists but is not active, just skip processing
if not source.is_active:
logger.info(f"EPG source {source_id} is not active. Skipping.")
lock_renewer.stop()
release_task_lock('refresh_epg_data', source_id)
# Force garbage collection before exit
gc.collect()
@ -184,6 +287,7 @@ def refresh_epg_data(source_id):
# Skip refresh for dummy EPG sources - they don't need refreshing
if source.source_type == 'dummy':
logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})")
lock_renewer.stop()
release_task_lock('refresh_epg_data', source_id)
gc.collect()
return
@ -194,6 +298,7 @@ def refresh_epg_data(source_id):
fetch_success = fetch_xmltv(source)
if not fetch_success:
logger.error(f"Failed to fetch XMLTV for source {source.name}")
lock_renewer.stop()
release_task_lock('refresh_epg_data', source_id)
# Force garbage collection before exit
gc.collect()
@ -202,6 +307,7 @@ def refresh_epg_data(source_id):
parse_channels_success = parse_channels_only(source)
if not parse_channels_success:
logger.error(f"Failed to parse channels for source {source.name}")
lock_renewer.stop()
release_task_lock('refresh_epg_data', source_id)
# Force garbage collection before exit
gc.collect()
@ -234,6 +340,7 @@ def refresh_epg_data(source_id):
source = None
# Force garbage collection before releasing the lock
gc.collect()
lock_renewer.stop()
release_task_lock('refresh_epg_data', source_id)
@ -388,42 +495,41 @@ def fetch_xmltv(source):
# Download to temporary file
with open(temp_download_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=16384): # Increased chunk size for better performance
if chunk:
f.write(chunk)
for chunk in response.iter_content(chunk_size=16384):
f.write(chunk)
downloaded += len(chunk)
elapsed_time = time.time() - start_time
downloaded += len(chunk)
elapsed_time = time.time() - start_time
# Calculate download speed in KB/s
speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0
# Calculate download speed in KB/s
speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0
# Calculate progress percentage
if total_size and total_size > 0:
progress = min(100, int((downloaded / total_size) * 100))
else:
# If no content length header, estimate progress
progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown
# Calculate progress percentage
if total_size and total_size > 0:
progress = min(100, int((downloaded / total_size) * 100))
else:
# If no content length header, estimate progress
progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown
# Time remaining (in seconds)
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0
# Time remaining (in seconds)
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0
# Only send updates at specified intervals to avoid flooding
current_time = time.time()
if current_time - last_update_time >= update_interval and progress > 0:
last_update_time = current_time
send_epg_update(
source.id,
"downloading",
progress,
speed=round(speed, 2),
elapsed_time=round(elapsed_time, 1),
time_remaining=round(time_remaining, 1),
downloaded=f"{downloaded / (1024 * 1024):.2f} MB"
)
# Only send updates at specified intervals to avoid flooding
current_time = time.time()
if current_time - last_update_time >= update_interval and progress > 0:
last_update_time = current_time
send_epg_update(
source.id,
"downloading",
progress,
speed=round(speed, 2),
elapsed_time=round(elapsed_time, 1),
time_remaining=round(time_remaining, 1),
downloaded=f"{downloaded / (1024 * 1024):.2f} MB"
)
# Explicitly delete the chunk to free memory immediately
del chunk
# Explicitly delete the chunk to free memory immediately
del chunk
# Send completion notification
send_epg_update(source.id, "downloading", 100)
@ -517,6 +623,7 @@ def fetch_xmltv(source):
source.save(update_fields=['status'])
logger.info(f"Cached EPG file saved to {source.file_path}")
return True
except requests.exceptions.HTTPError as e:
@ -831,7 +938,8 @@ def parse_channels_only(source):
# Replace full dictionary load with more efficient lookup set
existing_tvg_ids = set()
existing_epgs = {} # Initialize the dictionary that will lazily load objects
existing_epgs = {}
scanned_tvg_ids = set() # Track tvg_ids seen in the current scan for stale cleanup
last_id = 0
chunk_size = 5000
@ -858,6 +966,7 @@ def parse_channels_only(source):
batch_size = 500 # Process in batches to limit memory usage
progress = 0 # Initialize progress variable here
icon_url_max_length = EPGData._meta.get_field('icon_url').max_length # Get max length for icon_url field
name_max_length = EPGData._meta.get_field('name').max_length # Get max length for name field
# Track memory at key points
if process:
@ -879,7 +988,7 @@ def parse_channels_only(source):
# Open the file - no need to check file type since it's always XML now
logger.debug(f"Opening file for channel parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
if process:
logger.debug(f"[parse_channels_only] Memory after opening file: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -899,6 +1008,7 @@ def parse_channels_only(source):
channel_count += 1
tvg_id = elem.get('id', '').strip()
if tvg_id:
scanned_tvg_ids.add(tvg_id)
display_name = None
icon_url = None
for child in elem:
@ -913,6 +1023,10 @@ def parse_channels_only(source):
if not display_name:
display_name = tvg_id
if display_name and len(display_name) > name_max_length:
logger.warning(f"EPG display name too long ({len(display_name)} > {name_max_length}), truncating: {display_name[:80]}...")
display_name = display_name[:name_max_length]
# Use lazy loading approach to reduce memory usage
if tvg_id in existing_tvg_ids:
# Only fetch the object if we need to update it and it hasn't been loaded yet
@ -1046,6 +1160,16 @@ def parse_channels_only(source):
if epgs_to_update:
EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"])
logger.debug(f"[parse_channels_only] Updated final batch of {len(epgs_to_update)} EPG entries")
# Clean up stale EPGData: entries that existed before the scan but weren't seen, and aren't mapped to any channel.
# Use existing_tvg_ids - scanned_tvg_ids to avoid a full-table scan with a large EXCLUDE list.
potentially_stale = existing_tvg_ids - scanned_tvg_ids
if potentially_stale:
stale_qs = EPGData.objects.filter(epg_source=source, tvg_id__in=potentially_stale, channels__isnull=True)
deleted_count, _ = stale_qs.delete()
if deleted_count:
logger.info(f"[parse_channels_only] Cleaned up {deleted_count} stale EPG entries not in current scan and unmapped to any channel")
if process:
logger.debug(f"[parse_channels_only] Memory after final batch creation: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -1112,6 +1236,9 @@ def parse_channels_only(source):
existing_epgs = None
epgs_to_create = None
epgs_to_update = None
if 'scanned_tvg_ids' in locals() and scanned_tvg_ids is not None:
scanned_tvg_ids.clear()
scanned_tvg_ids = None
cleanup_memory(log_usage=should_log_memory, force_collection=True)
except Exception as e:
logger.warning(f"Cleanup error: {e}")
@ -1126,12 +1253,15 @@ def parse_channels_only(source):
@shared_task
@shared_task(time_limit=3600, soft_time_limit=3500)
def parse_programs_for_tvg_id(epg_id):
if not acquire_task_lock('parse_epg_programs', epg_id):
logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task")
return "Task already running"
lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id)
lock_renewer.start()
source_file = None
program_parser = None
programs_to_create = []
@ -1161,11 +1291,13 @@ def parse_programs_for_tvg_id(epg_id):
# Skip program parsing for dummy EPG sources - they don't have program data files
if epg_source.source_type == 'dummy':
logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})")
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
return
if not Channel.objects.filter(epg_data=epg).exists():
logger.info(f"No channels matched to EPG {epg.tvg_id}")
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
return
@ -1207,6 +1339,7 @@ def parse_programs_for_tvg_id(epg_id):
epg_source.last_message = f"Failed to download EPG data, cannot parse programs"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file")
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
return
@ -1217,6 +1350,7 @@ def parse_programs_for_tvg_id(epg_id):
epg_source.last_message = f"Failed to download EPG data, file missing after download"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download")
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
return
@ -1232,6 +1366,7 @@ def parse_programs_for_tvg_id(epg_id):
epg_source.last_message = f"No URL provided, cannot fetch EPG data"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided")
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
return
@ -1254,7 +1389,7 @@ def parse_programs_for_tvg_id(epg_id):
try:
# Open the file directly - no need to check compression
logger.debug(f"Opening file for parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
@ -1288,11 +1423,28 @@ def parse_programs_for_tvg_id(epg_id):
logger.trace(f"Number of custom properties: {len(custom_props)}")
custom_properties_json = custom_props
# Fallback: extract S/E from description when episode-num
# elements didn't provide them
if desc:
has_season = (custom_properties_json or {}).get('season') is not None
has_episode = (custom_properties_json or {}).get('episode') is not None
if not has_season or not has_episode:
d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc)
if d_season is not None and d_episode is not None:
if custom_properties_json is None:
custom_properties_json = {}
if not has_season:
custom_properties_json['season'] = d_season
if not has_episode:
custom_properties_json['episode'] = d_episode
custom_properties_json['season_episode_source'] = 'description'
desc = cleaned_desc
programs_to_create.append(ProgramData(
epg=epg,
start_time=start_time,
end_time=end_time,
title=title,
title=title[:255],
description=desc,
sub_title=sub_title,
tvg_id=epg.tvg_id,
@ -1379,7 +1531,7 @@ def parse_programs_for_tvg_id(epg_id):
epg_source = None
# Add comprehensive cleanup before releasing lock
cleanup_memory(log_usage=should_log_memory, force_collection=True)
# Memory tracking after processing
# Memory tracking after processing
if process:
try:
mem_after = process.memory_info().rss / 1024 / 1024
@ -1389,6 +1541,7 @@ def parse_programs_for_tvg_id(epg_id):
process = None
epg = None
programs_processed = None
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
@ -1509,7 +1662,7 @@ def parse_programs_for_source(epg_source, tvg_id=None):
try:
logger.debug(f"Opening file for single-pass parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
@ -1548,12 +1701,29 @@ def parse_programs_for_source(epg_source, tvg_id=None):
custom_props = extract_custom_properties(elem)
custom_properties_json = custom_props if custom_props else None
# Fallback: extract S/E from description when episode-num
# elements didn't provide them
if desc:
has_season = (custom_properties_json or {}).get('season') is not None
has_episode = (custom_properties_json or {}).get('episode') is not None
if not has_season or not has_episode:
d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc)
if d_season is not None and d_episode is not None:
if custom_properties_json is None:
custom_properties_json = {}
if not has_season:
custom_properties_json['season'] = d_season
if not has_episode:
custom_properties_json['episode'] = d_episode
custom_properties_json['season_episode_source'] = 'description'
desc = cleaned_desc
epg_id = tvg_id_to_epg_id[channel_id]
all_programs_to_create.append(ProgramData(
epg_id=epg_id,
start_time=start_time,
end_time=end_time,
title=title,
title=title[:255],
description=desc,
sub_title=sub_title,
tvg_id=channel_id,
@ -1605,6 +1775,10 @@ def parse_programs_for_source(epg_source, tvg_id=None):
batch_size = 1000
try:
with transaction.atomic():
# Kill any individual statement that hangs longer than 10 minutes.
# SET LOCAL automatically resets when this transaction ends (commit or rollback).
with connection.cursor() as cursor:
cursor.execute("SET LOCAL statement_timeout = '10min'")
# Delete existing programs for mapped EPGs
deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0]
logger.debug(f"Deleted {deleted_count} existing programs")
@ -1839,6 +2013,10 @@ def parse_schedules_direct_time(time_str):
raise
# Re-export from utils to preserve backward compatibility for any callers
from apps.epg.utils import extract_season_episode_from_description, _ONSCREEN_RE # noqa: F401
# Helper function to extract custom properties - moved to a separate function to clean up the code
def extract_custom_properties(prog):
# Create a new dictionary for each call
@ -1874,8 +2052,16 @@ def extract_custom_properties(prog):
except ValueError:
pass
elif system == 'onscreen' and ep_num.text:
# Just store the raw onscreen format
custom_props['onscreen_episode'] = ep_num.text.strip()
onscreen_text = ep_num.text.strip()
custom_props['onscreen_episode'] = onscreen_text
# Extract season/episode from onscreen format if not already set by xmltv_ns
if 'season' not in custom_props or 'episode' not in custom_props:
match = _ONSCREEN_RE.search(onscreen_text)
if match:
if 'season' not in custom_props:
custom_props['season'] = int(match.group(1))
if 'episode' not in custom_props:
custom_props['episode'] = int(match.group(2))
elif system == 'dd_progid' and ep_num.text:
# Store the dd_progid format
custom_props['dd_progid'] = ep_num.text.strip()

View file

View file

@ -0,0 +1,195 @@
import os
import tempfile
from django.test import TestCase
from apps.epg.tasks import (
_NAMED_ENTITY_RE,
_detect_xml_encoding,
_replace_html_entity,
_resolve_html_entities,
)
class ReplaceHtmlEntityTests(TestCase):
"""Tests for the regex callback that resolves individual HTML entities."""
def _sub(self, text):
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text)
def test_french_accented(self):
self.assertEqual(self._sub("Cha&icirc;ne T&eacute;l&eacute;"), "Chaîne Télé")
def test_german_umlauts(self):
self.assertEqual(self._sub("M&uuml;nchen &Uuml;bersicht &szlig;"), "München Übersicht ß")
def test_spanish(self):
self.assertEqual(self._sub("Espa&ntilde;a &iquest;Qu&eacute;?"), "España ¿Qué?")
def test_portuguese(self):
self.assertEqual(self._sub("Comunica&ccedil;&atilde;o"), "Comunicação")
def test_scandinavian(self):
self.assertEqual(self._sub("Norsk &oslash; &aring; &aelig;"), "Norsk ø å æ")
def test_greek_letters(self):
self.assertEqual(self._sub("&alpha;&beta;&gamma;"), "αβγ")
def test_currency_and_symbols(self):
self.assertEqual(self._sub("&copy; &euro; &pound; &yen;"), "© € £ ¥")
def test_preserves_xml_amp(self):
self.assertEqual(self._sub("A &amp; B"), "A &amp; B")
def test_preserves_xml_lt_gt(self):
self.assertEqual(self._sub("&lt;tag&gt;"), "&lt;tag&gt;")
def test_preserves_xml_quot_apos(self):
self.assertEqual(self._sub("&quot;hello&apos;"), "&quot;hello&apos;")
def test_preserves_uppercase_xml_entities(self):
"""&AMP;, &LT;, &GT;, &QUOT; resolve to XML-special chars; must not be replaced."""
self.assertEqual(self._sub("&AMP;"), "&AMP;")
self.assertEqual(self._sub("&LT;"), "&LT;")
self.assertEqual(self._sub("&GT;"), "&GT;")
self.assertEqual(self._sub("&QUOT;"), "&QUOT;")
def test_partial_entity_match_preserved(self):
"""html.unescape can partially match &amp inside &ampersand; — must not corrupt."""
self.assertEqual(self._sub("&ampersand;"), "&ampersand;")
def test_mixed_html_and_xml_entities(self):
self.assertEqual(
self._sub("R&eacute;sum&eacute; &amp; Co &lt;test&gt;"),
"Résumé &amp; Co &lt;test&gt;",
)
def test_plain_ascii_unchanged(self):
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text")
def test_direct_utf8_unchanged(self):
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ")
def test_unknown_entity_preserved(self):
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
class ResolveHtmlEntitiesFileTests(TestCase):
"""Tests for the file-level preprocessing function."""
def _make_file(self, content):
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
return path
def test_resolves_entities_in_file(self):
path = self._make_file(
'<?xml version="1.0"?>\n<tv><channel><display-name>T&eacute;l&eacute;</display-name></channel></tv>'
)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("Télé", content)
self.assertNotIn("&eacute;", content)
def test_preserves_xml_entities_in_file(self):
path = self._make_file("<tv><desc>A &amp; B &lt;C&gt;</desc></tv>")
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("&amp;", content)
self.assertIn("&lt;", content)
self.assertIn("&gt;", content)
def test_no_temp_file_left_on_success(self):
path = self._make_file("<tv>test</tv>")
_resolve_html_entities(path)
self.assertFalse(os.path.exists(path + ".entity_tmp"))
def test_plain_file_unchanged(self):
original = '<?xml version="1.0"?>\n<tv><channel><display-name>Plain</display-name></channel></tv>'
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
def test_utf8_content_preserved(self):
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("日本語テレビ", content)
def test_iso_8859_1_encoding(self):
"""Files declaring ISO-8859-1 should be read in that encoding."""
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Cha&icirc;ne</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("Cha\u00eene", content)
self.assertNotIn("&icirc;", content)
def test_detect_encoding_utf8_default(self):
"""Headers without an encoding declaration default to UTF-8."""
self.assertEqual(_detect_xml_encoding(b'<?xml version="1.0"?>'), "utf-8")
def test_detect_encoding_iso_8859_1(self):
"""Encoding is read from the XML declaration."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="ISO-8859-1"?>'),
"ISO-8859-1",
)
def test_detect_encoding_single_quotes(self):
"""Encoding detection works with single-quoted attributes."""
self.assertEqual(
_detect_xml_encoding(b"<?xml version='1.0' encoding='windows-1252'?>"),
"windows-1252",
)
def test_detect_encoding_unknown_falls_back(self):
"""Unrecognized encoding falls back to UTF-8."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="x-fake-codec"?>'),
"utf-8",
)
def test_iso_8859_1_with_entities_roundtrip(self):
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars."""
# Mix of direct ISO-8859-1 chars and HTML entities
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 &eacute;mission</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml_str.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved")
self.assertIn("\xe9mission", content, "Entity should be resolved")
self.assertNotIn("&eacute;", content)
def test_mismatched_encoding_leaves_file_untouched(self):
"""File declaring UTF-8 but containing Latin-1 bytes is left alone."""
# \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte
raw = b'<?xml version="1.0" encoding="UTF-8"?>\n<tv><channel><display-name>\xe9</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(raw)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
original_bytes = raw # save for comparison
_resolve_html_entities(path)
with open(path, "rb") as f:
result_bytes = f.read()
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error")

View file

@ -0,0 +1,655 @@
from django.test import TestCase
from django.utils import timezone
from apps.epg.models import EPGData, EPGSource, ProgramData
from apps.epg.serializers import ProgramDataSerializer, ProgramDetailSerializer
from apps.epg.utils import extract_season_episode, extract_season_episode_from_description
class ProgramDataSerializerTests(TestCase):
"""Tests for ProgramDataSerializer season/episode extraction from custom_properties."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_season_and_episode_from_custom_properties(self):
"""Season and episode should be extracted from custom_properties."""
program = self._create_program(
custom_properties={"season": 3, "episode": 5}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 3)
self.assertEqual(data["episode"], 5)
def test_season_only_from_custom_properties(self):
"""Season should be returned even when episode is absent."""
program = self._create_program(custom_properties={"season": 2})
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 2)
self.assertIsNone(data["episode"])
def test_episode_only_from_custom_properties(self):
"""Episode should be returned even when season is absent."""
program = self._create_program(custom_properties={"episode": 10})
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertEqual(data["episode"], 10)
def test_season_episode_null_when_custom_properties_is_none(self):
"""Both should be None when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_season_episode_null_when_custom_properties_is_empty(self):
"""Both should be None when custom_properties is an empty dict."""
program = self._create_program(custom_properties={})
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_season_episode_null_when_keys_absent(self):
"""Both should be None when custom_properties has other keys but no season/episode."""
program = self._create_program(
custom_properties={"categories": ["Drama"], "rating": "TV-14"}
)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_sub_title_included_in_serialized_data(self):
"""sub_title field should be present in serialized output."""
program = self._create_program(sub_title="The Pilot")
data = ProgramDataSerializer(program).data
self.assertEqual(data["sub_title"], "The Pilot")
def test_sub_title_null_when_not_set(self):
"""sub_title should be None when not set."""
program = self._create_program()
data = ProgramDataSerializer(program).data
self.assertIsNone(data["sub_title"])
def test_all_expected_fields_present(self):
"""Serialized output should contain all expected fields."""
program = self._create_program(
sub_title="Episode Title",
custom_properties={"season": 1, "episode": 1},
)
data = ProgramDataSerializer(program).data
expected_fields = {
"id", "start_time", "end_time", "title", "sub_title",
"description", "tvg_id", "season", "episode",
"is_new", "is_live", "is_premiere", "is_finale",
}
self.assertEqual(set(data.keys()), expected_fields)
def test_season_episode_from_onscreen_episode(self):
"""Season and episode should be parsed from onscreen_episode string."""
program = self._create_program(
custom_properties={"onscreen_episode": "S12 E6"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 12)
self.assertEqual(data["episode"], 6)
def test_onscreen_episode_no_space(self):
"""Should parse onscreen_episode without space between S and E."""
program = self._create_program(
custom_properties={"onscreen_episode": "S3E21"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 3)
self.assertEqual(data["episode"], 21)
def test_onscreen_episode_with_part(self):
"""Should parse season/episode even when part info follows."""
program = self._create_program(
custom_properties={"onscreen_episode": "S8 E8 P2/2"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 8)
self.assertEqual(data["episode"], 8)
def test_direct_season_episode_takes_priority_over_onscreen(self):
"""Direct season/episode keys should take priority over onscreen parsing."""
program = self._create_program(
custom_properties={
"season": 1, "episode": 2,
"onscreen_episode": "S99 E99",
}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 1)
self.assertEqual(data["episode"], 2)
def test_onscreen_episode_invalid_format(self):
"""Should return None for onscreen_episode that doesn't match S/E pattern."""
program = self._create_program(
custom_properties={"onscreen_episode": "Episode 5"}
)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_bulk_serialization_with_mixed_data(self):
"""Serializer should handle a mix of programs with and without metadata."""
p1 = self._create_program(
title="Show A",
sub_title="Ep 1",
custom_properties={"season": 1, "episode": 1},
)
p2 = self._create_program(
title="Movie B",
custom_properties=None,
)
p3 = self._create_program(
title="Show C",
custom_properties={},
)
data = ProgramDataSerializer([p1, p2, p3], many=True).data
self.assertEqual(len(data), 3)
self.assertEqual(data[0]["season"], 1)
self.assertEqual(data[0]["episode"], 1)
self.assertIsNone(data[1]["season"])
self.assertIsNone(data[1]["episode"])
self.assertIsNone(data[2]["season"])
self.assertIsNone(data[2]["episode"])
def test_is_new_true_when_flag_set(self):
"""is_new should be True when custom_properties has 'new' flag."""
program = self._create_program(custom_properties={"new": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_new"])
def test_is_live_true_when_flag_set(self):
"""is_live should be True when custom_properties has 'live' flag."""
program = self._create_program(custom_properties={"live": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_live"])
def test_is_premiere_true_when_flag_set(self):
"""is_premiere should be True when custom_properties has 'premiere' flag."""
program = self._create_program(custom_properties={"premiere": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_premiere"])
def test_flags_false_when_not_set(self):
"""All boolean flags should be False when not in custom_properties."""
program = self._create_program(custom_properties={"season": 1})
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_flags_false_when_custom_properties_none(self):
"""All boolean flags should be False when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_flags_false_when_custom_properties_empty(self):
"""All boolean flags should be False when custom_properties is empty."""
program = self._create_program(custom_properties={})
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_multiple_flags_set(self):
"""Multiple flags can be true simultaneously."""
program = self._create_program(
custom_properties={"new": True, "live": True, "premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_new"])
self.assertTrue(data["is_live"])
self.assertTrue(data["is_premiere"])
def test_flags_with_season_episode(self):
"""Flags should work alongside season/episode data."""
program = self._create_program(
custom_properties={"season": 5, "episode": 1, "new": True, "premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 5)
self.assertEqual(data["episode"], 1)
self.assertTrue(data["is_new"])
self.assertFalse(data["is_live"])
self.assertTrue(data["is_premiere"])
def test_is_finale_from_premiere_text_season_finale(self):
"""is_finale should be True when premiere_text contains 'Season Finale'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Season Finale"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_from_premiere_text_series_finale(self):
"""is_finale should be True when premiere_text contains 'Series Finale'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Series Finale"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_case_insensitive(self):
"""is_finale detection should be case-insensitive."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "SEASON FINALE"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_false_for_premiere_text(self):
"""is_finale should be False when premiere_text is 'Season Premiere'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Season Premiere"}
)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
def test_is_finale_false_when_no_premiere_text(self):
"""is_finale should be False when premiere_text is absent."""
program = self._create_program(
custom_properties={"premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
def test_is_finale_false_when_custom_properties_none(self):
"""is_finale should be False when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
class ExtractSeasonEpisodeHelperTests(TestCase):
"""Tests for the shared extract_season_episode helper function."""
def test_both_present(self):
season, episode = extract_season_episode({"season": 3, "episode": 5})
self.assertEqual(season, 3)
self.assertEqual(episode, 5)
def test_fallback_to_onscreen(self):
season, episode = extract_season_episode({"onscreen_episode": "S12 E6"})
self.assertEqual(season, 12)
self.assertEqual(episode, 6)
def test_direct_values_override_onscreen(self):
season, episode = extract_season_episode({
"season": 1, "episode": 2, "onscreen_episode": "S99 E99"
})
self.assertEqual(season, 1)
self.assertEqual(episode, 2)
def test_empty_dict(self):
season, episode = extract_season_episode({})
self.assertIsNone(season)
self.assertIsNone(episode)
def test_partial_with_onscreen_fill(self):
"""Direct season + onscreen episode fills the gap."""
season, episode = extract_season_episode({
"season": 5, "onscreen_episode": "S5E10"
})
self.assertEqual(season, 5)
self.assertEqual(episode, 10)
def test_description_fallback_s_e_format(self):
"""S01E01 in description should be used as third-tier fallback."""
season, episode = extract_season_episode({}, description="S2 E31 The Episode Title")
self.assertEqual(season, 2)
self.assertEqual(episode, 31)
def test_description_fallback_season_episode_format(self):
season, episode = extract_season_episode({}, description="Season 3 Episode 12 Some Title")
self.assertEqual(season, 3)
self.assertEqual(episode, 12)
def test_description_fallback_nxnn_format(self):
season, episode = extract_season_episode({}, description="5x03 Episode Name")
self.assertEqual(season, 5)
self.assertEqual(episode, 3)
def test_description_not_used_when_cp_has_both(self):
"""Description fallback should not override existing custom_properties values."""
season, episode = extract_season_episode(
{"season": 1, "episode": 2}, description="S99 E99 Fake"
)
self.assertEqual(season, 1)
self.assertEqual(episode, 2)
def test_description_not_used_when_onscreen_provides_both(self):
season, episode = extract_season_episode(
{"onscreen_episode": "S3E5"}, description="S99 E99 Fake"
)
self.assertEqual(season, 3)
self.assertEqual(episode, 5)
def test_description_fills_gap_after_partial_onscreen(self):
"""If onscreen provides only season, description can fill episode."""
# onscreen_episode "S5" doesn't match the S/E pattern, so no values from onscreen
# description provides both
season, episode = extract_season_episode(
{"season": 5}, description="S5 E10 Title"
)
self.assertEqual(season, 5)
self.assertEqual(episode, 10)
def test_description_none_is_safe(self):
season, episode = extract_season_episode({}, description=None)
self.assertIsNone(season)
self.assertIsNone(episode)
def test_description_empty_string_is_safe(self):
season, episode = extract_season_episode({}, description="")
self.assertIsNone(season)
self.assertIsNone(episode)
class ExtractSeasonEpisodeFromDescriptionTests(TestCase):
"""Tests for extract_season_episode_from_description() in tasks.py."""
def test_s_e_compact(self):
s, e, cleaned = extract_season_episode_from_description("S2E31 The Kevin Episode")
self.assertEqual(s, 2)
self.assertEqual(e, 31)
self.assertEqual(cleaned, "The Kevin Episode")
def test_s_e_with_space(self):
s, e, cleaned = extract_season_episode_from_description("S2 E31 The Kevin Episode")
self.assertEqual(s, 2)
self.assertEqual(e, 31)
self.assertEqual(cleaned, "The Kevin Episode")
def test_season_episode_words(self):
s, e, cleaned = extract_season_episode_from_description("Season 3 Episode 12 Title Here")
self.assertEqual(s, 3)
self.assertEqual(e, 12)
self.assertEqual(cleaned, "Title Here")
def test_nxnn_format(self):
s, e, cleaned = extract_season_episode_from_description("5x03 Episode Name")
self.assertEqual(s, 5)
self.assertEqual(e, 3)
self.assertEqual(cleaned, "Episode Name")
def test_leading_dash(self):
s, e, cleaned = extract_season_episode_from_description("- S1E5 Title")
self.assertEqual(s, 1)
self.assertEqual(e, 5)
self.assertEqual(cleaned, "Title")
def test_case_insensitive(self):
s, e, cleaned = extract_season_episode_from_description("s10e20 Lower Case")
self.assertEqual(s, 10)
self.assertEqual(e, 20)
self.assertEqual(cleaned, "Lower Case")
def test_no_match_returns_original(self):
s, e, cleaned = extract_season_episode_from_description("Just a normal description")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "Just a normal description")
def test_none_input(self):
s, e, cleaned = extract_season_episode_from_description(None)
self.assertIsNone(s)
self.assertIsNone(e)
self.assertIsNone(cleaned)
def test_empty_string(self):
s, e, cleaned = extract_season_episode_from_description("")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "")
def test_mid_string_s_e_not_matched(self):
"""S/E in middle of description should NOT be matched (anchored to start)."""
s, e, cleaned = extract_season_episode_from_description("Some intro text S1E5 title")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "Some intro text S1E5 title")
class ProgramDataSerializerDescriptionFallbackTests(TestCase):
"""Integration tests: serializer uses description fallback for S/E."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_se_from_description_when_no_cp(self):
program = self._create_program(
custom_properties={},
description="S2 E5 The Episode Title",
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 2)
self.assertEqual(data["episode"], 5)
def test_se_from_description_not_used_when_cp_has_values(self):
program = self._create_program(
custom_properties={"season": 1, "episode": 1},
description="S99 E99 Fake",
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 1)
self.assertEqual(data["episode"], 1)
class ProgramDetailSerializerTests(TestCase):
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_all_detail_fields_present(self):
"""Detail serializer should include all expected fields."""
program = self._create_program(custom_properties={"season": 1, "episode": 1})
data = ProgramDetailSerializer(program).data
expected_fields = {
"id", "start_time", "end_time", "title", "sub_title", "description", "tvg_id",
"season", "episode", "is_new", "is_live", "is_premiere", "is_finale",
"categories", "rating", "rating_system", "star_ratings",
"credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown",
"country", "language", "production_date", "original_air_date",
"imdb_id", "tmdb_id", "tvdb_id", "icon", "images",
}
self.assertEqual(set(data.keys()), expected_fields)
def test_categories_extraction(self):
program = self._create_program(
custom_properties={"categories": ["Drama", "Thriller"]}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["categories"], ["Drama", "Thriller"])
def test_categories_empty_when_absent(self):
program = self._create_program(custom_properties={})
data = ProgramDetailSerializer(program).data
self.assertEqual(data["categories"], [])
def test_rating_extraction(self):
program = self._create_program(
custom_properties={"rating": "TV-14", "rating_system": "VCHIP"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["rating"], "TV-14")
self.assertEqual(data["rating_system"], "VCHIP")
def test_star_ratings_extraction(self):
program = self._create_program(
custom_properties={"star_ratings": [{"value": "8.5/10", "system": "IMDB"}]}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(len(data["star_ratings"]), 1)
self.assertEqual(data["star_ratings"][0]["value"], "8.5/10")
def test_credits_extraction(self):
program = self._create_program(
custom_properties={
"credits": {
"actor": [{"name": "Bryan Cranston", "role": "Walter White"}],
"director": ["Rian Johnson"],
"writer": ["Moira Walley-Beckett"],
}
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(len(data["credits"]["actors"]), 1)
self.assertEqual(data["credits"]["actors"][0]["name"], "Bryan Cranston")
self.assertEqual(data["credits"]["directors"], ["Rian Johnson"])
self.assertEqual(data["credits"]["writers"], ["Moira Walley-Beckett"])
def test_credits_empty_when_absent(self):
program = self._create_program(custom_properties={})
data = ProgramDetailSerializer(program).data
self.assertEqual(data["credits"]["actors"], [])
self.assertEqual(data["credits"]["directors"], [])
def test_video_quality_extraction(self):
program = self._create_program(
custom_properties={"video": {"quality": "HDTV", "aspect": "16:9"}}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["video_quality"], "HDTV")
self.assertEqual(data["aspect_ratio"], "16:9")
def test_audio_extraction(self):
program = self._create_program(
custom_properties={"audio": {"stereo": "Dolby Digital"}}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["stereo"], "Dolby Digital")
def test_geographic_fields(self):
program = self._create_program(
custom_properties={"country": "US", "language": "en"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["country"], "US")
self.assertEqual(data["language"], "en")
def test_original_air_date(self):
program = self._create_program(
custom_properties={
"previously_shown_details": {"start": "2013-09-15"}
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["original_air_date"], "2013-09-15")
def test_external_ids(self):
program = self._create_program(
custom_properties={
"imdb.com_id": "tt0903747",
"themoviedb.org_id": "1396",
"thetvdb.com_id": "81189",
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["imdb_id"], "tt0903747")
self.assertEqual(data["tmdb_id"], "1396")
self.assertEqual(data["tvdb_id"], "81189")
def test_images_extraction(self):
program = self._create_program(
custom_properties={
"icon": "https://example.com/icon.png",
"images": [{"url": "https://example.com/poster.jpg", "type": "poster"}],
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["icon"], "https://example.com/icon.png")
self.assertEqual(len(data["images"]), 1)
def test_null_custom_properties_returns_safe_defaults(self):
"""All enriched fields should be null/empty when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDetailSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
self.assertEqual(data["categories"], [])
self.assertIsNone(data["rating"])
self.assertEqual(data["star_ratings"], [])
self.assertEqual(data["credits"]["actors"], [])
self.assertIsNone(data["video_quality"])
self.assertIsNone(data["country"])
self.assertIsNone(data["imdb_id"])
self.assertEqual(data["images"], [])
def test_season_episode_uses_shared_helper(self):
"""Detail serializer should use the same onscreen_episode fallback."""
program = self._create_program(
custom_properties={"onscreen_episode": "S5E14"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["season"], 5)
self.assertEqual(data["episode"], 14)
def test_status_flags_match_slim_serializer(self):
"""Status flags should produce identical results as ProgramDataSerializer."""
program = self._create_program(
custom_properties={
"new": True, "live": True, "premiere": True,
"premiere_text": "Season Finale",
}
)
slim = ProgramDataSerializer(program).data
detail = ProgramDetailSerializer(program).data
self.assertEqual(slim["is_new"], detail["is_new"])
self.assertEqual(slim["is_live"], detail["is_live"])
self.assertEqual(slim["is_premiere"], detail["is_premiere"])
self.assertEqual(slim["is_finale"], detail["is_finale"])

61
apps/epg/utils.py Normal file
View file

@ -0,0 +1,61 @@
"""
Shared EPG utilities season/episode extraction.
These live here (rather than in serializers.py or tasks.py) to avoid circular imports:
serializers tasks and channels/tasks serializers both need these functions.
"""
import re
# Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2"
_ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE)
# Ordered patterns for extracting season/episode from the start of description text.
# Only used as a fallback when <episode-num> XML elements don't provide S/E.
_DESC_SE_PATTERNS = [
# S01E01, S01 E01, S1E1, S1 E1
re.compile(r'^[\s\-:]*S(\d+)\s*E(\d+)[\s\-:.]*', re.IGNORECASE),
# Season 1 Episode 1, Season1 Episode1, Season1Episode1
re.compile(r'^[\s\-:]*Season\s*(\d+)\s*Episode\s*(\d+)[\s\-:.]*', re.IGNORECASE),
# 1x01 format (requires 2+ digit episode to avoid false positives)
re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'),
]
def extract_season_episode_from_description(desc):
"""
Extract season/episode from the beginning of description text.
Returns (season, episode, cleaned_desc).
Returns (None, None, desc) if no pattern matches.
"""
if not desc:
return None, None, desc
for pattern in _DESC_SE_PATTERNS:
match = pattern.match(desc)
if match:
season = int(match.group(1))
episode = int(match.group(2))
cleaned = desc[match.end():].strip()
return season, episode, cleaned
return None, None, desc
def extract_season_episode(cp, description=None):
"""Extract season/episode from custom_properties with onscreen_episode and description fallbacks."""
season = cp.get('season')
episode = cp.get('episode')
if (season is None or episode is None) and cp.get('onscreen_episode'):
match = _ONSCREEN_RE.search(cp['onscreen_episode'])
if match:
if season is None:
season = int(match.group(1))
if episode is None:
episode = int(match.group(2))
# Third fallback: extract S/E from description text
if (season is None or episode is None) and description:
d_season, d_episode, _ = extract_season_episode_from_description(description)
if season is None:
season = d_season
if episode is None:
episode = d_episode
return season, episode

View file

@ -1,6 +1,7 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
import logging
@ -46,6 +47,7 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
# 🔹 2) Discover API
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve HDHomeRun device discovery information",
@ -98,6 +100,7 @@ class DiscoverAPIView(APIView):
# 🔹 3) Lineup API
class LineupAPIView(APIView):
"""Returns available channel lineup"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve the available channel lineup",
@ -138,6 +141,7 @@ class LineupAPIView(APIView):
# 🔹 4) Lineup Status API
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve the HDHomeRun lineup status",
@ -155,6 +159,7 @@ class LineupStatusAPIView(APIView):
# 🔹 5) Device XML API
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
permission_classes = [AllowAny]
@extend_schema(
description="Retrieve the HDHomeRun device XML configuration",

View file

@ -8,6 +8,7 @@ from apps.accounts.permissions import (
)
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
@ -19,6 +20,7 @@ import json
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from core.utils import safe_upload_path
from apps.channels.models import ChannelGroupM3UAccount
from core.serializers import UserAgentSerializer
from apps.vod.models import M3UVODCategoryRelation
@ -37,7 +39,9 @@ import json
class M3UAccountViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U accounts"""
queryset = M3UAccount.objects.prefetch_related("channel_group")
queryset = M3UAccount.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).prefetch_related("channel_group", "profiles")
serializer_class = M3UAccountSerializer
def get_permissions(self):
@ -51,10 +55,12 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
file_path = None
if "file" in request.FILES:
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join("/data/uploads/m3us", file_name)
try:
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
os.makedirs("/data/uploads/m3us", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
@ -114,10 +120,12 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
file_path = None
if "file" in request.FILES:
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join("/data/uploads/m3us", file_name)
try:
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
os.makedirs("/data/uploads/m3us", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
@ -262,38 +270,50 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
category_settings = request.data.get("category_settings", [])
try:
for setting in group_settings:
group_id = setting.get("channel_group")
enabled = setting.get("enabled", True)
auto_sync = setting.get("auto_channel_sync", False)
sync_start = setting.get("auto_sync_channel_start")
custom_properties = setting.get("custom_properties", {})
if group_id:
ChannelGroupM3UAccount.objects.update_or_create(
channel_group_id=group_id,
with transaction.atomic():
group_objects = [
ChannelGroupM3UAccount(
channel_group_id=setting["channel_group"],
m3u_account=account,
defaults={
"enabled": enabled,
"auto_channel_sync": auto_sync,
"auto_sync_channel_start": sync_start,
"custom_properties": custom_properties,
},
enabled=setting.get("enabled", True),
auto_channel_sync=setting.get("auto_channel_sync", False),
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
custom_properties=setting.get("custom_properties", {}),
)
for setting in group_settings
if setting.get("channel_group")
]
if group_objects:
ChannelGroupM3UAccount.objects.bulk_create(
group_objects,
update_conflicts=True,
unique_fields=["channel_group", "m3u_account"],
update_fields=[
"enabled",
"auto_channel_sync",
"auto_sync_channel_start",
"custom_properties",
],
)
for setting in category_settings:
category_id = setting.get("id")
enabled = setting.get("enabled", True)
custom_properties = setting.get("custom_properties", {})
if category_id:
M3UVODCategoryRelation.objects.update_or_create(
category_id=category_id,
category_objects = [
M3UVODCategoryRelation(
category_id=setting["id"],
m3u_account=account,
defaults={
"enabled": enabled,
"custom_properties": custom_properties,
},
enabled=setting.get("enabled", True),
custom_properties=setting.get("custom_properties", {}),
)
for setting in category_settings
if setting.get("id")
]
if category_objects:
M3UVODCategoryRelation.objects.bulk_create(
category_objects,
update_conflicts=True,
unique_fields=["m3u_account", "category"],
update_fields=["enabled", "custom_properties"],
)
return Response({"message": "Group settings updated successfully"})

View file

@ -0,0 +1,57 @@
# Generated by Django 6.0.3 on 2026-03-14 19:41
from datetime import datetime, timezone
from django.db import migrations, models
def populate_exp_date_from_custom_properties(apps, schema_editor):
"""Backfill exp_date from custom_properties['user_info']['exp_date']."""
M3UAccountProfile = apps.get_model('m3u', 'M3UAccountProfile')
profiles_to_update = []
for profile in M3UAccountProfile.objects.filter(
custom_properties__isnull=False,
).exclude(custom_properties={}):
user_info = profile.custom_properties.get('user_info', {})
raw_exp = user_info.get('exp_date')
if raw_exp is None:
continue
parsed = None
try:
if isinstance(raw_exp, (int, float)):
parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
elif isinstance(raw_exp, str):
try:
parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
except ValueError:
parsed = datetime.fromisoformat(raw_exp)
except (ValueError, TypeError, OSError):
pass
if parsed is not None:
profile.exp_date = parsed
profiles_to_update.append(profile)
if profiles_to_update:
M3UAccountProfile.objects.bulk_update(profiles_to_update, ['exp_date'], batch_size=500)
class Migration(migrations.Migration):
dependencies = [
('m3u', '0018_add_profile_custom_properties'),
]
operations = [
migrations.AddField(
model_name='m3uaccountprofile',
name='exp_date',
field=models.DateTimeField(blank=True, help_text='Account expiration date, auto-synced from custom_properties on save', null=True),
),
migrations.RunPython(
populate_exp_date_from_custom_properties,
reverse_code=migrations.RunPython.noop,
),
]

View file

@ -1,3 +1,4 @@
from datetime import datetime, timezone
from django.db import models
from django.core.exceptions import ValidationError
from core.models import UserAgent
@ -264,11 +265,16 @@ class M3UAccountProfile(models.Model):
)
current_viewers = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
default=dict,
blank=True,
null=True,
help_text="Custom properties for storing account information from provider (e.g., XC account details, expiration dates)"
)
exp_date = models.DateTimeField(
null=True,
blank=True,
help_text="Account expiration date, auto-synced from custom_properties on save",
)
class Meta:
constraints = [
@ -280,36 +286,51 @@ class M3UAccountProfile(models.Model):
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
def get_account_expiration(self):
"""Get account expiration date from custom properties if available"""
def save(self, *args, **kwargs):
"""Auto-sync exp_date from custom_properties for XC accounts on every save.
For non-XC accounts, exp_date is set directly and left untouched here."""
parsed = self._parse_exp_date_from_custom_properties()
if parsed is not None:
# XC account with exp_date in custom_properties — always sync
self.exp_date = parsed
# else: keep whatever exp_date is already set (manual entry for non-XC)
super().save(*args, **kwargs)
@staticmethod
def _parse_exp_date(raw_value):
"""Parse a raw exp_date value (unix timestamp or ISO string) into a datetime."""
if raw_value is None:
return None
try:
if isinstance(raw_value, (int, float)):
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
elif isinstance(raw_value, str):
try:
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
except ValueError:
return datetime.fromisoformat(raw_value)
except (ValueError, TypeError, OSError):
pass
return None
def _parse_exp_date_from_custom_properties(self):
"""Extract exp_date from custom_properties JSON."""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
exp_date = user_info.get('exp_date')
if exp_date:
try:
from datetime import datetime
# XC exp_date is typically a Unix timestamp
if isinstance(exp_date, (int, float)):
return datetime.fromtimestamp(exp_date)
elif isinstance(exp_date, str):
# Try to parse as timestamp first, then as ISO date
try:
return datetime.fromtimestamp(float(exp_date))
except ValueError:
return datetime.fromisoformat(exp_date)
except (ValueError, TypeError):
pass
return None
return self._parse_exp_date(user_info.get('exp_date'))
def get_account_expiration(self):
"""Get account expiration date — uses the dedicated field if set, otherwise parses JSON."""
if self.exp_date:
return self.exp_date
return self._parse_exp_date_from_custom_properties()
def get_account_status(self):
"""Get account status from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('status')
@ -317,7 +338,7 @@ class M3UAccountProfile(models.Model):
"""Get maximum connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('max_connections')
@ -325,7 +346,7 @@ class M3UAccountProfile(models.Model):
"""Get active connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('active_cons')
@ -333,7 +354,7 @@ class M3UAccountProfile(models.Model):
"""Get last refresh timestamp from custom properties if available"""
if not self.custom_properties:
return None
last_refresh = self.custom_properties.get('last_refresh')
if last_refresh:
try:
@ -341,7 +362,7 @@ class M3UAccountProfile(models.Model):
return datetime.fromisoformat(last_refresh)
except (ValueError, TypeError):
pass
return None

View file

@ -7,6 +7,7 @@ from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
)
from datetime import timezone as dt_tz
import logging
import json
@ -52,12 +53,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
"search_pattern",
"replace_pattern",
"custom_properties",
"exp_date",
"account",
]
read_only_fields = ["id", "account"]
extra_kwargs = {
'search_pattern': {'required': False, 'allow_blank': True},
'replace_pattern': {'required': False, 'allow_blank': True},
'exp_date': {'required': False, 'allow_null': True},
}
def create(self, validated_data):
@ -90,14 +93,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
if instance.is_default:
# For default profiles, only allow updating name and custom_properties (for notes)
allowed_fields = {'name', 'custom_properties'}
# For default profiles, only allow updating name, custom_properties, exp_date, and patterns
allowed_fields = {'name', 'custom_properties', 'exp_date', 'search_pattern', 'replace_pattern'}
# Remove any fields that aren't allowed for default profiles
disallowed_fields = set(validated_data.keys()) - allowed_fields
if disallowed_fields:
raise serializers.ValidationError(
f"Default profiles can only modify name and notes. "
f"Default profiles can only modify name, notes, expiration, and URL patterns. "
f"Cannot modify: {', '.join(disallowed_fields)}"
)
@ -117,6 +120,12 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = serializers.SerializerMethodField()
earliest_expiration = serializers.SerializerMethodField()
all_expirations = serializers.SerializerMethodField()
exp_date = serializers.DateTimeField(
required=False, allow_null=True, write_only=True,
help_text="Expiration date for the default profile (write-through)",
)
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
@ -139,6 +148,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True)
cron_expression = serializers.CharField(required=False, allow_blank=True, default="")
class Meta:
model = M3UAccount
@ -158,6 +168,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"locked",
"channel_groups",
"refresh_interval",
"cron_expression",
"custom_properties",
"account_type",
"username",
@ -170,6 +181,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"auto_enable_new_groups_live",
"auto_enable_new_groups_vod",
"auto_enable_new_groups_series",
"earliest_expiration",
"all_expirations",
"exp_date",
]
extra_kwargs = {
"password": {
@ -188,9 +202,45 @@ class M3UAccountSerializer(serializers.ModelSerializer):
data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True)
data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True)
data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True)
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
# But first check if we have a transient _cron_expression (from create/update before signal runs)
cron_expr = ""
if hasattr(instance, '_cron_expression'):
cron_expr = instance._cron_expression
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
data["cron_expression"] = cron_expr
# Surface default profile's exp_date for the form.
# Use prefetch cache (obj.profiles.all()) to avoid an extra query per account.
# Always emit a Z-suffix UTC string so JS new Date() never misinterprets it as local.
default_profile = next((p for p in instance.profiles.all() if p.is_default), None)
exp = default_profile.exp_date if default_profile else None
if exp:
exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
data["exp_date"] = exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
else:
data["exp_date"] = None
return data
def update(self, instance, validated_data):
# Pop exp_date — it's written to the default profile, not the account
exp_date = validated_data.pop("exp_date", "__NOT_SET__")
# Pop cron_expression before it reaches model fields
# If not present (partial update), preserve the existing cron from the PeriodicTask
if "cron_expression" in validated_data:
cron_expr = validated_data.pop("cron_expression")
else:
cron_expr = ""
if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
instance._cron_expression = cron_expr
# Handle enable_vod preference and auto_enable_new_groups settings
enable_vod = validated_data.pop("enable_vod", None)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None)
@ -241,9 +291,29 @@ class M3UAccountSerializer(serializers.ModelSerializer):
memberships_to_update, ["enabled"]
)
# Write exp_date through to the default profile.
# Use a fresh DB query (not the prefetch cache) so we get the profile
# object AFTER the post_save signal (create_profile_for_m3u_account)
# has already updated max_streams, avoiding a stale-value overwrite.
if exp_date != "__NOT_SET__":
default_profile = instance.profiles.filter(is_default=True).first()
if default_profile:
default_profile.exp_date = exp_date
default_profile.save(update_fields=['exp_date'])
# Invalidate the profiles prefetch cache so to_representation
# sees the updated exp_date rather than the pre-request snapshot.
if '_prefetched_objects_cache' in instance.__dict__:
instance._prefetched_objects_cache.pop('profiles', None)
return instance
def create(self, validated_data):
# Pop exp_date — it's written to the default profile after creation
exp_date = validated_data.pop("exp_date", None)
# Pop cron_expression — it's not a model field
cron_expr = validated_data.pop("cron_expression", "")
# Handle enable_vod preference and auto_enable_new_groups settings during creation
enable_vod = validated_data.pop("enable_vod", False)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True)
@ -260,12 +330,51 @@ class M3UAccountSerializer(serializers.ModelSerializer):
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
validated_data["custom_properties"] = custom_props
return super().create(validated_data)
# Build instance manually so we can attach transient attr before save triggers signal
instance = M3UAccount(**validated_data)
instance._cron_expression = cron_expr
instance.save()
# Write exp_date through to the default profile created by post_save signal
if exp_date is not None:
default_profile = instance.profiles.filter(is_default=True).first()
if default_profile:
default_profile.exp_date = exp_date
default_profile.save()
return instance
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
def get_earliest_expiration(self, obj):
"""Return the soonest exp_date across all active profiles for this account."""
# Filter in Python over the prefetch cache to avoid an extra query per account.
expiring = [p.exp_date for p in obj.profiles.all() if p.is_active and p.exp_date]
if not expiring:
return None
exp = min(expiring)
exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
return exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
def get_all_expirations(self, obj):
"""Return exp_date info for every profile that has one (for tooltip)."""
# Filter in Python over the prefetch cache to avoid an extra query per account.
profiles = sorted(
(p for p in obj.profiles.all() if p.exp_date),
key=lambda p: p.exp_date,
)
return [
{
"profile_id": p.id,
"profile_name": p.name,
"exp_date": (p.exp_date.astimezone(dt_tz.utc) if p.exp_date.tzinfo else p.exp_date.replace(tzinfo=dt_tz.utc)).strftime('%Y-%m-%dT%H:%M:%SZ'),
"is_active": p.is_active,
}
for p in profiles
]
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""

View file

@ -1,9 +1,9 @@
# apps/m3u/signals.py
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import M3UAccount
from .models import M3UAccount, M3UAccountProfile
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
import json
import logging
@ -20,51 +20,109 @@ def refresh_account_on_save(sender, instance, created, **kwargs):
refresh_m3u_groups.delay(instance.id)
@receiver(post_save, sender=M3UAccount)
def create_or_update_refresh_task(sender, instance, **kwargs):
def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs):
"""
Create or update a Celery Beat periodic task when an M3UAccount is created/updated.
Supports both interval-based and cron-based scheduling via the shared utility.
"""
task_name = f"m3u_account-refresh-{instance.id}"
# Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message
# updates from the refresh task itself). We only need to reschedule when schedule-relevant
# fields change or when _cron_expression was explicitly set by the serializer.
SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'}
if (
not created
and update_fields is not None
and not (set(update_fields) & SCHEDULE_FIELDS)
and not hasattr(instance, '_cron_expression')
):
return
interval, _ = IntervalSchedule.objects.get_or_create(
every=int(instance.refresh_interval),
period=IntervalSchedule.HOURS
task_name = f"m3u_account-refresh-{instance.id}"
should_be_enabled = instance.is_active
# Read cron_expression from transient attribute set by the serializer.
# If not set (e.g. save came from a task updating status/last_message),
# preserve the existing crontab so we don't accidentally revert to interval.
if hasattr(instance, "_cron_expression"):
cron_expr = instance._cron_expression
else:
cron_expr = ""
try:
existing_task = instance.refresh_task
if existing_task and existing_task.crontab:
ct = existing_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
except Exception:
pass
task = create_or_update_periodic_task(
task_name=task_name,
celery_task_path="apps.m3u.tasks.refresh_single_m3u_account",
kwargs={"account_id": instance.id},
interval_hours=int(instance.refresh_interval),
cron_expression=cron_expr,
enabled=should_be_enabled,
)
# Task should be enabled only if refresh_interval != 0 AND account is active
should_be_enabled = (instance.refresh_interval != 0) and instance.is_active
# Ensure instance has the task linked
if instance.refresh_task_id != task.id:
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
@receiver(post_save, sender=M3UAccountProfile)
def update_profile_expiration_notification(sender, instance, created, update_fields=None, **kwargs):
"""
When a profile's exp_date is set or changed, immediately update its expiration notification
so the frontend reflects the new state without waiting for the daily celery task.
"""
# Only act when exp_date was involved in the save
if not created and update_fields is not None and "exp_date" not in update_fields:
return
# First check if the task already exists to avoid validation errors
try:
task = PeriodicTask.objects.get(name=task_name)
# Task exists, just update it
updated_fields = []
if not instance.exp_date:
# exp_date was cleared — remove any existing notifications immediately
from core.models import SystemNotification
from core.utils import send_notification_dismissed
if task.enabled != should_be_enabled:
task.enabled = should_be_enabled
updated_fields.append("enabled")
keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
return
if task.interval != interval:
task.interval = interval
updated_fields.append("interval")
from apps.m3u.tasks import evaluate_profile_expiration_notification
evaluate_profile_expiration_notification(instance)
except Exception as e:
logger.error(f"Error updating expiration notification for profile {instance.id}: {str(e)}")
if updated_fields:
task.save(update_fields=updated_fields)
# Ensure instance has the task
if instance.refresh_task_id != task.id:
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
@receiver(post_delete, sender=M3UAccountProfile)
def cleanup_profile_notifications(sender, instance, **kwargs):
"""
Delete expiration notifications for a profile when it is deleted.
Handles both direct deletion and cascade deletion from M3UAccount.
"""
try:
from core.models import SystemNotification
from core.utils import send_notification_dismissed
except PeriodicTask.DoesNotExist:
# Create new task if it doesn't exist
refresh_task = PeriodicTask.objects.create(
name=task_name,
interval=interval,
task="apps.m3u.tasks.refresh_single_m3u_account",
kwargs=json.dumps({"account_id": instance.id}),
enabled=should_be_enabled,
keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
if deleted_keys:
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
logger.debug(f"Cleaned up {len(deleted_keys)} notifications for deleted profile {instance.id}")
except Exception as e:
logger.error(f"Error cleaning up notifications for profile {instance.id}: {str(e)}")
@receiver(post_delete, sender=M3UAccount)
def delete_refresh_task(sender, instance, **kwargs):
@ -105,6 +163,34 @@ def update_status_on_active_change(sender, instance, **kwargs):
else:
# When deactivating, set status to disabled
instance.status = M3UAccount.Status.DISABLED
# Clean up any expiration notifications for all profiles of this account
try:
from core.models import SystemNotification
from core.utils import send_notification_dismissed
profile_ids = list(
M3UAccountProfile.objects.filter(m3u_account=instance)
.values_list("id", flat=True)
)
keys = [
key
for pid in profile_ids
for key in [f"xc-exp-warning-{pid}", f"xc-exp-expired-{pid}"]
]
if keys:
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
if deleted_keys:
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
logger.debug(
f"Cleaned up {len(deleted_keys)} notifications for deactivated M3U account {instance.id}"
)
except Exception as notify_err:
logger.error(f"Error cleaning up notifications on account deactivation: {notify_err}")
except M3UAccount.DoesNotExist:
# New record, will use default status
pass

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,216 @@
"""
Tests for evaluate_profile_expiration_notification.
Covers all four branches:
- no exp_date returns None, touches nothing
- already expired creates/updates expired notification, removes warning
- expiring within 7d creates/updates warning notification, removes expired
- not expiring soon removes any stale notifications, returns None
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import SimpleTestCase
from django.utils import timezone
def _make_profile(exp_date, profile_id=1, profile_name="Test Profile",
account_id=10, account_name="Test Account"):
"""Return a minimal mock M3UAccountProfile."""
profile = MagicMock()
profile.id = profile_id
profile.name = profile_name
profile.exp_date = exp_date
profile.m3u_account.id = account_id
profile.m3u_account.name = account_name
return profile
class EvaluateProfileExpirationNotificationTests(SimpleTestCase):
def setUp(self):
# These three names are local imports inside evaluate_profile_expiration_notification,
# so we must patch them at their source modules rather than on apps.m3u.tasks.
self.mock_sn = MagicMock()
self.mock_send_ws = patch("core.utils.send_websocket_notification").start()
self.mock_dismissed = patch("core.utils.send_notification_dismissed").start()
patch("core.models.SystemNotification", self.mock_sn).start()
def tearDown(self):
patch.stopall()
def _run(self, profile):
from apps.m3u.tasks import evaluate_profile_expiration_notification
return evaluate_profile_expiration_notification(profile)
# ------------------------------------------------------------------ #
# No expiration date
# ------------------------------------------------------------------ #
def test_no_exp_date_returns_none(self):
profile = _make_profile(exp_date=None)
result = self._run(profile)
self.assertIsNone(result)
self.mock_sn.objects.update_or_create.assert_not_called()
self.mock_send_ws.assert_not_called()
# ------------------------------------------------------------------ #
# Already expired
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_expired_creates_expired_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now - timedelta(days=1))
# No existing warning notification to delete
self.mock_sn.objects.filter.return_value.values_list.return_value = []
notification = MagicMock()
self.mock_sn.objects.update_or_create.return_value = (notification, True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-expired-{profile.id}")
self.mock_sn.objects.update_or_create.assert_called_once()
call_kwargs = self.mock_sn.objects.update_or_create.call_args
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-expired-{profile.id}")
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
self.mock_send_ws.assert_called_once_with(notification)
@patch("apps.m3u.tasks.timezone")
def test_expired_removes_stale_warning_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now - timedelta(hours=1))
warning_key = f"xc-exp-warning-{profile.id}"
# Simulate an existing warning notification
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
self._run(profile)
self.mock_dismissed.assert_any_call(warning_key)
# ------------------------------------------------------------------ #
# Expiring within 7 days
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_warning_window_creates_warning_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=3))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
notification = MagicMock()
self.mock_sn.objects.update_or_create.return_value = (notification, True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-warning-{profile.id}")
call_kwargs = self.mock_sn.objects.update_or_create.call_args
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-warning-{profile.id}")
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
self.mock_send_ws.assert_called_once_with(notification)
@patch("apps.m3u.tasks.timezone")
def test_warning_message_says_today_when_same_day(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(hours=2))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
self._run(profile)
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
self.assertIn("today", defaults["message"])
@patch("apps.m3u.tasks.timezone")
def test_warning_message_says_1_day(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(hours=30))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
self._run(profile)
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
self.assertIn("in 1 day", defaults["message"])
@patch("apps.m3u.tasks.timezone")
def test_warning_removes_stale_expired_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=5))
expired_key = f"xc-exp-expired-{profile.id}"
self.mock_sn.objects.filter.return_value.values_list.return_value = [expired_key]
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
self._run(profile)
self.mock_dismissed.assert_any_call(expired_key)
# ------------------------------------------------------------------ #
# Not expiring soon (> 7 days away)
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_not_expiring_soon_returns_none(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=30))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
result = self._run(profile)
self.assertIsNone(result)
self.mock_sn.objects.update_or_create.assert_not_called()
self.mock_send_ws.assert_not_called()
@patch("apps.m3u.tasks.timezone")
def test_not_expiring_soon_removes_stale_notifications(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=30))
warning_key = f"xc-exp-warning-{profile.id}"
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
self._run(profile)
self.mock_dismissed.assert_called_once_with(warning_key)
# ------------------------------------------------------------------ #
# Boundary: exactly at the 7-day warning threshold
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_exactly_7_days_away_triggers_warning(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
# exp_date == now + 7 days → exp <= warning_threshold → warning
profile = _make_profile(exp_date=now + timedelta(days=7))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-warning-{profile.id}")

View file

@ -0,0 +1,41 @@
from django.test import SimpleTestCase
from apps.m3u.tasks import parse_extinf_line
class ParseExtinfLineTests(SimpleTestCase):
def test_preserves_equals_padding_in_tvg_logo(self):
line = (
'#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" '
'tvg-chno="1001" '
'tvg-name="UK Sky Sports Premier League" '
'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?'
'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" '
'group-title="Team Games",UK Sky Sports Premier League'
)
parsed = parse_extinf_line(line)
self.assertIsNotNone(parsed)
self.assertEqual(
parsed["attributes"]["tvg-logo"],
"https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==",
)
self.assertEqual(parsed["attributes"]["group-title"], "Team Games")
self.assertEqual(parsed["name"], "UK Sky Sports Premier League")
def test_supports_single_quoted_attributes(self):
line = (
"#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' "
"group-title='Sports',Channel One"
)
parsed = parse_extinf_line(line)
self.assertIsNotNone(parsed)
self.assertEqual(
parsed["attributes"]["tvg-logo"],
"https://example.com/logo==.png",
)
self.assertEqual(parsed["attributes"]["group-title"], "Sports")
self.assertEqual(parsed["display_name"], "Channel One")

View file

@ -0,0 +1,106 @@
"""
Tests for memory cleanup behavior in M3U refresh tasks.
Verifies that database connections are properly closed, task locks are
released on all exit paths, and garbage collection runs where expected.
"""
from unittest.mock import patch, MagicMock
from django.test import SimpleTestCase
from apps.m3u.models import M3UAccount
class ProcessM3UBatchCleanupTests(SimpleTestCase):
"""Verify process_m3u_batch_direct cleans up after processing."""
@patch("apps.m3u.tasks.Stream")
@patch("apps.m3u.tasks.M3UAccount")
def test_connections_closed_after_batch(self, mock_account_cls, mock_stream_cls):
"""Database connections must be closed after batch processing (thread safety)."""
from apps.m3u.tasks import process_m3u_batch_direct
mock_account = MagicMock()
mock_account.filters.order_by.return_value = []
mock_account_cls.objects.get.return_value = mock_account
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
[]
)
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
with patch("django.db.connections") as mock_connections:
process_m3u_batch_direct(1, [], {}, ["name", "url"])
mock_connections.close_all.assert_called()
class LockReleaseTests(SimpleTestCase):
"""Verify task lock is released on all exit paths."""
@patch("apps.m3u.tasks.delete_m3u_refresh_task_by_id", return_value=False)
def test_lock_released_on_account_not_found(self, mock_delete):
"""release_task_lock must be called when account does not exist."""
with patch(
"apps.m3u.tasks.acquire_task_lock", return_value=True
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
"apps.m3u.tasks.TaskLockRenewer"
):
with patch(
"apps.m3u.tasks.M3UAccount.objects.get",
side_effect=M3UAccount.DoesNotExist,
):
from apps.m3u.tasks import refresh_single_m3u_account
refresh_single_m3u_account(99999)
mock_release.assert_called_once_with(
"refresh_single_m3u_account", 99999
)
def test_lock_released_on_exception(self):
"""release_task_lock must be called when an exception is raised."""
mock_account = MagicMock()
mock_account.is_active = True
mock_account.account_type = "STD"
mock_account.custom_properties = {}
mock_account.filters.all.return_value = []
mock_account.status = MagicMock()
with patch(
"apps.m3u.tasks.acquire_task_lock", return_value=True
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
"apps.m3u.tasks.TaskLockRenewer"
):
with patch(
"apps.m3u.tasks.M3UAccount.objects.get", return_value=mock_account
):
with patch("os.path.exists", return_value=False):
with patch(
"apps.m3u.tasks.refresh_m3u_groups",
side_effect=RuntimeError("test"),
):
from apps.m3u.tasks import refresh_single_m3u_account
try:
refresh_single_m3u_account(1)
except RuntimeError:
pass
mock_release.assert_called_once_with("refresh_single_m3u_account", 1)
class XCCategoryCleanupTests(SimpleTestCase):
"""Regression guard: process_xc_category_direct must continue to clean up."""
@patch("apps.m3u.tasks.XCClient")
@patch("apps.m3u.tasks.M3UAccount")
def test_xc_category_calls_gc_collect(self, mock_account_cls, mock_xc_client):
"""gc.collect() must be called after XC category processing."""
from apps.m3u.tasks import process_xc_category_direct
mock_account = MagicMock()
mock_account_cls.objects.get.return_value = mock_account
with patch("gc.collect") as mock_gc, patch("django.db.connections"):
process_xc_category_direct(1, {}, {}, ["name", "url"])
mock_gc.assert_called()

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,14 @@ from .api_views import (
PluginImportAPIView,
PluginDeleteAPIView,
PluginLogoAPIView,
PluginRepoListCreateAPIView,
PluginRepoPreviewAPIView,
PluginRepoDetailAPIView,
PluginRepoRefreshAPIView,
AvailablePluginsAPIView,
PluginDetailManifestAPIView,
PluginInstallFromRepoAPIView,
PluginRepoSettingsAPIView,
)
app_name = "plugins"
@ -21,4 +29,13 @@ urlpatterns = [
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
path("plugins/<str:key>/logo/", PluginLogoAPIView.as_view(), name="logo"),
# Plugin repos (hub / store) - static paths first, then parametric
path("repos/", PluginRepoListCreateAPIView.as_view(), name="repo-list"),
path("repos/available/", AvailablePluginsAPIView.as_view(), name="available-plugins"),
path("repos/plugin-detail/", PluginDetailManifestAPIView.as_view(), name="plugin-detail-manifest"),
path("repos/install/", PluginInstallFromRepoAPIView.as_view(), name="repo-install"),
path("repos/settings/", PluginRepoSettingsAPIView.as_view(), name="repo-settings"),
path("repos/preview/", PluginRepoPreviewAPIView.as_view(), name="repo-preview"),
path("repos/<int:pk>/", PluginRepoDetailAPIView.as_view(), name="repo-detail"),
path("repos/<int:pk>/refresh/", PluginRepoRefreshAPIView.as_view(), name="repo-refresh"),
]

File diff suppressed because it is too large Load diff

View file

@ -52,3 +52,53 @@ class PluginsConfig(AppConfig):
import logging
logging.getLogger(__name__).exception("Plugin discovery wiring failed during app ready")
# Register periodic task for refreshing plugin repo manifests
self._setup_repo_refresh_schedule()
# Refresh repo manifests once at startup so the UI always has current data
self._enqueue_startup_refresh()
def _enqueue_startup_refresh(self):
from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization():
return
try:
from .tasks import refresh_plugin_repos
refresh_plugin_repos.apply_async(countdown=10)
except Exception:
import logging
logging.getLogger(__name__).debug(
"Could not enqueue startup plugin repo refresh (Celery may not be ready yet)"
)
def _setup_repo_refresh_schedule(self):
from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization():
return
try:
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
from core.models import CoreSettings
from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME
interval = 6
try:
obj = CoreSettings.objects.get(key="plugin_repo_settings")
interval = obj.value.get("refresh_interval_hours", 6)
except CoreSettings.DoesNotExist:
pass
if interval == 0:
delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME)
else:
create_or_update_periodic_task(
task_name=PLUGIN_REPO_REFRESH_TASK_NAME,
celery_task_path="apps.plugins.tasks.refresh_plugin_repos",
interval_hours=interval,
enabled=True,
)
except Exception:
import logging
logging.getLogger(__name__).debug(
"Could not set up plugin repo refresh schedule (migrations may not have run yet)"
)

View file

@ -0,0 +1,11 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEacgfABYJKwYBBAHaRw8BAQdAh1MuVNBxk+CExQPjOVDvAGvIk6BdGS2ce9/h
zB7lYtW0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv
Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBEap
MFaOD7nKg0zX+H7AOmtMIjTOBQJpyB8AGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy
LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRB+wDprTCI0zvNZ
AP9r3TpMpiI8BCNo9B5M9lJ+QLRo9ihPWIcqBzJ9eFCoSQEAgguiZsNy6aJzKjIb
yDvGuoZi3I2/GNM/f2qVzFtgPQk=
=Zf/y
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -367,21 +367,35 @@ class PluginManager:
obj.save()
def list_plugins(self) -> List[Dict[str, Any]]:
from .models import PluginConfig
from .models import PluginConfig, PluginRepo
plugins: List[Dict[str, Any]] = []
with self._lock:
registry_snapshot = dict(self._registry)
try:
configs = {c.key: c for c in PluginConfig.objects.all()}
configs = {c.key: c for c in PluginConfig.objects.select_related("source_repo").all()}
except Exception as e:
# Database might not be migrated yet; fall back to registry only
logger.warning("PluginConfig table unavailable; listing registry only: %s", e)
configs = {}
# Build repo latest-version lookup from cached manifests
repo_latest = {} # slug -> latest_version
try:
for repo in PluginRepo.objects.filter(enabled=True):
manifest_data = repo.cached_manifest or {}
manifest = manifest_data.get("manifest", manifest_data)
for rp in manifest.get("plugins", []):
s = rp.get("slug", "")
if s:
repo_latest[s] = rp.get("latest_version", "")
except Exception:
pass
# First, include all discovered plugins
for key, lp in registry_snapshot.items():
conf = configs.get(key)
conf_slug = conf.slug if conf else ""
trusted = bool(conf and (conf.ever_enabled or conf.enabled))
logo_url = self._get_logo_url(key, path=lp.path)
plugins.append(
@ -393,7 +407,7 @@ class PluginManager:
"author": getattr(lp, "author", "") or "",
"help_url": getattr(lp, "help_url", "") or "",
"enabled": conf.enabled if conf else False,
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
"ever_enabled": conf.ever_enabled if conf else False,
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
@ -402,6 +416,22 @@ class PluginManager:
"loaded": bool(lp.loaded),
"legacy": bool(getattr(lp, "legacy", False)),
"logo_url": logo_url,
"source_repo": conf.source_repo_id if conf else None,
"source_repo_name": conf.source_repo.name if conf and conf.source_repo else None,
"is_official_repo": bool(conf and conf.source_repo and conf.source_repo.is_official),
"slug": conf_slug,
"is_managed": bool(conf and conf.source_repo_id),
"installed_version_is_prerelease": bool(
conf and conf.installed_version_is_prerelease
),
"update_available": bool(
conf_slug and conf and conf.source_repo_id
and not (conf and conf.installed_version_is_prerelease)
and repo_latest.get(conf_slug)
and lp.version != repo_latest.get(conf_slug)
),
"latest_version": repo_latest.get(conf_slug, ""),
"deprecated": conf.deprecated if conf else False,
}
)
@ -428,6 +458,22 @@ class PluginManager:
"loaded": False,
"legacy": False,
"logo_url": self._get_logo_url(key),
"source_repo": conf.source_repo_id,
"source_repo_name": conf.source_repo.name if conf.source_repo else None,
"is_official_repo": bool(conf.source_repo and conf.source_repo.is_official),
"slug": conf.slug,
"is_managed": bool(conf.source_repo_id),
"installed_version_is_prerelease": bool(
conf.installed_version_is_prerelease
),
"update_available": bool(
conf.slug and conf.source_repo_id
and not conf.installed_version_is_prerelease
and repo_latest.get(conf.slug)
and conf.version != repo_latest.get(conf.slug)
),
"latest_version": repo_latest.get(conf.slug or "", ""),
"deprecated": conf.deprecated,
}
)

View file

@ -0,0 +1,84 @@
import django.db.models.deletion
from django.db import migrations, models
def seed_official_repo(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.get_or_create(
url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json",
defaults={
"name": "Dispatcharr Official",
"is_official": True,
"enabled": True,
},
)
def unseed_official_repo(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.filter(is_official=True).delete()
class Migration(migrations.Migration):
dependencies = [
("plugins", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="PluginRepo",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("url", models.URLField(unique=True)),
("is_official", models.BooleanField(default=False)),
("enabled", models.BooleanField(default=True)),
("cached_manifest", models.JSONField(blank=True, default=dict)),
("last_fetched", models.DateTimeField(blank=True, null=True)),
("public_key", models.TextField(blank=True, default="")),
("signature_verified", models.BooleanField(blank=True, default=None, null=True)),
("last_fetch_status", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-is_official", "name"],
},
),
migrations.RunPython(seed_official_repo, unseed_official_repo),
migrations.AddField(
model_name="pluginconfig",
name="source_repo",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="installed_plugins",
to="plugins.pluginrepo",
),
),
migrations.AddField(
model_name="pluginconfig",
name="slug",
field=models.CharField(blank=True, default="", max_length=128),
),
migrations.AddField(
model_name="pluginconfig",
name="installed_version_is_prerelease",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="pluginconfig",
name="deprecated",
field=models.BooleanField(default=False),
),
]

View file

@ -12,8 +12,52 @@ class PluginConfig(models.Model):
# Tracks whether this plugin has ever been enabled at least once
ever_enabled = models.BooleanField(default=False)
settings = models.JSONField(default=dict, blank=True)
# Managed plugin fields (populated when installed from a repo)
source_repo = models.ForeignKey(
"PluginRepo",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="installed_plugins",
)
slug = models.CharField(max_length=128, blank=True, default="")
installed_version_is_prerelease = models.BooleanField(default=False)
deprecated = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def is_managed(self):
return bool(self.source_repo_id)
def __str__(self) -> str:
return f"{self.name} ({self.key})"
OFFICIAL_REPO_URL = (
"https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
)
class PluginRepo(models.Model):
"""A remote plugin repository manifest URL."""
name = models.CharField(max_length=255)
url = models.URLField(unique=True)
is_official = models.BooleanField(default=False)
enabled = models.BooleanField(default=True)
cached_manifest = models.JSONField(default=dict, blank=True)
public_key = models.TextField(blank=True, default="")
signature_verified = models.BooleanField(null=True, blank=True, default=None)
last_fetched = models.DateTimeField(null=True, blank=True)
last_fetch_status = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-is_official", "name"]
def __str__(self) -> str:
return self.name

View file

@ -1,4 +1,5 @@
from rest_framework import serializers
from .models import PluginRepo
class PluginActionSerializer(serializers.Serializer):
@ -9,6 +10,9 @@ class PluginActionSerializer(serializers.Serializer):
button_label = serializers.CharField(required=False, allow_blank=True)
button_variant = serializers.CharField(required=False, allow_blank=True)
button_color = serializers.CharField(required=False, allow_blank=True)
events = serializers.ListField(
child=serializers.CharField(), required=False, allow_empty=True
)
class PluginFieldOptionSerializer(serializers.Serializer):
@ -43,3 +47,40 @@ class PluginSerializer(serializers.Serializer):
fields = PluginFieldSerializer(many=True)
settings = serializers.JSONField()
actions = PluginActionSerializer(many=True)
source_repo = serializers.IntegerField(required=False, allow_null=True)
slug = serializers.CharField(required=False, allow_blank=True)
is_managed = serializers.BooleanField(required=False)
deprecated = serializers.BooleanField(required=False)
class PluginRepoSerializer(serializers.ModelSerializer):
registry_url = serializers.SerializerMethodField()
plugin_count = serializers.SerializerMethodField()
class Meta:
model = PluginRepo
fields = [
"id",
"name",
"url",
"is_official",
"enabled",
"public_key",
"signature_verified",
"registry_url",
"plugin_count",
"last_fetched",
"last_fetch_status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "name", "is_official", "signature_verified", "registry_url", "plugin_count", "last_fetched", "last_fetch_status", "created_at", "updated_at"]
def get_registry_url(self, obj):
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
return manifest.get("registry_url", "") or ""
def get_plugin_count(self, obj):
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
plugins = manifest.get("plugins", [])
return len(plugins) if isinstance(plugins, list) else 0

33
apps/plugins/tasks.py Normal file
View file

@ -0,0 +1,33 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
PLUGIN_REPO_REFRESH_TASK_NAME = "plugin-repo-refresh-task"
@shared_task
def refresh_plugin_repos():
"""Refresh cached manifests for all enabled plugin repos."""
from .models import PluginRepo
from .api_views import _fetch_manifest, _save_fetched_manifest_to_repo, _unmanage_dropped_slugs
from django.utils import timezone
repos = PluginRepo.objects.filter(enabled=True)
for repo in repos:
try:
key_text = repo.public_key if not repo.is_official else None
data, verified = _fetch_manifest(repo.url, public_key_text=key_text)
err = _save_fetched_manifest_to_repo(repo, data, verified)
if err:
logger.warning("Skipping repo '%s': %s", repo.name, err)
continue
_unmanage_dropped_slugs(repo, data)
logger.info("Refreshed plugin repo '%s'", repo.name)
except Exception as e:
resp = getattr(e, 'response', None)
status_str = str(resp.status_code) if resp is not None and hasattr(resp, 'status_code') else type(e).__name__
repo.last_fetch_status = status_str[:255]
repo.last_fetched = timezone.now()
repo.save(update_fields=["last_fetch_status", "last_fetched", "updated_at"])
logger.warning("Failed to refresh plugin repo '%s': %s", repo.name, e)

View file

@ -12,6 +12,6 @@ class ProxyConfig(AppConfig):
from .hls_proxy.server import ProxyServer as HLSProxyServer
from .ts_proxy.server import ProxyServer as TSProxyServer
# Initialize proxy servers
# Initialize proxy servers (TS uses singleton to prevent duplicate instances)
self.hls_proxy = HLSProxyServer()
self.ts_proxy = TSProxyServer()
self.ts_proxy = TSProxyServer.get_instance()

View file

@ -43,6 +43,7 @@ class BaseConfig:
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"new_client_behind_seconds": 5,
}
finally:
@ -81,6 +82,7 @@ class TSConfig(BaseConfig):
# Buffer settings
INITIAL_BEHIND_CHUNKS = 4 # How many chunks behind to start a client (4 chunks = ~1MB)
CHUNK_BATCH_SIZE = 5 # How many chunks to fetch in one batch
NEW_CLIENT_BEHIND_SECONDS = 5 # Start new clients this many seconds behind live (0 = start at live)
KEEPALIVE_INTERVAL = 0.5 # Seconds between keepalive packets when at buffer head
# Chunk read timeout
CHUNK_TIMEOUT = 5 # Seconds to wait for each chunk read
@ -97,7 +99,7 @@ class TSConfig(BaseConfig):
CLIENT_RECORD_TTL = 60 # How long client records persist in Redis (seconds). Client will be considered MIA after this time.
CLEANUP_CHECK_INTERVAL = 1 # How often to check for disconnected clients (seconds)
CLIENT_HEARTBEAT_INTERVAL = 5 # How often to send client heartbeats (seconds)
GHOST_CLIENT_MULTIPLIER = 6.0 # How many heartbeat intervals before client considered ghost (6 would mean 36 seconds if heartbeat interval is 6)
GHOST_CLIENT_MULTIPLIER = 10.0 # How many heartbeat intervals before client considered ghost (10 = 50s, must exceed STREAM_TIMEOUT + FAILOVER_GRACE_PERIOD = 40s)
CLIENT_WAIT_TIMEOUT = 30 # Seconds to wait for client to connect
# Stream health and recovery settings
@ -106,6 +108,7 @@ class TSConfig(BaseConfig):
MIN_STABLE_TIME_BEFORE_RECONNECT = 30 # Minimum seconds a stream must be stable to try reconnect
FAILOVER_GRACE_PERIOD = 20 # Extra time (seconds) to allow for stream switching before disconnecting clients
URL_SWITCH_TIMEOUT = 20 # Max time allowed for a stream switch operation
MAX_KEEPALIVE_DURATION = 300 # Keepalive packets prevent _is_timeout() from firing, so without this a permanently failed stream holds clients open indefinitely.

View file

@ -4,6 +4,8 @@ import logging
from django.http import StreamingHttpResponse, JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from rest_framework.decorators import api_view, permission_classes
from apps.accounts.permissions import IsAdmin
from .server import ProxyServer, Config
logger = logging.getLogger(__name__)
@ -15,7 +17,7 @@ def stream_endpoint(request, channel_id):
"""Handle HLS manifest requests"""
if channel_id not in proxy_server.stream_managers:
return JsonResponse({'error': 'Channel not found'}, status=404)
response = proxy_server.stream_endpoint(channel_id)
return StreamingHttpResponse(
response[0],
@ -30,10 +32,10 @@ def get_segment(request, segment_name):
try:
segment_num = int(segment_name.split('.')[0])
buffer = proxy_server.stream_buffers.get(segment_num)
if not buffer:
return JsonResponse({'error': 'Segment not found'}, status=404)
return StreamingHttpResponse(
buffer,
content_type='video/MP2T'
@ -44,19 +46,19 @@ def get_segment(request, segment_name):
logger.error(f"Error serving segment: {e}")
return JsonResponse({'error': str(e)}, status=500)
@csrf_exempt
@require_http_methods(["POST"])
@api_view(["POST"])
@permission_classes([IsAdmin])
def change_stream(request, channel_id):
"""Change stream URL for existing channel"""
try:
if channel_id not in proxy_server.stream_managers:
return JsonResponse({'error': 'Channel not found'}, status=404)
data = json.loads(request.body)
new_url = data.get('url')
if not new_url:
return JsonResponse({'error': 'No URL provided'}, status=400)
manager = proxy_server.stream_managers[channel_id]
if manager.update_url(new_url):
return JsonResponse({
@ -64,7 +66,7 @@ def change_stream(request, channel_id):
'channel': channel_id,
'url': new_url
})
return JsonResponse({
'message': 'URL unchanged',
'channel': channel_id,
@ -85,7 +87,7 @@ def initialize_stream(request, channel_id):
url = data.get('url')
if not url:
return JsonResponse({'error': 'No URL provided'}, status=400)
proxy_server.initialize_channel(url, channel_id)
return JsonResponse({
'message': 'Stream initialized',

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