mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 19:14:00 +00:00
Compare commits
318 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9989b41d1a | ||
|
|
9dc7b241b8 | ||
|
|
6bf126d432 | ||
|
|
7c34fbb7e0 | ||
|
|
6b2ed0754a | ||
|
|
5c9221e755 | ||
|
|
5b90ccff28 | ||
|
|
2a5ab5c268 | ||
|
|
726e0faa4c | ||
|
|
ddef24a8fc | ||
|
|
dc59469b21 | ||
|
|
18b41a57e2 | ||
|
|
3449469ece | ||
|
|
1a429b2cc6 | ||
|
|
6b675f814a | ||
|
|
ac38adcfce | ||
|
|
c1a28863c6 | ||
|
|
67ccbd70bc | ||
|
|
e94a1acc1c | ||
|
|
524ee6d479 | ||
|
|
172967c2fd | ||
|
|
0bb58542a5 | ||
|
|
df53439789 | ||
|
|
532ac1ccfd | ||
|
|
5e4441804c | ||
|
|
ce6b0099a1 | ||
|
|
019e38fa70 | ||
|
|
b1801cb92a | ||
|
|
5f603ac7b7 | ||
|
|
7e2765733b | ||
|
|
3749953c2c | ||
|
|
3011946c56 | ||
|
|
b30e953483 | ||
|
|
559151585e | ||
|
|
6c9cb30a90 | ||
|
|
820959e390 | ||
|
|
21901498de | ||
|
|
64912c6164 | ||
|
|
cdba6dac84 | ||
|
|
66089f14ee | ||
|
|
b6442e6421 | ||
|
|
2b62928b42 | ||
|
|
f9a2968ec5 | ||
|
|
0aed30253b | ||
|
|
b24f39d9f8 | ||
|
|
a40ac78ffd | ||
|
|
8303ce27ee | ||
|
|
692f2d27c1 | ||
|
|
84d2aedae5 | ||
|
|
a78bc7aa48 | ||
|
|
02268bbd9e | ||
|
|
31f448c673 | ||
|
|
fc8b955b36 | ||
|
|
5714cf23cf | ||
|
|
097e7118ef | ||
|
|
52c97120fb | ||
|
|
9da83d76fa | ||
|
|
b91385aacb | ||
|
|
1f30e2076d | ||
|
|
5263e18ed9 | ||
|
|
9ee5ba2535 | ||
|
|
6157bc1177 | ||
|
|
42248743fa | ||
|
|
540f3d9bed | ||
|
|
61df45c895 | ||
|
|
1b99f105c4 | ||
|
|
8bcc7c9ebe | ||
|
|
6f62d807f4 | ||
|
|
14bfd25d9a | ||
|
|
d3d51780dd | ||
|
|
cdfe16ae5d | ||
|
|
388c4bd28d | ||
|
|
6bd42c83f4 | ||
|
|
e6244adc0b | ||
|
|
b475bbc07b | ||
|
|
23dc7c0a92 | ||
|
|
33ada59dba | ||
|
|
49189465d2 | ||
|
|
817ebf21a9 | ||
|
|
07d17a5ffc | ||
|
|
99fc71de99 | ||
|
|
f6ad115a1f | ||
|
|
6d5a5a549a | ||
|
|
8cb1dab5ce | ||
|
|
ed1c449234 | ||
|
|
d1344adb4f | ||
|
|
ab8e3f93c6 | ||
|
|
e5d33861e6 | ||
|
|
ea9eaf4d0c | ||
|
|
58d60bf733 | ||
|
|
d56680cad8 | ||
|
|
2f60dc91ae | ||
|
|
c6e3f57e26 | ||
|
|
e887b9e1e5 | ||
|
|
223dff33ed | ||
|
|
3c2c42ce66 | ||
|
|
5c5b866bcf | ||
|
|
9ad198beb3 | ||
|
|
c1c32686bd | ||
|
|
6f2e99f47d | ||
|
|
27deef9ad6 | ||
|
|
3c658a6fc6 | ||
|
|
35dce2b7f8 | ||
|
|
7655060149 | ||
|
|
65fcaa885e | ||
|
|
b228d1cab1 | ||
|
|
71f1c7d5e5 | ||
|
|
18f7e8584a | ||
|
|
92946057c6 | ||
|
|
5849082650 | ||
|
|
080a2bbd74 | ||
|
|
d93cb6265f | ||
|
|
44cf9c4b90 | ||
|
|
0aff1e1d9c | ||
|
|
15d0b8f824 | ||
|
|
c0d6e951a3 | ||
|
|
408c3c6bea | ||
|
|
b8f657250d | ||
|
|
8b22f1c63a | ||
|
|
b26916dc7c | ||
|
|
a347e50e48 | ||
|
|
35ac9ea987 | ||
|
|
53e6d9874a | ||
|
|
c6dfce167b | ||
|
|
af71dc929f | ||
|
|
2a09ce49da | ||
|
|
cf421b7b39 | ||
|
|
ae58fdb980 | ||
|
|
dff97d9067 | ||
|
|
810a7a93a1 | ||
|
|
553bfc135c | ||
|
|
7649bfc88a | ||
|
|
a90872079b | ||
|
|
4f798e9a28 | ||
|
|
4d4cc92bc9 | ||
|
|
ebbc2a7b54 | ||
|
|
96db4f92c7 | ||
|
|
9c3ace6146 | ||
|
|
4b0066f6e7 | ||
|
|
54d97fd630 | ||
|
|
6fe67f4044 | ||
|
|
9bd05580e7 | ||
|
|
27fc812df9 | ||
|
|
aa60275a2a | ||
|
|
259db1196d | ||
|
|
2dcae97d01 | ||
|
|
3a81a34e4a | ||
|
|
de3a0a0713 | ||
|
|
72c0a4e69a | ||
|
|
c824f7a17f | ||
|
|
12f094cbc0 | ||
|
|
e8eec56783 | ||
|
|
692765c199 | ||
|
|
15fbf3d3d6 | ||
|
|
dafdb1a19b | ||
|
|
14ab2a0317 | ||
|
|
570adc2fe0 | ||
|
|
e6630d52c3 | ||
|
|
617eeae42a | ||
|
|
49a40ba7ed | ||
|
|
4dc8e27cfd | ||
|
|
eac3849486 | ||
|
|
827e18a3fd | ||
|
|
a71667436e | ||
|
|
22b957aee4 | ||
|
|
68ab03d95f | ||
|
|
8d5c13bdc1 | ||
|
|
97515c4d05 | ||
|
|
968862cad3 | ||
|
|
ac6d43af8c | ||
|
|
6ddbf2ccd4 | ||
|
|
5c43f14c19 | ||
|
|
502ca5bfc8 | ||
|
|
5c5a79962d | ||
|
|
391c3412d2 | ||
|
|
fc4ced9043 | ||
|
|
40ec6b6339 | ||
|
|
b4aa11c921 | ||
|
|
269acc2dda | ||
|
|
6d17663f84 | ||
|
|
6456d11795 | ||
|
|
f01c6563c1 | ||
|
|
96a39ce5d2 | ||
|
|
ec8594cd0a | ||
|
|
931f4dc50f | ||
|
|
fe44af7d38 | ||
|
|
4eb4b9c221 | ||
|
|
68fcbe3e2d | ||
|
|
28cff8e87b | ||
|
|
1a46ca4be4 | ||
|
|
7a4db31460 | ||
|
|
f1dde066c0 | ||
|
|
88de2d9bf0 | ||
|
|
5a552cd565 | ||
|
|
ca3d0eb9e1 | ||
|
|
7289e60aa4 | ||
|
|
8e0a7b44f5 | ||
|
|
9e941c7011 | ||
|
|
fa39a594be | ||
|
|
a8f48a1cbb | ||
|
|
f2dfe6deb6 | ||
|
|
8d990ebf93 | ||
|
|
e596a508c7 | ||
|
|
293745d568 | ||
|
|
136fb81d41 | ||
|
|
633b12e2d3 | ||
|
|
fd93c0dc3d | ||
|
|
8f3dc83543 | ||
|
|
c44cac2e9a | ||
|
|
78d1f70eda | ||
|
|
b7ee17ffba | ||
|
|
562393b77e | ||
|
|
c5e5016728 | ||
|
|
e2ceef5217 | ||
|
|
6b6eb11cc0 | ||
|
|
e483fc203b | ||
|
|
107246ff0b | ||
|
|
971065b8a8 | ||
|
|
578c50ea96 | ||
|
|
1b7840b715 | ||
|
|
6b4453e9ca | ||
|
|
3b93f0a08d | ||
|
|
ab8fd29434 | ||
|
|
53fa1e42a0 | ||
|
|
46d36fbbc6 | ||
|
|
7b6adf62d7 | ||
|
|
d2e764316d | ||
|
|
e984b234e3 | ||
|
|
0dc8898e8b | ||
|
|
107a891359 | ||
|
|
bccee9ebc1 | ||
|
|
3868f02c45 | ||
|
|
820c61caa0 | ||
|
|
808fadfef2 | ||
|
|
a29704af55 | ||
|
|
7139c88c35 | ||
|
|
04394b7eac | ||
|
|
c2ac08fdfd | ||
|
|
460677aeea | ||
|
|
46695588f7 | ||
|
|
cfe58db222 | ||
|
|
f991338376 | ||
|
|
704fb1f529 | ||
|
|
1918b7d633 | ||
|
|
a7b4db83fa | ||
|
|
5d424adbe8 | ||
|
|
56199aef81 | ||
|
|
fe8309fd45 | ||
|
|
c1ff5e35aa | ||
|
|
b2496dc4e7 | ||
|
|
c4bf21141c | ||
|
|
84e4623a2c | ||
|
|
e9f38169c2 | ||
|
|
8fec2060ca | ||
|
|
d06455d3c8 | ||
|
|
2b8bed704d | ||
|
|
34c938b1ce | ||
|
|
32442e0b64 | ||
|
|
7989fa3673 | ||
|
|
c389462dde | ||
|
|
db40421faa | ||
|
|
8f40f6065f | ||
|
|
67239d921d | ||
|
|
9393f72cc1 | ||
|
|
99c2b3b4d7 | ||
|
|
092ac2c735 | ||
|
|
38389a81e6 | ||
|
|
c91f4f9a6e | ||
|
|
5223d5430b | ||
|
|
a8a52f3251 | ||
|
|
f9336a8115 | ||
|
|
c27f28c0c1 | ||
|
|
546a05ff0b | ||
|
|
5744a55b06 | ||
|
|
703926bca5 | ||
|
|
7ed9b11a89 | ||
|
|
c274bd7fbb | ||
|
|
bbff67899f | ||
|
|
1703d6a703 | ||
|
|
8d59b0b27b | ||
|
|
37cc72a040 | ||
|
|
205deb150b | ||
|
|
fefa0abdc6 | ||
|
|
843940f552 | ||
|
|
5f9fa4c11f | ||
|
|
ed364a26c9 | ||
|
|
2909c9b339 | ||
|
|
b6bbd39469 | ||
|
|
bfa7019324 | ||
|
|
887a177581 | ||
|
|
19acdcc29d | ||
|
|
d3ecefb7d6 | ||
|
|
95975d5756 | ||
|
|
c6b23690f4 | ||
|
|
4fc12bca4a | ||
|
|
647c1316ba | ||
|
|
2dae24a02b | ||
|
|
705041ea81 | ||
|
|
bc046b3c92 | ||
|
|
0f0e855507 | ||
|
|
abf16fd104 | ||
|
|
7086f41d64 | ||
|
|
57cae0e4be | ||
|
|
abdf2d7864 | ||
|
|
d204d6ad64 | ||
|
|
f0e38a8d41 | ||
|
|
ea09fd0a84 | ||
|
|
e154e4e696 | ||
|
|
09f4ec91f1 | ||
|
|
7e8b546ee0 | ||
|
|
97038624fe | ||
|
|
341c133c74 | ||
|
|
4f45249067 | ||
|
|
6e07dce3f1 | ||
|
|
3b8717c4ce | ||
|
|
2781b21a89 | ||
|
|
bb9b95b4a4 | ||
|
|
d89f634213 |
396 changed files with 75314 additions and 12071 deletions
161
.github/workflows/backend-tests.yml
vendored
Normal file
161
.github/workflows/backend-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
name: Backend Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- 'apps/**'
|
||||||
|
- 'core/**'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'dispatcharr/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'version.py'
|
||||||
|
- 'manage.py'
|
||||||
|
- 'scripts/ci_backend_test_labels.py'
|
||||||
|
- 'scripts/ci_bootstrap_backend.sh'
|
||||||
|
- '.github/workflows/backend-tests.yml'
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- 'apps/**'
|
||||||
|
- 'core/**'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'dispatcharr/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'version.py'
|
||||||
|
- 'manage.py'
|
||||||
|
- 'scripts/ci_backend_test_labels.py'
|
||||||
|
- 'scripts/ci_bootstrap_backend.sh'
|
||||||
|
- '.github/workflows/backend-tests.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
full_suite:
|
||||||
|
description: Run the full backend test suite
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: backend-tests-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plan:
|
||||||
|
name: Plan test groups
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
labels: ${{ steps.resolve.outputs.labels }}
|
||||||
|
has_tests: ${{ steps.resolve.outputs.has_tests }}
|
||||||
|
base_image: ${{ steps.base_image.outputs.image }}
|
||||||
|
sync_python_deps: ${{ steps.base_image.outputs.sync_python_deps }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Collect changed paths
|
||||||
|
id: changed
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: > /tmp/changed_paths.txt
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
if [ "${{ inputs.full_suite }}" = "true" ]; then
|
||||||
|
: > /tmp/changed_paths.txt
|
||||||
|
echo "mode=full" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
git diff --name-only HEAD~1 HEAD > /tmp/changed_paths.txt || true
|
||||||
|
echo "mode=diff" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
elif [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
git fetch origin "${{ github.base_ref }}"
|
||||||
|
git diff --name-only "origin/${{ github.base_ref }}...HEAD" > /tmp/changed_paths.txt
|
||||||
|
echo "mode=pr" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
git ls-files > /tmp/changed_paths.txt
|
||||||
|
echo "mode=initial-push" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" > /tmp/changed_paths.txt
|
||||||
|
echo "mode=push" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Select base image
|
||||||
|
id: base_image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
TARGET_BRANCH="${{ github.base_ref }}"
|
||||||
|
else
|
||||||
|
TARGET_BRANCH="${{ github.ref_name }}"
|
||||||
|
fi
|
||||||
|
if [ "$TARGET_BRANCH" = "main" ]; then
|
||||||
|
TAG="base"
|
||||||
|
else
|
||||||
|
TAG="base-dev"
|
||||||
|
fi
|
||||||
|
REPO_OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
REPO_NAME="$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "image=ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
if grep -qx 'pyproject.toml' /tmp/changed_paths.txt || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "sync_python_deps=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "sync_python_deps=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
echo "Using base image: ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}"
|
||||||
|
|
||||||
|
- name: Resolve Django test labels
|
||||||
|
id: resolve
|
||||||
|
env:
|
||||||
|
FULL_SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.full_suite == true && 'true' || 'false' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
LABELS=$(python scripts/ci_backend_test_labels.py < /tmp/changed_paths.txt)
|
||||||
|
echo "labels=${LABELS}" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "${LABELS}" = "[]" ]; then
|
||||||
|
echo "has_tests=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_tests=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
echo "Selected labels: ${LABELS}"
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: ${{ matrix.label }}
|
||||||
|
needs: plan
|
||||||
|
if: needs.plan.outputs.has_tests == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ needs.plan.outputs.base_image }}
|
||||||
|
credentials:
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
options: --entrypoint ""
|
||||||
|
strategy:
|
||||||
|
max-parallel: 6
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
label: ${{ fromJSON(needs.plan.outputs.labels) }}
|
||||||
|
env:
|
||||||
|
DISPATCHARR_ENV: aio
|
||||||
|
DJANGO_SECRET_KEY: ci-test-secret-key
|
||||||
|
POSTGRES_DB: dispatcharr
|
||||||
|
POSTGRES_USER: dispatch
|
||||||
|
POSTGRES_PASSWORD: secret
|
||||||
|
DISPATCHARR_LOG_LEVEL: WARNING
|
||||||
|
SYNC_PYTHON_DEPS: ${{ needs.plan.outputs.sync_python_deps }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run tests in base image
|
||||||
|
env:
|
||||||
|
GITHUB_WORKSPACE: ${{ github.workspace }}
|
||||||
|
run: bash scripts/ci_bootstrap_backend.sh "${{ matrix.label }}" -v2
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,6 +17,7 @@ celerybeat-schedule*
|
||||||
dump.rdb
|
dump.rdb
|
||||||
debugpy*
|
debugpy*
|
||||||
uwsgi.sock
|
uwsgi.sock
|
||||||
|
.uwsgi-reload
|
||||||
package-lock.json
|
package-lock.json
|
||||||
models
|
models
|
||||||
.idea
|
.idea
|
||||||
|
|
|
||||||
261
CHANGELOG.md
261
CHANGELOG.md
|
|
@ -7,6 +7,267 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.28.2] - 2026-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Schedules Direct Extra Debugging option.** EPG source settings include an **Extra Schedules Direct Debugging** toggle that adds a `RouteTo: debug` header so Schedules Direct support can steer traffic to their debug server. The tooltip states it should only be enabled when SD support asks. If SD returns code 2055 (unexpected debug connection), the toggle is turned off automatically.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **EPG source form places Auto-Apply EPG Logos in the shared middle column for XMLTV and Schedules Direct.** The toggle previously lived in the SD-only right panel when editing a Schedules Direct source; it now uses the same middle-column control as XMLTV so SD-specific options (logo style, posters, debug) stay in the right panel.
|
||||||
|
- **Schedules Direct poster proxy shares auth tokens across uWSGI workers via Redis.** Tokens are stored in Django's cache until near `tokenExpires` (24h fallback), so concurrent `/poster/` requests on different workers reuse one SD session instead of each process hitting `/token` separately. Redis failures fall back to re-authentication.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Schedules Direct poster proxy no longer ignores daily image download limits or missing-image errors.** SD returns codes 5002/5003 as HTTP 200 with a JSON body; the proxy previously only inspected HTTP 400 and kept requesting images after the limit, which can get accounts blocked. It now detects 5002/5003 (subscriber and trial), stops further image fetches for that source until the next midnight UTC (persisted on the EPG source so all workers honor it), and on explicit IMAGE_NOT_FOUND (code 5000) clears that program's `sd_icon` so the same dead URI is not retried. Transient bare HTTP 404s do not blacklist the URI. Successful SD posters use a dedicated nginx cache under `/data/cache/sd_posters` (14 days); channel/VOD logos remain at `/data/cache/logos` (24 hours). On startup, an existing `/data/logo_cache` directory is moved to `/data/cache/logos` once so warm logo entries are kept. API and XMLTV icon URLs include a `?v=` hash of the stored SD image URI so a new artwork link after refresh uses a new cache key without waiting for TTL expiry. Selecting programs that still need artwork no longer uses a negated JSON `AND` that dropped every row when `sd_icon_missing` / `sd_poster_style` keys were absent (Postgres NULL), which had prevented poster links from being saved after refresh.
|
||||||
|
- **Schedules Direct auth and lineup change handling better match SD guidelines.** Token codes 3000/3001 (offline/busy) stop as idle with clear "do not retry" messaging instead of looking like credential failures; 4010 (too many unique IPs) and related account codes get explicit messages. Lineup add/delete respects a known daily change lockout before calling SD, handles 4100 on deletes, and the UI blocks remove when no changes remain (adds and deletes both count toward the 6/day limit).
|
||||||
|
- **Schedules Direct stops retrying `/token` on auth failures that will not clear by themselves.** Codes 4002/4003 (bad credentials), 4001/4005/4007/4008 (account state), 4009/4010 (login/IP limits), and 4004 (15-minute account lock) persist a cooldown on the EPG source. Soft codes 3000/3001 use a 1-hour idle cooldown. Lockouts clear when username/password change or the cooldown expires. Token auth is centralized in `sd_obtain_token` for refresh, lineup/form, and poster paths. That helper reuses a Redis-cached token until near `tokenExpires` (bound to a credential fingerprint, and cleared when the EPG source username/password change) so opening the SD form, refreshing EPG, and serving posters do not mint a new token on every call. When an authenticated SD call returns HTTP 401/403 (SD documents TOKEN_EXPIRED as 403), the cached token is cleared and the request is retried once with a fresh `/token`.
|
||||||
|
- **Schedules Direct code split out of general EPG modules.** Refresh pipeline and Celery tasks live in `apps/epg/sd_tasks.py`; lineup/poster API mixins in `apps/epg/sd_api.py`; shared protocol helpers remain in `sd_utils.py`. Progress WebSocket updates (`send_epg_update`) live in `apps/epg/utils.py` so XMLTV and SD tasks can import cleanly. `tasks.py` / `api_views.py` re-export or inherit so existing imports and Celery task names are unchanged.
|
||||||
|
|
||||||
|
## [0.28.1] - 2026-07-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Fresh AIO installs no longer crash during first-boot migrate when Redis is not up yet.** In AIO, `manage.py migrate` runs in the entrypoint before uWSGI starts Redis via `attach-daemon`. After CoreSettings group caching landed in 0.28.0, data migrations such as `m3u.0003` called live `CoreSettings.get_default_user_agent_id()` → Redis and failed with connection refused, leaving a partially migrated DB and a boot loop. Settings group cache reads/writes now fall back to Postgres on connectivity/timeout errors (with a throttled warning), skip cache fill on backend failure so a flapping Redis cannot re-poison entries after invalidate, and leave auth/protocol Redis errors loud. A regression test migrates a throwaway empty database with Redis unreachable. (Fixes #1459)
|
||||||
|
|
||||||
|
## [0.28.0] - 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Catch-up enable/disable controls.** System Settings includes an **Enable Catchup** toggle (default on) that blocks timeshift and catch-up playback for everyone and clears XC `tv_archive` / `tv_archive_duration` advertising when off. Per-user **Enable Catchup** on the user Permissions tab does the same for that user only (admin-managed; not writable via `PATCH /me/`). Channel/stream catch-up indicators remain visible in the web UI.
|
||||||
|
- **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb)
|
||||||
|
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
|
||||||
|
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom)
|
||||||
|
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. Sessions accept an optional programme `duration` in minutes, using the same buffered duration handling as XC clients. `POST /api/catchup/sessions/<session_id>/position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
|
||||||
|
- **Catch-up admin APIs.** `GET /proxy/catchup/stats/` lists active catch-up viewers; `POST /proxy/catchup/programs/` returns batch EPG metadata for those sessions; `POST /proxy/catchup/stop_client/` stops a viewer from the admin UI.
|
||||||
|
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. When playback continues past the original programme into the catch-up buffer, the card advances to the next EPG programme. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
|
||||||
|
- **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated).
|
||||||
|
- **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure.
|
||||||
|
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order.
|
||||||
|
- **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full.
|
||||||
|
- **Per-client session pool.** The first request without `session_id` and no fingerprint match receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Reconnects that omit `session_id` but match an existing pool entry (same user, IP, and user-agent) are served immediately without a redirect, matching IPTV client fast-forward behaviour. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching. Plain GET restarts stream from byte 0 with upstream `Content-Length` when known (provider-faithful). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot.
|
||||||
|
- Verbose timeshift logging follows the standard logger DEBUG level.
|
||||||
|
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via per-user `epg_prev_days` or `?prev_days=` URL parameter.
|
||||||
|
- **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer.
|
||||||
|
- **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. The Channels and Streams table filter menus include **Only Catch-up** to narrow each list to catch-up entries.
|
||||||
|
- **Combined connection stats API.** `GET /proxy/stats/` (admin) returns live, VOD, and catch-up connection stats in one JSON response (`live`, `vod`, `catchup`, `timestamp`). The Stats page polls this endpoint instead of separate live and VOD stats requests.
|
||||||
|
- **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810)
|
||||||
|
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings`. — Thanks [@nick4810](https://github.com/nick4810)
|
||||||
|
- **Frontend unit tests extended to forms, modals, Connect, and Plugin Browse.** `OutputProfile`, `ServerGroup`, `CreateChannelModal`, `ProfileModal`, `Connect`, `ConnectLogs`, `PluginBrowse`, and `useEpgPreview` now have Vitest + Testing Library suites. Repo-management UI was extracted from `PluginBrowse` into `ManageReposModal`; form/schema helpers moved into `OutputProfileUtils` and `ServerGroupUtils`, with plugin-repo settings helpers added to `PluginsUtils`. — Thanks [@nick4810](https://github.com/nick4810)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Channel list API paginates when only `page_size` is provided.** `ChannelPagination` previously required an explicit `page` query parameter; sending `page_size` alone returned the full unpaginated queryset. Requests with `page_size` but no `page` now paginate normally (page 1). Omitting both `page` and `page_size` still returns the full queryset for legacy clients and plugins.
|
||||||
|
- **VOD proxy now fails over across M3U accounts when the selected account is at capacity.** `stream_vod()` and `head_vod()` previously picked the highest-priority relation and returned HTTP 503 if that account's profile pool was full, without trying other accounts carrying the same title. The proxy now materialises active relations in a single query, walks them in priority order (preferred stream/account first), and streams from the first account with spare capacity—matching live-channel failover. (Closes #1385) — Thanks [@francescodg89-crypto](https://github.com/francescodg89-crypto)
|
||||||
|
- **URL validation now accepts underscores in non-FQDN hostnames.** Stream, M3U server, and EPG source URLs with Docker-style host labels (e.g. `http://my_service/`, `http://dispatcharr_web:8000/`) were rejected because the flexible-URL fallback did not allow underscores in hostname characters. — Thanks [@recurst](https://github.com/recurst)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **CoreSettings JSON groups are cached in Redis.** `CoreSettings._get_group()` caches stream, system, proxy, DVR, EPG, user-limit, and network-access settings so hot paths (including `network_access_allowed` on every stream/XC/catchup request and catch-up enable checks) no longer hit Postgres on each call. Cache entries invalidate on `CoreSettings` save or delete; proxy workers also clear their short process-local proxy-settings copy when `proxy_settings` changes.
|
||||||
|
- **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change.
|
||||||
|
- **M3U stream filters compile once per refresh.** Account filters are regex-compiled before batch workers start; accounts with no filters skip per-stream filter checks entirely.
|
||||||
|
- **M3U refresh releases parse catalogs sooner.** Standard accounts stream-parse the on-disk M3U instead of loading the full file into RAM; `extinf_data` is dropped immediately after batch DB work, before stale cleanup and auto-sync.
|
||||||
|
- **XC live refresh avoids redundant work during catalog filtering.** `collect_xc_streams` skips disabled categories before building entries and uses a shared URL prefix instead of formatting each stream URL separately. Auto-sync releases per-group logo/EPG caches after each group iteration.
|
||||||
|
- **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Authentication boundaries and local logo serving.**
|
||||||
|
- Logo and VOD logo `cache` endpoints no longer serve arbitrary filesystem paths: local URLs must resolve under `/data/logos` (blocks `/data/../…` traversal). Empty logo URLs return 404 instead of attempting a remote fetch.
|
||||||
|
- EPG source file upload API (`POST /api/epg/sources/upload/`) is admin-only and uses `safe_upload_path` so uploaded filenames cannot escape `/data/uploads/epgs`. The web UI does not expose this endpoint (EPG sources are added via URL, Schedules Direct, or dummy); the fix hardens the API-only path.
|
||||||
|
- M3U account passwords are omitted from API responses for non-admins; admins still receive them (the M3U edit form already blanks the password field on load).
|
||||||
|
- Channel bulk-regex rename, EPG name/logo/tvg-id apply, channel reorder, channel-group cleanup, recording stop/extend/comskip/metadata actions, and Connect integration APIs require admin. Guide-facing channel read helpers (`summary`, `ids`, etc.) remain available to standard users.
|
||||||
|
- System notifications: any authenticated user can still list and dismiss notifications visible to them; create/update/delete require admin (previously any authenticated user could create notifications).
|
||||||
|
- Django `auth.Group` CRUD (`/api/accounts/groups/`) and the permissions list endpoint require admin. Dispatcharr authorization uses `user_level`, not these groups; the React UI does not call these endpoints.
|
||||||
|
- Removed unused unauthenticated routes: `/proxy/hls/` (HLS proxy package retained for possible future use) and legacy `/output/stream/<uuid>/` (including the nginx location). Live playback continues via `/proxy/ts/stream/`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Deleting or sync-removing a channel while it is playing no longer leaves a hung proxy session.** Manual deletes no longer force-stop playback (optional `stop_stream` on the API / "Also stop active channel if playing" in the UI). M3U auto-sync and account cleanup still stop proxy sessions before removing channel rows. If a stream is left playing after a delete, later Stats stop / disconnect still releases the M3U profile connection slot from Redis metadata even when the Channel row is gone. (Fixes #870)
|
||||||
|
- **Cron builder quick presets with step intervals (e.g. every 6/12 hours) no longer apply the wrong expression.** Selecting `0 */6 * * *` or `0 */12 * * *` previously rebuilt through Simple-mode controls that could not express hour steps, so Apply saved `0 6 * * *` / `0 12 * * *` instead. Hourly frequency now includes an Interval dropdown (every hour, every 2/3/4/6/8/12 hours), and those presets load into it so editing keeps the step pattern. (Fixes #1320)
|
||||||
|
- **Live proxy fMP4/profile output Redis keys no longer expire during long sessions.** `output:{fmt}:owner`, `state`, and fMP4 `init` were written once with a 3600s TTL and never refreshed, so sessions longer than an hour lost coordination keys even while the remux/transcode was still running (late joiners and cross-worker `ensure_output_format` could fail). Those TTLs are now extended about once per minute from the manager reader loop while alive; graceful stop still deletes the keys immediately.
|
||||||
|
- **Ghost channel sessions stuck in `initializing` no longer block playback forever.** Failed live-proxy initialization previously wrote `state=initializing` to Redis _before_ acquiring the channel ownership lock. If init died in that window (exception, worker race), metadata was left behind with no owner lock, no URL, and no TTL. Later play requests treated the live worker heartbeat as proof the channel was healthy, attached to the dead session, and got `Error: Connection stalled` forever; failover never ran. The M3U profile connection slot from the failed init also leaked. Initialization now writes `initializing` only after ownership is held, tears down Redis/local leftovers immediately on failure, and gives temp/init metadata a TTL backstop. Play setup claims ownership (plus a same-worker in-progress flag) under a short gevent lock _before_ `generate_stream_url`, then releases that lock so followers are not blocked for the URL/init work.
|
||||||
|
- **Live proxy no longer leaks geventpool DB connections across stream setup / teardown.** `stream_ts()` released its checkout only on the happy path before `StreamingHttpResponse`, so early JSON failures, exceptions, 404s, and aborted channel init left slots counted against `MAX_CONNS` until restart (XC/`player_api` hung while `/api/core/version/` stayed fast). Setup now always calls `close_old_connections()` in a `finally` (including re-raised `Http404`); `ProxyServer.initialize_channel`, `ChannelService.initialize_channel`, XC auth, `next_stream`, channel status, and the ffmpeg stderr reader do the same. Channel/stream display names are taken from the caller or Redis during `StreamManager` / TS / fMP4 generator construction so those paths no longer check out the pool just to resolve a name. (Fixes #1418)
|
||||||
|
- **uWSGI/Daphne boot no longer leaves a permanent geventpool checkout on `core_coresettings`.** `AppConfig.ready` paths that sync the backup scheduler (cron timezone via `system_settings`), developer notifications, and plugin repo refresh now call `close_old_connections()` in `finally`, so a stuck idle `system_settings` query no longer burns one of the 8 pool slots from process start.
|
||||||
|
- **Live channel Redis `state` no longer stays latched at `buffering` after a buffering-timeout failover.** When FFmpeg speed dipped below the buffering threshold and the timeout triggered a stream switch, the in-memory buffering flag was cleared without rewriting Redis, and the same stats sample could re-write `buffering`. After the new stream recovered, the speed-good path only cleared Redis when that flag was still set, so a healthy session could report `buffering` until restart or retune. A successful buffering-timeout switch now writes `active` and skips the fallthrough `buffering` write. (Fixes #1449)
|
||||||
|
- **Plugin discovery no longer force-reloads on every connect event in multi-worker setups.** Reacting to a newer `.reload_token` (after install/update/reload) previously re-touched the token, so each uWSGI worker's next discovery re-staled every other worker and never converged. Streaming connect/disconnect events then re-imported every enabled plugin on the request path, leaking plugin background threads and degrading workers until restart. Stale-token reactions now reload locally without bumping the shared token; only an explicit `force_reload=True` (install/update/reload API) broadcasts. (Fixes #1452)
|
||||||
|
- **Channels table "Copy URL" no longer includes the web player's output profile.** The kebab-menu action was reusing the in-app preview URL builder, so copied links could include `output_format=mpegts` and `output_profile=...` from web-player prefs. Copy now emits a plain `/proxy/ts/stream/{uuid}` URL suitable for external players; Watch still applies player prefs.
|
||||||
|
- **Redis-backed VOD sessions no longer leave stale profile connection reservations after multi-worker range-request teardown.** Jellyfin/FFmpeg clients that issue concurrent byte-range requests for one VOD session could finish teardown while another worker held the session metadata lock (`vod_connection_lock:*`). `decrement_active_streams_and_check()` then failed with `DECR-AS-CHECK failed: could not acquire lock`, skipped the profile-slot release, and left `profile_connections:*` and the session hash occupied until restart or manual Redis cleanup. Per-session `active_streams` is now mutated with Redis Lua (independent of the metadata lock), metadata saves omit and never clobber that counter, idle cleanup deletes the session hash only when still idle, and late metadata writes cannot recreate a deleted session. (Fixes #1426)
|
||||||
|
- **EPG programme times no longer shift when PostgreSQL's server default timezone is non-UTC.** After the psycopg3 / Django upgrade, geventpool sessions were no longer pinned to UTC: Django's connection timezone setup is skipped whenever `self.pool` is truthy, and the older `connection_created` `SET TIME ZONE 'UTC0'` receiver was a nested closure registered with a weak reference, so it did not reliably stay registered (in production with `DEBUG=False` it was garbage-collected and never ran; `DEBUG=True` could keep it alive via Django's inspect LRU cache). Sessions that lacked a live pin therefore inherited the server default; on deployments whose default is non-UTC (for example from `/etc/localtime` bind-mounts), psycopg3 decoded `timestamptz` values under that zone and XMLTV output labeled local wall-clock times as `+0000`. The pool backend now sets `TimeZone=UTC` in the libpq startup packet so every pooled connection starts in UTC (and `RESET TimeZone` returns to UTC), and the fragile signal is removed. Stored data was never corrupted, only reads were wrong. (Fixes #651) — Thanks [@nagelm](https://github.com/nagelm)
|
||||||
|
- **Frontend date/time unit tests no longer fail outside UTC / en-US.** Three tests encoded the machine timezone or locale into their expectations (`dateTimeUtils.isSame`, `RecordingUtils.toDateString`, `SeriesModalUtils.getEpisodeAirdate`), so the suite failed on boxes east of UTC or non-US locales. Fixtures now use local calendar datetimes and locale-computed expectations so the suite passes in every environment. — Thanks [@nagelm](https://github.com/nagelm)
|
||||||
|
- **API error toasts no longer dump raw HTML error pages.** Failed responses that return Django or nginx HTML (for example 500/502/504 when the backend is down) were interpolated into the toast body as markup. Errors are now formatted for display: JSON bodies prefer `detail` / `error` / field messages, HTML and empty bodies collapse to a short status line, and long plain-text bodies are truncated. (Fixes #1261) — Thanks [@nagelm](https://github.com/nagelm)
|
||||||
|
- **Swagger/OpenAPI schema generation no longer fails under concurrent gevent requests.** Concurrent hits to `/api/schema/` (for example Swagger UI loading) could race on DRF's shared `AutoSchema` state and raise `AssertionError: Schema generation REQUIRES a view instance`, leaving Swagger empty. Cold builds could also hang the gevent uWSGI worker. Schema introspection now runs in a real OS thread, builds are single-flight per process, and the result is cached in Django's cache (Redis) so all workers share it.
|
||||||
|
- **`/proxy/ts/change_stream` now persists `stream_id` and waits for the real switch outcome in multi-worker deployments.** When the request landed on a worker that didn't own the channel, the owning worker's `STREAM_SWITCH` pubsub handler performed the switch but wrote only `url`/`user_agent` back to the channel's Redis metadata, so `GET /proxy/ts/status` kept reporting the old `stream_id` indefinitely; the handler now persists the full switch metadata (`stream_id`, `m3u_profile`, stream name) via the same `_update_channel_metadata()` helper the direct owner path uses. `stream_name` from the initial `get_stream_info_for_switch()` lookup is now forwarded through the pubsub payload (and from `change_stream` / `next_stream` on the direct owner path) so the owner no longer re-queries the database when writing metadata. The endpoint also no longer returns an optimistic 200 on the non-owner path: the requesting worker waits (up to 15s) for the owner to confirm via the `switch_status` Redis key and answers 502 if the owner reports failure or 504 if no confirmation arrives (e.g. owner worker gone); `next_stream` gets the same treatment. Requesting a switch to the URL the channel is already playing is now treated as success (with a metadata refresh) instead of a silent no-op, and the `switch_status` key now expires (60s TTL) instead of persisting forever. (Fixes #1412)
|
||||||
|
- **XC live streams, `/panel_api.php`, and `/xmltv.php` no longer crash when a visible channel has no channel number.** Since `channel_number` became nullable, auto-sync and compact numbering can leave a channel with `effective_channel_number=None` while it remains visible in output (`hidden_from_output=False`). The XC live-stream number map skipped those rows but still tried to emit them, causing `KeyError` on `get_live_streams` and a 500 on `/panel_api.php` (`xc_get_info`). Authenticated XMLTV export (`/xmltv.php`, profile EPG with a logged-in user) had the same gap and could fail chunk-cache rebuilds with `KeyError` on `channel_num_map[channel.id]`. Null-number channels are now deferred into the existing collision-free integer assignment pass (same second loop as fractional numbers) and receive the next available integer; `_xc_channel_entry` also falls back to `channel.id` if a row is still unmapped. HDHR lineup behaviour is unchanged (null-number channels remain omitted there).
|
||||||
|
- **System events and Connect dispatch no longer close the DB connection mid-call outside worker contexts.** `log_system_event()` and `dispatch_event_system()` always called `close_old_connections()` in `finally` blocks, which broke Django `TestCase` transactions and could interrupt in-flight ORM work when Connect/plugin handlers ran synchronously. Closes are now limited to gevent uWSGI workers (and the Celery sync-dispatch path where gevent is patched but no hub runs). Celery workers still reset connections via existing `task_postrun` / `task_prerun` hooks on the standard PostgreSQL backend (no geventpool).
|
||||||
|
- **Connect `trigger_event` no longer releases DB connections during plugin discovery.** Event dispatch calls `discover_plugins(use_cache=True)` to resolve plugin handlers; its unconditional `close_old_connections()` could tear down the caller's connection before `log_system_event()` or Schedules Direct refresh finished. Discovery from event dispatch now passes `release_connections=False`; boot-time discovery (`worker_process_init`, `post_migrate`, admin reload) still releases connections by default.
|
||||||
|
- **EPG Celery tasks no longer reset the DB connection when invoked outside a worker.** `_release_task_db_connection()` is gated on `_is_celery_worker_context()` so synchronous test runs and in-process task calls do not close the test database connection; active Celery tasks still reset poisoned connections after transient ORM errors.
|
||||||
|
- **Celery workers no longer use django-db-geventpool (fixes Postgres protocol errors under concurrent tasks).** Celery's default queue runs prefork (`--autoscale=6,1`), not gevent, but it was sharing the same `django-db-geventpool` backend as uWSGI. That pool is a process-wide singleton of warm connections; `fork()` (including autoscale spawning new children on demand) duplicated those sockets across processes and corrupted Postgres session state (`the last operation didn't produce records (command status: SET/BEGIN/ROLLBACK)`, `connection ... in transaction status INTRANS`, and matching server-side `there is already a transaction in progress` on one backend). Celery workers and beat now use Django's standard PostgreSQL backend (`CONN_MAX_AGE=0`, no warm pool); uWSGI keeps geventpool. Plugin discovery moved from `worker_ready` (arbiter) to `worker_process_init` (each child after `connections.close_all()`), so the prefork parent no longer opens DB handles that children would inherit. (Fixes #1404)
|
||||||
|
- **EPG channel parsing progress no longer exceeds 100%.** During `parsing_channels`, progress used the pre-parse database channel count as the denominator while the numerator counted channels found in the XML. When the guide grew (or on sources where the file had more channels than the DB), the UI could show values well above 100% (e.g. ~300%). The estimate now bumps when the XML exceeds the DB baseline, progress is capped at 98% until final cleanup, and update frequency adapts to the known total (~20 ticks on steady-state refreshes; coarse every-100-channel updates for first import or when the file outgrows the estimate). A final in-loop update reports the true parsed count before completion.
|
||||||
|
- **EPG refresh and per-channel parses no longer race.** Full-source refresh holds an `epg_source_file` lock through download, channel parse, and bulk programme swap; programme index builds acquire the same lock separately (after refresh releases it). Per-channel `parse_programs_for_tvg_id` tasks run concurrently for different tvg-ids (no file lock between them) but defer while a source refresh is in progress. Refresh waits until in-flight per-channel parses finish (Redis counter) before downloading. Channels matched mid-refresh are excluded from the bulk-parse snapshot but receive a follow-up per-channel parse when refresh finishes; orphan cleanup at swap time uses live channel assignments so mid-refresh matches are not wiped. Duplicate tasks for the same `epg_id` are deduped via `parse_epg_programs` lock with a log noting how many channels map to that EPG. Busy file/index deferrals retry for up to two hours (15s interval).
|
||||||
|
- **Per-channel EPG parse no longer wipes guide data on failure.** `parse_programs_for_tvg_id` used to delete existing programmes before streaming the XML and flush new rows in batches as it parsed, so any failure partway through left a mapped channel with no guide data. It also re-fetched the `EPGData` row mid-task instead of reusing the instance loaded at task start. The task now reuses the initial row and replaces programmes in one transaction at the end (delete old, insert new), so a parse failure leaves the previous guide intact.
|
||||||
|
- **Cold `/output/epg` rebuild no longer freezes the gevent uWSGI worker.** CPU-bound XMLTV generation in the chunk-cache leader loop ran without yielding to the gevent hub, so login, API, and HDHomeRun requests on the same worker hung for the full rebuild duration. `_stream_build` now calls `_cooperative_yield()` (`gevent.sleep(0)`) after each cached chunk so other greenlets can run between programme batches. (Fixes #1396)
|
||||||
|
- **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run).
|
||||||
|
- **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh.
|
||||||
|
- **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||||
|
- **XC refresh no longer wipes auto-created channels on an empty provider fetch.** When `collect_xc_streams()` returned no live streams (transient upstream failure, fetch exception, or no enabled category matched), the refresh used to continue into stale-marking and auto-sync, which deleted the account's entire auto-created channel lineup. An empty XC result now aborts before those steps, sets the account to `ERROR`, and preserves the existing lineup—matching the standard M3U path's empty/failed-download guards. (Fixes #1377) — Thanks [@Jacob-Lasky](https://github.com/Jacob-Lasky)
|
||||||
|
- **VOD refresh no longer wipes group selections on an empty category fetch.** When `get_vod_categories` or `get_series_categories` returned `[]` (transient provider outage, rate limiting, or VOD API hiccup during a scheduled refresh), `batch_create_categories` treated every existing relation as orphaned, deleted category links and often the `VODCategory` rows themselves, and the follow-up cleanup removed thousands of movie relations. On the next successful refresh, groups were recreated with `enabled=False` when `auto_enable_new_groups_vod` was off—matching reports of all VOD groups appearing unselected after routine M3U refreshes. An empty category list now aborts the VOD refresh before any relation deletion or content cleanup when the account already has category selections (new accounts with no existing relations are unchanged).
|
||||||
|
- **`ChannelUtils.requeryChannels()` returns the API promise again** so bulk channel delete, drag reorder, and inline channel-number saves await the table refetch before clearing selection or continuing. — Thanks [@nick4810](https://github.com/nick4810)
|
||||||
|
- **VOD connection card Content Duration badge** shows human-readable lengths (`45m`, `2h 0m`) again on the Stats page instead of bare minute integers. — Thanks [@nick4810](https://github.com/nick4810)
|
||||||
|
- **Frontend unit tests pass on Node 25+ again.** Node 25+ exposes a native `localStorage` stub on `globalThis` that lacks Storage API methods (`getItem`, `clear`, etc.), which shadows jsdom's implementation and breaks tests that touch `localStorage` (e.g. `TypeError: localStorage.clear is not a function`). `setupTests.js` now installs an in-memory shim on `globalThis` and `window` when those methods are missing; the shim is skipped automatically once Node provides a working implementation. — Thanks [@nick4810](https://github.com/nick4810)
|
||||||
|
- **Live proxy failover works with the default VLC stream profile.** When `cvlc` could not open an upstream URL it often stayed running in dummy mode without writing TS data, so the stream manager never left its read loop and never exhausted retries to switch streams. The locked VLC profile now passes `--play-and-exit` (migration 0027 for existing installs), and `VLCLogParser` treats `unable to open the mrl` as a fatal input error so the connection is closed and the normal retry → failover cycle can proceed. (Fixes #1415)
|
||||||
|
- **Live proxy no longer fails over after isolated provider disconnects.** Connection retries on the same URL used to accumulate indefinitely, so three brief drops spread across many hours of otherwise stable playback could exhaust retries and switch streams even when each reconnect succeeded. Retries now reset after 30 minutes without a failure (`RETRY_WINDOW_SECONDS`), so periodic provider drops (for example every few hours) each get a full retry budget on the current stream. Failover still triggers when three failures occur within that window.
|
||||||
|
- **Live proxy failover now walks backup streams in channel order.** After a stable session on a backup stream, `tried_stream_ids` is cleared so rotation continues from the current position (stream 2 → 3 → 4 → 1) instead of jumping back to stream 1. `get_alternate_streams()` returns alternates in that rotated order, matching the manual next-stream API.
|
||||||
|
- **Live proxy preview no longer 500s when joining an active channel on a non-owner worker.** `stream_ts()` now pre-registers the client before `ensure_output_profile()` (the same watchdog protection owners already had), so the non-owner cleanup thread no longer tears down `client_manager` while output-profile transcode is still starting. Failed setup paths remove the client again and return 503 instead of an unhandled `KeyError`.
|
||||||
|
- **Backend test suite reliability.** `dispatcharr.settings_test` creates `test_dispatcharr` as UTF-8 (`template0`) so EPG programme indexes and Unicode XMLTV data round-trip correctly. Tests were updated for DOCTYPE-based HTML entity resolution, EPG name-normalization expectations, migration 0037 unit invocation, Schedules Direct mocks, and other areas; full app test modules are discoverable when listed explicitly (bare `manage.py test` still only runs `core.tests` and top-level `tests/`).
|
||||||
|
- **Backup Manager "Created" column now updates when date/time format preferences change.** The table column definitions were memoized with an empty dependency array while closing over `fullDateTimeFormat`, so changing display preferences did not refresh backup timestamps until the page was reloaded.
|
||||||
|
|
||||||
|
## [0.27.2] - 2026-06-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Proxy grace-period settings are now split into three distinct timeouts.** The cleanup watchdog already applied `channel_init_grace_period` while a channel was still connecting (buffer not ready) and reused `channel_shutdown_delay` once `connection_ready_time` was set, including for `waiting_for_clients` with zero viewers. With the default `channel_shutdown_delay` of 0s, a buffered channel waiting for its first viewer was stopped almost immediately; raising shutdown delay was the only workaround, but that also delayed teardown after real disconnects. Behaviour is now:
|
||||||
|
- **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup.
|
||||||
|
- **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case).
|
||||||
|
- **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies when the buffer is ready but no viewer has connected yet.
|
||||||
|
- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep buffered channels alive with no viewers, set `channel_client_wait_period` instead (Settings → Proxy → Advanced).
|
||||||
|
- **Proxy settings UI: less-used options moved under Advanced.** Settings → Proxy now shows the day-to-day tuning fields by default (`buffering_timeout`, `buffering_speed`, `channel_shutdown_delay`, `new_client_behind_seconds`). **Buffer Chunk TTL**, **Channel Initialization Timeout**, and **Client Connect Grace Period** are tucked under **Show Advanced Settings**.
|
||||||
|
|
||||||
|
## [0.27.1] - 2026-06-25
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Updated `Django` 6.0.5 → 6.0.6, resolving the following CVEs:
|
||||||
|
- **CVE-2026-6873**: Signed cookie salt namespace collision in `HttpRequest.get_signed_cookie()`.
|
||||||
|
- **CVE-2026-7666**: Potential unencrypted email transmission via STARTTLS in the SMTP backend.
|
||||||
|
- **CVE-2026-8404**: Potential private data exposure via case-sensitive `Cache-Control` directives in `UpdateCacheMiddleware`.
|
||||||
|
- **CVE-2026-35193**: Potential private data exposure via missing `Vary: Authorization` in `UpdateCacheMiddleware`.
|
||||||
|
- **CVE-2026-48587**: Potential private data exposure via whitespace padding in the `Vary` header.
|
||||||
|
- Updated frontend npm dependencies to resolve 4 audit vulnerabilities (1 low, 2 moderate, 1 high):
|
||||||
|
- Updated `vite` 7.3.2 → 7.3.5, resolving **moderate** NTLMv2 hash disclosure via UNC path handling on Windows ([GHSA-v6wh-96g9-6wx3](https://github.com/advisories/GHSA-v6wh-96g9-6wx3)) and **high** `server.fs.deny` bypass on Windows alternate paths ([GHSA-fx2h-pf6j-xcff](https://github.com/advisories/GHSA-fx2h-pf6j-xcff))
|
||||||
|
- Updated `js-yaml` 4.1.1 → 5.1.0, resolving **moderate** quadratic-complexity DoS in merge key handling via repeated aliases ([GHSA-h67p-54hq-rp68](https://github.com/advisories/GHSA-h67p-54hq-rp68))
|
||||||
|
- Updated `esbuild` 0.27.3 → 0.28.1, resolving **low** arbitrary file read when running the development server on Windows ([GHSA-g7r4-m6w7-qqqr](https://github.com/advisories/GHSA-g7r4-m6w7-qqqr))
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Isolated backend test settings (`dispatcharr.settings_test`).** `python manage.py test` now switches to this module automatically (via `manage.py`). It creates an empty PostgreSQL `test_<dbname>` database (same engine as production), uses the standard Postgres backend instead of geventpool so `TestCase` transactions isolate correctly, and leaves Celery tasks queued (no eager `post_save` signal runs during tests). Set `TEST_USE_SQLITE=1` for an in-memory SQLite fallback when Postgres is unavailable.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **`get_vod_streams` and `get_series` XC API endpoints are faster and no longer exhaust Docker `/dev/shm`.** Large libraries (e.g. 125k movies) previously ran one wide `DISTINCT ON` query with parallel workers, which could fail with `could not resize shared memory segment … No space left on device` on the default 64MB container shm. Both endpoints now fetch display columns via `.values()` (no ORM model instantiation per row). Redundant `category` and `logo` joins were dropped in favor of FK ids; alphabetical sort runs in SQL. Typical full-library response time drops from ~23–28s to ~8–10s with stable shm usage.
|
||||||
|
- **XMLTV EPG export is faster and no longer balloons worker memory.** `generate_epg()` was reworked end-to-end for large guides. (Fixes #1366)
|
||||||
|
- Streams incrementally: on a cache miss each chunk is pushed to a Redis list as it is yielded (no `''.join()` in the worker); repeat requests within 300s stream chunks back from Redis. `malloc_trim` runs after cold builds.
|
||||||
|
- Channel streams are prefetched once (only `id`/`name`) instead of one query per custom-dummy channel; dummy `EPGData` programme existence is bulk-checked in a single query.
|
||||||
|
- The primary channel id is escaped once per `epg_id` group instead of once per programme (~750k fewer `html.escape` calls on a large guide).
|
||||||
|
- The channel query no longer JOINs multi-MB `programme_index` blobs per channel (~13s saved on a ~2000-channel guide; indices live in `EPGSourceIndex`).
|
||||||
|
- Programme export uses `(epg_id, id)` keyset pagination with a per-source `start_time` sort; a matching composite index on `ProgramData` (created `CONCURRENTLY` on PostgreSQL) lets each chunk use an ordered index range scan instead of re-sorting every chunk.
|
||||||
|
- **EPG grid endpoint releases its payload memory back to the OS.** `/api/epg/grid/` drops the redundant full-list copy when appending dummy programmes and runs `malloc_trim` once the response is sent, so worker RSS no longer ratchets up ~20MB per request.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`programme_index` moved off `EPGSource` into a dedicated `EPGSourceIndex` table.** The multi-MB byte-offset index was repeatedly pulled into web and Celery workers by ordinary `EPGSource` queries and `select_related` JOINs (which ignore manager-level `defer()`). It now lives in a one-to-one `EPGSourceIndex` row, read only when explicitly accessed through the `EPGSource.programme_index` property, so no list, detail, or JOIN query can load it by accident. Migration `0026` copies existing indices across. No API or index-build behavior change.
|
||||||
|
|
||||||
|
- **EPG generation extracted into `apps/output/epg.py`.** All XMLTV output logic (`generate_epg`, `generate_dummy_programs`, `generate_custom_dummy_programs`, `generate_dummy_epg`, and supporting helpers) moved from `apps/output/views.py` into a dedicated module. `views.py` retains the thin HTTP endpoint wrappers and auth checks; `epg.py` handles all content generation. No behavior change.
|
||||||
|
|
||||||
|
- **Channel list with nested streams loads faster.** `GET /api/channels/channels/?include_streams=true` (Channels UI and single-channel fetch) now builds nested stream payloads from the prefetched `channelstream_set` instead of issuing one extra streams M2M query per channel.
|
||||||
|
|
||||||
|
- Dependency updates:
|
||||||
|
- `Django` 6.0.5 → 6.0.6 (security patch; see Security section)
|
||||||
|
- `requests` 2.33.1 → 2.34.2
|
||||||
|
- `gevent` 26.4.0 → 26.5.0
|
||||||
|
- `torch` 2.11.0+cpu → 2.12.1+cpu
|
||||||
|
- `sentence-transformers` 5.4.1 → 5.6.0
|
||||||
|
- `lxml` 6.1.0 → 6.1.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Channel Initialization Grace Period is honoured during live stream startup.** Preview and playback no longer abort after a hardcoded 10s while the channel is still connecting with an empty buffer; the TS generator init-wait stall check and upstream health monitor now use the configured `channel_init_grace_period` (same as the server cleanup watchdog) instead of `CONNECTION_TIMEOUT`. (Fixes #1380)
|
||||||
|
- **Channels are marked `active` as soon as the buffer threshold is met and a client is streaming.** Once the initial buffer fills, state is set to `active` immediately when viewers are attached, or `waiting_for_clients` when the buffer is ready but no client is connected yet (e.g. proxy API warmup). The cleanup watchdog no longer waits an extra grace period before promoting to `active`. Proxy settings copy now describes `channel_init_grace_period` as an initialization buffer timeout.
|
||||||
|
- **DVR recording playback auth is complete for native video, HLS segments, and redirects.** Completed recordings use `/file/` with native `<video src>`, which cannot send `Authorization` headers. Playback accepts `?token=` query-param JWT (matching VOD streams), and the floating video player appends the token when assigning native URLs. The explicit HLS URL route (`/api/channels/recordings/.../hls/...`) now registers the same authenticators as the ViewSet `@action` handlers—previously only header JWT applied on that path, so `?token=` failed for native HLS clients. When the request used `?token=`, rewritten playlist segment URLs and `/file/`↔`/hls/` redirects preserve the token; hls.js clients that authenticate via `Authorization` are unchanged. `QueryParamJWTAuthentication` reads `request.query_params` on DRF requests.
|
||||||
|
- **In-progress DVR playback no longer jumps to the live edge.** The floating video player still opens in-progress recordings at the start of the seekable range, but hls.js was configured with `liveMaxLatencyDurationCount: 10`, which forced the playhead forward to "now" shortly after playback began. Live-edge sync is now disabled for recording HLS so users can watch, pause, and scrub from the beginning while the recording continues. (Fixes #1329)
|
||||||
|
- **`refresh_single_m3u_account` no longer re-raises after setting account ERROR.** Matches `refresh_epg_data`: the account status and UI already reflect the failure; Celery no longer marks the task FAILED after the terminal error state is persisted.
|
||||||
|
- **M3U refresh recovers DB connections and account status after failures.** Celery workers now call `close_old_connections()` before and after every task; `refresh_single_m3u_account` also resets its DB connection at task start and retries account loads once after a connection reset (avoids opaque `list index out of range` errors from poisoned worker connections). Accounts leave `fetching`/`parsing` for a terminal `error` or `success` state when refresh aborts. Error-path status updates use `QuerySet.update()` on a clean connection instead of `save()` on a connection that may already be INTRANS after `refresh_m3u_groups` or batch ORM failures. Failed group downloads now set `error` instead of returning silently. Refresh start replaces stale `last_message` text. `refresh_account_profiles` releases DB connections after each profile failure. (Fixes #1338)
|
||||||
|
- **EPG refresh recovers DB connections and source status after failures.** `refresh_epg_data` now mirrors the M3U hardening: connection reset at task start/end, retried source load after a poisoned-worker connection reset, `QuerySet.update()` for error status on a clean connection, and a `finally` guard that moves sources stuck in `fetching`/`parsing` to `error`. Programme index builds are skipped when programme parsing fails. `parse_channels_only` now persists the error status when a missing file has no URL to refetch.
|
||||||
|
- **M3U `custom_properties` JSON normalization.** Legacy rows and some API writes could store a JSON-encoded string instead of an object in `custom_properties`, causing refresh, profile sync, and auto-sync to fail with `'str' object has no attribute 'get'`. M3U account, profile, and group-relation models normalize on `save()`; group settings bulk writes and read/merge paths use shared helpers in `core.utils` without re-parsing dict values.
|
||||||
|
- **`POST /api/epg/import/` no longer loads the full EPG source row.** The dummy-source guard uses a narrow existence query instead of `objects.get()`, keeping the import trigger lightweight. Request/response shape is unchanged for the UI, plugins, and external importers.
|
||||||
|
- **XC live playback URL building now normalizes account server URLs.** On-demand live URLs (introduced for Server Group profile rotation in 0.27.0) rebuild `/live/{user}/{pass}/{stream_id}.ts` from current credentials instead of reusing the synced `stream.url`. That path now runs `normalize_server_url()` so pasted API URLs (e.g. `/player_api.php` with query params) are stripped while sub-paths like `/server1` are preserved. `get_transformed_credentials()` normalizes the base URL at the source; VOD movie and episode relations use the same helper instead of constructing a throwaway `XCClient` (which also avoided per-request HTTP session setup). (Fixes #1363)
|
||||||
|
- **Live proxy now releases geventpool DB connections on more paths.** `stream_ts()` calls `close_old_connections()` before `StreamingHttpResponse` so clients waiting on channel init do not keep a pool slot while the generator polls Redis. `generate_stream_url()`, `get_stream_info_for_switch()`, `get_alternate_streams()`, and `get_connections_left()` release in `finally` blocks after ORM work on stream-manager failover paths that run outside Django's request cycle. TS client disconnect cleanup matches fMP4, and `ChannelService` metadata helpers release after their ORM lookups.
|
||||||
|
- **OpenAPI schema paths for nested M3U profiles and filters no longer contain escaped slashes.** Router regex prefixes used unnecessary `\/`, which drf-spectacular serialized literally into the schema and broke client generators (e.g. oapi-codegen). Runtime URL matching is unchanged. (Fixes #1384)
|
||||||
|
- **Auto-sync range conflict warning no longer flags a group's own channels when using a channel-group override.** The M3U group settings "Range conflict" check compared occupant channel groups against the source group being configured. Auto-sync with `group_override` creates channels in the override target group, so those channels were misclassified as conflicts. The frontend now resolves the effective target group (override target when set, otherwise the source group) for classification. Genuine conflicts—manual channels, other accounts, different groups, or user-pinned numbers—still surface. (Fixes #1331) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||||
|
|
||||||
|
## [0.27.0] - 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Manual Server Groups for shared M3U connection limits.** The existing `ServerGroup` model and account FK are now wired into live and VOD playback. Accounts assigned to the same group share a credential-scoped Redis counter when their provider logins match (hashed fingerprint); unrelated logins in the same group keep separate counters. Enforcement uses each profile's `max_streams` - not a group-wide cap - and profiles with `max_streams=0` skip credential pooling for that profile while still rotating on their own per-profile counter. New `apps/m3u/connection_pool.py` centralizes reserve/release, profile rotation, and credential moves on profile switch. (Closes #1137) — Thanks [@Goldenfreddy0703](https://github.com/Goldenfreddy0703)
|
||||||
|
- **Server Groups manager UI** on the M3U Accounts page: create, rename, and delete groups with account counts and delete confirmation. Groups load on login via a new Zustand store and REST helpers (`/api/m3u/server-groups/`).
|
||||||
|
- **M3U account form** adds a Server Group picker (including inline “add group”), reorganized into three columns (source/auth, connection limits, sync/content), and a Manage server groups shortcut.
|
||||||
|
- **VOD profile selection** uses `pool_has_capacity_for_profile()` so grouped accounts respect shared credential limits before a profile is chosen (non-grouped accounts behave as before).
|
||||||
|
- **Live profile switches** move the shared credential counter when the new profile uses a different provider login; same-login switches leave the credential counter unchanged.
|
||||||
|
- **Credential release keys** stored at reserve time allow counters to be released even if the M3U profile row is deleted afterward.
|
||||||
|
- **PostgreSQL `application_name` tagging by process role.** Pool connections now set `application_name` at connect time (e.g. `Dispatcharr-uwsgi-{pid}`, `Dispatcharr-celery-worker-{pid}`, `Dispatcharr-celery-dvr-{pid}`) so `pg_stat_activity` shows which Dispatcharr process owns each backend instead of a generic client label.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Plugin repo manifests support split download and metadata base URLs.** `download_base_url` and `metadata_base_url` are optional alternatives to `root_url` in the plugin repository manifest, so repo authors can serve metadata (manifests, icons) and release zips from different origins without absolute URLs everywhere. Manifest and icon URLs resolve via `metadata_base_url` then `root_url`; latest/download URLs resolve via `download_base_url` then `root_url`. When `metadata_base_url` is set and a plugin entry has `manifest_url` but no `icon_url`, the icon defaults to `logo.png` in the same directory as the per-plugin manifest. Manifests using only `root_url` behave identically to before. — Thanks [@sethwv](https://github.com/sethwv)
|
||||||
|
- **Live and VOD connection slot logic now routes through `connection_pool`.** `Channel.get_stream()`, direct `Stream.get_stream()`, VOD profile selection, and the multi-worker VOD connection manager all call `reserve_profile_slot()` / `release_profile_slot()` instead of inline Redis INCR/DECR. VOD profile selection also checks `pool_has_capacity_for_profile()` before choosing a profile.
|
||||||
|
- **Live XC upstream URLs use current credentials.** `_resolve_live_stream_url()` builds `/live/{user}/{pass}/{stream_id}.ts` from transformed account credentials and the stream's provider `stream_id`, so playback stays aligned with the active login after credential or profile changes instead of reusing a stale `stream.url` from sync.
|
||||||
|
- **Channel stream switches check pooled capacity.** `get_stream_info_for_switch()` uses `profile_available_for_channel_switch()` when targeting a specific stream, and releases a reserved slot if URL assembly fails after `get_stream()` allocated one.
|
||||||
|
- **Live proxy init reads Redis assignment once.** After `generate_stream_url()` reserves a slot, the stream handler reads `channel_stream` / `stream_profile` from Redis instead of calling `get_stream()` again, avoiding double INCR under concurrent release.
|
||||||
|
- **Centralized Dispatcharr User-Agent construction in `core.utils`.** Outbound HTTP calls (Schedules Direct API, update checks, logo/VOD fallbacks, DVR recording clients) now use `dispatcharr_user_agent()`, `dispatcharr_dvr_user_agent()`, and `dispatcharr_http_headers()` instead of ad-hoc `Dispatcharr/{version}` strings and stale `Dispatcharr/1.0` fallbacks.
|
||||||
|
- **EPG auto-match overhaul** — matching logic moved to `apps/channels/epg_matching.py`; Celery tasks in `tasks.py` are thin wrappers.
|
||||||
|
- Single-channel auto-match is now asynchronous: the API returns `202 Accepted` and pushes the result over WebSocket (`single_channel_epg_match`), so large EPG libraries no longer hit the previous 30-second HTTP timeout.
|
||||||
|
- Progress, bulk completion, and single-channel results use `send_websocket_update` instead of `async_to_sync(channel_layer.group_send)`, so notifications work reliably under gevent-patched uWSGI and Celery workers.
|
||||||
|
- Single-channel and selected-channel auto-match always run, even when the channel already has EPG assigned; match-all (no channel IDs) still only processes channels without EPG.
|
||||||
|
- Rematching to the same EPG no longer re-saves the channel or queues program-parse tasks; only assignments that actually change are written and refreshed.
|
||||||
|
- **Easier EPG search when editing a channel.** The filter in the EPG picker now works more like a normal search box. You can type several words at once (e.g. `sky uk`) and it finds channels where every word appears somewhere in the name or TVG-ID, the order doesn't matter, and a word in the name can pair with one in the ID. Accents are ignored too, so `decale` matches `Décalé` and the other way around. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **XC live refresh releases bulk catalog data sooner during batch processing.** After filtering the single `get_all_live_streams` response, the full provider catalog list is dropped before batch DB work. `process_m3u_batch_direct` (the path XC refreshes use) now runs `gc.collect()` after each batch and clears batch slice references as thread futures complete. Large structures are still `del`'d in the refresh `finally` block before Celery's `task_postrun` runs `cleanup_memory()`.
|
||||||
|
- **VOD movie/series batch matching no longer scans the full no-ID catalog.** `process_movie_batch` and `process_series_batch` previously loaded every `Movie`/`Series` row without TMDB or IMDB IDs on each 1000-item chunk to resolve name+year duplicates. Lookup is now scoped to the names in the current batch via `lookup_by_name_year()`, which reduces memory and DB time per chunk. `refresh_vod_content` and `batch_refresh_series_episodes` are registered for Celery post-task memory cleanup (one GC pass at task end, not per chunk).
|
||||||
|
- **EPG programme parse now streams through PostgreSQL staging instead of holding the full catalogue in Python.** `parse_programs_for_source` writes parsed rows into a session-scoped temp table in batches (`_EPG_PARSE_BATCH_SIZE=2500`), then atomically swaps them into `ProgramData` with batched `DELETE ... RETURNING` + `INSERT` (`_EPG_SWAP_BATCH_SIZE=5000`) so Postgres never materializes the entire guide in one statement. Peak Celery memory during large XMLTV refreshes drops sharply compared with building a monolithic in-memory list before bulk insert. The byte-offset programme index build is deferred until after the swap completes so index construction no longer competes with parse for memory and I/O. `refresh_epg_data` closes its DB connection in `finally`, and `build_programme_index_task` is registered as a memory-intensive Celery task. SQLite/dev installs keep an in-memory fallback path.
|
||||||
|
- **Pooled PostgreSQL connections now rotate on a bounded lifetime.** psycopg3 client-side cache grows on handles kept open indefinitely by `django-db-geventpool`; recycling uWSGI workers would interrupt live streams. A thin custom backend (`dispatcharr.db.backends.postgresql_psycopg3`) closes and replaces pool connections after `DATABASE_POOL_CONN_MAX_LIFETIME` seconds (default 600, env-overridable; set `0` to disable) on checkout and return while preserving warm-pool reuse within the window. (Fixes #1343)
|
||||||
|
- **Schedules Direct refresh only fetches guide data for mapped channels.** Schedule MD5 checks and schedule downloads now target mapped lineup stations instead of the entire lineup, and schedule MD5 cache for unmapped stations is pruned each refresh. Unmapped lineup entries no longer trigger wasted schedule API calls when their MD5 changes.
|
||||||
|
- **EPG auto-match memory and throughput improvements.**
|
||||||
|
- Single-channel matching streams active EPG rows and keeps only the best match plus the top 20 candidates in memory; ML validates at most 21 names per channel instead of embedding the full catalog.
|
||||||
|
- Strong fuzzy matches (≥75% single channel, ≥80% bulk) skip ML entirely, avoiding a ~500MB PyTorch load when the fuzzy result is already reliable.
|
||||||
|
- Bulk matching uses a single fuzzy pass per channel instead of scanning the full catalog twice for best match and top candidates.
|
||||||
|
- Bulk exact `tvg_id` / Gracenote matching uses an in-memory index built alongside the EPG catalog (`build_epg_matching_catalog()`), giving O(1) lookups with no extra database queries.
|
||||||
|
- Bulk match apply uses batched queries (two fetches plus `bulk_update`) instead of one `EPGData.objects.get()` per matched channel.
|
||||||
|
- EPG normalization settings are cached once per matching run, avoiding repeated `CoreSettings` reads when normalizing thousands of names.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Live proxy channels could remain running with no clients after disconnect.** `stop_channel` called `log_system_event('channel_stop')` synchronously before local cleanup and Redis key deletion; Connect integrations or DB event writes on that path could block teardown indefinitely while the cleanup thread kept refreshing metadata TTL for a stale `stream_buffers` entry. Teardown now signals shutdown (`buffer.stopping`), runs model `release_stream()` and Redis key deletion, releases ownership, stops ffmpeg/output managers and local buffers, and only then logs the stop event asynchronously. Metadata TTL refresh skips channels mid-shutdown and non-owned channels; a stuck-stop watchdog forces cleanup if `stop_channel` does not return within ~10s (or 2× `channel_shutdown_delay`, whichever is greater). Stream buffers ignore late `add_chunk()` writes once shutdown begins (`buffer.stopping` set at teardown start).
|
||||||
|
- **Client reconnect during channel teardown could wedge the channel across uWSGI workers.** The disconnect path called `proxy_server.stop_channel()` directly, so other workers were not notified and reconnecting clients could attach while ownership was released on the owner worker. The cleanup thread then re-acquired expired ownership and looped on orphaned clients while the upstream connection stayed open. All lifecycle stops now go through coordinated Redis teardown (`channel_stopping` flag, metadata `stopping` state, and `CHANNEL_STOP` pubsub). New stream requests are rejected with `503 Retry-After` only during active teardown (not during the post-disconnect shutdown delay grace period); `initialize_channel()` and ownership re-acquisition refuse to proceed in those states; non-owner workers clean local resources only on pubsub; and orphaned-client sweeps force cleanup when teardown is already active. (Fixes #1342)
|
||||||
|
- **Orphaned upstream threads kept writing Redis after teardown.** Partial cleanup could remove a channel from `stream_managers` while the `stream-{uuid}` OS thread kept running, so orphan metadata sweeps only deleted Redis keys and chunks/metadata were immediately recreated (bare `total_bytes` hash with TTL -1). Stream managers now stop upstream when ownership is lost, orphan cleanup stops local ffmpeg/stream threads even when registry entries are gone (`_live_stream_managers` tracks managers until the thread exits), and forced recovery stops processes before Redis deletion instead of dropping dict entries without calling `stop()`.
|
||||||
|
- **Rapid channel switching could leave bare `buffer:index` keys in Redis.** `buffer.stop()` flushed a final partial chunk via `INCR` during teardown, creating a persistent index key (TTL -1) after chunk keys expired; under load overlapping disconnect and cleanup-thread stops could race on `_stop_local_stream_activity`, blocking on ffmpeg stderr join before `_clean_redis_keys` ever ran. Teardown no longer writes to Redis from `buffer.stop()`, disconnect uses coordinated stop only (no duplicate upstream stop), model `release_stream()` runs before Redis keys are scanned/deleted (so `profile_connections` counters are not left stuck), ownership is released after Redis cleanup but before the blocking local ffmpeg stop, and a `finally` block guarantees Redis cleanup if local teardown raises.
|
||||||
|
- **Live proxy could leak geventpool DB checkouts outside HTTP requests.** With `django-db-geventpool`, `connection.close()` returns handles to the per-worker pool (`MAX_CONNS=8`); without it, greenlets and OS threads keep connections checked out until the pool blocks on `pool.get()`. Proxy paths that touch the ORM outside Django's request cycle now call `close_old_connections()` after work completes: `_clean_redis_keys()`, profile lookup in `_establish_transcode_connection()` before spawning ffmpeg, and fMP4 client disconnect cleanup when releasing streams (TS disconnect relies on `log_system_event()`). (Fixes #1345)
|
||||||
|
- **System events could block callers on Connect and plugin handlers.** `log_system_event()` ran Connect subscriptions and plugin `"events"` hooks synchronously after the DB write, so slow integrations could stall live-proxy and streaming paths even when inserting the event row was fast. Connect/plugin dispatch now runs on a separate gevent when the hub is available (synchronously in Celery workers without a hub). `log_system_event()` and `dispatch_event_system()` also call `close_old_connections()` in `finally` blocks so integration work does not leave pool slots checked out on the caller or dispatch greenlet.
|
||||||
|
- **Plugins could leak geventpool DB checkouts after UI or Connect event runs.** Third-party plugins run inline on uWSGI greenlets (manual actions and Connect `"events"` hooks) with no guaranteed connection cleanup at the plugin boundary. `PluginManager.run_action()` and `stop_plugin()` now call `close_old_connections()` in a `finally` block so each action returns its pool slot whether the plugin succeeds or raises.
|
||||||
|
- **Connect events repeatedly queried the full plugin catalog during streaming.** `trigger_event()` called `list_plugins()` on every `client_connect` / `client_disconnect`, loading all `PluginConfig` rows and sometimes hitting plugin-repo work even when no plugin subscribed to the event. Dispatch now walks the in-memory registry via `iter_actions_for_event()`, returns immediately when no handlers exist, and runs a single batched `enabled` lookup for matching plugins only. Failed plugin actions are logged per handler instead of aborting the rest.
|
||||||
|
- **Plugin discovery left idle Postgres backends after worker boot.** `discover_plugins()` runs outside Django's request cycle during uWSGI and Celery startup; it now calls `close_old_connections()` in a `finally` block so bootstrap `PluginConfig` queries return their pool slot instead of appearing as long-lived idle connections in `pg_stat_activity`.
|
||||||
|
- **VOD proxy could leak geventpool DB checkouts during playback and stats updates.** `stream_vod()` ran ORM lookups for content and M3U profiles, then returned a long-lived `StreamingHttpResponse` without releasing the checkout, so each movie/episode stream could hold a pool slot for its full duration. Background VOD stats refresh (`build_vod_stats_data()`, triggered on start/stop and by the admin stats API) also queried movie, episode, and profile rows from daemon threads with no cleanup. `stream_vod()` now calls `close_old_connections()` before handing off to the streaming generator, and `build_vod_stats_data()` releases its checkout in a `finally` block.
|
||||||
|
- **Channel shutdown delay did not reset after a reconnect within the grace period.** `handle_client_disconnect()` used a fixed `gevent.sleep(shutdown_delay)` from the first last-client disconnect. If a client reconnected and disconnected again during the delay, an earlier disconnect handler could still stop the channel on the original timer instead of waiting the full delay from the latest disconnect. Shutdown now polls Redis (`last_client_disconnect` timestamp and client count) so concurrent disconnect handlers and multi-worker reconnects always honour the latest disconnect time.
|
||||||
|
- **Zombie ffmpeg could survive owner-lock expiry and orphan Redis sweeps.** When the 30s owner lock lapsed under single-worker load, disconnect handling treated the worker as non-owner so coordinated stop never ran, while ffmpeg kept writing and the orphan sweeper only deleted Redis keys (recreating them immediately). Disconnect now re-acquires ownership when local upstream is still active, stops locally when the last client leaves without a lock, orphan cleanup stops local ffmpeg before Redis deletion via `_has_local_upstream_activity`, re-init stops lingering upstream before starting a duplicate thread, and `is_channel_teardown_active` includes channels mid-`stop_channel` on this worker so rapid reconnect gets 503 during teardown.
|
||||||
|
- **Stale `channel_stream` Redis keys after a channel stopped could skip connection accounting on retune.** On `dev`, `get_stream()` reused any existing `channel_stream` / `stream_profile` assignment without reserving a new slot. If those keys were left behind after a stop, the next tune-in could reach the provider without incrementing Redis counters. `get_stream()` now releases stale assignments when proxy metadata shows the channel is inactive, then reserves fresh slots.
|
||||||
|
- **EPG auto-match reliability fixes.**
|
||||||
|
- Memory could spike to multiple GB on large EPG sources when building a full in-memory catalog before fuzzy matching; single-channel matching now streams rows and bounds ML work to a small candidate set.
|
||||||
|
- Wrong channel assignments from global ML similarity; ML validation now checks the fuzzy best match (or top fuzzy candidates as a last resort) instead of scoring the entire catalog.
|
||||||
|
- Channel form auto-match spinner could stick after errors or early task exits; all single-channel outcomes now push a WebSocket result, and the UI clears loading state after a 3-minute timeout.
|
||||||
|
- Bulk auto-match completion no longer calls `batch-set-epg` from the WebSocket handler, which had been re-applying every match and queueing redundant `parse_programs_for_tvg_id` tasks even when assignments were unchanged.
|
||||||
|
- **Schedules Direct lineup search country dropdown.** The country list was fetched directly from the SD API in the browser, which failed due to CORS and silently fell back to a 14-country hardcoded list. Countries are now included in the `GET sd-lineups` response (server-side fetch with proper User-Agent) so the full SD country list populates correctly. — Thanks [@sethwv](https://github.com/sethwv)
|
||||||
|
- **Schedules Direct program poster proxy omitted User-Agent on image requests.** The poster endpoint authenticated with SD correctly but fetched images with only the `token` header, which violates SD's API requirements (error 1003). Image requests now include the standard `Dispatcharr/{version}` User-Agent via `dispatcharr_http_headers()`.
|
||||||
|
- **Schedules Direct guide data missing after mapping a channel.** Lineup refreshes cached schedule MD5s for all stations, but `ProgramData` was only written for mapped channels. Mapping a channel later could leave MD5s looking unchanged so schedules were skipped and the channel had no guide until dates rolled outside the cached window. Refreshes now backfill fetch-window dates that lack `ProgramData` (including newly mapped stations with stale cache), fetch program metadata when no local `ProgramData` exists for a `programID`, and clean up orphaned `ProgramData` on unmapped `EPGData` entries. — Thanks [@sethwv](https://github.com/sethwv)
|
||||||
|
- **Schedules Direct guide fetch on channel map.** Mapping a channel (or bulk-assigning EPG) now triggers a targeted guide fetch for that `EPGData` entry—mirroring XMLTV's `parse_programs_for_tvg_id` flow—without waiting for the next full source refresh. Skips the fetch when `ProgramData` already exists so additional channels sharing the same `tvg-id` do not trigger redundant API calls. Bulk assignment of three or more SD stations without guide data on the same source queues one batched mapped-station fetch instead of separate per-station API sessions. Concurrent batch and single-EPG fetches coordinate via source-level locks with deferred retries so mappings are not dropped and overlapping SD API sessions are avoided when possible.
|
||||||
|
- **Auto-sync numbering modes now read only the fields each mode's UI exposes.** After the auto-sync overhaul, switching between Provider, Next Available, and Fixed modes left stale `auto_sync_channel_start` / `auto_sync_channel_end` values in the database while each mode's UI only edits a subset of those fields. Sync treated the hidden values as authoritative in every mode, which discarded valid provider numbers (floored at an auto-computed start), capped Next Available at a stale End, and ran range-enforcement deletes against provider- and next-available-numbered channels. Provider mode now honors `stream_chno` verbatim when free (Start/End bound only the fallback for numberless streams); Next Available ignores End; overflow-delete runs in Fixed mode only. Duplicate or already-taken provider numbers fall back to a free slot instead of being dropped or overwriting an existing channel. Provider mode's Start # field now drops End when it would invert the fallback range (matching Fixed mode). (Closes #1273) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||||
|
|
||||||
## [0.26.0] - 2026-06-07
|
## [0.26.0] - 2026-06-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,19 @@ Untested code is significantly less likely to be merged.
|
||||||
|
|
||||||
- Use Django's `TestCase` for unit/integration tests.
|
- Use Django's `TestCase` for unit/integration tests.
|
||||||
- Test files live at `apps/<app>/tests/`.
|
- Test files live at `apps/<app>/tests/`.
|
||||||
- Run the test suite with: `uv run python manage.py test`
|
- Run the backend test suite with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test
|
||||||
|
```
|
||||||
|
|
||||||
|
`manage.py` automatically uses `dispatcharr.settings_test`, which creates an empty PostgreSQL database `test_<dbname>` (same engine as production), runs migrations, and rolls back each test in a transaction. Your live VOD/channels data is not used.
|
||||||
|
|
||||||
|
Optional: `TEST_USE_SQLITE=1` for machines without Postgres (some PostgreSQL-only tests skip automatically).
|
||||||
|
|
||||||
|
Tests that exercise Celery task bodies should use `@override_settings(CELERY_TASK_ALWAYS_EAGER=True)` locally. Global eager mode is off because `post_save` signals on M3U/EPG models call `.delay()` and would break `TestCase` transaction isolation.
|
||||||
|
|
||||||
|
- Do **not** override with `--settings=dispatcharr.settings` on a live instance.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,12 +120,14 @@ If the name contains any of these, the repo will be rejected on add and skipped
|
||||||
|
|
||||||
### Top-Level Metadata
|
### Top-Level Metadata
|
||||||
|
|
||||||
| Field | Required | Description |
|
| 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_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. |
|
| `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. |
|
| `root_url` | No | Generic base URL for resolving all relative URLs in plugin entries. Trailing slashes are stripped. Used as the fallback when neither `download_base_url` nor `metadata_base_url` is set. |
|
||||||
| `plugins` | **Yes** | Array of plugin entry objects. |
|
| `download_base_url` | No | Base URL for resolving relative download URLs (`latest_url` in plugin entries; `url` and `latest_url` inside per-plugin manifest `versions`/`latest`). Overrides `root_url` for download assets when set. |
|
||||||
|
| `metadata_base_url` | No | Base URL for resolving relative metadata URLs (`manifest_url` and `icon_url` in plugin entries). Overrides `root_url` for metadata assets when set. |
|
||||||
|
| `plugins` | **Yes** | Array of plugin entry objects. |
|
||||||
|
|
||||||
### Plugin Entry Fields
|
### Plugin Entry Fields
|
||||||
|
|
||||||
|
|
@ -155,13 +157,18 @@ Extra fields in a plugin entry are passed through to the frontend as-is, so you
|
||||||
|
|
||||||
### URL Resolution
|
### 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:
|
Relative URL fields are resolved against a base URL. Dispatcharr uses two separate base URLs (one for metadata assets and one for download assets) so you can serve them from different origins (e.g., manifests and icons on GitHub Pages, release zips on a CDN).
|
||||||
|
|
||||||
```
|
**Resolution priority:**
|
||||||
{root_url}/{field_value}
|
|
||||||
```
|
|
||||||
|
|
||||||
This lets you keep plugin entries compact:
|
| Field(s) | Priority |
|
||||||
|
| --------- | -------- |
|
||||||
|
| `manifest_url`, `icon_url` | `metadata_base_url` → `root_url` |
|
||||||
|
| `latest_url` (plugin entries); `url`, `latest_url` (per-plugin manifest versions/latest) | `download_base_url` → `root_url` |
|
||||||
|
|
||||||
|
A field value is treated as relative if it does not start with `http://` or `https://`. Relative values are resolved as `{base_url}/{field_value}`. All base URL fields are optional; if none are set, URL fields must be absolute.
|
||||||
|
|
||||||
|
**Single base URL (simplest):** use `root_url` for everything:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -177,12 +184,45 @@ This lets you keep plugin entries compact:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL:
|
**Split base URLs:** use `metadata_base_url` and `download_base_url` when assets are served from different origins:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metadata_base_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
|
||||||
|
"download_base_url": "https://cdn.example.com/releases",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"slug": "my_plugin",
|
||||||
|
"manifest_url": "plugins/my_plugin/manifest.json",
|
||||||
|
"icon_url": "plugins/my_plugin/logo.png",
|
||||||
|
"latest_url": "my_plugin/my_plugin-1.0.0.zip"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
|
|
||||||
|
You can also combine `root_url` with one specific field. The specific field overrides for its consumers, and `root_url` covers the rest:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
|
||||||
|
"download_base_url": "https://cdn.example.com/releases"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Icon fallback:** If `icon_url` is missing, Dispatcharr tries two fallbacks in order:
|
||||||
|
|
||||||
|
1. **Manifest-directory fallback**: if a base URL is set (`root_url`, `metadata_base_url`, etc.) and `manifest_url` is present, the logo is assumed to live in the same directory as the per-plugin manifest:
|
||||||
|
```
|
||||||
|
{directory of resolved manifest_url}/logo.png
|
||||||
|
```
|
||||||
|
For example, if `manifest_url` resolves to `https://example.com/plugins/my_plugin/manifest.json`, the fallback icon URL is `https://example.com/plugins/my_plugin/logo.png`.
|
||||||
|
|
||||||
|
2. **GitHub fallback**: if `registry_url` is a GitHub URL, Dispatcharr converts it to a raw content URL:
|
||||||
|
```
|
||||||
|
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Per-Plugin Manifest (Optional)
|
## Per-Plugin Manifest (Optional)
|
||||||
|
|
@ -563,7 +603,9 @@ You can host release zips as GitHub Release assets and reference them with absol
|
||||||
"manifest": {
|
"manifest": {
|
||||||
"registry_name": "string (required)",
|
"registry_name": "string (required)",
|
||||||
"registry_url": "string (optional)",
|
"registry_url": "string (optional)",
|
||||||
"root_url": "string (optional)",
|
"root_url": "string (optional, generic base URL fallback)",
|
||||||
|
"download_base_url": "string (optional, overrides root_url for zip download URLs)",
|
||||||
|
"metadata_base_url": "string (optional, overrides root_url for manifest_url and icon_url)",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"slug": "string (required)",
|
"slug": "string (required)",
|
||||||
|
|
|
||||||
12
Plugins.md
12
Plugins.md
|
|
@ -307,6 +307,18 @@ Plugins are server-side Python code running within the Django application. You c
|
||||||
|
|
||||||
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
|
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
|
||||||
|
|
||||||
|
### Database connections
|
||||||
|
|
||||||
|
Dispatcharr uses `django-db-geventpool` with a bounded per-uWSGI-worker pool (`MAX_CONNS=8`). Each greenlet or OS thread that runs ORM code checks out a connection until Django closes it.
|
||||||
|
|
||||||
|
`PluginManager.run_action()` and `stop_plugin()` always call `close_old_connections()` in a `finally` block after your plugin returns (success or error). That returns the current greenlet's checkout to the pool. **You do not need to call `close_old_connections()` yourself for normal inline ORM inside `run()` or `stop()`.**
|
||||||
|
|
||||||
|
Still follow these rules:
|
||||||
|
|
||||||
|
- **Heavy or long work:** dispatch a Celery task (`.delay()`) and return quickly from `run()`. Celery workers close connections after each task; blocking the uWSGI gevent hub with `time.sleep`, sync HTTP, or large CPU work can freeze the whole worker regardless of DB cleanup.
|
||||||
|
- **Background threads or greenlets you spawn:** each thread/greenlet that uses the ORM must call `close_old_connections()` (or `connection.close()`) in its own `finally` block when done. The wrapper only covers the thread/greenlet that called `run_action()`.
|
||||||
|
- **Connect event hooks:** actions with an `"events"` list are dispatched from `log_system_event()` on a separate gevent when uWSGI has an active hub (otherwise synchronously, e.g. Celery). Keep handlers short or defer heavy work to Celery.
|
||||||
|
|
||||||
### Important: Don’t Ask Users for URL/User/Password
|
### Important: Don’t Ask Users for URL/User/Password
|
||||||
Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities.
|
Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities.
|
||||||
Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because:
|
Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because:
|
||||||
|
|
|
||||||
|
|
@ -252,8 +252,9 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||||
request.data.pop(key, None)
|
request.data.pop(key, None)
|
||||||
|
|
||||||
# Strip admin-managed keys from custom_properties so users cannot
|
# Strip admin-managed keys from custom_properties so users cannot
|
||||||
# set their own XC credentials or network rules via this endpoint.
|
# set their own XC credentials, network rules, or catchup access
|
||||||
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks"}
|
# via this endpoint.
|
||||||
|
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks", "catchup_enabled"}
|
||||||
cp = request.data.get("custom_properties")
|
cp = request.data.get("custom_properties")
|
||||||
if isinstance(cp, dict):
|
if isinstance(cp, dict):
|
||||||
for key in ADMIN_ONLY_PROPS:
|
for key in ADMIN_ONLY_PROPS:
|
||||||
|
|
@ -267,13 +268,18 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
# 🔹 3) Group Management APIs
|
# 🔹 3) Group Management APIs (Django auth.Group; unused by the React UI)
|
||||||
class GroupViewSet(viewsets.ModelViewSet):
|
class GroupViewSet(viewsets.ModelViewSet):
|
||||||
"""Handles CRUD operations for Groups"""
|
"""CRUD for Django auth groups and their permissions.
|
||||||
|
|
||||||
|
Dispatcharr authorization uses ``user_level``, not these groups. The
|
||||||
|
endpoint is kept for compatibility but restricted to admins so
|
||||||
|
non-admins cannot invent auth groups or attach Django permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = Group.objects.all()
|
queryset = Group.objects.all()
|
||||||
serializer_class = GroupSerializer
|
serializer_class = GroupSerializer
|
||||||
permission_classes = [Authenticated]
|
permission_classes = [IsAdmin]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
description="Retrieve a list of groups",
|
description="Retrieve a list of groups",
|
||||||
|
|
@ -358,9 +364,9 @@ class APIKeyViewSet(viewsets.ViewSet):
|
||||||
responses={200: PermissionSerializer(many=True)},
|
responses={200: PermissionSerializer(many=True)},
|
||||||
)
|
)
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([Authenticated])
|
@permission_classes([IsAdmin])
|
||||||
def list_permissions(request):
|
def list_permissions(request):
|
||||||
"""Returns a list of all available permissions"""
|
"""Returns a list of all available Django permissions (admin only)."""
|
||||||
permissions = Permission.objects.all()
|
permissions = Permission.objects.all()
|
||||||
serializer = PermissionSerializer(permissions, many=True)
|
serializer = PermissionSerializer(permissions, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,8 @@ class QueryParamJWTAuthentication(JWTAuthentication):
|
||||||
where the browser cannot set Authorization headers (e.g. <video src>)."""
|
where the browser cannot set Authorization headers (e.g. <video src>)."""
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
raw_token = request.GET.get('token')
|
params = getattr(request, "query_params", request.GET)
|
||||||
|
raw_token = params.get("token")
|
||||||
if not raw_token:
|
if not raw_token:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
0
apps/accounts/tests/__init__.py
Normal file
0
apps/accounts/tests/__init__.py
Normal file
44
apps/accounts/tests/test_auth_group_permissions.py
Normal file
44
apps/accounts/tests/test_auth_group_permissions.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Django auth.Group API is admin-only (unused by the React UI)."""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
|
from apps.accounts.api_views import GroupViewSet, list_permissions
|
||||||
|
|
||||||
|
|
||||||
|
class AuthGroupPermissionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.admin = User.objects.create_user(
|
||||||
|
username="auth_group_admin",
|
||||||
|
password="x",
|
||||||
|
user_level=User.UserLevel.ADMIN,
|
||||||
|
)
|
||||||
|
self.standard = User.objects.create_user(
|
||||||
|
username="auth_group_user",
|
||||||
|
password="x",
|
||||||
|
user_level=User.UserLevel.STANDARD,
|
||||||
|
)
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
self.group = Group.objects.create(name="unused-role")
|
||||||
|
|
||||||
|
def test_standard_user_cannot_list_groups(self):
|
||||||
|
request = self.factory.get("/api/accounts/groups/")
|
||||||
|
force_authenticate(request, user=self.standard)
|
||||||
|
view = GroupViewSet.as_view({"get": "list"})
|
||||||
|
response = view(request)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_admin_can_list_groups(self):
|
||||||
|
request = self.factory.get("/api/accounts/groups/")
|
||||||
|
force_authenticate(request, user=self.admin)
|
||||||
|
view = GroupViewSet.as_view({"get": "list"})
|
||||||
|
response = view(request)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_standard_user_cannot_list_permissions(self):
|
||||||
|
request = self.factory.get("/api/accounts/permissions/")
|
||||||
|
force_authenticate(request, user=self.standard)
|
||||||
|
response = list_permissions(request)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
200
apps/api/schema_views.py
Normal file
200
apps/api/schema_views.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""OpenAPI schema views that are safe under gevent concurrency.
|
||||||
|
|
||||||
|
DRF's AutoSchema descriptor keeps mutable per-class state (``self.view``).
|
||||||
|
When uWSGI serves requests with gevent, concurrent ``/api/schema/`` builds
|
||||||
|
interleave on that state and raise:
|
||||||
|
|
||||||
|
AssertionError: Schema generation REQUIRES a view instance
|
||||||
|
|
||||||
|
Cold builds also frequently hang the gevent hub when introspection runs in a
|
||||||
|
greenlet. Generation therefore runs in a real OS thread (gevent ThreadPool),
|
||||||
|
with per-process single-flight coordination and a Django/Redis cache shared
|
||||||
|
by all workers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Callable, Dict, Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from drf_spectacular.views import SpectacularAPIView
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Real OS threads; .get() waits without blocking the gevent hub.
|
||||||
|
from gevent import monkey
|
||||||
|
from gevent.threadpool import ThreadPool
|
||||||
|
|
||||||
|
_SCHEMA_POOL: Optional[ThreadPool] = ThreadPool(maxsize=1)
|
||||||
|
_GEVENT_PATCHED = monkey.is_module_patched("threading")
|
||||||
|
except ImportError: # pragma: no cover - non-gevent runtimes
|
||||||
|
_SCHEMA_POOL = None
|
||||||
|
_GEVENT_PATCHED = False
|
||||||
|
|
||||||
|
# Greenlet-safe after gevent monkey-patching.
|
||||||
|
_guard = threading.Lock()
|
||||||
|
_in_progress: Dict[str, threading.Event] = {}
|
||||||
|
_flight_results: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
_SCHEMA_CACHE_PREFIX = "openapi:schema:"
|
||||||
|
_SCHEMA_CACHE_VER_KEY = f"{_SCHEMA_CACHE_PREFIX}cache_ver"
|
||||||
|
_BUILD_TIMEOUT_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _run_schema_build(build: Callable[[], dict]) -> dict:
|
||||||
|
"""Run schema introspection off the gevent hub when monkey-patched."""
|
||||||
|
if _SCHEMA_POOL is not None and _GEVENT_PATCHED:
|
||||||
|
return _SCHEMA_POOL.spawn(build).get(timeout=_BUILD_TIMEOUT_SECONDS)
|
||||||
|
return build()
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_supports_delete_pattern() -> bool:
|
||||||
|
return callable(getattr(cache, "delete_pattern", None))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_schema_cache() -> None:
|
||||||
|
"""Invalidate cached schemas (tests / forced refresh after deploy)."""
|
||||||
|
with _guard:
|
||||||
|
_flight_results.clear()
|
||||||
|
try:
|
||||||
|
if _cache_supports_delete_pattern():
|
||||||
|
cache.delete_pattern(f"{_SCHEMA_CACHE_PREFIX}*")
|
||||||
|
return
|
||||||
|
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 0
|
||||||
|
cache.set(_SCHEMA_CACHE_VER_KEY, int(ver) + 1, timeout=None)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to clear OpenAPI schema cache", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_namespace() -> str:
|
||||||
|
"""Stable on Redis (pattern delete); versioned on LocMem for test clears."""
|
||||||
|
if _cache_supports_delete_pattern():
|
||||||
|
return "shared"
|
||||||
|
try:
|
||||||
|
ver = cache.get(_SCHEMA_CACHE_VER_KEY)
|
||||||
|
if ver is None:
|
||||||
|
cache.add(_SCHEMA_CACHE_VER_KEY, 1, timeout=None)
|
||||||
|
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 1
|
||||||
|
return f"v{int(ver)}"
|
||||||
|
except Exception:
|
||||||
|
return "v1"
|
||||||
|
|
||||||
|
|
||||||
|
class LockedSpectacularAPIView(SpectacularAPIView):
|
||||||
|
"""SpectacularAPIView with single-flight generation and Django cache."""
|
||||||
|
|
||||||
|
def _resolve_version(self, request):
|
||||||
|
return self.api_version or request.version or self._get_version_parameter(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cache_key(self, request) -> str:
|
||||||
|
version = self._resolve_version(request)
|
||||||
|
lang = request.GET.get("lang") if settings.USE_I18N else None
|
||||||
|
urlconf = self.urlconf
|
||||||
|
if urlconf is None:
|
||||||
|
urlconf_part = "default"
|
||||||
|
else:
|
||||||
|
urlconf_part = getattr(urlconf, "__name__", None) or type(urlconf).__name__
|
||||||
|
patterns_part = "custom" if self.patterns is not None else "default"
|
||||||
|
return (
|
||||||
|
f"{_SCHEMA_CACHE_PREFIX}{_key_namespace()}:"
|
||||||
|
f"{version}:{lang}:{self.serve_public}:{urlconf_part}:{patterns_part}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cache_get(self, key: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
return cache.get(key)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("OpenAPI schema cache get failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cache_set(self, key: str, schema: dict) -> None:
|
||||||
|
try:
|
||||||
|
# Use CACHES['default'] TIMEOUT (3600s in settings).
|
||||||
|
cache.set(key, schema)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("OpenAPI schema cache set failed", exc_info=True)
|
||||||
|
|
||||||
|
def _build_schema(self, request, version: Optional[str]) -> dict:
|
||||||
|
generator = self.generator_class(
|
||||||
|
urlconf=self.urlconf,
|
||||||
|
api_version=version,
|
||||||
|
patterns=self.patterns,
|
||||||
|
)
|
||||||
|
return generator.get_schema(request=request, public=self.serve_public)
|
||||||
|
|
||||||
|
def _get_schema_response(self, request):
|
||||||
|
version = self._resolve_version(request)
|
||||||
|
cache_key = self._cache_key(request)
|
||||||
|
|
||||||
|
cached = self._cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return self._schema_response(request, version, cached)
|
||||||
|
|
||||||
|
with _guard:
|
||||||
|
cached = self._cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return self._schema_response(request, version, cached)
|
||||||
|
|
||||||
|
event = _in_progress.get(cache_key)
|
||||||
|
if event is None:
|
||||||
|
event = threading.Event()
|
||||||
|
_in_progress[cache_key] = event
|
||||||
|
is_builder = True
|
||||||
|
else:
|
||||||
|
is_builder = False
|
||||||
|
|
||||||
|
if not is_builder:
|
||||||
|
if not event.wait(timeout=_BUILD_TIMEOUT_SECONDS):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Timed out waiting for OpenAPI schema generation."},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
cached = self._cache_get(cache_key)
|
||||||
|
if cached is None:
|
||||||
|
with _guard:
|
||||||
|
cached = _flight_results.get(cache_key)
|
||||||
|
if cached is None:
|
||||||
|
return Response(
|
||||||
|
{"detail": "OpenAPI schema generation failed."},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
return self._schema_response(request, version, cached)
|
||||||
|
|
||||||
|
try:
|
||||||
|
schema = _run_schema_build(
|
||||||
|
lambda: self._build_schema(request, version)
|
||||||
|
)
|
||||||
|
with _guard:
|
||||||
|
_flight_results[cache_key] = schema
|
||||||
|
self._cache_set(cache_key, schema)
|
||||||
|
return self._schema_response(request, version, schema)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("OpenAPI schema generation failed")
|
||||||
|
return Response(
|
||||||
|
{"detail": "OpenAPI schema generation failed."},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with _guard:
|
||||||
|
_in_progress.pop(cache_key, None)
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
def _schema_response(self, request, version, schema: dict) -> Response:
|
||||||
|
# LocMem returns the same object; deepcopy keeps responses isolated.
|
||||||
|
return Response(
|
||||||
|
data=copy.deepcopy(schema),
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'inline; filename="{self._get_filename(request, version)}"'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from django.urls import path, include, re_path
|
from django.urls import path, include, re_path
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
from drf_spectacular.views import SpectacularSwaggerView, SpectacularRedocView
|
||||||
|
|
||||||
|
from apps.api.schema_views import LockedSpectacularAPIView
|
||||||
|
|
||||||
app_name = 'api'
|
app_name = 'api'
|
||||||
|
|
||||||
|
|
@ -12,6 +14,7 @@ urlpatterns = [
|
||||||
path('core/', include(('core.api_urls', 'core'), namespace='core')),
|
path('core/', include(('core.api_urls', 'core'), namespace='core')),
|
||||||
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
|
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
|
||||||
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
|
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
|
||||||
|
path('catchup/', include(('apps.timeshift.api_urls', 'catchup'), namespace='catchup')),
|
||||||
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
|
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
|
||||||
path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')),
|
path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')),
|
||||||
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
|
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
|
||||||
|
|
@ -21,9 +24,9 @@ urlpatterns = [
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# OpenAPI Schema and Documentation (drf-spectacular)
|
# OpenAPI schema (single-flight + Django cache; see apps.api.schema_views)
|
||||||
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('schema/', LockedSpectacularAPIView.as_view(), name='schema'),
|
||||||
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
|
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
|
||||||
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
|
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
|
||||||
path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'),
|
path('swagger.json', LockedSpectacularAPIView.as_view(), name='schema-json'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ class BackupsConfig(AppConfig):
|
||||||
|
|
||||||
def _sync_backup_scheduler(self):
|
def _sync_backup_scheduler(self):
|
||||||
"""Sync backup scheduler task to database."""
|
"""Sync backup scheduler task to database."""
|
||||||
|
from django.db import close_old_connections
|
||||||
|
|
||||||
from core.models import CoreSettings
|
from core.models import CoreSettings
|
||||||
from .scheduler import _sync_periodic_task, DEFAULTS
|
from .scheduler import _sync_periodic_task, DEFAULTS
|
||||||
try:
|
try:
|
||||||
|
|
@ -38,3 +40,6 @@ class BackupsConfig(AppConfig):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't fail startup if there's an issue
|
# Log but don't fail startup if there's an issue
|
||||||
logger.warning(f"Failed to initialize backup scheduler: {e}")
|
logger.warning(f"Failed to initialize backup scheduler: {e}")
|
||||||
|
finally:
|
||||||
|
# Cron sync reads system_settings for timezone; return geventpool checkouts.
|
||||||
|
close_old_connections()
|
||||||
|
|
|
||||||
0
apps/backups/tests/__init__.py
Normal file
0
apps/backups/tests/__init__.py
Normal file
|
|
@ -10,7 +10,7 @@ from django.contrib.auth import get_user_model
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
from . import services
|
from apps.backups import services
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
@ -939,7 +939,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_get_schedule_settings_defaults(self):
|
def test_get_schedule_settings_defaults(self):
|
||||||
"""Test that get_schedule_settings returns defaults when no settings exist"""
|
"""Test that get_schedule_settings returns defaults when no settings exist"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
settings = scheduler.get_schedule_settings()
|
settings = scheduler.get_schedule_settings()
|
||||||
|
|
||||||
|
|
@ -953,7 +953,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_update_schedule_settings_stores_values(self):
|
def test_update_schedule_settings_stores_values(self):
|
||||||
"""Test that update_schedule_settings stores values correctly"""
|
"""Test that update_schedule_settings stores values correctly"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
result = scheduler.update_schedule_settings({
|
result = scheduler.update_schedule_settings({
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
|
|
@ -976,7 +976,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_update_schedule_settings_invalid_frequency(self):
|
def test_update_schedule_settings_invalid_frequency(self):
|
||||||
"""Test that invalid frequency raises ValueError"""
|
"""Test that invalid frequency raises ValueError"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(ValueError) as context:
|
||||||
scheduler.update_schedule_settings({'frequency': 'monthly'})
|
scheduler.update_schedule_settings({'frequency': 'monthly'})
|
||||||
|
|
@ -985,7 +985,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_update_schedule_settings_invalid_time(self):
|
def test_update_schedule_settings_invalid_time(self):
|
||||||
"""Test that invalid time raises ValueError"""
|
"""Test that invalid time raises ValueError"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(ValueError) as context:
|
||||||
scheduler.update_schedule_settings({'time': 'invalid'})
|
scheduler.update_schedule_settings({'time': 'invalid'})
|
||||||
|
|
@ -994,7 +994,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_update_schedule_settings_invalid_day_of_week(self):
|
def test_update_schedule_settings_invalid_day_of_week(self):
|
||||||
"""Test that invalid day_of_week raises ValueError"""
|
"""Test that invalid day_of_week raises ValueError"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(ValueError) as context:
|
||||||
scheduler.update_schedule_settings({'day_of_week': 7})
|
scheduler.update_schedule_settings({'day_of_week': 7})
|
||||||
|
|
@ -1003,7 +1003,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_update_schedule_settings_invalid_retention(self):
|
def test_update_schedule_settings_invalid_retention(self):
|
||||||
"""Test that negative retention_count raises ValueError"""
|
"""Test that negative retention_count raises ValueError"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(ValueError) as context:
|
||||||
scheduler.update_schedule_settings({'retention_count': -1})
|
scheduler.update_schedule_settings({'retention_count': -1})
|
||||||
|
|
@ -1012,7 +1012,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_sync_creates_periodic_task_when_enabled(self):
|
def test_sync_creates_periodic_task_when_enabled(self):
|
||||||
"""Test that enabling schedule creates a PeriodicTask"""
|
"""Test that enabling schedule creates a PeriodicTask"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
scheduler.update_schedule_settings({
|
scheduler.update_schedule_settings({
|
||||||
|
|
@ -1028,7 +1028,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_sync_deletes_periodic_task_when_disabled(self):
|
def test_sync_deletes_periodic_task_when_disabled(self):
|
||||||
"""Test that disabling schedule removes PeriodicTask"""
|
"""Test that disabling schedule removes PeriodicTask"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
# First enable
|
# First enable
|
||||||
|
|
@ -1047,7 +1047,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_weekly_schedule_sets_day_of_week(self):
|
def test_weekly_schedule_sets_day_of_week(self):
|
||||||
"""Test that weekly schedule sets correct day_of_week in crontab"""
|
"""Test that weekly schedule sets correct day_of_week in crontab"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
scheduler.update_schedule_settings({
|
scheduler.update_schedule_settings({
|
||||||
|
|
@ -1062,7 +1062,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_cron_expression_stores_value(self):
|
def test_cron_expression_stores_value(self):
|
||||||
"""Test that cron_expression is stored and retrieved correctly"""
|
"""Test that cron_expression is stored and retrieved correctly"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
result = scheduler.update_schedule_settings({
|
result = scheduler.update_schedule_settings({
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
|
|
@ -1077,7 +1077,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_cron_expression_creates_correct_schedule(self):
|
def test_cron_expression_creates_correct_schedule(self):
|
||||||
"""Test that cron expression creates correct CrontabSchedule"""
|
"""Test that cron expression creates correct CrontabSchedule"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
scheduler.update_schedule_settings({
|
scheduler.update_schedule_settings({
|
||||||
|
|
@ -1094,7 +1094,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_cron_expression_invalid_format(self):
|
def test_cron_expression_invalid_format(self):
|
||||||
"""Test that invalid cron expression raises ValueError"""
|
"""Test that invalid cron expression raises ValueError"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
|
|
||||||
# Too few parts
|
# Too few parts
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(ValueError) as context:
|
||||||
|
|
@ -1106,7 +1106,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_cron_expression_empty_uses_simple_mode(self):
|
def test_cron_expression_empty_uses_simple_mode(self):
|
||||||
"""Test that empty cron_expression falls back to simple frequency mode"""
|
"""Test that empty cron_expression falls back to simple frequency mode"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
scheduler.update_schedule_settings({
|
scheduler.update_schedule_settings({
|
||||||
|
|
@ -1123,7 +1123,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_cron_expression_overrides_simple_settings(self):
|
def test_cron_expression_overrides_simple_settings(self):
|
||||||
"""Test that cron_expression takes precedence over frequency/time"""
|
"""Test that cron_expression takes precedence over frequency/time"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
scheduler.update_schedule_settings({
|
scheduler.update_schedule_settings({
|
||||||
|
|
@ -1140,7 +1140,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_periodic_task_uses_system_timezone(self):
|
def test_periodic_task_uses_system_timezone(self):
|
||||||
"""Test that CrontabSchedule is created with the system timezone"""
|
"""Test that CrontabSchedule is created with the system timezone"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
from core.models import CoreSettings
|
from core.models import CoreSettings
|
||||||
|
|
||||||
|
|
@ -1164,7 +1164,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_periodic_task_timezone_updates_with_schedule(self):
|
def test_periodic_task_timezone_updates_with_schedule(self):
|
||||||
"""Test that CrontabSchedule timezone is updated when schedule is modified"""
|
"""Test that CrontabSchedule timezone is updated when schedule is modified"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
from core.models import CoreSettings
|
from core.models import CoreSettings
|
||||||
|
|
||||||
|
|
@ -1197,7 +1197,7 @@ class BackupSchedulerTestCase(TestCase):
|
||||||
|
|
||||||
def test_orphaned_crontab_cleanup(self):
|
def test_orphaned_crontab_cleanup(self):
|
||||||
"""Test that old CrontabSchedule is deleted when schedule changes"""
|
"""Test that old CrontabSchedule is deleted when schedule changes"""
|
||||||
from . import scheduler
|
from apps.backups import scheduler
|
||||||
from django_celery_beat.models import PeriodicTask, CrontabSchedule
|
from django_celery_beat.models import PeriodicTask, CrontabSchedule
|
||||||
|
|
||||||
# Create initial daily schedule
|
# Create initial daily schedule
|
||||||
|
|
@ -1246,7 +1246,7 @@ class BackupTasksTestCase(TestCase):
|
||||||
@patch('apps.backups.tasks.services.delete_backup')
|
@patch('apps.backups.tasks.services.delete_backup')
|
||||||
def test_cleanup_old_backups_keeps_recent(self, mock_delete, mock_list):
|
def test_cleanup_old_backups_keeps_recent(self, mock_delete, mock_list):
|
||||||
"""Test that cleanup keeps the most recent backups"""
|
"""Test that cleanup keeps the most recent backups"""
|
||||||
from .tasks import _cleanup_old_backups
|
from apps.backups.tasks import _cleanup_old_backups
|
||||||
|
|
||||||
mock_list.return_value = [
|
mock_list.return_value = [
|
||||||
{'name': 'backup-3.zip'}, # newest
|
{'name': 'backup-3.zip'}, # newest
|
||||||
|
|
@ -1263,7 +1263,7 @@ class BackupTasksTestCase(TestCase):
|
||||||
@patch('apps.backups.tasks.services.delete_backup')
|
@patch('apps.backups.tasks.services.delete_backup')
|
||||||
def test_cleanup_old_backups_does_nothing_when_under_limit(self, mock_delete, mock_list):
|
def test_cleanup_old_backups_does_nothing_when_under_limit(self, mock_delete, mock_list):
|
||||||
"""Test that cleanup does nothing when under retention limit"""
|
"""Test that cleanup does nothing when under retention limit"""
|
||||||
from .tasks import _cleanup_old_backups
|
from apps.backups.tasks import _cleanup_old_backups
|
||||||
|
|
||||||
mock_list.return_value = [
|
mock_list.return_value = [
|
||||||
{'name': 'backup-2.zip'},
|
{'name': 'backup-2.zip'},
|
||||||
|
|
@ -1279,7 +1279,7 @@ class BackupTasksTestCase(TestCase):
|
||||||
@patch('apps.backups.tasks.services.delete_backup')
|
@patch('apps.backups.tasks.services.delete_backup')
|
||||||
def test_cleanup_old_backups_zero_retention_keeps_all(self, mock_delete, mock_list):
|
def test_cleanup_old_backups_zero_retention_keeps_all(self, mock_delete, mock_list):
|
||||||
"""Test that retention_count=0 keeps all backups"""
|
"""Test that retention_count=0 keeps all backups"""
|
||||||
from .tasks import _cleanup_old_backups
|
from apps.backups.tasks import _cleanup_old_backups
|
||||||
|
|
||||||
mock_list.return_value = [
|
mock_list.return_value = [
|
||||||
{'name': 'backup-3.zip'},
|
{'name': 'backup-3.zip'},
|
||||||
|
|
@ -1296,7 +1296,7 @@ class BackupTasksTestCase(TestCase):
|
||||||
@patch('apps.backups.tasks._cleanup_old_backups')
|
@patch('apps.backups.tasks._cleanup_old_backups')
|
||||||
def test_scheduled_backup_task_success(self, mock_cleanup, mock_create):
|
def test_scheduled_backup_task_success(self, mock_cleanup, mock_create):
|
||||||
"""Test scheduled backup task success"""
|
"""Test scheduled backup task success"""
|
||||||
from .tasks import scheduled_backup_task
|
from apps.backups.tasks import scheduled_backup_task
|
||||||
|
|
||||||
mock_backup_file = MagicMock()
|
mock_backup_file = MagicMock()
|
||||||
mock_backup_file.name = 'scheduled-backup.zip'
|
mock_backup_file.name = 'scheduled-backup.zip'
|
||||||
|
|
@ -1316,7 +1316,7 @@ class BackupTasksTestCase(TestCase):
|
||||||
@patch('apps.backups.tasks._cleanup_old_backups')
|
@patch('apps.backups.tasks._cleanup_old_backups')
|
||||||
def test_scheduled_backup_task_no_cleanup_when_retention_zero(self, mock_cleanup, mock_create):
|
def test_scheduled_backup_task_no_cleanup_when_retention_zero(self, mock_cleanup, mock_create):
|
||||||
"""Test scheduled backup skips cleanup when retention is 0"""
|
"""Test scheduled backup skips cleanup when retention is 0"""
|
||||||
from .tasks import scheduled_backup_task
|
from apps.backups.tasks import scheduled_backup_task
|
||||||
|
|
||||||
mock_backup_file = MagicMock()
|
mock_backup_file = MagicMock()
|
||||||
mock_backup_file.name = 'scheduled-backup.zip'
|
mock_backup_file.name = 'scheduled-backup.zip'
|
||||||
|
|
@ -1332,7 +1332,7 @@ class BackupTasksTestCase(TestCase):
|
||||||
@patch('apps.backups.tasks.services.create_backup')
|
@patch('apps.backups.tasks.services.create_backup')
|
||||||
def test_scheduled_backup_task_failure(self, mock_create):
|
def test_scheduled_backup_task_failure(self, mock_create):
|
||||||
"""Test scheduled backup task handles failure"""
|
"""Test scheduled backup task handles failure"""
|
||||||
from .tasks import scheduled_backup_task
|
from apps.backups.tasks import scheduled_backup_task
|
||||||
|
|
||||||
mock_create.side_effect = Exception("Backup failed")
|
mock_create.side_effect = Exception("Backup failed")
|
||||||
|
|
||||||
|
|
@ -13,6 +13,7 @@ from .api_views import (
|
||||||
UpdateChannelMembershipAPIView,
|
UpdateChannelMembershipAPIView,
|
||||||
BulkUpdateChannelMembershipAPIView,
|
BulkUpdateChannelMembershipAPIView,
|
||||||
RecordingViewSet,
|
RecordingViewSet,
|
||||||
|
RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||||
RecurringRecordingRuleViewSet,
|
RecurringRecordingRuleViewSet,
|
||||||
GetChannelStreamsAPIView,
|
GetChannelStreamsAPIView,
|
||||||
GetChannelStreamStatsAPIView,
|
GetChannelStreamStatsAPIView,
|
||||||
|
|
@ -53,7 +54,10 @@ urlpatterns = [
|
||||||
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
|
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
|
||||||
path(
|
path(
|
||||||
'recordings/<int:pk>/hls/<path:seg_path>',
|
'recordings/<int:pk>/hls/<path:seg_path>',
|
||||||
RecordingViewSet.as_view({'get': 'hls'}),
|
RecordingViewSet.as_view(
|
||||||
|
{'get': 'hls'},
|
||||||
|
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||||
|
),
|
||||||
name='recording-hls',
|
name='recording-hls',
|
||||||
),
|
),
|
||||||
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),
|
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
|
||||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
|
@ -12,18 +14,21 @@ from django.db import connection, transaction
|
||||||
from django.db.models import Count, F, Prefetch
|
from django.db.models import Count, F, Prefetch
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
import os, json, requests, logging, mimetypes, threading, time
|
import os, json, requests, logging, mimetypes, threading, time
|
||||||
|
from urllib.parse import urlencode
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.utils.http import http_date
|
from django.utils.http import http_date
|
||||||
from apps.accounts.permissions import (
|
from apps.accounts.permissions import (
|
||||||
Authenticated,
|
Authenticated,
|
||||||
IsAdmin,
|
IsAdmin,
|
||||||
IsOwnerOfObject,
|
IsOwnerOfObject,
|
||||||
|
IsStandardUser,
|
||||||
permission_classes_by_action,
|
permission_classes_by_action,
|
||||||
permission_classes_by_method,
|
permission_classes_by_method,
|
||||||
)
|
)
|
||||||
|
|
||||||
from core.models import UserAgent, CoreSettings
|
from core.models import UserAgent, CoreSettings
|
||||||
from core.utils import RedisClient, safe_upload_path
|
from core.utils import RedisClient, safe_upload_path, resolve_safe_local_data_path
|
||||||
|
from apps.m3u.utils import convert_js_numbered_backreferences
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Stream,
|
Stream,
|
||||||
|
|
@ -71,6 +76,33 @@ from rest_framework.pagination import PageNumberPagination
|
||||||
from dispatcharr.utils import network_access_allowed
|
from dispatcharr.utils import network_access_allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_request_bool(value, default=False):
|
||||||
|
"""Parse a query/body boolean. Missing values use *default*."""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return bool(value)
|
||||||
|
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_proxy_sessions_for_channel_ids(channel_ids):
|
||||||
|
"""Stop live proxy sessions for channels before deleting their DB rows.
|
||||||
|
|
||||||
|
Runs outside Django's delete atomic so geventpool connection release during
|
||||||
|
teardown cannot poison the delete transaction.
|
||||||
|
"""
|
||||||
|
if not channel_ids:
|
||||||
|
return
|
||||||
|
from apps.proxy.live_proxy.services.channel_service import ChannelService
|
||||||
|
|
||||||
|
uuids = list(
|
||||||
|
Channel.objects.filter(id__in=channel_ids).values_list("uuid", flat=True)
|
||||||
|
)
|
||||||
|
ChannelService.stop_channels(uuids)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Negative cache for remote logo URLs that failed to fetch.
|
# Negative cache for remote logo URLs that failed to fetch.
|
||||||
|
|
@ -173,6 +205,10 @@ class StreamViewSet(viewsets.ModelViewSet):
|
||||||
if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"):
|
if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"):
|
||||||
qs = qs.filter(is_stale=False)
|
qs = qs.filter(is_stale=False)
|
||||||
|
|
||||||
|
is_catchup = self.request.query_params.get("is_catchup")
|
||||||
|
if is_catchup and str(is_catchup).lower() in ("1", "true", "yes", "on"):
|
||||||
|
qs = qs.filter(is_catchup=True)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
|
|
@ -365,6 +401,17 @@ class StreamViewSet(viewsets.ModelViewSet):
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
exclude_error = str(e)
|
exclude_error = str(e)
|
||||||
|
|
||||||
|
# The replace field accepts JS-style $1 backreferences, but the regex
|
||||||
|
# engine honors \1. Convert once so the preview's "after" matches the
|
||||||
|
# name the live rename produces (apps/m3u/tasks.py sync_auto_channels
|
||||||
|
# applies the same conversion on the same engine).
|
||||||
|
replace_repl = convert_js_numbered_backreferences(replace_pat)
|
||||||
|
|
||||||
|
# The live rename caps the result at Channel.name's column length
|
||||||
|
# before bulk_create; mirror that cap so the preview never shows a
|
||||||
|
# name the sync would truncate.
|
||||||
|
name_max_len = Channel._meta.get_field("name").max_length
|
||||||
|
|
||||||
# Capped at SCAN_CAP to bound memory on huge groups; the
|
# Capped at SCAN_CAP to bound memory on huge groups; the
|
||||||
# separate COUNT lets the client surface scan_limit_hit when
|
# separate COUNT lets the client surface scan_limit_hit when
|
||||||
# the preview covers only a sample.
|
# the preview covers only a sample.
|
||||||
|
|
@ -387,11 +434,12 @@ class StreamViewSet(viewsets.ModelViewSet):
|
||||||
total_scanned += 1
|
total_scanned += 1
|
||||||
if find_re is not None:
|
if find_re is not None:
|
||||||
try:
|
try:
|
||||||
new_name = find_re.sub(replace_pat, name, timeout=REGEX_TIMEOUT)
|
new_name = find_re.sub(replace_repl, name, timeout=REGEX_TIMEOUT)
|
||||||
except (TimeoutError, re.error) as e:
|
except (TimeoutError, re.error) as e:
|
||||||
find_error = find_error or f"Pattern timed out: {e}"
|
find_error = find_error or f"Pattern timed out: {e}"
|
||||||
find_re = None
|
find_re = None
|
||||||
continue
|
continue
|
||||||
|
new_name = new_name[:name_max_len]
|
||||||
if new_name != name:
|
if new_name != name:
|
||||||
find_match_count += 1
|
find_match_count += 1
|
||||||
if len(find_matches) < limit:
|
if len(find_matches) < limit:
|
||||||
|
|
@ -591,10 +639,12 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = ChannelGroupSerializer
|
serializer_class = ChannelGroupSerializer
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
|
if self.action == "cleanup_unused_groups":
|
||||||
|
return [IsAdmin()]
|
||||||
try:
|
try:
|
||||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return [Authenticated()]
|
return [IsAdmin()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Annotate both counts at the SQL level so the serializer methods
|
# Annotate both counts at the SQL level so the serializer methods
|
||||||
|
|
@ -739,7 +789,9 @@ class ChannelPagination(PageNumberPagination):
|
||||||
max_page_size = 10000 # Prevent excessive page sizes
|
max_page_size = 10000 # Prevent excessive page sizes
|
||||||
|
|
||||||
def paginate_queryset(self, queryset, request, view=None):
|
def paginate_queryset(self, queryset, request, view=None):
|
||||||
if not request.query_params.get(self.page_query_param):
|
if not request.query_params.get(self.page_query_param) and not request.query_params.get(
|
||||||
|
self.page_size_query_param
|
||||||
|
):
|
||||||
return None # disables pagination, returns full queryset
|
return None # disables pagination, returns full queryset
|
||||||
|
|
||||||
return super().paginate_queryset(queryset, request, view)
|
return super().paginate_queryset(queryset, request, view)
|
||||||
|
|
@ -868,6 +920,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Delete a channel. Optionally stop an active proxy session first.
|
||||||
|
|
||||||
|
Query param ``stop_stream`` (default false): when true, stop the live
|
||||||
|
proxy session *before* the DB delete (outside the delete atomic) so
|
||||||
|
playback ends with the channel row. When false, an in-progress stream
|
||||||
|
keeps playing until stopped from Stats or the client disconnects.
|
||||||
|
"""
|
||||||
|
instance = self.get_object()
|
||||||
|
stop_stream = _parse_request_bool(request.query_params.get("stop_stream"))
|
||||||
|
if stop_stream:
|
||||||
|
# Stop before super().destroy() so teardown is not inside the
|
||||||
|
# collector's transaction.atomic.
|
||||||
|
_stop_proxy_sessions_for_channel_ids([instance.id])
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action in [
|
if self.action in [
|
||||||
"edit_bulk",
|
"edit_bulk",
|
||||||
|
|
@ -877,13 +945,26 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
"match_epg",
|
"match_epg",
|
||||||
"set_epg",
|
"set_epg",
|
||||||
"batch_set_epg",
|
"batch_set_epg",
|
||||||
|
"bulk_regex_rename",
|
||||||
|
"set_names_from_epg",
|
||||||
|
"set_logos_from_epg",
|
||||||
|
"set_tvg_ids_from_epg",
|
||||||
|
"reorder",
|
||||||
]:
|
]:
|
||||||
return [IsAdmin()]
|
return [IsAdmin()]
|
||||||
|
|
||||||
|
if self.action in (
|
||||||
|
"get_ids",
|
||||||
|
"summary",
|
||||||
|
"numbers_in_range",
|
||||||
|
"by_uuids",
|
||||||
|
):
|
||||||
|
return [IsStandardUser()]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return [Authenticated()]
|
return [IsAdmin()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# get_ids and summary only need the filter conditions, not the full
|
# get_ids and summary only need the filter conditions, not the full
|
||||||
|
|
@ -925,6 +1006,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
only_streamless = self.request.query_params.get("only_streamless", None)
|
only_streamless = self.request.query_params.get("only_streamless", None)
|
||||||
only_stale = self.request.query_params.get("only_stale", None)
|
only_stale = self.request.query_params.get("only_stale", None)
|
||||||
only_has_overrides = self.request.query_params.get("only_has_overrides", None)
|
only_has_overrides = self.request.query_params.get("only_has_overrides", None)
|
||||||
|
only_catchup = self.request.query_params.get("only_catchup", None)
|
||||||
visibility_filter = self.request.query_params.get("visibility_filter", "active")
|
visibility_filter = self.request.query_params.get("visibility_filter", "active")
|
||||||
|
|
||||||
if channel_profile_id:
|
if channel_profile_id:
|
||||||
|
|
@ -950,6 +1032,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
q_filters &= Q(streams__is_stale=True)
|
q_filters &= Q(streams__is_stale=True)
|
||||||
if only_has_overrides:
|
if only_has_overrides:
|
||||||
q_filters &= Q(override__isnull=False)
|
q_filters &= Q(override__isnull=False)
|
||||||
|
if only_catchup:
|
||||||
|
q_filters &= Q(is_catchup=True)
|
||||||
|
|
||||||
# Visibility filter applies to list-style reads only; retrieve /
|
# Visibility filter applies to list-style reads only; retrieve /
|
||||||
# update / delete must still reach a hidden channel by id so the
|
# update / delete must still reach a hidden channel by id so the
|
||||||
|
|
@ -2087,7 +2171,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
fields={
|
fields={
|
||||||
'channel_ids': serializers.ListField(
|
'channel_ids': serializers.ListField(
|
||||||
child=serializers.IntegerField(),
|
child=serializers.IntegerField(),
|
||||||
help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.',
|
help_text='List of channel IDs to process (includes channels that already have EPG). If empty or not provided, only channels without EPG are processed.',
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2120,23 +2204,15 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
def match_channel_epg(self, request, pk=None):
|
def match_channel_epg(self, request, pk=None):
|
||||||
channel = self.get_object()
|
channel = self.get_object()
|
||||||
|
|
||||||
# Import the matching logic
|
match_single_channel_epg.delay(channel.id)
|
||||||
from apps.channels.tasks import match_single_channel_epg
|
return Response(
|
||||||
|
{
|
||||||
try:
|
"message": f"EPG matching started for channel '{channel.name}'",
|
||||||
# Try to match this specific channel - call synchronously for immediate response
|
"accepted": True,
|
||||||
result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30)
|
"channel_id": channel.id,
|
||||||
|
},
|
||||||
# Refresh the channel from DB to get any updates
|
status=status.HTTP_202_ACCEPTED,
|
||||||
channel.refresh_from_db()
|
)
|
||||||
|
|
||||||
return Response({
|
|
||||||
"message": result.get("message", "Channel matching completed"),
|
|
||||||
"matched": result.get("matched", False),
|
|
||||||
"channel": self.get_serializer(channel).data
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return Response({"error": str(e)}, status=400)
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
# 7) Set EPG and Refresh
|
# 7) Set EPG and Refresh
|
||||||
|
|
@ -2171,22 +2247,14 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
epg_data = EPGData.objects.get(pk=epg_data_id)
|
epg_data = EPGData.objects.get(pk=epg_data_id)
|
||||||
|
|
||||||
# Set the EPG data and save
|
# Set the EPG data and save. refresh_epg_programs (post_save) queues
|
||||||
|
# parse_programs_for_tvg_id for non-dummy sources — no second dispatch here.
|
||||||
channel.epg_data = epg_data
|
channel.epg_data = epg_data
|
||||||
channel.save(update_fields=["epg_data"])
|
channel.save(update_fields=["epg_data"])
|
||||||
|
|
||||||
# Only trigger program refresh for non-dummy EPG sources
|
|
||||||
status_message = None
|
status_message = None
|
||||||
if epg_data.epg_source.source_type != 'dummy':
|
if epg_data.epg_source.source_type != 'dummy':
|
||||||
# Explicitly trigger program refresh for this EPG
|
|
||||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
|
||||||
|
|
||||||
task_result = parse_programs_for_tvg_id.delay(epg_data.id)
|
|
||||||
|
|
||||||
# Prepare response with task status info
|
|
||||||
status_message = "EPG refresh queued"
|
status_message = "EPG refresh queued"
|
||||||
if task_result.result == "Task already running":
|
|
||||||
status_message = "EPG refresh already in progress"
|
|
||||||
|
|
||||||
# Build response message
|
# Build response message
|
||||||
message = f"EPG data set to {epg_data.tvg_id} for channel {channel.name}"
|
message = f"EPG data set to {epg_data.tvg_id} for channel {channel.name}"
|
||||||
|
|
@ -2321,7 +2389,6 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
# Extract channel IDs upfront
|
# Extract channel IDs upfront
|
||||||
channel_updates = {}
|
channel_updates = {}
|
||||||
unique_epg_ids = set()
|
|
||||||
|
|
||||||
for assoc in associations:
|
for assoc in associations:
|
||||||
channel_id = assoc.get("channel_id")
|
channel_id = assoc.get("channel_id")
|
||||||
|
|
@ -2331,24 +2398,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
channel_updates[channel_id] = epg_data_id
|
channel_updates[channel_id] = epg_data_id
|
||||||
if epg_data_id:
|
|
||||||
unique_epg_ids.add(epg_data_id)
|
|
||||||
|
|
||||||
# Batch fetch all channels (single query)
|
# Batch fetch all channels (single query)
|
||||||
channels_dict = {
|
channels_dict = {
|
||||||
c.id: c for c in Channel.objects.filter(id__in=channel_updates.keys())
|
c.id: c for c in Channel.objects.filter(id__in=channel_updates.keys())
|
||||||
}
|
}
|
||||||
|
|
||||||
# Collect channels to update
|
# Collect channels whose EPG assignment actually changes
|
||||||
channels_to_update = []
|
channels_to_update = []
|
||||||
|
changed_epg_ids = set()
|
||||||
for channel_id, epg_data_id in channel_updates.items():
|
for channel_id, epg_data_id in channel_updates.items():
|
||||||
if channel_id not in channels_dict:
|
if channel_id not in channels_dict:
|
||||||
logger.error(f"Channel with ID {channel_id} not found")
|
logger.error(f"Channel with ID {channel_id} not found")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
channel = channels_dict[channel_id]
|
channel = channels_dict[channel_id]
|
||||||
|
if channel.epg_data_id == epg_data_id:
|
||||||
|
continue
|
||||||
|
|
||||||
channel.epg_data_id = epg_data_id
|
channel.epg_data_id = epg_data_id
|
||||||
channels_to_update.append(channel)
|
channels_to_update.append(channel)
|
||||||
|
if epg_data_id:
|
||||||
|
changed_epg_ids.add(epg_data_id)
|
||||||
|
|
||||||
# Bulk update all channels (single query)
|
# Bulk update all channels (single query)
|
||||||
if channels_to_update:
|
if channels_to_update:
|
||||||
|
|
@ -2361,27 +2432,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
channels_updated = len(channels_to_update)
|
channels_updated = len(channels_to_update)
|
||||||
|
|
||||||
# Trigger program refresh for unique EPG data IDs (skip dummy EPGs)
|
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
|
||||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
|
||||||
from apps.epg.models import EPGData
|
|
||||||
|
|
||||||
# Batch fetch EPG data (single query)
|
programs_refreshed = dispatch_program_refresh_for_epg_ids(changed_epg_ids)
|
||||||
epg_data_dict = {
|
|
||||||
epg.id: epg
|
|
||||||
for epg in EPGData.objects.filter(id__in=unique_epg_ids).select_related('epg_source')
|
|
||||||
}
|
|
||||||
|
|
||||||
programs_refreshed = 0
|
|
||||||
for epg_id in unique_epg_ids:
|
|
||||||
epg_data = epg_data_dict.get(epg_id)
|
|
||||||
if not epg_data:
|
|
||||||
logger.error(f"EPGData with ID {epg_id} not found")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Only refresh non-dummy EPG sources
|
|
||||||
if epg_data.epg_source.source_type != 'dummy':
|
|
||||||
parse_programs_for_tvg_id.delay(epg_id)
|
|
||||||
programs_refreshed += 1
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
@ -2438,19 +2491,34 @@ class BulkDeleteChannelsAPIView(APIView):
|
||||||
return [Authenticated()]
|
return [Authenticated()]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
description="Bulk delete channels by ID",
|
description=(
|
||||||
|
"Bulk delete channels by ID. Optional ``stop_stream`` (default false) "
|
||||||
|
"stops active proxy sessions for those channels before deleting."
|
||||||
|
),
|
||||||
request=inline_serializer(
|
request=inline_serializer(
|
||||||
name="BulkDeleteChannelsRequest",
|
name="BulkDeleteChannelsRequest",
|
||||||
fields={
|
fields={
|
||||||
"channel_ids": serializers.ListField(
|
"channel_ids": serializers.ListField(
|
||||||
child=serializers.IntegerField(),
|
child=serializers.IntegerField(),
|
||||||
help_text="Channel IDs to delete",
|
help_text="Channel IDs to delete",
|
||||||
)
|
),
|
||||||
|
"stop_stream": serializers.BooleanField(
|
||||||
|
required=False,
|
||||||
|
default=False,
|
||||||
|
help_text=(
|
||||||
|
"When true, stop active live proxy sessions for these "
|
||||||
|
"channels before deleting. Default false leaves streams "
|
||||||
|
"playing until stopped separately."
|
||||||
|
),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def delete(self, request):
|
def delete(self, request):
|
||||||
channel_ids = request.data.get("channel_ids", [])
|
channel_ids = request.data.get("channel_ids", [])
|
||||||
|
stop_stream = _parse_request_bool(request.data.get("stop_stream"), default=False)
|
||||||
|
if stop_stream:
|
||||||
|
_stop_proxy_sessions_for_channel_ids(channel_ids)
|
||||||
Channel.objects.filter(id__in=channel_ids).delete()
|
Channel.objects.filter(id__in=channel_ids).delete()
|
||||||
return Response(
|
return Response(
|
||||||
{"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT
|
{"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT
|
||||||
|
|
@ -2771,23 +2839,26 @@ class LogoViewSet(viewsets.ModelViewSet):
|
||||||
"""Streams the logo file, whether it's local or remote."""
|
"""Streams the logo file, whether it's local or remote."""
|
||||||
logo = self.get_object()
|
logo = self.get_object()
|
||||||
logo_url = logo.url
|
logo_url = logo.url
|
||||||
|
if not logo_url:
|
||||||
|
raise Http404("Image not found")
|
||||||
if logo_url.startswith("/data"): # Local file
|
if logo_url.startswith("/data"): # Local file
|
||||||
if not os.path.exists(logo_url):
|
safe_path = resolve_safe_local_data_path(logo_url)
|
||||||
|
if safe_path is None or not os.path.exists(safe_path):
|
||||||
raise Http404("Image not found")
|
raise Http404("Image not found")
|
||||||
stat = os.stat(logo_url)
|
stat = os.stat(safe_path)
|
||||||
# Get proper mime type (first item of the tuple)
|
# Get proper mime type (first item of the tuple)
|
||||||
content_type, _ = mimetypes.guess_type(logo_url)
|
content_type, _ = mimetypes.guess_type(safe_path)
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = "image/jpeg" # Default to a common image type
|
content_type = "image/jpeg" # Default to a common image type
|
||||||
|
|
||||||
# Use context manager and set Content-Disposition to inline
|
# StreamingHttpResponse closes the file when the response finishes.
|
||||||
response = StreamingHttpResponse(
|
response = StreamingHttpResponse(
|
||||||
open(logo_url, "rb"), content_type=content_type
|
open(safe_path, "rb"), content_type=content_type
|
||||||
)
|
)
|
||||||
response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours
|
response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours
|
||||||
response["Last-Modified"] = http_date(stat.st_mtime)
|
response["Last-Modified"] = http_date(stat.st_mtime)
|
||||||
response["Content-Disposition"] = 'inline; filename="{}"'.format(
|
response["Content-Disposition"] = 'inline; filename="{}"'.format(
|
||||||
os.path.basename(logo_url)
|
os.path.basename(safe_path)
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -2805,8 +2876,9 @@ class LogoViewSet(viewsets.ModelViewSet):
|
||||||
user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id))
|
user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id))
|
||||||
user_agent = user_agent_obj.user_agent
|
user_agent = user_agent_obj.user_agent
|
||||||
except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError):
|
except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError):
|
||||||
# Fallback to hardcoded if default not found
|
# Fallback if default not found
|
||||||
user_agent = 'Dispatcharr/1.0'
|
from core.utils import dispatcharr_user_agent
|
||||||
|
user_agent = dispatcharr_user_agent()
|
||||||
|
|
||||||
# Hard total timeout (connect + full download) prevents a slow
|
# Hard total timeout (connect + full download) prevents a slow
|
||||||
# server dripping bytes from holding a greenlet indefinitely.
|
# server dripping bytes from holding a greenlet indefinitely.
|
||||||
|
|
@ -3266,18 +3338,55 @@ def _stop_dvr_clients(channel_uuid, recording_id=None):
|
||||||
return stopped
|
return stopped
|
||||||
|
|
||||||
|
|
||||||
|
# QueryParamJWTAuthentication supports native <video src> clients that cannot
|
||||||
|
# send Authorization headers. Authorization still requires an authenticated
|
||||||
|
# user via _user_can_play_recording; these classes only populate request.user.
|
||||||
|
RECORDING_PLAYBACK_AUTHENTICATORS = [
|
||||||
|
JWTAuthentication,
|
||||||
|
ApiKeyAuthentication,
|
||||||
|
QueryParamJWTAuthentication,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _recording_auth_query_suffix(request):
|
||||||
|
"""Suffix for rewritten recording URLs when auth used ?token= (native <video>).
|
||||||
|
|
||||||
|
hls.js clients authenticate via Authorization on each XHR and do not need
|
||||||
|
tokens embedded in playlist segment lines.
|
||||||
|
"""
|
||||||
|
from rest_framework.request import Request as DRFRequest
|
||||||
|
|
||||||
|
if isinstance(request, DRFRequest):
|
||||||
|
params = request.query_params
|
||||||
|
else:
|
||||||
|
params = request.GET
|
||||||
|
token = params.get("token")
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
return "?" + urlencode({"token": token})
|
||||||
|
|
||||||
|
|
||||||
class RecordingViewSet(viewsets.ModelViewSet):
|
class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Recording.objects.all()
|
queryset = Recording.objects.all()
|
||||||
serializer_class = RecordingSerializer
|
serializer_class = RecordingSerializer
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
# Allow unauthenticated playback of recording files (like other streaming endpoints)
|
# file/hls use AllowAny so DRF does not reject requests before auth
|
||||||
|
# classes run; _user_can_play_recording enforces authenticated access.
|
||||||
if self.action in ('file', 'hls'):
|
if self.action in ('file', 'hls'):
|
||||||
return [AllowAny()]
|
return [AllowAny()]
|
||||||
|
if self.action in (
|
||||||
|
'stop',
|
||||||
|
'extend',
|
||||||
|
'comskip',
|
||||||
|
'refresh_artwork',
|
||||||
|
'update_metadata',
|
||||||
|
):
|
||||||
|
return [IsAdmin()]
|
||||||
try:
|
try:
|
||||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return [Authenticated()]
|
return [IsAdmin()]
|
||||||
|
|
||||||
def _user_can_play_recording(self, request, recording):
|
def _user_can_play_recording(self, request, recording):
|
||||||
"""Authorization gate for recording playback (file/hls actions).
|
"""Authorization gate for recording playback (file/hls actions).
|
||||||
|
|
@ -3335,7 +3444,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({"success": False, "error": str(e)}, status=400)
|
return Response({"success": False, "error": str(e)}, status=400)
|
||||||
|
|
||||||
@action(detail=True, methods=["get"], url_path="file")
|
@action(
|
||||||
|
detail=True,
|
||||||
|
methods=["get"],
|
||||||
|
url_path="file",
|
||||||
|
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||||
|
)
|
||||||
def file(self, request, pk=None):
|
def file(self, request, pk=None):
|
||||||
"""Stream a completed recording file with HTTP Range support for seeking.
|
"""Stream a completed recording file with HTTP Range support for seeking.
|
||||||
|
|
||||||
|
|
@ -3359,7 +3473,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
if hls_dir and os.path.isdir(hls_dir):
|
if hls_dir and os.path.isdir(hls_dir):
|
||||||
hls_url = request.build_absolute_uri(
|
hls_url = request.build_absolute_uri(
|
||||||
f"/api/channels/recordings/{pk}/hls/index.m3u8"
|
f"/api/channels/recordings/{pk}/hls/index.m3u8"
|
||||||
)
|
) + _recording_auth_query_suffix(request)
|
||||||
return HttpResponseRedirect(hls_url)
|
return HttpResponseRedirect(hls_url)
|
||||||
if not file_path or not os.path.exists(file_path):
|
if not file_path or not os.path.exists(file_path):
|
||||||
raise Http404("Recording file not found")
|
raise Http404("Recording file not found")
|
||||||
|
|
@ -3423,7 +3537,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
|
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@action(detail=True, methods=["get"], url_path="hls/(?P<seg_path>.+)")
|
@action(
|
||||||
|
detail=True,
|
||||||
|
methods=["get"],
|
||||||
|
url_path="hls/(?P<seg_path>.+)",
|
||||||
|
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||||
|
)
|
||||||
def hls(self, request, pk=None, seg_path=None):
|
def hls(self, request, pk=None, seg_path=None):
|
||||||
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.
|
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.
|
||||||
|
|
||||||
|
|
@ -3447,9 +3566,10 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
cp = recording.custom_properties or {}
|
cp = recording.custom_properties or {}
|
||||||
file_path = cp.get("file_path")
|
file_path = cp.get("file_path")
|
||||||
if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0:
|
if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0:
|
||||||
return HttpResponseRedirect(
|
file_url = request.build_absolute_uri(
|
||||||
request.build_absolute_uri(f"/api/channels/recordings/{pk}/file/")
|
f"/api/channels/recordings/{pk}/file/"
|
||||||
)
|
) + _recording_auth_query_suffix(request)
|
||||||
|
return HttpResponseRedirect(file_url)
|
||||||
raise Http404("HLS content not available for this recording")
|
raise Http404("HLS content not available for this recording")
|
||||||
|
|
||||||
# Security: prevent path traversal outside the HLS directory
|
# Security: prevent path traversal outside the HLS directory
|
||||||
|
|
@ -3462,16 +3582,18 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
raise Http404(f"HLS file not found: {seg_path}")
|
raise Http404(f"HLS file not found: {seg_path}")
|
||||||
|
|
||||||
if seg_path.endswith(".m3u8"):
|
if seg_path.endswith(".m3u8"):
|
||||||
# Rewrite relative segment lines to absolute URLs through this API
|
# Rewrite relative segment lines to absolute URLs through this API.
|
||||||
|
# Propagate ?token= only for native <video> clients (see helper).
|
||||||
base_url = request.build_absolute_uri(
|
base_url = request.build_absolute_uri(
|
||||||
f"/api/channels/recordings/{pk}/hls/"
|
f"/api/channels/recordings/{pk}/hls/"
|
||||||
)
|
)
|
||||||
|
auth_suffix = _recording_auth_query_suffix(request)
|
||||||
lines = []
|
lines = []
|
||||||
with open(requested) as _f:
|
with open(requested) as _f:
|
||||||
for line in _f:
|
for line in _f:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped and not stripped.startswith("#"):
|
if stripped and not stripped.startswith("#"):
|
||||||
lines.append(f"{base_url}{stripped}\n")
|
lines.append(f"{base_url}{stripped}{auth_suffix}\n")
|
||||||
else:
|
else:
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
return HttpResponse("".join(lines), content_type="application/x-mpegURL")
|
return HttpResponse("".join(lines), content_type="application/x-mpegURL")
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import logging
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from .models import Channel, ChannelOverride, ChannelGroupM3UAccount
|
from .models import Channel, ChannelOverride, ChannelGroupM3UAccount
|
||||||
from apps.m3u.tasks import _custom_properties_as_dict, _next_available_number
|
from apps.m3u.tasks import _next_available_number
|
||||||
|
from core.utils import ensure_custom_properties_dict
|
||||||
from core.utils import (
|
from core.utils import (
|
||||||
acquire_task_lock,
|
acquire_task_lock,
|
||||||
natural_sort_key,
|
natural_sort_key,
|
||||||
|
|
@ -37,7 +38,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def is_compact_group(group_relation):
|
def is_compact_group(group_relation):
|
||||||
"""Return True if the given ChannelGroupM3UAccount is in compact mode."""
|
"""Return True if the given ChannelGroupM3UAccount is in compact mode."""
|
||||||
cp = _custom_properties_as_dict(group_relation.custom_properties)
|
cp = ensure_custom_properties_dict(group_relation.custom_properties)
|
||||||
return bool(cp.get("compact_numbering"))
|
return bool(cp.get("compact_numbering"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,7 +73,7 @@ def get_group_relation_for_channel(channel):
|
||||||
for rel in ChannelGroupM3UAccount.objects.filter(
|
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||||
m3u_account_id=channel.auto_created_by_id
|
m3u_account_id=channel.auto_created_by_id
|
||||||
):
|
):
|
||||||
cp = _custom_properties_as_dict(rel.custom_properties)
|
cp = ensure_custom_properties_dict(rel.custom_properties)
|
||||||
if str(cp.get("group_override", "")) == target:
|
if str(cp.get("group_override", "")) == target:
|
||||||
return rel
|
return rel
|
||||||
return None
|
return None
|
||||||
|
|
@ -179,7 +180,7 @@ def assign_compact_numbers_for_channels(channel_ids):
|
||||||
for rel in ChannelGroupM3UAccount.objects.filter(
|
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||||
m3u_account_id__in={k[0] for k in unresolved}
|
m3u_account_id__in={k[0] for k in unresolved}
|
||||||
):
|
):
|
||||||
cp = _custom_properties_as_dict(rel.custom_properties)
|
cp = ensure_custom_properties_dict(rel.custom_properties)
|
||||||
target = cp.get("group_override")
|
target = cp.get("group_override")
|
||||||
if not target:
|
if not target:
|
||||||
continue
|
continue
|
||||||
|
|
@ -295,7 +296,7 @@ def _repack_inner(group_relation):
|
||||||
account_id = group_relation.m3u_account_id
|
account_id = group_relation.m3u_account_id
|
||||||
group_id = group_relation.channel_group_id
|
group_id = group_relation.channel_group_id
|
||||||
|
|
||||||
cp = _custom_properties_as_dict(group_relation.custom_properties)
|
cp = ensure_custom_properties_dict(group_relation.custom_properties)
|
||||||
sort_order = cp.get("channel_sort_order") or ""
|
sort_order = cp.get("channel_sort_order") or ""
|
||||||
sort_reverse = bool(cp.get("channel_sort_reverse"))
|
sort_reverse = bool(cp.get("channel_sort_reverse"))
|
||||||
|
|
||||||
|
|
|
||||||
927
apps/channels/epg_matching.py
Normal file
927
apps/channels/epg_matching.py
Normal file
|
|
@ -0,0 +1,927 @@
|
||||||
|
"""
|
||||||
|
EPG channel matching: fuzzy scoring, optional ML validation, and UI notifications.
|
||||||
|
|
||||||
|
Celery tasks in tasks.py call into this module; keep orchestration here and
|
||||||
|
task wiring thin so matching logic stays testable without a worker.
|
||||||
|
"""
|
||||||
|
import gc
|
||||||
|
import heapq
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from rapidfuzz import fuzz
|
||||||
|
|
||||||
|
from apps.epg.models import EPGData
|
||||||
|
from core.models import CoreSettings
|
||||||
|
from core.utils import send_websocket_update
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ml_model_cache = {'sentence_transformer': None}
|
||||||
|
_normalize_settings_cache = None
|
||||||
|
|
||||||
|
ML_CANDIDATE_LIMIT = 20
|
||||||
|
SINGLE_CHANNEL_MATCH_TIMEOUT_MS = 180_000
|
||||||
|
|
||||||
|
COMMON_EXTRANEOUS_WORDS = [
|
||||||
|
"tv", "channel", "network", "television",
|
||||||
|
"east", "west", "hd", "uhd", "24/7",
|
||||||
|
"1080p", "720p", "540p", "480p",
|
||||||
|
"film", "movie", "movies",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def release_ml_models():
|
||||||
|
"""Unload sentence transformer and encourage PyTorch to release memory."""
|
||||||
|
if _ml_model_cache['sentence_transformer'] is None:
|
||||||
|
return
|
||||||
|
logger.info("Cleaning up ML models from memory")
|
||||||
|
model = _ml_model_cache['sentence_transformer']
|
||||||
|
_ml_model_cache['sentence_transformer'] = None
|
||||||
|
del model
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if hasattr(torch, 'cuda') and torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_normalize_settings_cache():
|
||||||
|
"""Reset cached normalization settings after a matching run."""
|
||||||
|
global _normalize_settings_cache
|
||||||
|
_normalize_settings_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_after_matching():
|
||||||
|
"""Release ML models and normalization cache after a matching run."""
|
||||||
|
release_ml_models()
|
||||||
|
clear_normalize_settings_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def get_sentence_transformer():
|
||||||
|
"""Lazy load the sentence transformer model only when needed."""
|
||||||
|
if _ml_model_cache['sentence_transformer'] is None:
|
||||||
|
try:
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
from sentence_transformers import util
|
||||||
|
|
||||||
|
model_name = "sentence-transformers/all-MiniLM-L6-v2"
|
||||||
|
cache_dir = "/data/models"
|
||||||
|
disable_downloads = os.environ.get('DISABLE_ML_DOWNLOADS', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
if disable_downloads:
|
||||||
|
hf_model_path = os.path.join(cache_dir, f"models--{model_name.replace('/', '--')}")
|
||||||
|
if not os.path.exists(hf_model_path):
|
||||||
|
logger.warning(
|
||||||
|
"ML model not found and downloads disabled (DISABLE_ML_DOWNLOADS=true). "
|
||||||
|
"Skipping ML matching."
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
logger.info(f"Loading sentence transformer model (cache: {cache_dir})")
|
||||||
|
_ml_model_cache['sentence_transformer'] = SentenceTransformer(
|
||||||
|
model_name,
|
||||||
|
cache_folder=cache_dir,
|
||||||
|
)
|
||||||
|
return _ml_model_cache['sentence_transformer'], util
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("sentence-transformers not available - ML-enhanced matching disabled")
|
||||||
|
return None, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load sentence transformer: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
from sentence_transformers import util
|
||||||
|
return _ml_model_cache['sentence_transformer'], util
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(name: str) -> str:
|
||||||
|
"""Normalize a channel/EPG name for fuzzy matching."""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
global _normalize_settings_cache
|
||||||
|
if _normalize_settings_cache is None:
|
||||||
|
prefixes = []
|
||||||
|
suffixes = []
|
||||||
|
custom_strings = []
|
||||||
|
try:
|
||||||
|
settings = CoreSettings.get_epg_settings()
|
||||||
|
mode = settings.get("epg_match_mode", "default")
|
||||||
|
if mode == "advanced":
|
||||||
|
prefixes = settings.get("epg_match_ignore_prefixes", [])
|
||||||
|
suffixes = settings.get("epg_match_ignore_suffixes", [])
|
||||||
|
custom_strings = settings.get("epg_match_ignore_custom", [])
|
||||||
|
if not isinstance(prefixes, list):
|
||||||
|
prefixes = []
|
||||||
|
if not isinstance(suffixes, list):
|
||||||
|
suffixes = []
|
||||||
|
if not isinstance(custom_strings, list):
|
||||||
|
custom_strings = []
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not load EPG matching settings: {e}")
|
||||||
|
_normalize_settings_cache = (prefixes, suffixes, custom_strings)
|
||||||
|
|
||||||
|
prefixes, suffixes, custom_strings = _normalize_settings_cache
|
||||||
|
result = name
|
||||||
|
|
||||||
|
for prefix in prefixes:
|
||||||
|
if not prefix or not isinstance(prefix, str):
|
||||||
|
continue
|
||||||
|
if result.startswith(prefix):
|
||||||
|
result = result[len(prefix):]
|
||||||
|
break
|
||||||
|
|
||||||
|
for suffix in suffixes:
|
||||||
|
if not suffix or not isinstance(suffix, str):
|
||||||
|
continue
|
||||||
|
if result.endswith(suffix):
|
||||||
|
result = result[:-len(suffix)]
|
||||||
|
break
|
||||||
|
|
||||||
|
for custom in custom_strings:
|
||||||
|
if not custom or not isinstance(custom, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = result.replace(custom, "")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to remove custom string '{custom}': {e}")
|
||||||
|
|
||||||
|
norm = result.lower()
|
||||||
|
norm = re.sub(r"\[.*?\]", "", norm)
|
||||||
|
|
||||||
|
call_sign_match = re.search(r"\(([A-Z]{3,5})\)", name)
|
||||||
|
preserved_call_sign = ""
|
||||||
|
if call_sign_match:
|
||||||
|
preserved_call_sign = " " + call_sign_match.group(1).lower()
|
||||||
|
|
||||||
|
norm = re.sub(r"\(.*?\)", "", norm)
|
||||||
|
norm = norm + preserved_call_sign
|
||||||
|
norm = re.sub(r"[^\w\s]", "", norm)
|
||||||
|
tokens = [t for t in norm.split() if t not in COMMON_EXTRANEOUS_WORDS]
|
||||||
|
return " ".join(tokens).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def send_epg_matching_progress(total_channels, matched_channels, current_channel_name="", stage="matching"):
|
||||||
|
"""Send bulk EPG matching progress via WebSocket."""
|
||||||
|
matched_count = (
|
||||||
|
len(matched_channels) if isinstance(matched_channels, list) else matched_channels
|
||||||
|
)
|
||||||
|
send_websocket_update(
|
||||||
|
'updates',
|
||||||
|
'update',
|
||||||
|
{
|
||||||
|
'type': 'epg_matching_progress',
|
||||||
|
'total': total_channels,
|
||||||
|
'matched': matched_count,
|
||||||
|
'remaining': total_channels - matched_count,
|
||||||
|
'current_channel': current_channel_name,
|
||||||
|
'stage': stage,
|
||||||
|
'progress_percent': round(matched_count / total_channels * 100, 1) if total_channels > 0 else 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_single_channel_epg_match_result(channel_id, matched, message, channel=None, epg_data=None):
|
||||||
|
"""Notify the UI that a single-channel EPG match attempt has finished."""
|
||||||
|
try:
|
||||||
|
from apps.channels.serializers import ChannelSerializer
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "single_channel_epg_match",
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"matched": matched,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if channel is not None:
|
||||||
|
payload["channel"] = ChannelSerializer(channel).data
|
||||||
|
if epg_data is not None:
|
||||||
|
payload["epg_id"] = epg_data.id
|
||||||
|
payload["epg_name"] = epg_data.name
|
||||||
|
|
||||||
|
send_websocket_update('updates', 'update', payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send single channel EPG match result: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_fuzzy_score(chan_norm, row, region_code=None):
|
||||||
|
"""Compute fuzzy match score with optional region bonus/penalty."""
|
||||||
|
if not row.get("norm_name"):
|
||||||
|
return 0
|
||||||
|
base_score = fuzz.ratio(chan_norm, row["norm_name"])
|
||||||
|
bonus = 0
|
||||||
|
if region_code and row.get("tvg_id"):
|
||||||
|
combined_text = row["tvg_id"].lower() + " " + row["name"].lower()
|
||||||
|
dot_regions = re.findall(r'\.([a-z]{2})', combined_text)
|
||||||
|
if dot_regions:
|
||||||
|
bonus = 15 if region_code in dot_regions else -15
|
||||||
|
elif region_code in combined_text:
|
||||||
|
bonus = 10
|
||||||
|
return base_score + bonus
|
||||||
|
|
||||||
|
|
||||||
|
def _ml_cosine_similarities(st_model, util, query_text, candidate_texts):
|
||||||
|
"""Encode only the query plus candidate texts (not the full EPG database)."""
|
||||||
|
if not candidate_texts:
|
||||||
|
return []
|
||||||
|
texts = [query_text] + list(candidate_texts)
|
||||||
|
embeddings = st_model.encode(texts, convert_to_tensor=True, show_progress_bar=False)
|
||||||
|
sim_scores = util.cos_sim(embeddings[0:1], embeddings[1:])[0]
|
||||||
|
return [float(s) for s in sim_scores]
|
||||||
|
|
||||||
|
|
||||||
|
def _active_epg_lookup_queryset():
|
||||||
|
"""Lightweight queryset for exact EPG lookups (includes nameless entries)."""
|
||||||
|
return (
|
||||||
|
EPGData.objects
|
||||||
|
.filter(epg_source__is_active=True)
|
||||||
|
.values('id', 'tvg_id', 'name', 'epg_source_id', 'epg_source__priority')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_epg_fuzzy_queryset():
|
||||||
|
"""Lightweight queryset for fuzzy EPG matching (requires a display name)."""
|
||||||
|
return (
|
||||||
|
_active_epg_lookup_queryset()
|
||||||
|
.filter(name__isnull=False)
|
||||||
|
.exclude(name='')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_from_epg_values(values_row):
|
||||||
|
tvg_id = values_row.get('tvg_id') or ''
|
||||||
|
normalized_tvg_id = tvg_id.strip().lower() if tvg_id else ''
|
||||||
|
return {
|
||||||
|
'id': values_row['id'],
|
||||||
|
'tvg_id': normalized_tvg_id,
|
||||||
|
'original_tvg_id': tvg_id,
|
||||||
|
'name': values_row['name'],
|
||||||
|
'epg_source_id': values_row['epg_source_id'],
|
||||||
|
'epg_source_priority': values_row.get('epg_source__priority') or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_epg_by_tvg_id(tvg_id):
|
||||||
|
"""Exact tvg_id lookup without loading the full EPG catalog into memory."""
|
||||||
|
if not tvg_id:
|
||||||
|
return None
|
||||||
|
values_row = _active_epg_lookup_queryset().filter(tvg_id__iexact=tvg_id.strip()).first()
|
||||||
|
return _row_from_epg_values(values_row) if values_row else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_epg_matching_catalog():
|
||||||
|
"""
|
||||||
|
Build the in-memory EPG catalog for bulk matching using a streaming DB cursor.
|
||||||
|
|
||||||
|
Returns (epg_data, tvg_id_index): the full catalog plus an O(1) in-memory
|
||||||
|
tvg_id lookup table (no extra DB queries). The index prefers the first entry
|
||||||
|
per tvg_id after priority sorting.
|
||||||
|
"""
|
||||||
|
epg_data = []
|
||||||
|
for values_row in _active_epg_fuzzy_queryset().iterator(chunk_size=500):
|
||||||
|
row = _row_from_epg_values(values_row)
|
||||||
|
row['norm_name'] = normalize_name(row['name'])
|
||||||
|
epg_data.append(row)
|
||||||
|
epg_data.sort(key=lambda x: x['epg_source_priority'], reverse=True)
|
||||||
|
return epg_data, build_epg_tvg_id_index(epg_data)
|
||||||
|
|
||||||
|
|
||||||
|
def build_epg_tvg_id_index(epg_data):
|
||||||
|
"""
|
||||||
|
Build an in-memory tvg_id -> row index from an EPG catalog (no DB queries).
|
||||||
|
epg_data must be sorted by source priority (highest first) so the first
|
||||||
|
entry wins when multiple sources share the same tvg_id.
|
||||||
|
"""
|
||||||
|
index = {}
|
||||||
|
for row in epg_data:
|
||||||
|
tvg_id = row.get("tvg_id")
|
||||||
|
if tvg_id and tvg_id not in index:
|
||||||
|
index[tvg_id] = row
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_program_parse_for_epg_assignments(changed_associations):
|
||||||
|
"""
|
||||||
|
Queue guide/program refresh for newly assigned EPG ids.
|
||||||
|
|
||||||
|
bulk_update bypasses post_save, so callers must invoke this when epg_data
|
||||||
|
actually changes (mirrors the M3U sync path).
|
||||||
|
"""
|
||||||
|
if not changed_associations:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
|
||||||
|
|
||||||
|
epg_ids = {
|
||||||
|
assoc["epg_data_id"]
|
||||||
|
for assoc in changed_associations
|
||||||
|
if assoc.get("epg_data_id")
|
||||||
|
}
|
||||||
|
return dispatch_program_refresh_for_epg_ids(epg_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_unchanged_epg_assignment(chan, epg_id, epg_name, epg_tvg_id, match_method):
|
||||||
|
chan_name = chan.get("name") or f"id={chan['id']}"
|
||||||
|
chan_tvg = chan.get("original_tvg_id") or chan.get("tvg_id") or ""
|
||||||
|
logger.debug(
|
||||||
|
f"Channel '{chan_name}' (id={chan['id']}, tvg_id={chan_tvg!r}) "
|
||||||
|
f"unchanged - already on EPG '{epg_name or '?'}' "
|
||||||
|
f"(id={epg_id}, tvg_id={(epg_tvg_id or '?')!r}, via {match_method})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_epg_match(
|
||||||
|
chan,
|
||||||
|
epg_id,
|
||||||
|
*,
|
||||||
|
epg_name,
|
||||||
|
epg_tvg_id,
|
||||||
|
match_method,
|
||||||
|
channels_to_update,
|
||||||
|
matched_channels,
|
||||||
|
unchanged_channels,
|
||||||
|
):
|
||||||
|
"""Record a match result; skip channels_to_update when assignment is already correct."""
|
||||||
|
if chan.get("current_epg_data_id") == epg_id:
|
||||||
|
unchanged_channels.append((chan["id"], chan.get("name") or "", epg_tvg_id or ""))
|
||||||
|
_log_unchanged_epg_assignment(chan, epg_id, epg_name, epg_tvg_id, match_method)
|
||||||
|
return
|
||||||
|
|
||||||
|
chan_name = chan.get("name") or f"id={chan['id']}"
|
||||||
|
chan_tvg = chan.get("original_tvg_id") or chan.get("tvg_id") or ""
|
||||||
|
fallback_name = chan.get("fallback_name") or chan_name
|
||||||
|
chan["epg_data_id"] = epg_id
|
||||||
|
channels_to_update.append(chan)
|
||||||
|
matched_channels.append((chan["id"], fallback_name, epg_tvg_id or ""))
|
||||||
|
logger.info(
|
||||||
|
f"Channel '{chan_name}' (id={chan['id']}, tvg_id={chan_tvg!r}) "
|
||||||
|
f"=> EPG '{epg_name or '?'}' (id={epg_id}, tvg_id={(epg_tvg_id or '?')!r}, via {match_method})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_matched_epg_to_channels(channels_to_update_dicts):
|
||||||
|
"""
|
||||||
|
Assign matched EPG rows to channels using two DB queries (channels + EPG).
|
||||||
|
|
||||||
|
Skips channels that already have the matched EPG. Returns association dicts
|
||||||
|
for channels whose epg_data assignment actually changed, and dispatches
|
||||||
|
program-parse tasks only for those new assignments.
|
||||||
|
"""
|
||||||
|
from apps.channels.models import Channel
|
||||||
|
|
||||||
|
if not channels_to_update_dicts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
channel_ids = [d["id"] for d in channels_to_update_dicts]
|
||||||
|
epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts}
|
||||||
|
epg_ids = {epg_id for epg_id in epg_mapping.values() if epg_id}
|
||||||
|
|
||||||
|
epg_by_id = {epg.id: epg for epg in EPGData.objects.filter(id__in=epg_ids)}
|
||||||
|
channels_list = list(Channel.objects.filter(id__in=channel_ids))
|
||||||
|
|
||||||
|
changed_associations = []
|
||||||
|
channels_to_bulk = []
|
||||||
|
for channel_obj in channels_list:
|
||||||
|
epg_data_id = epg_mapping.get(channel_obj.id)
|
||||||
|
if not epg_data_id:
|
||||||
|
continue
|
||||||
|
if channel_obj.epg_data_id == epg_data_id:
|
||||||
|
epg_row = epg_by_id.get(epg_data_id)
|
||||||
|
_log_unchanged_epg_assignment(
|
||||||
|
{
|
||||||
|
"id": channel_obj.id,
|
||||||
|
"name": channel_obj.name,
|
||||||
|
"original_tvg_id": channel_obj.tvg_id,
|
||||||
|
},
|
||||||
|
epg_data_id,
|
||||||
|
epg_row.name if epg_row else None,
|
||||||
|
epg_row.tvg_id if epg_row else None,
|
||||||
|
"apply",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
epg_data_obj = epg_by_id.get(epg_data_id)
|
||||||
|
if epg_data_obj:
|
||||||
|
channel_obj.epg_data = epg_data_obj
|
||||||
|
channels_to_bulk.append(channel_obj)
|
||||||
|
changed_associations.append(
|
||||||
|
{"channel_id": channel_obj.id, "epg_data_id": epg_data_id}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}")
|
||||||
|
|
||||||
|
if channels_to_bulk:
|
||||||
|
Channel.objects.bulk_update(channels_to_bulk, ["epg_data"])
|
||||||
|
|
||||||
|
parse_dispatched = _dispatch_program_parse_for_epg_assignments(changed_associations)
|
||||||
|
if parse_dispatched:
|
||||||
|
logger.info(
|
||||||
|
f"Dispatched {parse_dispatched} EPG program parse task(s) for changed assignments"
|
||||||
|
)
|
||||||
|
|
||||||
|
return changed_associations
|
||||||
|
|
||||||
|
|
||||||
|
def get_preferred_region_code():
|
||||||
|
try:
|
||||||
|
region_obj = CoreSettings.objects.get(key="preferred-region")
|
||||||
|
return region_obj.value.strip().lower()
|
||||||
|
except CoreSettings.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fuzzy_scan_core(chan_norm, rows, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||||
|
"""
|
||||||
|
Single-pass fuzzy scan: track best match and top-K candidates.
|
||||||
|
Rows must already include norm_name when scanning an in-memory catalog.
|
||||||
|
"""
|
||||||
|
best_score = 0
|
||||||
|
best_epg = None
|
||||||
|
top_heap = []
|
||||||
|
seq = 0
|
||||||
|
scanned = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if not row.get("norm_name"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
scanned += 1
|
||||||
|
score = _compute_fuzzy_score(chan_norm, row, region_code)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if score > 50:
|
||||||
|
logger.debug(f" EPG '{row['name']}' (norm: '{row['norm_name']}') => score: {score}")
|
||||||
|
|
||||||
|
priority = row['epg_source_priority']
|
||||||
|
if score > best_score or (
|
||||||
|
score == best_score
|
||||||
|
and priority > (best_epg.get('epg_source_priority', 0) if best_epg else -1)
|
||||||
|
):
|
||||||
|
best_score = score
|
||||||
|
best_epg = row
|
||||||
|
|
||||||
|
seq += 1
|
||||||
|
if len(top_heap) < candidate_limit:
|
||||||
|
heapq.heappush(top_heap, (score, priority, seq, row))
|
||||||
|
else:
|
||||||
|
smallest_score, smallest_priority, _, _ = top_heap[0]
|
||||||
|
if score > smallest_score or (score == smallest_score and priority > smallest_priority):
|
||||||
|
heapq.heapreplace(top_heap, (score, priority, seq, row))
|
||||||
|
|
||||||
|
top_candidates = sorted(top_heap, key=lambda item: (item[0], item[1]), reverse=True)
|
||||||
|
return best_score, best_epg, [(score, row) for score, _, _, row in top_candidates], scanned
|
||||||
|
|
||||||
|
|
||||||
|
def fuzzy_scan_epg_list(chan_norm, epg_data, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||||
|
"""Fuzzy scan over a pre-built in-memory EPG catalog (bulk matching)."""
|
||||||
|
logger.debug(f"Fuzzy matching '{chan_norm}' against EPG entries...")
|
||||||
|
return _fuzzy_scan_core(chan_norm, epg_data, region_code, candidate_limit)
|
||||||
|
|
||||||
|
|
||||||
|
def stream_fuzzy_epg_scan(chan_norm, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||||
|
"""Stream fuzzy scan over active EPG entries (single-channel matching)."""
|
||||||
|
|
||||||
|
def row_iterator():
|
||||||
|
for values_row in _active_epg_fuzzy_queryset().iterator(chunk_size=500):
|
||||||
|
row = _row_from_epg_values(values_row)
|
||||||
|
row['norm_name'] = normalize_name(row['name'])
|
||||||
|
yield row
|
||||||
|
|
||||||
|
logger.debug(f"Fuzzy matching '{chan_norm}' against EPG entries...")
|
||||||
|
return _fuzzy_scan_core(chan_norm, row_iterator(), region_code, candidate_limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_epg_match_thresholds(is_bulk_matching):
|
||||||
|
if is_bulk_matching:
|
||||||
|
return {
|
||||||
|
'FUZZY_HIGH_CONFIDENCE': 90,
|
||||||
|
'FUZZY_SKIP_ML': 80,
|
||||||
|
'FUZZY_MEDIUM_CONFIDENCE': 70,
|
||||||
|
'ML_HIGH_CONFIDENCE': 0.75,
|
||||||
|
'ML_LAST_RESORT': 0.65,
|
||||||
|
'FUZZY_LAST_RESORT_MIN': 50,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'FUZZY_HIGH_CONFIDENCE': 85,
|
||||||
|
'FUZZY_SKIP_ML': 75,
|
||||||
|
'FUZZY_MEDIUM_CONFIDENCE': 40,
|
||||||
|
'ML_HIGH_CONFIDENCE': 0.65,
|
||||||
|
'ML_LAST_RESORT': 0.50,
|
||||||
|
'FUZZY_LAST_RESORT_MIN': 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def try_epg_name_match(chan, best_score, best_epg, top_candidates, is_bulk_matching,
|
||||||
|
use_ml=True, ml_state=None):
|
||||||
|
"""
|
||||||
|
Apply fuzzy/ML thresholds to a channel's best fuzzy result.
|
||||||
|
Returns the matched EPG row dict, or None.
|
||||||
|
"""
|
||||||
|
if not best_epg:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thresholds = _get_epg_match_thresholds(is_bulk_matching)
|
||||||
|
fuzzy_high = thresholds['FUZZY_HIGH_CONFIDENCE']
|
||||||
|
fuzzy_skip_ml = thresholds['FUZZY_SKIP_ML']
|
||||||
|
fuzzy_medium = thresholds['FUZZY_MEDIUM_CONFIDENCE']
|
||||||
|
ml_high = thresholds['ML_HIGH_CONFIDENCE']
|
||||||
|
ml_last_resort = thresholds['ML_LAST_RESORT']
|
||||||
|
fuzzy_last_resort_min = thresholds['FUZZY_LAST_RESORT_MIN']
|
||||||
|
|
||||||
|
if best_score >= fuzzy_high:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} "
|
||||||
|
f"(score={best_score})"
|
||||||
|
)
|
||||||
|
return best_epg
|
||||||
|
|
||||||
|
if best_score >= fuzzy_skip_ml:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} "
|
||||||
|
f"(fuzzy={best_score}, ML skipped)"
|
||||||
|
)
|
||||||
|
return best_epg
|
||||||
|
|
||||||
|
if ml_state is None:
|
||||||
|
ml_state = {}
|
||||||
|
|
||||||
|
st_model = ml_state.get('st_model')
|
||||||
|
util = ml_state.get('util')
|
||||||
|
|
||||||
|
if best_score >= fuzzy_medium and use_ml:
|
||||||
|
if st_model is None:
|
||||||
|
st_model, util = get_sentence_transformer()
|
||||||
|
ml_state['st_model'] = st_model
|
||||||
|
ml_state['util'] = util
|
||||||
|
|
||||||
|
if st_model:
|
||||||
|
try:
|
||||||
|
logger.info("Validating fuzzy best match with ML model (single candidate)")
|
||||||
|
sims = _ml_cosine_similarities(st_model, util, chan["norm_chan"], [best_epg["norm_name"]])
|
||||||
|
top_value = sims[0] if sims else 0.0
|
||||||
|
|
||||||
|
if top_value >= ml_high - 1e-9:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => matched EPG tvg_id={best_epg['tvg_id']} "
|
||||||
|
f"(fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||||
|
)
|
||||||
|
return best_epg
|
||||||
|
if top_value >= ml_last_resort - 1e-9:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => LAST RESORT match EPG "
|
||||||
|
f"tvg_id={best_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||||
|
)
|
||||||
|
return best_epg
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => fuzzy={best_score}, "
|
||||||
|
f"ML-sim={top_value:.2f} < {ml_last_resort}, skipping"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"ML matching failed for channel {chan['id']}: {e}")
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => fuzzy score {best_score} below threshold, skipping"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if best_score >= fuzzy_last_resort_min and use_ml:
|
||||||
|
if st_model is None:
|
||||||
|
st_model, util = get_sentence_transformer()
|
||||||
|
ml_state['st_model'] = st_model
|
||||||
|
ml_state['util'] = util
|
||||||
|
|
||||||
|
if st_model and top_candidates:
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => trying ML last resort against "
|
||||||
|
f"top {len(top_candidates)} fuzzy candidates (fuzzy={best_score})"
|
||||||
|
)
|
||||||
|
candidate_rows = [row for _, row in top_candidates]
|
||||||
|
sims = _ml_cosine_similarities(
|
||||||
|
st_model,
|
||||||
|
util,
|
||||||
|
chan["norm_chan"],
|
||||||
|
[row["norm_name"] for row in candidate_rows],
|
||||||
|
)
|
||||||
|
top_index = max(range(len(sims)), key=lambda i: sims[i])
|
||||||
|
top_value = sims[top_index]
|
||||||
|
matched_epg = candidate_rows[top_index]
|
||||||
|
|
||||||
|
if top_value >= ml_last_resort - 1e-9:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => DESPERATE LAST RESORT match "
|
||||||
|
f"EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||||
|
)
|
||||||
|
return matched_epg
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => desperate last resort "
|
||||||
|
f"ML-sim {top_value:.2f} < {ml_last_resort}, giving up"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Last resort ML matching failed for channel {chan['id']}: {e}")
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} "
|
||||||
|
f"< {fuzzy_medium}, giving up"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} "
|
||||||
|
f"< {fuzzy_medium}, no ML fallback available"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_channel_match_data(channel):
|
||||||
|
"""Build the channel dict used by matching logic."""
|
||||||
|
normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else ""
|
||||||
|
normalized_gracenote_id = (
|
||||||
|
channel.tvc_guide_stationid.strip().lower() if channel.tvc_guide_stationid else ""
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": channel.id,
|
||||||
|
"name": channel.name,
|
||||||
|
"tvg_id": normalized_tvg_id,
|
||||||
|
"original_tvg_id": channel.tvg_id,
|
||||||
|
"gracenote_id": normalized_gracenote_id,
|
||||||
|
"original_gracenote_id": channel.tvc_guide_stationid,
|
||||||
|
"fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name,
|
||||||
|
"norm_chan": normalize_name(channel.name),
|
||||||
|
"current_epg_data_id": channel.epg_data_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def match_channels_to_epg(
|
||||||
|
channels_data,
|
||||||
|
epg_data,
|
||||||
|
region_code=None,
|
||||||
|
use_ml=True,
|
||||||
|
send_progress=True,
|
||||||
|
epg_tvg_id_index=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Match channels to EPG rows using exact ID, fuzzy, and optional ML strategies.
|
||||||
|
|
||||||
|
epg_tvg_id_index: optional pre-built tvg_id -> row map from build_epg_matching_catalog().
|
||||||
|
"""
|
||||||
|
channels_to_update = []
|
||||||
|
matched_channels = []
|
||||||
|
unchanged_channels = []
|
||||||
|
total_channels = len(channels_data)
|
||||||
|
|
||||||
|
if send_progress:
|
||||||
|
send_epg_matching_progress(total_channels, 0, stage="starting")
|
||||||
|
|
||||||
|
is_bulk_matching = len(channels_data) > 1
|
||||||
|
ml_state = {}
|
||||||
|
epg_by_tvg_id = epg_tvg_id_index if epg_tvg_id_index is not None else build_epg_tvg_id_index(epg_data)
|
||||||
|
|
||||||
|
if is_bulk_matching:
|
||||||
|
logger.info(f"Using conservative thresholds for bulk matching ({total_channels} channels)")
|
||||||
|
else:
|
||||||
|
logger.info("Using aggressive thresholds for single channel matching")
|
||||||
|
|
||||||
|
for index, chan in enumerate(channels_data):
|
||||||
|
normalized_tvg_id = chan.get("tvg_id", "")
|
||||||
|
fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"]
|
||||||
|
|
||||||
|
resolved_count = len(matched_channels) + len(unchanged_channels)
|
||||||
|
if send_progress and (index < 5 or index % 5 == 0 or index == total_channels - 1):
|
||||||
|
send_epg_matching_progress(
|
||||||
|
total_channels,
|
||||||
|
resolved_count,
|
||||||
|
current_channel_name=chan["name"][:50],
|
||||||
|
stage="matching",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_tvg_id:
|
||||||
|
epg_row = epg_by_tvg_id.get(normalized_tvg_id)
|
||||||
|
if epg_row:
|
||||||
|
_record_epg_match(
|
||||||
|
chan,
|
||||||
|
epg_row["id"],
|
||||||
|
epg_name=epg_row.get("name"),
|
||||||
|
epg_tvg_id=epg_row.get("original_tvg_id") or epg_row.get("tvg_id"),
|
||||||
|
match_method="exact tvg_id",
|
||||||
|
channels_to_update=channels_to_update,
|
||||||
|
matched_channels=matched_channels,
|
||||||
|
unchanged_channels=unchanged_channels,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_gracenote_id = chan.get("gracenote_id", "")
|
||||||
|
if normalized_gracenote_id:
|
||||||
|
epg_by_gracenote_id = epg_by_tvg_id.get(normalized_gracenote_id)
|
||||||
|
if epg_by_gracenote_id:
|
||||||
|
_record_epg_match(
|
||||||
|
chan,
|
||||||
|
epg_by_gracenote_id["id"],
|
||||||
|
epg_name=epg_by_gracenote_id.get("name"),
|
||||||
|
epg_tvg_id=epg_by_gracenote_id.get("original_tvg_id")
|
||||||
|
or epg_by_gracenote_id.get("tvg_id"),
|
||||||
|
match_method="exact gracenote_id",
|
||||||
|
channels_to_update=channels_to_update,
|
||||||
|
matched_channels=matched_channels,
|
||||||
|
unchanged_channels=unchanged_channels,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not chan["norm_chan"]:
|
||||||
|
logger.debug(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
best_score, best_epg, top_candidates, _scanned = fuzzy_scan_epg_list(
|
||||||
|
chan["norm_chan"], epg_data, region_code
|
||||||
|
)
|
||||||
|
if not best_epg:
|
||||||
|
logger.debug(f"Channel {chan['id']} '{chan['name']}' => no EPG entries with valid norm_name found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched_epg = try_epg_name_match(
|
||||||
|
chan,
|
||||||
|
best_score,
|
||||||
|
best_epg,
|
||||||
|
top_candidates,
|
||||||
|
is_bulk_matching,
|
||||||
|
use_ml=use_ml,
|
||||||
|
ml_state=ml_state,
|
||||||
|
)
|
||||||
|
if matched_epg:
|
||||||
|
_record_epg_match(
|
||||||
|
chan,
|
||||||
|
matched_epg["id"],
|
||||||
|
epg_name=matched_epg.get("name"),
|
||||||
|
epg_tvg_id=matched_epg.get("original_tvg_id") or matched_epg.get("tvg_id"),
|
||||||
|
match_method=f"fuzzy (score={best_score})",
|
||||||
|
channels_to_update=channels_to_update,
|
||||||
|
matched_channels=matched_channels,
|
||||||
|
unchanged_channels=unchanged_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
if send_progress:
|
||||||
|
send_epg_matching_progress(
|
||||||
|
total_channels,
|
||||||
|
len(matched_channels) + len(unchanged_channels),
|
||||||
|
stage="completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"channels_to_update": channels_to_update,
|
||||||
|
"matched_channels": matched_channels,
|
||||||
|
"unchanged_channels": unchanged_channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_single_channel_epg_match(channel_id):
|
||||||
|
"""
|
||||||
|
Match one channel to EPG data. Always notifies the UI via WebSocket before returning.
|
||||||
|
"""
|
||||||
|
from apps.channels.models import Channel
|
||||||
|
|
||||||
|
channel = None
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting integrated single channel EPG matching for channel ID {channel_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel = Channel.objects.get(id=channel_id)
|
||||||
|
except Channel.DoesNotExist:
|
||||||
|
message = "Channel not found"
|
||||||
|
send_single_channel_epg_match_result(channel_id, False, message)
|
||||||
|
return {"matched": False, "message": message}
|
||||||
|
|
||||||
|
channel_data = prepare_channel_match_data(channel)
|
||||||
|
logger.info(
|
||||||
|
f"Channel data prepared: name='{channel.name}', tvg_id='{channel_data['tvg_id']}', "
|
||||||
|
f"gracenote_id='{channel_data['gracenote_id']}', norm_chan='{channel_data['norm_chan']}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="matching")
|
||||||
|
region_code = get_preferred_region_code()
|
||||||
|
|
||||||
|
fallback_name = channel_data["tvg_id"] if channel_data["tvg_id"] else channel.name
|
||||||
|
matched_epg_row = None
|
||||||
|
match_via = None
|
||||||
|
|
||||||
|
if channel_data["tvg_id"]:
|
||||||
|
matched_epg_row = lookup_epg_by_tvg_id(channel_data["tvg_id"])
|
||||||
|
if matched_epg_row:
|
||||||
|
match_via = matched_epg_row["tvg_id"]
|
||||||
|
logger.info(
|
||||||
|
f"Channel {channel.id} '{fallback_name}' => EPG found by exact tvg_id={match_via}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matched_epg_row and channel_data["gracenote_id"]:
|
||||||
|
matched_epg_row = lookup_epg_by_tvg_id(channel_data["gracenote_id"])
|
||||||
|
if matched_epg_row:
|
||||||
|
match_via = f"gracenote:{matched_epg_row['tvg_id']}"
|
||||||
|
logger.info(
|
||||||
|
f"Channel {channel.id} '{fallback_name}' => EPG found by exact "
|
||||||
|
f"gracenote_id={channel_data['gracenote_id']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matched_epg_row and channel_data["norm_chan"]:
|
||||||
|
best_score, best_epg, top_candidates, scanned = stream_fuzzy_epg_scan(
|
||||||
|
channel_data["norm_chan"], region_code
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Matching single channel '{channel.name}' against {scanned} EPG entries"
|
||||||
|
)
|
||||||
|
if best_epg:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {channel.id} '{channel.name}' => best match: '{best_epg['name']}' "
|
||||||
|
f"(score: {best_score})"
|
||||||
|
)
|
||||||
|
matched_epg_row = try_epg_name_match(
|
||||||
|
channel_data,
|
||||||
|
best_score,
|
||||||
|
best_epg,
|
||||||
|
top_candidates,
|
||||||
|
is_bulk_matching=False,
|
||||||
|
use_ml=True,
|
||||||
|
)
|
||||||
|
if matched_epg_row:
|
||||||
|
match_via = matched_epg_row["tvg_id"]
|
||||||
|
elif not channel_data["norm_chan"]:
|
||||||
|
logger.debug(f"Channel {channel.id} '{channel.name}' => empty after normalization, skipping")
|
||||||
|
|
||||||
|
if not matched_epg_row:
|
||||||
|
has_fuzzy_epg = _active_epg_fuzzy_queryset().exists()
|
||||||
|
if not has_fuzzy_epg and not channel_data["tvg_id"] and not channel_data["gracenote_id"]:
|
||||||
|
message = "No EPG data available for matching (from active sources)"
|
||||||
|
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed")
|
||||||
|
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||||
|
return {"matched": False, "message": message}
|
||||||
|
|
||||||
|
if matched_epg_row:
|
||||||
|
try:
|
||||||
|
matched_epg_id = matched_epg_row["id"]
|
||||||
|
epg_data = (
|
||||||
|
channel.epg_data
|
||||||
|
if channel.epg_data_id == matched_epg_id
|
||||||
|
else EPGData.objects.get(id=matched_epg_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if channel.epg_data_id == matched_epg_id:
|
||||||
|
success_msg = (
|
||||||
|
f"Channel '{channel.name}' already matched with EPG '{epg_data.name}'"
|
||||||
|
)
|
||||||
|
if match_via:
|
||||||
|
success_msg += f" (matched via: {match_via})"
|
||||||
|
logger.info(success_msg)
|
||||||
|
send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed")
|
||||||
|
send_single_channel_epg_match_result(
|
||||||
|
channel.id, True, success_msg, channel=channel, epg_data=epg_data
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"matched": True,
|
||||||
|
"unchanged": True,
|
||||||
|
"message": success_msg,
|
||||||
|
"epg_name": epg_data.name,
|
||||||
|
"epg_id": epg_data.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.epg_data = epg_data
|
||||||
|
channel.save(update_fields=["epg_data"])
|
||||||
|
|
||||||
|
success_msg = f"Channel '{channel.name}' matched with EPG '{epg_data.name}'"
|
||||||
|
if match_via:
|
||||||
|
success_msg += f" (matched via: {match_via})"
|
||||||
|
|
||||||
|
logger.info(success_msg)
|
||||||
|
send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed")
|
||||||
|
channel.refresh_from_db()
|
||||||
|
send_single_channel_epg_match_result(
|
||||||
|
channel.id, True, success_msg, channel=channel, epg_data=epg_data
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"matched": True,
|
||||||
|
"message": success_msg,
|
||||||
|
"epg_name": epg_data.name,
|
||||||
|
"epg_id": epg_data.id,
|
||||||
|
}
|
||||||
|
except EPGData.DoesNotExist:
|
||||||
|
message = "Matched EPG data not found"
|
||||||
|
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||||
|
return {"matched": False, "message": message}
|
||||||
|
|
||||||
|
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed")
|
||||||
|
message = f"No suitable EPG match found for channel '{channel.name}'"
|
||||||
|
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||||
|
return {"matched": False, "message": message}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in integrated single channel EPG matching: {e}", exc_info=True)
|
||||||
|
message = f"Error during matching: {str(e)}"
|
||||||
|
send_single_channel_epg_match_result(
|
||||||
|
channel_id,
|
||||||
|
False,
|
||||||
|
message,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
return {"matched": False, "message": message}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
cleanup_after_matching()
|
||||||
101
apps/channels/migrations/0038_add_catchup_fields.py
Normal file
101
apps/channels/migrations/0038_add_catchup_fields.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""Add denormalized catch-up fields to Stream and Channel."""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_stream_catchup(apps, schema_editor):
|
||||||
|
"""Derive is_catchup/catchup_days from Stream.custom_properties JSON."""
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE dispatcharr_channels_stream
|
||||||
|
SET is_catchup = TRUE,
|
||||||
|
catchup_days = COALESCE(
|
||||||
|
CASE WHEN (custom_properties->>'tv_archive_duration') ~ '^\\d+$'
|
||||||
|
THEN (custom_properties->>'tv_archive_duration')::int
|
||||||
|
ELSE NULL
|
||||||
|
END, 7
|
||||||
|
)
|
||||||
|
WHERE custom_properties IS NOT NULL
|
||||||
|
AND custom_properties != 'null'::jsonb
|
||||||
|
AND (
|
||||||
|
custom_properties->>'tv_archive' = '1'
|
||||||
|
-- JSON booleans extract as lowercase 'true' via ->>; the
|
||||||
|
-- 'True' spelling covers Python-str values stored by
|
||||||
|
-- older import code.
|
||||||
|
OR custom_properties->>'tv_archive' = 'true'
|
||||||
|
OR custom_properties->>'tv_archive' = 'True'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_channel_catchup(apps, schema_editor):
|
||||||
|
"""Roll up catch-up fields from streams to channels."""
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE dispatcharr_channels_channel c SET
|
||||||
|
is_catchup = EXISTS (
|
||||||
|
SELECT 1 FROM dispatcharr_channels_channelstream cs
|
||||||
|
JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id
|
||||||
|
WHERE cs.channel_id = c.id AND s.is_catchup = TRUE
|
||||||
|
),
|
||||||
|
catchup_days = COALESCE((
|
||||||
|
SELECT MAX(s.catchup_days) FROM dispatcharr_channels_channelstream cs
|
||||||
|
JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id
|
||||||
|
WHERE cs.channel_id = c.id AND s.is_catchup = TRUE
|
||||||
|
), 0)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("dispatcharr_channels", "0037_auto_sync_overhaul"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Stream fields
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="stream",
|
||||||
|
name="is_catchup",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="stream",
|
||||||
|
name="catchup_days",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Number of days of catch-up archive available (tv_archive_duration)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Channel fields
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="is_catchup",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Whether any stream on this channel supports catch-up (tv_archive=1)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="catchup_days",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Max catch-up archive days across all streams on this channel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Backfill existing data
|
||||||
|
migrations.RunPython(
|
||||||
|
backfill_stream_catchup,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
backfill_channel_catchup,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -2,9 +2,9 @@ from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from core.models import StreamProfile, CoreSettings
|
from core.models import StreamProfile, CoreSettings
|
||||||
from core.utils import RedisClient
|
from core.utils import RedisClient, custom_properties_as_dict
|
||||||
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||||
from apps.proxy.live_proxy.constants import ChannelMetadataField
|
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# If you have an M3UAccount model in apps.m3u, you can still import it:
|
# If you have an M3UAccount model in apps.m3u, you can still import it:
|
||||||
from apps.m3u.models import M3UAccount
|
from apps.m3u.models import M3UAccount
|
||||||
|
from apps.m3u.connection_pool import reserve_profile_slot, release_profile_slot
|
||||||
|
|
||||||
|
|
||||||
# Add fallback functions if Redis isn't available
|
# Add fallback functions if Redis isn't available
|
||||||
|
|
@ -134,6 +135,17 @@ class Stream(models.Model):
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Populated at import from tv_archive / tv_archive_duration.
|
||||||
|
is_catchup = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)",
|
||||||
|
)
|
||||||
|
catchup_days = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Number of days of catch-up archive available (tv_archive_duration)",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account')
|
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account')
|
||||||
verbose_name = "Stream"
|
verbose_name = "Stream"
|
||||||
|
|
@ -209,13 +221,14 @@ class Stream(models.Model):
|
||||||
Finds an available profile for this stream and reserves a connection slot.
|
Finds an available profile for this stream and reserves a connection slot.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason)
|
Tuple[Optional[int], Optional[int], Optional[str], bool]:
|
||||||
|
(stream_id, profile_id, error_reason, slot_reserved)
|
||||||
"""
|
"""
|
||||||
redis_client = RedisClient.get_client()
|
redis_client = RedisClient.get_client()
|
||||||
profile_id = redis_client.get(f"stream_profile:{self.id}")
|
profile_id = redis_client.get(f"stream_profile:{self.id}")
|
||||||
if profile_id:
|
if profile_id:
|
||||||
profile_id = int(profile_id)
|
profile_id = int(profile_id)
|
||||||
return self.id, profile_id, None
|
return self.id, profile_id, None, False
|
||||||
|
|
||||||
# Retrieve the M3U account associated with the stream.
|
# Retrieve the M3U account associated with the stream.
|
||||||
m3u_account = self.m3u_account
|
m3u_account = self.m3u_account
|
||||||
|
|
@ -226,29 +239,22 @@ class Stream(models.Model):
|
||||||
]
|
]
|
||||||
|
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
logger.info(profile)
|
logger.debug("Evaluating profile %s for stream %s", profile.id, self.id)
|
||||||
# Skip inactive profiles
|
# Skip inactive profiles
|
||||||
if profile.is_active == False:
|
if profile.is_active == False:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Atomic slot reservation: INCR first, check, rollback if over capacity
|
# Atomic slot reservation via shared connection pool helper
|
||||||
if profile.max_streams == 0:
|
reserved, _count, _failure_reason = reserve_profile_slot(
|
||||||
reserved = True
|
profile, redis_client
|
||||||
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
|
|
||||||
|
|
||||||
if reserved:
|
if reserved:
|
||||||
redis_client.set(f"channel_stream:{self.id}", self.id)
|
redis_client.set(f"channel_stream:{self.id}", self.id)
|
||||||
redis_client.set(f"stream_profile:{self.id}", profile.id)
|
redis_client.set(f"stream_profile:{self.id}", profile.id)
|
||||||
return self.id, profile.id, None
|
return self.id, profile.id, None, True
|
||||||
|
|
||||||
return None, None, "All active M3U profiles have reached maximum connection limits"
|
return None, None, "All active M3U profiles have reached maximum connection limits", False
|
||||||
|
|
||||||
def release_stream(self):
|
def release_stream(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -277,12 +283,7 @@ class Stream(models.Model):
|
||||||
f"Stream {stream_id}: found profile_id={profile_id}"
|
f"Stream {stream_id}: found profile_id={profile_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
profile_connections_key = f"profile_connections:{profile_id}"
|
release_profile_slot(profile_id, redis_client)
|
||||||
|
|
||||||
# Only decrement if the profile had a max_connections limit
|
|
||||||
current_count = int(redis_client.get(profile_connections_key) or 0)
|
|
||||||
if current_count > 0:
|
|
||||||
redis_client.decr(profile_connections_key)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -374,6 +375,17 @@ class Channel(models.Model):
|
||||||
help_text="The M3U account that auto-created this channel"
|
help_text="The M3U account that auto-created this channel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Populated at import; rolled up via ChannelStream signal / m3u refresh.
|
||||||
|
is_catchup = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Whether any stream on this channel supports catch-up (tv_archive=1)",
|
||||||
|
)
|
||||||
|
catchup_days = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Max catch-up archive days across all streams on this channel",
|
||||||
|
)
|
||||||
|
|
||||||
# Hidden channels are excluded from HDHR, M3U, EPG, and XC output queries.
|
# Hidden channels are excluded from HDHR, M3U, EPG, and XC output queries.
|
||||||
# Auto-sync still recognizes them so they are not recreated when their
|
# Auto-sync still recognizes them so they are not recreated when their
|
||||||
# underlying provider stream persists; this is an output-layer concern, not
|
# underlying provider stream persists; this is an output-layer concern, not
|
||||||
|
|
@ -599,44 +611,91 @@ class Channel(models.Model):
|
||||||
|
|
||||||
return victim_id
|
return victim_id
|
||||||
|
|
||||||
def _check_and_reserve_profile_slot(self, profile, redis_client):
|
def _channel_proxy_is_active(self, redis_client) -> bool:
|
||||||
|
"""True when live proxy metadata shows this channel is still running."""
|
||||||
|
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||||
|
if not redis_client.exists(metadata_key):
|
||||||
|
return False
|
||||||
|
state = redis_client.hget(metadata_key, ChannelMetadataField.STATE)
|
||||||
|
if state is None:
|
||||||
|
return False
|
||||||
|
if isinstance(state, bytes):
|
||||||
|
state = state.decode()
|
||||||
|
return state in (
|
||||||
|
ChannelState.ACTIVE,
|
||||||
|
ChannelState.WAITING_FOR_CLIENTS,
|
||||||
|
ChannelState.BUFFERING,
|
||||||
|
ChannelState.INITIALIZING,
|
||||||
|
ChannelState.CONNECTING,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stream_assignment_is_reusable(self, redis_client, stream_id: int) -> bool:
|
||||||
"""
|
"""
|
||||||
Atomically check and reserve a connection slot for the given profile.
|
Return True when an existing channel_stream assignment should be reused.
|
||||||
|
|
||||||
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
|
Reuse when the proxy is active, or when metadata is not written yet
|
||||||
condition where separate GET + check + INCR operations could allow
|
(between get_stream() reserving slots and initialize_channel() starting).
|
||||||
concurrent requests to both pass the capacity check.
|
When metadata exists but the proxy is inactive, the assignment is stale.
|
||||||
|
|
||||||
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:
|
if self._channel_proxy_is_active(redis_client):
|
||||||
return (True, 0)
|
return True
|
||||||
|
|
||||||
profile_connections_key = f"profile_connections:{profile.id}"
|
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||||
|
if not redis_client.exists(metadata_key):
|
||||||
|
return redis_client.get(f"stream_profile:{stream_id}") is not None
|
||||||
|
|
||||||
# Atomically increment first — this is a single Redis command
|
return False
|
||||||
new_count = redis_client.incr(profile_connections_key)
|
|
||||||
|
|
||||||
if new_count <= profile.max_streams:
|
def _release_stale_stream_assignment(self, redis_client, stream_id: int) -> None:
|
||||||
return (True, new_count)
|
"""Release pool counters and remove stale channel/stream assignment keys."""
|
||||||
|
profile_id = None
|
||||||
|
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
|
||||||
|
if profile_id_bytes:
|
||||||
|
try:
|
||||||
|
profile_id = int(profile_id_bytes)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
"Invalid profile ID for stale assignment on stream %s: %s",
|
||||||
|
stream_id,
|
||||||
|
profile_id_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
# Over capacity — roll back the increment
|
if profile_id is None:
|
||||||
redis_client.decr(profile_connections_key)
|
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||||
return (False, new_count - 1)
|
meta_profile_id = redis_client.hget(
|
||||||
|
metadata_key, ChannelMetadataField.M3U_PROFILE
|
||||||
|
)
|
||||||
|
if meta_profile_id:
|
||||||
|
try:
|
||||||
|
profile_id = int(meta_profile_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
"Invalid profile ID in metadata for stale assignment on "
|
||||||
|
"channel %s: %s",
|
||||||
|
self.uuid,
|
||||||
|
meta_profile_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile_id is not None:
|
||||||
|
release_profile_slot(profile_id, redis_client)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Channel %s: releasing stale stream %s assignment without profile "
|
||||||
|
"info - profile_connections may leak",
|
||||||
|
self.uuid,
|
||||||
|
stream_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
redis_client.delete(f"channel_stream:{self.id}")
|
||||||
|
redis_client.delete(f"stream_profile:{stream_id}")
|
||||||
|
|
||||||
def get_stream(self, requester=None):
|
def get_stream(self, requester=None):
|
||||||
"""
|
"""
|
||||||
Finds an available stream for the requested channel and returns the selected stream and profile.
|
Finds an available stream for the requested channel and returns the selected stream and profile.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason)
|
Tuple[Optional[int], Optional[int], Optional[str], bool]:
|
||||||
|
(stream_id, profile_id, error_reason, slot_reserved)
|
||||||
"""
|
"""
|
||||||
redis_client = RedisClient.get_client()
|
redis_client = RedisClient.get_client()
|
||||||
error_reason = None
|
error_reason = None
|
||||||
|
|
@ -644,26 +703,42 @@ class Channel(models.Model):
|
||||||
# Check if this channel has any streams
|
# Check if this channel has any streams
|
||||||
if not self.streams.exists():
|
if not self.streams.exists():
|
||||||
error_reason = "No streams assigned to channel"
|
error_reason = "No streams assigned to channel"
|
||||||
return None, None, error_reason
|
return None, None, error_reason, False
|
||||||
|
|
||||||
# Check if a stream is already active for this channel
|
# Reuse assignment only when this channel is still active in the proxy.
|
||||||
|
# Stale channel_stream keys after stop/disconnect skip INCR and break pool
|
||||||
|
# accounting, which lets a second stream reach the provider and fail validation.
|
||||||
stream_id_bytes = redis_client.get(f"channel_stream:{self.id}")
|
stream_id_bytes = redis_client.get(f"channel_stream:{self.id}")
|
||||||
if stream_id_bytes:
|
if stream_id_bytes:
|
||||||
try:
|
try:
|
||||||
stream_id = int(stream_id_bytes)
|
stream_id = int(stream_id_bytes)
|
||||||
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
|
|
||||||
if profile_id_bytes:
|
|
||||||
try:
|
|
||||||
profile_id = int(profile_id_bytes)
|
|
||||||
return stream_id, profile_id, None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
logger.debug(
|
|
||||||
f"Invalid profile ID retrieved from Redis: {profile_id_bytes}"
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Invalid stream ID retrieved from Redis: {stream_id_bytes}"
|
f"Invalid stream ID retrieved from Redis: {stream_id_bytes}"
|
||||||
)
|
)
|
||||||
|
stream_id = None
|
||||||
|
|
||||||
|
if stream_id is not None:
|
||||||
|
if self._stream_assignment_is_reusable(redis_client, stream_id):
|
||||||
|
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
|
||||||
|
if profile_id_bytes:
|
||||||
|
try:
|
||||||
|
profile_id = int(profile_id_bytes)
|
||||||
|
logger.debug(
|
||||||
|
f"Channel {self.uuid}: reusing stream assignment "
|
||||||
|
f"stream={stream_id} profile={profile_id}"
|
||||||
|
)
|
||||||
|
return stream_id, profile_id, None, False
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
f"Invalid profile ID retrieved from Redis: {profile_id_bytes}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Channel {self.uuid}: releasing stale stream assignment "
|
||||||
|
f"(stream={stream_id}, proxy not active)"
|
||||||
|
)
|
||||||
|
self._release_stale_stream_assignment(redis_client, stream_id)
|
||||||
|
|
||||||
# No existing active stream, attempt to assign a new one
|
# No existing active stream, attempt to assign a new one
|
||||||
has_streams_but_maxed_out = False
|
has_streams_but_maxed_out = False
|
||||||
|
|
@ -697,7 +772,7 @@ class Channel(models.Model):
|
||||||
has_active_profiles = True
|
has_active_profiles = True
|
||||||
|
|
||||||
# Atomically check and reserve a slot (INCR-first pattern)
|
# Atomically check and reserve a slot (INCR-first pattern)
|
||||||
reserved, current_count = self._check_and_reserve_profile_slot(
|
reserved, current_count, failure_reason = reserve_profile_slot(
|
||||||
profile, redis_client
|
profile, redis_client
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -705,11 +780,16 @@ class Channel(models.Model):
|
||||||
# Slot reserved — assign stream to this channel
|
# Slot reserved — assign stream to this channel
|
||||||
redis_client.set(f"channel_stream:{self.id}", stream.id)
|
redis_client.set(f"channel_stream:{self.id}", stream.id)
|
||||||
redis_client.set(f"stream_profile:{stream.id}", profile.id)
|
redis_client.set(f"stream_profile:{stream.id}", profile.id)
|
||||||
|
logger.info(
|
||||||
|
f"Channel {self.uuid}: assigned stream {stream.id} "
|
||||||
|
f"profile {profile.id} ({profile.name})"
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
stream.id,
|
stream.id,
|
||||||
profile.id,
|
profile.id,
|
||||||
None,
|
None,
|
||||||
|
True,
|
||||||
) # Return newly assigned stream and matched profile
|
) # Return newly assigned stream and matched profile
|
||||||
else:
|
else:
|
||||||
# At capacity: try to preempt a lower-impact channel on this profile
|
# At capacity: try to preempt a lower-impact channel on this profile
|
||||||
|
|
@ -723,12 +803,21 @@ class Channel(models.Model):
|
||||||
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
|
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
|
||||||
# return self.id, profile.id, victim_channel_id
|
# return self.id, profile.id, victim_channel_id
|
||||||
|
|
||||||
# This profile is at max connections
|
|
||||||
has_streams_but_maxed_out = True
|
has_streams_but_maxed_out = True
|
||||||
logger.debug(
|
if failure_reason == "profile_full":
|
||||||
f"Profile {profile.id} at max connections: "
|
logger.info(
|
||||||
f"{current_count}/{profile.max_streams}"
|
f"Profile {profile.id} at max connections: "
|
||||||
)
|
f"{current_count}/{profile.max_streams}, trying next profile"
|
||||||
|
)
|
||||||
|
elif failure_reason == "credential_full":
|
||||||
|
logger.info(
|
||||||
|
f"Profile {profile.id} shared login pool full, trying next profile"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Profile {profile.id} reservation failed: "
|
||||||
|
f"{current_count}/{profile.max_streams}"
|
||||||
|
)
|
||||||
|
|
||||||
# No available streams - determine specific reason
|
# No available streams - determine specific reason
|
||||||
if has_streams_but_maxed_out:
|
if has_streams_but_maxed_out:
|
||||||
|
|
@ -738,7 +827,7 @@ class Channel(models.Model):
|
||||||
else:
|
else:
|
||||||
error_reason = "No active profiles found for any assigned stream"
|
error_reason = "No active profiles found for any assigned stream"
|
||||||
|
|
||||||
return None, None, error_reason
|
return None, None, error_reason, False
|
||||||
|
|
||||||
def release_stream(self):
|
def release_stream(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -782,12 +871,7 @@ class Channel(models.Model):
|
||||||
ChannelMetadataField.M3U_PROFILE,
|
ChannelMetadataField.M3U_PROFILE,
|
||||||
)
|
)
|
||||||
|
|
||||||
profile_connections_key = f"profile_connections:{profile_id}"
|
release_profile_slot(profile_id, redis_client)
|
||||||
current_count = int(
|
|
||||||
redis_client.get(profile_connections_key) or 0
|
|
||||||
)
|
|
||||||
if current_count > 0:
|
|
||||||
redis_client.decr(profile_connections_key)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -832,12 +916,7 @@ class Channel(models.Model):
|
||||||
f"stream {stream_id}"
|
f"stream {stream_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
profile_connections_key = f"profile_connections:{profile_id}"
|
release_profile_slot(profile_id, redis_client)
|
||||||
|
|
||||||
# Only decrement if the profile had a max_connections limit
|
|
||||||
current_count = int(redis_client.get(profile_connections_key) or 0)
|
|
||||||
if current_count > 0:
|
|
||||||
redis_client.decr(profile_connections_key)
|
|
||||||
|
|
||||||
# Clear metadata fields so duplicate release_stream() calls
|
# Clear metadata fields so duplicate release_stream() calls
|
||||||
# (e.g. from _clean_redis_keys or ChannelService.stop_channel)
|
# (e.g. from _clean_redis_keys or ChannelService.stop_channel)
|
||||||
|
|
@ -883,10 +962,31 @@ class Channel(models.Model):
|
||||||
if current_profile_id == new_profile_id:
|
if current_profile_id == new_profile_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Use pipeline for atomic profile switch to prevent counter drift
|
from apps.m3u.connection_pool import (
|
||||||
# if an exception occurs between DECR and INCR
|
move_credential_slot_on_profile_switch,
|
||||||
old_profile_connections_key = f"profile_connections:{current_profile_id}"
|
profile_connections_key,
|
||||||
new_profile_connections_key = f"profile_connections:{new_profile_id}"
|
)
|
||||||
|
from apps.m3u.models import M3UAccountProfile
|
||||||
|
|
||||||
|
old_profile = M3UAccountProfile.objects.select_related(
|
||||||
|
"m3u_account__server_group"
|
||||||
|
).get(id=current_profile_id)
|
||||||
|
new_profile = M3UAccountProfile.objects.select_related(
|
||||||
|
"m3u_account__server_group"
|
||||||
|
).get(id=new_profile_id)
|
||||||
|
|
||||||
|
if not move_credential_slot_on_profile_switch(
|
||||||
|
old_profile, new_profile, redis_client
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Shared login pool full for profile %s during stream profile switch",
|
||||||
|
new_profile_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Profile counters always move on switch; credential totals move only when login changes.
|
||||||
|
old_profile_connections_key = profile_connections_key(current_profile_id)
|
||||||
|
new_profile_connections_key = profile_connections_key(new_profile_id)
|
||||||
old_count = int(redis_client.get(old_profile_connections_key) or 0)
|
old_count = int(redis_client.get(old_profile_connections_key) or 0)
|
||||||
|
|
||||||
pipe = redis_client.pipeline()
|
pipe = redis_client.pipeline()
|
||||||
|
|
@ -1041,6 +1141,13 @@ class ChannelGroupM3UAccount(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})"
|
return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.custom_properties is not None and not isinstance(
|
||||||
|
self.custom_properties, dict
|
||||||
|
):
|
||||||
|
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Logo(models.Model):
|
class Logo(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ class StreamSerializer(serializers.ModelSerializer):
|
||||||
"stream_stats_updated_at",
|
"stream_stats_updated_at",
|
||||||
"stream_id",
|
"stream_id",
|
||||||
"stream_chno",
|
"stream_chno",
|
||||||
|
"is_catchup",
|
||||||
|
"catchup_days",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
|
|
@ -221,17 +223,6 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
|
||||||
# Accept both dict and JSON string for custom_properties (for backward compatibility)
|
|
||||||
val = data.get("custom_properties")
|
|
||||||
if isinstance(val, str):
|
|
||||||
try:
|
|
||||||
data["custom_properties"] = json.loads(val)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return super().to_internal_value(data)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
# Partial PATCHes only carry submitted fields; fill missing
|
# Partial PATCHes only carry submitted fields; fill missing
|
||||||
# start/end from the instance so the validator catches a PATCH
|
# start/end from the instance so the validator catches a PATCH
|
||||||
|
|
@ -459,6 +450,8 @@ class ChannelSerializer(serializers.ModelSerializer):
|
||||||
"logo_id",
|
"logo_id",
|
||||||
"user_level",
|
"user_level",
|
||||||
"is_adult",
|
"is_adult",
|
||||||
|
"is_catchup",
|
||||||
|
"catchup_days",
|
||||||
"hidden_from_output",
|
"hidden_from_output",
|
||||||
"auto_created",
|
"auto_created",
|
||||||
"auto_created_by",
|
"auto_created_by",
|
||||||
|
|
@ -562,10 +555,13 @@ class ChannelSerializer(serializers.ModelSerializer):
|
||||||
return LogoSerializer(obj.logo).data
|
return LogoSerializer(obj.logo).data
|
||||||
|
|
||||||
def get_streams(self, obj):
|
def get_streams(self, obj):
|
||||||
"""Retrieve ordered stream IDs for GET requests."""
|
"""Retrieve ordered streams for GET requests using prefetched channelstream_set."""
|
||||||
return StreamSerializer(
|
ordered_streams = [
|
||||||
obj.streams.all().order_by("channelstream__order"), many=True
|
cs.stream
|
||||||
).data
|
for cs in obj.channelstream_set.all()
|
||||||
|
if cs.stream_id is not None
|
||||||
|
]
|
||||||
|
return StreamSerializer(ordered_streams, many=True).data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
streams = validated_data.pop("streams", [])
|
streams = validated_data.pop("streams", [])
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# apps/channels/signals.py
|
# apps/channels/signals.py
|
||||||
|
|
||||||
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete, pre_delete
|
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.timezone import now, is_aware, make_aware
|
from django.utils.timezone import now, is_aware, make_aware
|
||||||
from celery.result import AsyncResult
|
from celery.result import AsyncResult
|
||||||
from django_celery_beat.models import ClockedSchedule, PeriodicTask
|
from django_celery_beat.models import ClockedSchedule, PeriodicTask
|
||||||
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording
|
from .models import Channel, Stream, ChannelStream, ChannelProfile, ChannelProfileMembership, Recording
|
||||||
from apps.m3u.models import M3UAccount
|
from apps.m3u.models import M3UAccount
|
||||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
from apps.epg.tasks import parse_programs_for_tvg_id
|
||||||
import json
|
import json
|
||||||
|
|
@ -60,38 +60,6 @@ def generate_custom_stream_hash(sender, instance, created, **kwargs):
|
||||||
# Use update to avoid triggering signals again
|
# Use update to avoid triggering signals again
|
||||||
Stream.objects.filter(id=instance.id).update(stream_hash=instance.stream_hash)
|
Stream.objects.filter(id=instance.id).update(stream_hash=instance.stream_hash)
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Channel)
|
|
||||||
def stop_proxy_session_before_channel_delete(sender, instance, **kwargs):
|
|
||||||
"""
|
|
||||||
When a Channel is deleted, stop any active TS proxy session for it first.
|
|
||||||
|
|
||||||
Without this, the proxy's Redis state (live:channel:{uuid}:*) survives
|
|
||||||
the Channel row and the connected clients' "Stop" button hits
|
|
||||||
`ChannelService.stop_channel(uuid)`, which calls `Channel.objects.get(uuid=...)`
|
|
||||||
and crashes with DoesNotExist (reported as 'Channel not found' in the UI).
|
|
||||||
Users then cannot close the stream; source-side connection limits stay
|
|
||||||
consumed. Covers manual deletes, bulk deletes, and sync-driven deletes
|
|
||||||
via the same signal path.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from apps.proxy.live_proxy.services.channel_service import ChannelService
|
|
||||||
|
|
||||||
channel_uuid = str(instance.uuid) if instance.uuid else None
|
|
||||||
if not channel_uuid:
|
|
||||||
return
|
|
||||||
# Best-effort: if the channel has no active session the service
|
|
||||||
# returns a benign 'Channel not found' result, which is ignored.
|
|
||||||
ChannelService.stop_channel(channel_uuid)
|
|
||||||
except Exception as e:
|
|
||||||
# Never block a channel delete on proxy cleanup failure. Log and
|
|
||||||
# continue so at least the DB row is removed.
|
|
||||||
logger.warning(
|
|
||||||
"Failed to stop proxy session before deleting channel %s: %s",
|
|
||||||
getattr(instance, "id", "<unknown>"),
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Channel)
|
@receiver(post_save, sender=Channel)
|
||||||
def assign_compact_number_on_unhide(sender, instance, created, **kwargs):
|
def assign_compact_number_on_unhide(sender, instance, created, **kwargs):
|
||||||
"""When a channel transitions from hidden to visible under compact
|
"""When a channel transitions from hidden to visible under compact
|
||||||
|
|
@ -201,18 +169,13 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
|
||||||
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
|
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
|
||||||
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
|
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
|
||||||
if instance.epg_data:
|
if instance.epg_data:
|
||||||
# Skip XMLTV program parser for SD sources — program data is handled
|
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
|
||||||
# by fetch_schedules_direct() directly.
|
|
||||||
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
|
|
||||||
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
|
|
||||||
return
|
return
|
||||||
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
|
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
|
||||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||||
# For new channels with EPG data, also refresh
|
# For new channels with EPG data, also refresh
|
||||||
elif created and instance.epg_data:
|
elif created and instance.epg_data:
|
||||||
# Skip XMLTV program parser for SD sources
|
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
|
||||||
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
|
|
||||||
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
|
|
||||||
return
|
return
|
||||||
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
|
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
|
||||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||||
|
|
@ -374,3 +337,20 @@ def schedule_task_on_save(sender, instance, created, **kwargs):
|
||||||
@receiver(post_delete, sender=Recording)
|
@receiver(post_delete, sender=Recording)
|
||||||
def revoke_task_on_delete(sender, instance, **kwargs):
|
def revoke_task_on_delete(sender, instance, **kwargs):
|
||||||
revoke_task(instance.task_id)
|
revoke_task(instance.task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver([post_save, post_delete], sender=ChannelStream)
|
||||||
|
def update_channel_catchup_fields(sender, instance, **kwargs):
|
||||||
|
"""Roll up catch-up flags from active streams (UI path; import uses SQL rollup)."""
|
||||||
|
from django.db.models import Max
|
||||||
|
|
||||||
|
channel = instance.channel
|
||||||
|
catchup_qs = channel.streams.filter(
|
||||||
|
is_catchup=True,
|
||||||
|
m3u_account__is_active=True,
|
||||||
|
)
|
||||||
|
max_days = catchup_qs.aggregate(max_days=Max("catchup_days"))["max_days"]
|
||||||
|
Channel.objects.filter(pk=channel.pk).update(
|
||||||
|
is_catchup=catchup_qs.exists(),
|
||||||
|
catchup_days=max_days or 0,
|
||||||
|
)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
158
apps/channels/tests/test_catchup_utils.py
Normal file
158
apps/channels/tests/test_catchup_utils.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
from apps.channels.models import Channel, ChannelStream, Stream
|
||||||
|
from apps.channels.utils import is_catchup_enabled, resolve_xc_epg_prev_days
|
||||||
|
from apps.m3u.models import M3UAccount
|
||||||
|
from core.models import SYSTEM_SETTINGS_KEY, CoreSettings
|
||||||
|
|
||||||
|
|
||||||
|
class IsCatchupEnabledTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User(username="catchup-gate", custom_properties={})
|
||||||
|
# Drop any leftover catchup_enabled so default-on tests see a clean slate.
|
||||||
|
obj = CoreSettings.objects.filter(key=SYSTEM_SETTINGS_KEY).first()
|
||||||
|
if obj is not None:
|
||||||
|
value = dict(obj.value or {})
|
||||||
|
if "catchup_enabled" in value:
|
||||||
|
value.pop("catchup_enabled", None)
|
||||||
|
obj.value = value
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# CoreSettings writes populate Redis; DB rollback does not clear it.
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def _set_system_catchup(self, enabled):
|
||||||
|
obj, _ = CoreSettings.objects.get_or_create(
|
||||||
|
key=SYSTEM_SETTINGS_KEY,
|
||||||
|
defaults={"name": "System Settings", "value": {}},
|
||||||
|
)
|
||||||
|
value = dict(obj.value or {})
|
||||||
|
value["catchup_enabled"] = enabled
|
||||||
|
obj.value = value
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
def test_defaults_to_enabled(self):
|
||||||
|
self.assertTrue(is_catchup_enabled())
|
||||||
|
self.assertTrue(is_catchup_enabled(user=self.user))
|
||||||
|
|
||||||
|
def test_system_disable_blocks_everyone(self):
|
||||||
|
self._set_system_catchup(False)
|
||||||
|
self.assertFalse(is_catchup_enabled())
|
||||||
|
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||||
|
|
||||||
|
def test_user_disable_only_affects_that_user(self):
|
||||||
|
self._set_system_catchup(True)
|
||||||
|
self.user.custom_properties = {"catchup_enabled": False}
|
||||||
|
other = User(username="other", custom_properties={})
|
||||||
|
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||||
|
self.assertTrue(is_catchup_enabled(user=other))
|
||||||
|
|
||||||
|
def test_user_disable_skips_system_settings_lookup(self):
|
||||||
|
self.user.custom_properties = {"catchup_enabled": False}
|
||||||
|
with patch.object(CoreSettings, "get_catchup_enabled") as system_mock:
|
||||||
|
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||||
|
system_mock.assert_not_called()
|
||||||
|
|
||||||
|
def test_system_disable_overrides_user_enable(self):
|
||||||
|
self._set_system_catchup(False)
|
||||||
|
self.user.custom_properties = {"catchup_enabled": True}
|
||||||
|
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveXcEpgPrevDaysTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.user = User(username="xc-prev", custom_properties={})
|
||||||
|
|
||||||
|
def test_url_prev_days_zero_is_explicit(self):
|
||||||
|
request = self.factory.get("/xmltv.php?prev_days=0")
|
||||||
|
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 0)
|
||||||
|
|
||||||
|
def test_user_epg_prev_days_used_when_url_omitted(self):
|
||||||
|
self.user.custom_properties = {"epg_prev_days": 5}
|
||||||
|
request = self.factory.get("/xmltv.php")
|
||||||
|
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 5)
|
||||||
|
|
||||||
|
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
|
||||||
|
def test_auto_detect_only_when_no_url_or_user_default(self, mock_compute):
|
||||||
|
request = self.factory.get("/xmltv.php")
|
||||||
|
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 14)
|
||||||
|
mock_compute.assert_called_once()
|
||||||
|
|
||||||
|
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
|
||||||
|
def test_per_channel_epg_skips_global_auto_detect(self, mock_compute):
|
||||||
|
request = self.factory.get("/player_api.php")
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_xc_epg_prev_days(request, self.user, auto_detect_fallback=False),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
mock_compute.assert_not_called()
|
||||||
|
|
||||||
|
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
|
||||||
|
def test_user_default_prevents_auto_detect(self, mock_compute):
|
||||||
|
self.user.custom_properties = {"epg_prev_days": 3}
|
||||||
|
request = self.factory.get("/xmltv.php")
|
||||||
|
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3)
|
||||||
|
mock_compute.assert_not_called()
|
||||||
|
|
||||||
|
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
|
||||||
|
def test_auto_detect_still_runs_when_catchup_disabled(self, mock_compute):
|
||||||
|
"""Catchup disable must not suppress historical EPG lookback."""
|
||||||
|
self.user.custom_properties = {"catchup_enabled": False}
|
||||||
|
request = self.factory.get("/xmltv.php")
|
||||||
|
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 14)
|
||||||
|
mock_compute.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class CatchupRollupActiveAccountTests(TestCase):
|
||||||
|
"""Denormalized catch-up flags ignore disabled M3U accounts."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.inactive = M3UAccount.objects.create(
|
||||||
|
name="catchup-inactive",
|
||||||
|
server_url="http://example.test",
|
||||||
|
account_type="XC",
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_channelstream_signal_ignores_inactive_catchup_stream(self):
|
||||||
|
channel = Channel.objects.create(name="inactive-only")
|
||||||
|
stream = Stream.objects.create(
|
||||||
|
name="inactive-catchup",
|
||||||
|
url="http://example.test/inactive",
|
||||||
|
m3u_account=self.inactive,
|
||||||
|
is_catchup=True,
|
||||||
|
catchup_days=9,
|
||||||
|
)
|
||||||
|
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
|
||||||
|
|
||||||
|
channel.refresh_from_db()
|
||||||
|
self.assertFalse(channel.is_catchup)
|
||||||
|
self.assertEqual(channel.catchup_days, 0)
|
||||||
|
|
||||||
|
def test_rollup_ignores_inactive_catchup_stream(self):
|
||||||
|
from apps.m3u.tasks import rollup_channel_catchup_fields
|
||||||
|
|
||||||
|
channel = Channel.objects.create(name="rollup-inactive-only")
|
||||||
|
stream = Stream.objects.create(
|
||||||
|
name="rollup-inactive-catchup",
|
||||||
|
url="http://example.test/rollup-inactive",
|
||||||
|
m3u_account=self.inactive,
|
||||||
|
is_catchup=True,
|
||||||
|
catchup_days=9,
|
||||||
|
)
|
||||||
|
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
|
||||||
|
Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9)
|
||||||
|
|
||||||
|
rollup_channel_catchup_fields(self.inactive.id)
|
||||||
|
|
||||||
|
channel.refresh_from_db()
|
||||||
|
self.assertFalse(channel.is_catchup)
|
||||||
|
self.assertEqual(channel.catchup_days, 0)
|
||||||
|
|
@ -293,7 +293,8 @@ class ChannelSummaryEffectiveValuesTests(TestCase):
|
||||||
def test_summary_returns_effective_values(self):
|
def test_summary_returns_effective_values(self):
|
||||||
response = self.client.get("/api/channels/channels/summary/")
|
response = self.client.get("/api/channels/channels/summary/")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
row = next(r for r in response.data if r["id"] == self.channel.id)
|
data = response.json()
|
||||||
|
row = next(r for r in data if r["id"] == self.channel.id)
|
||||||
self.assertEqual(row["name"], "Override Name")
|
self.assertEqual(row["name"], "Override Name")
|
||||||
self.assertEqual(row["channel_number"], 99.0)
|
self.assertEqual(row["channel_number"], 99.0)
|
||||||
self.assertEqual(row["channel_group_id"], self.other_group.id)
|
self.assertEqual(row["channel_group_id"], self.other_group.id)
|
||||||
|
|
@ -504,3 +505,117 @@ class SeriesRuleAPITests(TestCase):
|
||||||
def test_bulk_remove_requires_tvg_id_or_title(self):
|
def test_bulk_remove_requires_tvg_id_or_title(self):
|
||||||
resp = self.client.post(self.bulk_remove_url, {}, format="json")
|
resp = self.client.post(self.bulk_remove_url, {}, format="json")
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelListIncludeStreamsQueryTests(TestCase):
|
||||||
|
"""include_streams=true must not issue one stream query per channel."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.channels.models import ChannelStream, Stream
|
||||||
|
from apps.m3u.models import M3UAccount
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(username="list_admin", password="x")
|
||||||
|
self.user.user_level = 10
|
||||||
|
self.user.save()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
self.account = M3UAccount.objects.create(
|
||||||
|
name="list-test-account",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
)
|
||||||
|
self.group = ChannelGroup.objects.create(name=f"List Group {self.id}")
|
||||||
|
|
||||||
|
def _add_channel_with_stream(self, number):
|
||||||
|
from apps.channels.models import ChannelStream, Stream
|
||||||
|
|
||||||
|
channel = Channel.objects.create(
|
||||||
|
channel_number=float(number),
|
||||||
|
name=f"Channel {number}",
|
||||||
|
channel_group=self.group,
|
||||||
|
)
|
||||||
|
stream = Stream.objects.create(
|
||||||
|
name=f"Stream {number}",
|
||||||
|
url=f"http://example.com/{number}.ts",
|
||||||
|
m3u_account=self.account,
|
||||||
|
)
|
||||||
|
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
|
||||||
|
return channel
|
||||||
|
|
||||||
|
def _query_count_for_list(self):
|
||||||
|
from django.db import connection
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/channels/channels/",
|
||||||
|
{"page": 1, "page_size": 50, "include_streams": "true"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
return len(ctx.captured_queries)
|
||||||
|
|
||||||
|
def test_include_streams_query_count_stable_as_channels_grow(self):
|
||||||
|
self._add_channel_with_stream(1)
|
||||||
|
self._add_channel_with_stream(2)
|
||||||
|
self._add_channel_with_stream(3)
|
||||||
|
q_small = self._query_count_for_list()
|
||||||
|
|
||||||
|
self._add_channel_with_stream(4)
|
||||||
|
self._add_channel_with_stream(5)
|
||||||
|
self._add_channel_with_stream(6)
|
||||||
|
self._add_channel_with_stream(7)
|
||||||
|
q_large = self._query_count_for_list()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
q_small,
|
||||||
|
q_large,
|
||||||
|
"include_streams list should use prefetched channelstream_set, "
|
||||||
|
"not one streams M2M query per channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelListOnlyCatchupFilterTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="catchup_filter", password="x")
|
||||||
|
self.user.user_level = 10
|
||||||
|
self.user.save()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
self.catchup_channel = Channel.objects.create(
|
||||||
|
channel_number=1.0,
|
||||||
|
name="Catch-up Channel",
|
||||||
|
is_catchup=True,
|
||||||
|
catchup_days=7,
|
||||||
|
)
|
||||||
|
self.live_channel = Channel.objects.create(
|
||||||
|
channel_number=2.0,
|
||||||
|
name="Live Channel",
|
||||||
|
is_catchup=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_catchup_returns_catchup_channels(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/channels/channels/",
|
||||||
|
{"only_catchup": "true", "page": 1, "page_size": 50},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
ids = {row["id"] for row in response.data["results"]}
|
||||||
|
self.assertEqual(ids, {self.catchup_channel.id})
|
||||||
|
|
||||||
|
def test_only_catchup_does_not_force_distinct(self):
|
||||||
|
from django.db import connection
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/channels/channels/",
|
||||||
|
{"only_catchup": "true", "page": 1, "page_size": 50},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
sql = " ".join(q["sql"] for q in ctx.captured_queries).upper()
|
||||||
|
self.assertNotIn("DISTINCT", sql)
|
||||||
|
|
|
||||||
95
apps/channels/tests/test_channel_delete_stop_stream.py
Normal file
95
apps/channels/tests/test_channel_delete_stop_stream.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""Channel delete optionally stops live proxy sessions (default: leave playing)."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.channels.models import Channel, ChannelGroup
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelDeleteStopStreamAPITests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="deleter", password="testpass123")
|
||||||
|
self.user.user_level = 10
|
||||||
|
self.user.save()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
self.group = ChannelGroup.objects.create(name="Delete API Group")
|
||||||
|
self.channel = Channel.objects.create(
|
||||||
|
channel_number=42.0,
|
||||||
|
name="Playing Channel",
|
||||||
|
channel_group=self.group,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
|
||||||
|
)
|
||||||
|
def test_delete_without_stop_stream_leaves_proxy_running(self, mock_stop_channels):
|
||||||
|
url = f"/api/channels/channels/{self.channel.id}/"
|
||||||
|
response = self.client.delete(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
self.assertFalse(Channel.objects.filter(pk=self.channel.pk).exists())
|
||||||
|
mock_stop_channels.assert_not_called()
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
|
||||||
|
)
|
||||||
|
def test_delete_with_stop_stream_stops_before_delete(self, mock_stop_channels):
|
||||||
|
url = f"/api/channels/channels/{self.channel.id}/?stop_stream=true"
|
||||||
|
channel_uuid = self.channel.uuid
|
||||||
|
|
||||||
|
response = self.client.delete(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
self.assertFalse(Channel.objects.filter(pk=self.channel.pk).exists())
|
||||||
|
mock_stop_channels.assert_called_once()
|
||||||
|
stopped = list(mock_stop_channels.call_args[0][0])
|
||||||
|
self.assertEqual(stopped, [channel_uuid])
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
|
||||||
|
)
|
||||||
|
def test_bulk_delete_without_stop_stream(self, mock_stop_channels):
|
||||||
|
other = Channel.objects.create(
|
||||||
|
channel_number=43.0,
|
||||||
|
name="Other",
|
||||||
|
channel_group=self.group,
|
||||||
|
)
|
||||||
|
response = self.client.delete(
|
||||||
|
"/api/channels/channels/bulk-delete/",
|
||||||
|
{"channel_ids": [self.channel.id, other.id]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
self.assertFalse(Channel.objects.filter(pk=self.channel.pk).exists())
|
||||||
|
mock_stop_channels.assert_not_called()
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
|
||||||
|
)
|
||||||
|
def test_bulk_delete_with_stop_stream(self, mock_stop_channels):
|
||||||
|
other = Channel.objects.create(
|
||||||
|
channel_number=43.0,
|
||||||
|
name="Other",
|
||||||
|
channel_group=self.group,
|
||||||
|
)
|
||||||
|
response = self.client.delete(
|
||||||
|
"/api/channels/channels/bulk-delete/",
|
||||||
|
{
|
||||||
|
"channel_ids": [self.channel.id, other.id],
|
||||||
|
"stop_stream": True,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
mock_stop_channels.assert_called_once()
|
||||||
|
stopped = set(mock_stop_channels.call_args[0][0])
|
||||||
|
self.assertEqual(stopped, {self.channel.uuid, other.uuid})
|
||||||
|
|
@ -203,10 +203,9 @@ class InitialConnectionRetryTests(TestCase):
|
||||||
source = inspect.getsource(run_recording)
|
source = inspect.getsource(run_recording)
|
||||||
|
|
||||||
self.assertGreater(_dvr_ffmpeg_retry_window_seconds(), 0)
|
self.assertGreater(_dvr_ffmpeg_retry_window_seconds(), 0)
|
||||||
self.assertIn("reconnect", source.lower(),
|
|
||||||
"run_recording must contain input reconnection flags")
|
|
||||||
self.assertIn("_ffmpeg_outage_started", source)
|
self.assertIn("_ffmpeg_outage_started", source)
|
||||||
self.assertIn("_ffmpeg_retry_window", source)
|
self.assertIn("_ffmpeg_retry_window", source)
|
||||||
|
self.assertIn("_dvr_build_ffmpeg_cmd", source)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
58
apps/channels/tests/test_epg_match_apply.py
Normal file
58
apps/channels/tests/test_epg_match_apply.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Tests for applying EPG auto-match results to channels."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.channels.epg_matching import apply_matched_epg_to_channels
|
||||||
|
from apps.channels.models import Channel
|
||||||
|
from apps.epg.models import EPGData, EPGSource
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyMatchedEpgToChannelsTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.source = EPGSource.objects.create(
|
||||||
|
name="XML EPG",
|
||||||
|
source_type="xmltv",
|
||||||
|
url="http://example.com/epg.xml",
|
||||||
|
)
|
||||||
|
self.epg_one = EPGData.objects.create(
|
||||||
|
tvg_id="ch.one",
|
||||||
|
name="Channel One",
|
||||||
|
epg_source=self.source,
|
||||||
|
)
|
||||||
|
self.epg_two = EPGData.objects.create(
|
||||||
|
tvg_id="ch.two",
|
||||||
|
name="Channel Two",
|
||||||
|
epg_source=self.source,
|
||||||
|
)
|
||||||
|
self.channel = Channel.objects.create(
|
||||||
|
channel_number=1,
|
||||||
|
name="Channel One",
|
||||||
|
tvg_id="ch.one",
|
||||||
|
epg_data=self.epg_one,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks.parse_programs_for_tvg_id.delay")
|
||||||
|
def test_skips_unchanged_assignment(self, mock_delay):
|
||||||
|
changed = apply_matched_epg_to_channels(
|
||||||
|
[{"id": self.channel.id, "epg_data_id": self.epg_one.id}]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
mock_delay.assert_not_called()
|
||||||
|
self.channel.refresh_from_db()
|
||||||
|
self.assertEqual(self.channel.epg_data_id, self.epg_one.id)
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks.parse_programs_for_tvg_id.delay")
|
||||||
|
def test_updates_changed_assignment_and_dispatches_parse(self, mock_delay):
|
||||||
|
changed = apply_matched_epg_to_channels(
|
||||||
|
[{"id": self.channel.id, "epg_data_id": self.epg_two.id}]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
changed,
|
||||||
|
[{"channel_id": self.channel.id, "epg_data_id": self.epg_two.id}],
|
||||||
|
)
|
||||||
|
mock_delay.assert_called_once_with(self.epg_two.id)
|
||||||
|
self.channel.refresh_from_db()
|
||||||
|
self.assertEqual(self.channel.epg_data_id, self.epg_two.id)
|
||||||
117
apps/channels/tests/test_epg_name_normalize.py
Normal file
117
apps/channels/tests/test_epg_name_normalize.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""Tests for EPG channel name normalization (prefix/suffix/custom ignore rules)."""
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.channels.epg_matching import (
|
||||||
|
build_epg_tvg_id_index,
|
||||||
|
clear_normalize_settings_cache,
|
||||||
|
normalize_name,
|
||||||
|
)
|
||||||
|
from core.models import CoreSettings, EPG_SETTINGS_KEY
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizeNameSettingsTest(TestCase):
|
||||||
|
def _set_epg_settings(self, **kwargs):
|
||||||
|
obj, _ = CoreSettings.objects.get_or_create(
|
||||||
|
key=EPG_SETTINGS_KEY,
|
||||||
|
defaults={"name": "EPG Settings", "value": {}},
|
||||||
|
)
|
||||||
|
current = obj.value if isinstance(obj.value, dict) else {}
|
||||||
|
current.update(kwargs)
|
||||||
|
obj.value = current
|
||||||
|
obj.save()
|
||||||
|
clear_normalize_settings_cache()
|
||||||
|
|
||||||
|
def test_default_mode_does_not_apply_ignore_lists(self):
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="default",
|
||||||
|
epg_match_ignore_prefixes=["HD:"],
|
||||||
|
epg_match_ignore_suffixes=[" 4K"],
|
||||||
|
epg_match_ignore_custom=["Plus"],
|
||||||
|
)
|
||||||
|
result_default = normalize_name("HD:HBO Plus East 4K")
|
||||||
|
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="advanced",
|
||||||
|
epg_match_ignore_prefixes=["HD:"],
|
||||||
|
epg_match_ignore_suffixes=[" 4K"],
|
||||||
|
epg_match_ignore_custom=["Plus"],
|
||||||
|
)
|
||||||
|
result_advanced = normalize_name("HD:HBO Plus East 4K")
|
||||||
|
|
||||||
|
self.assertNotEqual(result_default, result_advanced)
|
||||||
|
self.assertEqual(result_advanced, "hbo")
|
||||||
|
|
||||||
|
def test_advanced_mode_strips_prefix(self):
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="advanced",
|
||||||
|
epg_match_ignore_prefixes=["HD:"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
normalize_name("HD:ABC 7 (WXYZ) - Springfield"),
|
||||||
|
"abc 7 springfield wxyz",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_advanced_mode_strips_suffix(self):
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="advanced",
|
||||||
|
epg_match_ignore_suffixes=[" 4K"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
normalize_name("NBC 5 (KABC) - Metro 4K"),
|
||||||
|
"nbc 5 metro kabc",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_advanced_mode_removes_custom_strings(self):
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="advanced",
|
||||||
|
epg_match_ignore_custom=["Plus"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
normalize_name("HBO Plus East"),
|
||||||
|
"hbo",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_advanced_mode_applies_prefix_suffix_and_custom_in_order(self):
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="advanced",
|
||||||
|
epg_match_ignore_prefixes=["Sling:"],
|
||||||
|
epg_match_ignore_suffixes=[" HD"],
|
||||||
|
epg_match_ignore_custom=["Plus"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
normalize_name("Sling:HBO Plus East HD"),
|
||||||
|
"hbo",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_first_matching_prefix_is_removed(self):
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="advanced",
|
||||||
|
epg_match_ignore_prefixes=["HD:", "SD:"],
|
||||||
|
)
|
||||||
|
self.assertEqual(normalize_name("HD:SD:Channel 5"), "sdchannel 5")
|
||||||
|
|
||||||
|
def test_call_sign_preserved_from_original_name(self):
|
||||||
|
self._set_epg_settings(epg_match_mode="default")
|
||||||
|
self.assertEqual(
|
||||||
|
normalize_name("NBC 5 (KABC) - Metro"),
|
||||||
|
"nbc 5 metro kabc",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tvg_id_index_prefers_first_entry_when_catalog_sorted_by_priority(self):
|
||||||
|
# Catalog from build_epg_matching_catalog() is highest-priority first.
|
||||||
|
epg_data = [
|
||||||
|
{"id": 2, "tvg_id": "abc.us", "epg_source_priority": 50, "name": "High"},
|
||||||
|
{"id": 1, "tvg_id": "abc.us", "epg_source_priority": 10, "name": "Low"},
|
||||||
|
]
|
||||||
|
index = build_epg_tvg_id_index(epg_data)
|
||||||
|
self.assertEqual(index["abc.us"]["id"], 2)
|
||||||
|
|
||||||
|
def test_settings_cache_refresh_picks_up_new_rules(self):
|
||||||
|
self._set_epg_settings(epg_match_mode="default")
|
||||||
|
self.assertEqual(normalize_name("HD:ABC"), "hdabc")
|
||||||
|
|
||||||
|
self._set_epg_settings(
|
||||||
|
epg_match_mode="advanced",
|
||||||
|
epg_match_ignore_prefixes=["HD:"],
|
||||||
|
)
|
||||||
|
self.assertEqual(normalize_name("HD:ABC"), "abc")
|
||||||
173
apps/channels/tests/test_get_stream_assignment.py
Normal file
173
apps/channels/tests/test_get_stream_assignment.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""Tests for Channel.get_stream() assignment reuse and stale cleanup."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.channels.models import Channel, ChannelStream, Stream
|
||||||
|
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||||
|
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||||
|
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAssignmentRedis:
|
||||||
|
"""In-memory Redis for channel_stream assignment tests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._strings = {}
|
||||||
|
self._hashes = {}
|
||||||
|
|
||||||
|
def _decode(self, value):
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.decode()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
value = self._strings.get(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return str(value).encode()
|
||||||
|
return str(value).encode()
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self._strings[key] = value
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
self._strings.pop(key, None)
|
||||||
|
self._hashes.pop(key, None)
|
||||||
|
|
||||||
|
def exists(self, key):
|
||||||
|
return key in self._strings or key in self._hashes
|
||||||
|
|
||||||
|
def hget(self, key, field):
|
||||||
|
return self._hashes.get(key, {}).get(field)
|
||||||
|
|
||||||
|
def hset(self, key, mapping=None, **kwargs):
|
||||||
|
bucket = self._hashes.setdefault(key, {})
|
||||||
|
if mapping:
|
||||||
|
bucket.update(mapping)
|
||||||
|
bucket.update(kwargs)
|
||||||
|
|
||||||
|
def incr(self, key):
|
||||||
|
current = int(self._decode(self.get(key)) or 0)
|
||||||
|
current += 1
|
||||||
|
self._strings[key] = current
|
||||||
|
return current
|
||||||
|
|
||||||
|
def decr(self, key):
|
||||||
|
current = int(self._decode(self.get(key)) or 0)
|
||||||
|
current -= 1
|
||||||
|
self._strings[key] = current
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelGetStreamAssignmentTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.redis = FakeAssignmentRedis()
|
||||||
|
self.account = M3UAccount.objects.create(
|
||||||
|
name="assignment-test",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
self.profile = M3UAccountProfile.objects.get(
|
||||||
|
m3u_account=self.account, is_default=True
|
||||||
|
)
|
||||||
|
self.profile.max_streams = 2
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.stream = Stream.objects.create(
|
||||||
|
name="Test Stream",
|
||||||
|
url="http://example.com/live/user/pass/1.ts",
|
||||||
|
m3u_account=self.account,
|
||||||
|
)
|
||||||
|
self.channel = Channel.objects.create(channel_number=501, name="Assignment Ch")
|
||||||
|
ChannelStream.objects.create(channel=self.channel, stream=self.stream, order=0)
|
||||||
|
|
||||||
|
self.metadata_key = RedisKeys.channel_metadata(str(self.channel.uuid))
|
||||||
|
|
||||||
|
def _seed_assignment(self):
|
||||||
|
self.redis.set(f"channel_stream:{self.channel.id}", self.stream.id)
|
||||||
|
self.redis.set(f"stream_profile:{self.stream.id}", self.profile.id)
|
||||||
|
|
||||||
|
@patch("apps.channels.models.RedisClient.get_client")
|
||||||
|
@patch("apps.channels.models.reserve_profile_slot")
|
||||||
|
def test_reuses_assignment_when_proxy_active(
|
||||||
|
self, mock_reserve, mock_get_client
|
||||||
|
):
|
||||||
|
mock_get_client.return_value = self.redis
|
||||||
|
self._seed_assignment()
|
||||||
|
self.redis.hset(
|
||||||
|
self.metadata_key,
|
||||||
|
{ChannelMetadataField.STATE: ChannelState.ACTIVE},
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
|
||||||
|
|
||||||
|
self.assertEqual(stream_id, self.stream.id)
|
||||||
|
self.assertEqual(profile_id, self.profile.id)
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertFalse(slot_reserved)
|
||||||
|
mock_reserve.assert_not_called()
|
||||||
|
|
||||||
|
@patch("apps.channels.models.RedisClient.get_client")
|
||||||
|
@patch("apps.channels.models.reserve_profile_slot")
|
||||||
|
def test_reuses_assignment_during_init_before_metadata(
|
||||||
|
self, mock_reserve, mock_get_client
|
||||||
|
):
|
||||||
|
mock_get_client.return_value = self.redis
|
||||||
|
self._seed_assignment()
|
||||||
|
|
||||||
|
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
|
||||||
|
|
||||||
|
self.assertEqual(stream_id, self.stream.id)
|
||||||
|
self.assertEqual(profile_id, self.profile.id)
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertFalse(slot_reserved)
|
||||||
|
mock_reserve.assert_not_called()
|
||||||
|
|
||||||
|
@patch("apps.channels.models.RedisClient.get_client")
|
||||||
|
@patch("apps.channels.models.release_profile_slot")
|
||||||
|
@patch("apps.channels.models.reserve_profile_slot")
|
||||||
|
def test_releases_stale_assignment_when_proxy_stopped(
|
||||||
|
self, mock_reserve, mock_release, mock_get_client
|
||||||
|
):
|
||||||
|
mock_get_client.return_value = self.redis
|
||||||
|
mock_reserve.return_value = (True, 1, None)
|
||||||
|
self._seed_assignment()
|
||||||
|
self.redis.hset(
|
||||||
|
self.metadata_key,
|
||||||
|
{ChannelMetadataField.STATE: ChannelState.STOPPED},
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
|
||||||
|
|
||||||
|
mock_release.assert_called_once_with(self.profile.id, self.redis)
|
||||||
|
mock_reserve.assert_called_once()
|
||||||
|
self.assertEqual(stream_id, self.stream.id)
|
||||||
|
self.assertEqual(profile_id, self.profile.id)
|
||||||
|
self.assertTrue(slot_reserved)
|
||||||
|
|
||||||
|
@patch("apps.channels.models.RedisClient.get_client")
|
||||||
|
def test_stream_assignment_is_reusable_during_init_pending(self, mock_get_client):
|
||||||
|
mock_get_client.return_value = self.redis
|
||||||
|
self._seed_assignment()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
self.channel._stream_assignment_is_reusable(self.redis, self.stream.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.channels.models.RedisClient.get_client")
|
||||||
|
def test_stream_assignment_not_reusable_when_stopped(self, mock_get_client):
|
||||||
|
mock_get_client.return_value = self.redis
|
||||||
|
self._seed_assignment()
|
||||||
|
self.redis.hset(
|
||||||
|
self.metadata_key,
|
||||||
|
{ChannelMetadataField.STATE: ChannelState.STOPPED},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
self.channel._stream_assignment_is_reusable(self.redis, self.stream.id)
|
||||||
|
)
|
||||||
81
apps/channels/tests/test_logo_path_jail.py
Normal file
81
apps/channels/tests/test_logo_path_jail.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Security-focused tests for local logo path jailing and related helpers."""
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
|
from apps.channels.api_views import LogoViewSet
|
||||||
|
from apps.channels.models import Logo
|
||||||
|
from core.utils import resolve_safe_local_data_path, safe_upload_path
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveSafeLocalDataPathTests(TestCase):
|
||||||
|
def test_accepts_file_under_logos_root(self):
|
||||||
|
logos_root = Path("/data/logos")
|
||||||
|
logos_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f"_jail_test_{uuid.uuid4().hex}.png"
|
||||||
|
target = logos_root / name
|
||||||
|
target.write_bytes(b"x")
|
||||||
|
try:
|
||||||
|
resolved = resolve_safe_local_data_path(
|
||||||
|
str(target), allowed_roots=("/data/logos",)
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved, str(target.resolve()))
|
||||||
|
finally:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_rejects_path_traversal_outside_root(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
resolve_safe_local_data_path(
|
||||||
|
"/data/../etc/passwd", allowed_roots=("/data/logos",)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_non_data_prefix(self):
|
||||||
|
self.assertIsNone(resolve_safe_local_data_path("/etc/passwd"))
|
||||||
|
|
||||||
|
def test_safe_upload_path_strips_directory_components(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = safe_upload_path("../../evil.xml", tmp)
|
||||||
|
self.assertEqual(Path(path).name, "evil.xml")
|
||||||
|
self.assertTrue(Path(path).is_relative_to(Path(tmp).resolve()))
|
||||||
|
|
||||||
|
|
||||||
|
class LogoCachePathJailTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user, _ = User.objects.get_or_create(
|
||||||
|
username="logo_cache_admin",
|
||||||
|
defaults={"user_level": User.UserLevel.ADMIN},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_traversal_url_returns_404(self):
|
||||||
|
logo = Logo.objects.create(
|
||||||
|
name="Traversal",
|
||||||
|
url="/data/../etc/passwd",
|
||||||
|
)
|
||||||
|
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
view = LogoViewSet.as_view({"get": "cache"})
|
||||||
|
response = view(request, pk=logo.id)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_valid_local_logo_is_served(self):
|
||||||
|
logos_root = Path("/data/logos")
|
||||||
|
logos_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f"_jail_serve_{uuid.uuid4().hex}.png"
|
||||||
|
file_path = logos_root / name
|
||||||
|
file_path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||||
|
logo = Logo.objects.create(name="Ok", url=str(file_path))
|
||||||
|
try:
|
||||||
|
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
|
||||||
|
view = LogoViewSet.as_view({"get": "cache"})
|
||||||
|
response = view(request, pk=logo.id)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
b"".join(response.streaming_content)
|
||||||
|
finally:
|
||||||
|
file_path.unlink(missing_ok=True)
|
||||||
|
|
@ -8,6 +8,7 @@ Covers:
|
||||||
5. Recovery skip-list: "recording" status NOT in terminal skip list
|
5. Recovery skip-list: "recording" status NOT in terminal skip list
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import datetime as dt
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
@ -17,6 +18,15 @@ from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
from apps.channels.models import Channel, Recording
|
from apps.channels.models import Channel, Recording
|
||||||
|
|
||||||
|
# Fixed wall time for collision tests: 10:30 avoids _2 appearing inside
|
||||||
|
# %Y%m%d_%H%M%S timestamps (e.g. hour 20 produces ..._205331 which contains "_2").
|
||||||
|
COLLISION_TEST_START = timezone.make_aware(dt.datetime(2026, 1, 15, 10, 30, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _path_has_collision_suffix(path, counter):
|
||||||
|
"""True when the MKV basename ends with _<counter>.mkv (not timestamp digits)."""
|
||||||
|
return path.endswith(f"_{counter}.mkv")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
|
|
@ -58,9 +68,9 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
"""_build_output_paths must increment the filename counter when
|
"""_build_output_paths must increment the filename counter when
|
||||||
EITHER the .mkv OR the .ts file already exists with size > 0."""
|
EITHER the .mkv OR the .ts file already exists with size > 0."""
|
||||||
|
|
||||||
def _call(self, channel, program, start, end):
|
def _call(self, channel, program, start, end, recording_id=1):
|
||||||
from apps.channels.tasks import _build_output_paths
|
from apps.channels.tasks import _build_output_paths
|
||||||
return _build_output_paths(channel, program, start, end)
|
return _build_output_paths(channel, program, start, end, recording_id)
|
||||||
|
|
||||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||||
return_value="TV/{show}/{start}.mkv")
|
return_value="TV/{show}/{start}.mkv")
|
||||||
|
|
@ -71,7 +81,7 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
ch = MagicMock(name="TestCh")
|
ch = MagicMock(name="TestCh")
|
||||||
ch.name = "TestCh"
|
ch.name = "TestCh"
|
||||||
program = {"title": "My Show"}
|
program = {"title": "My Show"}
|
||||||
now = timezone.now()
|
now = COLLISION_TEST_START
|
||||||
|
|
||||||
def mock_stat(path):
|
def mock_stat(path):
|
||||||
raise OSError("No such file")
|
raise OSError("No such file")
|
||||||
|
|
@ -80,8 +90,7 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
patch("os.makedirs"):
|
patch("os.makedirs"):
|
||||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||||
|
|
||||||
# Should NOT have a _2 suffix
|
self.assertFalse(_path_has_collision_suffix(final, 2))
|
||||||
self.assertNotIn("_2", final)
|
|
||||||
self.assertTrue(final.endswith(".mkv"))
|
self.assertTrue(final.endswith(".mkv"))
|
||||||
|
|
||||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||||
|
|
@ -89,32 +98,31 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
|
||||||
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
|
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):
|
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.
|
"""With the HLS pipeline, collision avoidance keys off the final MKV only.
|
||||||
The old code only checked MKV size, so it would reuse the path.
|
A 0-byte MKV placeholder is treated as unoccupied even if legacy TS
|
||||||
The fix also checks TS, so it must increment."""
|
segments exist elsewhere on disk."""
|
||||||
ch = MagicMock(name="TestCh")
|
ch = MagicMock(name="TestCh")
|
||||||
ch.name = "TestCh"
|
ch.name = "TestCh"
|
||||||
program = {"title": "My Show"}
|
program = {"title": "My Show"}
|
||||||
now = timezone.now()
|
now = COLLISION_TEST_START
|
||||||
|
|
||||||
def mock_stat(path):
|
def mock_stat(path):
|
||||||
if "_2" in path:
|
if _path_has_collision_suffix(path, 2):
|
||||||
raise OSError("No such file")
|
raise OSError("No such file")
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
if path.endswith('.mkv'):
|
if path.endswith('.mkv'):
|
||||||
result.st_size = 0 # MKV is 0-byte placeholder
|
result.st_size = 0 # MKV is 0-byte placeholder
|
||||||
elif path.endswith('.ts'):
|
elif path.endswith('.ts'):
|
||||||
result.st_size = 5000000 # TS has real data from pre-restart
|
result.st_size = 5000000 # legacy TS data is ignored for collision
|
||||||
else:
|
else:
|
||||||
result.st_size = 0
|
result.st_size = 0
|
||||||
return result
|
return result
|
||||||
|
|
||||||
with patch("os.stat", side_effect=mock_stat), \
|
with patch("os.stat", side_effect=mock_stat), \
|
||||||
patch("os.makedirs"):
|
patch("os.makedirs"):
|
||||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
final, hls_dir, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||||
|
|
||||||
# Must have incremented to _2
|
self.assertFalse(_path_has_collision_suffix(final, 2), "HLS path builder ignores legacy TS when MKV is empty")
|
||||||
self.assertIn("_2", final, "Should increment counter when TS file has data")
|
|
||||||
|
|
||||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||||
return_value="TV/{show}/{start}.mkv")
|
return_value="TV/{show}/{start}.mkv")
|
||||||
|
|
@ -125,10 +133,10 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
ch = MagicMock(name="TestCh")
|
ch = MagicMock(name="TestCh")
|
||||||
ch.name = "TestCh"
|
ch.name = "TestCh"
|
||||||
program = {"title": "My Show"}
|
program = {"title": "My Show"}
|
||||||
now = timezone.now()
|
now = COLLISION_TEST_START
|
||||||
|
|
||||||
def mock_stat(path):
|
def mock_stat(path):
|
||||||
if "_2" in path:
|
if _path_has_collision_suffix(path, 2):
|
||||||
raise OSError("No such file")
|
raise OSError("No such file")
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
if path.endswith('.mkv'):
|
if path.endswith('.mkv'):
|
||||||
|
|
@ -141,7 +149,7 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
patch("os.makedirs"):
|
patch("os.makedirs"):
|
||||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||||
|
|
||||||
self.assertIn("_2", final, "Should increment counter when MKV file has data")
|
self.assertTrue(_path_has_collision_suffix(final, 2), "Should increment counter when MKV file has data")
|
||||||
|
|
||||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||||
return_value="TV/{show}/{start}.mkv")
|
return_value="TV/{show}/{start}.mkv")
|
||||||
|
|
@ -152,7 +160,7 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
ch = MagicMock(name="TestCh")
|
ch = MagicMock(name="TestCh")
|
||||||
ch.name = "TestCh"
|
ch.name = "TestCh"
|
||||||
program = {"title": "My Show"}
|
program = {"title": "My Show"}
|
||||||
now = timezone.now()
|
now = COLLISION_TEST_START
|
||||||
|
|
||||||
def mock_stat(path):
|
def mock_stat(path):
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
|
|
@ -163,7 +171,7 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
patch("os.makedirs"):
|
patch("os.makedirs"):
|
||||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||||
|
|
||||||
self.assertNotIn("_2", final, "Should NOT increment when all files are empty")
|
self.assertFalse(_path_has_collision_suffix(final, 2), "Should NOT increment when all files are empty")
|
||||||
|
|
||||||
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
|
||||||
return_value="TV/{show}/{start}.mkv")
|
return_value="TV/{show}/{start}.mkv")
|
||||||
|
|
@ -174,23 +182,23 @@ class CollisionAvoidanceTests(TestCase):
|
||||||
ch = MagicMock(name="TestCh")
|
ch = MagicMock(name="TestCh")
|
||||||
ch.name = "TestCh"
|
ch.name = "TestCh"
|
||||||
program = {"title": "My Show"}
|
program = {"title": "My Show"}
|
||||||
now = timezone.now()
|
now = COLLISION_TEST_START
|
||||||
|
|
||||||
def mock_stat(path):
|
def mock_stat(path):
|
||||||
if "_3" in path:
|
if _path_has_collision_suffix(path, 3):
|
||||||
raise OSError("No such file")
|
raise OSError("No such file")
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
if path.endswith('.ts'):
|
if path.endswith('.mkv'):
|
||||||
result.st_size = 5000000
|
result.st_size = 1000000 # occupied MKV at base and _2
|
||||||
else:
|
else:
|
||||||
result.st_size = 0
|
result.st_size = 0
|
||||||
return result
|
return result
|
||||||
|
|
||||||
with patch("os.stat", side_effect=mock_stat), \
|
with patch("os.stat", side_effect=mock_stat), \
|
||||||
patch("os.makedirs"):
|
patch("os.makedirs"):
|
||||||
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
final, hls_dir, fname = self._call(ch, program, now, now + timedelta(hours=1))
|
||||||
|
|
||||||
self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied")
|
self.assertTrue(_path_has_collision_suffix(final, 3), "Should increment to _3 when base and _2 MKVs are occupied")
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -442,6 +450,7 @@ class RecoverySkipListTests(TestCase):
|
||||||
"""A recording with status='recording' should be recovered, not skipped."""
|
"""A recording with status='recording' should be recovered, not skipped."""
|
||||||
mock_redis_conn = MagicMock()
|
mock_redis_conn = MagicMock()
|
||||||
mock_redis_conn.set.return_value = True # Acquire lock
|
mock_redis_conn.set.return_value = True # Acquire lock
|
||||||
|
mock_redis_conn.exists.return_value = False # No active-recording lock
|
||||||
mock_redis_cls.get_client.return_value = mock_redis_conn
|
mock_redis_cls.get_client.return_value = mock_redis_conn
|
||||||
|
|
||||||
channel = _make_channel("Recovery Test", 300)
|
channel = _make_channel("Recovery Test", 300)
|
||||||
|
|
|
||||||
193
apps/channels/tests/test_recording_playback_auth.py
Normal file
193
apps/channels/tests/test_recording_playback_auth.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Tests for DVR recording playback authentication (file/hls endpoints)."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from apps.channels.api_views import _recording_auth_query_suffix
|
||||||
|
from apps.channels.models import Channel, Recording
|
||||||
|
|
||||||
|
|
||||||
|
def _make_admin():
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
user, _ = User.objects.get_or_create(
|
||||||
|
username="recording_playback_admin",
|
||||||
|
defaults={"user_level": User.UserLevel.ADMIN},
|
||||||
|
)
|
||||||
|
user.user_level = User.UserLevel.ADMIN
|
||||||
|
user.set_password("pass")
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver"])
|
||||||
|
@patch("apps.channels.api_views.network_access_allowed", return_value=True)
|
||||||
|
class RecordingPlaybackAuthTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.channel = Channel.objects.create(channel_number=42, name="Playback Auth Channel")
|
||||||
|
self.user = _make_admin()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.tmp = tempfile.NamedTemporaryFile(suffix=".mkv", delete=False)
|
||||||
|
self.tmp.write(b"\x00" * 1024)
|
||||||
|
self.tmp.close()
|
||||||
|
self.hls_dir = tempfile.mkdtemp(prefix="dvr_playback_auth_hls_")
|
||||||
|
with open(os.path.join(self.hls_dir, "index.m3u8"), "w", encoding="utf-8") as playlist:
|
||||||
|
playlist.write("#EXTM3U\n#EXTINF:4.0,\nseg_00001.ts\n")
|
||||||
|
with open(os.path.join(self.hls_dir, "seg_00001.ts"), "wb") as segment:
|
||||||
|
segment.write(b"\x00" * 188)
|
||||||
|
now = timezone.now()
|
||||||
|
self.recording = Recording.objects.create(
|
||||||
|
channel=self.channel,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now,
|
||||||
|
custom_properties={
|
||||||
|
"status": "completed",
|
||||||
|
"file_path": self.tmp.name,
|
||||||
|
"file_name": "test.mkv",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.tmp.name):
|
||||||
|
os.unlink(self.tmp.name)
|
||||||
|
if os.path.isdir(self.hls_dir):
|
||||||
|
shutil.rmtree(self.hls_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _jwt_for(user):
|
||||||
|
return str(RefreshToken.for_user(user).access_token)
|
||||||
|
|
||||||
|
def test_file_requires_authentication(self, _mock_network):
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/channels/recordings/{self.recording.id}/file/"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_file_accepts_jwt_query_param(self, _mock_network):
|
||||||
|
token = self._jwt_for(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/channels/recordings/{self.recording.id}/file/",
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_file_redirect_to_hls_preserves_token(self, _mock_network):
|
||||||
|
pending = os.path.join(self.hls_dir, "pending.mkv")
|
||||||
|
now = timezone.now()
|
||||||
|
in_progress = Recording.objects.create(
|
||||||
|
channel=self.channel,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now,
|
||||||
|
custom_properties={
|
||||||
|
"status": "recording",
|
||||||
|
"_hls_dir": self.hls_dir,
|
||||||
|
"file_path": pending,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = self._jwt_for(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/channels/recordings/{in_progress.id}/file/",
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("token=", response["Location"])
|
||||||
|
self.assertIn("/hls/index.m3u8", response["Location"])
|
||||||
|
|
||||||
|
def test_hls_playlist_rewrites_segments_with_token_when_present(self, _mock_network):
|
||||||
|
now = timezone.now()
|
||||||
|
hls_rec = Recording.objects.create(
|
||||||
|
channel=self.channel,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now,
|
||||||
|
custom_properties={
|
||||||
|
"status": "recording",
|
||||||
|
"_hls_dir": self.hls_dir,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = self._jwt_for(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.content.decode("utf-8")
|
||||||
|
self.assertIn("token=", body)
|
||||||
|
self.assertIn("seg_00001.ts", body)
|
||||||
|
|
||||||
|
def test_hls_playlist_omits_token_when_not_in_request(self, _mock_network):
|
||||||
|
now = timezone.now()
|
||||||
|
hls_rec = Recording.objects.create(
|
||||||
|
channel=self.channel,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now,
|
||||||
|
custom_properties={
|
||||||
|
"status": "recording",
|
||||||
|
"_hls_dir": self.hls_dir,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = self._jwt_for(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.content.decode("utf-8")
|
||||||
|
self.assertIn("seg_00001.ts", body)
|
||||||
|
self.assertNotIn("token=", body)
|
||||||
|
|
||||||
|
def test_hls_segment_accepts_jwt_query_param(self, _mock_network):
|
||||||
|
now = timezone.now()
|
||||||
|
hls_rec = Recording.objects.create(
|
||||||
|
channel=self.channel,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now,
|
||||||
|
custom_properties={
|
||||||
|
"status": "recording",
|
||||||
|
"_hls_dir": self.hls_dir,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = self._jwt_for(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/channels/recordings/{hls_rec.id}/hls/seg_00001.ts",
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_hls_redirect_to_file_preserves_token(self, _mock_network):
|
||||||
|
now = timezone.now()
|
||||||
|
hls_rec = Recording.objects.create(
|
||||||
|
channel=self.channel,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now,
|
||||||
|
custom_properties={
|
||||||
|
"status": "completed",
|
||||||
|
"file_path": self.tmp.name,
|
||||||
|
"file_name": "test.mkv",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = self._jwt_for(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("token=", response["Location"])
|
||||||
|
self.assertIn("/file/", response["Location"])
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingAuthQuerySuffixTests(TestCase):
|
||||||
|
def test_empty_when_no_token(self):
|
||||||
|
request = SimpleNamespace(GET={})
|
||||||
|
self.assertEqual(_recording_auth_query_suffix(request), "")
|
||||||
|
|
||||||
|
def test_includes_token_when_present(self):
|
||||||
|
request = SimpleNamespace(GET={"token": "abc123"})
|
||||||
|
self.assertEqual(_recording_auth_query_suffix(request), "?token=abc123")
|
||||||
|
|
@ -247,6 +247,14 @@ class BasicStatsGhostClientTests(TestCase):
|
||||||
redis.scard.return_value = len(client_ids)
|
redis.scard.return_value = len(client_ids)
|
||||||
redis.smembers.return_value = client_ids
|
redis.smembers.return_value = client_ids
|
||||||
redis.hget.return_value = None # individual field lookups
|
redis.hget.return_value = None # individual field lookups
|
||||||
|
redis.hmget.return_value = [
|
||||||
|
b'VLC/3.0',
|
||||||
|
b'127.0.0.1',
|
||||||
|
b'1773500000.0',
|
||||||
|
None,
|
||||||
|
b'mpegts',
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
|
||||||
# Pipeline for remove_ghost_clients
|
# Pipeline for remove_ghost_clients
|
||||||
pipe = MagicMock()
|
pipe = MagicMock()
|
||||||
|
|
|
||||||
|
|
@ -214,11 +214,12 @@ class DoStatsUpdateTests(TestCase):
|
||||||
"""_do_stats_update must call send_websocket_update with channel_stats."""
|
"""_do_stats_update must call send_websocket_update with channel_stats."""
|
||||||
cm = self._make_client_manager()
|
cm = self._make_client_manager()
|
||||||
|
|
||||||
mock_redis = MagicMock()
|
|
||||||
mock_redis.scan.return_value = (0, [])
|
|
||||||
|
|
||||||
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update") as mock_ws, \
|
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update") as mock_ws, \
|
||||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
patch(
|
||||||
|
"apps.proxy.live_proxy.channel_status.build_live_channel_stats_data",
|
||||||
|
return_value={"channels": [], "count": 0},
|
||||||
|
), \
|
||||||
|
patch("core.utils.RedisClient.get_client", return_value=MagicMock()):
|
||||||
cm._do_stats_update()
|
cm._do_stats_update()
|
||||||
|
|
||||||
mock_ws.assert_called_once()
|
mock_ws.assert_called_once()
|
||||||
|
|
@ -231,25 +232,26 @@ class DoStatsUpdateTests(TestCase):
|
||||||
"""Redis failure must be swallowed (logged), not propagated."""
|
"""Redis failure must be swallowed (logged), not propagated."""
|
||||||
cm = self._make_client_manager()
|
cm = self._make_client_manager()
|
||||||
|
|
||||||
with patch("redis.Redis.from_url", side_effect=Exception("Redis down")):
|
with patch("core.utils.RedisClient.get_client", side_effect=Exception("Redis down")):
|
||||||
try:
|
try:
|
||||||
cm._do_stats_update()
|
cm._do_stats_update()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.fail(f"_do_stats_update raised an exception: {e}")
|
self.fail(f"_do_stats_update raised an exception: {e}")
|
||||||
|
|
||||||
def test_do_stats_update_scans_channel_client_keys(self):
|
def test_do_stats_update_uses_build_live_channel_stats_data(self):
|
||||||
"""Must scan for live:channel:*:clients pattern."""
|
"""Must build live stats via the shared builder."""
|
||||||
cm = self._make_client_manager()
|
cm = self._make_client_manager()
|
||||||
|
|
||||||
mock_redis = MagicMock()
|
mock_redis = MagicMock()
|
||||||
mock_redis.scan.return_value = (0, [])
|
|
||||||
|
|
||||||
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update"), \
|
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update"), \
|
||||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
patch(
|
||||||
|
"apps.proxy.live_proxy.channel_status.build_live_channel_stats_data",
|
||||||
|
return_value={"channels": [{"channel_id": "ch-1"}], "count": 1},
|
||||||
|
) as mock_build, \
|
||||||
|
patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||||
cm._do_stats_update()
|
cm._do_stats_update()
|
||||||
|
|
||||||
scan_call = mock_redis.scan.call_args
|
mock_build.assert_called_once_with(mock_redis)
|
||||||
self.assertIn("live:channel:*:clients", str(scan_call))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
1029
apps/channels/tests/test_ts_proxy_teardown.py
Normal file
1029
apps/channels/tests/test_ts_proxy_teardown.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,13 @@
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROVIDER_ARCHIVE_CACHE_TTL_SECONDS = 300
|
||||||
|
MAX_AUTO_PREV_DAYS = 30
|
||||||
|
|
||||||
# Bound memory/DB work per chunk for large libraries (20k+ channels).
|
# Bound memory/DB work per chunk for large libraries (20k+ channels).
|
||||||
EPG_LOGO_APPLY_BATCH_SIZE = 500
|
EPG_LOGO_APPLY_BATCH_SIZE = 500
|
||||||
EPG_LOGO_APPLY_MAX_ERRORS = 100
|
EPG_LOGO_APPLY_MAX_ERRORS = 100
|
||||||
|
|
@ -23,6 +28,100 @@ def format_channel_number(value, empty=""):
|
||||||
return int(value)
|
return int(value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def compute_provider_archive_days_capped():
|
||||||
|
"""Max ``catchup_days`` across active XC catch-up streams (capped, cached).
|
||||||
|
|
||||||
|
Cached briefly so XC XMLTV exports without an explicit ``prev_days`` do not
|
||||||
|
repeat the aggregate query on every request.
|
||||||
|
"""
|
||||||
|
def _scan():
|
||||||
|
from django.db.models import Max
|
||||||
|
|
||||||
|
from apps.channels.models import Stream
|
||||||
|
|
||||||
|
result = Stream.objects.filter(
|
||||||
|
m3u_account__account_type="XC",
|
||||||
|
m3u_account__is_active=True,
|
||||||
|
is_catchup=True,
|
||||||
|
).aggregate(max_days=Max("catchup_days"))
|
||||||
|
return min(result["max_days"] or 0, MAX_AUTO_PREV_DAYS)
|
||||||
|
|
||||||
|
return cache.get_or_set(
|
||||||
|
"channels:provider_archive_days_capped",
|
||||||
|
_scan,
|
||||||
|
PROVIDER_ARCHIVE_CACHE_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
|
||||||
|
"""Resolve ``prev_days`` for XC XMLTV and player_api EPG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP request (reads ``?prev_days=``).
|
||||||
|
user: Authenticated user (reads ``custom_properties.epg_prev_days``).
|
||||||
|
auto_detect_fallback: When True (XC XMLTV), fall back to the largest
|
||||||
|
provider archive depth. When False (per-channel EPG), return 0 so
|
||||||
|
``xc_get_epg`` can expand to each channel's ``catchup_days``.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. URL ``?prev_days=`` (explicit; 0 means no past programmes)
|
||||||
|
2. ``user.custom_properties.epg_prev_days``
|
||||||
|
3. Auto-detect (only when *auto_detect_fallback* is True)
|
||||||
|
"""
|
||||||
|
user_custom = (user.custom_properties or {}) if user else {}
|
||||||
|
url_prev = request.GET.get("prev_days")
|
||||||
|
user_prev = user_custom.get("epg_prev_days") if user_custom else None
|
||||||
|
|
||||||
|
if url_prev is not None:
|
||||||
|
try:
|
||||||
|
return max(0, min(int(url_prev), MAX_AUTO_PREV_DAYS))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
if user_prev not in (None, ""):
|
||||||
|
try:
|
||||||
|
return max(0, min(int(user_prev), MAX_AUTO_PREV_DAYS))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if auto_detect_fallback:
|
||||||
|
return compute_provider_archive_days_capped()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_catchup_enabled(*, user=None):
|
||||||
|
"""Return whether catch-up / timeshift is allowed.
|
||||||
|
|
||||||
|
When a *user* is supplied, their ``custom_properties.catchup_enabled``
|
||||||
|
(default True) is checked first (no DB). System
|
||||||
|
``system_settings.catchup_enabled`` (default True, Redis-cached) can
|
||||||
|
disable catch-up for everyone. Both flags are JSON booleans.
|
||||||
|
"""
|
||||||
|
from core.models import CoreSettings
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
props = getattr(user, "custom_properties", None) or {}
|
||||||
|
if props.get("catchup_enabled") is False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return CoreSettings.get_catchup_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_catchup_streams(channel):
|
||||||
|
"""Active catch-up streams for a channel, in ``channelstream`` order.
|
||||||
|
|
||||||
|
Inactive M3U accounts are excluded, matching live dispatch.
|
||||||
|
"""
|
||||||
|
if not getattr(channel, "is_catchup", False):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return list(
|
||||||
|
channel.streams.filter(is_catchup=True, m3u_account__is_active=True)
|
||||||
|
.order_by("channelstream__order")
|
||||||
|
.select_related("m3u_account")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def increment_stream_count(account):
|
def increment_stream_count(account):
|
||||||
with lock:
|
with lock:
|
||||||
current_usage = active_streams_map.get(account.id, 0)
|
current_usage = active_streams_map.get(account.id, 0)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ from .serializers import (
|
||||||
DeliveryLogSerializer,
|
DeliveryLogSerializer,
|
||||||
)
|
)
|
||||||
from apps.accounts.permissions import (
|
from apps.accounts.permissions import (
|
||||||
Authenticated,
|
|
||||||
permission_classes_by_action,
|
|
||||||
IsAdmin,
|
IsAdmin,
|
||||||
)
|
)
|
||||||
from .handlers.webhook import WebhookHandler
|
from .handlers.webhook import WebhookHandler
|
||||||
|
|
@ -25,12 +23,8 @@ class IntegrationViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = IntegrationSerializer
|
serializer_class = IntegrationSerializer
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
try:
|
# Integrations expose webhook URLs / script paths in config; admin only.
|
||||||
perms = permission_classes_by_action[self.action]
|
return [IsAdmin()]
|
||||||
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")
|
@action(detail=True, methods=["get"], url_path="subscriptions")
|
||||||
def list_subscriptions(self, request, pk=None):
|
def list_subscriptions(self, request, pk=None):
|
||||||
|
|
|
||||||
|
|
@ -35,43 +35,56 @@ def _empty_subscription_chain():
|
||||||
|
|
||||||
|
|
||||||
class TriggerEventDispatchTests(SimpleTestCase):
|
class TriggerEventDispatchTests(SimpleTestCase):
|
||||||
def _run_trigger_event(self, plugins, event_name, payload):
|
def _run_trigger_event(self, handlers, event_name, payload, enabled_keys=None):
|
||||||
pm = MagicMock()
|
pm = MagicMock()
|
||||||
pm.list_plugins.return_value = plugins
|
pm.iter_actions_for_event.return_value = handlers
|
||||||
|
if enabled_keys is None:
|
||||||
|
enabled_keys = [key for key, _ in handlers]
|
||||||
|
|
||||||
|
enabled_qs = MagicMock()
|
||||||
|
enabled_qs.values_list.return_value = enabled_keys
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"apps.connect.utils.PluginManager.get", return_value=pm
|
"apps.connect.utils.PluginManager.get", return_value=pm
|
||||||
), patch(
|
), patch(
|
||||||
"apps.connect.utils.EventSubscription.objects.filter",
|
"apps.connect.utils.EventSubscription.objects.filter",
|
||||||
return_value=_empty_subscription_chain(),
|
return_value=_empty_subscription_chain(),
|
||||||
):
|
), patch(
|
||||||
|
"apps.plugins.models.PluginConfig"
|
||||||
|
) as mock_cfg:
|
||||||
|
mock_cfg.objects.filter.return_value = enabled_qs
|
||||||
from apps.connect.utils import trigger_event
|
from apps.connect.utils import trigger_event
|
||||||
|
|
||||||
trigger_event(event_name, payload)
|
trigger_event(event_name, payload)
|
||||||
return pm
|
return pm
|
||||||
|
|
||||||
def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self):
|
def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self):
|
||||||
"""The original bug: AttributeError on a disabled plugin's debug log
|
"""Enabled handlers still run when other plugins are disabled in DB."""
|
||||||
aborted the loop, so any plugin iterated AFTER a disabled one never
|
handlers = [
|
||||||
received events."""
|
("enabled-plugin", "on_event"),
|
||||||
plugins = [
|
|
||||||
{
|
|
||||||
"key": "disabled-plugin",
|
|
||||||
"name": "Disabled Plugin",
|
|
||||||
"enabled": False,
|
|
||||||
"actions": [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "enabled-plugin",
|
|
||||||
"name": "Enabled Plugin",
|
|
||||||
"enabled": True,
|
|
||||||
"actions": [
|
|
||||||
{"id": "on_event", "events": ["channel_start"]},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
pm = self._run_trigger_event(
|
pm = self._run_trigger_event(
|
||||||
plugins, "channel_start", {"channel_name": "TEST"}
|
handlers, "channel_start", {"channel_name": "TEST"}
|
||||||
|
)
|
||||||
|
|
||||||
|
pm.run_action.assert_called_once_with(
|
||||||
|
"enabled-plugin",
|
||||||
|
"on_event",
|
||||||
|
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_skips_handlers_for_disabled_plugins(self):
|
||||||
|
handlers = [
|
||||||
|
("disabled-plugin", "on_event"),
|
||||||
|
("enabled-plugin", "on_event"),
|
||||||
|
]
|
||||||
|
|
||||||
|
pm = self._run_trigger_event(
|
||||||
|
handlers,
|
||||||
|
"channel_start",
|
||||||
|
{"channel_name": "TEST"},
|
||||||
|
enabled_keys=["enabled-plugin"],
|
||||||
)
|
)
|
||||||
|
|
||||||
pm.run_action.assert_called_once_with(
|
pm.run_action.assert_called_once_with(
|
||||||
|
|
@ -81,22 +94,64 @@ class TriggerEventDispatchTests(SimpleTestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_action_without_matching_event_is_not_dispatched(self):
|
def test_action_without_matching_event_is_not_dispatched(self):
|
||||||
"""Sanity check: actions whose `events` list doesn't include the
|
"""When no handlers are registered for the event, run_action is not called."""
|
||||||
fired event, or which have no `events` key at all, are skipped."""
|
|
||||||
plugins = [
|
|
||||||
{
|
|
||||||
"key": "plugin",
|
|
||||||
"name": "Plugin",
|
|
||||||
"enabled": True,
|
|
||||||
"actions": [
|
|
||||||
{"id": "on_event", "events": ["channel_stop"]},
|
|
||||||
{"id": "manual_button"}, # no events key
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
pm = self._run_trigger_event(
|
pm = self._run_trigger_event(
|
||||||
plugins, "channel_start", {"channel_name": "TEST"}
|
[], "channel_start", {"channel_name": "TEST"}
|
||||||
)
|
)
|
||||||
|
|
||||||
pm.run_action.assert_not_called()
|
pm.run_action.assert_not_called()
|
||||||
|
|
||||||
|
def test_no_plugin_config_query_when_no_handlers(self):
|
||||||
|
pm = MagicMock()
|
||||||
|
pm.iter_actions_for_event.return_value = []
|
||||||
|
with patch(
|
||||||
|
"apps.connect.utils.PluginManager.get", return_value=pm
|
||||||
|
), patch(
|
||||||
|
"apps.connect.utils.EventSubscription.objects.filter",
|
||||||
|
return_value=_empty_subscription_chain(),
|
||||||
|
), patch(
|
||||||
|
"apps.plugins.models.PluginConfig"
|
||||||
|
) as mock_cfg:
|
||||||
|
from apps.connect.utils import trigger_event
|
||||||
|
|
||||||
|
trigger_event("channel_start", {"channel_name": "TEST"})
|
||||||
|
|
||||||
|
mock_cfg.objects.filter.assert_not_called()
|
||||||
|
|
||||||
|
def test_plugin_action_failure_does_not_block_sibling_handlers(self):
|
||||||
|
handlers = [
|
||||||
|
("failing-plugin", "on_event"),
|
||||||
|
("working-plugin", "on_event"),
|
||||||
|
]
|
||||||
|
|
||||||
|
pm = MagicMock()
|
||||||
|
pm.iter_actions_for_event.return_value = handlers
|
||||||
|
pm.run_action.side_effect = [RuntimeError("boom"), {"status": "ok"}]
|
||||||
|
|
||||||
|
enabled_qs = MagicMock()
|
||||||
|
enabled_qs.values_list.return_value = ["failing-plugin", "working-plugin"]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.connect.utils.PluginManager.get", return_value=pm
|
||||||
|
), patch(
|
||||||
|
"apps.connect.utils.EventSubscription.objects.filter",
|
||||||
|
return_value=_empty_subscription_chain(),
|
||||||
|
), patch(
|
||||||
|
"apps.plugins.models.PluginConfig"
|
||||||
|
) as mock_cfg:
|
||||||
|
mock_cfg.objects.filter.return_value = enabled_qs
|
||||||
|
from apps.connect.utils import trigger_event
|
||||||
|
|
||||||
|
trigger_event("channel_start", {"channel_name": "TEST"})
|
||||||
|
|
||||||
|
self.assertEqual(pm.run_action.call_count, 2)
|
||||||
|
pm.run_action.assert_any_call(
|
||||||
|
"failing-plugin",
|
||||||
|
"on_event",
|
||||||
|
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||||
|
)
|
||||||
|
pm.run_action.assert_any_call(
|
||||||
|
"working-plugin",
|
||||||
|
"on_event",
|
||||||
|
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# connect/utils.py
|
# connect/utils.py
|
||||||
import logging, json
|
import logging
|
||||||
from django.template import Template, Context
|
from django.template import Template, Context
|
||||||
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
|
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
|
||||||
from .handlers.webhook import WebhookHandler
|
from .handlers.webhook import WebhookHandler
|
||||||
|
|
@ -93,24 +93,45 @@ def trigger_event(event_name, payload):
|
||||||
)
|
)
|
||||||
|
|
||||||
pm = PluginManager.get()
|
pm = PluginManager.get()
|
||||||
pm.discover_plugins(sync_db=False, use_cache=True)
|
pm.discover_plugins(sync_db=False, use_cache=True, release_connections=False)
|
||||||
plugins = pm.list_plugins()
|
handlers = list(pm.iter_actions_for_event(event_name))
|
||||||
|
if not handlers:
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
|
from apps.plugins.models import PluginConfig
|
||||||
for plugin in plugins:
|
|
||||||
if not plugin["enabled"]:
|
handler_keys = {key for key, _ in handlers}
|
||||||
logger.debug(f"Skipping disabled plugin id={plugin['key']} name={plugin['name']}")
|
enabled_keys = set(
|
||||||
|
PluginConfig.objects.filter(enabled=True, key__in=handler_keys).values_list(
|
||||||
|
"key", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Dispatching event '%s' to %d plugin action(s) (%d enabled)",
|
||||||
|
event_name,
|
||||||
|
len(handlers),
|
||||||
|
len(enabled_keys),
|
||||||
|
)
|
||||||
|
params = {"event": event_name, "payload": payload}
|
||||||
|
for key, action_id in handlers:
|
||||||
|
if key not in enabled_keys:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping disabled plugin id=%s for event '%s'", key, event_name
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
logger.debug(
|
||||||
logger.debug(json.dumps(plugin))
|
"Triggering plugin action for event '%s' on plugin id=%s action=%s",
|
||||||
for action in plugin["actions"]:
|
event_name,
|
||||||
if "events" in action and event_name in action["events"]:
|
key,
|
||||||
key = plugin["key"]
|
action_id,
|
||||||
params = {"event": event_name, "payload": payload}
|
)
|
||||||
action_name = action.get("label") or action.get("id")
|
try:
|
||||||
action_id = action.get("id")
|
pm.run_action(key, action_id, params)
|
||||||
logger.debug(
|
except Exception:
|
||||||
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
|
logger.exception(
|
||||||
)
|
"Plugin action failed for event '%s' on plugin id=%s action=%s",
|
||||||
if action_id:
|
event_name,
|
||||||
pm.run_action(key, action_id, params)
|
key,
|
||||||
|
action_id,
|
||||||
|
)
|
||||||
|
|
|
||||||
0
apps/dashboard/tests/__init__.py
Normal file
0
apps/dashboard/tests/__init__.py
Normal file
|
|
@ -1,4 +1,3 @@
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -8,6 +7,10 @@ from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from apps.epg.sd_api import (
|
||||||
|
SchedulesDirectPosterMixin,
|
||||||
|
SchedulesDirectSourceMixin,
|
||||||
|
)
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
|
@ -32,6 +35,7 @@ from apps.accounts.permissions import (
|
||||||
permission_classes_by_action,
|
permission_classes_by_action,
|
||||||
permission_classes_by_method,
|
permission_classes_by_method,
|
||||||
)
|
)
|
||||||
|
from core.utils import safe_upload_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -39,7 +43,7 @@ logger = logging.getLogger(__name__)
|
||||||
# ─────────────────────────────
|
# ─────────────────────────────
|
||||||
# 1) EPG Source API (CRUD)
|
# 1) EPG Source API (CRUD)
|
||||||
# ─────────────────────────────
|
# ─────────────────────────────
|
||||||
class EPGSourceViewSet(viewsets.ModelViewSet):
|
class EPGSourceViewSet(SchedulesDirectSourceMixin, viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
API endpoint that allows EPG sources to be viewed or edited.
|
API endpoint that allows EPG sources to be viewed or edited.
|
||||||
"""
|
"""
|
||||||
|
|
@ -50,6 +54,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = EPGSourceSerializer
|
serializer_class = EPGSourceSerializer
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
|
if self.action == "upload":
|
||||||
|
return [IsAdmin()]
|
||||||
try:
|
try:
|
||||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|
@ -57,15 +63,13 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
||||||
if self.request.method == 'GET':
|
if self.request.method == 'GET':
|
||||||
return [IsStandardUser()]
|
return [IsStandardUser()]
|
||||||
return [IsAdmin()]
|
return [IsAdmin()]
|
||||||
return [Authenticated()]
|
return [IsAdmin()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
from django.db.models import Exists, OuterRef
|
from django.db.models import Exists, OuterRef
|
||||||
from apps.channels.models import Channel
|
from apps.channels.models import Channel
|
||||||
return EPGSource.objects.select_related(
|
return EPGSource.objects.select_related(
|
||||||
"refresh_task__crontab", "refresh_task__interval"
|
"refresh_task__crontab", "refresh_task__interval"
|
||||||
).defer(
|
|
||||||
'programme_index'
|
|
||||||
).annotate(
|
).annotate(
|
||||||
has_channels=Exists(
|
has_channels=Exists(
|
||||||
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
|
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
|
||||||
|
|
@ -84,8 +88,12 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
||||||
)
|
)
|
||||||
|
|
||||||
file = request.FILES["file"]
|
file = request.FILES["file"]
|
||||||
file_name = file.name
|
try:
|
||||||
file_path = os.path.join("/data/uploads/epgs", file_name)
|
file_path = safe_upload_path(file.name, "/data/uploads/epgs")
|
||||||
|
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(os.path.dirname(file_path), exist_ok=True)
|
||||||
with open(file_path, "wb+") as destination:
|
with open(file_path, "wb+") as destination:
|
||||||
|
|
@ -120,355 +128,20 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
||||||
return super().partial_update(request, *args, **kwargs)
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _sd_authenticate(self, source):
|
|
||||||
"""
|
|
||||||
Authenticate with Schedules Direct using stored credentials.
|
|
||||||
Returns (token, None) on success or (None, Response) on failure.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import requests as http_requests
|
|
||||||
from apps.epg.tasks import SD_BASE_URL
|
|
||||||
from version import __version__ as dispatcharr_version
|
|
||||||
|
|
||||||
username = (source.username or '').strip()
|
|
||||||
password = (source.password or '').strip()
|
|
||||||
if not username or not password:
|
|
||||||
return None, Response(
|
|
||||||
{"error": "Username and password are required."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
|
||||||
try:
|
|
||||||
auth_response = http_requests.post(
|
|
||||||
f"{SD_BASE_URL}/token",
|
|
||||||
json={'username': username, 'password': sha1_password},
|
|
||||||
headers={'Content-Type': 'application/json', 'User-Agent': f'Dispatcharr/{dispatcharr_version}'},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
auth_response.raise_for_status()
|
|
||||||
token = auth_response.json().get('token')
|
|
||||||
if not token:
|
|
||||||
return None, Response(
|
|
||||||
{"error": "Authentication failed. Check your credentials."},
|
|
||||||
status=status.HTTP_401_UNAUTHORIZED
|
|
||||||
)
|
|
||||||
return token, None
|
|
||||||
except http_requests.exceptions.RequestException as e:
|
|
||||||
return None, Response(
|
|
||||||
{"error": f"Authentication failed: {str(e)}"},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_sd_reset_at(self, source):
|
|
||||||
"""Retrieve stored reset timestamp from EPGSource model field."""
|
|
||||||
reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at')
|
|
||||||
return reset_at_str
|
|
||||||
|
|
||||||
def _get_sd_changes_remaining(self, source):
|
|
||||||
"""
|
|
||||||
Retrieve stored changesRemaining from EPGSource model field.
|
|
||||||
If a reset timestamp exists and has passed (midnight UTC), clears the
|
|
||||||
lockout automatically so the user can make adds again.
|
|
||||||
"""
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
cp = source.custom_properties or {}
|
|
||||||
changes_remaining = cp.get('sd_changes_remaining')
|
|
||||||
reset_at_str = cp.get('sd_changes_reset_at')
|
|
||||||
from django.utils.dateparse import parse_datetime
|
|
||||||
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
|
||||||
|
|
||||||
# If we have a reset timestamp and it has passed, clear the lockout
|
|
||||||
if changes_remaining == 0 and reset_at:
|
|
||||||
if timezone.now() >= reset_at:
|
|
||||||
cp = source.custom_properties or {}
|
|
||||||
cp.pop('sd_changes_remaining', None)
|
|
||||||
cp.pop('sd_changes_reset_at', None)
|
|
||||||
source.custom_properties = cp
|
|
||||||
source.save(update_fields=['custom_properties'])
|
|
||||||
return None
|
|
||||||
|
|
||||||
return changes_remaining
|
|
||||||
|
|
||||||
def _save_sd_changes_remaining(self, source, changes_remaining):
|
|
||||||
"""Persist changesRemaining to EPGSource model field."""
|
|
||||||
cp = source.custom_properties or {}
|
|
||||||
cp['sd_changes_remaining'] = changes_remaining
|
|
||||||
source.custom_properties = cp
|
|
||||||
source.save(update_fields=['custom_properties'])
|
|
||||||
|
|
||||||
def _save_sd_lockout(self, source):
|
|
||||||
"""
|
|
||||||
Persist a hard lockout to EPGSource custom_properties when SD returns
|
|
||||||
4100 MAX_LINEUP_CHANGES_REACHED. SD lineup change counters reset at
|
|
||||||
00:00Z (midnight UTC) per SD's documented behavior — error 4100 states
|
|
||||||
"lineup changes for today" and all SD rate counters reset at midnight UTC.
|
|
||||||
Lockout clears automatically when the next midnight UTC passes.
|
|
||||||
"""
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
# Calculate next midnight UTC — SD resets at 00:00Z not on a rolling window
|
|
||||||
tomorrow = (now + timedelta(days=1)).replace(
|
|
||||||
hour=0, minute=0, second=0, microsecond=0,
|
|
||||||
tzinfo=dt_timezone.utc
|
|
||||||
)
|
|
||||||
reset_at = tomorrow
|
|
||||||
|
|
||||||
cp = source.custom_properties or {}
|
|
||||||
cp['sd_changes_remaining'] = 0
|
|
||||||
cp['sd_changes_reset_at'] = reset_at.isoformat()
|
|
||||||
source.custom_properties = cp
|
|
||||||
source.save(update_fields=['custom_properties'])
|
|
||||||
logger.warning(
|
|
||||||
f"SD source {source.id}: daily add limit reached (4100). "
|
|
||||||
f"Lockout set until {reset_at.isoformat()}."
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
|
|
||||||
def sd_lineups(self, request, pk=None):
|
|
||||||
"""
|
|
||||||
GET — list lineups currently on the SD account
|
|
||||||
POST — add a lineup (body: {"lineup": "USA-NJ29486-X"})
|
|
||||||
DELETE — remove a lineup (body: {"lineup": "USA-NJ29486-X"})
|
|
||||||
"""
|
|
||||||
import requests as http_requests
|
|
||||||
from apps.epg.tasks import SD_BASE_URL
|
|
||||||
from version import __version__ as dispatcharr_version
|
|
||||||
|
|
||||||
source = self.get_object()
|
|
||||||
if source.source_type != 'schedules_direct':
|
|
||||||
return Response(
|
|
||||||
{"error": "This action is only available for Schedules Direct sources."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
token, error = self._sd_authenticate(source)
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
|
|
||||||
'token': token,
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
try:
|
|
||||||
resp = http_requests.get(
|
|
||||||
f"{SD_BASE_URL}/lineups",
|
|
||||||
headers=headers,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if resp.status_code == 400:
|
|
||||||
sd_data = resp.json()
|
|
||||||
sd_code = sd_data.get('code')
|
|
||||||
if sd_code == 4102:
|
|
||||||
return Response({
|
|
||||||
"lineups": [],
|
|
||||||
"max_lineups": 4,
|
|
||||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
|
||||||
"changes_reset_at": self._get_sd_reset_at(source),
|
|
||||||
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
|
|
||||||
})
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)]
|
|
||||||
return Response({
|
|
||||||
"lineups": lineups,
|
|
||||||
"max_lineups": 4,
|
|
||||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
|
||||||
"changes_reset_at": self._get_sd_reset_at(source),
|
|
||||||
})
|
|
||||||
except http_requests.exceptions.RequestException as e:
|
|
||||||
return Response(
|
|
||||||
{"error": f"Failed to fetch lineups: {str(e)}"},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY
|
|
||||||
)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
|
||||||
lineup_id = request.data.get('lineup')
|
|
||||||
if not lineup_id:
|
|
||||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
try:
|
|
||||||
resp = http_requests.put(
|
|
||||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
|
||||||
headers=headers,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
sd_data = resp.json()
|
|
||||||
sd_code = sd_data.get('code')
|
|
||||||
|
|
||||||
if resp.status_code == 400 or resp.status_code == 403:
|
|
||||||
if sd_code == 4100:
|
|
||||||
self._save_sd_lockout(source)
|
|
||||||
return Response({
|
|
||||||
"error": "daily_limit_reached",
|
|
||||||
"message": "You have reached your daily Schedules Direct lineup addition limit. SD allows 6 adds per 24-hour period. Resets at midnight UTC.",
|
|
||||||
"changes_remaining": 0,
|
|
||||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
if sd_code == 4101:
|
|
||||||
return Response({
|
|
||||||
"error": "max_lineups_reached",
|
|
||||||
"message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.",
|
|
||||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
if sd_code == 2100:
|
|
||||||
return Response({
|
|
||||||
"error": "duplicate_lineup",
|
|
||||||
"message": "This lineup is already on your Schedules Direct account.",
|
|
||||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
return Response({
|
|
||||||
"error": sd_data.get('message', 'Failed to add lineup.'),
|
|
||||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
# Persist changesRemaining to custom_properties
|
|
||||||
changes_remaining = sd_data.get('changesRemaining')
|
|
||||||
if changes_remaining is not None:
|
|
||||||
self._save_sd_changes_remaining(source, changes_remaining)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"SD lineup added for source {source.id}: {lineup_id}. "
|
|
||||||
f"changesRemaining: {changes_remaining}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Re-fetch stations so the new lineup's stations are available for matching
|
|
||||||
from apps.epg.tasks import fetch_schedules_direct_stations
|
|
||||||
fetch_schedules_direct_stations.delay(source.id)
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
**sd_data,
|
|
||||||
"changes_remaining": changes_remaining,
|
|
||||||
})
|
|
||||||
except http_requests.exceptions.RequestException as e:
|
|
||||||
return Response(
|
|
||||||
{"error": f"Failed to add lineup: {str(e)}"},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY
|
|
||||||
)
|
|
||||||
|
|
||||||
elif request.method == "DELETE":
|
|
||||||
lineup_id = request.data.get('lineup')
|
|
||||||
if not lineup_id:
|
|
||||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
try:
|
|
||||||
resp = http_requests.delete(
|
|
||||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
|
||||||
headers=headers,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if resp.status_code == 400:
|
|
||||||
sd_data = resp.json()
|
|
||||||
sd_code = sd_data.get('code')
|
|
||||||
if sd_code == 2103:
|
|
||||||
return Response({
|
|
||||||
"response": "OK",
|
|
||||||
"code": 0,
|
|
||||||
"message": "Lineup not found on account — already removed.",
|
|
||||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
|
||||||
})
|
|
||||||
resp.raise_for_status()
|
|
||||||
sd_data = resp.json()
|
|
||||||
# SD returns changesRemaining on deletes — persist it
|
|
||||||
changes_remaining = sd_data.get('changesRemaining')
|
|
||||||
if changes_remaining is not None:
|
|
||||||
self._save_sd_changes_remaining(source, changes_remaining)
|
|
||||||
logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}")
|
|
||||||
return Response({
|
|
||||||
**sd_data,
|
|
||||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
|
||||||
})
|
|
||||||
except http_requests.exceptions.RequestException as e:
|
|
||||||
return Response(
|
|
||||||
{"error": f"Failed to remove lineup: {str(e)}"},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="sd-lineups/search")
|
|
||||||
def sd_lineups_search(self, request, pk=None):
|
|
||||||
"""
|
|
||||||
Search available headends/lineups by country and postal code.
|
|
||||||
Body: {"country": "USA", "postalcode": "07030"}
|
|
||||||
Returns a flat list of lineups across all matching headends.
|
|
||||||
"""
|
|
||||||
import requests as http_requests
|
|
||||||
from apps.epg.tasks import SD_BASE_URL
|
|
||||||
from version import __version__ as dispatcharr_version
|
|
||||||
|
|
||||||
source = self.get_object()
|
|
||||||
if source.source_type != 'schedules_direct':
|
|
||||||
return Response(
|
|
||||||
{"error": "This action is only available for Schedules Direct sources."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
country = request.data.get('country', '').strip()
|
|
||||||
postalcode = request.data.get('postalcode', '').strip()
|
|
||||||
if not country or not postalcode:
|
|
||||||
return Response(
|
|
||||||
{"error": "country and postalcode are required."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
token, error = self._sd_authenticate(source)
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
|
|
||||||
'token': token,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = http_requests.get(
|
|
||||||
f"{SD_BASE_URL}/headends",
|
|
||||||
params={'country': country, 'postalcode': postalcode},
|
|
||||||
headers=headers,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
headends = resp.json()
|
|
||||||
lineups = []
|
|
||||||
for headend in headends:
|
|
||||||
for lineup in headend.get('lineups', []):
|
|
||||||
lineups.append({
|
|
||||||
'lineup': lineup.get('lineup'),
|
|
||||||
'name': lineup.get('name'),
|
|
||||||
'transport': headend.get('transport'),
|
|
||||||
'location': headend.get('location'),
|
|
||||||
'headend': headend.get('headend'),
|
|
||||||
})
|
|
||||||
return Response({"lineups": lineups})
|
|
||||||
except http_requests.exceptions.RequestException as e:
|
|
||||||
return Response(
|
|
||||||
{"error": f"Failed to search headends: {str(e)}"},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY
|
|
||||||
)
|
|
||||||
# ─────────────────────────────
|
|
||||||
# 2) Program API (CRUD)
|
|
||||||
# ─────────────────────────────
|
|
||||||
class ProgramSearchPagination(PageNumberPagination):
|
class ProgramSearchPagination(PageNumberPagination):
|
||||||
page_size = 50
|
page_size = 50
|
||||||
page_size_query_param = 'page_size'
|
page_size_query_param = 'page_size'
|
||||||
max_page_size = 500
|
max_page_size = 500
|
||||||
|
|
||||||
|
|
||||||
class ProgramViewSet(viewsets.ModelViewSet):
|
class ProgramViewSet(SchedulesDirectPosterMixin, viewsets.ModelViewSet):
|
||||||
"""Handles CRUD operations for EPG programs"""
|
"""Handles CRUD operations for EPG programs"""
|
||||||
|
|
||||||
queryset = ProgramData.objects.select_related("epg").all()
|
queryset = ProgramData.objects.select_related("epg").all()
|
||||||
serializer_class = ProgramDataSerializer
|
serializer_class = ProgramDataSerializer
|
||||||
|
|
||||||
# Per-source in-memory caches (token and error state)
|
# Short process-local cooldown for transient poster errors (auth/network).
|
||||||
_sd_poster_token_cache: dict = {}
|
# Image download limits are persisted on the EPG source (shared across workers).
|
||||||
_sd_poster_error_cache: dict = {}
|
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action == 'poster':
|
if self.action == 'poster':
|
||||||
|
|
@ -488,101 +161,6 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
||||||
serializer = self.get_serializer(instance)
|
serializer = self.get_serializer(instance)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny])
|
|
||||||
def poster(self, request, pk=None):
|
|
||||||
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
|
|
||||||
import requests as http_requests
|
|
||||||
from apps.epg.tasks import SD_BASE_URL
|
|
||||||
|
|
||||||
program = self.get_object()
|
|
||||||
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
|
|
||||||
if not poster_sd_url:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
source = program.epg.epg_source if program.epg else None
|
|
||||||
if not source or source.source_type != 'schedules_direct':
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id)
|
|
||||||
if error_cache and time.time() < error_cache['until']:
|
|
||||||
return Response(
|
|
||||||
{'error': f"SD temporarily unavailable: {error_cache['reason']}"},
|
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
cached = ProgramViewSet._sd_poster_token_cache.get(source.id)
|
|
||||||
token = cached['token'] if cached and time.time() < cached['expires'] else None
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest()
|
|
||||||
try:
|
|
||||||
from version import __version__ as dispatcharr_version
|
|
||||||
auth_resp = http_requests.post(
|
|
||||||
f"{SD_BASE_URL}/token",
|
|
||||||
json={'username': source.username, 'password': sha1_password},
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
|
|
||||||
},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
auth_data = auth_resp.json()
|
|
||||||
token = auth_data.get('token')
|
|
||||||
if not token:
|
|
||||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
|
||||||
'until': time.time() + 3600,
|
|
||||||
'reason': auth_data.get('message', 'Authentication failed'),
|
|
||||||
}
|
|
||||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
token_expires = auth_data.get('tokenExpires', time.time() + 86400)
|
|
||||||
ProgramViewSet._sd_poster_token_cache[source.id] = {
|
|
||||||
'token': token,
|
|
||||||
'expires': token_expires,
|
|
||||||
}
|
|
||||||
except http_requests.exceptions.RequestException:
|
|
||||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
|
||||||
'until': time.time() + 300,
|
|
||||||
'reason': 'Network error reaching Schedules Direct',
|
|
||||||
}
|
|
||||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
try:
|
|
||||||
img_resp = http_requests.get(
|
|
||||||
poster_sd_url,
|
|
||||||
headers={'token': token},
|
|
||||||
timeout=15,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
if img_resp.status_code in (401, 403):
|
|
||||||
ProgramViewSet._sd_poster_token_cache.pop(source.id, None)
|
|
||||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
|
||||||
'until': time.time() + 3600,
|
|
||||||
'reason': f'SD returned {img_resp.status_code}',
|
|
||||||
}
|
|
||||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
if img_resp.status_code == 400:
|
|
||||||
try:
|
|
||||||
err_code = img_resp.json().get('code')
|
|
||||||
except Exception:
|
|
||||||
err_code = None
|
|
||||||
if err_code == 5002:
|
|
||||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
|
||||||
'until': time.time() + 3600,
|
|
||||||
'reason': 'Daily image download limit reached (SD error 5002)',
|
|
||||||
}
|
|
||||||
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
|
|
||||||
if img_resp.status_code != 200:
|
|
||||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
from django.http import HttpResponse
|
|
||||||
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
|
|
||||||
response = HttpResponse(img_resp.content, content_type=content_type)
|
|
||||||
response['Cache-Control'] = 'public, max-age=86400'
|
|
||||||
return response
|
|
||||||
|
|
||||||
except http_requests.exceptions.RequestException:
|
|
||||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
logger.debug("Listing all EPG programs.")
|
logger.debug("Listing all EPG programs.")
|
||||||
return super().list(request, *args, **kwargs)
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
@ -976,7 +554,9 @@ class EPGGridAPIView(APIView):
|
||||||
channel_name=name_to_parse,
|
channel_name=name_to_parse,
|
||||||
num_days=1,
|
num_days=1,
|
||||||
program_length_hours=4,
|
program_length_hours=4,
|
||||||
epg_source=epg_source
|
epg_source=epg_source,
|
||||||
|
export_lookback=one_hour_ago,
|
||||||
|
export_cutoff=twenty_four_hours_later,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Custom dummy should always return data (either from patterns or fallback)
|
# Custom dummy should always return data (either from patterns or fallback)
|
||||||
|
|
@ -1072,13 +652,19 @@ class EPGGridAPIView(APIView):
|
||||||
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
|
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Combine regular and dummy programs
|
# Combine regular and dummy programs in place to avoid copying the large list
|
||||||
all_programs = list(serialized_programs) + dummy_programs
|
serialized_programs.extend(dummy_programs)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs)."
|
f"EPGGridAPIView: Returning {len(serialized_programs)} total programs (including {len(dummy_programs)} dummy programs)."
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({"data": all_programs}, status=status.HTTP_200_OK)
|
# The grid materializes tens of thousands of program dicts plus the
|
||||||
|
# rendered JSON; trim once the response is sent so worker RSS does not
|
||||||
|
# ratchet up per request.
|
||||||
|
from core.utils import spawn_memory_trim
|
||||||
|
response = Response({"data": serialized_programs}, status=status.HTTP_200_OK)
|
||||||
|
response._resource_closers.append(spawn_memory_trim)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────
|
# ─────────────────────────────
|
||||||
|
|
@ -1109,18 +695,24 @@ class EPGImportAPIView(APIView):
|
||||||
epg_id = request.data.get("id", None)
|
epg_id = request.data.get("id", None)
|
||||||
force = bool(request.data.get("force", False))
|
force = bool(request.data.get("force", False))
|
||||||
|
|
||||||
# Check if this is a dummy EPG source
|
# Reject dummy sources with a narrow existence query, no full row load.
|
||||||
try:
|
if epg_id is not None:
|
||||||
from .models import EPGSource
|
from .models import EPGSource
|
||||||
epg_source = EPGSource.objects.get(id=epg_id)
|
|
||||||
if epg_source.source_type == 'dummy':
|
if EPGSource.objects.filter(
|
||||||
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}")
|
id=epg_id, source_type="dummy"
|
||||||
|
).exists():
|
||||||
|
logger.info(
|
||||||
|
"EPGImportAPIView: Skipping refresh for dummy EPG source %s",
|
||||||
|
epg_id,
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "message": "Dummy EPG sources do not require refreshing."},
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": "Dummy EPG sources do not require refreshing.",
|
||||||
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
except EPGSource.DoesNotExist:
|
|
||||||
pass # Let the task handle the missing source
|
|
||||||
|
|
||||||
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
|
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
|
||||||
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
|
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
|
||||||
|
|
@ -1220,10 +812,9 @@ class CurrentProgramsAPIView(APIView):
|
||||||
# Limit to 50 IDs per request
|
# Limit to 50 IDs per request
|
||||||
epg_data_ids = epg_data_ids[:50]
|
epg_data_ids = epg_data_ids[:50]
|
||||||
|
|
||||||
# Defer the multi-MB programme_index the JOIN would pull once per row. The lookup reads it via a targeted refresh_from_db
|
epg_data_entries = EPGData.objects.select_related('epg_source').filter(
|
||||||
epg_data_entries = EPGData.objects.select_related('epg_source').defer(
|
id__in=epg_data_ids
|
||||||
'epg_source__programme_index'
|
)
|
||||||
).filter(id__in=epg_data_ids)
|
|
||||||
|
|
||||||
# Batch-fetch current programs for all requested EPG entries in one query
|
# Batch-fetch current programs for all requested EPG entries in one query
|
||||||
db_programs = ProgramData.objects.filter(
|
db_programs = ProgramData.objects.filter(
|
||||||
|
|
|
||||||
40
apps/epg/migrations/0025_programdata_epg_id_index.py
Normal file
40
apps/epg/migrations/0025_programdata_epg_id_index.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class AddIndexConcurrentlyIfPostgres(AddIndexConcurrently):
|
||||||
|
"""Create the index CONCURRENTLY on PostgreSQL (no table lock on large
|
||||||
|
tables), falling back to a normal blocking AddIndex on other backends
|
||||||
|
such as the sqlite dev/test fallback."""
|
||||||
|
|
||||||
|
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
if schema_editor.connection.vendor == 'postgresql':
|
||||||
|
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||||
|
else:
|
||||||
|
migrations.AddIndex.database_forwards(
|
||||||
|
self, app_label, schema_editor, from_state, to_state
|
||||||
|
)
|
||||||
|
|
||||||
|
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
if schema_editor.connection.vendor == 'postgresql':
|
||||||
|
super().database_backwards(app_label, schema_editor, from_state, to_state)
|
||||||
|
else:
|
||||||
|
migrations.AddIndex.database_backwards(
|
||||||
|
self, app_label, schema_editor, from_state, to_state
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epg', '0024_remove_epgsource_api_key_epgsource_password_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
AddIndexConcurrentlyIfPostgres(
|
||||||
|
model_name='programdata',
|
||||||
|
index=models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
52
apps/epg/migrations/0026_epgsourceindex.py
Normal file
52
apps/epg/migrations/0026_epgsourceindex.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def copy_index_forward(apps, schema_editor):
|
||||||
|
EPGSource = apps.get_model('epg', 'EPGSource')
|
||||||
|
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
|
||||||
|
rows = list(
|
||||||
|
EPGSource.objects.exclude(programme_index__isnull=True).values_list(
|
||||||
|
'id', 'programme_index'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for source_id, data in rows:
|
||||||
|
EPGSourceIndex.objects.update_or_create(
|
||||||
|
source_id=source_id, defaults={'data': data}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_index_backward(apps, schema_editor):
|
||||||
|
EPGSource = apps.get_model('epg', 'EPGSource')
|
||||||
|
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
|
||||||
|
for source_id, data in EPGSourceIndex.objects.values_list('source_id', 'data'):
|
||||||
|
EPGSource.objects.filter(id=source_id).update(programme_index=data)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epg', '0025_programdata_epg_id_index'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EPGSourceIndex',
|
||||||
|
fields=[
|
||||||
|
('source', models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name='index_record',
|
||||||
|
serialize=False,
|
||||||
|
to='epg.epgsource',
|
||||||
|
)),
|
||||||
|
('data', models.JSONField(blank=True, default=None, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(copy_index_forward, copy_index_backward),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='epgsource',
|
||||||
|
name='programme_index',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -62,12 +62,6 @@ class EPGSource(models.Model):
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Last status message, including success results or error information"
|
help_text="Last status message, including success results or error information"
|
||||||
)
|
)
|
||||||
programme_index = models.JSONField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
default=None,
|
|
||||||
help_text="Byte-offset index mapping tvg_id to file positions, built after each EPG refresh"
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(
|
created_at = models.DateTimeField(
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
help_text="Time when this source was created"
|
help_text="Time when this source was created"
|
||||||
|
|
@ -96,17 +90,21 @@ class EPGSource(models.Model):
|
||||||
file_ext = '.gz'
|
file_ext = '.gz'
|
||||||
elif mime_type == 'application/zip':
|
elif mime_type == 'application/zip':
|
||||||
file_ext = '.zip'
|
file_ext = '.zip'
|
||||||
|
elif mime_type == 'application/x-xz':
|
||||||
|
file_ext = '.xz'
|
||||||
elif mime_type == 'application/xml' or mime_type == 'text/xml':
|
elif mime_type == 'application/xml' or mime_type == 'text/xml':
|
||||||
file_ext = '.xml'
|
file_ext = '.xml'
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with open(self.file_path, 'rb') as f:
|
with open(self.file_path, 'rb') as f:
|
||||||
header = f.read(4)
|
header = f.read(6)
|
||||||
if header[:2] == b'\x1f\x8b':
|
if header.startswith(b'\x1f\x8b'):
|
||||||
file_ext = '.gz'
|
file_ext = '.gz'
|
||||||
elif header[:2] == b'PK':
|
elif header.startswith(b'PK'):
|
||||||
file_ext = '.zip'
|
file_ext = '.zip'
|
||||||
elif header[:5] == b'<?xml' or header[:5] == b'<tv>':
|
elif header.startswith(b'\xfd7zXZ\x00'):
|
||||||
|
file_ext = '.xz'
|
||||||
|
elif header.startswith(b'<?xml') or header.startswith(b'<tv>'):
|
||||||
file_ext = '.xml'
|
file_ext = '.xml'
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -124,6 +122,37 @@ class EPGSource(models.Model):
|
||||||
kwargs['update_fields'].remove('updated_at')
|
kwargs['update_fields'].remove('updated_at')
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def programme_index(self):
|
||||||
|
"""Byte-offset index for this source, read on demand from the separate
|
||||||
|
EPGSourceIndex table so the multi-MB blob is never pulled into EPGSource
|
||||||
|
queries or select_related JOINs. Returns the stored dict or None."""
|
||||||
|
return (
|
||||||
|
EPGSourceIndex.objects.filter(source_id=self.pk)
|
||||||
|
.values_list('data', flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EPGSourceIndex(models.Model):
|
||||||
|
"""Byte-offset programme index for an EPGSource, stored in its own table.
|
||||||
|
|
||||||
|
Kept out of EPGSource so the multi-MB JSON blob is only loaded when read
|
||||||
|
explicitly, never when querying or joining EPGSource rows.
|
||||||
|
"""
|
||||||
|
source = models.OneToOneField(
|
||||||
|
EPGSource,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='index_record',
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
data = models.JSONField(null=True, blank=True, default=None)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Programme index for source {self.source_id}"
|
||||||
|
|
||||||
|
|
||||||
class EPGData(models.Model):
|
class EPGData(models.Model):
|
||||||
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||||
name = models.CharField(max_length=512)
|
name = models.CharField(max_length=512)
|
||||||
|
|
@ -153,6 +182,11 @@ class ProgramData(models.Model):
|
||||||
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.')
|
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.')
|
||||||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} ({self.start_time} - {self.end_time})"
|
return f"{self.title} ({self.start_time} - {self.end_time})"
|
||||||
|
|
||||||
|
|
|
||||||
571
apps/epg/sd_api.py
Normal file
571
apps/epg/sd_api.py
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
"""
|
||||||
|
Schedules Direct HTTP API helpers and ViewSet mixins.
|
||||||
|
|
||||||
|
Keeps lineup management and poster proxy out of the general EPG view modules.
|
||||||
|
Protocol/auth helpers live in ``sd_utils``; the refresh pipeline in ``sd_tasks``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.epg.sd_utils import (
|
||||||
|
SD_AUTH_CREDENTIAL_LOCKOUT_CODES,
|
||||||
|
SD_BASE_URL,
|
||||||
|
SD_CODE_IMAGE_NOT_FOUND,
|
||||||
|
SD_IMAGE_LIMIT_CODES,
|
||||||
|
sd_auth_lockout_active,
|
||||||
|
sd_authorized_request,
|
||||||
|
sd_handle_2055,
|
||||||
|
sd_image_limit_active,
|
||||||
|
sd_mark_icon_missing,
|
||||||
|
sd_next_midnight_utc,
|
||||||
|
sd_obtain_token,
|
||||||
|
sd_parse_response_payload,
|
||||||
|
sd_save_image_limit_lockout,
|
||||||
|
)
|
||||||
|
from core.utils import dispatcharr_http_headers
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulesDirectSourceMixin:
|
||||||
|
"""Lineup management actions and helpers for Schedules Direct EPG sources."""
|
||||||
|
|
||||||
|
def _sd_authenticate(self, source):
|
||||||
|
"""
|
||||||
|
Authenticate with Schedules Direct using stored credentials.
|
||||||
|
Returns (token, None) on success or (None, Response) on failure.
|
||||||
|
"""
|
||||||
|
result = sd_obtain_token(source, timeout=15)
|
||||||
|
if result.ok:
|
||||||
|
return result.token, None
|
||||||
|
|
||||||
|
if result.debug_rejected:
|
||||||
|
return None, Response(
|
||||||
|
{"error": result.message},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.message == 'Username and password are required.':
|
||||||
|
http_status = status.HTTP_400_BAD_REQUEST
|
||||||
|
elif result.code in SD_AUTH_CREDENTIAL_LOCKOUT_CODES:
|
||||||
|
http_status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
elif result.lockout or result.soft or result.code:
|
||||||
|
http_status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
else:
|
||||||
|
http_status = status.HTTP_502_BAD_GATEWAY
|
||||||
|
|
||||||
|
return None, Response({"error": result.message}, status=http_status)
|
||||||
|
|
||||||
|
def _get_sd_reset_at(self, source):
|
||||||
|
"""Retrieve stored reset timestamp from EPGSource model field."""
|
||||||
|
reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at')
|
||||||
|
return reset_at_str
|
||||||
|
|
||||||
|
def _get_sd_changes_remaining(self, source):
|
||||||
|
"""
|
||||||
|
Retrieve stored changesRemaining from EPGSource model field.
|
||||||
|
If a reset timestamp exists and has passed (midnight UTC), clears the
|
||||||
|
lockout automatically so the user can make adds again.
|
||||||
|
"""
|
||||||
|
cp = source.custom_properties or {}
|
||||||
|
changes_remaining = cp.get('sd_changes_remaining')
|
||||||
|
reset_at_str = cp.get('sd_changes_reset_at')
|
||||||
|
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
||||||
|
|
||||||
|
# If we have a reset timestamp and it has passed, clear the lockout
|
||||||
|
if changes_remaining == 0 and reset_at:
|
||||||
|
if timezone.now() >= reset_at:
|
||||||
|
cp = source.custom_properties or {}
|
||||||
|
cp.pop('sd_changes_remaining', None)
|
||||||
|
cp.pop('sd_changes_reset_at', None)
|
||||||
|
source.custom_properties = cp
|
||||||
|
source.save(update_fields=['custom_properties'])
|
||||||
|
return None
|
||||||
|
|
||||||
|
return changes_remaining
|
||||||
|
|
||||||
|
def _save_sd_changes_remaining(self, source, changes_remaining):
|
||||||
|
"""
|
||||||
|
Persist changesRemaining on the EPG source.
|
||||||
|
|
||||||
|
When remaining hits 0, also store sd_changes_reset_at (next midnight UTC)
|
||||||
|
so the lockout can clear automatically. A positive remaining clears any
|
||||||
|
prior reset timestamp.
|
||||||
|
"""
|
||||||
|
cp = dict(source.custom_properties or {})
|
||||||
|
cp['sd_changes_remaining'] = changes_remaining
|
||||||
|
if changes_remaining == 0:
|
||||||
|
cp['sd_changes_reset_at'] = sd_next_midnight_utc().isoformat()
|
||||||
|
else:
|
||||||
|
cp.pop('sd_changes_reset_at', None)
|
||||||
|
source.custom_properties = cp
|
||||||
|
source.save(update_fields=['custom_properties'])
|
||||||
|
|
||||||
|
def _save_sd_lockout(self, source):
|
||||||
|
"""
|
||||||
|
Persist a hard lockout when SD returns 4100 MAX_LINEUP_CHANGES_REACHED.
|
||||||
|
|
||||||
|
SD lineup change counters reset at 00:00Z (midnight UTC). Lockout clears
|
||||||
|
automatically when that reset time passes.
|
||||||
|
"""
|
||||||
|
self._save_sd_changes_remaining(source, 0)
|
||||||
|
reset_at = (source.custom_properties or {}).get('sd_changes_reset_at')
|
||||||
|
logger.warning(
|
||||||
|
f"SD source {source.id}: daily lineup change limit reached (4100). "
|
||||||
|
f"Lockout set until {reset_at}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_sd_countries(self):
|
||||||
|
"""Fetch the SD country list (token not required; User-Agent is)."""
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{SD_BASE_URL}/available/countries",
|
||||||
|
headers=dispatcharr_http_headers(content_type=None),
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except http_requests.exceptions.RequestException as e:
|
||||||
|
logger.warning(f"Failed to fetch SD countries: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
|
||||||
|
def sd_lineups(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
GET — list lineups currently on the SD account
|
||||||
|
POST — add a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||||
|
DELETE — remove a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||||
|
"""
|
||||||
|
source = self.get_object()
|
||||||
|
if source.source_type != 'schedules_direct':
|
||||||
|
return Response(
|
||||||
|
{"error": "This action is only available for Schedules Direct sources."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
token, error = self._sd_authenticate(source)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
countries = self._fetch_sd_countries()
|
||||||
|
try:
|
||||||
|
resp, token = sd_authorized_request(
|
||||||
|
'GET',
|
||||||
|
f"{SD_BASE_URL}/lineups",
|
||||||
|
source=source,
|
||||||
|
token=token,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.status_code == 400:
|
||||||
|
sd_data = resp.json()
|
||||||
|
sd_code = sd_data.get('code')
|
||||||
|
if sd_code == 4102:
|
||||||
|
return Response({
|
||||||
|
"lineups": [],
|
||||||
|
"max_lineups": 4,
|
||||||
|
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||||
|
"changes_reset_at": self._get_sd_reset_at(source),
|
||||||
|
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
|
||||||
|
"countries": countries,
|
||||||
|
})
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)]
|
||||||
|
return Response({
|
||||||
|
"lineups": lineups,
|
||||||
|
"max_lineups": 4,
|
||||||
|
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||||
|
"changes_reset_at": self._get_sd_reset_at(source),
|
||||||
|
"countries": countries,
|
||||||
|
})
|
||||||
|
except http_requests.exceptions.RequestException as e:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to fetch lineups: {str(e)}"},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY
|
||||||
|
)
|
||||||
|
|
||||||
|
elif request.method == "POST":
|
||||||
|
lineup_id = request.data.get('lineup')
|
||||||
|
if not lineup_id:
|
||||||
|
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Honor a known daily change lockout without calling SD again.
|
||||||
|
if self._get_sd_changes_remaining(source) == 0:
|
||||||
|
return Response({
|
||||||
|
"error": "daily_limit_reached",
|
||||||
|
"message": (
|
||||||
|
"You have reached your daily Schedules Direct lineup "
|
||||||
|
"change limit (6 add/delete operations per 24 hours). "
|
||||||
|
"Resets at midnight UTC."
|
||||||
|
),
|
||||||
|
"changes_remaining": 0,
|
||||||
|
"changes_reset_at": self._get_sd_reset_at(source),
|
||||||
|
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp, token = sd_authorized_request(
|
||||||
|
'PUT',
|
||||||
|
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||||
|
source=source,
|
||||||
|
token=token,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
sd_data = resp.json()
|
||||||
|
sd_code = sd_data.get('code')
|
||||||
|
|
||||||
|
if resp.status_code == 400 or resp.status_code == 403:
|
||||||
|
if sd_code == 4100:
|
||||||
|
self._save_sd_lockout(source)
|
||||||
|
return Response({
|
||||||
|
"error": "daily_limit_reached",
|
||||||
|
"message": (
|
||||||
|
"You have reached your daily Schedules Direct lineup "
|
||||||
|
"change limit (6 add/delete operations per 24 hours). "
|
||||||
|
"Resets at midnight UTC."
|
||||||
|
),
|
||||||
|
"changes_remaining": 0,
|
||||||
|
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
if sd_code == 4101:
|
||||||
|
return Response({
|
||||||
|
"error": "max_lineups_reached",
|
||||||
|
"message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.",
|
||||||
|
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
if sd_code == 2100:
|
||||||
|
return Response({
|
||||||
|
"error": "duplicate_lineup",
|
||||||
|
"message": "This lineup is already on your Schedules Direct account.",
|
||||||
|
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
return Response({
|
||||||
|
"error": sd_data.get('message', 'Failed to add lineup.'),
|
||||||
|
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
# Persist changesRemaining to custom_properties
|
||||||
|
changes_remaining = sd_data.get('changesRemaining')
|
||||||
|
if changes_remaining is not None:
|
||||||
|
self._save_sd_changes_remaining(source, changes_remaining)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"SD lineup added for source {source.id}: {lineup_id}. "
|
||||||
|
f"changesRemaining: {changes_remaining}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-fetch stations so the new lineup's stations are available for matching
|
||||||
|
from apps.epg.tasks import fetch_schedules_direct_stations
|
||||||
|
fetch_schedules_direct_stations.delay(source.id)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
**sd_data,
|
||||||
|
"changes_remaining": changes_remaining,
|
||||||
|
})
|
||||||
|
except http_requests.exceptions.RequestException as e:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to add lineup: {str(e)}"},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY
|
||||||
|
)
|
||||||
|
|
||||||
|
elif request.method == "DELETE":
|
||||||
|
lineup_id = request.data.get('lineup')
|
||||||
|
if not lineup_id:
|
||||||
|
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if self._get_sd_changes_remaining(source) == 0:
|
||||||
|
return Response({
|
||||||
|
"error": "daily_limit_reached",
|
||||||
|
"message": (
|
||||||
|
"You have reached your daily Schedules Direct lineup "
|
||||||
|
"change limit (6 add/delete operations per 24 hours). "
|
||||||
|
"Resets at midnight UTC."
|
||||||
|
),
|
||||||
|
"changes_remaining": 0,
|
||||||
|
"changes_reset_at": self._get_sd_reset_at(source),
|
||||||
|
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp, token = sd_authorized_request(
|
||||||
|
'DELETE',
|
||||||
|
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||||
|
source=source,
|
||||||
|
token=token,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.status_code == 400:
|
||||||
|
sd_data = resp.json()
|
||||||
|
sd_code = sd_data.get('code')
|
||||||
|
if sd_code == 2103:
|
||||||
|
return Response({
|
||||||
|
"response": "OK",
|
||||||
|
"code": 0,
|
||||||
|
"message": "Lineup not found on account — already removed.",
|
||||||
|
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||||
|
})
|
||||||
|
if sd_code == 4100:
|
||||||
|
self._save_sd_lockout(source)
|
||||||
|
return Response({
|
||||||
|
"error": "daily_limit_reached",
|
||||||
|
"message": (
|
||||||
|
"You have reached your daily Schedules Direct lineup "
|
||||||
|
"change limit (6 add/delete operations per 24 hours). "
|
||||||
|
"Resets at midnight UTC."
|
||||||
|
),
|
||||||
|
"changes_remaining": 0,
|
||||||
|
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
resp.raise_for_status()
|
||||||
|
sd_data = resp.json()
|
||||||
|
# SD returns changesRemaining on deletes — persist it
|
||||||
|
changes_remaining = sd_data.get('changesRemaining')
|
||||||
|
if changes_remaining is not None:
|
||||||
|
self._save_sd_changes_remaining(source, changes_remaining)
|
||||||
|
logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}")
|
||||||
|
return Response({
|
||||||
|
**sd_data,
|
||||||
|
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||||
|
})
|
||||||
|
except http_requests.exceptions.RequestException as e:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to remove lineup: {str(e)}"},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="sd-lineups/search")
|
||||||
|
def sd_lineups_search(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Search available headends/lineups by country and postal code.
|
||||||
|
Body: {"country": "USA", "postalcode": "07030"}
|
||||||
|
Returns a flat list of lineups across all matching headends.
|
||||||
|
"""
|
||||||
|
source = self.get_object()
|
||||||
|
if source.source_type != 'schedules_direct':
|
||||||
|
return Response(
|
||||||
|
{"error": "This action is only available for Schedules Direct sources."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
country = request.data.get('country', '').strip()
|
||||||
|
postalcode = request.data.get('postalcode', '').strip()
|
||||||
|
if not country or not postalcode:
|
||||||
|
return Response(
|
||||||
|
{"error": "country and postalcode are required."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
token, error = self._sd_authenticate(source)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp, token = sd_authorized_request(
|
||||||
|
'GET',
|
||||||
|
f"{SD_BASE_URL}/headends",
|
||||||
|
source=source,
|
||||||
|
token=token,
|
||||||
|
timeout=15,
|
||||||
|
params={'country': country, 'postalcode': postalcode},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headends = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
headends = None
|
||||||
|
if isinstance(headends, dict) and sd_handle_2055(source, headends):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
"Schedules Direct rejected the debug routing header "
|
||||||
|
"(code 2055). Extra Schedules Direct Debugging has "
|
||||||
|
"been turned off."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
if not isinstance(headends, list):
|
||||||
|
headends = []
|
||||||
|
lineups = []
|
||||||
|
for headend in headends:
|
||||||
|
for lineup in headend.get('lineups', []):
|
||||||
|
lineups.append({
|
||||||
|
'lineup': lineup.get('lineup'),
|
||||||
|
'name': lineup.get('name'),
|
||||||
|
'transport': headend.get('transport'),
|
||||||
|
'location': headend.get('location'),
|
||||||
|
'headend': headend.get('headend'),
|
||||||
|
})
|
||||||
|
return Response({"lineups": lineups})
|
||||||
|
except http_requests.exceptions.RequestException as e:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to search headends: {str(e)}"},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY
|
||||||
|
)
|
||||||
|
# ─────────────────────────────
|
||||||
|
# 2) Program API (CRUD)
|
||||||
|
# ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulesDirectPosterMixin:
|
||||||
|
"""Program poster proxy for Schedules Direct artwork URIs."""
|
||||||
|
|
||||||
|
_sd_poster_error_cache: dict = {}
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny])
|
||||||
|
def poster(self, request, pk=None):
|
||||||
|
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
|
||||||
|
program = self.get_object()
|
||||||
|
cp = program.custom_properties or {}
|
||||||
|
if cp.get('sd_icon_missing'):
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
poster_sd_url = cp.get('sd_icon')
|
||||||
|
if not poster_sd_url:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
source = program.epg.epg_source if program.epg else None
|
||||||
|
if not source or source.source_type != 'schedules_direct':
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Persisted lockout (shared across workers) until next midnight UTC.
|
||||||
|
limit_active, limit_reason = sd_image_limit_active(source)
|
||||||
|
if limit_active:
|
||||||
|
self._sd_poster_error_cache[source.id] = {
|
||||||
|
'until': time.time() + 300,
|
||||||
|
'reason': limit_reason,
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
{'error': f"SD temporarily unavailable: {limit_reason}"},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_lockout_active, auth_lockout_reason, _lockout_code = (
|
||||||
|
sd_auth_lockout_active(source)
|
||||||
|
)
|
||||||
|
if auth_lockout_active:
|
||||||
|
# Rely on the persisted lockout only. Do not seed the process-local
|
||||||
|
# poster error cache here: that cache outlives credential changes and
|
||||||
|
# cooldown expiry and would keep returning 503 afterward.
|
||||||
|
return Response(
|
||||||
|
{'error': auth_lockout_reason},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
error_cache = self._sd_poster_error_cache.get(source.id)
|
||||||
|
if error_cache and time.time() < error_cache['until']:
|
||||||
|
return Response(
|
||||||
|
{'error': f"SD temporarily unavailable: {error_cache['reason']}"},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth = sd_obtain_token(source, timeout=10)
|
||||||
|
if auth.debug_rejected:
|
||||||
|
return Response(
|
||||||
|
{'error': auth.message},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if not auth.ok:
|
||||||
|
# Lockout codes are persisted by sd_obtain_token. Other failures
|
||||||
|
# use a short process-local cache so workers do not hammer /token.
|
||||||
|
if not auth.lockout:
|
||||||
|
self._sd_poster_error_cache[source.id] = {
|
||||||
|
'until': time.time() + (3600 if auth.code else 300),
|
||||||
|
'reason': auth.message or 'Authentication failed',
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
{'error': auth.message},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
token = auth.token
|
||||||
|
self._sd_poster_error_cache.pop(source.id, None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_resp, token = sd_authorized_request(
|
||||||
|
'GET',
|
||||||
|
poster_sd_url,
|
||||||
|
source=source,
|
||||||
|
token=token,
|
||||||
|
timeout=15,
|
||||||
|
content_type=None,
|
||||||
|
allow_redirects=True,
|
||||||
|
)
|
||||||
|
err_code, err_data = sd_parse_response_payload(img_resp)
|
||||||
|
if err_data is not None and sd_handle_2055(source, err_data):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'error': (
|
||||||
|
'Schedules Direct rejected the debug routing header '
|
||||||
|
'(code 2055). Extra Schedules Direct Debugging has '
|
||||||
|
'been turned off.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# SD documents 5002/5003 as HTTP 200 + JSON. Also accept 4xx.
|
||||||
|
if err_code in SD_IMAGE_LIMIT_CODES:
|
||||||
|
sd_save_image_limit_lockout(source, err_code)
|
||||||
|
self._sd_poster_error_cache[source.id] = {
|
||||||
|
'until': time.time() + 300,
|
||||||
|
'reason': (
|
||||||
|
f'Daily image download limit reached (SD error {err_code})'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||||
|
|
||||||
|
# Only blacklist on explicit IMAGE_NOT_FOUND (5000). Bare HTTP 404 can
|
||||||
|
# be a transient CDN/S3 miss for ephemeral URIs and must not permanently
|
||||||
|
# clear sd_icon (SD docs: code 5000 + HTTP 404).
|
||||||
|
if err_code == SD_CODE_IMAGE_NOT_FOUND:
|
||||||
|
sd_mark_icon_missing(program)
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if img_resp.status_code == 404:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if img_resp.status_code in (401, 403):
|
||||||
|
# Clear already happened inside sd_authorized_request; seed a short
|
||||||
|
# process cache only when the fresh-token retry still failed.
|
||||||
|
self._sd_poster_error_cache[source.id] = {
|
||||||
|
'until': time.time() + 3600,
|
||||||
|
'reason': f'SD returned {img_resp.status_code}',
|
||||||
|
}
|
||||||
|
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
# JSON error body (even on HTTP 200) is never a valid image.
|
||||||
|
if err_data is not None and err_code not in (None, 0):
|
||||||
|
logger.warning(
|
||||||
|
"SD poster proxy: unexpected JSON error code %s for program %s",
|
||||||
|
err_code,
|
||||||
|
program.id,
|
||||||
|
)
|
||||||
|
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
if img_resp.status_code != 200:
|
||||||
|
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
|
||||||
|
if 'json' in (content_type or '').lower():
|
||||||
|
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
response = HttpResponse(img_resp.content, content_type=content_type)
|
||||||
|
response['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
return response
|
||||||
|
|
||||||
|
except http_requests.exceptions.RequestException:
|
||||||
|
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
1840
apps/epg/sd_tasks.py
Normal file
1840
apps/epg/sd_tasks.py
Normal file
File diff suppressed because it is too large
Load diff
771
apps/epg/sd_utils.py
Normal file
771
apps/epg/sd_utils.py
Normal file
|
|
@ -0,0 +1,771 @@
|
||||||
|
"""
|
||||||
|
Schedules Direct API helpers: headers, error codes, and rate-limit lockouts.
|
||||||
|
|
||||||
|
See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta, timezone as dt_timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
|
||||||
|
from core.utils import dispatcharr_http_headers
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SD_BASE_URL = 'https://json.schedulesdirect.org/20141201'
|
||||||
|
|
||||||
|
# SD JSON error codes we must honor to avoid account blocks.
|
||||||
|
SD_CODE_INVALID_DEBUG = 2055
|
||||||
|
SD_CODE_SERVICE_OFFLINE = 3000
|
||||||
|
SD_CODE_SERVICE_BUSY = 3001
|
||||||
|
SD_CODE_ACCOUNT_EXPIRED = 4001
|
||||||
|
SD_CODE_INVALID_PASSWORD_HASH = 4002
|
||||||
|
SD_CODE_INVALID_USER_OR_PASSWORD = 4003
|
||||||
|
SD_CODE_ACCOUNT_LOCKED = 4004
|
||||||
|
SD_CODE_JSON_DISABLED = 4005
|
||||||
|
SD_CODE_APP_NOT_AUTHORIZED = 4007
|
||||||
|
SD_CODE_ACCOUNT_INACTIVE = 4008
|
||||||
|
SD_CODE_TOO_MANY_LOGINS = 4009
|
||||||
|
SD_CODE_TOO_MANY_UNIQUE_IPS = 4010
|
||||||
|
SD_CODE_IMAGE_NOT_FOUND = 5000
|
||||||
|
SD_CODE_MAX_IMAGE_DOWNLOADS = 5002
|
||||||
|
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL = 5003
|
||||||
|
|
||||||
|
SD_IMAGE_LIMIT_CODES = frozenset({
|
||||||
|
SD_CODE_MAX_IMAGE_DOWNLOADS,
|
||||||
|
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Soft /token failures: stop and retry later (idle), not a hard account error.
|
||||||
|
SD_AUTH_SOFT_CODES = frozenset({
|
||||||
|
SD_CODE_SERVICE_OFFLINE,
|
||||||
|
SD_CODE_SERVICE_BUSY,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wrong username/password (or hash): clear early when credentials change.
|
||||||
|
SD_AUTH_CREDENTIAL_LOCKOUT_CODES = frozenset({
|
||||||
|
SD_CODE_INVALID_PASSWORD_HASH,
|
||||||
|
SD_CODE_INVALID_USER_OR_PASSWORD,
|
||||||
|
})
|
||||||
|
|
||||||
|
# All /token codes we must not hammer. Includes soft codes (shorter cooldown).
|
||||||
|
SD_AUTH_LOCKOUT_CODES = frozenset({
|
||||||
|
SD_CODE_SERVICE_OFFLINE,
|
||||||
|
SD_CODE_SERVICE_BUSY,
|
||||||
|
SD_CODE_ACCOUNT_EXPIRED,
|
||||||
|
SD_CODE_INVALID_PASSWORD_HASH,
|
||||||
|
SD_CODE_INVALID_USER_OR_PASSWORD,
|
||||||
|
SD_CODE_ACCOUNT_LOCKED,
|
||||||
|
SD_CODE_JSON_DISABLED,
|
||||||
|
SD_CODE_APP_NOT_AUTHORIZED,
|
||||||
|
SD_CODE_ACCOUNT_INACTIVE,
|
||||||
|
SD_CODE_TOO_MANY_LOGINS,
|
||||||
|
SD_CODE_TOO_MANY_UNIQUE_IPS,
|
||||||
|
})
|
||||||
|
|
||||||
|
SD_AUTH_LOCKOUT_SECONDS = 24 * 3600
|
||||||
|
SD_AUTH_LOCKOUT_SECONDS_SOFT = 3600
|
||||||
|
SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK = 15 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def sd_auth_lockout_seconds_for_code(code):
|
||||||
|
"""Cooldown length for a /token error code."""
|
||||||
|
if code == SD_CODE_ACCOUNT_LOCKED:
|
||||||
|
return SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK
|
||||||
|
if code in SD_AUTH_SOFT_CODES:
|
||||||
|
return SD_AUTH_LOCKOUT_SECONDS_SOFT
|
||||||
|
return SD_AUTH_LOCKOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def sd_auth_lockout_retry_message():
|
||||||
|
"""Suffix explaining how to clear an auth lockout."""
|
||||||
|
return (
|
||||||
|
"Not retrying Schedules Direct authentication until the username or "
|
||||||
|
"password is updated, or the cooldown expires."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared across uWSGI workers via Django's Redis cache.
|
||||||
|
_SD_TOKEN_CACHE_PREFIX = 'sd:token:'
|
||||||
|
# Expire a bit early so we re-auth before SD rejects an almost-expired token.
|
||||||
|
_SD_TOKEN_CACHE_SKEW_SECONDS = 60
|
||||||
|
_SD_TOKEN_DEFAULT_TTL_SECONDS = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def sd_token_cache_key(source_id):
|
||||||
|
return f'{_SD_TOKEN_CACHE_PREFIX}{source_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def sd_credential_fingerprint(username, password):
|
||||||
|
"""
|
||||||
|
Stable fingerprint of SD credentials for lockout and token-cache matching.
|
||||||
|
|
||||||
|
Used so a persisted auth lockout or cached token clears automatically when
|
||||||
|
the user changes username or password.
|
||||||
|
"""
|
||||||
|
raw = f'{(username or "").strip()}\0{(password or "").strip()}'
|
||||||
|
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sd_get_cached_token(source_id, username=None, password=None):
|
||||||
|
"""
|
||||||
|
Return a cached SD token string for this source, or None.
|
||||||
|
|
||||||
|
Requires ``username`` / ``password`` so a cached session is only reused when
|
||||||
|
credentials still match (avoids using account A's token after switching to B).
|
||||||
|
On Redis failure, returns None so the caller re-authenticates.
|
||||||
|
"""
|
||||||
|
if source_id is None:
|
||||||
|
return None
|
||||||
|
current_fp = sd_credential_fingerprint(username, password)
|
||||||
|
try:
|
||||||
|
payload = cache.get(sd_token_cache_key(source_id))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"SD token cache get failed for source %s (%s); re-authenticating",
|
||||||
|
source_id,
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
token = payload.get('token')
|
||||||
|
expires = payload.get('expires')
|
||||||
|
if not token or not isinstance(expires, (int, float)):
|
||||||
|
return None
|
||||||
|
if payload.get('fp') != current_fp:
|
||||||
|
sd_clear_cached_token(source_id)
|
||||||
|
return None
|
||||||
|
if time.time() >= float(expires) - _SD_TOKEN_CACHE_SKEW_SECONDS:
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def sd_set_cached_token(source_id, token, expires=None, username=None, password=None):
|
||||||
|
"""
|
||||||
|
Cache an SD token for this source until near tokenExpires.
|
||||||
|
|
||||||
|
``expires`` is a UNIX epoch seconds value from SD's tokenExpires field.
|
||||||
|
Stores a credential fingerprint so callers must still match to reuse it.
|
||||||
|
"""
|
||||||
|
if source_id is None or not token:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
if expires is None:
|
||||||
|
expires = now + _SD_TOKEN_DEFAULT_TTL_SECONDS
|
||||||
|
try:
|
||||||
|
expires = float(expires)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
expires = now + _SD_TOKEN_DEFAULT_TTL_SECONDS
|
||||||
|
ttl = int(expires - now - _SD_TOKEN_CACHE_SKEW_SECONDS)
|
||||||
|
if ttl < 1:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cache.set(
|
||||||
|
sd_token_cache_key(source_id),
|
||||||
|
{
|
||||||
|
'token': token,
|
||||||
|
'expires': expires,
|
||||||
|
'fp': sd_credential_fingerprint(username, password),
|
||||||
|
},
|
||||||
|
timeout=ttl,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"SD token cache set failed for source %s (%s)",
|
||||||
|
source_id,
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sd_clear_cached_token(source_id):
|
||||||
|
"""Drop a cached SD token (e.g. after 401/403 from an image request)."""
|
||||||
|
if source_id is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cache.delete(sd_token_cache_key(source_id))
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"SD token cache delete failed for source %s (%s)",
|
||||||
|
source_id,
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sd_auth_failure_message(code, sd_message=None):
|
||||||
|
"""Human-readable message for a Schedules Direct /token error code."""
|
||||||
|
if code == SD_CODE_SERVICE_OFFLINE:
|
||||||
|
return (
|
||||||
|
"Schedules Direct is offline for maintenance. "
|
||||||
|
"Do not retry for at least 1 hour."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_SERVICE_BUSY:
|
||||||
|
return "Schedules Direct is busy. Stop requesting and retry later."
|
||||||
|
if code == SD_CODE_ACCOUNT_EXPIRED:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: account has expired. Please renew your "
|
||||||
|
"subscription at schedulesdirect.org."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_INVALID_PASSWORD_HASH:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: invalid password hash. Check that credentials "
|
||||||
|
"are stored correctly."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_INVALID_USER_OR_PASSWORD:
|
||||||
|
return "Schedules Direct: invalid username or password."
|
||||||
|
if code == SD_CODE_ACCOUNT_LOCKED:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: account locked due to too many failed login "
|
||||||
|
"attempts. Try again in 15 minutes."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_JSON_DISABLED:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: JSON API access is disabled for this account. "
|
||||||
|
"Contact Schedules Direct support."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_APP_NOT_AUTHORIZED:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: this application is not authorized. Please "
|
||||||
|
"contact the Dispatcharr maintainers."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_ACCOUNT_INACTIVE:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: account is inactive. Please log in to "
|
||||||
|
"schedulesdirect.org to reactivate."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_TOO_MANY_LOGINS:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: too many login attempts in 24 hours. Token is "
|
||||||
|
"valid for 24 hours. Check for misconfiguration."
|
||||||
|
)
|
||||||
|
if code == SD_CODE_TOO_MANY_UNIQUE_IPS:
|
||||||
|
return (
|
||||||
|
"Schedules Direct: too many unique IP addresses in 24 hours. "
|
||||||
|
"Avoid VPNs or multiple locations, or contact SD support to raise "
|
||||||
|
"the limit."
|
||||||
|
)
|
||||||
|
if sd_message:
|
||||||
|
return f"Schedules Direct authentication failed (code {code}): {sd_message}"
|
||||||
|
return f"Schedules Direct authentication failed (code {code})."
|
||||||
|
|
||||||
|
|
||||||
|
def sd_token_response_code(auth_data):
|
||||||
|
"""Return integer SD ``code`` from a /token JSON body, or 0."""
|
||||||
|
if not isinstance(auth_data, dict):
|
||||||
|
return 0
|
||||||
|
code = auth_data.get('code', 0)
|
||||||
|
return code if isinstance(code, int) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def sd_auth_lockout_active(source, username=None, password=None):
|
||||||
|
"""
|
||||||
|
Return (active, reason, code) for a persisted auth lockout.
|
||||||
|
|
||||||
|
Cleared when username/password changed, or sd_auth_lockout_until has passed.
|
||||||
|
"""
|
||||||
|
if source is None:
|
||||||
|
return False, None, None
|
||||||
|
|
||||||
|
cp = source.custom_properties or {}
|
||||||
|
if not cp.get('sd_auth_lockout'):
|
||||||
|
return False, None, None
|
||||||
|
|
||||||
|
until_str = cp.get('sd_auth_lockout_until')
|
||||||
|
until = parse_datetime(until_str) if until_str else None
|
||||||
|
if until is None or timezone.now() >= until:
|
||||||
|
sd_clear_auth_lockout(source)
|
||||||
|
return False, None, None
|
||||||
|
|
||||||
|
if username is None:
|
||||||
|
username = source.username
|
||||||
|
if password is None:
|
||||||
|
password = source.password
|
||||||
|
|
||||||
|
stored_fp = cp.get('sd_auth_lockout_credential_fp')
|
||||||
|
current_fp = sd_credential_fingerprint(username, password)
|
||||||
|
if not stored_fp or stored_fp != current_fp:
|
||||||
|
sd_clear_auth_lockout(source)
|
||||||
|
return False, None, None
|
||||||
|
|
||||||
|
code = cp.get('sd_auth_lockout_code')
|
||||||
|
reason = cp.get('sd_auth_lockout_reason') or (
|
||||||
|
'Schedules Direct authentication is temporarily blocked.'
|
||||||
|
)
|
||||||
|
return True, reason, code if isinstance(code, int) else None
|
||||||
|
|
||||||
|
|
||||||
|
def sd_save_auth_lockout(source, code, username=None, password=None, reason=None):
|
||||||
|
"""
|
||||||
|
Persist an auth lockout so we stop calling /token for a cooldown period.
|
||||||
|
|
||||||
|
Duration depends on the code (15m for 4004, 1h for offline/busy, else 24h).
|
||||||
|
Cleared early if username/password change.
|
||||||
|
"""
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
if code not in SD_AUTH_LOCKOUT_CODES:
|
||||||
|
return
|
||||||
|
|
||||||
|
if username is None:
|
||||||
|
username = source.username
|
||||||
|
if password is None:
|
||||||
|
password = source.password
|
||||||
|
|
||||||
|
if reason is None:
|
||||||
|
reason = sd_auth_failure_message(code)
|
||||||
|
|
||||||
|
seconds = sd_auth_lockout_seconds_for_code(code)
|
||||||
|
until = timezone.now() + timedelta(seconds=seconds)
|
||||||
|
cp = dict(source.custom_properties or {})
|
||||||
|
cp['sd_auth_lockout'] = True
|
||||||
|
cp['sd_auth_lockout_code'] = code
|
||||||
|
cp['sd_auth_lockout_reason'] = reason
|
||||||
|
cp['sd_auth_lockout_until'] = until.isoformat()
|
||||||
|
cp['sd_auth_lockout_credential_fp'] = sd_credential_fingerprint(
|
||||||
|
username, password
|
||||||
|
)
|
||||||
|
source.custom_properties = cp
|
||||||
|
source.save(update_fields=['custom_properties'])
|
||||||
|
sd_clear_cached_token(source.id)
|
||||||
|
logger.warning(
|
||||||
|
"SD source %s: auth lockout (code %s). Not calling /token until "
|
||||||
|
"username or password changes, or after %s.",
|
||||||
|
source.id,
|
||||||
|
code,
|
||||||
|
until.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sd_clear_auth_lockout(source):
|
||||||
|
"""Clear a persisted auth lockout (success, credentials changed, or expired)."""
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
cp = dict(source.custom_properties or {})
|
||||||
|
changed = False
|
||||||
|
for key in (
|
||||||
|
'sd_auth_lockout',
|
||||||
|
'sd_auth_lockout_code',
|
||||||
|
'sd_auth_lockout_reason',
|
||||||
|
'sd_auth_lockout_until',
|
||||||
|
'sd_auth_lockout_credential_fp',
|
||||||
|
):
|
||||||
|
if key in cp:
|
||||||
|
cp.pop(key, None)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
source.custom_properties = cp
|
||||||
|
source.save(update_fields=['custom_properties'])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SDTokenAuthResult:
|
||||||
|
"""Outcome of POST /token (or a short-circuit from a persisted lockout)."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
token: str | None = None
|
||||||
|
token_expires: float | None = None
|
||||||
|
code: int | None = None
|
||||||
|
message: str = ''
|
||||||
|
soft: bool = False
|
||||||
|
debug_rejected: bool = False
|
||||||
|
lockout: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
_DEBUG_REJECTED_MESSAGE = (
|
||||||
|
"Schedules Direct rejected the debug routing header (code 2055). "
|
||||||
|
"Extra Schedules Direct Debugging has been turned off. Retry without it "
|
||||||
|
"unless Schedules Direct support asked you to enable it."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sd_obtain_token(source, username=None, password=None, *, timeout=30):
|
||||||
|
"""
|
||||||
|
Return a Schedules Direct session token, reusing a cached one when valid.
|
||||||
|
|
||||||
|
Shared by refresh, lineup/form auth, and the poster proxy. Checks Redis for
|
||||||
|
an unexpired token bound to the current credentials before POSTing /token.
|
||||||
|
Honors persisted lockouts, reads JSON ``code`` before HTTP status, and
|
||||||
|
persists cooldowns for codes that must not be retried until cleared.
|
||||||
|
"""
|
||||||
|
if source is None:
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
message='Schedules Direct source is required.',
|
||||||
|
)
|
||||||
|
|
||||||
|
if username is None:
|
||||||
|
username = (source.username or '').strip()
|
||||||
|
else:
|
||||||
|
username = (username or '').strip()
|
||||||
|
if password is None:
|
||||||
|
password = (source.password or '').strip()
|
||||||
|
else:
|
||||||
|
password = (password or '').strip()
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
message='Username and password are required.',
|
||||||
|
)
|
||||||
|
|
||||||
|
active, reason, lockout_code = sd_auth_lockout_active(
|
||||||
|
source, username, password
|
||||||
|
)
|
||||||
|
if active:
|
||||||
|
msg = f"{reason} {sd_auth_lockout_retry_message()}"
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
code=lockout_code,
|
||||||
|
message=msg,
|
||||||
|
soft=lockout_code in SD_AUTH_SOFT_CODES if lockout_code else False,
|
||||||
|
lockout=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cached = sd_get_cached_token(source.id, username=username, password=password)
|
||||||
|
if cached:
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=True,
|
||||||
|
token=cached,
|
||||||
|
code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{SD_BASE_URL}/token",
|
||||||
|
json={'username': username, 'password': sha1_password},
|
||||||
|
headers=sd_headers_for_source(source),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
message=f'Network error authenticating with Schedules Direct: {exc}',
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth_data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
auth_data = {}
|
||||||
|
|
||||||
|
if sd_handle_2055(source, auth_data):
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
code=SD_CODE_INVALID_DEBUG,
|
||||||
|
message=_DEBUG_REJECTED_MESSAGE,
|
||||||
|
debug_rejected=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Honor JSON ``code`` before raise_for_status. SD error semantics are in
|
||||||
|
# the body; HTTP status alone is not reliable for auth failures.
|
||||||
|
auth_code = sd_token_response_code(auth_data)
|
||||||
|
if auth_code != 0:
|
||||||
|
msg = sd_auth_failure_message(
|
||||||
|
auth_code, auth_data.get('message', 'Unknown error')
|
||||||
|
)
|
||||||
|
soft = auth_code in SD_AUTH_SOFT_CODES
|
||||||
|
locked = False
|
||||||
|
if auth_code in SD_AUTH_LOCKOUT_CODES:
|
||||||
|
sd_save_auth_lockout(
|
||||||
|
source, auth_code, username, password, reason=msg
|
||||||
|
)
|
||||||
|
msg = f"{msg} {sd_auth_lockout_retry_message()}"
|
||||||
|
locked = True
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
code=auth_code,
|
||||||
|
message=msg,
|
||||||
|
soft=soft,
|
||||||
|
lockout=locked,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
message=f'Network error authenticating with Schedules Direct: {exc}',
|
||||||
|
)
|
||||||
|
|
||||||
|
token = auth_data.get('token') if isinstance(auth_data, dict) else None
|
||||||
|
if not token:
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=False,
|
||||||
|
message='Schedules Direct returned no token.',
|
||||||
|
)
|
||||||
|
|
||||||
|
sd_clear_auth_lockout(source)
|
||||||
|
expires = None
|
||||||
|
if isinstance(auth_data, dict):
|
||||||
|
expires = auth_data.get('tokenExpires')
|
||||||
|
if not isinstance(expires, (int, float)):
|
||||||
|
expires = time.time() + _SD_TOKEN_DEFAULT_TTL_SECONDS
|
||||||
|
expires = float(expires)
|
||||||
|
sd_set_cached_token(
|
||||||
|
source.id, token, expires, username=username, password=password
|
||||||
|
)
|
||||||
|
return SDTokenAuthResult(
|
||||||
|
ok=True,
|
||||||
|
token=token,
|
||||||
|
token_expires=expires,
|
||||||
|
code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sd_authorized_request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
*,
|
||||||
|
source,
|
||||||
|
token,
|
||||||
|
username=None,
|
||||||
|
password=None,
|
||||||
|
timeout=30,
|
||||||
|
content_type='application/json',
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Perform an authenticated Schedules Direct HTTP request.
|
||||||
|
|
||||||
|
On HTTP 401/403 (SD documents TOKEN_EXPIRED as 403 + code 4006), clears the
|
||||||
|
Redis token cache, obtains a fresh token, and retries the request once.
|
||||||
|
|
||||||
|
Returns ``(response, token)`` where ``token`` may have been refreshed.
|
||||||
|
"""
|
||||||
|
method_upper = (method or 'GET').upper()
|
||||||
|
http_fn = {
|
||||||
|
'GET': requests.get,
|
||||||
|
'POST': requests.post,
|
||||||
|
'PUT': requests.put,
|
||||||
|
'DELETE': requests.delete,
|
||||||
|
'HEAD': requests.head,
|
||||||
|
'PATCH': requests.patch,
|
||||||
|
}.get(method_upper)
|
||||||
|
|
||||||
|
def _once(current_token):
|
||||||
|
headers = sd_headers_for_source(
|
||||||
|
source,
|
||||||
|
token=current_token,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
if http_fn is not None:
|
||||||
|
return http_fn(url, headers=headers, timeout=timeout, **kwargs)
|
||||||
|
return requests.request(
|
||||||
|
method_upper, url, headers=headers, timeout=timeout, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _once(token)
|
||||||
|
if response.status_code not in (401, 403):
|
||||||
|
return response, token
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"SD source %s: %s %s returned %s; clearing cached token and retrying once",
|
||||||
|
getattr(source, 'id', None),
|
||||||
|
method_upper,
|
||||||
|
url,
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
sd_clear_cached_token(getattr(source, 'id', None))
|
||||||
|
auth_timeout = timeout if isinstance(timeout, (int, float)) else 30
|
||||||
|
auth = sd_obtain_token(
|
||||||
|
source,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=min(int(auth_timeout), 30) if auth_timeout else 30,
|
||||||
|
)
|
||||||
|
if not auth.ok or not auth.token:
|
||||||
|
return response, token
|
||||||
|
|
||||||
|
retry_response = _once(auth.token)
|
||||||
|
return retry_response, auth.token
|
||||||
|
|
||||||
|
|
||||||
|
def sd_next_midnight_utc():
|
||||||
|
"""Return the next Schedules Direct counter reset (00:00Z)."""
|
||||||
|
now = timezone.now()
|
||||||
|
return (now + timedelta(days=1)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0,
|
||||||
|
tzinfo=dt_timezone.utc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sd_headers_for_source(source, *, token=None, content_type='application/json'):
|
||||||
|
"""
|
||||||
|
Build outbound headers for Schedules Direct requests for this source.
|
||||||
|
|
||||||
|
When Extra Schedules Direct Debugging is enabled, adds RouteTo: debug so the
|
||||||
|
SD load balancer can steer traffic to their debug server (support-coordinated).
|
||||||
|
"""
|
||||||
|
cp = source.custom_properties or {} if source is not None else {}
|
||||||
|
route_to = 'debug' if cp.get('sd_extra_debugging') else None
|
||||||
|
return dispatcharr_http_headers(
|
||||||
|
token=token,
|
||||||
|
content_type=content_type,
|
||||||
|
route_to=route_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sd_disable_extra_debugging(source):
|
||||||
|
"""Clear the user-facing debug toggle after SD returns code 2055."""
|
||||||
|
if source is None:
|
||||||
|
return False
|
||||||
|
cp = dict(source.custom_properties or {})
|
||||||
|
if not cp.get('sd_extra_debugging'):
|
||||||
|
return False
|
||||||
|
cp['sd_extra_debugging'] = False
|
||||||
|
source.custom_properties = cp
|
||||||
|
source.save(update_fields=['custom_properties'])
|
||||||
|
logger.warning(
|
||||||
|
"SD source %s: received code 2055 (unexpected debug connection). "
|
||||||
|
"Disabled Extra Schedules Direct Debugging.",
|
||||||
|
source.id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sd_handle_2055(source, data):
|
||||||
|
"""
|
||||||
|
If data is an SD JSON error with code 2055, disable debug routing.
|
||||||
|
|
||||||
|
Returns True when 2055 was handled.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return False
|
||||||
|
if data.get('code') != SD_CODE_INVALID_DEBUG:
|
||||||
|
return False
|
||||||
|
sd_disable_extra_debugging(source)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sd_parse_response_payload(response):
|
||||||
|
"""Return (code, data_dict_or_None) for an SD HTTP response body."""
|
||||||
|
if response is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
content_type = (response.headers.get('Content-Type') or '').lower()
|
||||||
|
body = response.content or b''
|
||||||
|
looks_json = (
|
||||||
|
'json' in content_type
|
||||||
|
or body.lstrip()[:1] in (b'{', b'[')
|
||||||
|
)
|
||||||
|
if not looks_json:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, None
|
||||||
|
code = data.get('code')
|
||||||
|
return (code if isinstance(code, int) else None), data
|
||||||
|
|
||||||
|
|
||||||
|
def sd_image_limit_active(source):
|
||||||
|
"""
|
||||||
|
Return (active: bool, reason: str|None) for a persisted image download lockout.
|
||||||
|
|
||||||
|
Clears the lockout automatically once the next midnight UTC has passed.
|
||||||
|
"""
|
||||||
|
if source is None:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
cp = source.custom_properties or {}
|
||||||
|
if not cp.get('sd_image_limit_hit'):
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
reset_at_str = cp.get('sd_image_limit_reset_at')
|
||||||
|
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
||||||
|
if reset_at and timezone.now() >= reset_at:
|
||||||
|
sd_clear_image_limit_lockout(source)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
reason = cp.get('sd_image_limit_reason') or (
|
||||||
|
'Daily image download limit reached'
|
||||||
|
)
|
||||||
|
return True, reason
|
||||||
|
|
||||||
|
|
||||||
|
def sd_save_image_limit_lockout(source, code):
|
||||||
|
"""
|
||||||
|
Persist a source-wide image download lockout until next 00:00Z.
|
||||||
|
|
||||||
|
Used for SD codes 5002 (subscriber) and 5003 (trial).
|
||||||
|
"""
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
reset_at = sd_next_midnight_utc()
|
||||||
|
reason = (
|
||||||
|
f'Daily image download limit reached (SD error {code})'
|
||||||
|
if code
|
||||||
|
else 'Daily image download limit reached'
|
||||||
|
)
|
||||||
|
cp = dict(source.custom_properties or {})
|
||||||
|
cp['sd_image_limit_hit'] = True
|
||||||
|
cp['sd_image_limit_reset_at'] = reset_at.isoformat()
|
||||||
|
cp['sd_image_limit_reason'] = reason
|
||||||
|
source.custom_properties = cp
|
||||||
|
source.save(update_fields=['custom_properties'])
|
||||||
|
logger.warning(
|
||||||
|
"SD source %s: image download limit (code %s). Lockout until %s.",
|
||||||
|
source.id,
|
||||||
|
code,
|
||||||
|
reset_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sd_clear_image_limit_lockout(source):
|
||||||
|
"""Clear a persisted image download lockout after midnight UTC."""
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
cp = dict(source.custom_properties or {})
|
||||||
|
changed = False
|
||||||
|
for key in (
|
||||||
|
'sd_image_limit_hit',
|
||||||
|
'sd_image_limit_reset_at',
|
||||||
|
'sd_image_limit_reason',
|
||||||
|
):
|
||||||
|
if key in cp:
|
||||||
|
cp.pop(key, None)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
source.custom_properties = cp
|
||||||
|
source.save(update_fields=['custom_properties'])
|
||||||
|
|
||||||
|
|
||||||
|
def sd_mark_icon_missing(program):
|
||||||
|
"""
|
||||||
|
Clear a bad image URI so we never re-request it (SD code 5000).
|
||||||
|
|
||||||
|
Continues requesting the same missing URI can accumulate toward code 5004
|
||||||
|
and get the account blocked.
|
||||||
|
"""
|
||||||
|
if program is None:
|
||||||
|
return
|
||||||
|
cp = dict(program.custom_properties or {})
|
||||||
|
if 'sd_icon' not in cp and cp.get('sd_icon_missing'):
|
||||||
|
return
|
||||||
|
cp.pop('sd_icon', None)
|
||||||
|
cp['sd_icon_missing'] = True
|
||||||
|
program.custom_properties = cp
|
||||||
|
program.save(update_fields=['custom_properties'])
|
||||||
|
logger.info(
|
||||||
|
"SD program %s: IMAGE_NOT_FOUND (5000). Cleared sd_icon to avoid retries.",
|
||||||
|
program.id,
|
||||||
|
)
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
from apps.epg.sd_utils import (
|
||||||
|
sd_clear_cached_token,
|
||||||
|
sd_credential_fingerprint,
|
||||||
|
)
|
||||||
|
from apps.epg.utils import sd_poster_proxy_path
|
||||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import EPGSource, EPGData, ProgramData
|
from .models import EPGSource, EPGData, ProgramData
|
||||||
|
|
@ -67,11 +72,16 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
||||||
ct = 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}'
|
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
|
||||||
instance._cron_expression = cron_expr
|
instance._cron_expression = cron_expr
|
||||||
|
prior_fp = sd_credential_fingerprint(instance.username, instance.password)
|
||||||
for attr, value in validated_data.items():
|
for attr, value in validated_data.items():
|
||||||
if attr == 'password' and not value:
|
if attr == 'password' and not value:
|
||||||
continue
|
continue
|
||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
instance.save()
|
instance.save()
|
||||||
|
# Drop any Redis SD session tied to the previous username/password so
|
||||||
|
# poster traffic cannot keep using another account's token.
|
||||||
|
if prior_fp != sd_credential_fingerprint(instance.username, instance.password):
|
||||||
|
sd_clear_cached_token(instance.id)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
|
@ -169,9 +179,11 @@ class ProgramDetailSerializer(ProgramDataSerializer):
|
||||||
data['icon'] = cp.get('icon')
|
data['icon'] = cp.get('icon')
|
||||||
data['images'] = cp.get('images') or []
|
data['images'] = cp.get('images') or []
|
||||||
|
|
||||||
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth
|
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth.
|
||||||
if cp.get('sd_icon'):
|
# ``?v=`` tracks the sd_icon URI so nginx/browser caches bust when artwork changes.
|
||||||
poster_path = f"/api/epg/programs/{obj.id}/poster/"
|
sd_icon = cp.get('sd_icon')
|
||||||
|
if sd_icon:
|
||||||
|
poster_path = sd_poster_proxy_path(obj.id, sd_icon)
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
if request:
|
if request:
|
||||||
data['poster_url'] = build_absolute_uri_with_port(request, poster_path)
|
data['poster_url'] = build_absolute_uri_with_port(request, poster_path)
|
||||||
|
|
|
||||||
2986
apps/epg/tasks.py
2986
apps/epg/tasks.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,195 +1,149 @@
|
||||||
|
"""Tests for HTML entity resolution in XMLTV parsing (DOCTYPE injection path)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
from apps.epg.tasks import (
|
from apps.epg.tasks import _open_xmltv_file, _parse_programme_element
|
||||||
_NAMED_ENTITY_RE,
|
|
||||||
_detect_xml_encoding,
|
|
||||||
_replace_html_entity,
|
|
||||||
_resolve_html_entities,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReplaceHtmlEntityTests(TestCase):
|
def _parse_title(entity_text: str) -> str:
|
||||||
"""Tests for the regex callback that resolves individual HTML entities."""
|
"""Resolve entities the same way production programme parsing does."""
|
||||||
|
xml = (
|
||||||
|
f'<programme start="20000101000000 +0000" '
|
||||||
|
f'stop="20000101000000 +0000" channel="test.ch">'
|
||||||
|
f"<title>{entity_text}</title></programme>"
|
||||||
|
).encode("utf-8")
|
||||||
|
root = _parse_programme_element(xml)
|
||||||
|
return root.findtext("title")
|
||||||
|
|
||||||
def _sub(self, text):
|
|
||||||
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text)
|
def _read_display_name_from_file(path: str) -> str:
|
||||||
|
with _open_xmltv_file(path) as handle:
|
||||||
|
for _event, elem in etree.iterparse(handle, events=("end",), tag="display-name"):
|
||||||
|
text = (elem.text or "").strip()
|
||||||
|
elem.clear()
|
||||||
|
while elem.getprevious() is not None:
|
||||||
|
del elem.getparent()[0]
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class ParseProgrammeElementEntityTests(TestCase):
|
||||||
|
"""Entity resolution via _parse_programme_element + injected DOCTYPE."""
|
||||||
|
|
||||||
def test_french_accented(self):
|
def test_french_accented(self):
|
||||||
self.assertEqual(self._sub("Chaîne Télé"), "Chaîne Télé")
|
self.assertEqual(_parse_title("Chaîne Télé"), "Chaîne Télé")
|
||||||
|
|
||||||
def test_german_umlauts(self):
|
def test_german_umlauts(self):
|
||||||
self.assertEqual(self._sub("München Übersicht ß"), "München Übersicht ß")
|
self.assertEqual(
|
||||||
|
_parse_title("München Übersicht ß"),
|
||||||
|
"München Übersicht ß",
|
||||||
|
)
|
||||||
|
|
||||||
def test_spanish(self):
|
def test_spanish(self):
|
||||||
self.assertEqual(self._sub("España ¿Qué?"), "España ¿Qué?")
|
self.assertEqual(_parse_title("España ¿Qué?"), "España ¿Qué?")
|
||||||
|
|
||||||
def test_portuguese(self):
|
def test_portuguese(self):
|
||||||
self.assertEqual(self._sub("Comunicação"), "Comunicação")
|
self.assertEqual(_parse_title("Comunicação"), "Comunicação")
|
||||||
|
|
||||||
def test_scandinavian(self):
|
def test_scandinavian(self):
|
||||||
self.assertEqual(self._sub("Norsk ø å æ"), "Norsk ø å æ")
|
self.assertEqual(_parse_title("Norsk ø å æ"), "Norsk ø å æ")
|
||||||
|
|
||||||
def test_greek_letters(self):
|
def test_greek_letters(self):
|
||||||
self.assertEqual(self._sub("αβγ"), "αβγ")
|
self.assertEqual(_parse_title("αβγ"), "αβγ")
|
||||||
|
|
||||||
def test_currency_and_symbols(self):
|
def test_currency_and_symbols(self):
|
||||||
self.assertEqual(self._sub("© € £ ¥"), "© € £ ¥")
|
self.assertEqual(_parse_title("© € £ ¥"), "© € £ ¥")
|
||||||
|
|
||||||
def test_preserves_xml_amp(self):
|
def test_preserves_xml_amp(self):
|
||||||
self.assertEqual(self._sub("A & B"), "A & B")
|
self.assertEqual(_parse_title("A & B"), "A & B")
|
||||||
|
|
||||||
def test_preserves_xml_lt_gt(self):
|
def test_preserves_xml_lt_gt(self):
|
||||||
self.assertEqual(self._sub("<tag>"), "<tag>")
|
self.assertEqual(_parse_title("<tag>"), "<tag>")
|
||||||
|
|
||||||
def test_preserves_xml_quot_apos(self):
|
def test_preserves_xml_quot_apos(self):
|
||||||
self.assertEqual(self._sub(""hello'"), ""hello'")
|
self.assertEqual(_parse_title(""hello'"), '"hello\'')
|
||||||
|
|
||||||
def test_preserves_uppercase_xml_entities(self):
|
|
||||||
"""&, <, >, " resolve to XML-special chars; must not be replaced."""
|
|
||||||
self.assertEqual(self._sub("&"), "&")
|
|
||||||
self.assertEqual(self._sub("<"), "<")
|
|
||||||
self.assertEqual(self._sub(">"), ">")
|
|
||||||
self.assertEqual(self._sub("""), """)
|
|
||||||
|
|
||||||
def test_partial_entity_match_preserved(self):
|
|
||||||
"""html.unescape can partially match & inside &ersand; — must not corrupt."""
|
|
||||||
self.assertEqual(self._sub("&ersand;"), "&ersand;")
|
|
||||||
|
|
||||||
def test_mixed_html_and_xml_entities(self):
|
def test_mixed_html_and_xml_entities(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self._sub("Résumé & Co <test>"),
|
_parse_title("Résumé & Co <test>"),
|
||||||
"Résumé & Co <test>",
|
"Résumé & Co <test>",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_plain_ascii_unchanged(self):
|
def test_plain_ascii_unchanged(self):
|
||||||
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text")
|
self.assertEqual(_parse_title("Plain ASCII text"), "Plain ASCII text")
|
||||||
|
|
||||||
def test_direct_utf8_unchanged(self):
|
def test_direct_utf8_unchanged(self):
|
||||||
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ")
|
self.assertEqual(_parse_title("日本語テレビ"), "日本語テレビ")
|
||||||
|
|
||||||
def test_unknown_entity_preserved(self):
|
|
||||||
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
|
|
||||||
|
|
||||||
|
|
||||||
class ResolveHtmlEntitiesFileTests(TestCase):
|
class OpenXmltvFileEntityTests(TestCase):
|
||||||
"""Tests for the file-level preprocessing function."""
|
"""Entity resolution via _open_xmltv_file streaming (no disk rewrite)."""
|
||||||
|
|
||||||
def _make_file(self, content):
|
def _make_file(self, content, *, binary=False):
|
||||||
fd, path = tempfile.mkstemp(suffix=".xml")
|
fd, path = tempfile.mkstemp(suffix=".xml")
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
with os.fdopen(fd, "wb" if binary else "w", encoding=None if binary else "utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
|
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def test_resolves_entities_in_file(self):
|
def test_resolves_entities_in_file(self):
|
||||||
path = self._make_file(
|
path = self._make_file(
|
||||||
'<?xml version="1.0"?>\n<tv><channel><display-name>Télé</display-name></channel></tv>'
|
'<?xml version="1.0"?>\n'
|
||||||
|
"<tv><channel><display-name>Télé</display-name></channel></tv>"
|
||||||
)
|
)
|
||||||
_resolve_html_entities(path)
|
self.assertEqual(_read_display_name_from_file(path), "Télé")
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
self.assertIn("é", f.read(), "Source file must not be rewritten on disk")
|
||||||
self.assertIn("Télé", content)
|
|
||||||
self.assertNotIn("é", content)
|
|
||||||
|
|
||||||
def test_preserves_xml_entities_in_file(self):
|
def test_preserves_xml_entities_in_file(self):
|
||||||
path = self._make_file("<tv><desc>A & B <C></desc></tv>")
|
path = self._make_file("<tv><channel><display-name>A & B</display-name></channel></tv>")
|
||||||
_resolve_html_entities(path)
|
self.assertEqual(_read_display_name_from_file(path), "A & B")
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
self.assertIn("&", content)
|
|
||||||
self.assertIn("<", content)
|
|
||||||
self.assertIn(">", content)
|
|
||||||
|
|
||||||
def test_no_temp_file_left_on_success(self):
|
def test_plain_file_unchanged_on_disk(self):
|
||||||
path = self._make_file("<tv>test</tv>")
|
original = (
|
||||||
_resolve_html_entities(path)
|
'<?xml version="1.0"?>\n'
|
||||||
self.assertFalse(os.path.exists(path + ".entity_tmp"))
|
"<tv><channel><display-name>Plain</display-name></channel></tv>"
|
||||||
|
)
|
||||||
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)
|
path = self._make_file(original)
|
||||||
_resolve_html_entities(path)
|
_read_display_name_from_file(path)
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
self.assertEqual(f.read(), original)
|
||||||
self.assertEqual(content, original)
|
|
||||||
|
|
||||||
def test_utf8_content_preserved(self):
|
def test_utf8_content_preserved(self):
|
||||||
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
|
path = self._make_file(
|
||||||
path = self._make_file(original)
|
"<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
|
||||||
_resolve_html_entities(path)
|
)
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
self.assertEqual(_read_display_name_from_file(path), "日本語テレビ")
|
||||||
content = f.read()
|
|
||||||
self.assertIn("日本語テレビ", content)
|
|
||||||
|
|
||||||
def test_iso_8859_1_encoding(self):
|
def test_iso_8859_1_encoding(self):
|
||||||
"""Files declaring ISO-8859-1 should be read in that encoding."""
|
xml = (
|
||||||
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Chaîne</display-name></channel></tv>'
|
'<?xml version="1.0" encoding="ISO-8859-1"?>\n'
|
||||||
fd, path = tempfile.mkstemp(suffix=".xml")
|
"<tv><channel><display-name>Chaîne</display-name></channel></tv>"
|
||||||
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("î", 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",
|
|
||||||
)
|
)
|
||||||
|
path = self._make_file(xml.encode("iso-8859-1"), binary=True)
|
||||||
|
self.assertEqual(_read_display_name_from_file(path), "Cha\u00eene")
|
||||||
|
|
||||||
def test_iso_8859_1_with_entities_roundtrip(self):
|
def test_iso_8859_1_with_entities_roundtrip(self):
|
||||||
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars."""
|
xml_str = (
|
||||||
# Mix of direct ISO-8859-1 chars and HTML entities
|
'<?xml version="1.0" encoding="ISO-8859-1"?>\n'
|
||||||
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 émission</display-name></channel></tv>'
|
"<tv><channel><display-name>D\xe9j\xe0 émission</display-name></channel></tv>"
|
||||||
fd, path = tempfile.mkstemp(suffix=".xml")
|
)
|
||||||
with os.fdopen(fd, "wb") as f:
|
path = self._make_file(xml_str.encode("iso-8859-1"), binary=True)
|
||||||
f.write(xml_str.encode("iso-8859-1"))
|
name = _read_display_name_from_file(path)
|
||||||
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
|
self.assertIn("D\xe9j\xe0", name)
|
||||||
|
self.assertIn("\xe9mission", name)
|
||||||
|
|
||||||
_resolve_html_entities(path)
|
def test_existing_doctype_not_doubled(self):
|
||||||
with open(path, "r", encoding="iso-8859-1") as f:
|
"""Files that already declare DOCTYPE are opened without injecting another."""
|
||||||
content = f.read()
|
path = self._make_file(
|
||||||
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved")
|
'<!DOCTYPE tv SYSTEM "xmltv.dtd">\n'
|
||||||
self.assertIn("\xe9mission", content, "Entity should be resolved")
|
"<tv><channel><display-name>Has Doctype</display-name></channel></tv>"
|
||||||
self.assertNotIn("é", 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:
|
with open(path, "rb") as f:
|
||||||
result_bytes = f.read()
|
original = f.read()
|
||||||
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error")
|
with _open_xmltv_file(path) as handle:
|
||||||
|
self.assertEqual(handle.read(len(original)), original)
|
||||||
|
|
|
||||||
98
apps/epg/tests/test_epg_import_api.py
Normal file
98
apps/epg/tests/test_epg_import_api.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import connection
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.epg.models import EPGSource, EPGSourceIndex
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
IMPORT_URL = "/api/epg/import/"
|
||||||
|
|
||||||
|
|
||||||
|
class EPGImportAPITests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="epg_import_admin", password="testpass123"
|
||||||
|
)
|
||||||
|
self.user.user_level = 10
|
||||||
|
self.user.save()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||||
|
def test_import_dummy_source_rejected_without_dispatch(self, mock_delay):
|
||||||
|
source = EPGSource.objects.create(
|
||||||
|
name="Dummy EPG",
|
||||||
|
source_type="dummy",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
IMPORT_URL, {"id": source.id}, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertFalse(response.data["success"])
|
||||||
|
mock_delay.assert_not_called()
|
||||||
|
|
||||||
|
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||||
|
def test_import_xmltv_dispatches_without_loading_programme_index(
|
||||||
|
self, mock_delay
|
||||||
|
):
|
||||||
|
source = EPGSource.objects.create(
|
||||||
|
name="Large Index XMLTV",
|
||||||
|
source_type="xmltv",
|
||||||
|
url="http://example.com/epg.xml",
|
||||||
|
)
|
||||||
|
EPGSourceIndex.objects.create(
|
||||||
|
source=source,
|
||||||
|
data={
|
||||||
|
"channels": {f"ch.{i}": {"offsets": [0, 100]} for i in range(200)},
|
||||||
|
"interleaved_channels": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_delay.reset_mock()
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = self.client.post(
|
||||||
|
IMPORT_URL, {"id": source.id}, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||||
|
self.assertTrue(response.data["success"])
|
||||||
|
mock_delay.assert_called_once_with(source.id, force=False)
|
||||||
|
for query in ctx.captured_queries:
|
||||||
|
self.assertNotIn(
|
||||||
|
"programme_index",
|
||||||
|
query["sql"].lower(),
|
||||||
|
"import trigger should not read programme_index",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||||
|
def test_import_missing_source_still_dispatches(self, mock_delay):
|
||||||
|
response = self.client.post(IMPORT_URL, {"id": 99999}, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||||
|
mock_delay.assert_called_once_with(99999, force=False)
|
||||||
|
|
||||||
|
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||||
|
def test_import_honours_force_flag(self, mock_delay):
|
||||||
|
source = EPGSource.objects.create(
|
||||||
|
name="Force XMLTV",
|
||||||
|
source_type="xmltv",
|
||||||
|
url="http://example.com/epg.xml",
|
||||||
|
)
|
||||||
|
mock_delay.reset_mock()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
IMPORT_URL,
|
||||||
|
{"id": source.id, "force": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||||
|
mock_delay.assert_called_once_with(source.id, force=True)
|
||||||
|
|
@ -7,6 +7,7 @@ from rest_framework import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.epg.models import EPGData, EPGSource, ProgramData
|
from apps.epg.models import EPGData, EPGSource, ProgramData
|
||||||
|
from apps.channels.models import Channel
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
@ -63,6 +64,13 @@ class ProgramSearchAPIViewTests(TestCase):
|
||||||
|
|
||||||
cls.now = now
|
cls.now = now
|
||||||
cls.user = User.objects.create_user(username="testuser", password="pass", user_level=1)
|
cls.user = User.objects.create_user(username="testuser", password="pass", user_level=1)
|
||||||
|
# Standard users only see programs for EPG entries linked to accessible channels.
|
||||||
|
Channel.objects.create(
|
||||||
|
name="Test Channel",
|
||||||
|
channel_number=1,
|
||||||
|
epg_data=cls.epg,
|
||||||
|
user_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = APIClient(REMOTE_ADDR="127.0.0.1")
|
self.client = APIClient(REMOTE_ADDR="127.0.0.1")
|
||||||
|
|
|
||||||
95
apps/epg/tests/test_epg_source_file_lock.py
Normal file
95
apps/epg/tests/test_epg_source_file_lock.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
|
||||||
|
from apps.epg.models import EPGSource, EPGData
|
||||||
|
from apps.epg.tasks import (
|
||||||
|
_EPG_PARSE_DEFER_MAX,
|
||||||
|
_EPG_REFRESH_DEFER_SECONDS,
|
||||||
|
_defer_parse_programs_for_tvg_id,
|
||||||
|
_refresh_epg_data_impl,
|
||||||
|
parse_programs_for_tvg_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeferParseProgramsTests(SimpleTestCase):
|
||||||
|
@patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async")
|
||||||
|
def test_defer_schedules_retry_for_refresh(self, mock_apply_async):
|
||||||
|
result = _defer_parse_programs_for_tvg_id(5, False, 0, "source refresh in progress")
|
||||||
|
|
||||||
|
self.assertEqual(result, "Deferred")
|
||||||
|
mock_apply_async.assert_called_once_with(
|
||||||
|
args=[5],
|
||||||
|
kwargs={"force": False, "_defer_retry": 1},
|
||||||
|
countdown=_EPG_REFRESH_DEFER_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async")
|
||||||
|
def test_defer_gives_up_after_max_retries(self, mock_apply_async):
|
||||||
|
result = _defer_parse_programs_for_tvg_id(
|
||||||
|
5, False, _EPG_PARSE_DEFER_MAX, "source refresh in progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("Deferred too many times", result)
|
||||||
|
mock_apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class ParseProgramsForTvgIdLockTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.source = EPGSource.objects.create(
|
||||||
|
name="Lock Test",
|
||||||
|
source_type="xmltv",
|
||||||
|
file_path="/tmp/unused.xml",
|
||||||
|
)
|
||||||
|
self.epg = EPGData.objects.create(
|
||||||
|
tvg_id="test.channel",
|
||||||
|
name="Test",
|
||||||
|
epg_source=self.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async")
|
||||||
|
@patch("apps.epg.tasks.is_task_lock_held", return_value=True)
|
||||||
|
def test_defers_while_source_refresh_running(self, mock_held, mock_apply_async):
|
||||||
|
result = parse_programs_for_tvg_id(self.epg.id)
|
||||||
|
|
||||||
|
self.assertEqual(result, "Deferred")
|
||||||
|
mock_held.assert_called_once_with("refresh_epg_data", self.source.id)
|
||||||
|
mock_apply_async.assert_called_once()
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks._decr_source_tvg_parse_count")
|
||||||
|
@patch("apps.epg.tasks._incr_source_tvg_parse_count")
|
||||||
|
@patch("apps.epg.tasks.acquire_task_lock", return_value=False)
|
||||||
|
@patch("apps.epg.tasks.is_task_lock_held", return_value=False)
|
||||||
|
def test_skips_when_duplicate_epg_parse_running(
|
||||||
|
self, mock_held, mock_acquire, mock_incr, mock_decr
|
||||||
|
):
|
||||||
|
result = parse_programs_for_tvg_id(self.epg.id)
|
||||||
|
|
||||||
|
self.assertEqual(result, "Task already running")
|
||||||
|
mock_acquire.assert_called_once_with("parse_epg_programs", self.epg.id)
|
||||||
|
mock_incr.assert_called_once_with(self.source.id)
|
||||||
|
mock_decr.assert_called_once_with(self.source.id)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshEpgDataDeferTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.source = EPGSource.objects.create(
|
||||||
|
name="Refresh Defer Test",
|
||||||
|
source_type="xmltv",
|
||||||
|
file_path="/tmp/unused.xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks.refresh_epg_data.apply_async")
|
||||||
|
@patch("apps.epg.tasks.fetch_xmltv")
|
||||||
|
@patch("apps.epg.tasks._source_tvg_parse_count", return_value=1)
|
||||||
|
def test_refresh_defers_while_per_channel_parses_running(
|
||||||
|
self, mock_count, mock_fetch, mock_apply_async
|
||||||
|
):
|
||||||
|
_refresh_epg_data_impl(self.source.id)
|
||||||
|
|
||||||
|
mock_fetch.assert_not_called()
|
||||||
|
mock_apply_async.assert_called_once_with(
|
||||||
|
args=[self.source.id],
|
||||||
|
kwargs={"force": False, "_file_defer_retry": 1},
|
||||||
|
countdown=_EPG_REFRESH_DEFER_SECONDS,
|
||||||
|
)
|
||||||
73
apps/epg/tests/test_fetch_xmltv_xz.py
Normal file
73
apps/epg/tests/test_fetch_xmltv_xz.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""End-to-end coverage for fetch_xmltv() against a local xz-compressed
|
||||||
|
XMLTV file (no URL), mirroring FetchM3uLinesXzUploadTests in
|
||||||
|
apps/m3u/tests/test_xz_playlist.py.
|
||||||
|
|
||||||
|
fetch_xmltv is mocked everywhere else in the suite, so this exercises the
|
||||||
|
local-file-with-no-url branch (apps/epg/tasks.py fetch_xmltv) end to end:
|
||||||
|
detection, extraction via extract_compressed_file(), and the
|
||||||
|
extracted_file_path bookkeeping - with no mocking beyond what's needed to
|
||||||
|
keep the test hermetic (Celery Beat periodic task scheduling touches the
|
||||||
|
django_celery_beat tables in ways unrelated to this behavior).
|
||||||
|
"""
|
||||||
|
import lzma
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.epg.models import EPGSource
|
||||||
|
from apps.epg.tasks import fetch_xmltv
|
||||||
|
|
||||||
|
SAMPLE_XML = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
"<tv>\n"
|
||||||
|
' <channel id="xz.channel"/>\n'
|
||||||
|
' <programme channel="xz.channel" '
|
||||||
|
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||||
|
" <title>XZ Title</title>\n"
|
||||||
|
" </programme>\n"
|
||||||
|
"</tv>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FetchXmltvXzLocalFileTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.xz_path = None
|
||||||
|
self.source = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self.xz_path and os.path.exists(self.xz_path):
|
||||||
|
os.unlink(self.xz_path)
|
||||||
|
if (
|
||||||
|
self.source
|
||||||
|
and self.source.extracted_file_path
|
||||||
|
and os.path.exists(self.source.extracted_file_path)
|
||||||
|
):
|
||||||
|
os.unlink(self.source.extracted_file_path)
|
||||||
|
|
||||||
|
def test_fetch_xmltv_extracts_local_xz_file(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xz", delete=False) as xz_file:
|
||||||
|
self.xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
|
||||||
|
|
||||||
|
self.source = EPGSource.objects.create(
|
||||||
|
name="XZ local file source",
|
||||||
|
source_type="xmltv",
|
||||||
|
file_path=self.xz_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = fetch_xmltv(self.source)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
self.source.refresh_from_db()
|
||||||
|
self.assertEqual(self.source.status, EPGSource.STATUS_SUCCESS)
|
||||||
|
self.assertTrue(self.source.extracted_file_path)
|
||||||
|
self.assertTrue(os.path.exists(self.source.extracted_file_path))
|
||||||
|
|
||||||
|
with open(self.source.extracted_file_path, "r", encoding="utf-8") as f:
|
||||||
|
self.assertEqual(f.read(), SAMPLE_XML)
|
||||||
|
|
||||||
|
# The original compressed file is kept - fetch_xmltv extracts
|
||||||
|
# without deleting the source upload.
|
||||||
|
self.assertTrue(os.path.exists(self.xz_path))
|
||||||
274
apps/epg/tests/test_parse_programs_for_source.py
Normal file
274
apps/epg/tests/test_parse_programs_for_source.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.db import connection, transaction
|
||||||
|
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.epg.tasks import (
|
||||||
|
parse_programs_for_source,
|
||||||
|
_flush_epg_program_staging_batch,
|
||||||
|
_swap_staged_epg_programs,
|
||||||
|
_delete_orphaned_epg_programs,
|
||||||
|
_dispatch_late_mapped_epg_parses,
|
||||||
|
_EPG_PARSE_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _programme_xml(channel_id, title, start, stop):
|
||||||
|
return (
|
||||||
|
f' <programme start="{start}" stop="{stop}" channel="{channel_id}">\n'
|
||||||
|
f' <title>{title}</title>\n'
|
||||||
|
f' </programme>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _xmltv_file(programmes):
|
||||||
|
body = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<tv generator-info-name="test">\n'
|
||||||
|
f'{programmes}'
|
||||||
|
'</tv>\n'
|
||||||
|
)
|
||||||
|
handle = tempfile.NamedTemporaryFile(
|
||||||
|
mode='w',
|
||||||
|
suffix='.xml',
|
||||||
|
delete=False,
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
handle.write(body)
|
||||||
|
handle.close()
|
||||||
|
return handle.name
|
||||||
|
|
||||||
|
|
||||||
|
class ParseProgramsForSourceTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.source = EPGSource.objects.create(
|
||||||
|
name='XMLTV Parse Test',
|
||||||
|
source_type='xmltv',
|
||||||
|
)
|
||||||
|
self.mapped_epg = EPGData.objects.create(
|
||||||
|
epg_source=self.source,
|
||||||
|
tvg_id='mapped.channel',
|
||||||
|
name='Mapped Channel',
|
||||||
|
)
|
||||||
|
self.unmapped_epg = EPGData.objects.create(
|
||||||
|
epg_source=self.source,
|
||||||
|
tvg_id='unmapped.channel',
|
||||||
|
name='Unmapped Channel',
|
||||||
|
)
|
||||||
|
Channel.objects.create(
|
||||||
|
channel_number=1,
|
||||||
|
name='Mapped Channel',
|
||||||
|
epg_data=self.mapped_epg,
|
||||||
|
)
|
||||||
|
self.base_time = timezone.now().replace(minute=0, second=0, microsecond=0)
|
||||||
|
self.start = self.base_time.strftime('%Y%m%d%H%M%S +0000')
|
||||||
|
self.stop = (self.base_time + timedelta(hours=1)).strftime('%Y%m%d%H%M%S +0000')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if getattr(self, 'xml_path', None) and os.path.exists(self.xml_path):
|
||||||
|
os.unlink(self.xml_path)
|
||||||
|
|
||||||
|
def _configure_source_file(self, programmes):
|
||||||
|
self.xml_path = _xmltv_file(programmes)
|
||||||
|
self.source.file_path = self.xml_path
|
||||||
|
self.source.save(update_fields=['file_path'])
|
||||||
|
|
||||||
|
@patch('apps.epg.tasks.log_system_event')
|
||||||
|
@patch('apps.epg.tasks.send_epg_update')
|
||||||
|
def test_replaces_programs_for_mapped_channels(self, _send_update, _log_event):
|
||||||
|
old_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.mapped_epg,
|
||||||
|
start_time=old_start,
|
||||||
|
end_time=old_start + timedelta(hours=1),
|
||||||
|
title='Old Programme',
|
||||||
|
tvg_id=self.mapped_epg.tvg_id,
|
||||||
|
)
|
||||||
|
orphan_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.unmapped_epg,
|
||||||
|
start_time=orphan_start,
|
||||||
|
end_time=orphan_start + timedelta(hours=1),
|
||||||
|
title='Orphan Programme',
|
||||||
|
tvg_id=self.unmapped_epg.tvg_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
programmes = (
|
||||||
|
_programme_xml('mapped.channel', 'New Show', self.start, self.stop)
|
||||||
|
+ _programme_xml('unmapped.channel', 'Skipped Show', self.start, self.stop)
|
||||||
|
)
|
||||||
|
self._configure_source_file(programmes)
|
||||||
|
|
||||||
|
result = parse_programs_for_source(self.source)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
mapped_programs = ProgramData.objects.filter(epg=self.mapped_epg)
|
||||||
|
self.assertEqual(mapped_programs.count(), 1)
|
||||||
|
self.assertEqual(mapped_programs.get().title, 'New Show')
|
||||||
|
self.assertFalse(ProgramData.objects.filter(epg=self.unmapped_epg).exists())
|
||||||
|
|
||||||
|
@patch('apps.epg.tasks.log_system_event')
|
||||||
|
@patch('apps.epg.tasks.send_epg_update')
|
||||||
|
def test_atomic_failure_rolls_back_and_preserves_existing_programs(self, _send_update, _log_event):
|
||||||
|
old_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.mapped_epg,
|
||||||
|
start_time=old_start,
|
||||||
|
end_time=old_start + timedelta(hours=1),
|
||||||
|
title='Keep Me',
|
||||||
|
tvg_id=self.mapped_epg.tvg_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._configure_source_file(
|
||||||
|
_programme_xml('mapped.channel', 'Replacement', self.start, self.stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
swap_path = (
|
||||||
|
'apps.epg.tasks._swap_staged_epg_programs'
|
||||||
|
if connection.vendor == 'postgresql'
|
||||||
|
else 'apps.epg.tasks._swap_parsed_epg_programs'
|
||||||
|
)
|
||||||
|
with patch(swap_path, side_effect=RuntimeError('simulated insert failure')):
|
||||||
|
result = parse_programs_for_source(self.source)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
self.assertEqual(ProgramData.objects.filter(epg=self.mapped_epg).count(), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
ProgramData.objects.get(epg=self.mapped_epg).title,
|
||||||
|
'Keep Me',
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('apps.epg.tasks.log_system_event')
|
||||||
|
@patch('apps.epg.tasks.send_epg_update')
|
||||||
|
def test_streams_batches_without_holding_full_program_list(self, _send_update, _log_event):
|
||||||
|
if connection.vendor != 'postgresql':
|
||||||
|
self.skipTest('PostgreSQL staging batches are required for this assertion')
|
||||||
|
|
||||||
|
programme_count = _EPG_PARSE_BATCH_SIZE * 2
|
||||||
|
programmes = ''.join(
|
||||||
|
_programme_xml(
|
||||||
|
'mapped.channel',
|
||||||
|
f'Show {idx}',
|
||||||
|
self.start,
|
||||||
|
self.stop,
|
||||||
|
)
|
||||||
|
for idx in range(programme_count)
|
||||||
|
)
|
||||||
|
self._configure_source_file(programmes)
|
||||||
|
flush_sizes = []
|
||||||
|
original_flush = _flush_epg_program_staging_batch
|
||||||
|
|
||||||
|
def tracking_flush(batch):
|
||||||
|
flush_sizes.append(len(batch))
|
||||||
|
return original_flush(batch)
|
||||||
|
|
||||||
|
with patch('apps.epg.tasks._flush_epg_program_staging_batch', side_effect=tracking_flush):
|
||||||
|
result = parse_programs_for_source(self.source)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(ProgramData.objects.filter(epg=self.mapped_epg).count(), programme_count)
|
||||||
|
self.assertEqual(sum(flush_sizes), programme_count)
|
||||||
|
self.assertTrue(all(size <= _EPG_PARSE_BATCH_SIZE for size in flush_sizes))
|
||||||
|
self.assertGreater(len(flush_sizes), 1)
|
||||||
|
|
||||||
|
@patch('apps.epg.tasks.log_system_event')
|
||||||
|
@patch('apps.epg.tasks.send_epg_update')
|
||||||
|
def test_live_programs_remain_until_swap_commits(self, _send_update, _log_event):
|
||||||
|
if connection.vendor != 'postgresql':
|
||||||
|
self.skipTest('PostgreSQL staging swap is required for this assertion')
|
||||||
|
|
||||||
|
old_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.mapped_epg,
|
||||||
|
start_time=old_start,
|
||||||
|
end_time=old_start + timedelta(hours=1),
|
||||||
|
title='Old Programme',
|
||||||
|
tvg_id=self.mapped_epg.tvg_id,
|
||||||
|
)
|
||||||
|
self._configure_source_file(
|
||||||
|
_programme_xml('mapped.channel', 'New Show', self.start, self.stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
observed_titles_at_swap = []
|
||||||
|
|
||||||
|
def swap_with_visibility_check(mapped_epg_ids, epg_source, *args, **kwargs):
|
||||||
|
observed_titles_at_swap.append(
|
||||||
|
ProgramData.objects.get(epg=self.mapped_epg).title
|
||||||
|
)
|
||||||
|
return _swap_staged_epg_programs(mapped_epg_ids, epg_source, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch('apps.epg.tasks._swap_staged_epg_programs', side_effect=swap_with_visibility_check):
|
||||||
|
result = parse_programs_for_source(self.source)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(observed_titles_at_swap, ['Old Programme'])
|
||||||
|
self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'New Show')
|
||||||
|
|
||||||
|
@patch('apps.epg.tasks.log_system_event')
|
||||||
|
@patch('apps.epg.tasks.send_epg_update')
|
||||||
|
def test_swap_delete_is_rolled_back_when_insert_fails(self, _send_update, _log_event):
|
||||||
|
if connection.vendor != 'postgresql':
|
||||||
|
self.skipTest('PostgreSQL staging swap is required for this assertion')
|
||||||
|
|
||||||
|
old_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.mapped_epg,
|
||||||
|
start_time=old_start,
|
||||||
|
end_time=old_start + timedelta(hours=1),
|
||||||
|
title='Keep Me',
|
||||||
|
tvg_id=self.mapped_epg.tvg_id,
|
||||||
|
)
|
||||||
|
self._configure_source_file(
|
||||||
|
_programme_xml('mapped.channel', 'Replacement', self.start, self.stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
def failing_swap(mapped_epg_ids, epg_source, *args, **kwargs):
|
||||||
|
with transaction.atomic():
|
||||||
|
ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()
|
||||||
|
raise RuntimeError('simulated insert failure')
|
||||||
|
|
||||||
|
with patch('apps.epg.tasks._swap_staged_epg_programs', side_effect=failing_swap):
|
||||||
|
result = parse_programs_for_source(self.source)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'Keep Me')
|
||||||
|
|
||||||
|
def test_orphan_cleanup_respects_channels_mapped_during_bulk_parse(self):
|
||||||
|
late_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.unmapped_epg,
|
||||||
|
start_time=late_start,
|
||||||
|
end_time=late_start + timedelta(hours=1),
|
||||||
|
title='Late Match Programme',
|
||||||
|
tvg_id=self.unmapped_epg.tvg_id,
|
||||||
|
)
|
||||||
|
Channel.objects.create(
|
||||||
|
channel_number=2,
|
||||||
|
name='Late Mapped Channel',
|
||||||
|
epg_data=self.unmapped_epg,
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = _delete_orphaned_epg_programs(self.source)
|
||||||
|
|
||||||
|
self.assertEqual(deleted, 0)
|
||||||
|
self.assertEqual(ProgramData.objects.filter(epg=self.unmapped_epg).count(), 1)
|
||||||
|
|
||||||
|
@patch('apps.epg.tasks.dispatch_program_refresh_for_epg_ids', return_value=1)
|
||||||
|
def test_late_mapped_dispatches_per_channel_parse(self, mock_dispatch):
|
||||||
|
Channel.objects.create(
|
||||||
|
channel_number=2,
|
||||||
|
name='Late Mapped Channel',
|
||||||
|
epg_data=self.unmapped_epg,
|
||||||
|
)
|
||||||
|
bulk_snapshot = {self.mapped_epg.id}
|
||||||
|
|
||||||
|
dispatched = _dispatch_late_mapped_epg_parses(self.source, bulk_snapshot)
|
||||||
|
|
||||||
|
self.assertEqual(dispatched, 1)
|
||||||
|
mock_dispatch.assert_called_once_with({self.unmapped_epg.id})
|
||||||
138
apps/epg/tests/test_parse_programs_for_tvg_id.py
Normal file
138
apps/epg/tests/test_parse_programs_for_tvg_id.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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.epg.tasks import parse_programs_for_tvg_id
|
||||||
|
|
||||||
|
|
||||||
|
def _programme_xml(channel_id, title, start, stop):
|
||||||
|
return (
|
||||||
|
f' <programme start="{start}" stop="{stop}" channel="{channel_id}">\n'
|
||||||
|
f' <title>{title}</title>\n'
|
||||||
|
f' </programme>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _xmltv_file(programmes):
|
||||||
|
body = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<tv generator-info-name="test">\n'
|
||||||
|
f'{programmes}'
|
||||||
|
'</tv>\n'
|
||||||
|
)
|
||||||
|
handle = tempfile.NamedTemporaryFile(
|
||||||
|
mode='w',
|
||||||
|
suffix='.xml',
|
||||||
|
delete=False,
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
handle.write(body)
|
||||||
|
handle.close()
|
||||||
|
return handle.name
|
||||||
|
|
||||||
|
|
||||||
|
class ParseProgramsForTvgIdSwapTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._lock_patches = [
|
||||||
|
patch('apps.epg.tasks.is_task_lock_held', return_value=False),
|
||||||
|
patch('apps.epg.tasks.acquire_task_lock', return_value=True),
|
||||||
|
patch('apps.epg.tasks.release_task_lock'),
|
||||||
|
]
|
||||||
|
for p in self._lock_patches:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
self.source = EPGSource.objects.create(
|
||||||
|
name='Per-Channel Parse Test',
|
||||||
|
source_type='xmltv',
|
||||||
|
)
|
||||||
|
self.epg = EPGData.objects.create(
|
||||||
|
epg_source=self.source,
|
||||||
|
tvg_id='test.channel',
|
||||||
|
name='Test Channel',
|
||||||
|
)
|
||||||
|
self.channel = Channel.objects.create(
|
||||||
|
channel_number=1,
|
||||||
|
name='Test Channel',
|
||||||
|
epg_data=self.epg,
|
||||||
|
)
|
||||||
|
self.base_time = timezone.now().replace(minute=0, second=0, microsecond=0)
|
||||||
|
self.start = self.base_time.strftime('%Y%m%d%H%M%S +0000')
|
||||||
|
self.stop = (self.base_time + timedelta(hours=1)).strftime('%Y%m%d%H%M%S +0000')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for p in self._lock_patches:
|
||||||
|
p.stop()
|
||||||
|
if getattr(self, 'xml_path', None) and os.path.exists(self.xml_path):
|
||||||
|
os.unlink(self.xml_path)
|
||||||
|
|
||||||
|
def _configure_source_file(self, programmes):
|
||||||
|
self.xml_path = _xmltv_file(programmes)
|
||||||
|
self.source.file_path = self.xml_path
|
||||||
|
self.source.save(update_fields=['file_path'])
|
||||||
|
|
||||||
|
def test_replaces_programs_for_channel(self):
|
||||||
|
old_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.epg,
|
||||||
|
start_time=old_start,
|
||||||
|
end_time=old_start + timedelta(hours=1),
|
||||||
|
title='Old Programme',
|
||||||
|
tvg_id=self.epg.tvg_id,
|
||||||
|
)
|
||||||
|
self._configure_source_file(
|
||||||
|
_programme_xml('test.channel', 'New Show', self.start, self.stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
parse_programs_for_tvg_id(self.epg.id)
|
||||||
|
|
||||||
|
programs = ProgramData.objects.filter(epg=self.epg)
|
||||||
|
self.assertEqual(programs.count(), 1)
|
||||||
|
self.assertEqual(programs.get().title, 'New Show')
|
||||||
|
|
||||||
|
def test_failed_insert_preserves_existing_programs(self):
|
||||||
|
"""A failed atomic swap must not leave the channel with no guide data."""
|
||||||
|
old_start = self.base_time - timedelta(days=1)
|
||||||
|
ProgramData.objects.create(
|
||||||
|
epg=self.epg,
|
||||||
|
start_time=old_start,
|
||||||
|
end_time=old_start + timedelta(hours=1),
|
||||||
|
title='Keep Me',
|
||||||
|
tvg_id=self.epg.tvg_id,
|
||||||
|
)
|
||||||
|
self._configure_source_file(
|
||||||
|
_programme_xml('test.channel', 'Replacement', self.start, self.stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'apps.epg.tasks.ProgramData.objects.bulk_create',
|
||||||
|
side_effect=RuntimeError('simulated insert failure'),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
parse_programs_for_tvg_id(self.epg.id)
|
||||||
|
|
||||||
|
remaining = ProgramData.objects.filter(epg=self.epg)
|
||||||
|
self.assertEqual(remaining.count(), 1)
|
||||||
|
self.assertEqual(remaining.get().title, 'Keep Me')
|
||||||
|
|
||||||
|
def test_does_not_refetch_epg_data_mid_task(self):
|
||||||
|
"""The task must reuse the EPGData row loaded at task start."""
|
||||||
|
self._configure_source_file(
|
||||||
|
_programme_xml('test.channel', 'New Show', self.start, self.stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'apps.epg.tasks.EPGData.objects.get',
|
||||||
|
side_effect=AssertionError('should not re-fetch EPGData mid-task'),
|
||||||
|
) as mock_get:
|
||||||
|
parse_programs_for_tvg_id(self.epg.id)
|
||||||
|
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
self.assertEqual(
|
||||||
|
ProgramData.objects.filter(epg=self.epg).get().title, 'New Show'
|
||||||
|
)
|
||||||
44
apps/epg/tests/test_parsing_channels_progress.py
Normal file
44
apps/epg/tests/test_parsing_channels_progress.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.epg.tasks import (
|
||||||
|
_CHANNEL_PARSE_PROGRESS_CAP,
|
||||||
|
_CHANNEL_PARSE_PROGRESS_START,
|
||||||
|
_channel_parse_progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelParseProgressTests(SimpleTestCase):
|
||||||
|
def test_exceeding_estimate_recalculates_instead_of_going_over_cap(self):
|
||||||
|
"""101/100 used to yield ~90%+; now bumps estimate to 101 so ratio stays <= 1."""
|
||||||
|
progress, estimate = _channel_parse_progress(100, 100, had_db_baseline=True)
|
||||||
|
self.assertEqual(estimate, 100)
|
||||||
|
self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP)
|
||||||
|
|
||||||
|
progress, estimate = _channel_parse_progress(101, 100, had_db_baseline=True)
|
||||||
|
self.assertEqual(estimate, 101)
|
||||||
|
self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP)
|
||||||
|
self.assertGreater(progress, 90)
|
||||||
|
|
||||||
|
def test_large_xml_growth_never_exceeds_cap(self):
|
||||||
|
"""Previously 425 channels vs DB estimate of 100 could show ~300%."""
|
||||||
|
estimate = 100
|
||||||
|
for processed in (100, 200, 300, 425):
|
||||||
|
progress, estimate = _channel_parse_progress(
|
||||||
|
processed, estimate, had_db_baseline=True
|
||||||
|
)
|
||||||
|
self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP)
|
||||||
|
|
||||||
|
def test_first_import_crawls_instead_of_flat_ninety(self):
|
||||||
|
progress, estimate = _channel_parse_progress(100, 0, had_db_baseline=False)
|
||||||
|
self.assertEqual(estimate, 0)
|
||||||
|
self.assertEqual(progress, _CHANNEL_PARSE_PROGRESS_START + 1)
|
||||||
|
self.assertLess(progress, 90)
|
||||||
|
|
||||||
|
progress, _ = _channel_parse_progress(5000, 0, had_db_baseline=False)
|
||||||
|
self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP)
|
||||||
|
|
||||||
|
def test_partial_progress_when_below_estimate(self):
|
||||||
|
progress, estimate = _channel_parse_progress(50, 100, had_db_baseline=True)
|
||||||
|
self.assertEqual(estimate, 100)
|
||||||
|
self.assertGreater(progress, _CHANNEL_PARSE_PROGRESS_START)
|
||||||
|
self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import os
|
import os
|
||||||
import gzip
|
import gzip
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
@ -11,6 +11,7 @@ from apps.epg.tasks import (
|
||||||
find_current_program_for_tvg_id,
|
find_current_program_for_tvg_id,
|
||||||
build_programme_index,
|
build_programme_index,
|
||||||
build_programme_index_task,
|
build_programme_index_task,
|
||||||
|
_EPG_TVG_PARSE_DEFER_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||||
|
|
@ -519,7 +520,6 @@ class FindCurrentProgramTests(TestCase):
|
||||||
name="No Index",
|
name="No Index",
|
||||||
source_type="xmltv",
|
source_type="xmltv",
|
||||||
file_path=FIXTURE_XML,
|
file_path=FIXTURE_XML,
|
||||||
programme_index=None,
|
|
||||||
)
|
)
|
||||||
epg = EPGData.objects.create(
|
epg = EPGData.objects.create(
|
||||||
tvg_id="channel.current",
|
tvg_id="channel.current",
|
||||||
|
|
@ -664,38 +664,40 @@ class BuildProgrammeIndexTests(TestCase):
|
||||||
build_programme_index(99999)
|
build_programme_index(99999)
|
||||||
|
|
||||||
@patch("apps.epg.tasks.build_programme_index")
|
@patch("apps.epg.tasks.build_programme_index")
|
||||||
def test_task_builds_and_releases_lock_when_free(self, mock_build):
|
@patch("apps.epg.tasks.release_task_lock")
|
||||||
mock_redis = MagicMock()
|
@patch("apps.epg.tasks.acquire_task_lock", return_value=True)
|
||||||
mock_redis.set.return_value = True # lock acquired
|
def test_task_builds_and_releases_lock_when_free(
|
||||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
self, mock_acquire, mock_release, mock_build
|
||||||
build_programme_index_task(42)
|
):
|
||||||
|
build_programme_index_task(42)
|
||||||
|
|
||||||
mock_build.assert_called_once_with(42)
|
mock_build.assert_called_once_with(42)
|
||||||
mock_redis.set.assert_called_once()
|
mock_acquire.assert_called_once_with("epg_source_file", 42)
|
||||||
self.assertEqual(
|
mock_release.assert_called_once_with("epg_source_file", 42)
|
||||||
mock_redis.set.call_args.args[0], "building_programme_index_42"
|
|
||||||
)
|
|
||||||
mock_redis.delete.assert_called_once_with("building_programme_index_42")
|
|
||||||
|
|
||||||
@patch("apps.epg.tasks.build_programme_index")
|
@patch("apps.epg.tasks.build_programme_index")
|
||||||
def test_task_skips_when_lock_held(self, mock_build):
|
@patch("apps.epg.tasks.acquire_task_lock", return_value=False)
|
||||||
mock_redis = MagicMock()
|
def test_task_defers_when_lock_held(self, mock_acquire, mock_build):
|
||||||
mock_redis.set.return_value = False # another build in flight
|
with patch("apps.epg.tasks.build_programme_index_task.apply_async") as mock_apply:
|
||||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
|
||||||
build_programme_index_task(42)
|
build_programme_index_task(42)
|
||||||
|
|
||||||
mock_build.assert_not_called()
|
mock_build.assert_not_called()
|
||||||
mock_redis.delete.assert_not_called()
|
mock_apply.assert_called_once_with(
|
||||||
|
args=[42],
|
||||||
|
kwargs={'_defer_retry': 1},
|
||||||
|
countdown=_EPG_TVG_PARSE_DEFER_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
@patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom"))
|
@patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom"))
|
||||||
def test_task_releases_lock_on_failure(self, mock_build):
|
@patch("apps.epg.tasks.release_task_lock")
|
||||||
mock_redis = MagicMock()
|
@patch("apps.epg.tasks.acquire_task_lock", return_value=True)
|
||||||
mock_redis.set.return_value = True
|
def test_task_releases_lock_on_failure(
|
||||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
self, mock_acquire, mock_release, mock_build
|
||||||
with self.assertRaises(RuntimeError):
|
):
|
||||||
build_programme_index_task(42)
|
with self.assertRaises(RuntimeError):
|
||||||
|
build_programme_index_task(42)
|
||||||
|
|
||||||
mock_redis.delete.assert_called_once_with("building_programme_index_42")
|
mock_release.assert_called_once_with("epg_source_file", 42)
|
||||||
|
|
||||||
def test_per_channel_interleaved_marking(self):
|
def test_per_channel_interleaved_marking(self):
|
||||||
xml = (
|
xml = (
|
||||||
|
|
|
||||||
106
apps/epg/tests/test_refresh_db_recovery.py
Normal file
106
apps/epg/tests/test_refresh_db_recovery.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.epg.tasks import (
|
||||||
|
_db_query_with_retry,
|
||||||
|
_ensure_epg_refresh_terminal_status,
|
||||||
|
_get_epg_source,
|
||||||
|
_release_task_db_connection,
|
||||||
|
refresh_epg_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DbQueryWithRetryTests(SimpleTestCase):
|
||||||
|
def test_retries_after_index_error_from_poisoned_connection(self):
|
||||||
|
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.epg.tasks._release_task_db_connection"
|
||||||
|
) as mock_release:
|
||||||
|
result = _db_query_with_retry(fn, label="test query")
|
||||||
|
|
||||||
|
self.assertEqual(result, "ok")
|
||||||
|
self.assertEqual(fn.call_count, 2)
|
||||||
|
mock_release.assert_called_once()
|
||||||
|
|
||||||
|
def test_raises_after_exhausting_retries(self):
|
||||||
|
fn = MagicMock(side_effect=IndexError("list index out of range"))
|
||||||
|
|
||||||
|
with patch("apps.epg.tasks._release_task_db_connection"):
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
_db_query_with_retry(fn, label="test query", max_retries=2)
|
||||||
|
|
||||||
|
self.assertEqual(fn.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTaskDbStartupTests(SimpleTestCase):
|
||||||
|
@patch("apps.epg.tasks._ensure_epg_refresh_terminal_status")
|
||||||
|
@patch("apps.epg.tasks._refresh_epg_data_impl")
|
||||||
|
@patch("apps.epg.tasks.release_task_lock")
|
||||||
|
@patch("apps.epg.tasks.acquire_task_lock", return_value=True)
|
||||||
|
@patch("apps.epg.tasks.TaskLockRenewer")
|
||||||
|
@patch("apps.epg.tasks._release_task_db_connection")
|
||||||
|
def test_refresh_releases_db_connection_before_impl(
|
||||||
|
self,
|
||||||
|
mock_release,
|
||||||
|
_mock_renewer,
|
||||||
|
_mock_acquire,
|
||||||
|
_mock_release_lock,
|
||||||
|
mock_impl,
|
||||||
|
_mock_ensure_terminal,
|
||||||
|
):
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
def track_release():
|
||||||
|
call_order.append("release")
|
||||||
|
|
||||||
|
mock_release.side_effect = track_release
|
||||||
|
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
|
||||||
|
|
||||||
|
result = refresh_epg_data(42)
|
||||||
|
|
||||||
|
self.assertEqual(result, "done")
|
||||||
|
self.assertEqual(call_order[:2], ["release", "impl"])
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks.EPGSource")
|
||||||
|
def test_get_epg_source_uses_retry_helper(self, mock_model):
|
||||||
|
mock_source = MagicMock(id=42)
|
||||||
|
mock_model.objects.get.return_value = mock_source
|
||||||
|
|
||||||
|
with patch("apps.epg.tasks._db_query_with_retry") as mock_retry:
|
||||||
|
mock_retry.side_effect = lambda fn, **_: fn()
|
||||||
|
source = _get_epg_source(42)
|
||||||
|
|
||||||
|
mock_retry.assert_called_once()
|
||||||
|
mock_model.objects.get.assert_called_once_with(id=42)
|
||||||
|
self.assertIs(source, mock_source)
|
||||||
|
|
||||||
|
|
||||||
|
class EnsureEpgTerminalStatusTests(SimpleTestCase):
|
||||||
|
@patch("apps.epg.tasks.send_epg_update")
|
||||||
|
@patch("apps.epg.tasks._release_task_db_connection")
|
||||||
|
def test_marks_stuck_fetching_as_error(self, _mock_release, mock_ws):
|
||||||
|
with patch("apps.epg.tasks.EPGSource") as mock_model:
|
||||||
|
mock_model.STATUS_ERROR = "error"
|
||||||
|
qs = MagicMock()
|
||||||
|
mock_model.objects.filter.return_value = qs
|
||||||
|
qs.values_list.return_value.first.return_value = "fetching"
|
||||||
|
|
||||||
|
_ensure_epg_refresh_terminal_status(7)
|
||||||
|
|
||||||
|
qs.update.assert_called_once()
|
||||||
|
mock_ws.assert_called_once()
|
||||||
|
|
||||||
|
@patch("apps.epg.tasks.send_epg_update")
|
||||||
|
@patch("apps.epg.tasks._release_task_db_connection")
|
||||||
|
def test_leaves_success_unchanged(self, _mock_release, mock_ws):
|
||||||
|
with patch("apps.epg.tasks.EPGSource") as mock_model:
|
||||||
|
qs = MagicMock()
|
||||||
|
mock_model.objects.filter.return_value = qs
|
||||||
|
qs.values_list.return_value.first.return_value = "success"
|
||||||
|
|
||||||
|
_ensure_epg_refresh_terminal_status(7)
|
||||||
|
|
||||||
|
qs.update.assert_not_called()
|
||||||
|
mock_ws.assert_not_called()
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -100,27 +100,38 @@ class ProgramDataSerializerTests(TestCase):
|
||||||
self.assertEqual(set(data.keys()), expected_fields)
|
self.assertEqual(set(data.keys()), expected_fields)
|
||||||
|
|
||||||
def test_season_episode_from_onscreen_episode(self):
|
def test_season_episode_from_onscreen_episode(self):
|
||||||
"""Season and episode should be parsed from onscreen_episode string."""
|
"""Serializer reads precomputed season/episode from custom_properties (set at import)."""
|
||||||
program = self._create_program(
|
program = self._create_program(
|
||||||
custom_properties={"onscreen_episode": "S12 E6"}
|
custom_properties={
|
||||||
|
"onscreen_episode": "S12 E6",
|
||||||
|
"season": 12,
|
||||||
|
"episode": 6,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
data = ProgramDataSerializer(program).data
|
data = ProgramDataSerializer(program).data
|
||||||
self.assertEqual(data["season"], 12)
|
self.assertEqual(data["season"], 12)
|
||||||
self.assertEqual(data["episode"], 6)
|
self.assertEqual(data["episode"], 6)
|
||||||
|
|
||||||
def test_onscreen_episode_no_space(self):
|
def test_onscreen_episode_no_space(self):
|
||||||
"""Should parse onscreen_episode without space between S and E."""
|
"""Precomputed S/E in custom_properties is exposed by the serializer."""
|
||||||
program = self._create_program(
|
program = self._create_program(
|
||||||
custom_properties={"onscreen_episode": "S3E21"}
|
custom_properties={
|
||||||
|
"onscreen_episode": "S3E21",
|
||||||
|
"season": 3,
|
||||||
|
"episode": 21,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
data = ProgramDataSerializer(program).data
|
data = ProgramDataSerializer(program).data
|
||||||
self.assertEqual(data["season"], 3)
|
self.assertEqual(data["season"], 3)
|
||||||
self.assertEqual(data["episode"], 21)
|
self.assertEqual(data["episode"], 21)
|
||||||
|
|
||||||
def test_onscreen_episode_with_part(self):
|
def test_onscreen_episode_with_part(self):
|
||||||
"""Should parse season/episode even when part info follows."""
|
|
||||||
program = self._create_program(
|
program = self._create_program(
|
||||||
custom_properties={"onscreen_episode": "S8 E8 P2/2"}
|
custom_properties={
|
||||||
|
"onscreen_episode": "S8 E8 P2/2",
|
||||||
|
"season": 8,
|
||||||
|
"episode": 8,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
data = ProgramDataSerializer(program).data
|
data = ProgramDataSerializer(program).data
|
||||||
self.assertEqual(data["season"], 8)
|
self.assertEqual(data["season"], 8)
|
||||||
|
|
@ -139,7 +150,7 @@ class ProgramDataSerializerTests(TestCase):
|
||||||
self.assertEqual(data["episode"], 2)
|
self.assertEqual(data["episode"], 2)
|
||||||
|
|
||||||
def test_onscreen_episode_invalid_format(self):
|
def test_onscreen_episode_invalid_format(self):
|
||||||
"""Should return None for onscreen_episode that doesn't match S/E pattern."""
|
"""Without precomputed S/E keys, serializer returns None."""
|
||||||
program = self._create_program(
|
program = self._create_program(
|
||||||
custom_properties={"onscreen_episode": "Episode 5"}
|
custom_properties={"onscreen_episode": "Episode 5"}
|
||||||
)
|
)
|
||||||
|
|
@ -433,7 +444,7 @@ class ExtractSeasonEpisodeFromDescriptionTests(TestCase):
|
||||||
|
|
||||||
|
|
||||||
class ProgramDataSerializerDescriptionFallbackTests(TestCase):
|
class ProgramDataSerializerDescriptionFallbackTests(TestCase):
|
||||||
"""Integration tests: serializer uses description fallback for S/E."""
|
"""Serializer does not parse description at request time; import precomputes S/E."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.epg_source = EPGSource.objects.create(
|
self.epg_source = EPGSource.objects.create(
|
||||||
|
|
@ -460,8 +471,8 @@ class ProgramDataSerializerDescriptionFallbackTests(TestCase):
|
||||||
description="S2 E5 The Episode Title",
|
description="S2 E5 The Episode Title",
|
||||||
)
|
)
|
||||||
data = ProgramDataSerializer(program).data
|
data = ProgramDataSerializer(program).data
|
||||||
self.assertEqual(data["season"], 2)
|
self.assertIsNone(data["season"])
|
||||||
self.assertEqual(data["episode"], 5)
|
self.assertIsNone(data["episode"])
|
||||||
|
|
||||||
def test_se_from_description_not_used_when_cp_has_values(self):
|
def test_se_from_description_not_used_when_cp_has_values(self):
|
||||||
program = self._create_program(
|
program = self._create_program(
|
||||||
|
|
@ -473,6 +484,34 @@ class ProgramDataSerializerDescriptionFallbackTests(TestCase):
|
||||||
self.assertEqual(data["episode"], 1)
|
self.assertEqual(data["episode"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class SDPosterProxyPathTests(TestCase):
|
||||||
|
"""Cache-bust query on SD poster proxy URLs."""
|
||||||
|
|
||||||
|
def test_stable_uri_stable_bust(self):
|
||||||
|
from apps.epg.utils import sd_poster_cache_bust, sd_poster_proxy_path
|
||||||
|
|
||||||
|
uri = 'https://json.schedulesdirect.org/20141201/image/assets/a.jpg'
|
||||||
|
self.assertEqual(sd_poster_cache_bust(uri), sd_poster_cache_bust(uri))
|
||||||
|
path = sd_poster_proxy_path(42, uri)
|
||||||
|
self.assertTrue(path.startswith('/api/epg/programs/42/poster/?v='))
|
||||||
|
self.assertIn(sd_poster_cache_bust(uri), path)
|
||||||
|
|
||||||
|
def test_uri_change_changes_bust(self):
|
||||||
|
from apps.epg.utils import sd_poster_cache_bust
|
||||||
|
|
||||||
|
a = 'https://json.schedulesdirect.org/20141201/image/assets/a.jpg'
|
||||||
|
b = 'https://json.schedulesdirect.org/20141201/image/assets/b.jpg'
|
||||||
|
self.assertNotEqual(sd_poster_cache_bust(a), sd_poster_cache_bust(b))
|
||||||
|
|
||||||
|
def test_empty_uri_has_no_query(self):
|
||||||
|
from apps.epg.utils import sd_poster_proxy_path
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sd_poster_proxy_path(7, None),
|
||||||
|
'/api/epg/programs/7/poster/',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProgramDetailSerializerTests(TestCase):
|
class ProgramDetailSerializerTests(TestCase):
|
||||||
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
|
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
|
||||||
|
|
||||||
|
|
@ -506,6 +545,8 @@ class ProgramDetailSerializerTests(TestCase):
|
||||||
"credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown",
|
"credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown",
|
||||||
"country", "language", "production_date", "original_air_date",
|
"country", "language", "production_date", "original_air_date",
|
||||||
"imdb_id", "tmdb_id", "tvdb_id", "icon", "images",
|
"imdb_id", "tmdb_id", "tvdb_id", "icon", "images",
|
||||||
|
"poster_url", "content_advisory", "content_ratings", "event_details",
|
||||||
|
"runtime", "runtime_units",
|
||||||
}
|
}
|
||||||
self.assertEqual(set(data.keys()), expected_fields)
|
self.assertEqual(set(data.keys()), expected_fields)
|
||||||
|
|
||||||
|
|
@ -631,9 +672,13 @@ class ProgramDetailSerializerTests(TestCase):
|
||||||
self.assertEqual(data["images"], [])
|
self.assertEqual(data["images"], [])
|
||||||
|
|
||||||
def test_season_episode_uses_shared_helper(self):
|
def test_season_episode_uses_shared_helper(self):
|
||||||
"""Detail serializer should use the same onscreen_episode fallback."""
|
"""Detail serializer reads precomputed S/E from custom_properties."""
|
||||||
program = self._create_program(
|
program = self._create_program(
|
||||||
custom_properties={"onscreen_episode": "S5E14"}
|
custom_properties={
|
||||||
|
"onscreen_episode": "S5E14",
|
||||||
|
"season": 5,
|
||||||
|
"episode": 14,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
data = ProgramDetailSerializer(program).data
|
data = ProgramDetailSerializer(program).data
|
||||||
self.assertEqual(data["season"], 5)
|
self.assertEqual(data["season"], 5)
|
||||||
|
|
@ -653,3 +698,16 @@ class ProgramDetailSerializerTests(TestCase):
|
||||||
self.assertEqual(slim["is_live"], detail["is_live"])
|
self.assertEqual(slim["is_live"], detail["is_live"])
|
||||||
self.assertEqual(slim["is_premiere"], detail["is_premiere"])
|
self.assertEqual(slim["is_premiere"], detail["is_premiere"])
|
||||||
self.assertEqual(slim["is_finale"], detail["is_finale"])
|
self.assertEqual(slim["is_finale"], detail["is_finale"])
|
||||||
|
|
||||||
|
def test_poster_url_includes_cache_bust_from_sd_icon(self):
|
||||||
|
from apps.epg.utils import sd_poster_proxy_path
|
||||||
|
|
||||||
|
uri = 'https://json.schedulesdirect.org/20141201/image/assets/p.jpg'
|
||||||
|
program = self._create_program(custom_properties={"sd_icon": uri})
|
||||||
|
data = ProgramDetailSerializer(program).data
|
||||||
|
self.assertEqual(data["poster_url"], sd_poster_proxy_path(program.id, uri))
|
||||||
|
|
||||||
|
def test_poster_url_none_without_sd_icon(self):
|
||||||
|
program = self._create_program(custom_properties={})
|
||||||
|
data = ProgramDetailSerializer(program).data
|
||||||
|
self.assertIsNone(data["poster_url"])
|
||||||
|
|
|
||||||
49
apps/epg/tests/test_upload_security.py
Normal file
49
apps/epg/tests/test_upload_security.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""EPG upload path safety and admin-only permission."""
|
||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
|
from apps.epg.api_views import EPGSourceViewSet
|
||||||
|
|
||||||
|
|
||||||
|
class EPGUploadSecurityTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.admin = User.objects.create_user(
|
||||||
|
username="epg_upload_admin",
|
||||||
|
password="x",
|
||||||
|
user_level=User.UserLevel.ADMIN,
|
||||||
|
)
|
||||||
|
self.standard = User.objects.create_user(
|
||||||
|
username="epg_upload_user",
|
||||||
|
password="x",
|
||||||
|
user_level=User.UserLevel.STANDARD,
|
||||||
|
)
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
|
||||||
|
def _upload(self, user, filename="guide.xml"):
|
||||||
|
content = SimpleUploadedFile(
|
||||||
|
filename, b"<?xml version='1.0'?><tv></tv>", content_type="application/xml"
|
||||||
|
)
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/epg/sources/upload/",
|
||||||
|
{"file": content, "name": "Uploaded", "source_type": "xmltv"},
|
||||||
|
format="multipart",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=user)
|
||||||
|
view = EPGSourceViewSet.as_view({"post": "upload"})
|
||||||
|
return view(request)
|
||||||
|
|
||||||
|
def test_standard_user_forbidden(self):
|
||||||
|
response = self._upload(self.standard)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@patch("apps.epg.api_views.safe_upload_path", side_effect=ValueError("bad"))
|
||||||
|
def test_traversal_filename_rejected(self, _mock):
|
||||||
|
response = self._upload(self.admin, filename="../../evil.xml")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn("Invalid filename", response.data.get("error", ""))
|
||||||
291
apps/epg/tests/test_xz_support.py
Normal file
291
apps/epg/tests/test_xz_support.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""Tests for native xz (.xz) support in EPG source ingestion.
|
||||||
|
|
||||||
|
Covers the three dispatch points that previously only recognized gzip/zip:
|
||||||
|
- detect_file_format() format sniffing (magic bytes, extension, mimetype)
|
||||||
|
- extract_compressed_file() decompression
|
||||||
|
- EPGSource.get_cache_file() extension inference for uploaded files
|
||||||
|
"""
|
||||||
|
import lzma
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.epg.models import EPGSource
|
||||||
|
from apps.epg.tasks import detect_file_format, extract_compressed_file
|
||||||
|
|
||||||
|
SAMPLE_XML = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
"<tv>\n"
|
||||||
|
' <channel id="xz.channel"/>\n'
|
||||||
|
' <programme channel="xz.channel" '
|
||||||
|
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||||
|
" <title>XZ Title</title>\n"
|
||||||
|
" </programme>\n"
|
||||||
|
"</tv>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectFileFormatXzTests(SimpleTestCase):
|
||||||
|
def test_detects_xz_by_magic_bytes(self):
|
||||||
|
compressed = lzma.compress(SAMPLE_XML.encode("utf-8"))
|
||||||
|
|
||||||
|
format_type, is_compressed, file_extension = detect_file_format(
|
||||||
|
content=compressed[:64]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(format_type, "xz")
|
||||||
|
self.assertTrue(is_compressed)
|
||||||
|
self.assertEqual(file_extension, ".xz")
|
||||||
|
|
||||||
|
def test_detects_xz_by_extension(self):
|
||||||
|
format_type, is_compressed, file_extension = detect_file_format(
|
||||||
|
file_path="/tmp/epg_source.xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(format_type, "xz")
|
||||||
|
self.assertTrue(is_compressed)
|
||||||
|
self.assertEqual(file_extension, ".xz")
|
||||||
|
|
||||||
|
def test_detects_xz_with_compound_extension(self):
|
||||||
|
# Compound extensions like .xml.xz must prioritize the compression
|
||||||
|
# extension, matching the existing .xml.gz behavior.
|
||||||
|
format_type, is_compressed, file_extension = detect_file_format(
|
||||||
|
file_path="/tmp/epg_source.xml.xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(format_type, "xz")
|
||||||
|
self.assertTrue(is_compressed)
|
||||||
|
self.assertEqual(file_extension, ".xz")
|
||||||
|
|
||||||
|
def test_detects_xz_by_mimetype_fallback(self):
|
||||||
|
# Python's stdlib mimetypes module treats .xz as an *encoding*
|
||||||
|
# suffix rather than a full MIME type (mirroring .gz), so the
|
||||||
|
# mimetype branch is only reachable when guess_type is coerced to
|
||||||
|
# return the MIME type directly (e.g. a customized mimetypes
|
||||||
|
# configuration). Exercise that fallback branch directly, matching
|
||||||
|
# how the existing gzip/zip mimetype fallback branches are tested.
|
||||||
|
with patch("mimetypes.guess_type", return_value=("application/x-xz", None)):
|
||||||
|
format_type, is_compressed, file_extension = detect_file_format(
|
||||||
|
file_path="/tmp/epg_source_with_no_known_suffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(format_type, "xz")
|
||||||
|
self.assertTrue(is_compressed)
|
||||||
|
self.assertEqual(file_extension, ".xz")
|
||||||
|
|
||||||
|
def test_content_magic_bytes_take_priority_over_extension(self):
|
||||||
|
compressed = lzma.compress(SAMPLE_XML.encode("utf-8"))
|
||||||
|
|
||||||
|
# Mismatched extension should not override a confident content match.
|
||||||
|
format_type, is_compressed, file_extension = detect_file_format(
|
||||||
|
file_path="/tmp/misleading_name.gz", content=compressed[:64]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(format_type, "xz")
|
||||||
|
self.assertTrue(is_compressed)
|
||||||
|
self.assertEqual(file_extension, ".xz")
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractCompressedFileXzTests(SimpleTestCase):
|
||||||
|
def test_round_trips_lzma_compressed_xmltv_to_identical_xml(self):
|
||||||
|
xz_path = None
|
||||||
|
xml_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".xml.xz", delete=False
|
||||||
|
) as xz_file:
|
||||||
|
xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
|
||||||
|
|
||||||
|
xml_path = f"{os.path.splitext(os.path.splitext(xz_path)[0])[0]}.xml"
|
||||||
|
|
||||||
|
extracted_path = extract_compressed_file(xz_path, xml_path)
|
||||||
|
|
||||||
|
self.assertEqual(extracted_path, xml_path)
|
||||||
|
with open(extracted_path, "r", encoding="utf-8") as f:
|
||||||
|
self.assertEqual(f.read(), SAMPLE_XML)
|
||||||
|
finally:
|
||||||
|
if xz_path and os.path.exists(xz_path):
|
||||||
|
os.unlink(xz_path)
|
||||||
|
if xml_path and os.path.exists(xml_path):
|
||||||
|
os.unlink(xml_path)
|
||||||
|
|
||||||
|
def test_extracts_xz_file_detected_purely_by_magic_bytes(self):
|
||||||
|
# No .xz suffix at all - detection must rely on the LZMA magic
|
||||||
|
# number, not the filename.
|
||||||
|
xz_path = None
|
||||||
|
xml_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".bin", delete=False
|
||||||
|
) as xz_file:
|
||||||
|
xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
|
||||||
|
|
||||||
|
xml_path = f"{os.path.splitext(xz_path)[0]}.xml"
|
||||||
|
|
||||||
|
extracted_path = extract_compressed_file(xz_path, xml_path)
|
||||||
|
|
||||||
|
self.assertEqual(extracted_path, xml_path)
|
||||||
|
with open(extracted_path, "r", encoding="utf-8") as f:
|
||||||
|
self.assertEqual(f.read(), SAMPLE_XML)
|
||||||
|
finally:
|
||||||
|
if xz_path and os.path.exists(xz_path):
|
||||||
|
os.unlink(xz_path)
|
||||||
|
if xml_path and os.path.exists(xml_path):
|
||||||
|
os.unlink(xml_path)
|
||||||
|
|
||||||
|
def test_deletes_original_when_requested(self):
|
||||||
|
xz_path = None
|
||||||
|
xml_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".xml.xz", delete=False
|
||||||
|
) as xz_file:
|
||||||
|
xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
|
||||||
|
|
||||||
|
xml_path = f"{os.path.splitext(os.path.splitext(xz_path)[0])[0]}.xml"
|
||||||
|
|
||||||
|
extracted_path = extract_compressed_file(
|
||||||
|
xz_path, xml_path, delete_original=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(extracted_path, xml_path)
|
||||||
|
self.assertFalse(os.path.exists(xz_path))
|
||||||
|
finally:
|
||||||
|
xz_path = None # already deleted by extract_compressed_file
|
||||||
|
if xml_path and os.path.exists(xml_path):
|
||||||
|
os.unlink(xml_path)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractCompressedFileCorruptXzTests(SimpleTestCase):
|
||||||
|
def test_corrupt_xz_fails_closed_with_no_partial_output(self):
|
||||||
|
# Valid LZMA magic bytes (fd 37 7a 58 5a 00) followed by garbage: the
|
||||||
|
# format sniff reports 'xz' (magic bytes match), but decompression
|
||||||
|
# itself must fail. extract_compressed_file() wraps this in a
|
||||||
|
# blanket except Exception so a corrupt/truncated upload can never
|
||||||
|
# raise out of the Celery worker - it must return None instead.
|
||||||
|
xz_path = None
|
||||||
|
xml_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".xz", delete=False
|
||||||
|
) as xz_file:
|
||||||
|
xz_path = xz_file.name
|
||||||
|
xz_file.write(b"\xfd7zXZ\x00" + b"not actually lzma data" * 20)
|
||||||
|
|
||||||
|
xml_path = f"{os.path.splitext(xz_path)[0]}.xml"
|
||||||
|
|
||||||
|
extracted_path = extract_compressed_file(xz_path, xml_path)
|
||||||
|
|
||||||
|
self.assertIsNone(extracted_path)
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(xml_path),
|
||||||
|
"extract_compressed_file must not leave a partial output file "
|
||||||
|
"behind when decompression fails",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if xz_path and os.path.exists(xz_path):
|
||||||
|
os.unlink(xz_path)
|
||||||
|
if xml_path and os.path.exists(xml_path):
|
||||||
|
os.unlink(xml_path)
|
||||||
|
|
||||||
|
|
||||||
|
class EPGSourceGetCacheFileXzTests(SimpleTestCase):
|
||||||
|
def test_infers_xz_extension_from_mimetype(self):
|
||||||
|
xz_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix="", delete=False
|
||||||
|
) as xz_file:
|
||||||
|
xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
|
||||||
|
|
||||||
|
source = EPGSource(
|
||||||
|
name="XZ mimetype source",
|
||||||
|
source_type="xmltv",
|
||||||
|
file_path=xz_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"mimetypes.guess_type", return_value=("application/x-xz", None)
|
||||||
|
):
|
||||||
|
cache_file = source.get_cache_file()
|
||||||
|
|
||||||
|
self.assertTrue(cache_file.endswith(".xz"))
|
||||||
|
finally:
|
||||||
|
if xz_path and os.path.exists(xz_path):
|
||||||
|
os.unlink(xz_path)
|
||||||
|
|
||||||
|
def test_infers_xz_extension_from_magic_bytes(self):
|
||||||
|
xz_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix="", delete=False
|
||||||
|
) as xz_file:
|
||||||
|
xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
|
||||||
|
|
||||||
|
source = EPGSource(
|
||||||
|
name="XZ magic bytes source",
|
||||||
|
source_type="xmltv",
|
||||||
|
file_path=xz_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No mimetype guess available and no extension on disk - must
|
||||||
|
# fall back to sniffing the LZMA magic number.
|
||||||
|
with patch("mimetypes.guess_type", return_value=(None, None)):
|
||||||
|
cache_file = source.get_cache_file()
|
||||||
|
|
||||||
|
self.assertTrue(cache_file.endswith(".xz"))
|
||||||
|
finally:
|
||||||
|
if xz_path and os.path.exists(xz_path):
|
||||||
|
os.unlink(xz_path)
|
||||||
|
|
||||||
|
|
||||||
|
class EPGSourceGetCacheFileRawXmlTests(SimpleTestCase):
|
||||||
|
"""Regression coverage for the bare-<tv> raw-XML magic-byte detection.
|
||||||
|
|
||||||
|
get_cache_file()'s magic-byte read was widened from f.read(4) to
|
||||||
|
f.read(6) to fit the 6-byte xz signature. That widening broke the
|
||||||
|
fixed-length slice comparison `header[:5] == b'<tv>'`: a 5-byte slice
|
||||||
|
of a 6-byte header can never equal the 4-byte literal b'<tv>', so a
|
||||||
|
raw (uncompressed) XMLTV file with no filename extension and no
|
||||||
|
resolvable mimetype was silently misdetected as '.tmp' instead of
|
||||||
|
'.xml'. Using header.startswith(...) instead of a fixed-length slice
|
||||||
|
comparison is correct regardless of how many bytes are read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _cache_file_for_content(self, content):
|
||||||
|
raw_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix="", delete=False) as raw_file:
|
||||||
|
raw_path = raw_file.name
|
||||||
|
raw_file.write(content)
|
||||||
|
|
||||||
|
source = EPGSource(
|
||||||
|
name="Raw XML source",
|
||||||
|
source_type="xmltv",
|
||||||
|
file_path=raw_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No extension on disk and no resolvable mimetype - detection
|
||||||
|
# must fall back to sniffing the magic bytes.
|
||||||
|
with patch("mimetypes.guess_type", return_value=(None, None)):
|
||||||
|
return source.get_cache_file()
|
||||||
|
finally:
|
||||||
|
if raw_path and os.path.exists(raw_path):
|
||||||
|
os.unlink(raw_path)
|
||||||
|
|
||||||
|
def test_infers_xml_extension_from_bare_tv_tag(self):
|
||||||
|
cache_file = self._cache_file_for_content(SAMPLE_XML.split("\n", 1)[1].encode("utf-8"))
|
||||||
|
|
||||||
|
self.assertTrue(cache_file.endswith(".xml"))
|
||||||
|
|
||||||
|
def test_infers_xml_extension_from_xml_declaration(self):
|
||||||
|
cache_file = self._cache_file_for_content(SAMPLE_XML.encode("utf-8"))
|
||||||
|
|
||||||
|
self.assertTrue(cache_file.endswith(".xml"))
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
"""
|
"""
|
||||||
Shared EPG utilities — season/episode extraction.
|
Shared EPG utilities.
|
||||||
|
|
||||||
These live here (rather than in serializers.py or tasks.py) to avoid circular imports:
|
Season/episode extraction, WebSocket progress updates, and SD poster proxy URL
|
||||||
serializers → tasks and channels/tasks → serializers both need these functions.
|
helpers live here so serializers, XMLTV output, and tasks can import without
|
||||||
|
circular dependencies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import gc
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from core.utils import send_websocket_update
|
||||||
|
|
||||||
# Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2"
|
# Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2"
|
||||||
_ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE)
|
_ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
@ -21,6 +26,34 @@ _DESC_SE_PATTERNS = [
|
||||||
re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'),
|
re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_SD_POSTER_CACHE_BUST_LEN = 12
|
||||||
|
|
||||||
|
|
||||||
|
def sd_poster_cache_bust(sd_icon_url):
|
||||||
|
"""
|
||||||
|
Short content hash of an SD poster URI for nginx cache busting.
|
||||||
|
|
||||||
|
Same URI keeps the same ``v`` (long-lived nginx cache). A new artwork URI
|
||||||
|
after refresh gets a new ``v`` so clients do not keep stale bytes.
|
||||||
|
"""
|
||||||
|
if not sd_icon_url:
|
||||||
|
return ''
|
||||||
|
return hashlib.sha256(sd_icon_url.encode('utf-8')).hexdigest()[:_SD_POSTER_CACHE_BUST_LEN]
|
||||||
|
|
||||||
|
|
||||||
|
def sd_poster_proxy_path(program_id, sd_icon_url):
|
||||||
|
"""
|
||||||
|
Relative proxy path for a program poster.
|
||||||
|
|
||||||
|
Includes ``?v=`` when ``sd_icon_url`` is set so nginx cache keys change with
|
||||||
|
the upstream SD URI. The poster endpoint ignores ``v``; nginx keys on full URI.
|
||||||
|
"""
|
||||||
|
path = f'/api/epg/programs/{program_id}/poster/'
|
||||||
|
bust = sd_poster_cache_bust(sd_icon_url)
|
||||||
|
if not bust:
|
||||||
|
return path
|
||||||
|
return f'{path}?v={bust}'
|
||||||
|
|
||||||
|
|
||||||
def extract_season_episode_from_description(desc):
|
def extract_season_episode_from_description(desc):
|
||||||
"""
|
"""
|
||||||
|
|
@ -59,3 +92,23 @@ def extract_season_episode(cp, description=None):
|
||||||
if episode is None:
|
if episode is None:
|
||||||
episode = d_episode
|
episode = d_episode
|
||||||
return season, episode
|
return season, episode
|
||||||
|
|
||||||
|
|
||||||
|
def send_epg_update(source_id, action, progress, **kwargs):
|
||||||
|
"""Send WebSocket update about EPG download/parsing progress."""
|
||||||
|
data = {
|
||||||
|
"progress": progress,
|
||||||
|
"type": "epg_refresh",
|
||||||
|
"source": source_id,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
data.update(kwargs)
|
||||||
|
|
||||||
|
# High-frequency program parsing needs more aggressive memory management
|
||||||
|
collect_garbage = action == "parsing_programs" and progress % 10 == 0
|
||||||
|
send_websocket_update('updates', 'update', data, collect_garbage=collect_garbage)
|
||||||
|
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if action == "parsing_programs" and progress % 50 == 0:
|
||||||
|
gc.collect()
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,12 @@ app_name = "m3u"
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
|
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
|
||||||
router.register(
|
router.register(
|
||||||
r"accounts\/(?P<account_id>\d+)\/profiles",
|
r"accounts/(?P<account_id>\d+)/profiles",
|
||||||
M3UAccountProfileViewSet,
|
M3UAccountProfileViewSet,
|
||||||
basename="m3u-account-profiles",
|
basename="m3u-account-profiles",
|
||||||
)
|
)
|
||||||
router.register(
|
router.register(
|
||||||
r"accounts\/(?P<account_id>\d+)\/filters",
|
r"accounts/(?P<account_id>\d+)/filters",
|
||||||
M3UFilterViewSet,
|
M3UFilterViewSet,
|
||||||
basename="m3u-filters",
|
basename="m3u-filters",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||||
from core.models import UserAgent
|
from core.models import UserAgent
|
||||||
from core.utils import safe_upload_path
|
from core.utils import safe_upload_path, ensure_custom_properties_dict
|
||||||
from apps.channels.models import ChannelGroupM3UAccount
|
from apps.channels.models import ChannelGroupM3UAccount
|
||||||
from core.serializers import UserAgentSerializer
|
from core.serializers import UserAgentSerializer
|
||||||
from apps.vod.models import M3UVODCategoryRelation
|
from apps.vod.models import M3UVODCategoryRelation
|
||||||
|
|
@ -283,36 +283,21 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Snapshot channels so proxy sessions can be stopped outside
|
# Snapshot channels so proxy sessions can be stopped outside
|
||||||
# the DB transaction. The pre_delete signal would otherwise
|
# the DB transaction. Teardown must not run inside the atomic
|
||||||
# fire ChannelService.stop_channel (Redis pub / hgetall /
|
# delete (geventpool connection release would poison the TX).
|
||||||
# setex) per channel inside the atomic, holding the DB
|
|
||||||
# connection across thousands of blocking RPCs and gumming up
|
|
||||||
# the connection pool.
|
|
||||||
channels_to_delete = list(
|
channels_to_delete = list(
|
||||||
Channel.objects.filter(
|
Channel.objects.filter(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
auto_created_by=instance,
|
auto_created_by=instance,
|
||||||
).values_list("id", "uuid")
|
).values_list("id", "uuid")
|
||||||
)
|
)
|
||||||
for _, channel_uuid in channels_to_delete:
|
ChannelService.stop_channels(
|
||||||
if not channel_uuid:
|
channel_uuid for _, channel_uuid in channels_to_delete
|
||||||
continue
|
)
|
||||||
try:
|
|
||||||
ChannelService.stop_channel(str(channel_uuid))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to stop proxy session for channel %s "
|
|
||||||
"during account cleanup: %s",
|
|
||||||
channel_uuid,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
|
|
||||||
channel_ids = [cid for cid, _ in channels_to_delete]
|
channel_ids = [cid for cid, _ in channels_to_delete]
|
||||||
# Channel + account writes share an atomic so an account
|
# Channel + account writes share an atomic so an account
|
||||||
# delete failure rolls back the channel deletes too. The
|
# delete failure rolls back the channel deletes too.
|
||||||
# pre_delete signal will fire again here but its proxy stop
|
|
||||||
# is fast on already-stopped channels (a single Redis check
|
|
||||||
# returns "not found" immediately).
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if channel_ids:
|
if channel_ids:
|
||||||
_, per_model = Channel.objects.filter(
|
_, per_model = Channel.objects.filter(
|
||||||
|
|
@ -536,7 +521,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
||||||
auto_channel_sync=setting.get("auto_channel_sync", False),
|
auto_channel_sync=setting.get("auto_channel_sync", False),
|
||||||
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
|
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
|
||||||
auto_sync_channel_end=setting.get("auto_sync_channel_end"),
|
auto_sync_channel_end=setting.get("auto_sync_channel_end"),
|
||||||
custom_properties=setting.get("custom_properties", {}),
|
custom_properties=ensure_custom_properties_dict(
|
||||||
|
setting.get("custom_properties")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for setting in group_settings
|
for setting in group_settings
|
||||||
if setting.get("channel_group")
|
if setting.get("channel_group")
|
||||||
|
|
@ -561,7 +548,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
||||||
category_id=setting["id"],
|
category_id=setting["id"],
|
||||||
m3u_account=account,
|
m3u_account=account,
|
||||||
enabled=setting.get("enabled", True),
|
enabled=setting.get("enabled", True),
|
||||||
custom_properties=setting.get("custom_properties", {}),
|
custom_properties=ensure_custom_properties_dict(
|
||||||
|
setting.get("custom_properties")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for setting in category_settings
|
for setting in category_settings
|
||||||
if setting.get("id")
|
if setting.get("id")
|
||||||
|
|
|
||||||
323
apps/m3u/connection_pool.py
Normal file
323
apps/m3u/connection_pool.py
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
"""
|
||||||
|
Shared connection pool enforcement for M3U accounts in the same ServerGroup.
|
||||||
|
|
||||||
|
Profile selection rotates across M3UAccountProfile rows using each profile's own
|
||||||
|
Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup, a credential-scoped counter is checked on reserve/release
|
||||||
|
so accounts sharing the same provider login share one limit without blocking
|
||||||
|
unrelated logins on the same group. Account profiles with max_streams=0 skip
|
||||||
|
credential enforcement for that profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Literal, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ReserveFailureReason = Literal["profile_full", "credential_full"]
|
||||||
|
|
||||||
|
PROFILE_CONNECTIONS_KEY = "profile_connections:{profile_id}"
|
||||||
|
PROFILE_CREDENTIAL_RELEASE_KEY = "profile_credential_release:{profile_id}"
|
||||||
|
SERVER_GROUP_CONNECTIONS_KEY = "server_group_connections:{group_id}:{fingerprint}"
|
||||||
|
|
||||||
|
_XC_URL_CREDENTIALS_RE = re.compile(
|
||||||
|
r"/(?:live|movie|series)/([^/]+)/([^/]+)/",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_connections_key(profile_id: int) -> str:
|
||||||
|
return PROFILE_CONNECTIONS_KEY.format(profile_id=profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_credential_release_key(profile_id: int) -> str:
|
||||||
|
"""Redis key storing the credential counter to release when the profile row is gone."""
|
||||||
|
return PROFILE_CREDENTIAL_RELEASE_KEY.format(profile_id=profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
def server_group_connections_key(group_id: int, fingerprint: str) -> str:
|
||||||
|
"""Redis key for per-credential usage within a ServerGroup."""
|
||||||
|
return SERVER_GROUP_CONNECTIONS_KEY.format(
|
||||||
|
group_id=group_id,
|
||||||
|
fingerprint=fingerprint[:16],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_credential_fingerprint(username: str, password: str) -> Optional[str]:
|
||||||
|
"""Return a stable hash for grouping accounts with the same IPTV login."""
|
||||||
|
if not username or not password:
|
||||||
|
return None
|
||||||
|
normalized = f"{username.strip().lower()}\0{password.strip()}"
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_credentials_from_stream_url(url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Parse username/password embedded in an Xtream-style stream URL."""
|
||||||
|
if not url:
|
||||||
|
return None, None
|
||||||
|
match = _XC_URL_CREDENTIALS_RE.search(url)
|
||||||
|
if not match:
|
||||||
|
return None, None
|
||||||
|
return match.group(1), match.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint_from_profile_stream_url(profile) -> Optional[str]:
|
||||||
|
"""STD/M3U: fingerprint from a sample stream URL after profile rewrite."""
|
||||||
|
from apps.channels.models import Stream
|
||||||
|
|
||||||
|
sample_url = (
|
||||||
|
Stream.objects.filter(m3u_account=profile.m3u_account)
|
||||||
|
.exclude(url="")
|
||||||
|
.values_list("url", flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not sample_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apps.proxy.live_proxy.url_utils import transform_url
|
||||||
|
|
||||||
|
transformed = transform_url(
|
||||||
|
sample_url,
|
||||||
|
profile.search_pattern or "",
|
||||||
|
profile.replace_pattern or "",
|
||||||
|
)
|
||||||
|
url_user, url_pass = extract_credentials_from_stream_url(
|
||||||
|
transformed or sample_url
|
||||||
|
)
|
||||||
|
return compute_credential_fingerprint(url_user or "", url_pass or "")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Could not derive profile %s fingerprint from stream URL: %s",
|
||||||
|
profile.pk,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_credential_fingerprint(profile) -> Optional[str]:
|
||||||
|
"""Fingerprint for credentials this profile uses at playback time."""
|
||||||
|
m3u_account = profile.m3u_account
|
||||||
|
|
||||||
|
if m3u_account.account_type == "XC":
|
||||||
|
try:
|
||||||
|
from apps.m3u.tasks import get_transformed_credentials
|
||||||
|
|
||||||
|
_url, username, password = get_transformed_credentials(m3u_account, profile)
|
||||||
|
fingerprint = compute_credential_fingerprint(username or "", password or "")
|
||||||
|
if fingerprint:
|
||||||
|
return fingerprint
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Could not resolve transformed credentials for profile %s: %s",
|
||||||
|
profile.pk,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
fingerprint = _fingerprint_from_profile_stream_url(profile)
|
||||||
|
if fingerprint:
|
||||||
|
return fingerprint
|
||||||
|
|
||||||
|
return compute_credential_fingerprint(
|
||||||
|
m3u_account.username or "",
|
||||||
|
m3u_account.password or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_enforced_server_group_for_profile(profile):
|
||||||
|
"""Return the ServerGroup for credential pooling when the account is assigned to one."""
|
||||||
|
group = profile.m3u_account.server_group
|
||||||
|
if group:
|
||||||
|
return group
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_counter_key(profile, group) -> Optional[str]:
|
||||||
|
fingerprint = get_profile_credential_fingerprint(profile)
|
||||||
|
if not fingerprint:
|
||||||
|
return None
|
||||||
|
return server_group_connections_key(group.id, fingerprint)
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_connection_count(profile, redis_client) -> int:
|
||||||
|
return int(redis_client.get(profile_connections_key(profile.id)) or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_connection_count(profile, redis_client) -> int:
|
||||||
|
group = get_enforced_server_group_for_profile(profile)
|
||||||
|
if not group:
|
||||||
|
return 0
|
||||||
|
cred_key = _credential_counter_key(profile, group)
|
||||||
|
if not cred_key:
|
||||||
|
return 0
|
||||||
|
return int(redis_client.get(cred_key) or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_has_capacity_for_selection(profile, redis_client) -> bool:
|
||||||
|
"""Per-profile capacity check used when rotating across profiles on one account."""
|
||||||
|
if profile.max_streams == 0:
|
||||||
|
return True
|
||||||
|
return get_profile_connection_count(profile, redis_client) < profile.max_streams
|
||||||
|
|
||||||
|
|
||||||
|
def group_has_capacity_for_profile(profile, redis_client) -> bool:
|
||||||
|
# Profiles with max_streams=0 skip credential enforcement entirely. An unlimited
|
||||||
|
# profile in a pooled group can still stream while other accounts share the login.
|
||||||
|
group = get_enforced_server_group_for_profile(profile)
|
||||||
|
if not group or profile.max_streams == 0:
|
||||||
|
return True
|
||||||
|
cred_key = _credential_counter_key(profile, group)
|
||||||
|
if not cred_key:
|
||||||
|
return True
|
||||||
|
return int(redis_client.get(cred_key) or 0) < profile.max_streams
|
||||||
|
|
||||||
|
|
||||||
|
def pool_has_capacity_for_profile(profile, redis_client) -> bool:
|
||||||
|
"""Non-mutating check before reserve: profile slot and credential slot if applicable."""
|
||||||
|
return profile_has_capacity_for_selection(profile, redis_client) and group_has_capacity_for_profile(
|
||||||
|
profile, redis_client
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_available_for_channel_switch(
|
||||||
|
profile, redis_client, *, channel_already_on_profile: bool
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Non-mutating capacity check when selecting a profile for an in-flight channel.
|
||||||
|
|
||||||
|
If the channel already holds this profile's slots, skip re-checking capacity.
|
||||||
|
"""
|
||||||
|
if channel_already_on_profile:
|
||||||
|
return True
|
||||||
|
return pool_has_capacity_for_profile(profile, redis_client)
|
||||||
|
|
||||||
|
|
||||||
|
def move_credential_slot_on_profile_switch(
|
||||||
|
old_profile, new_profile, redis_client
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Move the shared credential counter when switching to a different provider login.
|
||||||
|
|
||||||
|
Profile counters are managed separately by Channel.update_stream_profile().
|
||||||
|
Returns False when the new profile's credential pool is full.
|
||||||
|
"""
|
||||||
|
old_fp = get_profile_credential_fingerprint(old_profile)
|
||||||
|
new_fp = get_profile_credential_fingerprint(new_profile)
|
||||||
|
if old_fp == new_fp:
|
||||||
|
return True
|
||||||
|
|
||||||
|
_release_credential_slot_by_profile_id(old_profile.id, redis_client)
|
||||||
|
|
||||||
|
cred_reserved, cred_key = _reserve_server_group_slot_for_profile(
|
||||||
|
new_profile, redis_client
|
||||||
|
)
|
||||||
|
if not cred_reserved:
|
||||||
|
restore_reserved, restore_key = _reserve_server_group_slot_for_profile(
|
||||||
|
old_profile, redis_client
|
||||||
|
)
|
||||||
|
if restore_reserved and restore_key:
|
||||||
|
_remember_credential_release_key(
|
||||||
|
old_profile.id, restore_key, redis_client
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if cred_key:
|
||||||
|
_remember_credential_release_key(new_profile.id, cred_key, redis_client)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_decr(redis_client, key: str) -> None:
|
||||||
|
current = int(redis_client.get(key) or 0)
|
||||||
|
if current <= 0:
|
||||||
|
return
|
||||||
|
new_count = redis_client.decr(key)
|
||||||
|
if new_count < 0:
|
||||||
|
redis_client.set(key, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_credential_release_key(
|
||||||
|
profile_id: int, cred_key: str, redis_client
|
||||||
|
) -> None:
|
||||||
|
redis_client.set(profile_credential_release_key(profile_id), cred_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_credential_slot_by_profile_id(profile_id: int, redis_client) -> bool:
|
||||||
|
"""Release a reserved credential counter using the key stored at reserve time."""
|
||||||
|
release_key = profile_credential_release_key(profile_id)
|
||||||
|
cred_key = redis_client.get(release_key)
|
||||||
|
if not cred_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(cred_key, bytes):
|
||||||
|
cred_key = cred_key.decode()
|
||||||
|
_safe_decr(redis_client, cred_key)
|
||||||
|
redis_client.delete(release_key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _reserve_server_group_slot_for_profile(
|
||||||
|
profile, redis_client
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
group = get_enforced_server_group_for_profile(profile)
|
||||||
|
if not group or profile.max_streams == 0:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
cred_key = _credential_counter_key(profile, group)
|
||||||
|
if not cred_key:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
cred_count = redis_client.incr(cred_key)
|
||||||
|
if cred_count <= profile.max_streams:
|
||||||
|
return True, cred_key
|
||||||
|
|
||||||
|
redis_client.decr(cred_key)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_profile_slot(
|
||||||
|
profile, redis_client
|
||||||
|
) -> Tuple[bool, int, Optional[ReserveFailureReason]]:
|
||||||
|
"""
|
||||||
|
Atomically reserve profile + optional credential slots (INCR-first).
|
||||||
|
|
||||||
|
Returns (reserved, profile_count_after_attempt, failure_reason).
|
||||||
|
failure_reason is set when reserved is False.
|
||||||
|
"""
|
||||||
|
profile_key = profile_connections_key(profile.id)
|
||||||
|
profile_count = 0
|
||||||
|
|
||||||
|
if profile.max_streams > 0:
|
||||||
|
profile_count = redis_client.incr(profile_key)
|
||||||
|
if profile_count > profile.max_streams:
|
||||||
|
redis_client.decr(profile_key)
|
||||||
|
return False, profile_count - 1, "profile_full"
|
||||||
|
|
||||||
|
cred_reserved, cred_key = _reserve_server_group_slot_for_profile(
|
||||||
|
profile, redis_client
|
||||||
|
)
|
||||||
|
if not cred_reserved:
|
||||||
|
if profile.max_streams > 0:
|
||||||
|
redis_client.decr(profile_key)
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
profile_count - 1 if profile.max_streams > 0 else 0,
|
||||||
|
"credential_full",
|
||||||
|
)
|
||||||
|
|
||||||
|
if cred_key:
|
||||||
|
_remember_credential_release_key(profile.id, cred_key, redis_client)
|
||||||
|
|
||||||
|
return True, profile_count, None
|
||||||
|
|
||||||
|
|
||||||
|
def release_profile_slot(profile_id: int, redis_client) -> None:
|
||||||
|
"""Release profile and shared credential slots after a stream end."""
|
||||||
|
_release_credential_slot_by_profile_id(profile_id, redis_client)
|
||||||
|
|
||||||
|
profile_key = profile_connections_key(profile_id)
|
||||||
|
current = int(redis_client.get(profile_key) or 0)
|
||||||
|
if current > 0:
|
||||||
|
redis_client.decr(profile_key)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# apps/m3u/forms.py
|
# apps/m3u/forms.py
|
||||||
from django import forms
|
from django import forms
|
||||||
from .models import M3UAccount, M3UFilter
|
from .models import M3UAccount, M3UFilter
|
||||||
|
from core.utils import ensure_custom_properties_dict
|
||||||
import re
|
import re
|
||||||
|
|
||||||
class M3UAccountForm(forms.ModelForm):
|
class M3UAccountForm(forms.ModelForm):
|
||||||
|
|
@ -28,7 +29,9 @@ class M3UAccountForm(forms.ModelForm):
|
||||||
|
|
||||||
# Set initial value for enable_vod from custom_properties
|
# Set initial value for enable_vod from custom_properties
|
||||||
if self.instance and self.instance.custom_properties:
|
if self.instance and self.instance.custom_properties:
|
||||||
custom_props = self.instance.custom_properties or {}
|
custom_props = self.instance.custom_properties
|
||||||
|
if not isinstance(custom_props, dict):
|
||||||
|
custom_props = ensure_custom_properties_dict(custom_props)
|
||||||
self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
|
self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
|
|
@ -37,8 +40,9 @@ class M3UAccountForm(forms.ModelForm):
|
||||||
# Handle enable_vod field
|
# Handle enable_vod field
|
||||||
enable_vod = self.cleaned_data.get('enable_vod', False)
|
enable_vod = self.cleaned_data.get('enable_vod', False)
|
||||||
|
|
||||||
# Parse existing custom_properties
|
|
||||||
custom_props = instance.custom_properties or {}
|
custom_props = instance.custom_properties or {}
|
||||||
|
if not isinstance(custom_props, dict):
|
||||||
|
custom_props = ensure_custom_properties_dict(custom_props)
|
||||||
|
|
||||||
# Update VOD preference
|
# Update VOD preference
|
||||||
custom_props['enable_vod'] = enable_vod
|
custom_props['enable_vod'] = enable_vod
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from django.dispatch import receiver
|
||||||
from apps.channels.models import StreamProfile
|
from apps.channels.models import StreamProfile
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
from core.models import CoreSettings, UserAgent
|
from core.models import CoreSettings, UserAgent
|
||||||
|
from core.utils import custom_properties_as_dict
|
||||||
|
|
||||||
CUSTOM_M3U_ACCOUNT_NAME = "custom"
|
CUSTOM_M3U_ACCOUNT_NAME = "custom"
|
||||||
|
|
||||||
|
|
@ -124,6 +125,11 @@ class M3UAccount(models.Model):
|
||||||
return user_agent
|
return user_agent
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
if self.custom_properties is not None and not isinstance(
|
||||||
|
self.custom_properties, dict
|
||||||
|
):
|
||||||
|
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||||
|
|
||||||
# Prevent auto_now behavior by handling updated_at manually
|
# Prevent auto_now behavior by handling updated_at manually
|
||||||
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
|
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
|
||||||
# Don't modify updated_at for regular updates
|
# Don't modify updated_at for regular updates
|
||||||
|
|
@ -194,7 +200,13 @@ class M3UFilter(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class ServerGroup(models.Model):
|
class ServerGroup(models.Model):
|
||||||
"""Represents a logical grouping of servers or channels."""
|
"""
|
||||||
|
Groups M3U accounts that share provider credentials.
|
||||||
|
|
||||||
|
Accounts assigned to the same server group share credential-scoped connection
|
||||||
|
counters when their logins match. Limits come from each account profile's
|
||||||
|
max_streams, not from the group itself.
|
||||||
|
"""
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100, unique=True, help_text="Unique name for this server group."
|
max_length=100, unique=True, help_text="Unique name for this server group."
|
||||||
|
|
@ -257,6 +269,11 @@ class M3UAccountProfile(models.Model):
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Auto-sync exp_date from custom_properties for XC accounts on every save.
|
"""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."""
|
For non-XC accounts, exp_date is set directly and left untouched here."""
|
||||||
|
if self.custom_properties is not None and not isinstance(
|
||||||
|
self.custom_properties, dict
|
||||||
|
):
|
||||||
|
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||||
|
|
||||||
parsed = self._parse_exp_date_from_custom_properties()
|
parsed = self._parse_exp_date_from_custom_properties()
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
# XC account with exp_date in custom_properties — always sync
|
# XC account with exp_date in custom_properties — always sync
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from core.utils import validate_flexible_url
|
from core.utils import validate_flexible_url, ensure_custom_properties_dict
|
||||||
from rest_framework import serializers, status
|
from rest_framework import serializers, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||||
|
|
@ -189,6 +189,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
||||||
"password": {
|
"password": {
|
||||||
"required": False,
|
"required": False,
|
||||||
"allow_blank": True,
|
"allow_blank": True,
|
||||||
|
"write_only": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,6 +214,13 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
data = super().to_representation(instance)
|
data = super().to_representation(instance)
|
||||||
|
|
||||||
|
# write_only strips password for everyone; re-add only for admins so
|
||||||
|
# operator tooling / profile regex helpers still see credentials.
|
||||||
|
request = self.context.get("request")
|
||||||
|
user = getattr(request, "user", None) if request else None
|
||||||
|
if user is not None and getattr(user, "user_level", 0) >= 10:
|
||||||
|
data["password"] = instance.password or ""
|
||||||
|
|
||||||
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings
|
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings
|
||||||
custom_props = instance.custom_properties or {}
|
custom_props = instance.custom_properties or {}
|
||||||
|
|
||||||
|
|
@ -270,11 +278,15 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
||||||
# overwrite their corresponding keys; clients should set those via
|
# overwrite their corresponding keys; clients should set those via
|
||||||
# the typed top-level fields rather than the custom_properties
|
# the typed top-level fields rather than the custom_properties
|
||||||
# payload.
|
# payload.
|
||||||
incoming_custom = validated_data.get("custom_properties") or {}
|
incoming_custom = {}
|
||||||
custom_props = {
|
if "custom_properties" in validated_data:
|
||||||
**(instance.custom_properties or {}),
|
incoming_custom = validated_data["custom_properties"] or {}
|
||||||
**incoming_custom,
|
if not isinstance(incoming_custom, dict):
|
||||||
}
|
incoming_custom = ensure_custom_properties_dict(incoming_custom)
|
||||||
|
existing_custom = instance.custom_properties or {}
|
||||||
|
if not isinstance(existing_custom, dict):
|
||||||
|
existing_custom = ensure_custom_properties_dict(existing_custom)
|
||||||
|
custom_props = {**existing_custom, **incoming_custom}
|
||||||
|
|
||||||
if enable_vod is not None:
|
if enable_vod is not None:
|
||||||
custom_props["enable_vod"] = enable_vod
|
custom_props["enable_vod"] = enable_vod
|
||||||
|
|
@ -346,7 +358,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
||||||
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True)
|
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True)
|
||||||
|
|
||||||
# Parse existing custom_properties or create new
|
# Parse existing custom_properties or create new
|
||||||
custom_props = validated_data.get("custom_properties", {})
|
custom_props = validated_data.get("custom_properties") or {}
|
||||||
|
if not isinstance(custom_props, dict):
|
||||||
|
custom_props = ensure_custom_properties_dict(custom_props)
|
||||||
|
|
||||||
# Set preferences (default to True for auto_enable_new_groups)
|
# Set preferences (default to True for auto_enable_new_groups)
|
||||||
custom_props["enable_vod"] = enable_vod
|
custom_props["enable_vod"] = enable_vod
|
||||||
|
|
|
||||||
1088
apps/m3u/tasks.py
1088
apps/m3u/tasks.py
File diff suppressed because it is too large
Load diff
594
apps/m3u/tests/test_connection_pool.py
Normal file
594
apps/m3u/tests/test_connection_pool.py
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
"""Tests for shared ServerGroup connection pools (#1137)."""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from apps.m3u.connection_pool import (
|
||||||
|
extract_credentials_from_stream_url,
|
||||||
|
get_credential_connection_count,
|
||||||
|
get_enforced_server_group_for_profile,
|
||||||
|
get_profile_connection_count,
|
||||||
|
get_profile_credential_fingerprint,
|
||||||
|
group_has_capacity_for_profile,
|
||||||
|
pool_has_capacity_for_profile,
|
||||||
|
profile_has_capacity_for_selection,
|
||||||
|
profile_connections_key,
|
||||||
|
profile_credential_release_key,
|
||||||
|
release_profile_slot,
|
||||||
|
reserve_profile_slot,
|
||||||
|
server_group_connections_key,
|
||||||
|
)
|
||||||
|
from apps.m3u.models import M3UAccount, M3UAccountProfile, ServerGroup
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
"""Minimal in-memory Redis stand-in for counter tests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
val = self._data.get(key)
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, str):
|
||||||
|
return val.encode()
|
||||||
|
return str(val).encode()
|
||||||
|
|
||||||
|
def set(self, key, value, ex=None):
|
||||||
|
try:
|
||||||
|
self._data[key] = int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def incr(self, key):
|
||||||
|
self._data[key] = self._data.get(key, 0) + 1
|
||||||
|
return self._data[key]
|
||||||
|
|
||||||
|
def decr(self, key):
|
||||||
|
self._data[key] = self._data.get(key, 0) - 1
|
||||||
|
return self._data[key]
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
self._data.pop(key, None)
|
||||||
|
|
||||||
|
def pipeline(self):
|
||||||
|
return FakeRedisPipeline(self)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedisPipeline:
|
||||||
|
def __init__(self, redis):
|
||||||
|
self.redis = redis
|
||||||
|
self._ops = []
|
||||||
|
|
||||||
|
def decr(self, key):
|
||||||
|
self._ops.append(("decr", key))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def incr(self, key):
|
||||||
|
self._ops.append(("incr", key))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self._ops.append(("set", key, value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
for op in self._ops:
|
||||||
|
if op[0] == "decr":
|
||||||
|
self.redis.decr(op[1])
|
||||||
|
elif op[0] == "incr":
|
||||||
|
self.redis.incr(op[1])
|
||||||
|
elif op[0] == "set":
|
||||||
|
self.redis.set(op[1], op[2])
|
||||||
|
self._ops = []
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractCredentialsTests(TestCase):
|
||||||
|
def test_extract_credentials_from_xc_style_url(self):
|
||||||
|
url = "http://example.com/live/alice/secret123/99999.ts"
|
||||||
|
user, password = extract_credentials_from_stream_url(url)
|
||||||
|
self.assertEqual(user, "alice")
|
||||||
|
self.assertEqual(password, "secret123")
|
||||||
|
|
||||||
|
|
||||||
|
class ManualServerGroupTests(TestCase):
|
||||||
|
def test_group_enforced_when_account_assigned(self):
|
||||||
|
group = ServerGroup.objects.create(name="provider-a")
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Account A",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_group=group,
|
||||||
|
)
|
||||||
|
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
|
||||||
|
self.assertEqual(get_enforced_server_group_for_profile(profile), group)
|
||||||
|
|
||||||
|
def test_accounts_in_same_group_share_credential_counter(self):
|
||||||
|
group = ServerGroup.objects.create(name="shared")
|
||||||
|
account1 = M3UAccount.objects.create(
|
||||||
|
name="XC Account",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
account2 = M3UAccount.objects.create(
|
||||||
|
name="M3U Account",
|
||||||
|
account_type="STD",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_group=group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
profile1 = M3UAccountProfile.objects.get(m3u_account=account1, is_default=True)
|
||||||
|
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
|
||||||
|
profile1.max_streams = 1
|
||||||
|
profile1.save()
|
||||||
|
profile2.max_streams = 1
|
||||||
|
profile2.save()
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
reserved1, _, _ = reserve_profile_slot(profile1, redis)
|
||||||
|
self.assertTrue(reserved1)
|
||||||
|
|
||||||
|
reserved2, _, _ = reserve_profile_slot(profile2, redis)
|
||||||
|
self.assertFalse(reserved2)
|
||||||
|
self.assertFalse(group_has_capacity_for_profile(profile2, redis))
|
||||||
|
|
||||||
|
def test_profile_rotation_when_default_profile_full(self):
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Multi-profile",
|
||||||
|
account_type="XC",
|
||||||
|
max_streams=1,
|
||||||
|
)
|
||||||
|
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
default.max_streams = 1
|
||||||
|
default.save()
|
||||||
|
|
||||||
|
alt = M3UAccountProfile.objects.create(
|
||||||
|
m3u_account=account,
|
||||||
|
name="alt_profile",
|
||||||
|
is_default=False,
|
||||||
|
is_active=True,
|
||||||
|
max_streams=1,
|
||||||
|
search_pattern="",
|
||||||
|
replace_pattern="",
|
||||||
|
)
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
reserved, _, _ = reserve_profile_slot(default, redis)
|
||||||
|
self.assertTrue(reserved)
|
||||||
|
self.assertFalse(profile_has_capacity_for_selection(default, redis))
|
||||||
|
self.assertTrue(profile_has_capacity_for_selection(alt, redis))
|
||||||
|
|
||||||
|
|
||||||
|
class PoolEnforcementTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.redis = FakeRedis()
|
||||||
|
self.group = ServerGroup.objects.create(name="test-pool")
|
||||||
|
self.account = M3UAccount.objects.create(
|
||||||
|
name="Test Account",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=self.group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
self.profile = M3UAccountProfile.objects.get(
|
||||||
|
m3u_account=self.account, is_default=True
|
||||||
|
)
|
||||||
|
self.profile.max_streams = 1
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
def test_group_has_capacity_reads_credential_counter_directly(self):
|
||||||
|
cred_key = server_group_connections_key(
|
||||||
|
self.group.id,
|
||||||
|
get_profile_credential_fingerprint(self.profile),
|
||||||
|
)
|
||||||
|
self.redis.set(cred_key, 1)
|
||||||
|
|
||||||
|
self.assertFalse(group_has_capacity_for_profile(self.profile, self.redis))
|
||||||
|
|
||||||
|
self.redis.set(cred_key, 0)
|
||||||
|
self.assertTrue(group_has_capacity_for_profile(self.profile, self.redis))
|
||||||
|
|
||||||
|
def test_reserve_and_release_both_counters(self):
|
||||||
|
reserved, count, _ = reserve_profile_slot(self.profile, self.redis)
|
||||||
|
self.assertTrue(reserved)
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
|
||||||
|
cred_key = server_group_connections_key(
|
||||||
|
self.group.id,
|
||||||
|
get_profile_credential_fingerprint(self.profile),
|
||||||
|
)
|
||||||
|
profile_key = profile_connections_key(self.profile.id)
|
||||||
|
self.assertEqual(self.redis._data[cred_key], 1)
|
||||||
|
self.assertEqual(self.redis._data[profile_key], 1)
|
||||||
|
|
||||||
|
release_profile_slot(self.profile.id, self.redis)
|
||||||
|
self.assertEqual(self.redis._data[cred_key], 0)
|
||||||
|
self.assertEqual(self.redis._data[profile_key], 0)
|
||||||
|
|
||||||
|
def test_same_credential_capped_at_profile_max(self):
|
||||||
|
"""Shared credential counter is capped by each profile's max_streams."""
|
||||||
|
account2 = M3UAccount.objects.create(
|
||||||
|
name="Second Account",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=self.group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
|
||||||
|
profile2.max_streams = 1
|
||||||
|
profile2.save()
|
||||||
|
|
||||||
|
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
|
||||||
|
self.assertFalse(reserve_profile_slot(profile2, self.redis)[0])
|
||||||
|
|
||||||
|
fp = get_profile_credential_fingerprint(self.profile)
|
||||||
|
cred_key = server_group_connections_key(self.group.id, fp)
|
||||||
|
self.assertEqual(self.redis._data[cred_key], 1)
|
||||||
|
|
||||||
|
def test_different_logins_both_stream_in_same_group(self):
|
||||||
|
"""Different provider logins keep separate credential counters in one group."""
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Grouped multi-login",
|
||||||
|
account_type="XC",
|
||||||
|
username="login_a",
|
||||||
|
password="pass_a",
|
||||||
|
server_group=self.group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
default.max_streams = 1
|
||||||
|
default.save()
|
||||||
|
|
||||||
|
alt = M3UAccountProfile.objects.create(
|
||||||
|
m3u_account=account,
|
||||||
|
name="alt_login",
|
||||||
|
is_default=False,
|
||||||
|
is_active=True,
|
||||||
|
max_streams=1,
|
||||||
|
search_pattern="",
|
||||||
|
replace_pattern="",
|
||||||
|
)
|
||||||
|
|
||||||
|
fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||||
|
side_effect=lambda profile: fp_a if profile.id == default.id else fp_b,
|
||||||
|
):
|
||||||
|
self.assertTrue(reserve_profile_slot(default, self.redis)[0])
|
||||||
|
self.assertTrue(reserve_profile_slot(alt, self.redis)[0])
|
||||||
|
|
||||||
|
key_a = server_group_connections_key(self.group.id, fp_a)
|
||||||
|
key_b = server_group_connections_key(self.group.id, fp_b)
|
||||||
|
self.assertEqual(self.redis._data[key_a], 1)
|
||||||
|
self.assertEqual(self.redis._data[key_b], 1)
|
||||||
|
|
||||||
|
def test_no_fingerprint_skips_credential_counter(self):
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="No creds",
|
||||||
|
account_type="STD",
|
||||||
|
server_group=self.group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
profile.max_streams = 1
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
self.assertTrue(reserve_profile_slot(profile, self.redis)[0])
|
||||||
|
self.assertEqual(get_credential_connection_count(profile, self.redis), 0)
|
||||||
|
self.assertEqual(get_credential_connection_count(profile, self.redis), 0)
|
||||||
|
|
||||||
|
def test_release_when_profile_row_deleted(self):
|
||||||
|
profile_id = self.profile.id
|
||||||
|
fp = get_profile_credential_fingerprint(self.profile)
|
||||||
|
cred_key = server_group_connections_key(self.group.id, fp)
|
||||||
|
|
||||||
|
reserved, _, failure_reason = reserve_profile_slot(
|
||||||
|
self.profile, self.redis
|
||||||
|
)
|
||||||
|
self.assertTrue(reserved)
|
||||||
|
self.assertIsNone(failure_reason)
|
||||||
|
self.profile.delete()
|
||||||
|
|
||||||
|
release_profile_slot(profile_id, self.redis)
|
||||||
|
|
||||||
|
self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0)
|
||||||
|
self.assertEqual(self.redis._data[cred_key], 0)
|
||||||
|
self.assertNotIn(profile_credential_release_key(profile_id), self.redis._data)
|
||||||
|
|
||||||
|
def test_release_uses_stored_credential_key_without_db_lookup(self):
|
||||||
|
profile_id = self.profile.id
|
||||||
|
cred_key = server_group_connections_key(
|
||||||
|
self.group.id,
|
||||||
|
get_profile_credential_fingerprint(self.profile),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
|
||||||
|
|
||||||
|
with patch("apps.m3u.models.M3UAccountProfile.objects.get") as mock_get:
|
||||||
|
release_profile_slot(profile_id, self.redis)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0)
|
||||||
|
self.assertEqual(self.redis._data[cred_key], 0)
|
||||||
|
|
||||||
|
def test_reserve_returns_failure_reason_without_extra_checks(self):
|
||||||
|
account2 = M3UAccount.objects.create(
|
||||||
|
name="Reason Account",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=self.group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
|
||||||
|
profile2.max_streams = 1
|
||||||
|
profile2.save()
|
||||||
|
|
||||||
|
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
|
||||||
|
reserved, _count, reason = reserve_profile_slot(profile2, self.redis)
|
||||||
|
self.assertFalse(reserved)
|
||||||
|
self.assertEqual(reason, "credential_full")
|
||||||
|
|
||||||
|
|
||||||
|
class StaleAssignmentTests(TestCase):
|
||||||
|
def test_stale_assignment_releases_counters(self):
|
||||||
|
from apps.channels.models import Channel, Stream
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
group = ServerGroup.objects.create(name="stale-group")
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Stale Account",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
profile.max_streams = 1
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
stream = Stream.objects.create(name="Stale Stream", m3u_account=account)
|
||||||
|
channel = Channel.objects.create(channel_number=502, name="Stale Channel")
|
||||||
|
channel.streams.add(stream)
|
||||||
|
|
||||||
|
reserve_profile_slot(profile, redis)
|
||||||
|
redis.set(f"channel_stream:{channel.id}", stream.id)
|
||||||
|
redis.set(f"stream_profile:{stream.id}", profile.id)
|
||||||
|
|
||||||
|
profile_key = profile_connections_key(profile.id)
|
||||||
|
cred_key = server_group_connections_key(
|
||||||
|
group.id, get_profile_credential_fingerprint(profile)
|
||||||
|
)
|
||||||
|
self.assertEqual(redis._data[profile_key], 1)
|
||||||
|
self.assertEqual(redis._data[cred_key], 1)
|
||||||
|
|
||||||
|
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||||
|
channel._release_stale_stream_assignment(redis, stream.id)
|
||||||
|
|
||||||
|
self.assertNotIn(f"channel_stream:{channel.id}", redis._data)
|
||||||
|
self.assertNotIn(f"stream_profile:{stream.id}", redis._data)
|
||||||
|
self.assertEqual(redis._data[profile_key], 0)
|
||||||
|
self.assertEqual(redis._data[cred_key], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateStreamProfileTests(TestCase):
|
||||||
|
def test_switch_updates_profile_counters_when_group_assigned(self):
|
||||||
|
from apps.channels.models import Channel, Stream
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
group = ServerGroup.objects.create(name="switch-group")
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Switch Account",
|
||||||
|
account_type="XC",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
profile_a.max_streams = 1
|
||||||
|
profile_a.save()
|
||||||
|
profile_b = M3UAccountProfile.objects.create(
|
||||||
|
m3u_account=account,
|
||||||
|
name="alt",
|
||||||
|
is_default=False,
|
||||||
|
is_active=True,
|
||||||
|
max_streams=1,
|
||||||
|
search_pattern="",
|
||||||
|
replace_pattern="",
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = Stream.objects.create(name="Test Stream", m3u_account=account)
|
||||||
|
channel = Channel.objects.create(channel_number=501, name="Switch Channel")
|
||||||
|
channel.streams.add(stream)
|
||||||
|
|
||||||
|
reserve_profile_slot(profile_a, redis)
|
||||||
|
redis.set(f"channel_stream:{channel.id}", stream.id)
|
||||||
|
redis.set(f"stream_profile:{stream.id}", profile_a.id)
|
||||||
|
|
||||||
|
cred_key = server_group_connections_key(
|
||||||
|
group.id, get_profile_credential_fingerprint(profile_a)
|
||||||
|
)
|
||||||
|
cred_before = redis._data[cred_key]
|
||||||
|
|
||||||
|
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||||
|
self.assertTrue(channel.update_stream_profile(profile_b.id))
|
||||||
|
|
||||||
|
self.assertEqual(int(redis.get(f"stream_profile:{stream.id}")), profile_b.id)
|
||||||
|
self.assertEqual(redis._data[profile_connections_key(profile_a.id)], 0)
|
||||||
|
self.assertEqual(redis._data[profile_connections_key(profile_b.id)], 1)
|
||||||
|
self.assertEqual(redis._data[cred_key], cred_before)
|
||||||
|
|
||||||
|
def test_switch_moves_credential_counter_when_login_changes(self):
|
||||||
|
from apps.channels.models import Channel, Stream
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
group = ServerGroup.objects.create(name="cred-switch-group")
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Cred Switch Account",
|
||||||
|
account_type="XC",
|
||||||
|
username="login_a",
|
||||||
|
password="pass_a",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
profile_a.max_streams = 1
|
||||||
|
profile_a.save()
|
||||||
|
profile_b = M3UAccountProfile.objects.create(
|
||||||
|
m3u_account=account,
|
||||||
|
name="alt_login",
|
||||||
|
is_default=False,
|
||||||
|
is_active=True,
|
||||||
|
max_streams=1,
|
||||||
|
search_pattern="",
|
||||||
|
replace_pattern="",
|
||||||
|
)
|
||||||
|
|
||||||
|
fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
key_a = server_group_connections_key(group.id, fp_a)
|
||||||
|
key_b = server_group_connections_key(group.id, fp_b)
|
||||||
|
|
||||||
|
stream = Stream.objects.create(name="Cred Switch Stream", m3u_account=account)
|
||||||
|
channel = Channel.objects.create(channel_number=503, name="Cred Switch Channel")
|
||||||
|
channel.streams.add(stream)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||||
|
side_effect=lambda profile: fp_a if profile.id == profile_a.id else fp_b,
|
||||||
|
):
|
||||||
|
reserve_profile_slot(profile_a, redis)
|
||||||
|
redis.set(f"channel_stream:{channel.id}", stream.id)
|
||||||
|
redis.set(f"stream_profile:{stream.id}", profile_a.id)
|
||||||
|
self.assertEqual(redis._data[key_a], 1)
|
||||||
|
self.assertNotIn(key_b, redis._data)
|
||||||
|
|
||||||
|
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||||
|
self.assertTrue(channel.update_stream_profile(profile_b.id))
|
||||||
|
|
||||||
|
self.assertEqual(redis._data[key_a], 0)
|
||||||
|
self.assertEqual(redis._data[key_b], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class VodProfileSelectionTests(TestCase):
|
||||||
|
def test_get_m3u_profile_skips_default_when_profile_full(self):
|
||||||
|
from apps.proxy.vod_proxy.views import _get_m3u_profile
|
||||||
|
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="VOD multi-profile",
|
||||||
|
account_type="XC",
|
||||||
|
username="xc_user_a",
|
||||||
|
password="xc_pass_a",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
max_streams=1,
|
||||||
|
)
|
||||||
|
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
default.max_streams = 1
|
||||||
|
default.save()
|
||||||
|
|
||||||
|
alt = M3UAccountProfile.objects.create(
|
||||||
|
m3u_account=account,
|
||||||
|
name="alt_profile",
|
||||||
|
is_default=False,
|
||||||
|
is_active=True,
|
||||||
|
max_streams=1,
|
||||||
|
search_pattern="",
|
||||||
|
replace_pattern="",
|
||||||
|
)
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
self.assertTrue(reserve_profile_slot(default, redis)[0])
|
||||||
|
|
||||||
|
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||||
|
result = _get_m3u_profile(account, None, None)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
selected, _connections = result
|
||||||
|
self.assertEqual(selected.id, alt.id)
|
||||||
|
|
||||||
|
def test_get_m3u_profile_skips_default_when_credential_pool_full(self):
|
||||||
|
from apps.proxy.vod_proxy.views import _get_m3u_profile
|
||||||
|
|
||||||
|
group = ServerGroup.objects.create(name="vod-cred-pool")
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="VOD pooled",
|
||||||
|
account_type="XC",
|
||||||
|
username="shared_user",
|
||||||
|
password="shared_pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
default.max_streams = 1
|
||||||
|
default.save()
|
||||||
|
|
||||||
|
alt = M3UAccountProfile.objects.create(
|
||||||
|
m3u_account=account,
|
||||||
|
name="alt_login",
|
||||||
|
is_default=False,
|
||||||
|
is_active=True,
|
||||||
|
max_streams=1,
|
||||||
|
search_pattern="",
|
||||||
|
replace_pattern="",
|
||||||
|
)
|
||||||
|
|
||||||
|
other_account = M3UAccount.objects.create(
|
||||||
|
name="Other pooled account",
|
||||||
|
account_type="XC",
|
||||||
|
username="shared_user",
|
||||||
|
password="shared_pass",
|
||||||
|
server_url="http://xc.example.com",
|
||||||
|
server_group=group,
|
||||||
|
max_streams=5,
|
||||||
|
)
|
||||||
|
other_profile = M3UAccountProfile.objects.get(
|
||||||
|
m3u_account=other_account, is_default=True
|
||||||
|
)
|
||||||
|
other_profile.max_streams = 1
|
||||||
|
other_profile.save()
|
||||||
|
|
||||||
|
fp_shared = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||||
|
fp_alt = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
with patch(
|
||||||
|
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||||
|
side_effect=lambda profile: (
|
||||||
|
fp_shared
|
||||||
|
if profile.id in (default.id, other_profile.id)
|
||||||
|
else fp_alt
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self.assertTrue(reserve_profile_slot(other_profile, redis)[0])
|
||||||
|
|
||||||
|
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||||
|
result = _get_m3u_profile(account, None, None)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
selected, _connections = result
|
||||||
|
self.assertEqual(selected.id, alt.id)
|
||||||
|
|
@ -30,9 +30,47 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase):
|
||||||
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
|
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
|
||||||
|
|
||||||
with patch("django.db.connections") as mock_connections:
|
with patch("django.db.connections") as mock_connections:
|
||||||
process_m3u_batch_direct(1, [], {}, ["name", "url"])
|
process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[])
|
||||||
mock_connections.close_all.assert_called()
|
mock_connections.close_all.assert_called()
|
||||||
|
|
||||||
|
@patch("apps.m3u.tasks.Stream")
|
||||||
|
@patch("apps.m3u.tasks.M3UAccount")
|
||||||
|
def test_batch_calls_gc_collect(self, mock_account_cls, mock_stream_cls):
|
||||||
|
"""gc.collect() must run after each batch so XC refresh threads release promptly."""
|
||||||
|
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("gc.collect") as mock_gc, patch("django.db.connections"):
|
||||||
|
process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[])
|
||||||
|
mock_gc.assert_called()
|
||||||
|
|
||||||
|
@patch("apps.m3u.tasks.Stream")
|
||||||
|
@patch("apps.m3u.tasks.M3UAccount")
|
||||||
|
def test_precompiled_empty_filters_skip_db_lookup(
|
||||||
|
self, mock_account_cls, mock_stream_cls,
|
||||||
|
):
|
||||||
|
"""When filters are precompiled as empty, batch workers must not query filters."""
|
||||||
|
from apps.m3u.tasks import process_m3u_batch_direct
|
||||||
|
|
||||||
|
mock_account = MagicMock()
|
||||||
|
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"):
|
||||||
|
process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[])
|
||||||
|
|
||||||
|
mock_account.filters.order_by.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class LockReleaseTests(SimpleTestCase):
|
class LockReleaseTests(SimpleTestCase):
|
||||||
"""Verify task lock is released on all exit paths."""
|
"""Verify task lock is released on all exit paths."""
|
||||||
|
|
|
||||||
46
apps/m3u/tests/test_password_visibility.py
Normal file
46
apps/m3u/tests/test_password_visibility.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""M3U account password visibility for admin vs standard users."""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIRequestFactory
|
||||||
|
|
||||||
|
from apps.m3u.models import M3UAccount
|
||||||
|
from apps.m3u.serializers import M3UAccountSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class M3UPasswordVisibilityTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.admin = User.objects.create_user(
|
||||||
|
username="m3u_pwd_admin",
|
||||||
|
password="x",
|
||||||
|
user_level=User.UserLevel.ADMIN,
|
||||||
|
)
|
||||||
|
self.standard = User.objects.create_user(
|
||||||
|
username="m3u_pwd_user",
|
||||||
|
password="x",
|
||||||
|
user_level=User.UserLevel.STANDARD,
|
||||||
|
)
|
||||||
|
self.account = M3UAccount.objects.create(
|
||||||
|
name="XC Acc",
|
||||||
|
server_url="http://example.test",
|
||||||
|
account_type="XC",
|
||||||
|
username="xcuser",
|
||||||
|
password="super-secret",
|
||||||
|
)
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
|
||||||
|
def test_admin_sees_password(self):
|
||||||
|
request = self.factory.get("/api/m3u/accounts/")
|
||||||
|
request.user = self.admin
|
||||||
|
data = M3UAccountSerializer(
|
||||||
|
self.account, context={"request": request}
|
||||||
|
).data
|
||||||
|
self.assertEqual(data.get("password"), "super-secret")
|
||||||
|
|
||||||
|
def test_standard_user_does_not_see_password(self):
|
||||||
|
request = self.factory.get("/api/m3u/accounts/")
|
||||||
|
request.user = self.standard
|
||||||
|
data = M3UAccountSerializer(
|
||||||
|
self.account, context={"request": request}
|
||||||
|
).data
|
||||||
|
self.assertNotIn("password", data)
|
||||||
74
apps/m3u/tests/test_refresh_db_recovery.py
Normal file
74
apps/m3u/tests/test_refresh_db_recovery.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.m3u.tasks import (
|
||||||
|
_db_query_with_retry,
|
||||||
|
_get_active_m3u_account,
|
||||||
|
_release_task_db_connection,
|
||||||
|
refresh_single_m3u_account,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DbQueryWithRetryTests(SimpleTestCase):
|
||||||
|
def test_retries_after_index_error_from_poisoned_connection(self):
|
||||||
|
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.m3u.tasks._release_task_db_connection"
|
||||||
|
) as mock_release:
|
||||||
|
result = _db_query_with_retry(fn, label="test query")
|
||||||
|
|
||||||
|
self.assertEqual(result, "ok")
|
||||||
|
self.assertEqual(fn.call_count, 2)
|
||||||
|
mock_release.assert_called_once()
|
||||||
|
|
||||||
|
def test_raises_after_exhausting_retries(self):
|
||||||
|
fn = MagicMock(side_effect=IndexError("list index out of range"))
|
||||||
|
|
||||||
|
with patch("apps.m3u.tasks._release_task_db_connection"):
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
_db_query_with_retry(fn, label="test query", max_retries=2)
|
||||||
|
|
||||||
|
self.assertEqual(fn.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTaskDbStartupTests(SimpleTestCase):
|
||||||
|
@patch("apps.m3u.tasks._ensure_m3u_refresh_terminal_status")
|
||||||
|
@patch("apps.m3u.tasks._refresh_single_m3u_account_impl")
|
||||||
|
@patch("apps.m3u.tasks.release_task_lock")
|
||||||
|
@patch("apps.m3u.tasks.acquire_task_lock", return_value=True)
|
||||||
|
@patch("apps.m3u.tasks.TaskLockRenewer")
|
||||||
|
@patch("apps.m3u.tasks._release_task_db_connection")
|
||||||
|
def test_refresh_releases_db_connection_before_impl(
|
||||||
|
self,
|
||||||
|
mock_release,
|
||||||
|
_mock_renewer,
|
||||||
|
_mock_acquire,
|
||||||
|
_mock_release_lock,
|
||||||
|
mock_impl,
|
||||||
|
_mock_ensure_terminal,
|
||||||
|
):
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
def track_release():
|
||||||
|
call_order.append("release")
|
||||||
|
|
||||||
|
mock_release.side_effect = track_release
|
||||||
|
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
|
||||||
|
|
||||||
|
result = refresh_single_m3u_account(140)
|
||||||
|
|
||||||
|
self.assertEqual(result, "done")
|
||||||
|
self.assertEqual(call_order[:2], ["release", "impl"])
|
||||||
|
|
||||||
|
@patch("apps.m3u.tasks.M3UAccount")
|
||||||
|
def test_get_active_m3u_account_uses_retry_helper(self, mock_model):
|
||||||
|
mock_model.objects.get.return_value = MagicMock(is_active=True)
|
||||||
|
|
||||||
|
with patch("apps.m3u.tasks._db_query_with_retry") as mock_retry:
|
||||||
|
mock_retry.side_effect = lambda fn, **_: fn()
|
||||||
|
account = _get_active_m3u_account(140)
|
||||||
|
|
||||||
|
mock_retry.assert_called_once()
|
||||||
|
self.assertTrue(account.is_active)
|
||||||
212
apps/m3u/tests/test_rename_preview_parity.py
Normal file
212
apps/m3u/tests/test_rename_preview_parity.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""
|
||||||
|
Differential parity tests: the auto-sync rename preview must predict the exact
|
||||||
|
name the live rename produces, across a broad matrix of regex strategies.
|
||||||
|
|
||||||
|
Both the preview and the live rename compile with the third-party `regex`
|
||||||
|
module (for its JS-aligned syntax and per-call timeout). They can only be
|
||||||
|
trusted together if, for every find/replace a user might author, the preview's
|
||||||
|
predicted `after` equals the channel name the sync actually writes.
|
||||||
|
|
||||||
|
Each case is run end-to-end: real streams, the real sync_auto_channels rename,
|
||||||
|
and the real regex-preview endpoint, compared per original stream name.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.channels.models import Channel, ChannelGroup, ChannelStream, Stream
|
||||||
|
from apps.m3u.models import M3UAccount
|
||||||
|
from apps.m3u.tasks import sync_auto_channels
|
||||||
|
|
||||||
|
|
||||||
|
# Diverse names: distinct word-prefixes (collision-resistant), with quality
|
||||||
|
# tags, brackets, pipes, ampersands, extra whitespace, underscores, dots, CJK,
|
||||||
|
# and emoji, to exercise anchors, classes, boundaries, and Unicode handling.
|
||||||
|
NAMES = [
|
||||||
|
"Alpha Channel 11",
|
||||||
|
"Bravo Sports HD",
|
||||||
|
"Charlie News FHD",
|
||||||
|
"Delta Movie (2024)",
|
||||||
|
"Echo [UK] 4K",
|
||||||
|
"Foxtrot spaced",
|
||||||
|
"Golf|Pipe|Name",
|
||||||
|
"Hotel & Inn <x>",
|
||||||
|
"India 日本語 77",
|
||||||
|
"Juliet 📺 88",
|
||||||
|
"Kilo_under_9",
|
||||||
|
"Lima.dot.name",
|
||||||
|
]
|
||||||
|
|
||||||
|
# (find, replace) pairs spanning common user strategies and edge cases.
|
||||||
|
STRATEGIES = [
|
||||||
|
# --- capture groups ---
|
||||||
|
(r"(.+) Channel (\d+)", r"$1 #$2"),
|
||||||
|
(r"(\w+) (\w+)", r"$2 $1"),
|
||||||
|
(r"(.+)", r"$1 - $1"),
|
||||||
|
(r"(.+)", r"[$1]"),
|
||||||
|
(r"(.+) (\d+)$", r"$2 $1"),
|
||||||
|
# --- strip / delete ---
|
||||||
|
(r" (HD|FHD|4K|SD)\b", r""),
|
||||||
|
(r"\s+", r" "),
|
||||||
|
(r"[\[\(].*?[\]\)]", r""),
|
||||||
|
(r"\d+", r""),
|
||||||
|
(r"[_.]", r" "),
|
||||||
|
# --- anchors / inserts ---
|
||||||
|
(r"^", r"NEW "),
|
||||||
|
(r"$", r" LIVE"),
|
||||||
|
(r"^(\w+)", r"<$1>"),
|
||||||
|
# --- char classes / Unicode (divergence hunters) ---
|
||||||
|
(r"\w+", r"W"),
|
||||||
|
(r"\b\w", r"_"),
|
||||||
|
(r"[A-Z]", r"*"),
|
||||||
|
(r"\s", r"_"),
|
||||||
|
(r"[^\x00-\x7F]+", r"?"),
|
||||||
|
# --- non-capturing / lookaround / in-pattern backref ---
|
||||||
|
(r"(?:Channel|Movie|News) ", r""),
|
||||||
|
(r"(\w)\1", r"$1"),
|
||||||
|
(r"\w+(?= )", r"X"),
|
||||||
|
(r"(?<=\d)\d", r"#"),
|
||||||
|
# --- literal $ and odd replacements ---
|
||||||
|
(r" ", r" $ "),
|
||||||
|
(r"o", r"0"),
|
||||||
|
# --- invalid group references (rejected by both engines) ---
|
||||||
|
(r"(.+)", r"$2"),
|
||||||
|
(r"(.+)", r"$10"),
|
||||||
|
# --- rename that expands past Channel.name's column length ---
|
||||||
|
(r"(.+)", r"$1" * 40),
|
||||||
|
# --- regex-module syntax: quantified anchor, JS-style and duplicate
|
||||||
|
# named groups (these transform; both paths use the regex module) ---
|
||||||
|
(r"^*", r"$"),
|
||||||
|
(r"(?<season>\d+)", r"S$1"),
|
||||||
|
(r"(?P<n>x)(?P<n>y)", r"z"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RenamePreviewParityTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
admin = User.objects.create_superuser(
|
||||||
|
username="admin_rename_parity", password="pw", user_level=10
|
||||||
|
)
|
||||||
|
cls.client_api = APIClient()
|
||||||
|
cls.client_api.force_authenticate(user=admin)
|
||||||
|
cls.account = M3UAccount.objects.create(
|
||||||
|
name="Rename Parity Provider",
|
||||||
|
server_url="http://example.com/test.m3u",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync(self):
|
||||||
|
return sync_auto_channels(
|
||||||
|
self.account.id,
|
||||||
|
scan_start_time=(
|
||||||
|
timezone.now() - timezone.timedelta(minutes=1)
|
||||||
|
).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_case(self, group_name, find, replace):
|
||||||
|
"""Returns a list of human-readable mismatch strings (empty == parity)."""
|
||||||
|
group = ChannelGroup.objects.create(name=group_name)
|
||||||
|
from apps.channels.models import ChannelGroupM3UAccount
|
||||||
|
|
||||||
|
ChannelGroupM3UAccount.objects.create(
|
||||||
|
m3u_account=self.account,
|
||||||
|
channel_group=group,
|
||||||
|
enabled=True,
|
||||||
|
auto_channel_sync=True,
|
||||||
|
auto_sync_channel_start=1000,
|
||||||
|
custom_properties={
|
||||||
|
"name_regex_pattern": find,
|
||||||
|
"name_replace_pattern": replace,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for i, name in enumerate(NAMES):
|
||||||
|
Stream.objects.create(
|
||||||
|
name=name,
|
||||||
|
url=f"http://example.com/{group_name}_{i}.m3u8",
|
||||||
|
m3u_account=self.account,
|
||||||
|
channel_group=group,
|
||||||
|
tvg_id=f"{group_name}-{i}",
|
||||||
|
last_seen=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- live rename via sync ---
|
||||||
|
result = self._sync()
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
return [f"[{find!r} -> {replace!r}] sync status={result.get('status')}"]
|
||||||
|
|
||||||
|
channels = Channel.objects.filter(
|
||||||
|
auto_created_by=self.account, channel_group=group
|
||||||
|
)
|
||||||
|
cs_rows = ChannelStream.objects.filter(
|
||||||
|
channel__in=channels
|
||||||
|
).select_related("channel", "stream")
|
||||||
|
sync_map = {row.stream.name: row.channel.name for row in cs_rows}
|
||||||
|
|
||||||
|
# --- preview endpoint ---
|
||||||
|
response = self.client_api.get(
|
||||||
|
"/api/channels/streams/regex-preview/",
|
||||||
|
{
|
||||||
|
"channel_group": group_name,
|
||||||
|
"find": find,
|
||||||
|
"replace": replace,
|
||||||
|
"limit": 50,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return [f"[{find!r} -> {replace!r}] preview HTTP {response.status_code}"]
|
||||||
|
data = response.data
|
||||||
|
# When the preview reports find_error it predicts no rename at all.
|
||||||
|
preview_map = {name: name for name in NAMES}
|
||||||
|
if "find_error" not in data:
|
||||||
|
for m in data.get("find_matches", []):
|
||||||
|
preview_map[m["before"]] = m["after"]
|
||||||
|
|
||||||
|
mismatches = []
|
||||||
|
for name in NAMES:
|
||||||
|
sync_after = sync_map.get(name)
|
||||||
|
if sync_after is None:
|
||||||
|
mismatches.append(
|
||||||
|
f"[{find!r} -> {replace!r}] {name!r}: no channel created"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
preview_after = preview_map[name]
|
||||||
|
if preview_after != sync_after:
|
||||||
|
mismatches.append(
|
||||||
|
f"[{find!r} -> {replace!r}] {name!r}: "
|
||||||
|
f"preview={preview_after!r} sync={sync_after!r} "
|
||||||
|
f"(find_error={data.get('find_error')!r})"
|
||||||
|
)
|
||||||
|
return mismatches
|
||||||
|
|
||||||
|
def test_preview_predicts_rename_across_strategies(self):
|
||||||
|
all_mismatches = []
|
||||||
|
for idx, (find, replace) in enumerate(STRATEGIES):
|
||||||
|
all_mismatches.extend(
|
||||||
|
self._run_case(f"ParityG{idx}", find, replace)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
all_mismatches,
|
||||||
|
[],
|
||||||
|
"Preview diverged from the live rename:\n"
|
||||||
|
+ "\n".join(all_mismatches),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlong_rename_is_bounded_not_aborting_sync(self):
|
||||||
|
# A rename that expands past Channel.name's column length must not
|
||||||
|
# abort the bulk_create sync. Both sync and preview cap at the column
|
||||||
|
# length so the channel is created (truncated) and the preview shows
|
||||||
|
# the same bounded name.
|
||||||
|
max_len = Channel._meta.get_field("name").max_length
|
||||||
|
mismatches = self._run_case("OverlongG", r"(.+)", "$1" * 40)
|
||||||
|
self.assertEqual(mismatches, [], "\n".join(mismatches))
|
||||||
|
|
||||||
|
group = ChannelGroup.objects.get(name="OverlongG")
|
||||||
|
channels = Channel.objects.filter(
|
||||||
|
auto_created_by=self.account, channel_group=group
|
||||||
|
)
|
||||||
|
self.assertEqual(channels.count(), len(NAMES))
|
||||||
|
self.assertTrue(all(len(c.name) <= max_len for c in channels))
|
||||||
|
self.assertTrue(any(len(c.name) == max_len for c in channels))
|
||||||
156
apps/m3u/tests/test_stream_filters.py
Normal file
156
apps/m3u/tests/test_stream_filters.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""Tests for M3U stream filter compilation and batch application."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
|
||||||
|
from apps.m3u.tasks import (
|
||||||
|
_compile_m3u_stream_filters,
|
||||||
|
_stream_passes_m3u_filters,
|
||||||
|
process_m3u_batch_direct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompileM3UStreamFiltersTests(SimpleTestCase):
|
||||||
|
def test_compiles_case_insensitive_when_configured(self):
|
||||||
|
filter_obj = MagicMock()
|
||||||
|
filter_obj.regex_pattern = "news"
|
||||||
|
filter_obj.custom_properties = {"case_sensitive": False}
|
||||||
|
|
||||||
|
compiled = _compile_m3u_stream_filters([filter_obj])
|
||||||
|
|
||||||
|
self.assertEqual(len(compiled), 1)
|
||||||
|
pattern, _ = compiled[0]
|
||||||
|
self.assertTrue(pattern.search("NEWS"))
|
||||||
|
|
||||||
|
def test_compiles_case_sensitive_by_default(self):
|
||||||
|
filter_obj = MagicMock()
|
||||||
|
filter_obj.regex_pattern = "news"
|
||||||
|
filter_obj.custom_properties = {}
|
||||||
|
|
||||||
|
compiled = _compile_m3u_stream_filters([filter_obj])
|
||||||
|
|
||||||
|
pattern, _ = compiled[0]
|
||||||
|
self.assertIsNone(pattern.search("NEWS"))
|
||||||
|
self.assertTrue(pattern.search("news"))
|
||||||
|
|
||||||
|
|
||||||
|
class StreamPassesM3UFiltersTests(SimpleTestCase):
|
||||||
|
def _compiled(self, *, filter_type="name", exclude=False, pattern="Adult"):
|
||||||
|
filter_obj = MagicMock()
|
||||||
|
filter_obj.filter_type = filter_type
|
||||||
|
filter_obj.exclude = exclude
|
||||||
|
filter_obj.regex_pattern = pattern
|
||||||
|
filter_obj.custom_properties = {}
|
||||||
|
return _compile_m3u_stream_filters([filter_obj])
|
||||||
|
|
||||||
|
def test_include_filter_passes_matching_stream(self):
|
||||||
|
compiled = self._compiled(exclude=False)
|
||||||
|
self.assertTrue(
|
||||||
|
_stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_include_filter_passes_non_matching_stream(self):
|
||||||
|
"""Non-matching streams still pass unless a matching exclude filter hits."""
|
||||||
|
compiled = self._compiled(exclude=False, pattern="news")
|
||||||
|
self.assertTrue(
|
||||||
|
_stream_passes_m3u_filters("Sports", "http://x", "Sports", compiled)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exclude_filter_rejects_matching_stream(self):
|
||||||
|
compiled = self._compiled(exclude=True, pattern="Adult")
|
||||||
|
self.assertFalse(
|
||||||
|
_stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_url_filter_type_targets_url(self):
|
||||||
|
compiled = self._compiled(filter_type="url", exclude=True, pattern="blocked")
|
||||||
|
self.assertFalse(
|
||||||
|
_stream_passes_m3u_filters("OK", "http://blocked.example/live", "News", compiled)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
_stream_passes_m3u_filters("blocked name", "http://ok.example/live", "News", compiled)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_group_filter_type_targets_group(self):
|
||||||
|
compiled = self._compiled(filter_type="group", exclude=True, pattern="Hidden")
|
||||||
|
self.assertFalse(
|
||||||
|
_stream_passes_m3u_filters("Channel", "http://x", "Hidden Group", compiled)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessM3UBatchFilterTests(TestCase):
|
||||||
|
def _mock_stream_meta(self, mock_stream_cls, max_length=255):
|
||||||
|
mock_field = MagicMock()
|
||||||
|
mock_field.max_length = max_length
|
||||||
|
mock_stream_cls._meta.get_field.return_value = mock_field
|
||||||
|
|
||||||
|
@patch("apps.m3u.tasks._bulk_update_stream_refresh_batches")
|
||||||
|
@patch("apps.m3u.tasks.Stream")
|
||||||
|
@patch("apps.m3u.tasks.M3UAccount")
|
||||||
|
def test_exclude_filter_skips_stream_import(
|
||||||
|
self, mock_account_cls, mock_stream_cls, mock_bulk_update,
|
||||||
|
):
|
||||||
|
self._mock_stream_meta(mock_stream_cls)
|
||||||
|
mock_account = MagicMock()
|
||||||
|
mock_account.account_type = "STD"
|
||||||
|
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")
|
||||||
|
|
||||||
|
filter_obj = MagicMock()
|
||||||
|
filter_obj.regex_pattern = "skip-me"
|
||||||
|
filter_obj.filter_type = "name"
|
||||||
|
filter_obj.exclude = True
|
||||||
|
filter_obj.custom_properties = {}
|
||||||
|
compiled = _compile_m3u_stream_filters([filter_obj])
|
||||||
|
|
||||||
|
batch = [{
|
||||||
|
"name": "skip-me channel",
|
||||||
|
"url": "http://example/live",
|
||||||
|
"attributes": {"group-title": "News"},
|
||||||
|
"vlc_opts": {},
|
||||||
|
}]
|
||||||
|
|
||||||
|
with patch("django.db.connections"):
|
||||||
|
result = process_m3u_batch_direct(
|
||||||
|
1, batch, {"News": 1}, ["name", "url"], compiled_filters=compiled,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("0 created", result)
|
||||||
|
mock_stream_cls.objects.bulk_create.assert_not_called()
|
||||||
|
mock_bulk_update.assert_called_once_with([], [], batch_size=200)
|
||||||
|
|
||||||
|
@patch("apps.m3u.tasks._bulk_update_stream_refresh_batches")
|
||||||
|
@patch("apps.m3u.tasks.Stream")
|
||||||
|
@patch("apps.m3u.tasks.M3UAccount")
|
||||||
|
def test_no_filters_imports_matching_stream(
|
||||||
|
self, mock_account_cls, mock_stream_cls, mock_bulk_update,
|
||||||
|
):
|
||||||
|
self._mock_stream_meta(mock_stream_cls)
|
||||||
|
mock_account = MagicMock()
|
||||||
|
mock_account.account_type = "STD"
|
||||||
|
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")
|
||||||
|
mock_stream_cls.objects.bulk_create.return_value = []
|
||||||
|
|
||||||
|
batch = [{
|
||||||
|
"name": "News One",
|
||||||
|
"url": "http://example/live",
|
||||||
|
"attributes": {"group-title": "News"},
|
||||||
|
"vlc_opts": {},
|
||||||
|
}]
|
||||||
|
|
||||||
|
with patch("django.db.connections"), patch(
|
||||||
|
"apps.m3u.tasks.transaction.atomic",
|
||||||
|
):
|
||||||
|
result = process_m3u_batch_direct(
|
||||||
|
1, batch, {"News": 1}, ["name", "url"], compiled_filters=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("1 created", result)
|
||||||
|
mock_stream_cls.objects.bulk_create.assert_called_once()
|
||||||
|
|
@ -7,6 +7,7 @@ the correct post-fix behavior. Comments call out the failure mode and the
|
||||||
fix location.
|
fix location.
|
||||||
"""
|
"""
|
||||||
from unittest import skipUnless
|
from unittest import skipUnless
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.test import TestCase, TransactionTestCase
|
from django.test import TestCase, TransactionTestCase
|
||||||
|
|
@ -46,13 +47,14 @@ def _attach_group_to_account(account, group, custom_properties=None):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_stream(account, group, name="ESPN", tvg_id="espn"):
|
def _make_stream(account, group, name="ESPN", tvg_id="espn", stream_chno=None):
|
||||||
return Stream.objects.create(
|
return Stream.objects.create(
|
||||||
name=name,
|
name=name,
|
||||||
url=f"http://example.com/{name.lower()}.m3u8",
|
url=f"http://example.com/{name.lower()}.m3u8",
|
||||||
m3u_account=account,
|
m3u_account=account,
|
||||||
channel_group=group,
|
channel_group=group,
|
||||||
tvg_id=tvg_id,
|
tvg_id=tvg_id,
|
||||||
|
stream_chno=stream_chno,
|
||||||
last_seen=timezone.now(),
|
last_seen=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -272,16 +274,37 @@ class AccountDeleteCleanupTests(TestCase):
|
||||||
|
|
||||||
class ChannelDeleteStopsProxyTests(TestCase):
|
class ChannelDeleteStopsProxyTests(TestCase):
|
||||||
"""
|
"""
|
||||||
Issue #870: When an auto-sync refresh deletes a channel that has an
|
Issue #870: sync-driven channel deletes must stop active proxy sessions
|
||||||
active proxy session, the session's Redis state survives, making the UI
|
before the DB row is removed. Manual deletes leave this optional via the
|
||||||
"Stop" button fail with 'Channel not found'. Fix is a pre_delete signal
|
stop_stream API flag (plain model.delete() does not stop).
|
||||||
on Channel that calls ChannelService.stop_channel first, covering
|
|
||||||
manual, bulk, and sync-triggered deletes uniformly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_pre_delete_signal_calls_stop_channel(self):
|
def test_model_delete_does_not_auto_stop_proxy(self):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="Sports")
|
||||||
|
channel = Channel.objects.create(
|
||||||
|
name="ESPN",
|
||||||
|
channel_number=1,
|
||||||
|
channel_group=group,
|
||||||
|
auto_created=True,
|
||||||
|
auto_created_by=account,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel"
|
||||||
|
) as mock_stop:
|
||||||
|
channel.delete()
|
||||||
|
|
||||||
|
mock_stop.assert_not_called()
|
||||||
|
self.assertFalse(Channel.objects.filter(id=channel.id).exists())
|
||||||
|
|
||||||
|
def test_sync_helper_stops_proxy_before_delete(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from apps.m3u.tasks import _delete_channels_stopping_streams
|
||||||
|
|
||||||
account = _make_account()
|
account = _make_account()
|
||||||
group = _make_group(name="Sports")
|
group = _make_group(name="Sports")
|
||||||
channel = Channel.objects.create(
|
channel = Channel.objects.create(
|
||||||
|
|
@ -294,16 +317,22 @@ class ChannelDeleteStopsProxyTests(TestCase):
|
||||||
channel_uuid = str(channel.uuid)
|
channel_uuid = str(channel.uuid)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel"
|
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
|
||||||
) as mock_stop:
|
) as mock_stop:
|
||||||
channel.delete()
|
deleted = _delete_channels_stopping_streams([channel])
|
||||||
|
|
||||||
mock_stop.assert_called_once_with(channel_uuid)
|
self.assertEqual(deleted, 1)
|
||||||
|
mock_stop.assert_called_once()
|
||||||
|
stopped = [str(u) for u in mock_stop.call_args[0][0] if u]
|
||||||
|
self.assertEqual(stopped, [channel_uuid])
|
||||||
|
self.assertFalse(Channel.objects.filter(id=channel.id).exists())
|
||||||
|
|
||||||
def test_pre_delete_signal_swallows_stop_errors(self):
|
def test_sync_helper_continues_delete_when_stop_channel_fails(self):
|
||||||
"""Proxy failure must not block the DB delete."""
|
"""Per-channel proxy failures must not block the DB delete."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from apps.m3u.tasks import _delete_channels_stopping_streams
|
||||||
|
|
||||||
account = _make_account()
|
account = _make_account()
|
||||||
group = _make_group(name="Sports")
|
group = _make_group(name="Sports")
|
||||||
channel = Channel.objects.create(
|
channel = Channel.objects.create(
|
||||||
|
|
@ -313,14 +342,16 @@ class ChannelDeleteStopsProxyTests(TestCase):
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
auto_created_by=account,
|
auto_created_by=account,
|
||||||
)
|
)
|
||||||
|
channel_id = channel.id
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel",
|
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel",
|
||||||
side_effect=Exception("proxy is down"),
|
side_effect=Exception("proxy is down"),
|
||||||
):
|
):
|
||||||
channel.delete()
|
deleted = _delete_channels_stopping_streams([channel])
|
||||||
|
|
||||||
self.assertFalse(Channel.objects.filter(id=channel.id).exists())
|
self.assertEqual(deleted, 1)
|
||||||
|
self.assertFalse(Channel.objects.filter(id=channel_id).exists())
|
||||||
|
|
||||||
|
|
||||||
class RangeEnforcementTests(TestCase):
|
class RangeEnforcementTests(TestCase):
|
||||||
|
|
@ -413,21 +444,21 @@ class RangeEnforcementTests(TestCase):
|
||||||
"new auto-channel duplicates A's effective channel number.",
|
"new auto-channel duplicates A's effective channel number.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_provider_mode_fallback_respects_range_start(self):
|
def test_provider_mode_numberless_fallback_uses_visible_start(self):
|
||||||
# Provider-mode streams without a usable stream_chno fall back to
|
# In provider mode the visible "Start #" is channel_numbering_fallback,
|
||||||
# the next-available picker. The fallback must honor the group's
|
# so a numberless stream's fallback walks from there, not from the
|
||||||
# configured `auto_sync_channel_start` so freshly-created channels
|
# hidden auto_sync_channel_start (set far above the range here to prove
|
||||||
# never land below the user's chosen range. Without this, a group
|
# it is ignored).
|
||||||
# configured for [100, 200] silently spawned channels at #1 when
|
# Fail signature: 0 channels created, or a channel below 100 = fallback
|
||||||
# the provider omitted channel-number metadata.
|
# seeded from the wrong field.
|
||||||
account = _make_account()
|
account = _make_account()
|
||||||
group = _make_group(name="Sports")
|
group = _make_group(name="Sports")
|
||||||
rel = _attach_group_to_account(account, group)
|
rel = _attach_group_to_account(account, group)
|
||||||
rel.auto_sync_channel_start = 100
|
rel.auto_sync_channel_start = 5000 # hidden; must be ignored
|
||||||
rel.auto_sync_channel_end = 200
|
rel.auto_sync_channel_end = 200
|
||||||
rel.custom_properties = {
|
rel.custom_properties = {
|
||||||
"channel_numbering_mode": "provider",
|
"channel_numbering_mode": "provider",
|
||||||
"channel_numbering_fallback": 1,
|
"channel_numbering_fallback": 100, # the visible "Start #"
|
||||||
}
|
}
|
||||||
rel.save()
|
rel.save()
|
||||||
|
|
||||||
|
|
@ -643,6 +674,34 @@ class NumbersInRangeLookupTests(TestCase):
|
||||||
)
|
)
|
||||||
self.assertFalse(occupant["has_channel_number_override"])
|
self.assertFalse(occupant["has_channel_number_override"])
|
||||||
|
|
||||||
|
def test_group_override_channel_reports_target_group(self):
|
||||||
|
# When auto-sync routes channels into a different group via
|
||||||
|
# group_override, the occupant's channel_group_id is the override
|
||||||
|
# target, not the source group being configured. The frontend relies
|
||||||
|
# on this to recognize override-routed channels as the config's own
|
||||||
|
# output (effectiveSyncGroupId), so the warning does not flag them.
|
||||||
|
account = _make_account()
|
||||||
|
source = _make_group(name="SourceGrp")
|
||||||
|
target = _make_group(name="TargetGrp")
|
||||||
|
Channel.objects.create(
|
||||||
|
name="Routed",
|
||||||
|
channel_number=3210,
|
||||||
|
channel_group=target,
|
||||||
|
auto_created=True,
|
||||||
|
auto_created_by=account,
|
||||||
|
)
|
||||||
|
client = self._client()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/channels/channels/numbers-in-range/?start=3210&end=3210"
|
||||||
|
)
|
||||||
|
|
||||||
|
occupant = response.data["occupants"][0]
|
||||||
|
self.assertEqual(occupant["channel_group_id"], target.id)
|
||||||
|
self.assertNotEqual(occupant["channel_group_id"], source.id)
|
||||||
|
self.assertTrue(occupant["auto_created"])
|
||||||
|
self.assertEqual(occupant["auto_created_by_account_id"], account.id)
|
||||||
|
|
||||||
def test_manual_channel_exposed_with_auto_created_false(self):
|
def test_manual_channel_exposed_with_auto_created_false(self):
|
||||||
# Manual channels are always a real collision worth surfacing.
|
# Manual channels are always a real collision worth surfacing.
|
||||||
# The response must flag them with auto_created=False and a null
|
# The response must flag them with auto_created=False and a null
|
||||||
|
|
@ -734,6 +793,128 @@ class RegexPreviewTests(TestCase):
|
||||||
self.assertEqual(response.data["total_scanned"], 3)
|
self.assertEqual(response.data["total_scanned"], 3)
|
||||||
self.assertFalse(response.data["scan_limit_hit"])
|
self.assertFalse(response.data["scan_limit_hit"])
|
||||||
|
|
||||||
|
def test_find_replace_applies_numbered_capture_group(self):
|
||||||
|
# The replace field accepts JS-style $1 backreferences, but the regex
|
||||||
|
# engine expects \1. Without the conversion the preview echoes the
|
||||||
|
# literal "$1", so the previewed "after" disagrees with the name the
|
||||||
|
# live rename produces.
|
||||||
|
account = self._make_account()
|
||||||
|
group = _make_group(name="Sports")
|
||||||
|
Stream.objects.create(
|
||||||
|
name="High Limit Racing at Eagle @ Jun 9 7:00 PM",
|
||||||
|
url="http://example.com/hlr.m3u8",
|
||||||
|
m3u_account=account,
|
||||||
|
channel_group=group,
|
||||||
|
last_seen=timezone.now(),
|
||||||
|
)
|
||||||
|
client = self._client()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/channels/streams/regex-preview/",
|
||||||
|
{"channel_group": "Sports", "find": r"(.+) @.*", "replace": "$1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["find_match_count"], 1)
|
||||||
|
after = response.data["find_matches"][0]["after"]
|
||||||
|
self.assertEqual(after, "High Limit Racing at Eagle")
|
||||||
|
self.assertNotIn("$1", after)
|
||||||
|
|
||||||
|
def test_preview_after_matches_live_sync_rename(self):
|
||||||
|
# Guards the defect class: the preview and the live rename are
|
||||||
|
# separate code paths that must convert the replacement identically,
|
||||||
|
# so the preview can never promise an output the sync would not yield.
|
||||||
|
name = "High Limit Racing at Eagle @ Jun 9 7:00 PM"
|
||||||
|
account = self._make_account()
|
||||||
|
group = _make_group(name="Racing")
|
||||||
|
_attach_group_to_account(
|
||||||
|
account,
|
||||||
|
group,
|
||||||
|
custom_properties={
|
||||||
|
"name_regex_pattern": r"(.+) @.*",
|
||||||
|
"name_replace_pattern": "$1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_make_stream(account, group, name=name, tvg_id="hlr")
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
self.assertEqual(result.get("status"), "ok")
|
||||||
|
channel = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
live_name = channel.name
|
||||||
|
|
||||||
|
client = self._client()
|
||||||
|
response = client.get(
|
||||||
|
"/api/channels/streams/regex-preview/",
|
||||||
|
{"channel_group": "Racing", "find": r"(.+) @.*", "replace": "$1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
preview_after = response.data["find_matches"][0]["after"]
|
||||||
|
self.assertEqual(preview_after, live_name)
|
||||||
|
self.assertEqual(preview_after, "High Limit Racing at Eagle")
|
||||||
|
|
||||||
|
def test_regex_engine_pattern_transforms_in_preview(self):
|
||||||
|
# Both the preview and the live rename use the regex module, which is
|
||||||
|
# more permissive than stdlib re and matches the JS-style syntax the UI
|
||||||
|
# authors. A quantified anchor like "^*" (which stdlib re rejects)
|
||||||
|
# compiles and transforms rather than reporting an error.
|
||||||
|
account = self._make_account()
|
||||||
|
group = _make_group(name="Sports")
|
||||||
|
Stream.objects.create(
|
||||||
|
name="Doc95",
|
||||||
|
url="http://example.com/doc95.m3u8",
|
||||||
|
m3u_account=account,
|
||||||
|
channel_group=group,
|
||||||
|
last_seen=timezone.now(),
|
||||||
|
)
|
||||||
|
client = self._client()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/channels/streams/regex-preview/",
|
||||||
|
{"channel_group": "Sports", "find": "^*", "replace": "$"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn("find_error", response.data)
|
||||||
|
self.assertEqual(response.data["find_match_count"], 1)
|
||||||
|
# ^* matches the empty string at every position, so the literal $
|
||||||
|
# replacement is inserted between characters.
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["find_matches"][0]["after"], "$D$o$c$9$5$"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preview_and_sync_agree_on_regex_only_pattern(self):
|
||||||
|
# Parity guard for the engine alignment: a pattern valid in regex but
|
||||||
|
# not stdlib re must transform identically in the sync and the preview,
|
||||||
|
# rather than diverging (the sync no longer silently keeps the
|
||||||
|
# original name for these patterns).
|
||||||
|
name = "Doc95"
|
||||||
|
account = self._make_account()
|
||||||
|
group = _make_group(name="Docs")
|
||||||
|
_attach_group_to_account(
|
||||||
|
account,
|
||||||
|
group,
|
||||||
|
custom_properties={
|
||||||
|
"name_regex_pattern": "^*",
|
||||||
|
"name_replace_pattern": "$",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_make_stream(account, group, name=name, tvg_id="doc95")
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
self.assertEqual(result.get("status"), "ok")
|
||||||
|
channel = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
live_name = channel.name
|
||||||
|
self.assertNotEqual(live_name, name)
|
||||||
|
|
||||||
|
client = self._client()
|
||||||
|
response = client.get(
|
||||||
|
"/api/channels/streams/regex-preview/",
|
||||||
|
{"channel_group": "Docs", "find": "^*", "replace": "$"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["find_matches"][0]["after"], live_name)
|
||||||
|
|
||||||
def test_filter_returns_matched_names_with_count(self):
|
def test_filter_returns_matched_names_with_count(self):
|
||||||
account = self._make_account()
|
account = self._make_account()
|
||||||
group = _make_group(name="Sports")
|
group = _make_group(name="Sports")
|
||||||
|
|
@ -2039,10 +2220,13 @@ class Migration0037DemoteOrphansTests(TestCase):
|
||||||
"apps.channels.migrations.0037_auto_sync_overhaul"
|
"apps.channels.migrations.0037_auto_sync_overhaul"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The migration function takes (apps, schema_editor); apps is
|
connection = MagicMock()
|
||||||
# the historical app registry. For this unit test we call with
|
cursor = MagicMock()
|
||||||
# the live registry.
|
connection.cursor.return_value.__enter__.return_value = cursor
|
||||||
module.backfill_auto_created_by_null(django_apps, None)
|
connection.cursor.return_value.__exit__.return_value = False
|
||||||
|
schema_editor = MagicMock(connection=connection)
|
||||||
|
|
||||||
|
module.backfill_auto_created_by_null(django_apps, schema_editor)
|
||||||
|
|
||||||
ch.refresh_from_db()
|
ch.refresh_from_db()
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
|
|
@ -2300,3 +2484,315 @@ class CompactNumberingIdempotencyTests(TransactionTestCase):
|
||||||
"pass with no hide or override change. _repack_inner is following "
|
"pass with no hide or override change. _repack_inner is following "
|
||||||
"physical row order instead of id order.",
|
"physical row order instead of id order.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderNumberingHonorsProviderNumberTests(TestCase):
|
||||||
|
"""
|
||||||
|
Provider numbering uses a stream's provider number (stream_chno) verbatim.
|
||||||
|
The group start is auto-populated by the UI and is not editable in provider
|
||||||
|
mode (the UI binds "Start #" to channel_numbering_fallback), so treating it
|
||||||
|
as a lower bound silently discarded valid provider numbers: on a lineup
|
||||||
|
topping out near 5000, provider numbers 100-150 landed at ~5000.
|
||||||
|
|
||||||
|
The start and end bound only the fallback for numberless streams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_provider_number_below_high_auto_start_is_honored(self):
|
||||||
|
# Provider numbers 100-104 with an auto-set start of 5000 must land at
|
||||||
|
# their provider numbers.
|
||||||
|
# Fail signature: channels at 5000-5004 = start used as a hard floor.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_start = 5000
|
||||||
|
rel.auto_sync_channel_end = None
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 1,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
for i in range(5):
|
||||||
|
_make_stream(
|
||||||
|
account, group, name=f"PPV {i}", tvg_id=f"ppv{i}",
|
||||||
|
stream_chno=100 + i,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
numbers = sorted(
|
||||||
|
Channel.objects.filter(
|
||||||
|
auto_created=True, auto_created_by=account
|
||||||
|
).values_list("channel_number", flat=True)
|
||||||
|
)
|
||||||
|
self.assertEqual(numbers, [100.0, 101.0, 102.0, 103.0, 104.0])
|
||||||
|
|
||||||
|
def test_provider_number_honored_when_start_unset(self):
|
||||||
|
# start blank -> defaults to 1.0; provider numbers still honored.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_start = None
|
||||||
|
rel.auto_sync_channel_end = None
|
||||||
|
rel.custom_properties = {"channel_numbering_mode": "provider"}
|
||||||
|
rel.save()
|
||||||
|
_make_stream(account, group, name="PPV", tvg_id="ppv", stream_chno=100)
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
created = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertEqual(created.channel_number, 100.0)
|
||||||
|
|
||||||
|
def test_numberless_stream_uses_fallback_not_hidden_start(self):
|
||||||
|
# In provider mode without a range, a stream lacking a provider
|
||||||
|
# number falls back to channel_numbering_fallback (the visible
|
||||||
|
# "Start #"), not the hidden auto_sync_channel_start.
|
||||||
|
# Fail signature: channel at 5000 = fallback bumped to hidden start.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_start = 5000
|
||||||
|
rel.auto_sync_channel_end = None
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 300,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
_make_stream(account, group, name="NoChno", tvg_id="nc")
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
created = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertEqual(created.channel_number, 300.0)
|
||||||
|
|
||||||
|
def test_provider_number_below_range_is_honored_verbatim(self):
|
||||||
|
# A provider number below the group's Start/End is used as-is, not
|
||||||
|
# coerced into the range.
|
||||||
|
# Fail signature: channel pulled to >= 100 = range coercing a provider
|
||||||
|
# number.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_end = 200
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 100,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
_make_stream(account, group, name="Low", tvg_id="low", stream_chno=50)
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
created = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertEqual(created.channel_number, 50.0)
|
||||||
|
|
||||||
|
def test_provider_number_above_end_is_honored_verbatim(self):
|
||||||
|
# Provider numbers above the configured End are also honored as-is;
|
||||||
|
# the End caps only the fallback for numberless streams.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_end = 200
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 1,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
_make_stream(account, group, name="High", tvg_id="high", stream_chno=5000)
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
created = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertEqual(created.channel_number, 5000.0)
|
||||||
|
|
||||||
|
def test_provider_number_within_range_is_honored(self):
|
||||||
|
# An in-range provider number is used as-is.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_end = 200
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 1,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
_make_stream(account, group, name="Mid", tvg_id="mid", stream_chno=150)
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
created = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertEqual(created.channel_number, 150.0)
|
||||||
|
|
||||||
|
def test_duplicate_provider_numbers_keep_one_and_fall_back_the_other(self):
|
||||||
|
# Two streams claim the same provider number (common in messy event
|
||||||
|
# feeds). One keeps it; the colliding one falls back to a different free
|
||||||
|
# number rather than being dropped or overwriting the first.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 1,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
_make_stream(account, group, name="A", tvg_id="a", stream_chno=77250)
|
||||||
|
_make_stream(account, group, name="B", tvg_id="b", stream_chno=77250)
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
numbers = sorted(
|
||||||
|
Channel.objects.filter(
|
||||||
|
auto_created=True, auto_created_by=account
|
||||||
|
).values_list("channel_number", flat=True)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(numbers), 2)
|
||||||
|
self.assertIn(77250.0, numbers)
|
||||||
|
self.assertNotEqual(numbers[0], numbers[1])
|
||||||
|
|
||||||
|
def test_provider_number_colliding_with_manual_channel_falls_back(self):
|
||||||
|
# A provider number matching an existing channel must not overwrite it;
|
||||||
|
# the auto-created channel falls back to a different free number.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 1,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
manual = Channel.objects.create(name="Manual", channel_number=88250)
|
||||||
|
_make_stream(account, group, name="P", tvg_id="p", stream_chno=88250)
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
created = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertNotEqual(created.channel_number, 88250.0)
|
||||||
|
manual.refresh_from_db()
|
||||||
|
self.assertEqual(manual.channel_number, 88250.0)
|
||||||
|
|
||||||
|
def test_provider_fallback_exhaustion_reports_visible_start(self):
|
||||||
|
# RANGE_EXHAUSTED must cite the fallback range (channel_numbering_fallback
|
||||||
|
# to End), not the hidden auto_sync_channel_start left from another mode.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_start = 5000
|
||||||
|
rel.auto_sync_channel_end = 102
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 100,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
for i in range(4):
|
||||||
|
_make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}")
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["channels_created"], 3)
|
||||||
|
self.assertEqual(result["channels_failed"], 1)
|
||||||
|
error = result["failed_stream_details"][0]["error"]
|
||||||
|
self.assertIn("100-102", error)
|
||||||
|
self.assertNotIn("5000", error)
|
||||||
|
|
||||||
|
|
||||||
|
class CrossModeNumberingFieldTests(TestCase):
|
||||||
|
"""
|
||||||
|
Each numbering mode's UI exposes only a subset of the persisted fields,
|
||||||
|
and switching modes does not reset the others. The backend must therefore
|
||||||
|
read only the fields a mode actually owns, so a stale/hidden value left by
|
||||||
|
another mode cannot silently change numbering. These guard the two
|
||||||
|
remaining facets of that family (the provider-floor facet is covered by
|
||||||
|
ProviderNumberingHonorsProviderNumberTests).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _restamp(self, account):
|
||||||
|
Stream.objects.filter(m3u_account=account).update(
|
||||||
|
last_seen=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_next_available_ignores_configured_end(self):
|
||||||
|
# next_available exposes no Start/End in its UI, so a stale End left
|
||||||
|
# over from a prior mode must not cap it. Every stream gets the lowest
|
||||||
|
# free number from 1 regardless of the End.
|
||||||
|
# Fail signature: streams beyond the End fail = next_available honoring
|
||||||
|
# a hidden cap.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_start = 1
|
||||||
|
rel.auto_sync_channel_end = 3 # stale cap from a prior mode
|
||||||
|
rel.custom_properties = {"channel_numbering_mode": "next_available"}
|
||||||
|
rel.save()
|
||||||
|
for i in range(5):
|
||||||
|
_make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}")
|
||||||
|
|
||||||
|
result = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
self.assertEqual(result["channels_created"], 5)
|
||||||
|
self.assertEqual(result["channels_failed"], 0)
|
||||||
|
|
||||||
|
def test_provider_channels_outside_range_are_not_deleted(self):
|
||||||
|
# Range enforcement (the overflow-delete) is fixed-mode only. A provider
|
||||||
|
# channel whose number is outside [start, end] is authoritative and must
|
||||||
|
# survive sync, not be deleted and churned into a new row.
|
||||||
|
# Fail signature: channels_deleted > 0 on the second sync = overflow
|
||||||
|
# delete firing in provider mode.
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_end = 200
|
||||||
|
rel.custom_properties = {
|
||||||
|
"channel_numbering_mode": "provider",
|
||||||
|
"channel_numbering_fallback": 1,
|
||||||
|
}
|
||||||
|
rel.save()
|
||||||
|
_make_stream(account, group, name="High", tvg_id="high", stream_chno=5000)
|
||||||
|
|
||||||
|
first = _sync(account)
|
||||||
|
self.assertEqual(first["channels_created"], 1)
|
||||||
|
original = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertEqual(original.channel_number, 5000.0)
|
||||||
|
|
||||||
|
self._restamp(account)
|
||||||
|
second = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(second["channels_deleted"], 0)
|
||||||
|
survivor = Channel.objects.get(auto_created=True, auto_created_by=account)
|
||||||
|
self.assertEqual(survivor.id, original.id)
|
||||||
|
self.assertEqual(survivor.channel_number, 5000.0)
|
||||||
|
|
||||||
|
def test_next_available_channels_outside_stale_range_not_deleted(self):
|
||||||
|
# Same gate for next_available: tightening a stale End must not delete
|
||||||
|
# already-assigned channels (range enforcement is fixed-mode only).
|
||||||
|
account = _make_account()
|
||||||
|
group = _make_group(name="PPV")
|
||||||
|
rel = _attach_group_to_account(account, group)
|
||||||
|
rel.auto_sync_channel_start = 1
|
||||||
|
rel.auto_sync_channel_end = None
|
||||||
|
rel.custom_properties = {"channel_numbering_mode": "next_available"}
|
||||||
|
rel.save()
|
||||||
|
for i in range(5):
|
||||||
|
_make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}")
|
||||||
|
|
||||||
|
self.assertEqual(_sync(account)["channels_created"], 5)
|
||||||
|
|
||||||
|
rel.auto_sync_channel_end = 3 # stale cap appears
|
||||||
|
rel.save()
|
||||||
|
self._restamp(account)
|
||||||
|
second = _sync(account)
|
||||||
|
|
||||||
|
self.assertEqual(second["channels_deleted"], 0)
|
||||||
|
self.assertEqual(
|
||||||
|
Channel.objects.filter(
|
||||||
|
auto_created=True, auto_created_by=account
|
||||||
|
).count(),
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
|
||||||
131
apps/m3u/tests/test_xc_empty_fetch_guard.py
Normal file
131
apps/m3u/tests/test_xc_empty_fetch_guard.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""
|
||||||
|
Regression test for the Xtream Codes empty-fetch channel wipe.
|
||||||
|
|
||||||
|
Bug: when an XC provider returns no live streams on a routine refresh (a
|
||||||
|
transient upstream failure, a fetch exception, or no enabled category
|
||||||
|
matching), ``collect_xc_streams`` returns ``[]`` and the refresh used to fall
|
||||||
|
through to stale-marking and ``sync_auto_channels``. With nothing "seen" this
|
||||||
|
refresh, auto-sync deletes the account's entire auto-created channel lineup.
|
||||||
|
|
||||||
|
Fix: ``_refresh_single_m3u_account_impl`` aborts the XC branch when
|
||||||
|
``collect_xc_streams`` returns empty, setting the account to ERROR before any
|
||||||
|
stale-marking or auto-sync runs, mirroring the standard-path empty guards.
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.test import TransactionTestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.channels.models import (
|
||||||
|
Channel,
|
||||||
|
ChannelGroup,
|
||||||
|
ChannelGroupM3UAccount,
|
||||||
|
Stream,
|
||||||
|
)
|
||||||
|
from apps.m3u.models import M3UAccount
|
||||||
|
from apps.m3u.tasks import _refresh_single_m3u_account_impl
|
||||||
|
|
||||||
|
|
||||||
|
class XCEmptyFetchGuardTests(TransactionTestCase):
|
||||||
|
def _setup_xc_account_with_auto_channel(self):
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Test XC Provider",
|
||||||
|
server_url="http://example.com",
|
||||||
|
username="user",
|
||||||
|
password="pass",
|
||||||
|
account_type=M3UAccount.Types.XC,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
group = ChannelGroup.objects.create(name="Sports")
|
||||||
|
ChannelGroupM3UAccount.objects.create(
|
||||||
|
m3u_account=account,
|
||||||
|
channel_group=group,
|
||||||
|
enabled=True,
|
||||||
|
auto_channel_sync=True,
|
||||||
|
auto_sync_channel_start=100,
|
||||||
|
custom_properties={"xc_id": "123"},
|
||||||
|
)
|
||||||
|
# A pre-existing stream and the auto-created channel built from it on a
|
||||||
|
# prior healthy refresh -- this is exactly what the bug deletes.
|
||||||
|
stream = Stream.objects.create(
|
||||||
|
name="ESPN",
|
||||||
|
url="http://example.com/espn.m3u8",
|
||||||
|
m3u_account=account,
|
||||||
|
channel_group=group,
|
||||||
|
last_seen=timezone.now(),
|
||||||
|
is_stale=False,
|
||||||
|
)
|
||||||
|
channel = Channel.objects.create(
|
||||||
|
channel_number=100,
|
||||||
|
name="ESPN",
|
||||||
|
channel_group=group,
|
||||||
|
auto_created=True,
|
||||||
|
auto_created_by=account,
|
||||||
|
)
|
||||||
|
return account, group, stream, channel
|
||||||
|
|
||||||
|
@patch("apps.m3u.tasks.sync_auto_channels")
|
||||||
|
@patch("apps.m3u.tasks.collect_xc_streams", return_value=[])
|
||||||
|
@patch("apps.m3u.tasks.refresh_m3u_groups")
|
||||||
|
def test_empty_xc_fetch_aborts_before_sync_and_preserves_channels(
|
||||||
|
self, mock_refresh_groups, _mock_collect, mock_sync
|
||||||
|
):
|
||||||
|
account, group, stream, channel = self._setup_xc_account_with_auto_channel()
|
||||||
|
# XC refresh: empty extinf_data is normal, groups must be present.
|
||||||
|
mock_refresh_groups.return_value = ([], {"Sports": group.id})
|
||||||
|
|
||||||
|
result = _refresh_single_m3u_account_impl(account.id)
|
||||||
|
|
||||||
|
# The refresh aborts, so auto channel sync never runs.
|
||||||
|
mock_sync.assert_not_called()
|
||||||
|
# The auto-created channel survives the empty fetch.
|
||||||
|
self.assertTrue(Channel.objects.filter(pk=channel.pk).exists())
|
||||||
|
# The stream is not marked stale (stale-marking is skipped on abort).
|
||||||
|
stream.refresh_from_db()
|
||||||
|
self.assertFalse(stream.is_stale)
|
||||||
|
# The account is surfaced as errored, not silently "successful".
|
||||||
|
account.refresh_from_db()
|
||||||
|
self.assertEqual(account.status, M3UAccount.Status.ERROR)
|
||||||
|
self.assertIn("no streams returned from provider", result)
|
||||||
|
|
||||||
|
@patch("apps.m3u.tasks.log_system_event")
|
||||||
|
@patch("apps.m3u.tasks.send_m3u_update")
|
||||||
|
@patch("apps.m3u.tasks.cleanup_stale_group_relationships")
|
||||||
|
@patch("apps.m3u.tasks.cleanup_streams", return_value=0)
|
||||||
|
@patch("apps.m3u.tasks.process_m3u_batch_direct", return_value="1 created, 0 updated")
|
||||||
|
@patch("apps.m3u.tasks.sync_auto_channels")
|
||||||
|
@patch("apps.m3u.tasks.refresh_m3u_groups")
|
||||||
|
def test_non_empty_xc_fetch_still_runs_sync(
|
||||||
|
self,
|
||||||
|
mock_refresh_groups,
|
||||||
|
mock_sync,
|
||||||
|
_mock_process,
|
||||||
|
_mock_cleanup_streams,
|
||||||
|
_mock_cleanup_groups,
|
||||||
|
_mock_ws,
|
||||||
|
_mock_log,
|
||||||
|
):
|
||||||
|
# The guard must not fire on a healthy refresh: a non-empty fetch
|
||||||
|
# proceeds to auto channel sync as before.
|
||||||
|
account, group, _stream, _channel = self._setup_xc_account_with_auto_channel()
|
||||||
|
mock_refresh_groups.return_value = ([], {"Sports": group.id})
|
||||||
|
mock_sync.return_value = {
|
||||||
|
"status": "ok",
|
||||||
|
"channels_created": 1,
|
||||||
|
"channels_updated": 0,
|
||||||
|
"channels_deleted": 0,
|
||||||
|
"channels_failed": 0,
|
||||||
|
"failed_stream_details": [],
|
||||||
|
}
|
||||||
|
xc_stream = {
|
||||||
|
"name": "ESPN",
|
||||||
|
"url": "http://example.com/espn.m3u8",
|
||||||
|
"attributes": {"group-title": "Sports", "stream_id": "1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("apps.m3u.tasks.collect_xc_streams", return_value=[xc_stream]):
|
||||||
|
_refresh_single_m3u_account_impl(account.id)
|
||||||
|
|
||||||
|
mock_sync.assert_called_once()
|
||||||
|
account.refresh_from_db()
|
||||||
|
self.assertEqual(account.status, M3UAccount.Status.SUCCESS)
|
||||||
139
apps/m3u/tests/test_xc_live_url.py
Normal file
139
apps/m3u/tests/test_xc_live_url.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Tests for XC stream URL normalization and on-demand URL building."""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.channels.models import Stream
|
||||||
|
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||||
|
from apps.m3u.tasks import get_transformed_credentials
|
||||||
|
from apps.proxy.live_proxy.url_utils import _resolve_live_stream_url
|
||||||
|
from apps.vod.models import Episode, M3UEpisodeRelation, M3UMovieRelation, Movie, Series
|
||||||
|
from core.xtream_codes import normalize_server_url
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizeServerUrlTests(TestCase):
|
||||||
|
def test_preserves_sub_path(self):
|
||||||
|
url = "https://myserver.fun/server1"
|
||||||
|
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||||
|
|
||||||
|
def test_strips_player_api_php_and_query_params(self):
|
||||||
|
url = "https://myserver.fun/server1/player_api.php?username=foo&password=bar"
|
||||||
|
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||||
|
|
||||||
|
def test_strips_trailing_slash(self):
|
||||||
|
url = "https://myserver.fun/server1/"
|
||||||
|
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||||
|
|
||||||
|
def test_nested_sub_path_with_php_endpoint(self):
|
||||||
|
url = "http://server/Pluto/gb/player_api.php"
|
||||||
|
self.assertEqual(normalize_server_url(url), "http://server/Pluto/gb")
|
||||||
|
|
||||||
|
|
||||||
|
class GetTransformedCredentialsTests(TestCase):
|
||||||
|
def test_returns_normalized_server_url(self):
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Sub-path XC",
|
||||||
|
account_type="XC",
|
||||||
|
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||||
|
username="alice",
|
||||||
|
password="secret",
|
||||||
|
)
|
||||||
|
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
|
||||||
|
server_url, username, password = get_transformed_credentials(account, profile)
|
||||||
|
|
||||||
|
self.assertEqual(server_url, "https://myserver.fun/server1")
|
||||||
|
self.assertEqual(username, "alice")
|
||||||
|
self.assertEqual(password, "secret")
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveLiveStreamUrlTests(TestCase):
|
||||||
|
def test_builds_url_from_normalized_base_not_raw_account_url(self):
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="Live sub-path",
|
||||||
|
account_type="XC",
|
||||||
|
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||||
|
username="alice",
|
||||||
|
password="secret",
|
||||||
|
)
|
||||||
|
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
stream = Stream.objects.create(
|
||||||
|
name="Test Channel",
|
||||||
|
m3u_account=account,
|
||||||
|
stream_id="12345",
|
||||||
|
url="https://myserver.fun/server1/live/olduser/oldpass/12345.ts",
|
||||||
|
)
|
||||||
|
|
||||||
|
url = _resolve_live_stream_url(stream, account, profile)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
url,
|
||||||
|
"https://myserver.fun/server1/live/alice/secret/12345.ts",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_std_account_uses_stored_stream_url(self):
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="STD account",
|
||||||
|
account_type="STD",
|
||||||
|
server_url="https://example.com/list.m3u",
|
||||||
|
username="alice",
|
||||||
|
password="secret",
|
||||||
|
)
|
||||||
|
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||||
|
stream = Stream.objects.create(
|
||||||
|
name="STD Stream",
|
||||||
|
m3u_account=account,
|
||||||
|
url="https://provider.example/stream/abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
url = _resolve_live_stream_url(stream, account, profile)
|
||||||
|
|
||||||
|
self.assertEqual(url, "https://provider.example/stream/abc123")
|
||||||
|
|
||||||
|
|
||||||
|
class VodStreamUrlTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.account = M3UAccount.objects.create(
|
||||||
|
name="VOD sub-path",
|
||||||
|
account_type="XC",
|
||||||
|
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||||
|
username="alice",
|
||||||
|
password="secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_movie_relation_builds_normalized_url(self):
|
||||||
|
movie = Movie.objects.create(name="Test Movie")
|
||||||
|
relation = M3UMovieRelation.objects.create(
|
||||||
|
m3u_account=self.account,
|
||||||
|
movie=movie,
|
||||||
|
stream_id="999",
|
||||||
|
container_extension="mkv",
|
||||||
|
)
|
||||||
|
|
||||||
|
url = relation.get_stream_url()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
url,
|
||||||
|
"https://myserver.fun/server1/movie/alice/secret/999.mkv",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_episode_relation_builds_normalized_url(self):
|
||||||
|
series = Series.objects.create(name="Test Series")
|
||||||
|
episode = Episode.objects.create(
|
||||||
|
series=series,
|
||||||
|
name="Pilot",
|
||||||
|
season_number=1,
|
||||||
|
episode_number=1,
|
||||||
|
)
|
||||||
|
relation = M3UEpisodeRelation.objects.create(
|
||||||
|
m3u_account=self.account,
|
||||||
|
episode=episode,
|
||||||
|
stream_id="888",
|
||||||
|
container_extension="mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
url = relation.get_stream_url()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
url,
|
||||||
|
"https://myserver.fun/server1/series/alice/secret/888.mp4",
|
||||||
|
)
|
||||||
66
apps/m3u/tests/test_xz_playlist.py
Normal file
66
apps/m3u/tests/test_xz_playlist.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Tests for native xz (.xz) support for uploaded M3U playlists.
|
||||||
|
|
||||||
|
Mirrors the existing .gz handling: an uploaded .xz playlist is treated as a
|
||||||
|
streamable text source (opened lazily via _open_m3u_text_source), never
|
||||||
|
loaded fully into memory like the .zip path.
|
||||||
|
"""
|
||||||
|
import lzma
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
|
||||||
|
from apps.m3u.models import M3UAccount
|
||||||
|
from apps.m3u.tasks import _open_m3u_text_source, fetch_m3u_lines
|
||||||
|
|
||||||
|
SAMPLE_M3U = (
|
||||||
|
"#EXTM3U\n"
|
||||||
|
'#EXTINF:-1 tvg-id="channel.one",Channel One\n'
|
||||||
|
"http://example.com/stream1\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenM3uTextSourceXzTests(SimpleTestCase):
|
||||||
|
def test_opens_xz_playlist_for_line_by_line_reading(self):
|
||||||
|
xz_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xz", delete=False) as xz_file:
|
||||||
|
xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_M3U.encode("utf-8")))
|
||||||
|
|
||||||
|
with _open_m3u_text_source(xz_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
self.assertEqual(content, SAMPLE_M3U)
|
||||||
|
finally:
|
||||||
|
if xz_path and os.path.exists(xz_path):
|
||||||
|
os.unlink(xz_path)
|
||||||
|
|
||||||
|
|
||||||
|
class FetchM3uLinesXzUploadTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.xz_path = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self.xz_path and os.path.exists(self.xz_path):
|
||||||
|
os.unlink(self.xz_path)
|
||||||
|
|
||||||
|
def test_fetch_m3u_lines_returns_path_for_xz_upload(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xz", delete=False) as xz_file:
|
||||||
|
self.xz_path = xz_file.name
|
||||||
|
xz_file.write(lzma.compress(SAMPLE_M3U.encode("utf-8")))
|
||||||
|
|
||||||
|
account = M3UAccount.objects.create(
|
||||||
|
name="XZ upload account",
|
||||||
|
file_path=self.xz_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
source, success = fetch_m3u_lines(account)
|
||||||
|
|
||||||
|
self.assertTrue(success)
|
||||||
|
# Like the .gz path, .xz playlists are streamed rather than loaded
|
||||||
|
# into memory, so fetch_m3u_lines hands back the path itself.
|
||||||
|
self.assertEqual(source, self.xz_path)
|
||||||
|
|
||||||
|
with _open_m3u_text_source(source) as f:
|
||||||
|
self.assertEqual(f.read(), SAMPLE_M3U)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# apps/m3u/utils.py
|
# apps/m3u/utils.py
|
||||||
|
import regex
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
@ -9,6 +10,18 @@ active_streams_map = {}
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_js_numbered_backreferences(replacement):
|
||||||
|
"""Translate JS-style ``$1``/``$2`` backreferences to Python ``\\1``/``\\2``.
|
||||||
|
|
||||||
|
Auto-sync replace patterns are authored in JS regex syntax, but Python's
|
||||||
|
regex engines honor backslash backreferences, not ``$1``. The live rename
|
||||||
|
and the UI preview must convert identically, so both call this single
|
||||||
|
helper and cannot drift apart (otherwise the preview promises an output
|
||||||
|
the sync would never produce).
|
||||||
|
"""
|
||||||
|
return regex.sub(r"\$(\d+)", r"\\\1", replacement)
|
||||||
|
|
||||||
|
|
||||||
def normalize_stream_url(url):
|
def normalize_stream_url(url):
|
||||||
"""
|
"""
|
||||||
Normalize stream URLs for compatibility with FFmpeg.
|
Normalize stream URLs for compatibility with FFmpeg.
|
||||||
|
|
|
||||||
1739
apps/output/epg.py
Normal file
1739
apps/output/epg.py
Normal file
File diff suppressed because it is too large
Load diff
242
apps/output/streaming_chunk_cache.py
Normal file
242
apps/output/streaming_chunk_cache.py
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"""Single-flight Redis chunk cache for large streaming HTTP responses."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.http import StreamingHttpResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATUS_BUILDING = "building"
|
||||||
|
STATUS_READY = "ready"
|
||||||
|
STATUS_ERROR = "error"
|
||||||
|
|
||||||
|
DEFAULT_CACHE_TTL = 300
|
||||||
|
DEFAULT_LOCK_TTL = 120
|
||||||
|
DEFAULT_POLL_INTERVAL = 0.05
|
||||||
|
DEFAULT_MAX_FOLLOWER_WAIT = 600
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks_key(base_key):
|
||||||
|
return f"{base_key}:chunks"
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_key(base_key):
|
||||||
|
return f"{base_key}:ready"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_key(base_key):
|
||||||
|
return f"{base_key}:status"
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_key(base_key):
|
||||||
|
return f"{base_key}:lock"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_chunk(chunk):
|
||||||
|
if chunk is None:
|
||||||
|
return None
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
return chunk.decode("utf-8")
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_chunk(chunk):
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
return chunk
|
||||||
|
return chunk.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_wait(interval):
|
||||||
|
try:
|
||||||
|
from core.utils import _is_gevent_monkey_patched
|
||||||
|
|
||||||
|
if _is_gevent_monkey_patched():
|
||||||
|
import gevent
|
||||||
|
|
||||||
|
gevent.sleep(interval)
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis():
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
|
return get_redis_connection("default")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status(redis, base_key):
|
||||||
|
raw = redis.get(_status_key(base_key))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return _decode_chunk(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_build_keys(redis, base_key):
|
||||||
|
redis.delete(
|
||||||
|
_chunks_key(base_key),
|
||||||
|
_status_key(base_key),
|
||||||
|
_ready_key(base_key),
|
||||||
|
_lock_key(base_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_acquire_lock(redis, base_key, lock_ttl):
|
||||||
|
return bool(redis.set(_lock_key(base_key), "1", nx=True, ex=lock_ttl))
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_build_ttl(redis, base_key, lock_ttl):
|
||||||
|
redis.expire(_lock_key(base_key), lock_ttl)
|
||||||
|
redis.expire(_status_key(base_key), lock_ttl)
|
||||||
|
redis.expire(_chunks_key(base_key), lock_ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_ready(redis, base_key):
|
||||||
|
offset = 0
|
||||||
|
chunks_key = _chunks_key(base_key)
|
||||||
|
while True:
|
||||||
|
chunk = redis.lindex(chunks_key, offset)
|
||||||
|
if chunk is None:
|
||||||
|
break
|
||||||
|
yield _decode_chunk(chunk)
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_build(redis, base_key, source, cache_ttl, lock_ttl):
|
||||||
|
"""Leader: stream to client and append each chunk to Redis."""
|
||||||
|
chunks_key = _chunks_key(base_key)
|
||||||
|
status_key = _status_key(base_key)
|
||||||
|
try:
|
||||||
|
from django.core.cache import cache as django_cache
|
||||||
|
|
||||||
|
django_cache.delete(base_key) # clear any non-chunked entry under this key
|
||||||
|
redis.delete(chunks_key, _ready_key(base_key))
|
||||||
|
redis.set(status_key, STATUS_BUILDING, ex=lock_ttl)
|
||||||
|
refresh_interval = max(1, lock_ttl // 4)
|
||||||
|
last_refresh = 0.0
|
||||||
|
from core.utils import _cooperative_yield
|
||||||
|
|
||||||
|
for chunk in source():
|
||||||
|
redis.rpush(chunks_key, _encode_chunk(chunk))
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_refresh >= refresh_interval:
|
||||||
|
_refresh_build_ttl(redis, base_key, lock_ttl)
|
||||||
|
last_refresh = now
|
||||||
|
_cooperative_yield()
|
||||||
|
yield chunk
|
||||||
|
redis.set(status_key, STATUS_READY)
|
||||||
|
redis.set(_ready_key(base_key), "1")
|
||||||
|
redis.expire(chunks_key, cache_ttl)
|
||||||
|
redis.expire(status_key, cache_ttl)
|
||||||
|
redis.expire(_ready_key(base_key), cache_ttl)
|
||||||
|
logger.debug("Cached response in %s chunks", redis.llen(chunks_key))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Chunk cache build failed for %s", base_key)
|
||||||
|
redis.delete(chunks_key)
|
||||||
|
redis.set(status_key, STATUS_ERROR, ex=60)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
redis.delete(_lock_key(base_key))
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_follow(redis, base_key, source, cache_ttl, lock_ttl, poll_interval, max_follower_wait):
|
||||||
|
"""Follower: read chunks as the leader writes them."""
|
||||||
|
offset = 0
|
||||||
|
deadline = time.monotonic() + max_follower_wait
|
||||||
|
idle_polls = 0
|
||||||
|
chunks_key = _chunks_key(base_key)
|
||||||
|
lock_key = _lock_key(base_key)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = redis.lindex(chunks_key, offset)
|
||||||
|
if chunk is not None:
|
||||||
|
idle_polls = 0
|
||||||
|
yield _decode_chunk(chunk)
|
||||||
|
offset += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = _get_status(redis, base_key)
|
||||||
|
if status == STATUS_READY:
|
||||||
|
break
|
||||||
|
|
||||||
|
if status == STATUS_ERROR:
|
||||||
|
_clear_build_keys(redis, base_key)
|
||||||
|
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
|
||||||
|
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||||
|
return
|
||||||
|
raise RuntimeError("Chunk cache build failed")
|
||||||
|
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
|
||||||
|
logger.warning("Chunk cache follower timed out; rebuilding %s", base_key)
|
||||||
|
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||||
|
return
|
||||||
|
logger.warning("Chunk cache follower timed out after partial read for %s", base_key)
|
||||||
|
break
|
||||||
|
|
||||||
|
lock_active = bool(redis.exists(lock_key))
|
||||||
|
if status != STATUS_BUILDING and not lock_active:
|
||||||
|
idle_polls += 1
|
||||||
|
if offset == 0 and idle_polls >= max(1, int(1.0 / poll_interval)):
|
||||||
|
if _try_acquire_lock(redis, base_key, lock_ttl):
|
||||||
|
logger.warning("Chunk cache leader lost; rebuilding %s", base_key)
|
||||||
|
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
idle_polls = 0
|
||||||
|
|
||||||
|
_poll_wait(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def stream_cached_response(
|
||||||
|
cache_key,
|
||||||
|
source,
|
||||||
|
*,
|
||||||
|
content_type="application/xml",
|
||||||
|
filename=None,
|
||||||
|
cache_ttl=DEFAULT_CACHE_TTL,
|
||||||
|
lock_ttl=DEFAULT_LOCK_TTL,
|
||||||
|
poll_interval=DEFAULT_POLL_INTERVAL,
|
||||||
|
max_follower_wait=DEFAULT_MAX_FOLLOWER_WAIT,
|
||||||
|
redis=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Stream a large response with single-flight Redis chunk caching.
|
||||||
|
|
||||||
|
``source`` must be a callable returning a chunk iterator. Only the leader
|
||||||
|
invokes it; concurrent followers replay chunks already written to Redis, so
|
||||||
|
the expensive ``source`` runs at most once per ``cache_key``.
|
||||||
|
"""
|
||||||
|
if redis is None:
|
||||||
|
redis = _get_redis()
|
||||||
|
|
||||||
|
if redis.get(_ready_key(cache_key)):
|
||||||
|
logger.debug("Serving response from chunk cache")
|
||||||
|
stream = _stream_ready(redis, cache_key)
|
||||||
|
else:
|
||||||
|
status = _get_status(redis, cache_key)
|
||||||
|
if status == STATUS_ERROR:
|
||||||
|
_clear_build_keys(redis, cache_key)
|
||||||
|
|
||||||
|
if _try_acquire_lock(redis, cache_key, lock_ttl):
|
||||||
|
logger.debug("Building response (cache leader)")
|
||||||
|
stream = _stream_build(redis, cache_key, source, cache_ttl, lock_ttl)
|
||||||
|
else:
|
||||||
|
logger.debug("Following in-flight cache build")
|
||||||
|
stream = _stream_follow(
|
||||||
|
redis,
|
||||||
|
cache_key,
|
||||||
|
source,
|
||||||
|
cache_ttl,
|
||||||
|
lock_ttl,
|
||||||
|
poll_interval,
|
||||||
|
max_follower_wait,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(stream, content_type=content_type)
|
||||||
|
if filename:
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
response["Cache-Control"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
from django.test import TestCase, Client
|
|
||||||
from django.urls import reverse
|
|
||||||
from apps.channels.models import Channel, ChannelGroup
|
|
||||||
from apps.epg.models import EPGData, EPGSource
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
class OutputM3UTest(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = Client()
|
|
||||||
|
|
||||||
def test_generate_m3u_response(self):
|
|
||||||
"""
|
|
||||||
Test that the M3U endpoint returns a valid M3U file.
|
|
||||||
"""
|
|
||||||
url = reverse('output:generate_m3u')
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
content = response.content.decode()
|
|
||||||
self.assertIn("#EXTM3U", content)
|
|
||||||
|
|
||||||
def test_generate_m3u_response_post_empty_body(self):
|
|
||||||
"""
|
|
||||||
Test that a POST request with an empty body returns 200 OK.
|
|
||||||
"""
|
|
||||||
url = reverse('output:generate_m3u')
|
|
||||||
|
|
||||||
response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded')
|
|
||||||
content = response.content.decode()
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK")
|
|
||||||
self.assertIn("#EXTM3U", content)
|
|
||||||
|
|
||||||
def test_generate_m3u_response_post_with_body(self):
|
|
||||||
"""
|
|
||||||
Test that a POST request with a non-empty body returns 403 Forbidden.
|
|
||||||
"""
|
|
||||||
url = reverse('output:generate_m3u')
|
|
||||||
|
|
||||||
response = self.client.post(url, data={'evilstring': 'muhahaha'})
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
|
|
||||||
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())
|
|
||||||
|
|
||||||
|
|
||||||
class OutputEPGXMLEscapingTest(TestCase):
|
|
||||||
"""Test XML escaping of channel_id attributes in EPG generation"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.client = Client()
|
|
||||||
self.group = ChannelGroup.objects.create(name="Test Group")
|
|
||||||
|
|
||||||
def test_channel_id_with_ampersand(self):
|
|
||||||
"""Test channel ID with ampersand is properly escaped"""
|
|
||||||
channel = Channel.objects.create(
|
|
||||||
channel_number=1.0,
|
|
||||||
name="Test Channel",
|
|
||||||
tvg_id="News & Sports",
|
|
||||||
channel_group=self.group
|
|
||||||
)
|
|
||||||
|
|
||||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
content = response.content.decode()
|
|
||||||
|
|
||||||
# Should contain escaped ampersand
|
|
||||||
self.assertIn('id="News & Sports"', content)
|
|
||||||
self.assertNotIn('id="News & Sports"', content)
|
|
||||||
|
|
||||||
# Verify XML is parseable
|
|
||||||
try:
|
|
||||||
ET.fromstring(content)
|
|
||||||
except ET.ParseError as e:
|
|
||||||
self.fail(f"Generated EPG is not valid XML: {e}")
|
|
||||||
|
|
||||||
def test_channel_id_with_angle_brackets(self):
|
|
||||||
"""Test channel ID with < and > characters"""
|
|
||||||
channel = Channel.objects.create(
|
|
||||||
channel_number=2.0,
|
|
||||||
name="HD Channel",
|
|
||||||
tvg_id="Channel <HD>",
|
|
||||||
channel_group=self.group
|
|
||||||
)
|
|
||||||
|
|
||||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
content = response.content.decode()
|
|
||||||
self.assertIn('id="Channel <HD>"', content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
ET.fromstring(content)
|
|
||||||
except ET.ParseError as e:
|
|
||||||
self.fail(f"Generated EPG with < > is not valid XML: {e}")
|
|
||||||
|
|
||||||
def test_channel_id_with_all_special_chars(self):
|
|
||||||
"""Test channel ID with all XML special characters"""
|
|
||||||
channel = Channel.objects.create(
|
|
||||||
channel_number=3.0,
|
|
||||||
name="Complex Channel",
|
|
||||||
tvg_id='Test & "Special" <Chars>',
|
|
||||||
channel_group=self.group
|
|
||||||
)
|
|
||||||
|
|
||||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
content = response.content.decode()
|
|
||||||
self.assertIn('id="Test & "Special" <Chars>"', content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
tree = ET.fromstring(content)
|
|
||||||
# Verify we can find the channel with correct ID in parsed tree
|
|
||||||
channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" <Chars>"]')
|
|
||||||
self.assertIsNotNone(channel_elem)
|
|
||||||
except ET.ParseError as e:
|
|
||||||
self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
|
|
||||||
|
|
||||||
def test_program_channel_attribute_escaping(self):
|
|
||||||
"""Test that programme elements also have escaped channel attributes"""
|
|
||||||
epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy")
|
|
||||||
epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source)
|
|
||||||
channel = Channel.objects.create(
|
|
||||||
channel_number=4.0,
|
|
||||||
name="Program Test",
|
|
||||||
tvg_id="News & Sports",
|
|
||||||
epg_data=epg_data,
|
|
||||||
channel_group=self.group
|
|
||||||
)
|
|
||||||
|
|
||||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
content = response.content.decode()
|
|
||||||
|
|
||||||
# Check programme elements have escaped channel attributes
|
|
||||||
self.assertIn('channel="News & Sports"', content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
tree = ET.fromstring(content)
|
|
||||||
programmes = tree.findall('.//programme[@channel="News & Sports"]')
|
|
||||||
self.assertGreater(len(programmes), 0)
|
|
||||||
except ET.ParseError as e:
|
|
||||||
self.fail(f"Generated EPG with programme elements is not valid XML: {e}")
|
|
||||||
0
apps/output/tests/__init__.py
Normal file
0
apps/output/tests/__init__.py
Normal file
187
apps/output/tests/test_streaming_chunk_cache.py
Normal file
187
apps/output/tests/test_streaming_chunk_cache.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from apps.output.streaming_chunk_cache import (
|
||||||
|
STATUS_BUILDING,
|
||||||
|
STATUS_READY,
|
||||||
|
_chunks_key,
|
||||||
|
_lock_key,
|
||||||
|
_ready_key,
|
||||||
|
_status_key,
|
||||||
|
stream_cached_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
"""Minimal Redis stand-in for chunk-cache unit tests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._strings = {}
|
||||||
|
self._lists = {}
|
||||||
|
self._expires_at = {}
|
||||||
|
|
||||||
|
def _purge_expired(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
expired = [key for key, deadline in self._expires_at.items() if deadline <= now]
|
||||||
|
for key in expired:
|
||||||
|
self._strings.pop(key, None)
|
||||||
|
self._lists.pop(key, None)
|
||||||
|
self._expires_at.pop(key, None)
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
self._purge_expired()
|
||||||
|
return self._strings.get(key)
|
||||||
|
|
||||||
|
def set(self, key, value, nx=False, ex=None):
|
||||||
|
self._purge_expired()
|
||||||
|
if nx and key in self._strings:
|
||||||
|
return None
|
||||||
|
self._strings[key] = value
|
||||||
|
if ex is not None:
|
||||||
|
self._expires_at[key] = time.monotonic() + ex
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, *keys):
|
||||||
|
for key in keys:
|
||||||
|
self._strings.pop(key, None)
|
||||||
|
self._lists.pop(key, None)
|
||||||
|
self._expires_at.pop(key, None)
|
||||||
|
|
||||||
|
def exists(self, key):
|
||||||
|
self._purge_expired()
|
||||||
|
return key in self._strings or key in self._lists
|
||||||
|
|
||||||
|
def expire(self, key, ttl):
|
||||||
|
if key in self._strings or key in self._lists:
|
||||||
|
self._expires_at[key] = time.monotonic() + ttl
|
||||||
|
return True
|
||||||
|
|
||||||
|
def rpush(self, key, value):
|
||||||
|
self._lists.setdefault(key, []).append(value)
|
||||||
|
|
||||||
|
def lindex(self, key, offset):
|
||||||
|
items = self._lists.get(key, [])
|
||||||
|
if offset < len(items):
|
||||||
|
return items[offset]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def llen(self, key):
|
||||||
|
return len(self._lists.get(key, []))
|
||||||
|
|
||||||
|
|
||||||
|
def _consume(response):
|
||||||
|
return b"".join(response.streaming_content).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingChunkCacheTests(TestCase):
|
||||||
|
def test_leader_caches_chunks_and_sets_ready(self):
|
||||||
|
redis = FakeRedis()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def source():
|
||||||
|
calls.append(1)
|
||||||
|
yield "<tv>"
|
||||||
|
yield "</tv>"
|
||||||
|
|
||||||
|
body = _consume(stream_cached_response("cache:test", source, redis=redis))
|
||||||
|
|
||||||
|
self.assertEqual(body, "<tv></tv>")
|
||||||
|
self.assertEqual(calls, [1])
|
||||||
|
self.assertEqual(redis.get(_ready_key("cache:test")), "1")
|
||||||
|
self.assertEqual(redis.get(_status_key("cache:test")), STATUS_READY)
|
||||||
|
self.assertEqual(redis.llen(_chunks_key("cache:test")), 2)
|
||||||
|
self.assertFalse(redis.exists(_lock_key("cache:test")))
|
||||||
|
|
||||||
|
def test_cache_hit_skips_source(self):
|
||||||
|
redis = FakeRedis()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def source():
|
||||||
|
calls.append(1)
|
||||||
|
yield "<tv>"
|
||||||
|
yield "</tv>"
|
||||||
|
|
||||||
|
_consume(stream_cached_response("cache:test", source, redis=redis))
|
||||||
|
calls.clear()
|
||||||
|
body = _consume(stream_cached_response("cache:test", source, redis=redis))
|
||||||
|
|
||||||
|
self.assertEqual(body, "<tv></tv>")
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
def test_follower_reads_leader_chunks_without_rebuilding(self):
|
||||||
|
redis = FakeRedis()
|
||||||
|
base = "cache:follow"
|
||||||
|
leader_started = threading.Event()
|
||||||
|
rebuild_calls = []
|
||||||
|
|
||||||
|
def slow_source():
|
||||||
|
rebuild_calls.append(1)
|
||||||
|
leader_started.set()
|
||||||
|
yield "a"
|
||||||
|
time.sleep(0.05)
|
||||||
|
yield "b"
|
||||||
|
|
||||||
|
def forbidden_source():
|
||||||
|
rebuild_calls.append(2)
|
||||||
|
yield "SHOULD_NOT_RUN"
|
||||||
|
|
||||||
|
def leader():
|
||||||
|
_consume(
|
||||||
|
stream_cached_response(
|
||||||
|
base,
|
||||||
|
slow_source,
|
||||||
|
redis=redis,
|
||||||
|
poll_interval=0.01,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
leader_thread = threading.Thread(target=leader)
|
||||||
|
leader_thread.start()
|
||||||
|
leader_started.wait(timeout=5)
|
||||||
|
follower_body = _consume(
|
||||||
|
stream_cached_response(
|
||||||
|
base,
|
||||||
|
forbidden_source,
|
||||||
|
redis=redis,
|
||||||
|
poll_interval=0.01,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
leader_thread.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertEqual(follower_body, "ab")
|
||||||
|
self.assertEqual(rebuild_calls, [1])
|
||||||
|
|
||||||
|
def test_only_one_leader_when_two_clients_start_together(self):
|
||||||
|
redis = FakeRedis()
|
||||||
|
build_calls = []
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
def source():
|
||||||
|
build_calls.append(threading.current_thread().name)
|
||||||
|
yield "x"
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
barrier.wait()
|
||||||
|
results[threading.current_thread().name] = _consume(
|
||||||
|
stream_cached_response(
|
||||||
|
"cache:race",
|
||||||
|
source,
|
||||||
|
redis=redis,
|
||||||
|
poll_interval=0.01,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=worker, name="t1"),
|
||||||
|
threading.Thread(target=worker, name="t2"),
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
self.assertEqual(results["t1"], "x")
|
||||||
|
self.assertEqual(results["t2"], "x")
|
||||||
|
self.assertEqual(len(build_calls), 1)
|
||||||
1165
apps/output/tests/test_views.py
Normal file
1165
apps/output/tests/test_views.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,5 @@
|
||||||
from django.urls import path, re_path, include
|
from django.urls import path, re_path, include
|
||||||
from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream
|
from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream
|
||||||
from core.views import stream_view
|
|
||||||
|
|
||||||
app_name = "output"
|
app_name = "output"
|
||||||
|
|
||||||
|
|
@ -9,6 +8,4 @@ urlpatterns = [
|
||||||
re_path(r"^m3u(?:/(?P<profile_name>[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"),
|
re_path(r"^m3u(?:/(?P<profile_name>[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"),
|
||||||
# Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/`
|
# Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/`
|
||||||
re_path(r"^epg(?:/(?P<profile_name>[^/]+))?/?$", epg_endpoint, name="epg_endpoint"),
|
re_path(r"^epg(?:/(?P<profile_name>[^/]+))?/?$", epg_endpoint, name="epg_endpoint"),
|
||||||
# Allow both `/stream/<int:stream_id>` and `/stream/<int:stream_id>/`
|
|
||||||
re_path(r"^stream/(?P<channel_uuid>[0-9a-fA-F\-]+)/?$", stream_view, name="stream"),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
1964
apps/output/views.py
1964
apps/output/views.py
File diff suppressed because it is too large
Load diff
|
|
@ -335,16 +335,28 @@ def _save_fetched_manifest_to_repo(repo, data, verified):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_manifest_base_urls(manifest: dict) -> tuple[str, str]:
|
||||||
|
"""Return (download_base_url, metadata_base_url) from a manifest dict.
|
||||||
|
|
||||||
|
Both fields fall back to root_url when not set. All base URL fields are
|
||||||
|
optional; callers must guard against empty strings before building URLs.
|
||||||
|
"""
|
||||||
|
root_url = manifest.get("root_url", "").rstrip("/")
|
||||||
|
download_base_url = manifest.get("download_base_url", "").rstrip("/") or root_url
|
||||||
|
metadata_base_url = manifest.get("metadata_base_url", "").rstrip("/") or root_url
|
||||||
|
return download_base_url, metadata_base_url
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_plugin_detail_cache(repo_id, manifest_data):
|
def _invalidate_plugin_detail_cache(repo_id, manifest_data):
|
||||||
manifest = manifest_data.get("manifest", manifest_data)
|
manifest = manifest_data.get("manifest", manifest_data)
|
||||||
root_url = manifest.get("root_url", "").rstrip("/")
|
_, metadata_base_url = _resolve_manifest_base_urls(manifest)
|
||||||
keys = []
|
keys = []
|
||||||
for p in manifest.get("plugins", []):
|
for p in manifest.get("plugins", []):
|
||||||
url = p.get("manifest_url", "")
|
url = p.get("manifest_url", "")
|
||||||
if not url:
|
if not url:
|
||||||
continue
|
continue
|
||||||
if root_url and not url.startswith(("http://", "https://")):
|
if metadata_base_url and not url.startswith(("http://", "https://")):
|
||||||
url = f"{root_url}/{url}"
|
url = f"{metadata_base_url}/{url}"
|
||||||
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
|
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
|
||||||
if keys:
|
if keys:
|
||||||
cache.delete_many(keys)
|
cache.delete_many(keys)
|
||||||
|
|
@ -1045,18 +1057,28 @@ class AvailablePluginsAPIView(PluginAuthMixin, APIView):
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
manifest_data = repo.cached_manifest or {}
|
manifest_data = repo.cached_manifest or {}
|
||||||
manifest = manifest_data.get("manifest", manifest_data)
|
manifest = manifest_data.get("manifest", manifest_data)
|
||||||
root_url = manifest.get("root_url", "").rstrip("/")
|
download_base_url, metadata_base_url = _resolve_manifest_base_urls(manifest)
|
||||||
registry_url = manifest.get("registry_url", "").rstrip("/")
|
registry_url = manifest.get("registry_url", "").rstrip("/")
|
||||||
repo_plugins = manifest.get("plugins", [])
|
repo_plugins = manifest.get("plugins", [])
|
||||||
for p in repo_plugins:
|
for p in repo_plugins:
|
||||||
slug = p.get("slug", "")
|
slug = p.get("slug", "")
|
||||||
plugin_data = {**p}
|
plugin_data = {**p}
|
||||||
# Resolve relative URLs against root_url; absolute URLs pass through
|
# Resolve relative URLs; metadata and download assets use separate bases
|
||||||
if root_url:
|
if metadata_base_url:
|
||||||
for url_field in ("manifest_url", "latest_url", "icon_url"):
|
for url_field in ("manifest_url", "icon_url"):
|
||||||
val = plugin_data.get(url_field, "")
|
val = plugin_data.get(url_field, "")
|
||||||
if val and not val.startswith(("http://", "https://")):
|
if val and not val.startswith(("http://", "https://")):
|
||||||
plugin_data[url_field] = f"{root_url}/{val}"
|
plugin_data[url_field] = f"{metadata_base_url}/{val}"
|
||||||
|
if download_base_url:
|
||||||
|
val = plugin_data.get("latest_url", "")
|
||||||
|
if val and not val.startswith(("http://", "https://")):
|
||||||
|
plugin_data["latest_url"] = f"{download_base_url}/{val}"
|
||||||
|
# Fallback icon_url: if metadata_base_url is explicitly set and manifest_url
|
||||||
|
# is known, assume logo.png lives in the same directory as the per-plugin
|
||||||
|
# manifest. Guard against root_url-only manifests to preserve the GitHub fallback.
|
||||||
|
if not plugin_data.get("icon_url") and manifest.get("metadata_base_url") and plugin_data.get("manifest_url"):
|
||||||
|
manifest_dir = plugin_data["manifest_url"].rsplit("/", 1)[0]
|
||||||
|
plugin_data["icon_url"] = f"{manifest_dir}/logo.png"
|
||||||
# Fallback icon_url from main branch when not provided
|
# Fallback icon_url from main branch when not provided
|
||||||
if not plugin_data.get("icon_url") and registry_url:
|
if not plugin_data.get("icon_url") and registry_url:
|
||||||
# registry_url is e.g. https://github.com/Dispatcharr/Plugins
|
# registry_url is e.g. https://github.com/Dispatcharr/Plugins
|
||||||
|
|
@ -1161,18 +1183,18 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
|
||||||
# Resolve relative URLs in versions
|
# Resolve relative URLs in versions
|
||||||
repo_manifest = repo.cached_manifest or {}
|
repo_manifest = repo.cached_manifest or {}
|
||||||
inner = repo_manifest.get("manifest", repo_manifest)
|
inner = repo_manifest.get("manifest", repo_manifest)
|
||||||
root_url = inner.get("root_url", "").rstrip("/")
|
download_base_url, _ = _resolve_manifest_base_urls(inner)
|
||||||
|
|
||||||
if root_url and isinstance(manifest_obj.get("versions"), list):
|
if download_base_url and isinstance(manifest_obj.get("versions"), list):
|
||||||
for v in manifest_obj["versions"]:
|
for v in manifest_obj["versions"]:
|
||||||
url_val = v.get("url", "")
|
url_val = v.get("url", "")
|
||||||
if url_val and not url_val.startswith(("http://", "https://")):
|
if url_val and not url_val.startswith(("http://", "https://")):
|
||||||
v["url"] = f"{root_url}/{url_val}"
|
v["url"] = f"{download_base_url}/{url_val}"
|
||||||
if root_url and isinstance(manifest_obj.get("latest"), dict):
|
if download_base_url and isinstance(manifest_obj.get("latest"), dict):
|
||||||
for url_field in ("url", "latest_url"):
|
for url_field in ("url", "latest_url"):
|
||||||
url_val = manifest_obj["latest"].get(url_field, "")
|
url_val = manifest_obj["latest"].get(url_field, "")
|
||||||
if url_val and not url_val.startswith(("http://", "https://")):
|
if url_val and not url_val.startswith(("http://", "https://")):
|
||||||
manifest_obj["latest"][url_field] = f"{root_url}/{url_val}"
|
manifest_obj["latest"][url_field] = f"{download_base_url}/{url_val}"
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"manifest": manifest_obj,
|
"manifest": manifest_obj,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ class PluginsConfig(AppConfig):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _setup_repo_refresh_schedule(self):
|
def _setup_repo_refresh_schedule(self):
|
||||||
|
from django.db import close_old_connections
|
||||||
|
|
||||||
from dispatcharr.app_initialization import should_skip_initialization
|
from dispatcharr.app_initialization import should_skip_initialization
|
||||||
if should_skip_initialization():
|
if should_skip_initialization():
|
||||||
return
|
return
|
||||||
|
|
@ -116,3 +118,6 @@ class PluginsConfig(AppConfig):
|
||||||
logging.getLogger(__name__).debug(
|
logging.getLogger(__name__).debug(
|
||||||
"Could not set up plugin repo refresh schedule (migrations may not have run yet)"
|
"Could not set up plugin repo refresh schedule (migrations may not have run yet)"
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
# Boot ORM runs outside a request cycle; return geventpool checkouts.
|
||||||
|
close_old_connections()
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import sys
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import close_old_connections, transaction
|
||||||
|
|
||||||
from .models import PluginConfig
|
from .models import PluginConfig
|
||||||
|
|
||||||
|
|
@ -68,7 +68,13 @@ class PluginManager:
|
||||||
sync_db: bool = True,
|
sync_db: bool = True,
|
||||||
force_reload: bool = False,
|
force_reload: bool = False,
|
||||||
use_cache: bool = False,
|
use_cache: bool = False,
|
||||||
|
release_connections: bool = True,
|
||||||
) -> Dict[str, LoadedPlugin]:
|
) -> Dict[str, LoadedPlugin]:
|
||||||
|
# Only an explicit caller force_reload broadcasts via the shared token.
|
||||||
|
# Reacting to a newer token must reload locally without re-touching it;
|
||||||
|
# otherwise every consumer becomes a producer and multi-worker setups
|
||||||
|
# never converge.
|
||||||
|
caller_force_reload = force_reload
|
||||||
token = self._get_reload_token()
|
token = self._get_reload_token()
|
||||||
if use_cache and not force_reload:
|
if use_cache and not force_reload:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -76,7 +82,7 @@ class PluginManager:
|
||||||
return self._registry
|
return self._registry
|
||||||
if token > self._last_reload_token:
|
if token > self._last_reload_token:
|
||||||
force_reload = True
|
force_reload = True
|
||||||
if force_reload:
|
if caller_force_reload:
|
||||||
self._touch_reload_token()
|
self._touch_reload_token()
|
||||||
token = self._get_reload_token()
|
token = self._get_reload_token()
|
||||||
|
|
||||||
|
|
@ -92,6 +98,30 @@ class PluginManager:
|
||||||
key: lp.path for key, lp in self._registry.items() if lp and lp.path
|
key: lp.path for key, lp in self._registry.items() if lp and lp.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._discover_plugins_impl(
|
||||||
|
sync_db=sync_db,
|
||||||
|
force_reload=force_reload,
|
||||||
|
previous_packages=previous_packages,
|
||||||
|
previous_aliases=previous_aliases,
|
||||||
|
previous_paths=previous_paths,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Discovery runs outside Django's request/task cycle (boot, worker_ready).
|
||||||
|
if release_connections:
|
||||||
|
close_old_connections()
|
||||||
|
|
||||||
|
def _discover_plugins_impl(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
sync_db: bool,
|
||||||
|
force_reload: bool,
|
||||||
|
previous_packages: Dict[str, str],
|
||||||
|
previous_aliases: Dict[str, str],
|
||||||
|
previous_paths: Dict[str, str],
|
||||||
|
token: int,
|
||||||
|
) -> Dict[str, LoadedPlugin]:
|
||||||
try:
|
try:
|
||||||
configs: Optional[Dict[str, PluginConfig]] = None
|
configs: Optional[Dict[str, PluginConfig]] = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -247,6 +277,23 @@ class PluginManager:
|
||||||
logger.exception("Deferring plugin DB sync; database not ready yet")
|
logger.exception("Deferring plugin DB sync; database not ready yet")
|
||||||
return self._registry
|
return self._registry
|
||||||
|
|
||||||
|
def iter_actions_for_event(self, event_name: str) -> Iterator[Tuple[str, str]]:
|
||||||
|
"""Yield (plugin_key, action_id) pairs from the in-memory registry."""
|
||||||
|
with self._lock:
|
||||||
|
registry = list(self._registry.items())
|
||||||
|
for key, lp in registry:
|
||||||
|
for action in lp.actions or []:
|
||||||
|
if not isinstance(action, dict):
|
||||||
|
continue
|
||||||
|
action_id = action.get("id")
|
||||||
|
events = action.get("events")
|
||||||
|
if (
|
||||||
|
action_id
|
||||||
|
and isinstance(events, (list, tuple))
|
||||||
|
and event_name in events
|
||||||
|
):
|
||||||
|
yield key, action_id
|
||||||
|
|
||||||
def _load_plugin(
|
def _load_plugin(
|
||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
|
|
@ -492,79 +539,85 @@ class PluginManager:
|
||||||
return cfg.settings
|
return cfg.settings
|
||||||
|
|
||||||
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
lp = self.get_plugin(key)
|
try:
|
||||||
if not lp or not lp.instance:
|
|
||||||
# Attempt a lightweight re-discovery in case the registry was rebuilt
|
|
||||||
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
|
|
||||||
lp = self.get_plugin(key)
|
lp = self.get_plugin(key)
|
||||||
if not lp or not lp.instance:
|
if not lp or not lp.instance:
|
||||||
raise ValueError(f"Plugin '{key}' not found")
|
# Attempt a lightweight re-discovery in case the registry was rebuilt
|
||||||
|
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
|
||||||
|
lp = self.get_plugin(key)
|
||||||
|
if not lp or not lp.instance:
|
||||||
|
raise ValueError(f"Plugin '{key}' not found")
|
||||||
|
|
||||||
cfg = PluginConfig.objects.get(key=key)
|
cfg = PluginConfig.objects.get(key=key)
|
||||||
if not cfg.enabled:
|
if not cfg.enabled:
|
||||||
raise PermissionError(f"Plugin '{key}' is disabled")
|
raise PermissionError(f"Plugin '{key}' is disabled")
|
||||||
params = params or {}
|
params = params or {}
|
||||||
|
|
||||||
context = self._build_context(lp, cfg)
|
context = self._build_context(lp, cfg)
|
||||||
|
|
||||||
# Run either via Celery if plugin provides a delayed method, or inline
|
run_method = getattr(lp.instance, "run", None)
|
||||||
run_method = getattr(lp.instance, "run", None)
|
if not callable(run_method):
|
||||||
if not callable(run_method):
|
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
|
||||||
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = run_method(action_id, params, context)
|
result = run_method(action_id, params, context)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Plugin '{key}' action '{action_id}' failed")
|
logger.exception(f"Plugin '{key}' action '{action_id}' failed")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Normalize return
|
if isinstance(result, dict):
|
||||||
if isinstance(result, dict):
|
return result
|
||||||
return result
|
return {"status": "ok", "result": result}
|
||||||
return {"status": "ok", "result": result}
|
finally:
|
||||||
|
# Return geventpool checkouts for this greenlet/thread after every action,
|
||||||
|
# including Connect event hooks and manual UI runs.
|
||||||
|
close_old_connections()
|
||||||
|
|
||||||
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
|
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
|
||||||
lp = self.get_plugin(key)
|
|
||||||
if not lp or not lp.instance:
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
cfg = PluginConfig.objects.get(key=key)
|
lp = self.get_plugin(key)
|
||||||
except PluginConfig.DoesNotExist:
|
if not lp or not lp.instance:
|
||||||
return False
|
return False
|
||||||
if not cfg.enabled:
|
|
||||||
return False
|
|
||||||
|
|
||||||
context = self._build_context(lp, cfg)
|
|
||||||
if reason:
|
|
||||||
context["reason"] = reason
|
|
||||||
|
|
||||||
stop_method = getattr(lp.instance, "stop", None)
|
|
||||||
if callable(stop_method):
|
|
||||||
try:
|
try:
|
||||||
stop_method(context)
|
cfg = PluginConfig.objects.get(key=key)
|
||||||
return True
|
except PluginConfig.DoesNotExist:
|
||||||
except TypeError:
|
return False
|
||||||
|
if not cfg.enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
context = self._build_context(lp, cfg)
|
||||||
|
if reason:
|
||||||
|
context["reason"] = reason
|
||||||
|
|
||||||
|
stop_method = getattr(lp.instance, "stop", None)
|
||||||
|
if callable(stop_method):
|
||||||
try:
|
try:
|
||||||
stop_method()
|
stop_method(context)
|
||||||
return True
|
return True
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
stop_method()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Plugin '%s' stop() failed", key)
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Plugin '%s' stop() failed", key)
|
logger.exception("Plugin '%s' stop() failed", key)
|
||||||
return False
|
return False
|
||||||
except Exception:
|
|
||||||
logger.exception("Plugin '%s' stop() failed", key)
|
|
||||||
return False
|
|
||||||
|
|
||||||
run_method = getattr(lp.instance, "run", None)
|
run_method = getattr(lp.instance, "run", None)
|
||||||
if callable(run_method):
|
if callable(run_method):
|
||||||
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
|
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
|
||||||
if "stop" in actions:
|
if "stop" in actions:
|
||||||
try:
|
try:
|
||||||
run_method("stop", {}, context)
|
run_method("stop", {}, context)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Plugin '%s' stop action failed", key)
|
logger.exception("Plugin '%s' stop action failed", key)
|
||||||
return False
|
return False
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
close_old_connections()
|
||||||
|
|
||||||
def stop_all_plugins(self, reason: Optional[str] = None) -> int:
|
def stop_all_plugins(self, reason: Optional[str] = None) -> int:
|
||||||
stopped = 0
|
stopped = 0
|
||||||
|
|
|
||||||
0
apps/plugins/tests/__init__.py
Normal file
0
apps/plugins/tests/__init__.py
Normal file
51
apps/plugins/tests/test_iter_actions_for_event.py
Normal file
51
apps/plugins/tests/test_iter_actions_for_event.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.plugins.loader import LoadedPlugin, PluginManager
|
||||||
|
|
||||||
|
|
||||||
|
class IterActionsForEventTests(SimpleTestCase):
|
||||||
|
def test_yields_matching_handlers(self):
|
||||||
|
pm = PluginManager()
|
||||||
|
pm._registry = {
|
||||||
|
"alpha": LoadedPlugin(
|
||||||
|
key="alpha",
|
||||||
|
name="Alpha",
|
||||||
|
actions=[
|
||||||
|
{"id": "on_start", "events": ["channel_start"]},
|
||||||
|
{"id": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"beta": LoadedPlugin(
|
||||||
|
key="beta",
|
||||||
|
name="Beta",
|
||||||
|
actions=[
|
||||||
|
{"id": "on_both", "events": ["channel_start", "channel_stop"]},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
list(pm.iter_actions_for_event("channel_start")),
|
||||||
|
[("alpha", "on_start"), ("beta", "on_both")],
|
||||||
|
)
|
||||||
|
self.assertEqual(list(pm.iter_actions_for_event("channel_stop")), [("beta", "on_both")])
|
||||||
|
|
||||||
|
def test_ignores_string_events_value(self):
|
||||||
|
pm = PluginManager()
|
||||||
|
pm._registry = {
|
||||||
|
"bad": LoadedPlugin(
|
||||||
|
key="bad",
|
||||||
|
name="Bad",
|
||||||
|
actions=[{"id": "hook", "events": "client_connect"}],
|
||||||
|
),
|
||||||
|
"good": LoadedPlugin(
|
||||||
|
key="good",
|
||||||
|
name="Good",
|
||||||
|
actions=[{"id": "hook", "events": ["client_connect"]}],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
list(pm.iter_actions_for_event("client_connect")),
|
||||||
|
[("good", "hook")],
|
||||||
|
)
|
||||||
124
apps/plugins/tests/test_reload_token.py
Normal file
124
apps/plugins/tests/test_reload_token.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Reload-token handling must converge across workers.
|
||||||
|
|
||||||
|
Reacting to a stale .reload_token must reload locally without re-touching the
|
||||||
|
token. Re-touching turns every consumer into a producer and causes a permanent
|
||||||
|
force-reload ping-pong under multi-worker uWSGI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.plugins.loader import PluginManager
|
||||||
|
|
||||||
|
|
||||||
|
class PluginReloadTokenTests(SimpleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmpdir = tempfile.mkdtemp(prefix="dispatcharr-plugins-")
|
||||||
|
self._env = patch.dict(os.environ, {"DISPATCHARR_PLUGINS_DIR": self._tmpdir})
|
||||||
|
self._env.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._env.stop()
|
||||||
|
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _make_worker(self) -> PluginManager:
|
||||||
|
return PluginManager()
|
||||||
|
|
||||||
|
def _pin_token_mtime(self, worker: PluginManager, age_seconds: float = 60.0) -> float:
|
||||||
|
"""Write the token file with an older mtime so a retouch is detectable."""
|
||||||
|
worker._touch_reload_token()
|
||||||
|
pinned = time.time() - age_seconds
|
||||||
|
os.utime(worker._reload_token_path, (pinned, pinned))
|
||||||
|
return worker._get_reload_token()
|
||||||
|
|
||||||
|
def test_reacting_to_stale_token_does_not_retouch(self):
|
||||||
|
"""Consuming a reload signal must not re-broadcast it."""
|
||||||
|
worker = self._make_worker()
|
||||||
|
seed = self._pin_token_mtime(worker)
|
||||||
|
self.assertGreater(seed, 0.0)
|
||||||
|
|
||||||
|
# Process has never observed this token (fresh worker after a reload).
|
||||||
|
worker._last_reload_token = 0.0
|
||||||
|
worker._discovery_completed = False
|
||||||
|
|
||||||
|
worker.discover_plugins(sync_db=False, use_cache=True)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
worker._get_reload_token(),
|
||||||
|
seed,
|
||||||
|
"reacting to a stale reload token must not bump .reload_token",
|
||||||
|
)
|
||||||
|
self.assertEqual(worker._last_reload_token, seed)
|
||||||
|
|
||||||
|
def test_stale_token_still_force_reloads_locally(self):
|
||||||
|
"""A stale token must still purge/reload modules in this process."""
|
||||||
|
worker = self._make_worker()
|
||||||
|
self._pin_token_mtime(worker)
|
||||||
|
worker._last_reload_token = 0.0
|
||||||
|
worker._discovery_completed = False
|
||||||
|
|
||||||
|
with patch.object(worker, "_touch_reload_token") as mock_touch:
|
||||||
|
with patch.object(
|
||||||
|
worker, "_discover_plugins_impl", return_value={}
|
||||||
|
) as mock_impl:
|
||||||
|
worker.discover_plugins(sync_db=False, use_cache=True)
|
||||||
|
|
||||||
|
mock_touch.assert_not_called()
|
||||||
|
mock_impl.assert_called_once()
|
||||||
|
self.assertTrue(mock_impl.call_args.kwargs["force_reload"])
|
||||||
|
|
||||||
|
def test_explicit_force_reload_touches_token(self):
|
||||||
|
"""Caller-requested force_reload must broadcast to other workers."""
|
||||||
|
worker = self._make_worker()
|
||||||
|
seed = self._pin_token_mtime(worker)
|
||||||
|
worker._last_reload_token = seed
|
||||||
|
worker._discovery_completed = True
|
||||||
|
|
||||||
|
worker.discover_plugins(sync_db=False, force_reload=True)
|
||||||
|
|
||||||
|
self.assertGreater(worker._get_reload_token(), seed)
|
||||||
|
self.assertEqual(worker._last_reload_token, worker._get_reload_token())
|
||||||
|
|
||||||
|
def test_multi_worker_stale_reactions_converge(self):
|
||||||
|
"""Two workers reacting to one broadcast must not ping-pong forever."""
|
||||||
|
worker_a = self._make_worker()
|
||||||
|
worker_b = self._make_worker()
|
||||||
|
|
||||||
|
# Legitimate broadcast (install/update/reload API), pinned so any
|
||||||
|
# later consumer retouch is visible in mtime comparisons.
|
||||||
|
worker_a.discover_plugins(sync_db=False, force_reload=True)
|
||||||
|
broadcast = self._pin_token_mtime(worker_a)
|
||||||
|
worker_a._last_reload_token = broadcast
|
||||||
|
worker_a._discovery_completed = True
|
||||||
|
|
||||||
|
# Worker B has not seen the broadcast yet.
|
||||||
|
worker_b._last_reload_token = 0.0
|
||||||
|
worker_b._discovery_completed = False
|
||||||
|
|
||||||
|
# Alternate discoveries as connect-event dispatch would across workers.
|
||||||
|
for _ in range(5):
|
||||||
|
worker_a.discover_plugins(sync_db=False, use_cache=True)
|
||||||
|
worker_b.discover_plugins(sync_db=False, use_cache=True)
|
||||||
|
|
||||||
|
self.assertEqual(worker_a._get_reload_token(), broadcast)
|
||||||
|
self.assertEqual(worker_b._get_reload_token(), broadcast)
|
||||||
|
self.assertEqual(worker_a._last_reload_token, broadcast)
|
||||||
|
self.assertEqual(worker_b._last_reload_token, broadcast)
|
||||||
|
|
||||||
|
def test_cache_hit_skips_discovery_after_convergence(self):
|
||||||
|
"""Steady-state connect events must not re-enter discovery."""
|
||||||
|
worker = self._make_worker()
|
||||||
|
worker.discover_plugins(sync_db=False, force_reload=True)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
worker, "_discover_plugins_impl", return_value={}
|
||||||
|
) as mock_impl:
|
||||||
|
worker.discover_plugins(sync_db=False, use_cache=True)
|
||||||
|
worker.discover_plugins(sync_db=False, use_cache=True)
|
||||||
|
|
||||||
|
mock_impl.assert_not_called()
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue