diff --git a/.dockerignore b/.dockerignore index c79ca7b4..002f1c63 100755 --- a/.dockerignore +++ b/.dockerignore @@ -29,5 +29,5 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md data/ +docker/data/ diff --git a/.github/workflows/base-image.yml b/.github/workflows/base-image.yml index f926d892..e3a8a4ee 100644 --- a/.github/workflows/base-image.yml +++ b/.github/workflows/base-image.yml @@ -6,13 +6,13 @@ on: paths: - 'docker/DispatcharrBase' - '.github/workflows/base-image.yml' - - 'requirements.txt' + - 'pyproject.toml' pull_request: branches: [main, dev] paths: - 'docker/DispatcharrBase' - '.github/workflows/base-image.yml' - - 'requirements.txt' + - 'pyproject.toml' workflow_dispatch: # Allow manual triggering permissions: @@ -101,6 +101,28 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }} + docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }} + labels: | + org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }} + org.opencontainers.image.description=Your ultimate IPTV & stream Management companion. + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }} + org.opencontainers.image.created=${{ needs.prepare.outputs.timestamp }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=See repository + org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/ + org.opencontainers.image.vendor=${{ needs.prepare.outputs.repo_owner }} + org.opencontainers.image.authors=${{ github.actor }} + maintainer=${{ github.actor }} + build_version=DispatcharrBase version: ${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }} + - name: Build and push Docker base image uses: docker/build-push-action@v4 with: @@ -113,6 +135,7 @@ jobs: ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }} docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }} docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} build-args: | REPO_OWNER=${{ needs.prepare.outputs.repo_owner }} REPO_NAME=${{ needs.prepare.outputs.repo_name }} @@ -154,18 +177,74 @@ jobs: # GitHub Container Registry manifests # branch tag (e.g. base or base-dev) - docker buildx imagetools create --tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \ + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \ + --tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \ ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64 # branch + timestamp tag - docker buildx imagetools create --tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-${TIMESTAMP} \ + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \ + --tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-${TIMESTAMP} \ ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-arm64 # Docker Hub manifests # branch tag (e.g. base or base-dev) - docker buildx imagetools create --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \ + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \ + --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \ docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64 # branch + timestamp tag - docker buildx imagetools create --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-${TIMESTAMP} \ + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \ + --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-${TIMESTAMP} \ docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-arm64 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80bd8984..bf5b6040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,27 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # use metadata from the prepare job + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }} + docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }} + labels: | + org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }} + org.opencontainers.image.description=Your ultimate IPTV & stream Management companion. + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }} + org.opencontainers.image.created=${{ needs.prepare.outputs.timestamp }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=See repository + org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/ + org.opencontainers.image.vendor=${{ needs.prepare.outputs.repo_owner }} + org.opencontainers.image.authors=${{ github.actor }} + maintainer=${{ github.actor }} + build_version=Dispatcharr version: ${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }} - name: Build and push Docker image uses: docker/build-push-action@v4 @@ -137,6 +157,7 @@ jobs: ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }} docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }} docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} build-args: | REPO_OWNER=${{ needs.prepare.outputs.repo_owner }} REPO_NAME=${{ needs.prepare.outputs.repo_name }} @@ -180,17 +201,42 @@ jobs: echo "Creating multi-arch manifest for ${OWNER}/${REPO}" - # branch tag (e.g. latest or dev) - docker buildx imagetools create --tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \ + # ghcr.io: both the branch tag (e.g. dev) and the version+timestamp tag + # point to the same manifest by using multiple --tag flags in one call, + # which prevents orphaned untagged images on each new build + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \ + --tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \ + --tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \ ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64 - # version + timestamp tag - docker buildx imagetools create --tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \ - ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-arm64 - - # also create Docker Hub manifests using the same username - docker buildx imagetools create --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \ + # Docker Hub: same single call with both tags + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \ + --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \ + --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP} \ docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64 - - docker buildx imagetools create --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP} \ - docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-arm64 diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..4e9e2505 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,41 @@ +name: Frontend Tests + +on: + push: + branches: [main, dev] + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + pull_request: + branches: [main, dev] + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + +jobs: + test: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./frontend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: './frontend/package-lock.json' + + - name: Install dependencies + run: npm ci + + # - name: Run linter + # run: npm run lint + + - name: Run tests + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9734eb4..9186541d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: new_version: ${{ steps.update_version.outputs.new_version }} repo_owner: ${{ steps.meta.outputs.repo_owner }} repo_name: ${{ steps.meta.outputs.repo_name }} + timestamp: ${{ steps.timestamp.outputs.timestamp }} steps: - uses: actions/checkout@v3 with: @@ -56,6 +57,12 @@ jobs: REPO_NAME=$(echo "${{ github.repository }}" | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]') echo "repo_name=${REPO_NAME}" >> $GITHUB_OUTPUT + - name: Generate timestamp for build + id: timestamp + run: | + TIMESTAMP=$(date -u +'%Y%m%d%H%M%S') + echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT + - name: Commit and Tag run: | git add version.py CHANGELOG.md @@ -104,6 +111,28 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }} + docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }} + labels: | + org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }} + org.opencontainers.image.description=Your ultimate IPTV & stream Management companion. + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ needs.prepare.outputs.new_version }} + org.opencontainers.image.created=${{ needs.prepare.outputs.timestamp }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=See repository + org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/ + org.opencontainers.image.vendor=${{ needs.prepare.outputs.repo_owner }} + org.opencontainers.image.authors=${{ github.actor }} + maintainer=${{ github.actor }} + build_version=Dispatcharr version: ${{ needs.prepare.outputs.new_version }} Build date: ${{ needs.prepare.outputs.timestamp }} + - name: Build and push Docker image uses: docker/build-push-action@v4 with: @@ -115,6 +144,7 @@ jobs: ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.new_version }}-${{ matrix.platform }} docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:latest-${{ matrix.platform }} docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.new_version }}-${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} build-args: | REPO_OWNER=${{ needs.prepare.outputs.repo_owner }} REPO_NAME=${{ needs.prepare.outputs.repo_name }} @@ -149,25 +179,48 @@ jobs: OWNER=${{ needs.prepare.outputs.repo_owner }} REPO=${{ needs.prepare.outputs.repo_name }} VERSION=${{ needs.prepare.outputs.new_version }} + TIMESTAMP=${{ needs.prepare.outputs.timestamp }} echo "Creating multi-arch manifest for ${OWNER}/${REPO}" # GitHub Container Registry manifests - # latest tag - docker buildx imagetools create --tag ghcr.io/${OWNER}/${REPO}:latest \ - ghcr.io/${OWNER}/${REPO}:latest-amd64 ghcr.io/${OWNER}/${REPO}:latest-arm64 - - # version tag - docker buildx imagetools create --tag ghcr.io/${OWNER}/${REPO}:${VERSION} \ + # Create one manifest with both latest and version tags + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${VERSION}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \ + --tag ghcr.io/${OWNER}/${REPO}:latest \ + --tag ghcr.io/${OWNER}/${REPO}:${VERSION} \ ghcr.io/${OWNER}/${REPO}:${VERSION}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-arm64 # Docker Hub manifests - # latest tag - docker buildx imagetools create --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest \ - docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest-arm64 - - # version tag - docker buildx imagetools create --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION} \ + # Create one manifest with both latest and version tags + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ + --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ + --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "index:org.opencontainers.image.version=${VERSION}" \ + --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ + --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ + --annotation "index:org.opencontainers.image.licenses=See repository" \ + --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ + --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ + --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ + --annotation "index:maintainer=${{ github.actor }}" \ + --annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \ + --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest \ + --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION} \ docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-arm64 create-release: diff --git a/.gitignore b/.gitignore index a9d76412..6d1becaa 100755 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ dump.rdb debugpy* uwsgi.sock package-lock.json -models \ No newline at end of file +models +.idea +uv.lock \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/1.png b/1.png deleted file mode 100644 index 10b11162..00000000 Binary files a/1.png and /dev/null differ diff --git a/2.png b/2.png deleted file mode 100644 index 730a9d07..00000000 Binary files a/2.png and /dev/null differ diff --git a/3.png b/3.png deleted file mode 100644 index 800592bb..00000000 Binary files a/3.png and /dev/null differ diff --git a/4.png b/4.png deleted file mode 100644 index e6d18010..00000000 Binary files a/4.png and /dev/null differ diff --git a/5.png b/5.png deleted file mode 100644 index edd44882..00000000 Binary files a/5.png and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b95749e..410baed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,20 +7,413 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Updated Django 5.2.9 → 5.2.11, resolving the following CVEs: + - **CVE-2025-13473** (low): Username enumeration via timing difference in mod_wsgi authentication handler. + - **CVE-2025-14550** (moderate): Potential denial-of-service via repeated headers on ASGI requests. + - **CVE-2026-1207** (high): Potential SQL injection via raster lookups on PostGIS. + - **CVE-2026-1285** (moderate): Potential denial-of-service in `django.utils.text.Truncator` HTML methods via inputs with large numbers of unmatched HTML end tags. + - **CVE-2026-1287** (high): Potential SQL injection in column aliases via control characters in `FilteredRelation`. + - **CVE-2026-1312** (high): Potential SQL injection via `QuerySet.order_by()` and `FilteredRelation` when using column aliases containing periods. +- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high): + - Updated `ajv` 6.12.6 → 6.14.0, resolving a **moderate** ReDoS vulnerability when using the `$data` option ([GHSA-2g4f-4pwh-qvx6](https://github.com/advisories/GHSA-2g4f-4pwh-qvx6)) + - Enforced `minimatch` ≥10.2.2 via npm overrides, resolving **high** ReDoS via repeated wildcards with non-matching literal patterns ([GHSA-3ppc-4f35-3m26](https://github.com/advisories/GHSA-3ppc-4f35-3m26)) affecting `minimatch`, `@eslint/config-array`, `@eslint/eslintrc`, and `eslint` + ### Added -- Sort buttons for 'Group' and 'M3U' columns in Streams table for improved stream organization and filtering - Thanks [@bobey6](https://github.com/bobey6) +- API key authentication: Added support for API key-based authentication as an alternative to JWT tokens. Users can generate and revoke their own personal API key from their profile page, enabling programmatic access for scripts, automations, and third-party integrations without exposing account credentials. Keys authenticate via the `Authorization: ApiKey ` header or the `X-API-Key: ` header. Admin users can additionally generate and revoke keys on behalf of any user. +- Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations. +- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942) +- Event-driven webhooks and script execution (Connect): Added new Connect feature that enables event-driven execution of custom scripts and webhooks in response to system events. Supports multiple event types including channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect). Event data is available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads. Plugins can now subscribe to events by specifying an `events` array in their action definitions. Includes connection testing endpoint with dummy payloads for validation. (Closes #203) +- Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165) +- Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups: + - **Fixed Start Number** (default): Start at a specified number and increment sequentially + - **Use Provider Number**: Use channel numbers from the M3U source (tvg-chno), with configurable fallback if provider number is missing + - **Next Available**: Auto-assign starting from 1, skipping all used channel numbers + Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433) +- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd) +- `series_relation` foreign key on `M3UEpisodeRelation`: episode relations now carry a direct FK to their parent `M3USeriesRelation`. This enables correct CASCADE deletion (removing a series relation automatically removes its episode relations), precise per-provider scoping during stale-stream cleanup. +- Streamer accounts attempting to log into the web UI now receive a clear notification explaining they cannot access the UI but their stream URLs still work. Previously the login button would silently stop with no feedback. ### Changed -- **Performance**: EPG program parsing optimized for sources with many channels but only a fraction mapped. Now parses XML file once per source instead of once per channel, dramatically reducing I/O and CPU overhead. For sources with 10,000 channels and 100 mapped, this results in ~99x fewer file opens and ~100x fewer full file scans. Orphaned programs for unmapped channels are also cleaned up during refresh to prevent database bloat. Database updates are now atomic to prevent clients from seeing empty/partial EPG data during refresh. +- Dependency updates: + - `Django` 5.2.9 → 5.2.11 (security patch; see Security section) + - `celery` 5.6.0 → 5.6.2 + - `psutil` 7.1.3 → 7.2.2 + - `torch` 2.9.1+cpu → 2.10.0+cpu + - `sentence-transformers` 5.2.0 → 5.2.3 + - `ajv` 6.12.6 → 6.14.0 (security patch; see Security section) + - `minimatch` enforced ≥10.2.2 via npm overrides (security patch; see Security section) + - `react` / `react-dom` 19.2.3 → 19.2.4 + - `react-router-dom` / `react-router` 7.12.0 → 7.13.0 + - `react-hook-form` 7.70.0 → 7.71.2 + - `react-draggable` 4.4.6 → 4.5.0 + - `@tanstack/react-table` 8.21.2 → 8.21.3 + - `video.js` 8.23.4 → 8.23.7 + - `vite` 7.3.0 → 7.3.1 + - `zustand` 5.0.9 → 5.0.11 + - `allotment` 1.20.4 → 1.20.5 + - `prettier` 3.7.4 → 3.8.1 + - `@swc/wasm` 1.15.7 → 1.15.11 + - `@testing-library/react` 16.3.1 → 16.3.2 + - `@types/react` 19.2.7 → 19.2.14 + - `@vitejs/plugin-react-swc` 4.2.2 → 4.2.3 +- Channel store optimization: Refactored frontend channel loading to only fetch channel IDs on initial login (matching the streams store pattern), instead of loading full channel objects upfront. Full channel data is fetched lazily as needed. This dramatically reduces login time and initial page load when large channel libraries are present. +- DVR scheduling: Channel selector now displays the channel number alongside the channel name when scheduling a recording. +- TV Guide performance improvements: Optimized the TV Guide with horizontal culling for off-screen program rows (only rendering visible programs), throttled now-line position updates, and improved scroll performance. Reduces unnecessary DOM work and improves responsiveness with large EPG datasets. +- Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity. +- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible. +- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839) +- XC API `get_series` now includes `tmdb_id` and `imdb_id` fields, matching `get_vod_streams`. Clients that use TMDB enrichment (e.g. Chillio) can now resolve clean series titles and poster images. - Thanks [@firestaerter3](https://github.com/firestaerter3) + +### Fixed + +- Fixed admin permission checks inconsistently using `is_superuser`/`is_staff` instead of `user_level>=10`, causing API-created admin accounts to intermittently see the setup page, lose access to backup endpoints, and miss admin-only notifications. `manage.py createsuperuser` now also correctly sets `user_level=10`. (Fixes #954) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Channel table group filter sort order: The group dropdown in the channel table is now sorted alphabetically. +- DVR one-time recording scheduling: Fixed a bug where scheduling a one-time recording for a future program caused the recording to start immediately instead of at the scheduled time. +- XC API `added` field type inconsistencies: `get_live_streams` and `get_vod_info` now return the `added` field as a string (e.g., `"1708300800"`) instead of an integer, fixing compatibility with XC clients that have strict JSON serializers (such as Jellyfin's Xtream Library plugin). (Closes #978) +- Stream Profile form User-Agent not populating when editing: The User-Agent field was not correctly loaded from the existing profile when opening the edit modal. (Fixes #650) +- VOD proxy connection counter leak on client disconnect: Fixed a connection leak in the VOD proxy where connection counters were not properly decremented when clients disconnected, causing the connection pool to lose track of available connections. The multi-worker connection manager now correctly handles client disconnection events across all proxy configurations. Includes three key fixes: (1) Replaced GET-check-INCR race condition with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams; (2) Decrement profile counter directly in stream generator exit paths instead of deferring to daemon thread cleanup; (3) Decrement profile counter on create_connection() failure to release reserved slots. (Fixes #962, #971, #451, #533) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- XC profile refresh credential extraction with sub-paths: Fixed credential extraction in `get_transformed_credentials()` to use negative indices anchored to the known tail structure instead of hardcoded indices that broke when server URLs contained sub-paths (e.g., `http://server.com/portal/a/`). This ensures XC accounts with sub-paths in their server URLs work correctly for profile refreshes. (Fixes #945) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique. +- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- VOD episode UUID regeneration on every refresh: a pre-emptive `Episode.objects.delete()` in `refresh_series_episodes` ran before `batch_process_episodes`, defeating its update-in-place logic and forcing all episodes to be recreated with new UUIDs on every refresh. Clients (Jellyfin, Emby, Plex, etc.) with cached episode paths received 500 errors until a full library rescan. Removing the delete allows episodes to be updated in place with stable UUIDs. (Fixes #785, #985, #820) - Thanks [@znake-oil](https://github.com/znake-oil) +- VOD stale episode stream cleanup scoped incorrectly per provider: when a provider removed a stream from a series, `batch_process_episodes` could delete episode relations belonging to a different provider version of the same series (e.g. EN vs ES) that had deduped to the same `Series` object via TMDB/IMDB ID. Cleanup is now scoped to the specific `M3USeriesRelation` that was queried. + +## [0.19.0] - 2026-02-10 + +### Added + +- Add system notifications and update checks + -Real-time notifications for system events and alerts + -Per-user notification management and dismissal + -Update check on startup and every 24 hours to notify users of available versions + -Notification center UI component + -Automatic cleanup of expired notifications +- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click. +- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view. +- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations. +- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including: + - `useLocalStorage` hook tests with localStorage mocking and error handling + - `useSmartLogos` hook tests for logo loading and management + - `useTablePreferences` hook tests for table settings persistence + - `useAuthStore` tests for authentication flow and token management + - `useChannelsStore` tests for channel data management + - `useUserAgentsStore` tests for user agent CRUD operations + - `useUsersStore` tests for user management functionality + - `useVODLogosStore` tests for VOD logo operations + - `useVideoStore` tests for video player state management + - `useWarningsStore` tests for warning suppression functionality + - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) +- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Redis authentication support for modular deployments: Added support for authentication when connecting to external Redis instances using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). REDIS_PASSWORD and REDIS_USER environment variables with URL encoding for special characters. (Closes #937) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name. +- Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing. +- Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown. +- Plugin action buttons can define `button_label`, `button_variant`, and `button_color` (e.g., Stop in red), falling back to “Run” for older plugins. +- Plugin card metadata: plugins can specify `author` and `help_url` in `plugin.json` to show author and docs link in the UI. +- Plugin cards can now be expanded/collapsed by clicking the header or chevron to hide settings and actions. + +### Changed + +- XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors. +- App initialization efficiency: Refactored app initialization to prevent redundant execution across multiple worker processes. Created `dispatcharr.app_initialization` utility module with `should_skip_initialization()` function that prevents custom initialization tasks (backup scheduler sync, developer notifications sync) from running during management commands, in worker processes, or in development servers. This significantly reduces startup overhead in multi-worker deployments (e.g., uWSGI with 10 workers now syncs the scheduler once instead of 10 times). Applied to both `CoreConfig` and `BackupsConfig` apps. +- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default. +- Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues. +- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. +- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: + - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added + - Modern OpenAPI 3.0 specification compliance + - Better auto-generation of request/response schemas + - Improved documentation accuracy with serializer introspection +- Switched to uv for package management: Migrated from pip to uv (Astral's fast Python package installer) for improved dependency resolution speed and reliability. This includes updates to Docker build processes, installation scripts (debian_install.sh), and project configuration (pyproject.toml) to leverage uv's features like virtual environment management and lockfile generation. - Thanks [@tobimichael96](https://github.com/tobimichael96) for getting it started! +- Copy to Clipboard: Refactored `copyToClipboard` utility function to include notification handling internally, eliminating duplicate notification code across the frontend. The function now accepts optional parameters for customizing success/failure messages while providing consistent behavior across all copy operations. + +### Fixed + +- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. (Fixes #915) +- Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention. +- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) +- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). +- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible. +- Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir) +- Hardened plugin loading to avoid executing plugin code unless the plugin is enabled. +- Prevented plugin package names from shadowing standard library or installed modules by namespacing plugin imports with safe aliases. +- Added safety limits to plugin ZIP imports (file count and size caps) and sanitized plugin keys derived from uploads. +- Enforced strict boolean parsing for plugin enable/disable requests to avoid accidental enables from truthy strings. +- Applied plugin field defaults server-side when running actions so plugins receive expected settings even before a user saves. +- Plugin settings UI improvements: render `info`/`text` fields, support `input_type: password`, show descriptions/placeholders, surface save failures, and keep settings in sync after refresh. +- Disabled plugins now collapse settings/actions to match the closed state before first enable. +- Plugin card header controls (delete/version/toggle) now stay right-aligned even with long descriptions. +- Improved plugin logo resolution (case-insensitive paths + absolute URLs), fixing dev UI logo loading without a Vite proxy. +- Plugin reload now hits the backend, clears module caches across workers, and refreshes the UI so code changes apply without a full backend restart. +- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths. +- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action. +- Plugin enable/disable toggles now update immediately without requiring a full page refresh. +- M3U/EPG tasks downloading endlessly for large files: Fixed the root cause where the Redis task lock (300s TTL) expired during long downloads, allowing Celery Beat to start competing duplicate tasks that never completed. Added a `TaskLockRenewer` daemon thread that periodically extends the lock TTL while a task is actively working, applied to all long-running task paths (M3U refresh, M3U group refresh, EPG refresh, EPG program parsing). Also adds an HTTP timeout to M3U download requests, streams M3U downloads directly to a temp file on disk instead of accumulating the entire file in memory, and adds Celery task time limits as a safety net against runaway tasks. (Fixes #861) - Thanks [@CodeBormen](https://github.com/CodeBormen) + +## [0.18.1] - 2026-01-27 + +### Fixed + +- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure + +## [0.18.0] - 2026-01-27 + +### Security + +- Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities: + - **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)) + - **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7)) +- Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router) +- Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details. + +### Added + +- Series Rules API Swagger Documentation: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints (`GET /series-rules/`, `POST /series-rules/`, `DELETE /series-rules/{tvg_id}/`, `POST /series-rules/evaluate/`, `POST /series-rules/bulk-remove/`), including detailed descriptions, request/response schemas, and error handling information for improved API discoverability +- Editable Channel Table Mode: + - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. + - EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. + - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. (Closes #333) + - Group column uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors. + - All changes are saved via API with optimistic UI updates and error handling. +- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: + - Currently playing program title displayed with live broadcast indicator (green Radio icon) + - Expandable program descriptions via chevron button + - Progress bar showing elapsed and remaining time for currently playing programs + - Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels + - Smart scheduling that fetches new program data 5 seconds after current program ends + - Only polls when active channel list changes, not on stats refresh +- Channel preview button: Added preview functionality to active stream cards on stats page +- Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667) +- Streams table: Added "Hide Stale" filter to quickly hide streams marked as stale. +- Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom) +- DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery. +- Mature content filtering support: + - Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting + - Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data + - Type-safe conversion supporting both integer (0/1) and string ("0"/"1") formats from different providers + - UI controls in channel edit form (Switch with tooltip) and bulk edit form (Select dropdown) for easy management + - XtreamCodes API support with proper integer formatting (0/1) in live stream responses + - Automatic propagation from streams to channels during both single and bulk channel creation operations + - Included in serializers for full API support + - User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content) +- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663) +- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647) +- Streams table UI: Added descriptive tooltips to top-toolbar buttons (Add to Channel, Create Channels, Filters, Create Stream, Delete) and to row action icons (Add to Channel, Create New Channel). Tooltips now use a 500ms open delay for consistent behavior with existing table header tooltips. + +### Changed + +- Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering: + - Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely + - Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()` + - Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls + - Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses + - Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches + - Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development + - Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store +- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence. +- Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology. +- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. +- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `.g maintainability and providing consistent API across all tables. +- Optimized unassociated streams filter performance: Replaced inefficient reverse foreign key NULL check (`channels__isnull=True`) with Count annotation approach, reducing query time from 4-5 seconds to under 500ms for large datasets (75k+ streams) + +### Fixed + +- Channels table pagination error handling: Fixed "Invalid page" error notifications that appeared when filters reduced the result set while on a page beyond the new total. The API layer now automatically detects invalid page errors, resets pagination to page 1, and retries the request transparently. (Fixes #864) +- Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712) +- Fixed nginx startup failure due to group name mismatch in non-container deployments - Thanks [@s0len](https://github.com/s0len) (Fixes #877) +- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869) +- Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page: + - Stream connection card "Connected" column + - VOD connection card "Connection Start Time" column + - M3U table "Updated" column + - EPG table "Updated" column + - Users table "Last Login" and "Date Joined" columns + - All components now use centralized `format()` helper from dateTimeUtils for consistency +- Removed unused imports from table components for cleaner code +- Fixed build-dev.sh script stability: Resolved Dockerfile and build context paths to be relative to script location for reliable execution from any working directory, added proper --platform argument handling with array-safe quoting, and corrected push behavior to honor -p flag with accurate messaging. Improved formatting and quoting throughout to prevent word-splitting issues - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) +- Fixed TypeError on streams table load after container restart: Added robust data validation and type coercion to handle malformed filter options during container startup. The streams table MultiSelect components now safely convert group names to strings and filter out null/undefined values, preventing "right-hand side of 'in' should be an object, got number" errors when the backend hasn't fully initialized. API error handling returns safe defaults. +- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues. +- Fixed streams table column header overflow: Implemented fixed-height column headers (30px max-height) with pill-style filter display showing first selection plus count (e.g., "Sport +3"). Prevents header expansion when multiple filters are selected, maintaining compact table layout. (Fixes #613) +- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page. +- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database. +- Fixed channel profile filtering to properly restrict content based on assigned channel profiles for all non-admin users (user_level < 10) instead of only streamers (user_level == 0). This corrects the XtreamCodes API endpoints (`get_live_categories` and `get_live_streams`) along with M3U and EPG generation, ensuring standard users (level 1) are properly restricted by their assigned channel profiles. Previously, "Standard" users with channel profiles assigned would see all channels instead of only those in their assigned profiles. +- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. +- Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access. + +## [0.17.0] - 2026-01-13 + +### Added + +- Added tooltip on filter pills showing all selected items in a vertical list (up to 10 items, with "+N more" indicator) +- Loading feedback for all confirmation dialogs: Extended visual loading indicators across all confirmation dialogs throughout the application. Delete, cleanup, and bulk operation dialogs now show an animated dots loader and disabled state during async operations, providing consistent user feedback for backups (restore/delete), channels, EPGs, logos, VOD logos, M3U accounts, streams, users, groups, filters, profiles, batch operations, and network access changes. +- Channel profile edit and duplicate functionality: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access. +- ProfileModal component extracted for improved code organization and maintainability of channel profile management operations. +- Frontend unit tests for pages and utilities: Added comprehensive unit test coverage for frontend components within pages/ and JS files within utils/, along with a GitHub Actions workflow (`frontend-tests.yml`) to automatically run tests on commits and pull requests - Thanks [@nick4810](https://github.com/nick4810) +- Channel Profile membership control for manual channel creation and bulk operations: Extended the existing `channel_profile_ids` parameter from `POST /api/channels/from-stream/` to also support `POST /api/channels/` (manual creation) and bulk creation tasks with the same flexible semantics: + - Omitted parameter (default): Channels are added to ALL profiles (preserves backward compatibility) + - Empty array `[]`: Channels are added to NO profiles + - Sentinel value `[0]`: Channels are added to ALL profiles (explicit) + - Specific IDs `[1, 2, ...]`: Channels are added only to the specified profiles + This allows API consumers to control profile membership across all channel creation methods without requiring all channels to be added to every profile by default. +- Channel profile selection in creation modal: Users can now choose which profiles to add channels to when creating channels from streams (both single and bulk operations). Options include adding to all profiles, no profiles, or specific profiles with mutual exclusivity between special options ("All Profiles", "None") and specific profile selections. Profile selection defaults to the current table filter for intuitive workflow. +- Group retention policy for M3U accounts: Groups now follow the same stale retention logic as streams, using the account's `stale_stream_days` setting. Groups that temporarily disappear from an M3U source are retained for the configured retention period instead of being immediately deleted, preserving user settings and preventing data loss when providers temporarily remove/re-add groups. (Closes #809) +- Visual stale indicators for streams and groups: Added `is_stale` field to Stream and both `is_stale` and `last_seen` fields to ChannelGroupM3UAccount models to track items in their retention grace period. Stale groups display with orange buttons and a warning tooltip, while stale streams show with a red background color matching the visual treatment of empty channels. + +### Changed + +- Settings architecture refactored to use grouped JSON storage: Migrated from individual CharField settings to grouped JSONField settings for improved performance, maintainability, and type safety. Settings are now organized into logical groups (stream_settings, dvr_settings, backup_settings, system_settings, proxy_settings, network_access) with automatic migration handling. Backend provides helper methods (`get_stream_settings()`, `get_default_user_agent_id()`, etc.) for easy access. Frontend simplified by removing complex key mapping logic and standardizing on underscore-based field names throughout. +- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution. (Fixes #805) +- VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase +- Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability +- Stats and VOD pages refactored for clearer separation of concerns: extracted Stream/VOD connection cards (StreamConnectionCard, VodConnectionCard, VODCard, SeriesCard), moved page logic into dedicated utils, and lazy-loaded heavy components with ErrorBoundary fallbacks to improve readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) +- Channel creation modal refactored: Extracted and unified channel numbering dialogs from StreamsTable into a dedicated CreateChannelModal component that handles both single and bulk channel creation with cleaner, more maintainable implementation and integrated profile selection controls. + +### Fixed + +- Fixed bulk channel profile membership update endpoint silently ignoring channels without existing membership records. The endpoint now creates missing memberships automatically (matching single-channel endpoint behavior), validates that all channel IDs exist before processing, and provides detailed response feedback including counts of updated vs. created memberships. Added comprehensive Swagger documentation with request/response schemas. +- Fixed bulk channel edit endpoint crashing with `ValueError: Field names must be given to bulk_update()` when the first channel in the update list had no actual field changes. The endpoint now collects all unique field names from all channels being updated instead of only looking at the first channel, properly handling cases where different channels update different fields or when some channels have no changes - Thanks [@mdellavo](https://github.com/mdellavo) (Fixes #804) +- Fixed PostgreSQL backup restore not completely cleaning database before restoration. The restore process now drops and recreates the entire `public` schema before running `pg_restore`, ensuring a truly clean restore that removes all tables, functions, and other objects not present in the backup file. This prevents leftover database objects from persisting when restoring backups from older branches or versions. Added `--no-owner` flag to `pg_restore` to avoid role permission errors when the backup was created by a different PostgreSQL user. +- Fixed TV Guide loading overlay not disappearing after navigating from DVR page. The `fetchRecordings()` function in the channels store was setting `isLoading: true` on start but never resetting it to `false` on successful completion, causing the Guide page's loading overlay to remain visible indefinitely when accessed after the DVR page. +- Fixed stream profile parameters not properly handling quoted arguments. Switched from basic `.split()` to `shlex.split()` for parsing command-line parameters, allowing proper handling of multi-word arguments in quotes (e.g., OAuth tokens in HTTP headers like `"--twitch-api-header=Authorization=OAuth token123"`). This ensures external streaming tools like Streamlink and FFmpeg receive correctly formatted arguments when using stream profiles with complex parameters - Thanks [@justinforlenza](https://github.com/justinforlenza) (Fixes #833) +- Fixed bulk and manual channel creation not refreshing channel profile memberships in the UI for all connected clients. WebSocket `channels_created` event now calls `fetchChannelProfiles()` to ensure profile membership updates are reflected in real-time for all users without requiring a page refresh. +- Fixed Channel Profile filter incorrectly applying profile membership filtering even when "Show Disabled" was enabled, preventing all channels from being displayed. Profile filter now only applies when hiding disabled channels. (Fixes #825) +- Fixed manual channel creation not adding channels to channel profiles. Manually created channels are now added to the selected profile if one is active, or to all profiles if "All" is selected, matching the behavior of channels created from streams. +- Fixed VOD streams disappearing from stats page during playback by adding `socket-timeout = 600` to production uWSGI config. The missing directive caused uWSGI to use its default 4-second timeout, triggering premature cleanup when clients buffered content. Now matches the existing `http-timeout = 600` value and prevents timeout errors during normal client buffering - Thanks [@patchy8736](https://github.com/patchy8736) +- Fixed Channels table EPG column showing "Not Assigned" on initial load for users with large EPG datasets. Added `tvgsLoaded` flag to EPG store to track when EPG data has finished loading, ensuring the table waits for EPG data before displaying. EPG cells now show animated skeleton placeholders while loading instead of incorrectly showing "Not Assigned". (Fixes #810) +- Fixed VOD profile connection count not being decremented when stream connection fails (timeout, 404, etc.), preventing profiles from reaching capacity limits and rejecting valid stream requests +- Fixed React warning in Channel form by removing invalid `removeTrailingZeros` prop from NumberInput component +- Release workflow Docker tagging: Fixed issue where `latest` and version tags (e.g., `0.16.0`) were creating separate manifests instead of pointing to the same image digest, which caused old `latest` tags to become orphaned/untagged after new releases. Now creates a single multi-arch manifest with both tags, maintaining proper tag relationships and download statistics visibility on GitHub. +- Fixed onboarding message appearing in the Channels Table when filtered results are empty. The onboarding message now only displays when there are no channels created at all, not when channels exist but are filtered out by current filters. +- Fixed `M3UMovieRelation.get_stream_url()` and `M3UEpisodeRelation.get_stream_url()` to use XC client's `_normalize_url()` method instead of simple `rstrip('/')`. This properly handles malformed M3U account URLs (e.g., containing `/player_api.php` or query parameters) before constructing VOD stream endpoints, matching behavior of live channel URL building. (Closes #722) +- Fixed bulk_create and bulk_update errors during VOD content refresh by pre-checking object existence with optimized bulk queries (3 queries total instead of N per batch) before creating new objects. This ensures all movie/series objects have primary keys before relation operations, preventing "prohibited to prevent data loss due to unsaved related object" errors. Additionally fixed duplicate key constraint violations by treating TMDB/IMDB ID values of `0` or `'0'` as invalid (some providers use this to indicate "no ID"), converting them to NULL to prevent multiple items from incorrectly sharing the same ID. (Fixes #813) + +## [0.16.0] - 2026-01-04 + +### Added + +- Advanced filtering for Channels table: Filter menu now allows toggling disabled channels visibility (when a profile is selected) and filtering to show only empty channels without streams (Closes #182) +- Network Access warning modal now displays the client's IP address for better transparency when network restrictions are being enforced - Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) (Closes #778) +- VLC streaming support - Thanks [@sethwv](https://github.com/sethwv) + - Added `cvlc` as an alternative streaming backend alongside FFmpeg and Streamlink + - Log parser refactoring: Introduced `LogParserFactory` and stream-specific parsers (`FFmpegLogParser`, `VLCLogParser`, `StreamlinkLogParser`) to enable codec and resolution detection from multiple streaming tools + - VLC log parsing for stream information: Detects video/audio codecs from TS demux output, supports both stream-copy and transcode modes with resolution/FPS extraction from transcode output + - Locked, read-only VLC stream profile configured for headless operation with intelligent audio/video codec detection + - VLC and required plugins installed in Docker environment with headless configuration +- ErrorBoundary component for handling frontend errors gracefully with generic error message - Thanks [@nick4810](https://github.com/nick4810) + +### Changed + +- Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. - Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) +- Region code options now intentionally include both `GB` (ISO 3166-1 standard) and `UK` (commonly used by EPG/XMLTV providers) to accommodate real-world EPG data variations. Many providers use `UK` in channel identifiers (e.g., `BBCOne.uk`) despite `GB` being the official ISO country code. Users should select the region code that matches their specific EPG provider's convention for optimal region-based EPG matching bonuses - Thanks [@bigpandaaaa](https://github.com/bigpandaaaa) +- Channel number inputs in stream-to-channel creation modals no longer have a maximum value restriction, allowing users to enter any valid channel number supported by the database +- Stream log parsing refactored to use factory pattern: Simplified `ChannelService.parse_and_store_stream_info()` to route parsing through specialized log parsers instead of inline program-specific logic (~150 lines of code removed) +- Stream profile names in fixtures updated to use proper capitalization (ffmpeg → FFmpeg, streamlink → Streamlink) +- Frontend component refactoring for improved code organization and maintainability - Thanks [@nick4810](https://github.com/nick4810) + - Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis, GuideRow, HourTimeline, PluginCard, ProgramRecordingModal, SeriesRecordingModal, Field) + - Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils, guideUtils, PluginsUtils, PluginCardUtils, notificationUtils) + - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal, ProgramRecordingModal, SeriesRecordingModal, PluginCard) with loading fallbacks + - Removed unused Dashboard and Home pages + - Guide page refactoring: Extracted GuideRow and HourTimeline components, moved grid calculations and utility functions to guideUtils.js, added loading states for initial data fetching, improved performance through better memoization + - Plugins page refactoring: Extracted PluginCard and Field components, added Zustand store for plugin state management, improved plugin action confirmation handling, better separation of concerns between UI and business logic +- Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements +- M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs +- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) + - Extracted individual settings forms (DVR, Network Access, Proxy, Stream, System, UI) into separate components with dedicated utility files + - Moved larger nested components into their own files + - Moved business logic into corresponding utils/ files + - Extracted larger in-line component logic into its own function + - Each panel in Settings now uses its own form state with the parent component handling active state management + +### Fixed + +- Auto Channel Sync Force EPG Source feature not properly forcing "No EPG" assignment - When selecting "Force EPG Source" > "No EPG (Disabled)", channels were still being auto-matched to EPG data instead of forcing dummy/no EPG. Now correctly sets `force_dummy_epg` flag to prevent unwanted EPG assignment. (Fixes #788) +- VOD episode processing now properly handles season and episode numbers from APIs that return string values instead of integers, with comprehensive error logging to track data quality issues - Thanks [@patchy8736](https://github.com/patchy8736) (Fixes #770) +- VOD episode-to-stream relations are now validated to ensure episodes have been saved to the database before creating relations, preventing integrity errors when bulk_create operations encounter conflicts - Thanks [@patchy8736](https://github.com/patchy8736) +- VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant) +- M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704) +- M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation) +- Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect +- XtreamCodes EPG limit parameter now properly converted to integer to prevent type errors when accessing EPG listings (Fixes #781) +- Docker container file permissions: Django management commands (`migrate`, `collectstatic`) now run as the non-root user to prevent root-owned `__pycache__` and static files from causing permission issues - Thanks [@sethwv](https://github.com/sethwv) +- Stream validation now continues with GET request if HEAD request fails due to connection issues - Thanks [@kvnnap](https://github.com/kvnnap) (Fixes #782) +- XtreamCodes M3U files now correctly set `x-tvg-url` and `url-tvg` headers to reference XC EPG URL (`xmltv.php`) instead of standard EPG endpoint when downloaded via XC API (Fixes #629) + +## [0.15.1] - 2025-12-22 + +### Fixed + +- XtreamCodes EPG `has_archive` field now returns integer `0` instead of string `"0"` for proper JSON type consistency +- nginx now gracefully handles hosts without IPv6 support by automatically disabling IPv6 binding at startup (Fixes #744) + +## [0.15.0] - 2025-12-20 + +### Added + +- VOD client stop button in Stats page: Users can now disconnect individual VOD clients from the Stats view, similar to the existing channel client disconnect functionality. +- Automated configuration backup/restore system with scheduled backups, retention policies, and async task processing - Thanks [@stlalpha](https://github.com/stlalpha) (Closes #153) +- Stream group as available hash option: Users can now select 'Group' as a hash key option in Settings → Stream Settings → M3U Hash Key, allowing streams to be differentiated by their group membership in addition to name, URL, TVG-ID, and M3U ID + +### Changed + +- Initial super user creation page now matches the login page design with logo, welcome message, divider, and version display for a more consistent and polished first-time setup experience +- Removed unreachable code path in m3u output - Thanks [@DawtCom](https://github.com/DawtCom) +- GitHub Actions workflows now use `docker/metadata-action` for cleaner and more maintainable OCI-compliant image label generation across all build pipelines (ci.yml, base-image.yml, release.yml). Labels are applied to both platform-specific images and multi-arch manifests with proper annotation formatting. - Thanks [@mrdynamo]https://github.com/mrdynamo) (Closes #724) +- Update docker/dev-build.sh to support private registries, multiple architectures and pushing. Now you can do things like `dev-build.sh -p -r my.private.registry -a linux/arm64,linux/amd64` - Thanks [@jdblack](https://github.com/jblack) +- Updated dependencies: Django (5.2.4 → 5.2.9) includes CVE security patch, psycopg2-binary (2.9.10 → 2.9.11), celery (5.5.3 → 5.6.0), djangorestframework (3.16.0 → 3.16.1), requests (2.32.4 → 2.32.5), psutil (7.0.0 → 7.1.3), gevent (25.5.1 → 25.9.1), rapidfuzz (3.13.0 → 3.14.3), torch (2.7.1 → 2.9.1), sentence-transformers (5.1.0 → 5.2.0), lxml (6.0.0 → 6.0.2) (Closes #662) +- Frontend dependencies updated: Vite (6.2.0 → 7.1.7), ESLint (9.21.0 → 9.27.0), and related packages; added npm `overrides` to enforce js-yaml@^4.1.1 for transitive security fix. All 6 reported vulnerabilities resolved with `npm audit fix`. +- Floating video player now supports resizing via a drag handles, with minimum size enforcement and viewport/page boundary constraints to keep it visible. +- Redis connection settings now fully configurable via environment variables (`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_URL`), replacing hardcoded `localhost:6379` values throughout the codebase. This enables use of external Redis services in production deployments. (Closes #762) +- Celery broker and result backend URLs now respect `REDIS_HOST`/`REDIS_PORT`/`REDIS_DB` settings as defaults, with `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` environment variables available for override. + +### Fixed + +- Docker init script now validates DISPATCHARR_PORT is an integer before using it, preventing sed errors when Kubernetes sets it to a service URL like `tcp://10.98.37.10:80`. Falls back to default port 9191 when invalid (Fixes #737) +- M3U Profile form now properly resets local state for search and replace patterns after saving, preventing validation errors when adding multiple profiles in a row +- DVR series rule deletion now properly handles TVG IDs that contain slashes by encoding them in the URL path (Fixes #697) +- VOD episode processing now correctly handles duplicate episodes (same episode in multiple languages/qualities) by reusing Episode records across multiple M3UEpisodeRelation entries instead of attempting to create duplicates (Fixes #556) +- XtreamCodes series streaming endpoint now correctly handles episodes with multiple streams (different languages/qualities) by selecting the best available stream based on account priority (Fixes #569) +- XtreamCodes series info API now returns unique episodes instead of duplicate entries when multiple streams exist for the same episode (different languages/qualities) +- nginx now gracefully handles hosts without IPv6 support by automatically disabling IPv6 binding at startup (Fixes #744) +- XtreamCodes EPG API now returns correct date/time format for start/end fields and proper string types for timestamps and channel_id +- XtreamCodes EPG API now handles None values for title and description fields to prevent AttributeError +- XtreamCodes EPG `id` field now provides unique identifiers per program listing instead of always returning "0" for better client EPG handling +- XtreamCodes EPG `epg_id` field now correctly returns the EPGData record ID (representing the EPG source/channel mapping) instead of a dummy value + +## [0.14.0] - 2025-12-09 + +### Added + +- Sort buttons for 'Group' and 'M3U' columns in Streams table for improved stream organization and filtering - Thanks [@bobey6](https://github.com/bobey6) +- EPG source priority field for controlling which EPG source is preferred when multiple sources have matching entries for a channel (higher numbers = higher priority) (Closes #603) + +### Changed + +- EPG program parsing optimized for sources with many channels but only a fraction mapped. Now parses XML file once per source instead of once per channel, dramatically reducing I/O and CPU overhead. For sources with 10,000 channels and 100 mapped, this results in ~99x fewer file opens and ~100x fewer full file scans. Orphaned programs for unmapped channels are also cleaned up during refresh to prevent database bloat. Database updates are now atomic to prevent clients from seeing empty/partial EPG data during refresh. - EPG table now displays detailed status messages including refresh progress, success messages, and last message for idle sources (matching M3U table behavior) (Closes #214) - IPv6 access now allowed by default with all IPv6 CIDRs accepted - Thanks [@adrianmace](https://github.com/adrianmace) - nginx.conf updated to bind to both IPv4 and IPv6 ports - Thanks [@jordandalley](https://github.com/jordandalley) +- EPG matching now respects source priority and only uses active (enabled) EPG sources (Closes #672) +- EPG form API Key field now only visible when Schedules Direct source type is selected ### Fixed - EPG table "Updated" column now updates in real-time via WebSocket using the actual backend timestamp instead of requiring a page refresh +- Bulk channel editor confirmation dialog now displays the correct stream profile name that will be applied to the selected channels. +- uWSGI not found and 502 bad gateway on first startup + +## [0.13.1] - 2025-12-06 + +### Fixed + +- JWT token generated so is unique for each deployment ## [0.13.0] - 2025-12-02 diff --git a/Plugins.md b/Plugins.md index 62ea0d87..d528f1e4 100644 --- a/Plugins.md +++ b/Plugins.md @@ -8,7 +8,43 @@ This document explains how to build, install, and use Python plugins in Dispatch 1) Create a folder under `/app/data/plugins/my_plugin/` (host path `data/plugins/my_plugin/` in the repo). -2) Add a `plugin.py` file exporting a `Plugin` class: +2) Add a `plugin.json` manifest (new standard) and a `plugin.py` file: + +`/app/data/plugins/my_plugin/plugin.json` +```json +{ + "name": "My Plugin", + "version": "0.1.0", + "description": "Does something useful", + "author": "Acme Labs", + "help_url": "https://example.com/docs/my-plugin", + "fields": [ + { "id": "enabled", "label": "Enabled", "type": "boolean", "default": true }, + { "id": "limit", "label": "Item limit", "type": "number", "default": 5 }, + { + "id": "mode", + "label": "Mode", + "type": "select", + "default": "safe", + "options": [ + { "value": "safe", "label": "Safe" }, + { "value": "fast", "label": "Fast" } + ] + }, + { "id": "note", "label": "Note", "type": "string", "default": "" } + ], + "actions": [ + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue" + } + ] +} +``` ``` # /app/data/plugins/my_plugin/plugin.py @@ -16,6 +52,8 @@ class Plugin: name = "My Plugin" version = "0.1.0" description = "Does something useful" + author = "Acme Labs" + help_url = "https://example.com/docs/my-plugin" # Settings fields rendered by the UI and persisted by the backend fields = [ @@ -31,7 +69,14 @@ class Plugin: # Actions appear as buttons. Clicking one calls run(action, params, context) actions = [ - {"id": "do_work", "label": "Do Work", "description": "Process items"}, + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue", + }, ] def run(self, action: str, params: dict, context: dict): @@ -59,8 +104,10 @@ class Plugin: - Each plugin is a directory containing either: - `plugin.py` exporting a `Plugin` class, or - a Python package (`__init__.py`) exporting a `Plugin` class. +- New standard: include a `plugin.json` manifest alongside your code for safe metadata discovery. +- Optional: include `logo.png` next to `plugin.py` to show a logo in the UI. -The directory name (lowercased, spaces as `_`) is used as the registry key and module import path (e.g. `my_plugin.plugin`). +The directory name (lowercased, spaces as `_`) is used as the registry key. Plugins are imported under a safe internal package name; if the folder name is a valid identifier (and not reserved), it is also registered as an alias for convenience. --- @@ -69,7 +116,8 @@ The directory name (lowercased, spaces as `_`) is used as the registry key and m - Discovery runs at server startup and on-demand when: - Fetching the plugins list from the UI - Hitting `POST /api/plugins/plugins/reload/` -- The loader imports each plugin module and instantiates `Plugin()`. +- The loader reads `plugin.json` for metadata without executing plugin code. +- Plugin code is only imported and instantiated when the plugin is enabled. - Metadata (name, version, description) and a per-plugin settings JSON are stored in the DB. Backend code: @@ -80,6 +128,45 @@ Backend code: --- +## Plugin Manifest (`plugin.json`) + +`plugin.json` lets Dispatcharr list your plugin safely without executing code. It should live next to `plugin.py`. + +Example: +``` +{ + "name": "My Plugin", + "version": "1.2.3", + "description": "Does something useful", + "author": "Acme Labs", + "help_url": "https://example.com/docs/my-plugin", + "fields": [ + { "id": "limit", "label": "Item limit", "type": "number", "default": 5 } + ], + "actions": [ + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue" + } + ] +} +``` + +Notes: +- `author` and `help_url` are optional. If provided, the UI shows “By {author}” and a Docs link. +- If your plugin includes a `logo.png` file next to `plugin.py`, it will be shown on the plugin card. + +If `plugin.json` is missing or invalid, the plugin is treated as **legacy**: +- The name is inferred from the folder name. +- `logo.png` still displays if present. +- The UI shows a warning asking the developer to upgrade to the new standard. + +--- + ## Plugin Interface Export a `Plugin` class. Supported attributes and behavior: @@ -87,34 +174,72 @@ Export a `Plugin` class. Supported attributes and behavior: - `name` (str): Human-readable name. - `version` (str): Semantic version string. - `description` (str): Short description. +- `author` (str, optional): Author or team name shown on the card. +- `help_url` (str, optional): Docs/support link shown on the card. - `fields` (list): Settings schema used by the UI to render controls. -- `actions` (list): Available actions; the UI renders a Run button for each. +- `actions` (list): Available actions; the UI renders a button for each (defaults to Run). - `run(action, params, context)` (callable): Invoked when a user clicks an action. +- `stop(context)` (optional callable): Invoked when the plugin is disabled, deleted, or reloaded so you can gracefully shut down any processes you started. If `stop()` is not defined but you have an action with id `stop`, Dispatcharr will call `run("stop", {}, context)` as a fallback. ### Settings Schema Supported field `type`s: - `boolean` - `number` -- `string` +- `string` (single-line text) +- `text` (multi-line textarea) - `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`) +- `info` (display-only text; useful for headings or notes) Common field keys: - `id` (str): Settings key. - `label` (str): Label shown in the UI. - `type` (str): One of above. - `default` (any): Default value used until saved. -- `help_text` (str, optional): Shown under the control. +- `help_text` / `description` (str, optional): Shown under the control. +- `placeholder` (str, optional): Placeholder text for inputs. +- `input_type` (str, optional): For `string` fields, set to `"password"` to mask input. - `options` (list, for select): List of `{value, label}`. +Notes: +- For `info` fields, you can use `description`/`help_text` (or `value`) to show the text. + The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`. +### Example: stop() Hook +``` +import signal + +class Plugin: + name = "Example Plugin" + version = "1.0.0" + description = "Shows how to shut down gracefully." + + def run(self, action: str, params: dict, context: dict): + # Start a subprocess or background task here and store its PID. + # Example: save pid in /data or in your own module-level variable. + return {"status": "ok"} + + def stop(self, context: dict): + logger = context.get("logger") + pid = self._read_pid() # your helper + if pid: + try: + os.kill(pid, signal.SIGTERM) + logger.info("Stopped process %s", pid) + except Exception: + logger.exception("Failed to stop process %s", pid) +``` + Read settings in `run` via `context["settings"]`. ### Actions Each action is a dict: - `id` (str): Unique action id. -- `label` (str): Button label. +- `label` (str): Action label. - `description` (str, optional): Helper text. +- `button_label` (str, optional): Button text (defaults to “Run”). +- `button_variant` (str, optional): Button style (Mantine variants like `filled`, `outline`, `subtle`). +- `button_color` (str, optional): Button color (e.g., `red`, `blue`, `orange`). Clicking an action calls your plugin’s `run(action, params, context)` and shows a notification with the result or error. @@ -182,6 +307,110 @@ Plugins are server-side Python code running within the Django application. You c Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking. +### Important: Don’t Ask Users for URL/User/Password +Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities. +Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because: + +- It encourages users to enter privileged credentials. +- Malicious plugins could exfiltrate credentials. +- It duplicates access that plugins already have internally. + +If you are writing a plugin, **use internal Python APIs** (models/tasks/utils) instead of making HTTP calls with user credentials. + +### When You Do Need HTTP +In rare cases you may need to call a Dispatcharr HTTP endpoint (for example, to reuse an existing API response serializer). In that case: + +1. **Do not ask the user for credentials.** + Use the backend’s internal access where possible. + +2. Prefer **local/internal URLs** (never user-provided): + - Docker: `http://web:9191` (service name inside the container network) + - Dev: `http://127.0.0.1:5656` + +3. Use Django helpers when building URLs: + ``` + from django.urls import reverse + path = reverse("api:channels:list") # example name + url = f"http://127.0.0.1:5656{path}" + ``` + +4. Use a short timeout and robust error handling: + ``` + import requests + resp = requests.get(url, timeout=10) + resp.raise_for_status() + data = resp.json() + ``` + +### Examples: Preferred Internal Access (No HTTP, No Credentials) + +**Example 1: List channels directly from the DB** +``` +from apps.channels.models import Channel + +channels = Channel.objects.all().values("id", "name", "number")[:50] +return {"status": "ok", "channels": list(channels)} +``` + +**Example 2: Kick off an existing refresh task** +``` +from apps.m3u.tasks import refresh_m3u_accounts +from apps.epg.tasks import refresh_all_epg_data + +refresh_m3u_accounts.delay() +refresh_all_epg_data.delay() +return {"status": "queued"} +``` + +**Example 3: Send a WebSocket update to the UI** +``` +from core.utils import send_websocket_update + +send_websocket_update( + "updates", + "update", + {"type": "plugin", "plugin": "my_plugin", "message": "Refresh queued"} +) +``` + +### Example: HTTP Access (Only If You Must) + +**Find the endpoint** +- Use `reverse()` with the named route when possible. +- If you don’t know the route name, inspect `apps/*/api_urls.py` or Django’s URL config to find it. + +``` +from django.urls import reverse +import requests + +path = reverse("api:channels:list") # named route from apps/channels/api_urls.py +url = f"http://127.0.0.1:5656{path}" + +resp = requests.get(url, timeout=10) +resp.raise_for_status() +data = resp.json() +``` + +### How Developers Find the API + +1. **Prefer internal models/tasks** (best and safest). +2. **Check `apps/*/api_urls.py`** for named routes and endpoint patterns. + - Example: `apps/channels/api_urls.py` for channel endpoints. +3. **Find the view** referenced in the URL config to see required params. + - Example: `apps/channels/api_views.py` or `apps/epg/api_views.py`. +4. **Use `reverse()`** with the named route to build the path. + - This avoids hardcoding paths and keeps plugins compatible if URLs change. +5. **Only use internal hostnames** (never user-provided URL). + +### What Plugins Can Access +Because plugins run inside the server process, they can: +- Read and write database models (same permissions as the app) +- Invoke Celery tasks +- Send websocket updates +- Read configuration and settings + +Treat plugins as **trusted server code** and avoid exposing sensitive data in plugin settings or logs. + --- ## REST Endpoints (for UI and tooling) @@ -203,7 +432,9 @@ Notes: - In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder. - The archive should contain either `plugin.py` or a Python package (`__init__.py`). +- Include `plugin.json` in the plugin folder to provide metadata without executing code. - On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default). + - If `plugin.json` is missing, the plugin is marked as legacy and the UI will show a warning. --- @@ -214,6 +445,7 @@ Notes: - The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code. - The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button. - Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403. +- Dispatcharr will not import or execute plugin code unless the plugin is enabled. --- @@ -263,7 +495,7 @@ class Plugin: ## Troubleshooting - Plugin not listed: ensure the folder exists and contains `plugin.py` with a `Plugin` class. -- Import errors: the folder name is the import name; avoid spaces or exotic characters. +- Import errors: ensure the folder contains `plugin.py` or a package `__init__.py`. Folder names with spaces or dashes are supported; if you need to import by folder name inside your plugin, use a valid Python identifier. - No confirmation: include a boolean field with `id: "confirm"` and set it to true or default true. - HTTP 403 on run: the plugin is disabled; enable it from the toggle or via the `enabled/` endpoint. diff --git a/README.md b/README.md index 9b359e25..1efaecb3 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,87 @@ # 🎬 Dispatcharr — Your Ultimate IPTV & Stream Management Companion +

- Dispatcharr Logo + Dispatcharr Logo

--- ## 📖 What is Dispatcharr? -Dispatcharr is an **open-source powerhouse** for managing IPTV streams and EPG data with elegance and control.\ +Dispatcharr is an **open-source powerhouse** for managing IPTV streams, EPG data, and VOD content with elegance and control.\ Born from necessity and built with passion, it started as a personal project by **[OkinawaBoss](https://github.com/OkinawaBoss)** and evolved with contributions from legends like **[dekzter](https://github.com/dekzter)**, **[SergeantPanda](https://github.com/SergeantPanda)** and **Bucatini**. -> Think of Dispatcharr as the \*arr family’s IPTV cousin — simple, smart, and designed for streamers who want reliability and flexibility. +> Think of Dispatcharr as the \*arr family's IPTV cousin — simple, smart, and designed for streamers who want reliability and flexibility. --- -## 🧪 What’s New in Beta +## 🎯 What Can I Do With Dispatcharr? -Dispatcharr has officially entered **BETA**, bringing powerful new features and improvements across the board: +Dispatcharr empowers you with complete IPTV control. Here are some real-world scenarios: -✨ **Proxy Streaming Engine** — Optimize bandwidth, reduce provider connections, and increase stream reliability\ -📊 **Real-Time Stats Dashboard** — Live insights into stream health and client activity\ -🧠 **EPG Auto-Match** — Match program data to channels automatically\ -⚙️ **Streamlink + FFmpeg Support** — Flexible backend options for streaming and recording\ -🎬 **VOD Management** — Full Video on Demand support with movies and TV series\ -🧼 **UI & UX Enhancements** — Smoother, faster, more responsive interface\ -🛁 **Output Compatibility** — HDHomeRun, M3U, and XMLTV EPG support for Plex, Jellyfin, and more +💡 **Consolidate Multiple IPTV Sources**\ +Combine streams from multiple providers into a single interface. Manage, filter, and organize thousands of channels with ease. + +📺 **Integrate with Media Centers**\ +Use HDHomeRun emulation to add virtual tuners to **Plex**, **Emby**, or **Jellyfin**. They'll discover Dispatcharr as a live TV source and can record programs directly to their own DVR libraries. + +📡 **Create a Personal TV Ecosystem**\ +Merge live TV channels with custom EPG guides. Generate XMLTV schedules or use auto-matching to align channels with existing program data. Export as M3U, Xtream Codes API, or HDHomeRun device. + +🔧 **Transcode & Optimize Streams**\ +Configure output profiles with FFmpeg transcoding to optimize streams for different clients — reduce bandwidth, standardize formats, or add audio normalization. + +🔐 **Centralize VPN Access**\ +Run Dispatcharr through a VPN container (like Gluetun) so all streams route through a single VPN connection. Your clients access geo-blocked content without needing individual VPNs, reducing bandwidth overhead and simplifying network management. + +🚀 **Monitor & Manage in Real-Time**\ +Track active streams, client connections, and bandwidth usage with live statistics. Monitor buffering events and stream quality. Automatic failover keeps viewers connected when streams fail—seamlessly switching to backup sources without interruption. + +👥 **Share Access Safely**\ +Create multiple user accounts with granular permissions. Share streams via M3U playlists or Xtream Codes API while controlling which users access which channels, profiles, or features. Network-based access restrictions available for additional security. + +🔌 **Extend with Plugins**\ +Build custom integrations using Dispatcharr's robust plugin system. Automate tasks, connect to external services, or add entirely new workflows. --- ## ✨ Why You'll Love Dispatcharr -✅ **Full IPTV Control** — Import, organize, proxy, and monitor IPTV streams on your own terms\ -✅ **Smart Playlist Handling** — M3U import, filtering, grouping, and failover support\ -✅ **VOD Content Management** — Organize movies and TV series with metadata and streaming\ -✅ **Reliable EPG Integration** — Match and manage TV guide data with ease\ -✅ **Clean & Responsive Interface** — Modern design that gets out of your way\ -✅ **Fully Self-Hosted** — Total control, zero reliance on third-party services +✅ **Stream Proxy & Relay** — Intercept and proxy IPTV streams with real-time client management\ +✅ **M3U & Xtream Codes** — Import, filter, and organize playlists with multiple backend support\ +✅ **EPG Matching & Generation** — Auto-match EPG to channels or generate custom TV guides\ +✅ **Video on Demand** — Stream movies and TV series with rich metadata and IMDB/TMDB integration\ +✅ **Multi-Format Output** — Export as M3U, XMLTV EPG, Xtream Codes API, or HDHomeRun device\ +✅ **Real-Time Monitoring** — Live connection stats, bandwidth tracking, and automatic failover\ +✅ **Stream Profiles** — Configure different stream profiles for various clients and bandwidth requirements\ +✅ **Flexible Streaming Backends** — VLC, FFmpeg, Streamlink, or custom backends for transcoding and streaming\ +✅ **Multi-User & Access Control** — Granular permissions and network-based access restrictions\ +✅ **Plugin System** — Extend functionality with custom plugins for automation and integrations\ +✅ **Fully Self-Hosted** — Total control, no third-party dependencies --- - # Screenshots -![image](https://github.com/user-attachments/assets/bf7bc40a-d0e6-4f9f-8029-65b27d4205f9) +
+ Channels + TV Guide + Stats & Monitoring + M3U & EPG Manager + VOD Library + Settings +
-![image](https://github.com/user-attachments/assets/0835fd92-f7dc-4773-bdb7-7f88fd2f882d) +--- -![image](https://github.com/user-attachments/assets/710f2bc4-250f-4161-a6ed-44d5082a30c4) +## 🛠️ Troubleshooting & Help -![image](https://github.com/user-attachments/assets/68a38d78-8f61-4c27-88f8-c52ba93d460d) +- **General help?** Visit [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) +- **Community support?** Join our [Discord](https://discord.gg/Sp45V5BcxU) -![image](https://github.com/user-attachments/assets/63686b9a-6faf-43a3-ae7a-c9e10a216b5b) +--- - - - -# 🚀 Get Started in Minutes +## 🚀 Get Started in Minutes ### 🐳 Quick Start with Docker (Recommended) @@ -72,42 +98,50 @@ docker run -d \ --- -### 🐳 Docker Compose Options +### 🐋 Docker Compose Options -| Use Case | File | Description | -| --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| **All-in-One Deployment** | [docker-compose.aio.yml](docker/docker-compose.aio.yml) | ⭐ Recommended! A simple, all-in-one solution — everything runs in a single container for quick setup. | -| **Modular Deployment** | [docker-compose.yml](docker/docker-compose.yml) | Separate containers for Dispatcharr, Celery, and Postgres — perfect if you want more granular control. | -| **Development Environment** | [docker-compose.dev.yml](docker/docker-compose.dev.yml) | Developer-friendly setup with pre-configured ports and settings for contributing and testing. | +| Use Case | File | Description | +| --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **All-in-One Deployment** | [docker-compose.aio.yml](docker/docker-compose.aio.yml) | ⭐ Recommended! A simple, all-in-one solution — everything runs in a single container for quick setup. | +| **Modular Deployment** | [docker-compose.yml](docker/docker-compose.yml) | Separate containers for Dispatcharr, Celery, Redis, and Postgres — perfect if you want more granular control. | +| **Development Environment** | [docker-compose.dev.yml](docker/docker-compose.dev.yml) | Developer-friendly setup with pre-configured ports and settings for contributing and testing. | --- -### ⚒️ Building from Source (For the Adventurous) +### 🛠️ Building from Source > ⚠️ **Warning**: Not officially supported — but if you're here, you know what you're doing! -If you are running a Debian based operating system you can install using the `debian_install.sh` script. If you are on another operating system and come up with a script let us know! We would love to add it here! +If you are running a Debian-based OS, use the `debian_install.sh` script. For other OS, contribute a script and we’ll add it! --- ## 🤝 Want to Contribute? We welcome **PRs, issues, ideas, and suggestions**!\ -Here’s how you can join the party: +Here's how you can join the party: - Follow our coding style and best practices. - Be respectful, helpful, and open-minded. - Respect the **CC BY-NC-SA license**. -> Whether it’s writing docs, squashing bugs, or building new features, your contribution matters! 🙌 +> Whether it's writing docs, squashing bugs, or building new features, your contribution matters! 🙋 --- -## 📚 Roadmap & Documentation +## 📚 Documentation & Roadmap -- 📚 **Roadmap:** Coming soon! - 📖 **Documentation:** [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) +**Upcoming Features (in no particular order):** + +- 🎬 **VOD Management Enhancements** — Granular metadata control and cleanup of unwanted VOD content +- 📁 **Media Library** — Import local files and serve them over XC API +- 👥 **Enhanced User Management** — Customizable XC API output per user account +- 🔄 **Output Stream Profiles** — Different clients with different stream profiles (bandwidth control, quality tiers) +- 🔌 **Fallback Videos** — Automatic fallback content when channels are unavailable +- 📡 **Webhooks** — Event-driven integrations and automations + --- ## ❤️ Shoutouts @@ -120,7 +154,7 @@ A huge thank you to all the incredible open-source projects and libraries that p > Dispatcharr is licensed under **CC BY-NC-SA 4.0**: -- **BY**: Give credit where credit’s due. +- **BY**: Give credit where credit's due. - **NC**: No commercial use. - **SA**: Share alike if you remix. @@ -131,8 +165,8 @@ For full license details, see [LICENSE](https://creativecommons.org/licenses/by- ## ✉️ Connect With Us Have a question? Want to suggest a feature? Just want to say hi?\ -➡️ **[Open an issue](https://github.com/Dispatcharr/Dispatcharr/issues)** or reach out on [Discord]( https://discord.gg/Sp45V5BcxU). +➡️ **[Open an issue](https://github.com/Dispatcharr/Dispatcharr/issues)** or reach out on [Discord](https://discord.gg/Sp45V5BcxU). --- -### 🚀 *Happy Streaming! The Dispatcharr Team* +### 🚀 _Happy Streaming! The Dispatcharr Team_ diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index ac062841..90aa84fa 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -7,7 +7,7 @@ from .models import User class CustomUserAdmin(UserAdmin): fieldsets = ( (None, {'fields': ('username', 'password', 'avatar_config', 'groups')}), - ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}), + ('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}), ('Important dates', {'fields': ('last_login', 'date_joined')}), ) diff --git a/apps/accounts/api_urls.py b/apps/accounts/api_urls.py index dda3832c..27de8eb7 100644 --- a/apps/accounts/api_urls.py +++ b/apps/accounts/api_urls.py @@ -4,6 +4,7 @@ from .api_views import ( AuthViewSet, UserViewSet, GroupViewSet, + APIKeyViewSet, TokenObtainPairView, TokenRefreshView, list_permissions, @@ -17,6 +18,7 @@ app_name = "accounts" router = DefaultRouter() router.register(r"users", UserViewSet, basename="user") router.register(r"groups", GroupViewSet, basename="group") +router.register(r"api-keys", APIKeyViewSet, basename="api-key") # 🔹 Custom Authentication Endpoints auth_view = AuthViewSet.as_view({"post": "login"}) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 41e2f077..cf2d9225 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -1,13 +1,15 @@ from django.contrib.auth import authenticate, login, logout +import logging from django.contrib.auth.models import Group, Permission from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view, permission_classes, action from rest_framework.response import Response -from rest_framework import viewsets, status -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from rest_framework import viewsets, status, serializers +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes import json +import secrets from .permissions import IsAdmin, Authenticated from dispatcharr.utils import network_access_allowed @@ -15,6 +17,8 @@ from .models import User from .serializers import UserSerializer, GroupSerializer, PermissionSerializer from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +logger = logging.getLogger(__name__) + class TokenObtainPairView(TokenObtainPairView): def post(self, request, *args, **kwargs): @@ -25,6 +29,7 @@ class TokenObtainPairView(TokenObtainPairView): username = request.data.get("username", 'unknown') client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user=username, @@ -43,6 +48,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') try: + logger.debug(f"Attempting JWT login for user={username}") response = super().post(request, *args, **kwargs) # If login was successful, update last_login and log success @@ -61,6 +67,7 @@ class TokenObtainPairView(TokenObtainPairView): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Login success: user={username} ip={client_ip}") except User.DoesNotExist: pass # User doesn't exist, but login somehow succeeded else: @@ -72,6 +79,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent=user_agent, reason='Invalid credentials', ) + logger.info(f"Login failed: user={username} ip={client_ip}") return response @@ -84,6 +92,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent=user_agent, reason=f'Authentication error: {str(e)[:100]}', ) + logger.error(f"Login error for user={username}: {e}") raise # Re-raise the exception to maintain normal error flow @@ -95,6 +104,7 @@ class TokenRefreshView(TokenRefreshView): from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user='token_refresh', @@ -109,8 +119,8 @@ class TokenRefreshView(TokenRefreshView): @csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists. def initialize_superuser(request): - # If a superuser already exists, always indicate that - if User.objects.filter(is_superuser=True).exists(): + # If an admin-level user already exists, the system is configured + if User.objects.filter(user_level__gte=10).exists(): return JsonResponse({"superuser_exists": True}) if request.method == "POST": @@ -147,19 +157,15 @@ class AuthViewSet(viewsets.ViewSet): return [IsAuthenticated()] return [] - @swagger_auto_schema( - operation_description="Authenticate and log in a user", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["username", "password"], - properties={ - "username": openapi.Schema(type=openapi.TYPE_STRING), - "password": openapi.Schema( - type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD - ), + @extend_schema( + description="Authenticate and log in a user", + request=inline_serializer( + name="LoginRequest", + fields={ + "username": serializers.CharField(), + "password": serializers.CharField(), }, ), - responses={200: "Login successful", 400: "Invalid credentials"}, ) def login(self, request): """Logs in a user and returns user details""" @@ -171,6 +177,7 @@ class AuthViewSet(viewsets.ViewSet): from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.debug(f"Login attempt via session: user={username} ip={client_ip}") if user: login(request, user) @@ -186,6 +193,7 @@ class AuthViewSet(viewsets.ViewSet): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Login success via session: user={username} ip={client_ip}") return Response( { @@ -207,11 +215,11 @@ class AuthViewSet(viewsets.ViewSet): user_agent=user_agent, reason='Invalid credentials', ) + logger.info(f"Login failed via session: user={username} ip={client_ip}") return Response({"error": "Invalid credentials"}, status=400) - @swagger_auto_schema( - operation_description="Log out the current user", - responses={200: "Logout successful"}, + @extend_schema( + description="Log out the current user", ) def logout(self, request): """Logs out the authenticated user""" @@ -227,6 +235,7 @@ class AuthViewSet(viewsets.ViewSet): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Logout: user={username} ip={client_ip}") logout(request) return Response({"message": "Logout successful"}) @@ -245,32 +254,31 @@ class UserViewSet(viewsets.ModelViewSet): return [IsAdmin()] - @swagger_auto_schema( - operation_description="Retrieve a list of users", + @extend_schema( + description="Retrieve a list of users", responses={200: UserSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific user by ID") + @extend_schema(description="Retrieve a specific user by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new user") + @extend_schema(description="Create a new user") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a user") + @extend_schema(description="Update a user") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a user") + @extend_schema(description="Delete a user") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) - @swagger_auto_schema( - method="get", - operation_description="Get active user information", + @extend_schema( + description="Get active user information", ) @action(detail=False, methods=["get"], url_path="me") def me(self, request): @@ -287,34 +295,86 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [Authenticated] - @swagger_auto_schema( - operation_description="Retrieve a list of groups", + @extend_schema( + description="Retrieve a list of groups", responses={200: GroupSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific group by ID") + @extend_schema(description="Retrieve a specific group by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new group") + @extend_schema(description="Create a new group") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a group") + @extend_schema(description="Update a group") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a group") + @extend_schema(description="Delete a group") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) +# API Key management +class APIKeyViewSet(viewsets.ViewSet): + permission_classes = [Authenticated] + + def list(self, request): + user = request.user + return Response({"key": user.api_key}) + + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + raw = secrets.token_urlsafe(40) + target_user.api_key = raw + target_user.save(update_fields=["api_key"]) + + user_data = UserSerializer(target_user).data + return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=["post"], url_path="revoke") + def revoke(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + target_user.api_key = None + target_user.save(update_fields=["api_key"]) + + return Response({"success": True}) + + # 🔹 4) Permissions List API -@swagger_auto_schema( - method="get", - operation_description="Retrieve a list of all permissions", +@extend_schema( + description="Retrieve a list of all permissions", responses={200: PermissionSerializer(many=True)}, ) @api_view(["GET"]) diff --git a/apps/accounts/authentication.py b/apps/accounts/authentication.py new file mode 100644 index 00000000..5380af49 --- /dev/null +++ b/apps/accounts/authentication.py @@ -0,0 +1,49 @@ +from rest_framework import authentication +from rest_framework import exceptions +from django.conf import settings +from .models import User + + +class ApiKeyAuthentication(authentication.BaseAuthentication): + """ + Accepts header `Authorization: ApiKey ` or `X-API-Key: `. + """ + + keyword = "ApiKey" + + def authenticate(self, request): + # Check X-API-Key header first + raw_key = request.META.get("HTTP_X_API_KEY") + + if not raw_key: + auth = authentication.get_authorization_header(request).split() + if not auth: + return None + + if len(auth) != 2: + return None + + scheme = auth[0].decode().lower() + if scheme != self.keyword.lower(): + return None + + raw_key = auth[1].decode() + + if not raw_key: + return None + + if not raw_key: + return None + + try: + user = User.objects.get(api_key=raw_key) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid API key") + + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return (user, None) + + def authenticate_header(self, request): + return self.keyword diff --git a/apps/accounts/migrations/0004_user_api_key.py b/apps/accounts/migrations/0004_user_api_key.py new file mode 100644 index 00000000..fee7c983 --- /dev/null +++ b/apps/accounts/migrations/0004_user_api_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-21 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_user_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='api_key', + field=models.CharField(blank=True, db_index=True, max_length=200, null=True), + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index da5e36bc..e04d66ed 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,9 +1,16 @@ # apps/accounts/models.py from django.db import models -from django.contrib.auth.models import AbstractUser, Permission +from django.contrib.auth.models import AbstractUser, Permission, UserManager + + +class CustomUserManager(UserManager): + def create_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('user_level', 10) + return super().create_superuser(username, email, password, **extra_fields) class User(AbstractUser): + objects = CustomUserManager() """ Custom user model for Dispatcharr. Inherits from Django's AbstractUser to add additional fields if needed. @@ -22,6 +29,7 @@ class User(AbstractUser): ) user_level = models.IntegerField(default=UserLevel.STREAMER) custom_properties = models.JSONField(default=dict, blank=True, null=True) + api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True) def __str__(self): return self.username diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 865d29af..05e11ebb 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -28,19 +28,20 @@ class UserSerializer(serializers.ModelSerializer): channel_profiles = serializers.PrimaryKeyRelatedField( queryset=ChannelProfile.objects.all(), many=True, required=False ) + api_key = serializers.CharField(read_only=True, allow_null=True) class Meta: model = User fields = [ "id", "username", + "api_key", "email", "user_level", "password", "channel_profiles", "custom_properties", "avatar_config", - "is_active", "is_staff", "is_superuser", "last_login", @@ -54,7 +55,6 @@ class UserSerializer(serializers.ModelSerializer): user = User(**validated_data) user.set_password(validated_data["password"]) - user.is_active = True user.save() user.channel_profiles.set(channel_profiles) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 00000000..629207ba --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,72 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +User = get_user_model() + + +class InitializeSuperuserTests(TestCase): + """Tests for the initialize_superuser endpoint""" + + def setUp(self): + self.client = APIClient() + self.url = "/api/accounts/initialize-superuser/" + + def test_returns_true_when_superuser_exists(self): + """Superuser with is_superuser=True should be detected""" + User.objects.create_superuser( + username="admin", password="testpass123", user_level=10 + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_true_when_admin_level_user_exists(self): + """User with user_level=10 but is_superuser=False should be detected""" + user = User.objects.create_user(username="admin", password="testpass123") + user.user_level = 10 + user.is_superuser = False + user.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_false_when_no_admin_exists(self): + """No admin or superuser should return false""" + # Create a non-admin user + User.objects.create_user(username="regular", password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_returns_false_when_no_users_exist(self): + """Empty database should return false""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_create_superuser_when_none_exists(self): + """POST should create superuser when none exists""" + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123", "email": "admin@test.com"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists()) + + def test_cannot_create_superuser_when_admin_exists(self): + """POST should fail when an admin-level user already exists""" + user = User.objects.create_user(username="existing", password="testpass123") + user.user_level = 10 + user.save() + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + # Should NOT have created a new user + self.assertFalse(User.objects.filter(username="newadmin").exists()) \ No newline at end of file diff --git a/apps/api/urls.py b/apps/api/urls.py index 7d9edb52..b176bc1b 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,23 +1,8 @@ from django.urls import path, include, re_path -from drf_yasg.views import get_schema_view -from drf_yasg import openapi -from rest_framework.permissions import AllowAny +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView app_name = 'api' -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version='v1', - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="support@dispatcharr.local"), - license=openapi.License(name="Unlicense"), - ), - public=True, - permission_classes=(AllowAny,), -) - urlpatterns = [ path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')), path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')), @@ -27,6 +12,8 @@ urlpatterns = [ path('core/', include(('core.api_urls', 'core'), namespace='core')), path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')), path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')), + path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')), + path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')), # path('output/', include(('apps.output.api_urls', 'output'), namespace='output')), #path('player/', include(('apps.player.api_urls', 'player'), namespace='player')), #path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')), @@ -34,8 +21,9 @@ urlpatterns = [ - # Swagger Documentation api_urls - re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), - path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'), + # OpenAPI Schema and Documentation (drf-spectacular) + path('schema/', SpectacularAPIView.as_view(), name='schema'), + re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'), + path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'), ] diff --git a/apps/backups/__init__.py b/apps/backups/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backups/api_urls.py b/apps/backups/api_urls.py new file mode 100644 index 00000000..226758cc --- /dev/null +++ b/apps/backups/api_urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import api_views + +app_name = "backups" + +urlpatterns = [ + path("", api_views.list_backups, name="backup-list"), + path("create/", api_views.create_backup, name="backup-create"), + path("upload/", api_views.upload_backup, name="backup-upload"), + path("schedule/", api_views.get_schedule, name="backup-schedule-get"), + path("schedule/update/", api_views.update_schedule, name="backup-schedule-update"), + path("status//", api_views.backup_status, name="backup-status"), + path("/download-token/", api_views.get_download_token, name="backup-download-token"), + path("/download/", api_views.download_backup, name="backup-download"), + path("/delete/", api_views.delete_backup, name="backup-delete"), + path("/restore/", api_views.restore_backup, name="backup-restore"), +] diff --git a/apps/backups/api_views.py b/apps/backups/api_views.py new file mode 100644 index 00000000..a5af495e --- /dev/null +++ b/apps/backups/api_views.py @@ -0,0 +1,365 @@ +import hashlib +import hmac +import logging +import os +from pathlib import Path + +from celery.result import AsyncResult +from django.conf import settings +from django.http import HttpResponse, StreamingHttpResponse, Http404 +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes, parser_classes +from rest_framework.permissions import AllowAny +from apps.accounts.permissions import IsAdmin +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response + +from . import services +from .tasks import create_backup_task, restore_backup_task +from .scheduler import get_schedule_settings, update_schedule_settings + +logger = logging.getLogger(__name__) + + +def _generate_task_token(task_id: str) -> str: + """Generate a signed token for task status access without auth.""" + secret = settings.SECRET_KEY.encode() + return hmac.new(secret, task_id.encode(), hashlib.sha256).hexdigest()[:32] + + +def _verify_task_token(task_id: str, token: str) -> bool: + """Verify a task token is valid.""" + expected = _generate_task_token(task_id) + return hmac.compare_digest(expected, token) + + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def list_backups(request): + """List all available backup files.""" + try: + backups = services.list_backups() + return Response(backups, status=status.HTTP_200_OK) + except Exception as e: + return Response( + {"detail": f"Failed to list backups: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def create_backup(request): + """Create a new backup (async via Celery).""" + try: + task = create_backup_task.delay() + return Response( + { + "detail": "Backup started", + "task_id": task.id, + "task_token": _generate_task_token(task.id), + }, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + return Response( + {"detail": f"Failed to start backup: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def backup_status(request, task_id): + """Check the status of a backup/restore task. + + Requires either: + - Valid admin authentication, OR + - Valid task_token query parameter + """ + # Check for token-based auth (for restore when session is invalidated) + token = request.query_params.get("token") + if token: + if not _verify_task_token(task_id, token): + return Response( + {"detail": "Invalid task token"}, + status=status.HTTP_403_FORBIDDEN, + ) + else: + # Fall back to admin auth check + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: + return Response( + {"detail": "Authentication required"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + try: + result = AsyncResult(task_id) + + if result.ready(): + task_result = result.get() + if task_result.get("status") == "completed": + return Response({ + "state": "completed", + "result": task_result, + }) + else: + return Response({ + "state": "failed", + "error": task_result.get("error", "Unknown error"), + }) + elif result.failed(): + return Response({ + "state": "failed", + "error": str(result.result), + }) + else: + return Response({ + "state": result.state.lower(), + }) + except Exception as e: + return Response( + {"detail": f"Failed to get task status: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def get_download_token(request, filename): + """Get a signed token for downloading a backup file.""" + try: + # Security: prevent path traversal + if ".." in filename or "/" in filename or "\\" in filename: + raise Http404("Invalid filename") + + backup_dir = services.get_backup_dir() + backup_file = backup_dir / filename + + if not backup_file.exists(): + raise Http404("Backup file not found") + + token = _generate_task_token(filename) + return Response({"token": token}) + except Http404: + raise + except Exception as e: + return Response( + {"detail": f"Failed to generate token: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def download_backup(request, filename): + """Download a backup file. + + Requires either: + - Valid admin authentication, OR + - Valid download_token query parameter + """ + # Check for token-based auth (avoids CORS preflight issues) + token = request.query_params.get("token") + if token: + if not _verify_task_token(filename, token): + return Response( + {"detail": "Invalid download token"}, + status=status.HTTP_403_FORBIDDEN, + ) + else: + # Fall back to admin auth check + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: + return Response( + {"detail": "Authentication required"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + try: + # Security: prevent path traversal by checking for suspicious characters + if ".." in filename or "/" in filename or "\\" in filename: + raise Http404("Invalid filename") + + backup_dir = services.get_backup_dir() + backup_file = (backup_dir / filename).resolve() + + # Security: ensure the resolved path is still within backup_dir + if not str(backup_file).startswith(str(backup_dir.resolve())): + raise Http404("Invalid filename") + + if not backup_file.exists() or not backup_file.is_file(): + raise Http404("Backup file not found") + + file_size = backup_file.stat().st_size + + # Use X-Accel-Redirect for nginx (AIO container) - nginx serves file directly + # Fall back to streaming for non-nginx deployments + use_nginx_accel = os.environ.get("USE_NGINX_ACCEL", "").lower() == "true" + logger.info(f"[DOWNLOAD] File: {filename}, Size: {file_size}, USE_NGINX_ACCEL: {use_nginx_accel}") + + if use_nginx_accel: + # X-Accel-Redirect: Django returns immediately, nginx serves file + logger.info(f"[DOWNLOAD] Using X-Accel-Redirect: /protected-backups/{filename}") + response = HttpResponse() + response["X-Accel-Redirect"] = f"/protected-backups/{filename}" + response["Content-Type"] = "application/zip" + response["Content-Length"] = file_size + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + else: + # Streaming fallback for non-nginx deployments + logger.info(f"[DOWNLOAD] Using streaming fallback (no nginx)") + def file_iterator(file_path, chunk_size=2 * 1024 * 1024): + with open(file_path, "rb") as f: + while chunk := f.read(chunk_size): + yield chunk + + response = StreamingHttpResponse( + file_iterator(backup_file), + content_type="application/zip", + ) + response["Content-Length"] = file_size + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + except Http404: + raise + except Exception as e: + return Response( + {"detail": f"Download failed: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["DELETE"]) +@permission_classes([IsAdmin]) +def delete_backup(request, filename): + """Delete a backup file.""" + try: + # Security: prevent path traversal + if ".." in filename or "/" in filename or "\\" in filename: + raise Http404("Invalid filename") + + services.delete_backup(filename) + return Response( + {"detail": "Backup deleted successfully"}, + status=status.HTTP_204_NO_CONTENT, + ) + except FileNotFoundError: + raise Http404("Backup file not found") + except Exception as e: + return Response( + {"detail": f"Delete failed: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["POST"]) +@permission_classes([IsAdmin]) +@parser_classes([MultiPartParser, FormParser]) +def upload_backup(request): + """Upload a backup file for restoration.""" + uploaded = request.FILES.get("file") + if not uploaded: + return Response( + {"detail": "No file uploaded"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + backup_dir = services.get_backup_dir() + filename = uploaded.name or "uploaded-backup.zip" + + # Ensure unique filename + backup_file = backup_dir / filename + counter = 1 + while backup_file.exists(): + name_parts = filename.rsplit(".", 1) + if len(name_parts) == 2: + backup_file = backup_dir / f"{name_parts[0]}-{counter}.{name_parts[1]}" + else: + backup_file = backup_dir / f"{filename}-{counter}" + counter += 1 + + # Save uploaded file + with backup_file.open("wb") as f: + for chunk in uploaded.chunks(): + f.write(chunk) + + return Response( + { + "detail": "Backup uploaded successfully", + "filename": backup_file.name, + }, + status=status.HTTP_201_CREATED, + ) + except Exception as e: + return Response( + {"detail": f"Upload failed: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def restore_backup(request, filename): + """Restore from a backup file (async via Celery). WARNING: This will flush the database!""" + try: + # Security: prevent path traversal + if ".." in filename or "/" in filename or "\\" in filename: + raise Http404("Invalid filename") + + backup_dir = services.get_backup_dir() + backup_file = backup_dir / filename + + if not backup_file.exists(): + raise Http404("Backup file not found") + + task = restore_backup_task.delay(filename) + return Response( + { + "detail": "Restore started", + "task_id": task.id, + "task_token": _generate_task_token(task.id), + }, + status=status.HTTP_202_ACCEPTED, + ) + except Http404: + raise + except Exception as e: + return Response( + {"detail": f"Failed to start restore: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def get_schedule(request): + """Get backup schedule settings.""" + try: + settings = get_schedule_settings() + return Response(settings) + except Exception as e: + return Response( + {"detail": f"Failed to get schedule: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@api_view(["PUT"]) +@permission_classes([IsAdmin]) +def update_schedule(request): + """Update backup schedule settings.""" + try: + settings = update_schedule_settings(request.data) + return Response(settings) + except ValueError as e: + return Response( + {"detail": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + return Response( + {"detail": f"Failed to update schedule: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apps/backups/apps.py b/apps/backups/apps.py new file mode 100644 index 00000000..d22ffb29 --- /dev/null +++ b/apps/backups/apps.py @@ -0,0 +1,40 @@ +import logging + +from django.apps import AppConfig + +logger = logging.getLogger(__name__) + + +class BackupsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.backups" + verbose_name = "Backups" + + def ready(self): + """Initialize backup scheduler on app startup.""" + from dispatcharr.app_initialization import should_skip_initialization + + # Skip if this is a management command, worker process, or dev server + if should_skip_initialization(): + return + + logger.debug("Syncing backup scheduler on app startup") + self._sync_backup_scheduler() + + def _sync_backup_scheduler(self): + """Sync backup scheduler task to database.""" + from core.models import CoreSettings + from .scheduler import _sync_periodic_task, DEFAULTS + try: + # Ensure settings exist with defaults if this is a new install + CoreSettings.objects.get_or_create( + key="backup_settings", + defaults={"name": "Backup Settings", "value": DEFAULTS.copy()} + ) + + # Always sync the periodic task (handles new installs, updates, or missing tasks) + logger.debug("Syncing backup scheduler") + _sync_periodic_task() + except Exception as e: + # Log but don't fail startup if there's an issue + logger.warning(f"Failed to initialize backup scheduler: {e}") diff --git a/apps/backups/migrations/__init__.py b/apps/backups/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backups/models.py b/apps/backups/models.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backups/scheduler.py b/apps/backups/scheduler.py new file mode 100644 index 00000000..a427757d --- /dev/null +++ b/apps/backups/scheduler.py @@ -0,0 +1,133 @@ +import json +import logging + +from django_celery_beat.models import PeriodicTask + +from core.models import CoreSettings +from core.scheduling import ( + create_or_update_periodic_task, + delete_periodic_task, +) + +logger = logging.getLogger(__name__) + +BACKUP_SCHEDULE_TASK_NAME = "backup-scheduled-task" + +DEFAULTS = { + "schedule_enabled": True, + "schedule_frequency": "daily", + "schedule_time": "03:00", + "schedule_day_of_week": 0, # Sunday + "retention_count": 3, + "schedule_cron_expression": "", +} + + +def _get_backup_settings(): + """Get all backup settings from CoreSettings grouped JSON.""" + try: + settings_obj = CoreSettings.objects.get(key="backup_settings") + return settings_obj.value if isinstance(settings_obj.value, dict) else DEFAULTS.copy() + except CoreSettings.DoesNotExist: + return DEFAULTS.copy() + + +def _update_backup_settings(updates: dict) -> None: + """Update backup settings in the grouped JSON.""" + obj, created = CoreSettings.objects.get_or_create( + key="backup_settings", + defaults={"name": "Backup Settings", "value": DEFAULTS.copy()} + ) + current = obj.value if isinstance(obj.value, dict) else {} + current.update(updates) + obj.value = current + obj.save() + + +def get_schedule_settings() -> dict: + """Get all backup schedule settings.""" + settings = _get_backup_settings() + return { + "enabled": bool(settings.get("schedule_enabled", DEFAULTS["schedule_enabled"])), + "frequency": str(settings.get("schedule_frequency", DEFAULTS["schedule_frequency"])), + "time": str(settings.get("schedule_time", DEFAULTS["schedule_time"])), + "day_of_week": int(settings.get("schedule_day_of_week", DEFAULTS["schedule_day_of_week"])), + "retention_count": int(settings.get("retention_count", DEFAULTS["retention_count"])), + "cron_expression": str(settings.get("schedule_cron_expression", DEFAULTS["schedule_cron_expression"])), + } + + +def update_schedule_settings(data: dict) -> dict: + """Update backup schedule settings and sync the PeriodicTask.""" + # Validate + if "frequency" in data and data["frequency"] not in ("daily", "weekly"): + raise ValueError("frequency must be 'daily' or 'weekly'") + + if "time" in data: + try: + hour, minute = data["time"].split(":") + int(hour) + int(minute) + except (ValueError, AttributeError): + raise ValueError("time must be in HH:MM format") + + if "day_of_week" in data: + day = int(data["day_of_week"]) + if day < 0 or day > 6: + raise ValueError("day_of_week must be 0-6 (Sunday-Saturday)") + + if "retention_count" in data: + count = int(data["retention_count"]) + if count < 0: + raise ValueError("retention_count must be >= 0") + + # Update settings with proper key names + updates = {} + if "enabled" in data: + updates["schedule_enabled"] = bool(data["enabled"]) + if "frequency" in data: + updates["schedule_frequency"] = str(data["frequency"]) + if "time" in data: + updates["schedule_time"] = str(data["time"]) + if "day_of_week" in data: + updates["schedule_day_of_week"] = int(data["day_of_week"]) + if "retention_count" in data: + updates["retention_count"] = int(data["retention_count"]) + if "cron_expression" in data: + updates["schedule_cron_expression"] = str(data["cron_expression"]) + + _update_backup_settings(updates) + + # Sync the periodic task + _sync_periodic_task() + + return get_schedule_settings() + + +def _sync_periodic_task() -> None: + """Create, update, or delete the scheduled backup task based on settings.""" + settings = get_schedule_settings() + + if not settings["enabled"]: + delete_periodic_task(BACKUP_SCHEDULE_TASK_NAME) + logger.info("Backup schedule disabled, removed periodic task") + return + + # Check if using cron expression (advanced mode) + if settings["cron_expression"]: + cron_expr = settings["cron_expression"] + else: + # Build a cron expression from simple frequency settings + hour, minute = settings["time"].split(":") + if settings["frequency"] == "daily": + cron_expr = f"{minute} {hour} * * *" + else: # weekly + cron_expr = f"{minute} {hour} * * {settings['day_of_week']}" + + create_or_update_periodic_task( + task_name=BACKUP_SCHEDULE_TASK_NAME, + celery_task_path="apps.backups.tasks.scheduled_backup_task", + kwargs={"retention_count": settings["retention_count"]}, + cron_expression=cron_expr, + enabled=True, + ) diff --git a/apps/backups/services.py b/apps/backups/services.py new file mode 100644 index 00000000..b638e701 --- /dev/null +++ b/apps/backups/services.py @@ -0,0 +1,350 @@ +import datetime +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from zipfile import ZipFile, ZIP_DEFLATED +import logging +import pytz + +from django.conf import settings +from core.models import CoreSettings + +logger = logging.getLogger(__name__) + + +def get_backup_dir() -> Path: + """Get the backup directory, creating it if necessary.""" + backup_dir = Path(settings.BACKUP_ROOT) + backup_dir.mkdir(parents=True, exist_ok=True) + return backup_dir + + +def _is_postgresql() -> bool: + """Check if we're using PostgreSQL.""" + return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql" + + +def _get_pg_env() -> dict: + """Get environment variables for PostgreSQL commands.""" + db_config = settings.DATABASES["default"] + env = os.environ.copy() + env["PGPASSWORD"] = db_config.get("PASSWORD", "") + return env + + +def _get_pg_args() -> list[str]: + """Get common PostgreSQL command arguments.""" + db_config = settings.DATABASES["default"] + return [ + "-h", db_config.get("HOST", "localhost"), + "-p", str(db_config.get("PORT", 5432)), + "-U", db_config.get("USER", "postgres"), + "-d", db_config.get("NAME", "dispatcharr"), + ] + + +def _dump_postgresql(output_file: Path) -> None: + """Dump PostgreSQL database using pg_dump.""" + logger.info("Dumping PostgreSQL database with pg_dump...") + + cmd = [ + "pg_dump", + *_get_pg_args(), + "-Fc", # Custom format for pg_restore + "-v", # Verbose + "-f", str(output_file), + ] + + result = subprocess.run( + cmd, + env=_get_pg_env(), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + logger.error(f"pg_dump failed: {result.stderr}") + raise RuntimeError(f"pg_dump failed: {result.stderr}") + + logger.debug(f"pg_dump output: {result.stderr}") + + +def _clean_postgresql_schema() -> None: + """Drop and recreate the public schema to ensure a completely clean restore.""" + logger.info("[PG_CLEAN] Dropping and recreating public schema...") + + # Commands to drop and recreate schema + sql_commands = "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO public;" + + cmd = [ + "psql", + *_get_pg_args(), + "-c", sql_commands, + ] + + result = subprocess.run( + cmd, + env=_get_pg_env(), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + logger.error(f"[PG_CLEAN] Failed to clean schema: {result.stderr}") + raise RuntimeError(f"Failed to clean PostgreSQL schema: {result.stderr}") + + logger.info("[PG_CLEAN] Schema cleaned successfully") + + +def _restore_postgresql(dump_file: Path) -> None: + """Restore PostgreSQL database using pg_restore.""" + logger.info("[PG_RESTORE] Starting pg_restore...") + logger.info(f"[PG_RESTORE] Dump file: {dump_file}") + + # Drop and recreate schema to ensure a completely clean restore + _clean_postgresql_schema() + + pg_args = _get_pg_args() + logger.info(f"[PG_RESTORE] Connection args: {pg_args}") + + cmd = [ + "pg_restore", + "--no-owner", # Skip ownership commands (we already created schema) + *pg_args, + "-v", # Verbose + str(dump_file), + ] + + logger.info(f"[PG_RESTORE] Running command: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + env=_get_pg_env(), + capture_output=True, + text=True, + ) + + logger.info(f"[PG_RESTORE] Return code: {result.returncode}") + + # pg_restore may return non-zero even on partial success + # Check for actual errors vs warnings + if result.returncode != 0: + # Some errors during restore are expected (e.g., "does not exist" when cleaning) + # Only fail on critical errors + stderr = result.stderr.lower() + if "fatal" in stderr or "could not connect" in stderr: + logger.error(f"[PG_RESTORE] Failed critically: {result.stderr}") + raise RuntimeError(f"pg_restore failed: {result.stderr}") + else: + logger.warning(f"[PG_RESTORE] Completed with warnings: {result.stderr[:500]}...") + + logger.info("[PG_RESTORE] Completed successfully") + + +def _dump_sqlite(output_file: Path) -> None: + """Dump SQLite database using sqlite3 .backup command.""" + logger.info("Dumping SQLite database with sqlite3 .backup...") + db_path = Path(settings.DATABASES["default"]["NAME"]) + + if not db_path.exists(): + raise FileNotFoundError(f"SQLite database not found: {db_path}") + + # Use sqlite3 .backup command via stdin for reliable execution + result = subprocess.run( + ["sqlite3", str(db_path)], + input=f".backup '{output_file}'\n", + capture_output=True, + text=True, + ) + + if result.returncode != 0: + logger.error(f"sqlite3 backup failed: {result.stderr}") + raise RuntimeError(f"sqlite3 backup failed: {result.stderr}") + + # Verify the backup file was created + if not output_file.exists(): + raise RuntimeError("sqlite3 backup failed: output file not created") + + logger.info(f"sqlite3 backup completed successfully: {output_file}") + + +def _restore_sqlite(dump_file: Path) -> None: + """Restore SQLite database by replacing the database file.""" + logger.info("Restoring SQLite database...") + db_path = Path(settings.DATABASES["default"]["NAME"]) + backup_current = None + + # Backup current database before overwriting + if db_path.exists(): + backup_current = db_path.with_suffix(".db.bak") + shutil.copy2(db_path, backup_current) + logger.info(f"Backed up current database to {backup_current}") + + # Ensure parent directory exists + db_path.parent.mkdir(parents=True, exist_ok=True) + + # The backup file from _dump_sqlite is a complete SQLite database file + # We can simply copy it over the existing database + shutil.copy2(dump_file, db_path) + + # Verify the restore worked by checking if sqlite3 can read it + result = subprocess.run( + ["sqlite3", str(db_path)], + input=".tables\n", + capture_output=True, + text=True, + ) + + if result.returncode != 0: + logger.error(f"sqlite3 verification failed: {result.stderr}") + # Try to restore from backup + if backup_current and backup_current.exists(): + shutil.copy2(backup_current, db_path) + logger.info("Restored original database from backup") + raise RuntimeError(f"sqlite3 restore verification failed: {result.stderr}") + + logger.info("sqlite3 restore completed successfully") + + +def create_backup() -> Path: + """ + Create a backup archive containing database dump and data directories. + Returns the path to the created backup file. + """ + backup_dir = get_backup_dir() + + # Use system timezone for filename (user-friendly), but keep internal timestamps as UTC + system_tz_name = CoreSettings.get_system_time_zone() + try: + system_tz = pytz.timezone(system_tz_name) + now_local = datetime.datetime.now(datetime.UTC).astimezone(system_tz) + timestamp = now_local.strftime("%Y.%m.%d.%H.%M.%S") + except Exception as e: + logger.warning(f"Failed to use system timezone {system_tz_name}: {e}, falling back to UTC") + timestamp = datetime.datetime.now(datetime.UTC).strftime("%Y.%m.%d.%H.%M.%S") + + backup_name = f"dispatcharr-backup-{timestamp}.zip" + backup_file = backup_dir / backup_name + + logger.info(f"Creating backup: {backup_name}") + + with tempfile.TemporaryDirectory(prefix="dispatcharr-backup-") as temp_dir: + temp_path = Path(temp_dir) + + # Determine database type and dump accordingly + if _is_postgresql(): + db_dump_file = temp_path / "database.dump" + _dump_postgresql(db_dump_file) + db_type = "postgresql" + else: + db_dump_file = temp_path / "database.sqlite3" + _dump_sqlite(db_dump_file) + db_type = "sqlite" + + # Create ZIP archive with compression and ZIP64 support for large files + with ZipFile(backup_file, "w", compression=ZIP_DEFLATED, allowZip64=True) as zip_file: + # Add database dump + zip_file.write(db_dump_file, db_dump_file.name) + + # Add metadata + metadata = { + "format": "dispatcharr-backup", + "version": 2, + "database_type": db_type, + "database_file": db_dump_file.name, + "created_at": datetime.datetime.now(datetime.UTC).isoformat(), + } + zip_file.writestr("metadata.json", json.dumps(metadata, indent=2)) + + logger.info(f"Backup created successfully: {backup_file}") + return backup_file + + +def restore_backup(backup_file: Path) -> None: + """ + Restore from a backup archive. + WARNING: This will overwrite the database! + """ + if not backup_file.exists(): + raise FileNotFoundError(f"Backup file not found: {backup_file}") + + logger.info(f"Restoring from backup: {backup_file}") + + with tempfile.TemporaryDirectory(prefix="dispatcharr-restore-") as temp_dir: + temp_path = Path(temp_dir) + + # Extract backup + logger.debug("Extracting backup archive...") + with ZipFile(backup_file, "r") as zip_file: + zip_file.extractall(temp_path) + + # Read metadata + metadata_file = temp_path / "metadata.json" + if not metadata_file.exists(): + raise ValueError("Invalid backup: missing metadata.json") + + with open(metadata_file) as f: + metadata = json.load(f) + + # Restore database + _restore_database(temp_path, metadata) + + logger.info("Restore completed successfully") + + +def _restore_database(temp_path: Path, metadata: dict) -> None: + """Restore database from backup.""" + db_type = metadata.get("database_type", "postgresql") + db_file = metadata.get("database_file", "database.dump") + dump_file = temp_path / db_file + + if not dump_file.exists(): + raise ValueError(f"Invalid backup: missing {db_file}") + + current_db_type = "postgresql" if _is_postgresql() else "sqlite" + + if db_type != current_db_type: + raise ValueError( + f"Database type mismatch: backup is {db_type}, " + f"but current database is {current_db_type}" + ) + + if db_type == "postgresql": + _restore_postgresql(dump_file) + else: + _restore_sqlite(dump_file) + + +def list_backups() -> list[dict]: + """List all available backup files with metadata.""" + backup_dir = get_backup_dir() + backups = [] + + for backup_file in sorted(backup_dir.glob("dispatcharr-backup-*.zip"), reverse=True): + # Use UTC timezone so frontend can convert to user's local time + created_time = datetime.datetime.fromtimestamp(backup_file.stat().st_mtime, datetime.UTC) + backups.append({ + "name": backup_file.name, + "size": backup_file.stat().st_size, + "created": created_time.isoformat(), + }) + + return backups + + +def delete_backup(filename: str) -> None: + """Delete a backup file.""" + backup_dir = get_backup_dir() + backup_file = backup_dir / filename + + if not backup_file.exists(): + raise FileNotFoundError(f"Backup file not found: {filename}") + + if not backup_file.is_file(): + raise ValueError(f"Invalid backup file: {filename}") + + backup_file.unlink() + logger.info(f"Deleted backup: {filename}") diff --git a/apps/backups/tasks.py b/apps/backups/tasks.py new file mode 100644 index 00000000..f531fef8 --- /dev/null +++ b/apps/backups/tasks.py @@ -0,0 +1,106 @@ +import logging +import traceback +from celery import shared_task + +from . import services + +logger = logging.getLogger(__name__) + + +def _cleanup_old_backups(retention_count: int) -> int: + """Delete old backups, keeping only the most recent N. Returns count deleted.""" + if retention_count <= 0: + return 0 + + backups = services.list_backups() + if len(backups) <= retention_count: + return 0 + + # Backups are sorted newest first, so delete from the end + to_delete = backups[retention_count:] + deleted = 0 + + for backup in to_delete: + try: + services.delete_backup(backup["name"]) + deleted += 1 + logger.info(f"[CLEANUP] Deleted old backup: {backup['name']}") + except Exception as e: + logger.error(f"[CLEANUP] Failed to delete {backup['name']}: {e}") + + return deleted + + +@shared_task(bind=True) +def create_backup_task(self): + """Celery task to create a backup asynchronously.""" + try: + logger.info(f"[BACKUP] Starting backup task {self.request.id}") + backup_file = services.create_backup() + logger.info(f"[BACKUP] Task {self.request.id} completed: {backup_file.name}") + return { + "status": "completed", + "filename": backup_file.name, + "size": backup_file.stat().st_size, + } + except Exception as e: + logger.error(f"[BACKUP] Task {self.request.id} failed: {str(e)}") + logger.error(f"[BACKUP] Traceback: {traceback.format_exc()}") + return { + "status": "failed", + "error": str(e), + } + + +@shared_task(bind=True) +def restore_backup_task(self, filename: str): + """Celery task to restore a backup asynchronously.""" + try: + logger.info(f"[RESTORE] Starting restore task {self.request.id} for {filename}") + backup_dir = services.get_backup_dir() + backup_file = backup_dir / filename + logger.info(f"[RESTORE] Backup file path: {backup_file}") + services.restore_backup(backup_file) + logger.info(f"[RESTORE] Task {self.request.id} completed successfully") + return { + "status": "completed", + "filename": filename, + } + except Exception as e: + logger.error(f"[RESTORE] Task {self.request.id} failed: {str(e)}") + logger.error(f"[RESTORE] Traceback: {traceback.format_exc()}") + return { + "status": "failed", + "error": str(e), + } + + +@shared_task(bind=True) +def scheduled_backup_task(self, retention_count: int = 0): + """Celery task for scheduled backups with optional retention cleanup.""" + try: + logger.info(f"[SCHEDULED] Starting scheduled backup task {self.request.id}") + + # Create backup + backup_file = services.create_backup() + logger.info(f"[SCHEDULED] Backup created: {backup_file.name}") + + # Cleanup old backups if retention is set + deleted = 0 + if retention_count > 0: + deleted = _cleanup_old_backups(retention_count) + logger.info(f"[SCHEDULED] Cleanup complete, deleted {deleted} old backup(s)") + + return { + "status": "completed", + "filename": backup_file.name, + "size": backup_file.stat().st_size, + "deleted_count": deleted, + } + except Exception as e: + logger.error(f"[SCHEDULED] Task {self.request.id} failed: {str(e)}") + logger.error(f"[SCHEDULED] Traceback: {traceback.format_exc()}") + return { + "status": "failed", + "error": str(e), + } diff --git a/apps/backups/tests.py b/apps/backups/tests.py new file mode 100644 index 00000000..e5743623 --- /dev/null +++ b/apps/backups/tests.py @@ -0,0 +1,1252 @@ +import json +import tempfile +from io import BytesIO +from pathlib import Path +from zipfile import ZipFile +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from . import services + +User = get_user_model() + + +class BackupServicesTestCase(TestCase): + """Test cases for backup services""" + + def setUp(self): + self.temp_backup_dir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.services.settings') + def test_get_backup_dir_creates_directory(self, mock_settings): + """Test that get_backup_dir creates the directory if it doesn't exist""" + mock_settings.BACKUP_ROOT = self.temp_backup_dir + + with patch('apps.backups.services.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path_instance.mkdir = MagicMock() + mock_path.return_value = mock_path_instance + + services.get_backup_dir() + mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._dump_sqlite') + def test_create_backup_success_sqlite(self, mock_dump_sqlite, mock_is_pg, mock_get_backup_dir): + """Test successful backup creation with SQLite""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + mock_is_pg.return_value = False + + # Mock SQLite dump to create a temp file + def mock_dump(output_file): + output_file.write_text("sqlite dump") + + mock_dump_sqlite.side_effect = mock_dump + + result = services.create_backup() + + self.assertIsInstance(result, Path) + self.assertTrue(result.exists()) + self.assertTrue(result.name.startswith('dispatcharr-backup-')) + self.assertTrue(result.name.endswith('.zip')) + + # Verify the backup contains expected files + with ZipFile(result, 'r') as zf: + names = zf.namelist() + self.assertIn('database.sqlite3', names) + self.assertIn('metadata.json', names) + + # Check metadata + metadata = json.loads(zf.read('metadata.json')) + self.assertEqual(metadata['version'], 2) + self.assertEqual(metadata['database_type'], 'sqlite') + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._dump_postgresql') + def test_create_backup_success_postgresql(self, mock_dump_pg, mock_is_pg, mock_get_backup_dir): + """Test successful backup creation with PostgreSQL""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + mock_is_pg.return_value = True + + # Mock PostgreSQL dump to create a temp file + def mock_dump(output_file): + output_file.write_bytes(b"pg dump data") + + mock_dump_pg.side_effect = mock_dump + + result = services.create_backup() + + self.assertIsInstance(result, Path) + self.assertTrue(result.exists()) + + # Verify the backup contains expected files + with ZipFile(result, 'r') as zf: + names = zf.namelist() + self.assertIn('database.dump', names) + self.assertIn('metadata.json', names) + + # Check metadata + metadata = json.loads(zf.read('metadata.json')) + self.assertEqual(metadata['version'], 2) + self.assertEqual(metadata['database_type'], 'postgresql') + + @patch('apps.backups.services.get_backup_dir') + def test_list_backups_empty(self, mock_get_backup_dir): + """Test listing backups when none exist""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + result = services.list_backups() + + self.assertEqual(result, []) + + @patch('apps.backups.services.get_backup_dir') + def test_list_backups_with_files(self, mock_get_backup_dir): + """Test listing backups with existing backup files""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a fake backup file + test_backup = backup_dir / "dispatcharr-backup-2025.01.01.12.00.00.zip" + test_backup.write_text("fake backup content") + + result = services.list_backups() + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['name'], test_backup.name) + self.assertIn('size', result[0]) + self.assertIn('created', result[0]) + + @patch('apps.backups.services.get_backup_dir') + def test_delete_backup_success(self, mock_get_backup_dir): + """Test successful backup deletion""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a fake backup file + test_backup = backup_dir / "dispatcharr-backup-test.zip" + test_backup.write_text("fake backup content") + + self.assertTrue(test_backup.exists()) + + services.delete_backup(test_backup.name) + + self.assertFalse(test_backup.exists()) + + @patch('apps.backups.services.get_backup_dir') + def test_delete_backup_not_found(self, mock_get_backup_dir): + """Test deleting a non-existent backup raises error""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + with self.assertRaises(FileNotFoundError): + services.delete_backup("nonexistent-backup.zip") + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._restore_postgresql') + def test_restore_backup_postgresql(self, mock_restore_pg, mock_is_pg, mock_get_backup_dir): + """Test successful restoration of PostgreSQL backup""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = True + + # Create PostgreSQL backup file + backup_file = backup_dir / "test-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.dump', b'pg dump data') + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'postgresql', + 'database_file': 'database.dump' + })) + + services.restore_backup(backup_file) + + mock_restore_pg.assert_called_once() + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._restore_sqlite') + def test_restore_backup_sqlite(self, mock_restore_sqlite, mock_is_pg, mock_get_backup_dir): + """Test successful restoration of SQLite backup""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = False + + # Create SQLite backup file + backup_file = backup_dir / "test-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.sqlite3', 'sqlite data') + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'sqlite', + 'database_file': 'database.sqlite3' + })) + + services.restore_backup(backup_file) + + mock_restore_sqlite.assert_called_once() + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + def test_restore_backup_database_type_mismatch(self, mock_is_pg, mock_get_backup_dir): + """Test restore fails when database type doesn't match""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = True # Current system is PostgreSQL + + # Create SQLite backup file + backup_file = backup_dir / "test-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.sqlite3', 'sqlite data') + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'sqlite', # Backup is SQLite + 'database_file': 'database.sqlite3' + })) + + with self.assertRaises(ValueError) as context: + services.restore_backup(backup_file) + + self.assertIn('mismatch', str(context.exception).lower()) + + def test_restore_backup_not_found(self): + """Test restoring from non-existent backup file""" + fake_path = Path("/tmp/nonexistent-backup-12345.zip") + + with self.assertRaises(FileNotFoundError): + services.restore_backup(fake_path) + + @patch('apps.backups.services.get_backup_dir') + def test_restore_backup_missing_metadata(self, mock_get_backup_dir): + """Test restoring from backup without metadata.json""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a backup file missing metadata.json + backup_file = backup_dir / "invalid-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.dump', b'fake dump data') + + with self.assertRaises(ValueError) as context: + services.restore_backup(backup_file) + + self.assertIn('metadata.json', str(context.exception)) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + def test_restore_backup_missing_database(self, mock_is_pg, mock_get_backup_dir): + """Test restoring from backup missing database dump""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = True + + # Create backup file missing database dump + backup_file = backup_dir / "invalid-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'postgresql', + 'database_file': 'database.dump' + })) + + with self.assertRaises(ValueError) as context: + services.restore_backup(backup_file) + + self.assertIn('database.dump', str(context.exception)) + + +class BackupAPITestCase(TestCase): + """Test cases for backup API endpoints""" + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123' + ) + self.admin_user = User.objects.create_superuser( + username='admin', + email='admin@example.com', + password='adminpass123' + ) + self.temp_backup_dir = tempfile.mkdtemp() + + def get_auth_header(self, user): + """Helper method to get JWT auth header for a user""" + refresh = RefreshToken.for_user(user) + return f'Bearer {str(refresh.access_token)}' + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + def test_list_backups_requires_admin(self): + """Test that listing backups requires admin privileges""" + url = '/api/backups/' + + # Unauthenticated request + response = self.client.get(url) + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.get(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.services.list_backups') + def test_list_backups_success(self, mock_list_backups): + """Test successful backup listing""" + mock_list_backups.return_value = [ + { + 'name': 'backup-test.zip', + 'size': 1024, + 'created': '2025-01-01T12:00:00' + } + ] + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['name'], 'backup-test.zip') + + def test_create_backup_requires_admin(self): + """Test that creating backups requires admin privileges""" + url = '/api/backups/create/' + + # Unauthenticated request + response = self.client.post(url) + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.post(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_create_backup_success(self, mock_create_task): + """Test successful backup creation via API (async task)""" + mock_task = MagicMock() + mock_task.id = 'test-task-id-123' + mock_create_task.return_value = mock_task + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/create/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + data = response.json() + self.assertIn('task_id', data) + self.assertIn('task_token', data) + self.assertEqual(data['task_id'], 'test-task-id-123') + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_create_backup_failure(self, mock_create_task): + """Test backup creation failure handling""" + mock_create_task.side_effect = Exception("Failed to start task") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/create/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn('detail', data) + + @patch('apps.backups.services.get_backup_dir') + def test_download_backup_success(self, mock_get_backup_dir): + """Test successful backup download""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test backup content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/download/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/zip') + + @patch('apps.backups.services.get_backup_dir') + def test_download_backup_not_found(self, mock_get_backup_dir): + """Test downloading non-existent backup""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/download/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + @patch('apps.backups.services.delete_backup') + def test_delete_backup_success(self, mock_delete_backup): + """Test successful backup deletion via API""" + mock_delete_backup.return_value = None + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/delete/' + response = self.client.delete(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 204) + mock_delete_backup.assert_called_once_with('test-backup.zip') + + @patch('apps.backups.services.delete_backup') + def test_delete_backup_not_found(self, mock_delete_backup): + """Test deleting non-existent backup via API""" + mock_delete_backup.side_effect = FileNotFoundError("Not found") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/delete/' + response = self.client.delete(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + def test_upload_backup_requires_file(self): + """Test that upload requires a file""" + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/upload/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertIn('No file uploaded', data['detail']) + + @patch('apps.backups.services.get_backup_dir') + def test_upload_backup_success(self, mock_get_backup_dir): + """Test successful backup upload""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + # Create a fake backup file + fake_backup = BytesIO(b"fake backup content") + fake_backup.name = 'uploaded-backup.zip' + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/upload/' + response = self.client.post(url, {'file': fake_backup}, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertIn('filename', data) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.tasks.restore_backup_task.delay') + def test_restore_backup_success(self, mock_restore_task, mock_get_backup_dir): + """Test successful backup restoration via API (async task)""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + mock_task = MagicMock() + mock_task.id = 'test-restore-task-456' + mock_restore_task.return_value = mock_task + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test backup content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/restore/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + data = response.json() + self.assertIn('task_id', data) + self.assertIn('task_token', data) + self.assertEqual(data['task_id'], 'test-restore-task-456') + + @patch('apps.backups.services.get_backup_dir') + def test_restore_backup_not_found(self, mock_get_backup_dir): + """Test restoring from non-existent backup via API""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/restore/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + # --- Backup Status Endpoint Tests --- + + def test_backup_status_requires_auth_or_token(self): + """Test that backup_status requires auth or valid token""" + url = '/api/backups/status/fake-task-id/' + + # Unauthenticated request without token + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + def test_backup_status_invalid_token(self): + """Test that backup_status rejects invalid tokens""" + url = '/api/backups/status/fake-task-id/?token=invalid-token' + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + @patch('apps.backups.api_views.AsyncResult') + def test_backup_status_with_admin_auth(self, mock_async_result): + """Test backup_status with admin authentication""" + mock_result = MagicMock() + mock_result.ready.return_value = False + mock_result.failed.return_value = False + mock_result.state = 'PENDING' + mock_async_result.return_value = mock_result + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/status/test-task-id/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['state'], 'pending') + + @patch('apps.backups.api_views.AsyncResult') + @patch('apps.backups.api_views._verify_task_token') + def test_backup_status_with_valid_token(self, mock_verify, mock_async_result): + """Test backup_status with valid token""" + mock_verify.return_value = True + mock_result = MagicMock() + mock_result.ready.return_value = True + mock_result.get.return_value = {'status': 'completed', 'filename': 'test.zip'} + mock_async_result.return_value = mock_result + + url = '/api/backups/status/test-task-id/?token=valid-token' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['state'], 'completed') + + @patch('apps.backups.api_views.AsyncResult') + def test_backup_status_task_failed(self, mock_async_result): + """Test backup_status when task failed""" + mock_result = MagicMock() + mock_result.ready.return_value = True + mock_result.get.return_value = {'status': 'failed', 'error': 'Something went wrong'} + mock_async_result.return_value = mock_result + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/status/test-task-id/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['state'], 'failed') + self.assertIn('Something went wrong', data['error']) + + # --- Download Token Endpoint Tests --- + + def test_get_download_token_requires_admin(self): + """Test that get_download_token requires admin privileges""" + url = '/api/backups/test.zip/download-token/' + + response = self.client.get(url) + self.assertIn(response.status_code, [401, 403]) + + response = self.client.get(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.services.get_backup_dir') + def test_get_download_token_success(self, mock_get_backup_dir): + """Test successful download token generation""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/download-token/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn('token', data) + self.assertEqual(len(data['token']), 32) + + @patch('apps.backups.services.get_backup_dir') + def test_get_download_token_not_found(self, mock_get_backup_dir): + """Test download token for non-existent file""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/download-token/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + # --- Download with Token Auth Tests --- + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.api_views._verify_task_token') + def test_download_backup_with_valid_token(self, mock_verify, mock_get_backup_dir): + """Test downloading backup with valid token (no auth header)""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_verify.return_value = True + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test backup content") + + url = '/api/backups/test-backup.zip/download/?token=valid-token' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + + @patch('apps.backups.services.get_backup_dir') + def test_download_backup_invalid_token(self, mock_get_backup_dir): + """Test downloading backup with invalid token""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + url = '/api/backups/test-backup.zip/download/?token=invalid-token' + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.tasks.restore_backup_task.delay') + def test_restore_backup_task_start_failure(self, mock_restore_task, mock_get_backup_dir): + """Test restore task start failure via API""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_restore_task.side_effect = Exception("Failed to start restore task") + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/restore/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn('detail', data) + + def test_get_schedule_requires_admin(self): + """Test that getting schedule requires admin privileges""" + url = '/api/backups/schedule/' + + # Unauthenticated request + response = self.client.get(url) + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.get(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.api_views.get_schedule_settings') + def test_get_schedule_success(self, mock_get_settings): + """Test successful schedule retrieval""" + mock_get_settings.return_value = { + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + 'day_of_week': 0, + 'retention_count': 5, + 'cron_expression': '', + } + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/schedule/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['enabled'], True) + self.assertEqual(data['frequency'], 'daily') + self.assertEqual(data['retention_count'], 5) + + def test_update_schedule_requires_admin(self): + """Test that updating schedule requires admin privileges""" + url = '/api/backups/schedule/update/' + + # Unauthenticated request + response = self.client.put(url, {}, content_type='application/json') + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.put( + url, + {}, + content_type='application/json', + HTTP_AUTHORIZATION=self.get_auth_header(self.user) + ) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.api_views.update_schedule_settings') + def test_update_schedule_success(self, mock_update_settings): + """Test successful schedule update""" + mock_update_settings.return_value = { + 'enabled': True, + 'frequency': 'weekly', + 'time': '02:00', + 'day_of_week': 1, + 'retention_count': 10, + 'cron_expression': '', + } + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/schedule/update/' + response = self.client.put( + url, + {'enabled': True, 'frequency': 'weekly', 'time': '02:00', 'day_of_week': 1, 'retention_count': 10}, + content_type='application/json', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['frequency'], 'weekly') + self.assertEqual(data['day_of_week'], 1) + + @patch('apps.backups.api_views.update_schedule_settings') + def test_update_schedule_validation_error(self, mock_update_settings): + """Test schedule update with invalid data""" + mock_update_settings.side_effect = ValueError("frequency must be 'daily' or 'weekly'") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/schedule/update/' + response = self.client.put( + url, + {'frequency': 'invalid'}, + content_type='application/json', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertIn('frequency', data['detail']) + + +class BackupAdminPermissionTestCase(TestCase): + """Test that backup endpoints use user_level (not is_staff/is_superuser) for admin checks. + + This validates the IsAdminUser -> IsAdmin permission change. + API-created admins have user_level=10 but is_staff=False and is_superuser=False. + """ + + def setUp(self): + self.client = APIClient() + # API-created admin: user_level=10 but NOT is_staff or is_superuser + self.api_admin = User.objects.create_user( + username='api_admin', + email='apiadmin@example.com', + password='testpass123' + ) + self.api_admin.user_level = 10 + self.api_admin.is_staff = False + self.api_admin.is_superuser = False + self.api_admin.save() + + # User with is_staff=True but low user_level (should NOT have access) + self.staff_user = User.objects.create_user( + username='staffuser', + email='staff@example.com', + password='testpass123' + ) + self.staff_user.is_staff = True + self.staff_user.user_level = 1 + self.staff_user.save() + + self.temp_backup_dir = tempfile.mkdtemp() + + def get_auth_header(self, user): + refresh = RefreshToken.for_user(user) + return f'Bearer {str(refresh.access_token)}' + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.services.list_backups') + def test_api_created_admin_can_list_backups(self, mock_list_backups): + """API-created admin (user_level=10, is_staff=False) should access backup endpoints""" + mock_list_backups.return_value = [] + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + + def test_staff_user_without_user_level_cannot_list_backups(self): + """User with is_staff=True but user_level < 10 should NOT access backup endpoints""" + auth_header = self.get_auth_header(self.staff_user) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_api_created_admin_can_create_backup(self, mock_create_task): + """API-created admin should be able to create backups""" + mock_task = MagicMock() + mock_task.id = 'test-task-id' + mock_create_task.return_value = mock_task + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.post('/api/backups/create/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + + @patch('apps.backups.services.get_backup_dir') + def test_api_created_admin_can_delete_backup(self, mock_get_backup_dir): + """API-created admin should be able to delete backups""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.delete( + '/api/backups/test-backup.zip/delete/', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 204) + + +class BackupSchedulerTestCase(TestCase): + """Test cases for backup scheduler""" + + databases = {'default'} + + @classmethod + def setUpClass(cls): + pass + + @classmethod + def tearDownClass(cls): + pass + + def setUp(self): + from core.models import CoreSettings + # Clean up any existing settings + CoreSettings.objects.filter(key__startswith='backup_').delete() + + def tearDown(self): + from core.models import CoreSettings + from django_celery_beat.models import PeriodicTask + CoreSettings.objects.filter(key__startswith='backup_').delete() + PeriodicTask.objects.filter(name='backup-scheduled-task').delete() + + def test_get_schedule_settings_defaults(self): + """Test that get_schedule_settings returns defaults when no settings exist""" + from . import scheduler + + settings = scheduler.get_schedule_settings() + + # These should match the DEFAULTS in scheduler.py + self.assertEqual(settings['enabled'], True) + self.assertEqual(settings['frequency'], 'daily') + self.assertEqual(settings['time'], '03:00') + self.assertEqual(settings['day_of_week'], 0) + self.assertEqual(settings['retention_count'], 3) + self.assertEqual(settings['cron_expression'], '') + + def test_update_schedule_settings_stores_values(self): + """Test that update_schedule_settings stores values correctly""" + from . import scheduler + + result = scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'weekly', + 'time': '04:30', + 'day_of_week': 3, + 'retention_count': 7, + }) + + self.assertEqual(result['enabled'], True) + self.assertEqual(result['frequency'], 'weekly') + self.assertEqual(result['time'], '04:30') + self.assertEqual(result['day_of_week'], 3) + self.assertEqual(result['retention_count'], 7) + + # Verify persistence + settings = scheduler.get_schedule_settings() + self.assertEqual(settings['enabled'], True) + self.assertEqual(settings['frequency'], 'weekly') + + def test_update_schedule_settings_invalid_frequency(self): + """Test that invalid frequency raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'frequency': 'monthly'}) + + self.assertIn('frequency', str(context.exception).lower()) + + def test_update_schedule_settings_invalid_time(self): + """Test that invalid time raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'time': 'invalid'}) + + self.assertIn('HH:MM', str(context.exception)) + + def test_update_schedule_settings_invalid_day_of_week(self): + """Test that invalid day_of_week raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'day_of_week': 7}) + + self.assertIn('day_of_week', str(context.exception).lower()) + + def test_update_schedule_settings_invalid_retention(self): + """Test that negative retention_count raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'retention_count': -1}) + + self.assertIn('retention_count', str(context.exception).lower()) + + def test_sync_creates_periodic_task_when_enabled(self): + """Test that enabling schedule creates a PeriodicTask""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '05:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertTrue(task.enabled) + self.assertEqual(task.crontab.hour, '05') + self.assertEqual(task.crontab.minute, '00') + + def test_sync_deletes_periodic_task_when_disabled(self): + """Test that disabling schedule removes PeriodicTask""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + # First enable + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '05:00', + }) + + self.assertTrue(PeriodicTask.objects.filter(name='backup-scheduled-task').exists()) + + # Then disable + scheduler.update_schedule_settings({'enabled': False}) + + self.assertFalse(PeriodicTask.objects.filter(name='backup-scheduled-task').exists()) + + def test_weekly_schedule_sets_day_of_week(self): + """Test that weekly schedule sets correct day_of_week in crontab""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'weekly', + 'time': '06:00', + 'day_of_week': 3, # Wednesday + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.day_of_week, '3') + + def test_cron_expression_stores_value(self): + """Test that cron_expression is stored and retrieved correctly""" + from . import scheduler + + result = scheduler.update_schedule_settings({ + 'enabled': True, + 'cron_expression': '*/5 * * * *', + }) + + self.assertEqual(result['cron_expression'], '*/5 * * * *') + + # Verify persistence + settings = scheduler.get_schedule_settings() + self.assertEqual(settings['cron_expression'], '*/5 * * * *') + + def test_cron_expression_creates_correct_schedule(self): + """Test that cron expression creates correct CrontabSchedule""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'cron_expression': '*/15 2 * * 1-5', # Every 15 mins during 2 AM hour on weekdays + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.minute, '*/15') + self.assertEqual(task.crontab.hour, '2') + self.assertEqual(task.crontab.day_of_month, '*') + self.assertEqual(task.crontab.month_of_year, '*') + self.assertEqual(task.crontab.day_of_week, '1-5') + + def test_cron_expression_invalid_format(self): + """Test that invalid cron expression raises ValueError""" + from . import scheduler + + # Too few parts + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({ + 'enabled': True, + 'cron_expression': '0 3 *', + }) + self.assertIn('5 parts', str(context.exception)) + + def test_cron_expression_empty_uses_simple_mode(self): + """Test that empty cron_expression falls back to simple frequency mode""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '04:00', + 'cron_expression': '', # Empty, should use simple mode + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.minute, '00') + self.assertEqual(task.crontab.hour, '04') + self.assertEqual(task.crontab.day_of_week, '*') + + def test_cron_expression_overrides_simple_settings(self): + """Test that cron_expression takes precedence over frequency/time""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + 'cron_expression': '0 */6 * * *', # Every 6 hours (should override daily at 3 AM) + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.minute, '0') + self.assertEqual(task.crontab.hour, '*/6') + self.assertEqual(task.crontab.day_of_week, '*') + + def test_periodic_task_uses_system_timezone(self): + """Test that CrontabSchedule is created with the system timezone""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + from core.models import CoreSettings + + original_tz = CoreSettings.get_system_time_zone() + + try: + # Set a non-UTC timezone + CoreSettings.set_system_time_zone('America/New_York') + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(str(task.crontab.timezone), 'America/New_York') + finally: + scheduler.update_schedule_settings({'enabled': False}) + CoreSettings.set_system_time_zone(original_tz) + + def test_periodic_task_timezone_updates_with_schedule(self): + """Test that CrontabSchedule timezone is updated when schedule is modified""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + from core.models import CoreSettings + + original_tz = CoreSettings.get_system_time_zone() + + try: + # Create initial schedule with one timezone + CoreSettings.set_system_time_zone('America/Los_Angeles') + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '02:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(str(task.crontab.timezone), 'America/Los_Angeles') + + # Change system timezone and update schedule + CoreSettings.set_system_time_zone('Europe/London') + scheduler.update_schedule_settings({ + 'enabled': True, + 'time': '04:00', + }) + + task.refresh_from_db() + self.assertEqual(str(task.crontab.timezone), 'Europe/London') + finally: + scheduler.update_schedule_settings({'enabled': False}) + CoreSettings.set_system_time_zone(original_tz) + + def test_orphaned_crontab_cleanup(self): + """Test that old CrontabSchedule is deleted when schedule changes""" + from . import scheduler + from django_celery_beat.models import PeriodicTask, CrontabSchedule + + # Create initial daily schedule + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + first_crontab_id = task.crontab.id + initial_count = CrontabSchedule.objects.count() + + # Change to weekly schedule (different crontab) + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'weekly', + 'day_of_week': 3, + 'time': '03:00', + }) + + task.refresh_from_db() + second_crontab_id = task.crontab.id + + # Verify old crontab was deleted + self.assertNotEqual(first_crontab_id, second_crontab_id) + self.assertFalse(CrontabSchedule.objects.filter(id=first_crontab_id).exists()) + self.assertEqual(CrontabSchedule.objects.count(), initial_count) + + # Cleanup + scheduler.update_schedule_settings({'enabled': False}) + + +class BackupTasksTestCase(TestCase): + """Test cases for backup Celery tasks""" + + def setUp(self): + self.temp_backup_dir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.tasks.services.list_backups') + @patch('apps.backups.tasks.services.delete_backup') + def test_cleanup_old_backups_keeps_recent(self, mock_delete, mock_list): + """Test that cleanup keeps the most recent backups""" + from .tasks import _cleanup_old_backups + + mock_list.return_value = [ + {'name': 'backup-3.zip'}, # newest + {'name': 'backup-2.zip'}, + {'name': 'backup-1.zip'}, # oldest + ] + + deleted = _cleanup_old_backups(retention_count=2) + + self.assertEqual(deleted, 1) + mock_delete.assert_called_once_with('backup-1.zip') + + @patch('apps.backups.tasks.services.list_backups') + @patch('apps.backups.tasks.services.delete_backup') + def test_cleanup_old_backups_does_nothing_when_under_limit(self, mock_delete, mock_list): + """Test that cleanup does nothing when under retention limit""" + from .tasks import _cleanup_old_backups + + mock_list.return_value = [ + {'name': 'backup-2.zip'}, + {'name': 'backup-1.zip'}, + ] + + deleted = _cleanup_old_backups(retention_count=5) + + self.assertEqual(deleted, 0) + mock_delete.assert_not_called() + + @patch('apps.backups.tasks.services.list_backups') + @patch('apps.backups.tasks.services.delete_backup') + def test_cleanup_old_backups_zero_retention_keeps_all(self, mock_delete, mock_list): + """Test that retention_count=0 keeps all backups""" + from .tasks import _cleanup_old_backups + + mock_list.return_value = [ + {'name': 'backup-3.zip'}, + {'name': 'backup-2.zip'}, + {'name': 'backup-1.zip'}, + ] + + deleted = _cleanup_old_backups(retention_count=0) + + self.assertEqual(deleted, 0) + mock_delete.assert_not_called() + + @patch('apps.backups.tasks.services.create_backup') + @patch('apps.backups.tasks._cleanup_old_backups') + def test_scheduled_backup_task_success(self, mock_cleanup, mock_create): + """Test scheduled backup task success""" + from .tasks import scheduled_backup_task + + mock_backup_file = MagicMock() + mock_backup_file.name = 'scheduled-backup.zip' + mock_backup_file.stat.return_value.st_size = 1024 + mock_create.return_value = mock_backup_file + mock_cleanup.return_value = 2 + + result = scheduled_backup_task(retention_count=5) + + self.assertEqual(result['status'], 'completed') + self.assertEqual(result['filename'], 'scheduled-backup.zip') + self.assertEqual(result['size'], 1024) + self.assertEqual(result['deleted_count'], 2) + mock_cleanup.assert_called_once_with(5) + + @patch('apps.backups.tasks.services.create_backup') + @patch('apps.backups.tasks._cleanup_old_backups') + def test_scheduled_backup_task_no_cleanup_when_retention_zero(self, mock_cleanup, mock_create): + """Test scheduled backup skips cleanup when retention is 0""" + from .tasks import scheduled_backup_task + + mock_backup_file = MagicMock() + mock_backup_file.name = 'scheduled-backup.zip' + mock_backup_file.stat.return_value.st_size = 1024 + mock_create.return_value = mock_backup_file + + result = scheduled_backup_task(retention_count=0) + + self.assertEqual(result['status'], 'completed') + self.assertEqual(result['deleted_count'], 0) + mock_cleanup.assert_not_called() + + @patch('apps.backups.tasks.services.create_backup') + def test_scheduled_backup_task_failure(self, mock_create): + """Test scheduled backup task handles failure""" + from .tasks import scheduled_backup_task + + mock_create.side_effect = Exception("Backup failed") + + result = scheduled_backup_task(retention_count=5) + + self.assertEqual(result['status'], 'failed') + self.assertIn('Backup failed', result['error']) diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 7999abd9..bd53ae45 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -47,7 +47,7 @@ urlpatterns = [ path('series-rules/', SeriesRulesAPIView.as_view(), name='series_rules'), path('series-rules/evaluate/', EvaluateSeriesRulesAPIView.as_view(), name='evaluate_series_rules'), path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'), - path('series-rules//', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'), + path('series-rules//', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'), path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'), path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'), ] diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index c48164b0..b38c4690 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -4,11 +4,16 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser, FormParser, JSONParser -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction -import os, json, requests, logging +from django.db.models import Count, F +from django.db.models import Q +import os, json, requests, logging, mimetypes +from django.utils.http import http_date +from urllib.parse import unquote from apps.accounts.permissions import ( Authenticated, IsAdmin, @@ -94,13 +99,14 @@ class StreamFilter(django_filters.FilterSet): channel_group_name = OrInFilter( field_name="channel_group__name", lookup_expr="icontains" ) - m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") + m3u_account = django_filters.BaseInFilter(field_name="m3u_account__id") m3u_account_name = django_filters.CharFilter( field_name="m3u_account__name", lookup_expr="icontains" ) m3u_account_is_active = django_filters.BooleanFilter( field_name="m3u_account__is_active" ) + tvg_id = django_filters.CharFilter(lookup_expr="icontains") class Meta: model = Stream @@ -110,6 +116,7 @@ class StreamFilter(django_filters.FilterSet): "m3u_account", "m3u_account_name", "m3u_account_is_active", + "tvg_id", ] @@ -124,10 +131,12 @@ class StreamViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = StreamFilter search_fields = ["name", "channel_group__name"] - ordering_fields = ["name", "channel_group__name", "m3u_account__name"] + ordering_fields = ["name", "channel_group__name", "m3u_account__name", "tvg_id"] ordering = ["-name"] def get_permissions(self): + if self.action == "duplicate": + return [IsAdmin()] try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: @@ -143,14 +152,20 @@ class StreamViewSet(viewsets.ModelViewSet): qs = qs.filter(channels__id=assigned) unassigned = self.request.query_params.get("unassigned") - if unassigned == "1": - qs = qs.filter(channels__isnull=True) + if unassigned and str(unassigned).lower() in ("1", "true", "yes", "on"): + # Use annotation with Count for better performance on large datasets + qs = qs.annotate(channel_count=Count('channels')).filter(channel_count=0) channel_group = self.request.query_params.get("channel_group") if channel_group: group_names = channel_group.split(",") qs = qs.filter(channel_group__name__in=group_names) + # Allow client to hide stale streams (streams marked as is_stale=True) + hide_stale = self.request.query_params.get("hide_stale") + if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"): + qs = qs.filter(is_stale=False) + return qs def list(self, request, *args, **kwargs): @@ -190,17 +205,82 @@ class StreamViewSet(viewsets.ModelViewSet): # Return the response with the list of unique group names return Response(list(group_names)) - @swagger_auto_schema( - method="post", - operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["ids"], - properties={ - "ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to retrieve" + @action(detail=False, methods=["get"], url_path="filter-options") + def get_filter_options(self, request, *args, **kwargs): + """ + Get available filter options based on current filter state. + Uses a hierarchical approach: M3U is the parent filter, Group filters based on M3U. + """ + # For group options: we need to bypass the channel_group custom queryset filtering + # Store original request params + original_params = request.query_params + + # Create modified params without channel_group for getting group options + params_without_group = request.GET.copy() + params_without_group.pop('channel_group', None) + params_without_group.pop('channel_group_name', None) + + # Temporarily modify request to exclude channel_group + request._request.GET = params_without_group + base_queryset_for_groups = self.get_queryset() + + # Apply filterset (which will apply M3U filters) + group_filterset = self.filterset_class( + params_without_group, + queryset=base_queryset_for_groups + ) + group_queryset = group_filterset.qs + + group_names = ( + group_queryset.exclude(channel_group__isnull=True) + .order_by("channel_group__name") + .values_list("channel_group__name", flat=True) + .distinct() + ) + + # For M3U options: show ALL M3Us (don't filter by anything except name search) + params_for_m3u = request.GET.copy() + params_for_m3u.pop('m3u_account', None) + params_for_m3u.pop('channel_group', None) + params_for_m3u.pop('channel_group_name', None) + + # Temporarily modify request to exclude filters for M3U options + request._request.GET = params_for_m3u + base_queryset_for_m3u = self.get_queryset() + + m3u_filterset = self.filterset_class( + params_for_m3u, + queryset=base_queryset_for_m3u + ) + m3u_queryset = m3u_filterset.qs + + m3u_accounts = ( + m3u_queryset.exclude(m3u_account__isnull=True) + .order_by("m3u_account__name") + .values("m3u_account__id", "m3u_account__name") + .distinct() + ) + + # Restore original params + request._request.GET = original_params + + return Response({ + "groups": list(group_names), + "m3u_accounts": [ + {"id": m3u["m3u_account__id"], "name": m3u["m3u_account__name"]} + for m3u in m3u_accounts + ] + }) + + @extend_schema( + methods=["POST"], + description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", + request=inline_serializer( + name="StreamByIdsRequest", + fields={ + "ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to retrieve" ), }, ), @@ -234,12 +314,8 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): return [Authenticated()] def get_queryset(self): - """Add annotation for association counts""" - from django.db.models import Count - return ChannelGroup.objects.annotate( - channel_count=Count('channels', distinct=True), - m3u_account_count=Count('m3u_accounts', distinct=True) - ) + """Return channel groups with prefetched relations for efficient counting""" + return ChannelGroup.objects.prefetch_related('channels', 'm3u_accounts').all() def update(self, request, *args, **kwargs): """Override update to check M3U associations""" @@ -267,23 +343,27 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): return super().partial_update(request, *args, **kwargs) - @swagger_auto_schema( - method="post", - operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)", - responses={200: "Cleanup completed"}, + @extend_schema( + methods=["POST"], + description="Delete all channel groups that have no associations (no channels or M3U accounts)", ) @action(detail=False, methods=["post"], url_path="cleanup") def cleanup_unused_groups(self, request): """Delete all channel groups with no channels or M3U account associations""" - from django.db.models import Count + from django.db.models import Q, Exists, OuterRef + + # Find groups with no channels and no M3U account associations using Exists subqueries + from .models import Channel, ChannelGroupM3UAccount + + has_channels = Channel.objects.filter(channel_group_id=OuterRef('pk')) + has_accounts = ChannelGroupM3UAccount.objects.filter(channel_group_id=OuterRef('pk')) - # Find groups with no channels and no M3U account associations unused_groups = ChannelGroup.objects.annotate( - channel_count=Count('channels', distinct=True), - m3u_account_count=Count('m3u_accounts', distinct=True) + has_channels=Exists(has_channels), + has_accounts=Exists(has_accounts) ).filter( - channel_count=0, - m3u_account_count=0 + has_channels=False, + has_accounts=False ) deleted_count = unused_groups.count() @@ -384,6 +464,72 @@ class ChannelViewSet(viewsets.ModelViewSet): ordering_fields = ["channel_number", "name", "channel_group__name"] ordering = ["-channel_number"] + def create(self, request, *args, **kwargs): + """Override create to handle channel profile membership""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + channel = serializer.save() + + # Handle channel profile membership + # Semantics: + # - Omitted (None): add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only + channel_profile_ids = request.data.get("channel_profile_ids") + if channel_profile_ids is not None: + # Normalize single ID to array + if not isinstance(channel_profile_ids, list): + channel_profile_ids = [channel_profile_ids] + + # Determine action based on semantics + if channel_profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + elif isinstance(channel_profile_ids, list) and len(channel_profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(channel_profile_ids, list) and 0 in channel_profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + else: + # Specific profile IDs + try: + channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) + if len(channel_profiles) != len(channel_profile_ids): + missing_ids = set(channel_profile_ids) - set(channel_profiles.values_list('id', flat=True)) + return Response( + {"error": f"Channel profiles with IDs {list(missing_ids)} not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in channel_profiles + ]) + except Exception as e: + return Response( + {"error": f"Error creating profile memberships: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + def get_permissions(self): if self.action in [ "edit_bulk", @@ -419,10 +565,45 @@ class ChannelViewSet(viewsets.ModelViewSet): group_names = channel_group.split(",") qs = qs.filter(channel_group__name__in=group_names) - if self.request.user.user_level < 10: - qs = qs.filter(user_level__lte=self.request.user.user_level) + filters = {} + q_filters = Q() - return qs + channel_profile_id = self.request.query_params.get("channel_profile_id") + show_disabled_param = self.request.query_params.get("show_disabled", None) + only_streamless = self.request.query_params.get("only_streamless", None) + + if channel_profile_id: + try: + profile_id_int = int(channel_profile_id) + + if show_disabled_param is None: + # Show only enabled channels: channels that have a membership + # record for this profile with enabled=True + # Default is DISABLED (channels without membership are hidden) + filters["channelprofilemembership__channel_profile_id"] = profile_id_int + filters["channelprofilemembership__enabled"] = True + # If show_disabled is True, show all channels (no filtering needed) + + except (ValueError, TypeError): + # Ignore invalid profile id values + pass + + if only_streamless: + q_filters &= Q(streams__isnull=True) + + if self.request.user.user_level < 10: + filters["user_level__lte"] = self.request.user.user_level + # Hide adult content if user preference is set + custom_props = self.request.user.custom_properties or {} + if custom_props.get('hide_adult_content', False): + filters["is_adult"] = False + + if filters: + qs = qs.filter(**filters) + if q_filters: + qs = qs.filter(q_filters) + + return qs.distinct() def get_serializer_context(self): context = super().get_serializer_context() @@ -518,11 +699,18 @@ class ChannelViewSet(viewsets.ModelViewSet): # Single bulk_update query instead of individual saves channels_to_update = [channel for channel, _ in validated_updates] if channels_to_update: - Channel.objects.bulk_update( - channels_to_update, - fields=list(validated_updates[0][1].keys()), - batch_size=100 - ) + # Collect all unique field names from all updates + all_fields = set() + for _, validated_data in validated_updates: + all_fields.update(validated_data.keys()) + + # Only call bulk_update if there are fields to update + if all_fields: + Channel.objects.bulk_update( + channels_to_update, + fields=list(all_fields), + batch_size=100 + ) # Return the updated objects (already in memory) serialized_channels = ChannelSerializer( @@ -536,6 +724,104 @@ class ChannelViewSet(viewsets.ModelViewSet): "channels": serialized_channels }) + @extend_schema( + methods=["POST"], + description=( + "Bulk rename channel names using a regex find/replace executed server-side. " + "Accepts JavaScript-style named groups (e.g., (?...)) and converts them to Python syntax. " + "Supports flags: 'i' (IGNORECASE). Replacement tokens like $1, $& and $ are translated to Python." + ), + request=inline_serializer( + name="BulkRegexRenameRequest", + fields={ + "channel_ids": serializers.ListField(child=serializers.IntegerField()), + "find": serializers.CharField(), + "replace": serializers.CharField(required=False, allow_blank=True), + "flags": serializers.CharField(required=False, allow_blank=True), + }, + ), + ) + @action(detail=False, methods=["post"], url_path="edit/bulk-regex") + def bulk_regex_rename(self, request): + """ + Efficiently apply a regex find/replace to the `name` field of multiple channels. + """ + import regex as re + + channel_ids = request.data.get("channel_ids", []) + pattern = request.data.get("find", "") + replace = request.data.get("replace", "") + flags_str = request.data.get("flags", "") or "" + + if not isinstance(channel_ids, list) or len(channel_ids) == 0: + return Response({"error": "channel_ids must be a non-empty list"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(pattern, str) or pattern.strip() == "": + return Response({"error": "find (regex pattern) is required"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(replace, str): + return Response({"error": "replace must be a string"}, status=status.HTTP_400_BAD_REQUEST) + + # Convert JS-style named groups to Python (?...) -> (?P...) + try: + converted_pattern = re.sub(r"\(\?<([^>]+)>", r"(?P<\1>", pattern) + except Exception as e: + return Response({"error": f"Failed to normalize pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST) + + # Compile flags + re_flags = 0 + if "i" in flags_str: + re_flags |= re.IGNORECASE + # Note: 'g' (global) is the default behavior of re.sub; no action needed. + + # Translate common JS replacement tokens to Python + def translate_js_replacement(rep: str) -> str: + # $$ -> $ + rep = rep.replace("$$", "$") + # $& -> \g<0> + rep = rep.replace("$&", r"\g<0>") + # $ -> \g + rep = re.sub(r"\$<([A-Za-z_][A-Za-z0-9_]*)>", r"\\g<\1>", rep) + # $1 -> \g<1>, $2 -> \g<2>, etc. + rep = re.sub(r"\$(\d+)", r"\\g<\1>", rep) + return rep + + try: + replacement_py = translate_js_replacement(replace) + compiled = re.compile(converted_pattern, flags=re_flags) + except Exception as e: + return Response({"error": f"Invalid regex pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST) + + # Fetch channels in one query + channels = list(Channel.objects.filter(id__in=channel_ids)) + if not channels: + return Response({"error": "No matching channels found for provided IDs"}, status=status.HTTP_404_NOT_FOUND) + + changed = [] + for ch in channels: + current = ch.name or "" + try: + new_name = compiled.sub(replacement_py, current) + except Exception as e: + # Skip problematic replacements but continue processing others + logger.warning(f"Regex replacement failed for channel {ch.id}: {e}") + continue + + # Only update if name actually changes and remains non-empty + if new_name != current and new_name.strip(): + ch.name = new_name + changed.append(ch) + + # Apply updates in bulk + updated_count = 0 + if changed: + with transaction.atomic(): + Channel.objects.bulk_update(changed, fields=["name"], batch_size=100) + updated_count = len(changed) + + return Response({ + "success": True, + "updated_count": updated_count, + }, status=status.HTTP_200_OK) + @action(detail=False, methods=["post"], url_path="set-names-from-epg") def set_names_from_epg(self, request): """ @@ -643,25 +929,66 @@ class ChannelViewSet(viewsets.ModelViewSet): # Return the response with the list of IDs return Response(list(channel_ids)) - @swagger_auto_schema( - method="post", - operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "starting_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="Starting channel number to assign (can be decimal)", + @action(detail=False, methods=["get"], url_path="summary") + def summary(self, request, *args, **kwargs): + """Return a lightweight list of channels with only the fields needed by the TV Guide.""" + queryset = self.filter_queryset(self.get_queryset()) + data = list( + queryset.values( + "id", + "name", + "logo_id", + "channel_number", + "uuid", + "epg_data_id", + "channel_group_id", + ) + ) + return Response(data) + + @extend_schema( + methods=["POST"], + description="Retrieve channels by a list of UUIDs using POST to avoid URL length limitations", + request=inline_serializer( + name="ChannelByUUIDsRequest", + fields={ + "uuids": serializers.ListField( + child=serializers.CharField(), + help_text="List of channel UUIDs to retrieve", + ) + }, + ), + responses={200: ChannelSerializer(many=True)}, + ) + @action(detail=False, methods=["post"], url_path="by-uuids") + def get_by_uuids(self, request, *args, **kwargs): + uuids = request.data.get("uuids", []) + if not isinstance(uuids, list): + return Response( + {"error": "uuids must be a list of strings"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + channels = Channel.objects.filter(uuid__in=uuids) + serializer = self.get_serializer(channels, many=True) + return Response(serializer.data) + + @extend_schema( + methods=["POST"], + description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", + request=inline_serializer( + name="AssignChannelsRequest", + fields={ + "starting_number": serializers.FloatField( + help_text="Starting channel number to assign (can be decimal)", + required=False, ), - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to assign", + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to assign", ), }, ), - responses={200: "Channels have been auto-assigned!"}, ) @action(detail=False, methods=["post"], url_path="assign") def assign(self, request): @@ -681,33 +1008,28 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK ) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Create a new channel from an existing stream. " "If 'channel_number' is provided, it will be used (if available); " "otherwise, the next available channel number is assigned. " "If 'channel_profile_ids' is provided, the channel will only be added to those profiles. " "Accepts either a single ID or an array of IDs." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_id"], - properties={ - "stream_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="ID of the stream to link" + request=inline_serializer( + name="FromStreamRequest", + fields={ + "stream_id": serializers.IntegerField(help_text="ID of the stream to link"), + "channel_number": serializers.FloatField( + help_text="(Optional) Desired channel number. Must not be in use.", + required=False, ), - "channel_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="(Optional) Desired channel number. Must not be in use.", - ), - "name": openapi.Schema( - type=openapi.TYPE_STRING, description="Desired channel name" - ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs. If not provided, channel is added to all profiles." + "name": serializers.CharField(help_text="Desired channel name", required=False), + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), }, ), @@ -729,18 +1051,13 @@ class ChannelViewSet(viewsets.ModelViewSet): if name is None: name = stream.name - # Check if client provided a channel_number; if not, auto-assign one. - stream_custom_props = stream.custom_properties or {} + # Check if client provided a channel_number; if not, use stream_chno or auto-assign channel_number = request.data.get("channel_number") if channel_number is None: - # Channel number not provided by client, check stream properties or auto-assign - if "tvg-chno" in stream_custom_props: - channel_number = float(stream_custom_props["tvg-chno"]) - elif "channel-number" in stream_custom_props: - channel_number = float(stream_custom_props["channel-number"]) - elif "num" in stream_custom_props: - channel_number = float(stream_custom_props["num"]) + # Channel number not provided by client, check stream's channel number or auto-assign + if stream.stream_chno is not None: + channel_number = stream.stream_chno elif channel_number == 0: # Special case: 0 means ignore provider numbers and auto-assign channel_number = None @@ -761,9 +1078,8 @@ class ChannelViewSet(viewsets.ModelViewSet): if Channel.objects.filter(channel_number=channel_number).exists(): channel_number = Channel.get_next_available_channel_number(channel_number) # Get the tvc_guide_stationid from custom properties if it exists - tvc_guide_stationid = None - if "tvc-guide-stationid" in stream_custom_props: - tvc_guide_stationid = stream_custom_props["tvc-guide-stationid"] + stream_custom_props = stream.custom_properties or {} + tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid") channel_data = { "channel_number": channel_number, @@ -771,6 +1087,7 @@ class ChannelViewSet(viewsets.ModelViewSet): "tvg_id": stream.tvg_id, "tvc_guide_stationid": tvc_guide_stationid, "streams": [stream_id], + "is_adult": stream.is_adult, } # Only add channel_group_id if the stream has a channel group @@ -800,14 +1117,37 @@ class ChannelViewSet(viewsets.ModelViewSet): channel.streams.add(stream) # Handle channel profile membership + # Semantics: + # - Omitted (None): add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only channel_profile_ids = request.data.get("channel_profile_ids") if channel_profile_ids is not None: # Normalize single ID to array if not isinstance(channel_profile_ids, list): channel_profile_ids = [channel_profile_ids] - if channel_profile_ids: - # Add channel only to the specified profiles + # Determine action based on semantics + if channel_profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + elif isinstance(channel_profile_ids, list) and len(channel_profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(channel_profile_ids, list) and 0 in channel_profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + else: + # Specific profile IDs try: channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) if len(channel_profiles) != len(channel_profile_ids): @@ -830,13 +1170,6 @@ class ChannelViewSet(viewsets.ModelViewSet): {"error": f"Error creating profile memberships: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, ) - else: - # Default behavior: add to all profiles - profiles = ChannelProfile.objects.all() - ChannelProfileMembership.objects.bulk_create([ - ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) - for profile in profiles - ]) # Send WebSocket notification for single channel creation from core.utils import send_websocket_update @@ -850,34 +1183,31 @@ class ChannelViewSet(viewsets.ModelViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Asynchronously bulk create channels from stream IDs. " "Returns a task ID to track progress via WebSocket. " "This is the recommended approach for large bulk operations." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to create channels from" + request=inline_serializer( + name="FromStreamBulkRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to create channels from" ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s) to add the channels to. If not provided, channels are added to all profiles." + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), - "starting_channel_number": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number" + "starting_channel_number": serializers.IntegerField( + help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number", + required=False, ), }, ), - responses={202: "Task started successfully"}, ) @action(detail=False, methods=["post"], url_path="from-stream/bulk") def from_stream_bulk(self, request): @@ -917,20 +1247,19 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 6) EPG Fuzzy Matching # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'channel_ids': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.' + @extend_schema( + methods=["POST"], + description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", + request=inline_serializer( + name="MatchEpgRequest", + fields={ + 'channel_ids': serializers.ListField( + child=serializers.IntegerField(), + help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.', + required=False, ) } ), - responses={202: "EPG matching task initiated"}, ) @action(detail=False, methods=["post"], url_path="match-epg") def match_epg(self, request): @@ -951,10 +1280,9 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": message}, status=status.HTTP_202_ACCEPTED ) - @swagger_auto_schema( - method="post", - operation_description="Try to auto-match this specific channel with EPG data.", - responses={200: "EPG matching completed", 202: "EPG matching task initiated"}, + @extend_schema( + methods=["POST"], + description="Try to auto-match this specific channel with EPG data.", ) @action(detail=True, methods=["post"], url_path="match-epg") def match_channel_epg(self, request, pk=None): @@ -981,16 +1309,13 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 7) Set EPG and Refresh # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Set EPG data for a channel and refresh program data", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["epg_data_id"], - properties={ - "epg_data_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="EPG data ID to link" - ) + @extend_schema( + methods=["POST"], + description="Set EPG data for a channel and refresh program data", + request=inline_serializer( + name="SetEpgRequest", + fields={ + "epg_data_id": serializers.IntegerField(help_text="EPG data ID to link") }, ), responses={200: "EPG data linked and refresh triggered"}, @@ -1046,25 +1371,106 @@ class ChannelViewSet(viewsets.ModelViewSet): except Exception as e: return Response({"error": str(e)}, status=400) - @swagger_auto_schema( - method="post", - operation_description="Associate multiple channels with EPG data without triggering a full refresh", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "associations": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "channel_id": openapi.Schema(type=openapi.TYPE_INTEGER), - "epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER), + @extend_schema( + description=( + "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). " + "The channel will receive the next whole number after the target channel, and all subsequent " + "channels will be renumbered accordingly." + ), + request=inline_serializer( + name="ReorderChannelRequest", + fields={ + "insert_after_id": serializers.IntegerField( + help_text="ID of the channel to insert after. Use null to move to the beginning.", + required=False, + allow_null=True, + ), + }, + ), + ) + @action(detail=True, methods=["post"], url_path="reorder") + def reorder(self, request, pk=None): + """ + Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). + Shifts other channels as needed to maintain contiguous ordering. + """ + channel = self.get_object() + insert_after_id = request.data.get("insert_after_id") + old_channel_number = channel.channel_number + + with transaction.atomic(): + if insert_after_id is None: + # Move to the beginning (channel_number = 1) + target_number = 0 + desired_number = 1 + else: + try: + target_channel = Channel.objects.get(id=insert_after_id) + target_number = target_channel.channel_number or 0 + desired_number = int(target_number) + 1 + except Channel.DoesNotExist: + return Response( + {"error": "Target channel not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if desired_number == old_channel_number: + # No change needed + return Response( + { + "message": f"Channel {channel.name} already at position {desired_number}", + "channel": self.get_serializer(channel).data, + }, + status=status.HTTP_200_OK, + ) + + if desired_number < old_channel_number: + # Moving up: increment all channels between desired_number and old_channel_number-1 + Channel.objects.filter( + channel_number__gte=desired_number, + channel_number__lt=old_channel_number + ).update(channel_number=F('channel_number') + 1) + channel.channel_number = desired_number + channel.save(update_fields=['channel_number']) + elif desired_number > old_channel_number: + # Moving down: shift down channels between old+1 and desired-1, then set to desired-1 + if desired_number > old_channel_number + 1: + Channel.objects.filter( + channel_number__gt=old_channel_number, + channel_number__lt=desired_number + ).update(channel_number=F('channel_number') - 1) + channel.channel_number = desired_number - 1 + channel.save(update_fields=['channel_number']) + else: + # No move or same position + channel.channel_number = desired_number + channel.save(update_fields=['channel_number']) + + return Response( + { + "message": f"Channel {channel.name} moved to position {desired_number}", + "channel": self.get_serializer(channel).data, + }, + status=status.HTTP_200_OK, + ) + + @extend_schema( + methods=["POST"], + description="Associate multiple channels with EPG data without triggering a full refresh", + request=inline_serializer( + name="BatchSetEpgRequest", + fields={ + "associations": serializers.ListField( + child=inline_serializer( + name="EpgAssociation", + fields={ + "channel_id": serializers.IntegerField(), + "epg_data_id": serializers.IntegerField(), }, ), ) }, ), - responses={200: "EPG data linked for multiple channels"}, ) @action(detail=False, methods=["post"], url_path="batch-set-epg") def batch_set_epg(self, request): @@ -1162,20 +1568,17 @@ class BulkDeleteStreamsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete streams by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Stream IDs to delete", + @extend_schema( + description="Bulk delete streams by ID", + request=inline_serializer( + name="BulkDeleteStreamsRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Stream IDs to delete", ) }, ), - responses={204: "Streams deleted"}, ) def delete(self, request, *args, **kwargs): stream_ids = request.data.get("stream_ids", []) @@ -1198,20 +1601,17 @@ class BulkDeleteChannelsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete channels by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to delete", + @extend_schema( + description="Bulk delete channels by ID", + request=inline_serializer( + name="BulkDeleteChannelsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to delete", ) }, ), - responses={204: "Channels deleted"}, ) def delete(self, request): channel_ids = request.data.get("channel_ids", []) @@ -1233,20 +1633,17 @@ class BulkDeleteLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete logos by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["logo_ids"], - properties={ - "logo_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Logo IDs to delete", + @extend_schema( + description="Bulk delete logos by ID", + request=inline_serializer( + name="BulkDeleteLogosRequest", + fields={ + "logo_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Logo IDs to delete", ) }, ), - responses={204: "Logos deleted"}, ) def delete(self, request): logo_ids = request.data.get("logo_ids", []) @@ -1303,19 +1700,18 @@ class CleanupUnusedLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Delete all channel logos that are not used by any channels", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "delete_files": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - description="Whether to delete local logo files from disk", - default=False + @extend_schema( + description="Delete all channel logos that are not used by any channels", + request=inline_serializer( + name="CleanupUnusedLogosRequest", + fields={ + "delete_files": serializers.BooleanField( + help_text="Whether to delete local logo files from disk", + default=False, + required=False, ) }, ), - responses={200: "Cleanup completed"}, ) def post(self, request): """Delete all channel logos with no channel associations""" @@ -1528,11 +1924,10 @@ class LogoViewSet(viewsets.ModelViewSet): """Streams the logo file, whether it's local or remote.""" logo = self.get_object() logo_url = logo.url - if logo_url.startswith("/data"): # Local file if not os.path.exists(logo_url): raise Http404("Image not found") - + stat = os.stat(logo_url) # Get proper mime type (first item of the tuple) content_type, _ = mimetypes.guess_type(logo_url) if not content_type: @@ -1542,6 +1937,8 @@ class LogoViewSet(viewsets.ModelViewSet): response = StreamingHttpResponse( open(logo_url, "rb"), content_type=content_type ) + response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours + response["Last-Modified"] = http_date(stat.st_mtime) response["Content-Disposition"] = 'inline; filename="{}"'.format( os.path.basename(logo_url) ) @@ -1581,6 +1978,10 @@ class LogoViewSet(viewsets.ModelViewSet): remote_response.iter_content(chunk_size=8192), content_type=content_type, ) + if(remote_response.headers.get("Cache-Control")): + response["Cache-Control"] = remote_response.headers.get("Cache-Control") + if(remote_response.headers.get("Last-Modified")): + response["Last-Modified"] = remote_response.headers.get("Last-Modified") response["Content-Disposition"] = 'inline; filename="{}"'.format( os.path.basename(logo_url) ) @@ -1612,11 +2013,58 @@ class ChannelProfileViewSet(viewsets.ModelViewSet): return self.request.user.channel_profiles.all() def get_permissions(self): + if self.action == "duplicate": + return [IsAdmin()] try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] + @action(detail=True, methods=["post"], url_path="duplicate", permission_classes=[IsAdmin]) + def duplicate(self, request, pk=None): + requested_name = str(request.data.get("name", "")).strip() + + if not requested_name: + return Response( + {"detail": "Name is required to duplicate a profile."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ChannelProfile.objects.filter(name=requested_name).exists(): + return Response( + {"detail": "A channel profile with this name already exists."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + source_profile = self.get_object() + + with transaction.atomic(): + new_profile = ChannelProfile.objects.create(name=requested_name) + + source_memberships = ChannelProfileMembership.objects.filter( + channel_profile=source_profile + ) + source_enabled_map = { + membership.channel_id: membership.enabled + for membership in source_memberships + } + + new_memberships = list( + ChannelProfileMembership.objects.filter(channel_profile=new_profile) + ) + for membership in new_memberships: + membership.enabled = source_enabled_map.get( + membership.channel_id, False + ) + + if new_memberships: + ChannelProfileMembership.objects.bulk_update( + new_memberships, ["enabled"] + ) + + serializer = self.get_serializer(new_profile) + return Response(serializer.data, status=status.HTTP_201_CREATED) + class GetChannelStreamsAPIView(APIView): def get_permissions(self): @@ -1673,6 +2121,10 @@ class BulkUpdateChannelMembershipAPIView(APIView): except KeyError: return [Authenticated()] + @extend_schema( + description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", + request=BulkChannelProfileMembershipSerializer, + ) def patch(self, request, profile_id): """Bulk enable or disable channels for a specific profile""" # Get the channel profile @@ -1685,21 +2137,67 @@ class BulkUpdateChannelMembershipAPIView(APIView): updates = serializer.validated_data["channels"] channel_ids = [entry["channel_id"] for entry in updates] - memberships = ChannelProfileMembership.objects.filter( + # Validate that all channels exist + existing_channels = set( + Channel.objects.filter(id__in=channel_ids).values_list("id", flat=True) + ) + invalid_channels = [cid for cid in channel_ids if cid not in existing_channels] + + if invalid_channels: + return Response( + { + "error": "Some channels do not exist", + "invalid_channels": invalid_channels, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get existing memberships + existing_memberships = ChannelProfileMembership.objects.filter( channel_profile=channel_profile, channel_id__in=channel_ids ) + membership_dict = {m.channel_id: m for m in existing_memberships} - membership_dict = {m.channel.id: m for m in memberships} + # Prepare lists for bulk operations + memberships_to_update = [] + memberships_to_create = [] for entry in updates: channel_id = entry["channel_id"] enabled_status = entry["enabled"] + if channel_id in membership_dict: + # Update existing membership membership_dict[channel_id].enabled = enabled_status + memberships_to_update.append(membership_dict[channel_id]) + else: + # Create new membership + memberships_to_create.append( + ChannelProfileMembership( + channel_profile=channel_profile, + channel_id=channel_id, + enabled=enabled_status, + ) + ) - ChannelProfileMembership.objects.bulk_update(memberships, ["enabled"]) + # Perform bulk operations + with transaction.atomic(): + if memberships_to_update: + ChannelProfileMembership.objects.bulk_update( + memberships_to_update, ["enabled"] + ) + if memberships_to_create: + ChannelProfileMembership.objects.bulk_create(memberships_to_create) - return Response({"status": "success"}, status=status.HTTP_200_OK) + return Response( + { + "status": "success", + "updated": len(memberships_to_update), + "created": len(memberships_to_create), + "invalid_channels": [], + }, + status=status.HTTP_200_OK, + ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1745,7 +2243,7 @@ class RecordingViewSet(viewsets.ModelViewSet): def get_permissions(self): # Allow unauthenticated playback of recording files (like other streaming endpoints) - if getattr(self, 'action', None) == 'file': + if self.action == 'file': return [AllowAny()] try: return [perm() for perm in permission_classes_by_action[self.action]] @@ -1988,9 +2486,25 @@ class SeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] + @extend_schema( + summary="List all series rules", + description="Retrieve all configured DVR series recording rules.", + ) def get(self, request): return Response({"rules": CoreSettings.get_dvr_series_rules()}) + @extend_schema( + summary="Create or update a series rule", + description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", + request=inline_serializer( + name="SeriesRuleRequest", + fields={ + 'tvg_id': serializers.CharField(help_text='Channel TVG ID'), + 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'), + 'title': serializers.CharField(help_text='Series title', required=False), + }, + ), + ) def post(self, request): data = request.data or {} tvg_id = str(data.get("tvg_id") or "").strip() @@ -2023,8 +2537,15 @@ class DeleteSeriesRuleAPIView(APIView): except KeyError: return [Authenticated()] + @extend_schema( + summary="Delete a series rule", + description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", + parameters=[ + OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'), + ], + ) def delete(self, request, tvg_id): - tvg_id = str(tvg_id) + tvg_id = unquote(str(tvg_id or "")) rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id] CoreSettings.set_dvr_series_rules(rules) return Response({"success": True, "rules": rules}) @@ -2037,6 +2558,16 @@ class EvaluateSeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] + @extend_schema( + summary="Evaluate series rules", + description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", + request=inline_serializer( + name="EvaluateSeriesRulesRequest", + fields={ + "tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."), + }, + ), + ) def post(self, request): tvg_id = request.data.get("tvg_id") # Run synchronously so UI sees results immediately @@ -2058,6 +2589,18 @@ class BulkRemoveSeriesRecordingsAPIView(APIView): except KeyError: return [Authenticated()] + @extend_schema( + summary="Bulk remove scheduled recordings for a series", + description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", + request=inline_serializer( + name="BulkRemoveSeriesRecordingsRequest", + fields={ + "tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"), + "title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"), + "scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"), + }, + ), + ) def post(self, request): from django.utils import timezone tvg_id = str(request.data.get("tvg_id") or "").strip() diff --git a/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py b/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py new file mode 100644 index 00000000..2428a97b --- /dev/null +++ b/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2026-01-09 18:19 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0030_alter_stream_url'), + ] + + operations = [ + migrations.AddField( + model_name='channelgroupm3uaccount', + name='is_stale', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'), + ), + migrations.AddField( + model_name='channelgroupm3uaccount', + name='last_seen', + field=models.DateTimeField(db_index=True, default=datetime.datetime.now, help_text='Last time this group was seen in the M3U source during a refresh'), + ), + migrations.AddField( + model_name='stream', + name='is_stale', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream is stale (not seen in recent refresh, pending deletion)'), + ), + ] diff --git a/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py new file mode 100644 index 00000000..cbf6ba4f --- /dev/null +++ b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2026-01-17 16:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0031_channelgroupm3uaccount_is_stale_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='is_adult', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this channel contains adult content'), + ), + migrations.AddField( + model_name='stream', + name='is_adult', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream contains adult content'), + ), + ] diff --git a/apps/channels/migrations/0033_stream_id_stream_chno.py b/apps/channels/migrations/0033_stream_id_stream_chno.py new file mode 100644 index 00000000..31690a93 --- /dev/null +++ b/apps/channels/migrations/0033_stream_id_stream_chno.py @@ -0,0 +1,205 @@ +# Generated by Django - Add stream_id and channel_number fields with data migration + +from django.db import migrations, models +import hashlib +import json +import logging + +logger = logging.getLogger(__name__) + + +def populate_fields_and_rehash(apps, schema_editor): + """ + Populate stream_id and stream_chno from custom_properties for XC account streams, + populate stream_chno from tvg-chno for standard M3U accounts, + then rehash XC streams using stable hash keys. + """ + Stream = apps.get_model('dispatcharr_channels', 'Stream') + M3UAccount = apps.get_model('m3u', 'M3UAccount') + CoreSettings = apps.get_model('core', 'CoreSettings') + + # Get hash keys from settings + try: + stream_settings = CoreSettings.objects.get(key='stream_settings') + hash_key_str = stream_settings.value.get('m3u_hash_key', '') if stream_settings.value else '' + keys = [k.strip() for k in hash_key_str.split(',') if k.strip()] if hash_key_str else [] + except CoreSettings.DoesNotExist: + keys = [] + + logger.info(f"Using hash keys: {keys}") + + # Get XC account IDs + xc_account_ids = set( + M3UAccount.objects.filter(account_type='XC').values_list('id', flat=True) + ) + + logger.info(f"Found {len(xc_account_ids)} XC accounts") + + # Track hash collisions for XC streams + hash_map = {} # new_hash -> stream_id + duplicates_to_delete = [] + + # Process all streams in batches + batch_size = 1000 + processed = 0 + updated = 0 + + total_count = Stream.objects.count() + logger.info(f"Processing {total_count} total streams") + + streams_to_update = [] + + for stream in Stream.objects.select_related('channel_group', 'm3u_account').iterator(chunk_size=batch_size): + processed += 1 + needs_update = False + + custom_props = stream.custom_properties or {} + is_xc = stream.m3u_account_id in xc_account_ids if stream.m3u_account_id else False + + # Extract stream_id (XC accounts only) + if is_xc and isinstance(custom_props, dict): + provider_stream_id = custom_props.get('stream_id') + if provider_stream_id: + try: + stream.stream_id = int(provider_stream_id) + needs_update = True + except (ValueError, TypeError): + pass + + # Extract stream_chno + channel_num = None + if isinstance(custom_props, dict): + if is_xc: + # XC accounts use 'num' + channel_num = custom_props.get('num') + else: + # Standard M3U accounts use 'tvg-chno' or 'channel-number' (case insensitive check) + for key in ['tvg-chno', 'TVG-CHNO', 'tvg-Chno', 'Tvg-Chno', 'channel-number', 'Channel-Number', 'CHANNEL-NUMBER']: + if key in custom_props: + channel_num = custom_props.get(key) + break + + if channel_num is not None: + try: + stream.stream_chno = float(channel_num) + needs_update = True + except (ValueError, TypeError): + pass + + # Rehash XC streams only when 'url' is in hash keys (otherwise hash wouldn't change) + if is_xc and stream.stream_id and keys and 'url' in keys: + # For XC accounts, use stream_id instead of url when 'url' is in the hash keys + # This ensures credential/URL changes don't break stream identity + effective_url = stream.stream_id + + # Get group name + group_name = stream.channel_group.name if stream.channel_group else None + + # Build hash parts + stream_parts = { + "name": stream.name, + "url": effective_url, + "tvg_id": stream.tvg_id, + "m3u_id": stream.m3u_account_id, + "group": group_name + } + hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts} + + # When using stream_id instead of URL, we MUST include m3u_id to prevent + # collisions across different XC accounts (stream_id is only unique per account) + if 'm3u_id' not in hash_parts: + hash_parts['m3u_id'] = stream.m3u_account_id + + # Generate hash + serialized_obj = json.dumps(hash_parts, sort_keys=True) + new_hash = hashlib.sha256(serialized_obj.encode()).hexdigest() + + # Check for collisions + if new_hash in hash_map: + # Duplicate - mark for deletion (keep the first one) + duplicates_to_delete.append(stream.id) + continue + + hash_map[new_hash] = stream.id + stream.stream_hash = new_hash + needs_update = True + + if needs_update: + streams_to_update.append(stream) + updated += 1 + + # Bulk update in batches + if len(streams_to_update) >= batch_size: + Stream.objects.bulk_update( + streams_to_update, + ['stream_id', 'stream_chno', 'stream_hash'], + batch_size=500 + ) + logger.info(f"Updated batch: {processed}/{total_count} streams processed") + streams_to_update = [] + + # Final batch + if streams_to_update: + Stream.objects.bulk_update( + streams_to_update, + ['stream_id', 'stream_chno', 'stream_hash'], + batch_size=500 + ) + + # Delete duplicates if any + if duplicates_to_delete: + logger.warning(f"Deleting {len(duplicates_to_delete)} duplicate streams due to hash collisions") + Stream.objects.filter(id__in=duplicates_to_delete).delete() + + logger.info(f"Migration complete: {updated} streams updated, {len(duplicates_to_delete)} duplicates removed") + + +def reverse_migration(apps, schema_editor): + """ + Reverse migration - clear fields but don't attempt to reverse hash changes. + """ + Stream = apps.get_model('dispatcharr_channels', 'Stream') + Stream.objects.all().update(stream_id=None, stream_chno=None) + logger.info("Cleared stream_id and stream_chno fields. Note: stream hashes were not reverted.") + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0032_channel_is_adult_stream_is_adult'), + ('m3u', '0018_add_profile_custom_properties'), + ('core', '0020_change_coresettings_value_to_jsonfield'), + ] + + operations = [ + # Schema changes - add fields WITHOUT indexes first + migrations.AddField( + model_name='stream', + name='stream_id', + field=models.IntegerField( + blank=True, + help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', + null=True, + ), + ), + migrations.AddField( + model_name='stream', + name='stream_chno', + field=models.FloatField( + blank=True, + help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', + null=True, + ), + ), + # Data migration (may delete duplicates, which would conflict with pending index creation) + migrations.RunPython(populate_fields_and_rehash, reverse_migration), + # Add indexes AFTER data migration completes + migrations.AddIndex( + model_name='stream', + index=models.Index(fields=['stream_id'], name='dispatcharr_stream_id_idx'), + ), + migrations.AddIndex( + model_name='stream', + index=models.Index(fields=['stream_chno'], name='dispatcharr_stream_chno_idx'), + ), + ] diff --git a/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py b/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py new file mode 100644 index 00000000..77b88768 --- /dev/null +++ b/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.9 on 2026-02-01 03:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0033_stream_id_stream_chno'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='stream', + name='dispatcharr_stream_id_idx', + ), + migrations.RemoveIndex( + model_name='stream', + name='dispatcharr_stream_chno_idx', + ), + migrations.AlterField( + model_name='stream', + name='stream_chno', + field=models.FloatField(blank=True, db_index=True, help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', null=True), + ), + migrations.AlterField( + model_name='stream', + name='stream_id', + field=models.IntegerField(blank=True, db_index=True, help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', null=True), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 7282d025..207d78f2 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -94,8 +94,31 @@ class Stream(models.Model): db_index=True, ) last_seen = models.DateTimeField(db_index=True, default=datetime.now) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)" + ) + is_adult = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream contains adult content" + ) custom_properties = models.JSONField(default=dict, blank=True, null=True) + stream_id = models.IntegerField( + null=True, + blank=True, + db_index=True, + help_text="Provider stream ID (e.g., XC stream_id) for stable identity across credential changes" + ) + stream_chno = models.FloatField( + null=True, + blank=True, + db_index=True, + help_text="Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1" + ) + # Stream statistics fields stream_stats = models.JSONField( null=True, @@ -119,14 +142,27 @@ class Stream(models.Model): return self.name or self.url or f"Stream ID {self.id}" @classmethod - def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None): + def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None, + account_type=None, stream_id=None): if keys is None: keys = CoreSettings.get_m3u_hash_key().split(",") - stream_parts = {"name": name, "url": url, "tvg_id": tvg_id, "m3u_id": m3u_id} + # For XC accounts, use stream_id instead of url when 'url' is in the hash keys + # This ensures credential/URL changes don't break stream identity + effective_url = url + use_stream_id = account_type == 'XC' and stream_id and 'url' in keys + if use_stream_id: + effective_url = stream_id + + stream_parts = {"name": name, "url": effective_url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group} hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts} + # When using stream_id instead of URL, we MUST include m3u_id to prevent + # collisions across different XC accounts (stream_id is only unique per account) + if use_stream_id and 'm3u_id' not in hash_parts: + hash_parts['m3u_id'] = m3u_id + # Serialize and hash the dictionary serialized_obj = json.dumps( hash_parts, sort_keys=True @@ -296,6 +332,12 @@ class Channel(models.Model): user_level = models.IntegerField(default=0) + is_adult = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this channel contains adult content" + ) + auto_created = models.BooleanField( default=False, help_text="Whether this channel was automatically created via M3U auto channel sync" @@ -738,6 +780,16 @@ class ChannelGroupM3UAccount(models.Model): blank=True, help_text='Starting channel number for auto-created channels in this group' ) + last_seen = models.DateTimeField( + default=datetime.now, + db_index=True, + help_text='Last time this group was seen in the M3U source during a refresh' + ) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)' + ) class Meta: unique_together = ("channel_group", "m3u_account") diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 635281d5..abf26e91 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -104,7 +104,7 @@ class StreamSerializer(serializers.ModelSerializer): allow_null=True, required=False, ) - read_only_fields = ["is_custom", "m3u_account", "stream_hash"] + read_only_fields = ["is_custom", "m3u_account", "stream_hash", "stream_id", "stream_chno"] class Meta: model = Stream @@ -119,12 +119,16 @@ class StreamSerializer(serializers.ModelSerializer): "current_viewers", "updated_at", "last_seen", + "is_stale", + "is_adult", "stream_profile_id", "is_custom", "channel_group", "stream_hash", "stream_stats", "stream_stats_updated_at", + "stream_id", + "stream_chno", ] def get_fields(self): @@ -155,7 +159,7 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): class Meta: model = ChannelGroupM3UAccount - fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"] + fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties", "is_stale", "last_seen"] def to_representation(self, instance): data = super().to_representation(instance) @@ -179,8 +183,8 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): # Channel Group # class ChannelGroupSerializer(serializers.ModelSerializer): - channel_count = serializers.IntegerField(read_only=True) - m3u_account_count = serializers.IntegerField(read_only=True) + channel_count = serializers.SerializerMethodField() + m3u_account_count = serializers.SerializerMethodField() m3u_accounts = ChannelGroupM3UAccountSerializer( many=True, read_only=True @@ -190,6 +194,14 @@ class ChannelGroupSerializer(serializers.ModelSerializer): model = ChannelGroup fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"] + def get_channel_count(self, obj): + """Get count of channels in this group""" + return obj.channels.count() + + def get_m3u_account_count(self, obj): + """Get count of M3U accounts associated with this group""" + return obj.m3u_accounts.count() + class ChannelProfileSerializer(serializers.ModelSerializer): channels = serializers.SerializerMethodField() @@ -284,6 +296,7 @@ class ChannelSerializer(serializers.ModelSerializer): "uuid", "logo_id", "user_level", + "is_adult", "auto_created", "auto_created_by", "auto_created_by_name", @@ -321,6 +334,13 @@ class ChannelSerializer(serializers.ModelSerializer): "channel_number", Channel.get_next_available_channel_number() ) validated_data["channel_number"] = channel_number + + # Auto-assign Default Group if no channel_group is specified + if "channel_group" not in validated_data or validated_data.get("channel_group") is None: + from apps.channels.models import ChannelGroup + default_group, _ = ChannelGroup.objects.get_or_create(name="Default Group") + validated_data["channel_group"] = default_group + channel = Channel.objects.create(**validated_data) # Add streams in the specified order diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 27b361ba..75ef16dd 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -85,12 +85,23 @@ def create_profile_memberships(sender, instance, created, **kwargs): for channel in channels ]) -def schedule_recording_task(instance): - eta = instance.start_time +def schedule_recording_task(instance, eta=None): + # Use the explicitly-passed (and timezone-aware) eta if provided; + # fall back to instance.start_time only as a last resort. + if eta is None: + eta = instance.start_time + # Ensure eta is timezone-aware before comparing against now() + if eta is not None and not is_aware(eta): + eta = make_aware(eta) + # countdown=0 fires immediately (in-progress programs whose start_time was + # clamped to now by the serializer), countdown>0 delays until start_time + # (future programs). Using an integer countdown avoids any timezone + # serialization ambiguity that can occur with an absolute eta datetime. + countdown = max(0, int((eta - now()).total_seconds())) # Pass recording_id first so task can persist metadata to the correct row task = run_recording.apply_async( args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)], - eta=eta + countdown=countdown, ) return task.id @@ -133,7 +144,10 @@ def schedule_task_on_save(sender, instance, created, **kwargs): # Optionally allow slight fudge factor (1 second) to ensure scheduling happens if start_time > current_time - timedelta(seconds=1): print("Scheduling recording task!") - task_id = schedule_recording_task(instance) + # Pass the corrected, timezone-aware start_time explicitly so + # schedule_recording_task uses it as the Celery ETA rather than + # re-reading instance.start_time which may still be naive. + task_id = schedule_recording_task(instance, eta=start_time) instance.task_id = task_id instance.save(update_fields=['task_id']) else: diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index b63577d2..9791536f 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -139,6 +139,7 @@ COMMON_EXTRANEOUS_WORDS = [ def normalize_name(name: str) -> str: """ A more aggressive normalization that: + - Removes user-configured prefixes/suffixes/custom strings (only if mode is 'advanced') - Lowercases - Removes bracketed/parenthesized text - Removes punctuation @@ -148,7 +149,76 @@ def normalize_name(name: str) -> str: if not name: return "" - norm = name.lower() + # Load user-configured EPG matching rules (fail gracefully) + prefixes = [] + suffixes = [] + custom_strings = [] + + try: + from core.models import CoreSettings + settings = CoreSettings.get_epg_settings() + + # Check if user has enabled advanced mode + mode = settings.get("epg_match_mode", "default") + + # Only use custom settings if mode is 'advanced' + if mode == "advanced": + prefixes = settings.get("epg_match_ignore_prefixes", []) + suffixes = settings.get("epg_match_ignore_suffixes", []) + custom_strings = settings.get("epg_match_ignore_custom", []) + + # Ensure we have lists + if not isinstance(prefixes, list): + prefixes = [] + if not isinstance(suffixes, list): + suffixes = [] + if not isinstance(custom_strings, list): + custom_strings = [] + + except Exception as e: + # Settings unavailable or error - continue with empty lists (graceful degradation) + logger.debug(f"Could not load EPG matching settings: {e}") + prefixes = [] + suffixes = [] + custom_strings = [] + + result = name + + # Step 1: Remove prefixes (from START only - exact string match) + for prefix in prefixes: + # Skip empty or non-string entries + if not prefix or not isinstance(prefix, str): + continue + # Exact match at start + if result.startswith(prefix): + result = result[len(prefix):] + break # Only remove first matching prefix + + # Step 2: Remove suffixes (from END only - exact string match) + for suffix in suffixes: + # Skip empty or non-string entries + if not suffix or not isinstance(suffix, str): + continue + # Exact match at end + if result.endswith(suffix): + result = result[:-len(suffix)] + break # Only remove first matching suffix + + # Step 3: Remove custom strings (from ANYWHERE - exact string match) + for custom in custom_strings: + # Skip empty or non-string entries + if not custom or not isinstance(custom, str): + continue + try: + # Exact string removal (replace with empty string) + result = result.replace(custom, "") + except Exception as e: + # If removal fails for any reason, skip this entry + logger.debug(f"Failed to remove custom string '{custom}': {e}") + continue + + # Step 4: Existing normalization logic (unchanged) + norm = result.lower() norm = re.sub(r"\[.*?\]", "", norm) # Extract and preserve important call signs from parentheses before removing them @@ -295,7 +365,11 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True if score > 50: # Only show decent matches logger.debug(f" EPG '{row['name']}' (norm: '{row['norm_name']}') => score: {score} (base: {base_score}, bonus: {bonus})") - if score > best_score: + # When scores are equal, prefer higher priority EPG source + row_priority = row.get('epg_source_priority', 0) + best_priority = best_epg.get('epg_source_priority', 0) if best_epg else -1 + + if score > best_score or (score == best_score and row_priority > best_priority): best_score = score best_epg = row @@ -471,9 +545,9 @@ def match_epg_channels(): "norm_chan": normalize_name(channel.name) # Always use channel name for fuzzy matching! }) - # Get all EPG data + # Get all EPG data from active sources, ordered by source priority (highest first) so we prefer higher priority matches epg_data = [] - for epg in EPGData.objects.all(): + for epg in EPGData.objects.select_related('epg_source').filter(epg_source__is_active=True): normalized_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" epg_data.append({ 'id': epg.id, @@ -482,9 +556,13 @@ def match_epg_channels(): 'name': epg.name, 'norm_name': normalize_name(epg.name), 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + 'epg_source_priority': epg.epg_source.priority if epg.epg_source else 0, }) - logger.info(f"Processing {len(channels_data)} channels against {len(epg_data)} EPG entries") + # Sort EPG data by source priority (highest first) so we prefer higher priority matches + epg_data.sort(key=lambda x: x['epg_source_priority'], reverse=True) + + logger.info(f"Processing {len(channels_data)} channels against {len(epg_data)} EPG entries (from active sources only)") # Run EPG matching with progress updates - automatically uses conservative thresholds for bulk operations result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True) @@ -618,9 +696,9 @@ def match_selected_channels_epg(channel_ids): "norm_chan": normalize_name(channel.name) }) - # Get all EPG data + # Get all EPG data from active sources, ordered by source priority (highest first) so we prefer higher priority matches epg_data = [] - for epg in EPGData.objects.all(): + for epg in EPGData.objects.select_related('epg_source').filter(epg_source__is_active=True): normalized_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" epg_data.append({ 'id': epg.id, @@ -629,9 +707,13 @@ def match_selected_channels_epg(channel_ids): 'name': epg.name, 'norm_name': normalize_name(epg.name), 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + 'epg_source_priority': epg.epg_source.priority if epg.epg_source else 0, }) - logger.info(f"Processing {len(channels_data)} selected channels against {len(epg_data)} EPG entries") + # Sort EPG data by source priority (highest first) so we prefer higher priority matches + epg_data.sort(key=lambda x: x['epg_source_priority'], reverse=True) + + logger.info(f"Processing {len(channels_data)} selected channels against {len(epg_data)} EPG entries (from active sources only)") # Run EPG matching with progress updates - automatically uses appropriate thresholds result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True) @@ -749,9 +831,10 @@ def match_single_channel_epg(channel_id): test_normalized = normalize_name(test_name) logger.debug(f"DEBUG normalization example: '{test_name}' → '{test_normalized}' (call sign preserved)") - # Get all EPG data for matching - must include norm_name field + # Get all EPG data for matching from active sources - must include norm_name field + # Ordered by source priority (highest first) so we prefer higher priority matches epg_data_list = [] - for epg in EPGData.objects.filter(name__isnull=False).exclude(name=''): + for epg in EPGData.objects.select_related('epg_source').filter(epg_source__is_active=True, name__isnull=False).exclude(name=''): normalized_epg_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" epg_data_list.append({ 'id': epg.id, @@ -760,10 +843,14 @@ def match_single_channel_epg(channel_id): 'name': epg.name, 'norm_name': normalize_name(epg.name), 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + 'epg_source_priority': epg.epg_source.priority if epg.epg_source else 0, }) + # Sort EPG data by source priority (highest first) so we prefer higher priority matches + epg_data_list.sort(key=lambda x: x['epg_source_priority'], reverse=True) + if not epg_data_list: - return {"matched": False, "message": "No EPG data available for matching"} + return {"matched": False, "message": "No EPG data available for matching (from active sources)"} logger.info(f"Matching single channel '{channel.name}' against {len(epg_data_list)} EPG entries") @@ -1857,18 +1944,97 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): remux_success = False try: if temp_ts_path and os.path.exists(temp_ts_path): - subprocess.run([ - "ffmpeg", "-y", "-i", temp_ts_path, "-c", "copy", final_path - ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - remux_success = os.path.exists(final_path) - # Clean up temp file on success + # First attempt: Direct TS to MKV remux + result = subprocess.run([ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS + "-err_detect", "ignore_err", # Ignore minor stream errors + "-i", temp_ts_path, + "-map", "0", # Map all streams + "-c", "copy", + final_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + # Check if FFmpeg succeeded (return code 0) and output file is valid + if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + remux_success = True + logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}") + else: + # Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps + logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV") + + # Clean up partial/failed MKV + try: + if os.path.exists(final_path): + os.remove(final_path) + except Exception: + pass + + # Step 1: TS → MP4 (MP4 container handles broken timestamps better) + temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4" + result_mp4 = subprocess.run([ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", + "-err_detect", "ignore_err", + "-i", temp_ts_path, + "-map", "0", + "-c", "copy", + temp_mp4_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0: + logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV") + + # Step 2: MP4 → MKV (clean timestamps from MP4) + result_mkv = subprocess.run([ + "ffmpeg", "-y", + "-i", temp_mp4_path, + "-map", "0", + "-c", "copy", + final_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + remux_success = True + logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}") + else: + logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})") + + # Clean up temp MP4 + try: + if os.path.exists(temp_mp4_path): + os.remove(temp_mp4_path) + except Exception: + pass + else: + logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})") + + # Clean up temp TS file only on successful remux if remux_success: try: os.remove(temp_ts_path) + logger.debug(f"Cleaned up temp TS file: {temp_ts_path}") + except Exception as e: + logger.warning(f"Failed to remove temp TS file: {e}") + else: + # Keep TS file for debugging/manual recovery if remux failed + logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}") + # Clean up any partial MKV + try: + if os.path.exists(final_path): + os.remove(final_path) + logger.debug(f"Cleaned up partial MKV file: {final_path}") except Exception: pass + except Exception as e: - logger.warning(f"MKV remux failed: {e}") + logger.warning(f"MKV remux failed with exception: {e}") + # Clean up any partial files on exception + try: + if os.path.exists(final_path): + os.remove(final_path) + except Exception: + pass # Persist final metadata to Recording (status, ended_at, and stream stats if available) try: @@ -2530,13 +2696,9 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None channel_number = None if starting_channel_number is None: - # Mode 1: Use provider numbers when available - if "tvg-chno" in stream_custom_props: - channel_number = float(stream_custom_props["tvg-chno"]) - elif "channel-number" in stream_custom_props: - channel_number = float(stream_custom_props["channel-number"]) - elif "num" in stream_custom_props: - channel_number = float(stream_custom_props["num"]) + # Mode 1: Use provider numbers when available (from stream_chno field) + if stream.stream_chno is not None: + channel_number = stream.stream_chno # For modes 2 and 3 (starting_channel_number == 0 or specific number), # ignore provider numbers and use sequential assignment @@ -2565,6 +2727,7 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None "name": name, "tvc_guide_stationid": tvc_guide_stationid, "tvg_id": stream.tvg_id, + "is_adult": stream.is_adult, } # Only add channel_group_id if the stream has a channel group @@ -2659,7 +2822,38 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None ) # Handle channel profile membership - if profile_ids: + # Semantics: + # - None: add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0 in array: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only + if profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + all_profiles = ChannelProfile.objects.all() + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + elif isinstance(profile_ids, list) and len(profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(profile_ids, list) and 0 in profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + all_profiles = ChannelProfile.objects.all() + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + else: + # Specific profile IDs try: specific_profiles = ChannelProfile.objects.filter(id__in=profile_ids) channel_profile_memberships.extend([ @@ -2675,17 +2869,6 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None 'channel_id': channel.id, 'error': f'Failed to add to profiles: {str(e)}' }) - else: - # Add to all profiles by default - all_profiles = ChannelProfile.objects.all() - channel_profile_memberships.extend([ - ChannelProfileMembership( - channel_profile=profile, - channel=channel, - enabled=True - ) - for profile in all_profiles - ]) # Bulk update channels with logos if update: diff --git a/apps/channels/tests/test_channel_api.py b/apps/channels/tests/test_channel_api.py new file mode 100644 index 00000000..bb245da1 --- /dev/null +++ b/apps/channels/tests/test_channel_api.py @@ -0,0 +1,211 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.channels.models import Channel, ChannelGroup + +User = get_user_model() + + +class ChannelBulkEditAPITests(TestCase): + def setUp(self): + # Create a test admin user (user_level >= 10) and authenticate + self.user = User.objects.create_user(username="testuser", password="testpass123") + self.user.user_level = 10 # Set admin level + self.user.save() + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.bulk_edit_url = "/api/channels/channels/edit/bulk/" + + # Create test channel group + self.group1 = ChannelGroup.objects.create(name="Test Group 1") + self.group2 = ChannelGroup.objects.create(name="Test Group 2") + + # Create test channels + self.channel1 = Channel.objects.create( + channel_number=1.0, + name="Channel 1", + tvg_id="channel1", + channel_group=self.group1 + ) + self.channel2 = Channel.objects.create( + channel_number=2.0, + name="Channel 2", + tvg_id="channel2", + channel_group=self.group1 + ) + self.channel3 = Channel.objects.create( + channel_number=3.0, + name="Channel 3", + tvg_id="channel3" + ) + + def test_bulk_edit_success(self): + """Test successful bulk update of multiple channels""" + data = [ + {"id": self.channel1.id, "name": "Updated Channel 1"}, + {"id": self.channel2.id, "name": "Updated Channel 2", "channel_number": 22.0}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Successfully updated 2 channels") + self.assertEqual(len(response.data["channels"]), 2) + + # Verify database changes + self.channel1.refresh_from_db() + self.channel2.refresh_from_db() + self.assertEqual(self.channel1.name, "Updated Channel 1") + self.assertEqual(self.channel2.name, "Updated Channel 2") + self.assertEqual(self.channel2.channel_number, 22.0) + + def test_bulk_edit_with_empty_validated_data_first(self): + """ + Test the bug fix: when first channel has empty validated_data. + This was causing: ValueError: Field names must be given to bulk_update() + """ + # Create a channel with data that will be "unchanged" (empty validated_data) + # We'll send the same data it already has + data = [ + # First channel: no actual changes (this would create empty validated_data) + {"id": self.channel1.id}, + # Second channel: has changes + {"id": self.channel2.id, "name": "Updated Channel 2"}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + # Should not crash with ValueError + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Successfully updated 2 channels") + + # Verify the channel with changes was updated + self.channel2.refresh_from_db() + self.assertEqual(self.channel2.name, "Updated Channel 2") + + def test_bulk_edit_all_empty_updates(self): + """Test when all channels have empty updates (no actual changes)""" + data = [ + {"id": self.channel1.id}, + {"id": self.channel2.id}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + # Should succeed without calling bulk_update + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Successfully updated 2 channels") + + def test_bulk_edit_mixed_fields(self): + """Test bulk update where different channels update different fields""" + data = [ + {"id": self.channel1.id, "name": "New Name 1"}, + {"id": self.channel2.id, "channel_number": 99.0}, + {"id": self.channel3.id, "tvg_id": "new_tvg_id", "name": "New Name 3"}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Successfully updated 3 channels") + + # Verify all updates + self.channel1.refresh_from_db() + self.channel2.refresh_from_db() + self.channel3.refresh_from_db() + + self.assertEqual(self.channel1.name, "New Name 1") + self.assertEqual(self.channel2.channel_number, 99.0) + self.assertEqual(self.channel3.tvg_id, "new_tvg_id") + self.assertEqual(self.channel3.name, "New Name 3") + + def test_bulk_edit_with_channel_group(self): + """Test bulk update with channel_group_id changes""" + data = [ + {"id": self.channel1.id, "channel_group_id": self.group2.id}, + {"id": self.channel3.id, "channel_group_id": self.group1.id}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify group changes + self.channel1.refresh_from_db() + self.channel3.refresh_from_db() + self.assertEqual(self.channel1.channel_group, self.group2) + self.assertEqual(self.channel3.channel_group, self.group1) + + def test_bulk_edit_nonexistent_channel(self): + """Test bulk update with a channel that doesn't exist""" + nonexistent_id = 99999 + data = [ + {"id": nonexistent_id, "name": "Should Fail"}, + {"id": self.channel1.id, "name": "Should Still Update"}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + # Should return 400 with errors + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("errors", response.data) + self.assertEqual(len(response.data["errors"]), 1) + self.assertEqual(response.data["errors"][0]["channel_id"], nonexistent_id) + self.assertEqual(response.data["errors"][0]["error"], "Channel not found") + + # The valid channel should still be updated + self.assertEqual(response.data["updated_count"], 1) + + def test_bulk_edit_validation_error(self): + """Test bulk update with invalid data (validation error)""" + data = [ + {"id": self.channel1.id, "channel_number": "invalid_number"}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + # Should return 400 with validation errors + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("errors", response.data) + self.assertEqual(len(response.data["errors"]), 1) + self.assertIn("channel_number", response.data["errors"][0]["errors"]) + + def test_bulk_edit_empty_channel_updates(self): + """Test bulk update with empty list""" + data = [] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + # Empty list is accepted and returns success with 0 updates + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Successfully updated 0 channels") + + def test_bulk_edit_missing_channel_updates(self): + """Test bulk update without proper format (dict instead of list)""" + data = {"channel_updates": {}} + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error"], "Expected a list of channel updates") + + def test_bulk_edit_preserves_other_fields(self): + """Test that bulk update only changes specified fields""" + original_channel_number = self.channel1.channel_number + original_tvg_id = self.channel1.tvg_id + + data = [ + {"id": self.channel1.id, "name": "Only Name Changed"}, + ] + + response = self.client.patch(self.bulk_edit_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify only name changed, other fields preserved + self.channel1.refresh_from_db() + self.assertEqual(self.channel1.name, "Only Name Changed") + self.assertEqual(self.channel1.channel_number, original_channel_number) + self.assertEqual(self.channel1.tvg_id, original_tvg_id) diff --git a/apps/connect/__init__.py b/apps/connect/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/api_urls.py b/apps/connect/api_urls.py new file mode 100644 index 00000000..664c437b --- /dev/null +++ b/apps/connect/api_urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from .api_views import ( + IntegrationViewSet, + EventSubscriptionViewSet, + DeliveryLogViewSet, +) + +app_name = 'connect' + +router = DefaultRouter() +router.register(r'integrations', IntegrationViewSet, basename='integration') +router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription') +router.register(r'logs', DeliveryLogViewSet, basename='delivery-log') + +urlpatterns = [] +urlpatterns += router.urls diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py new file mode 100644 index 00000000..de3903c0 --- /dev/null +++ b/apps/connect/api_views.py @@ -0,0 +1,196 @@ +from rest_framework import viewsets, status +from rest_framework.pagination import PageNumberPagination +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.response import Response +from rest_framework.decorators import action +from django.utils import timezone +from .models import Integration, EventSubscription, DeliveryLog +from .serializers import ( + IntegrationSerializer, + EventSubscriptionSerializer, + DeliveryLogSerializer, +) +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, + IsAdmin, +) +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler + + +class IntegrationViewSet(viewsets.ModelViewSet): + queryset = Integration.objects.all() + serializer_class = IntegrationSerializer + + def get_permissions(self): + try: + perms = permission_classes_by_action[self.action] + except KeyError: + # Respect view/action-specific permission_classes if provided; fallback to Authenticated + perms = getattr(self, "permission_classes", [Authenticated]) + return [perm() for perm in perms] + + @action(detail=True, methods=["get"], url_path="subscriptions") + def list_subscriptions(self, request, pk=None): + qs = EventSubscription.objects.filter(integration_id=pk) + serializer = EventSubscriptionSerializer(qs, many=True) + return Response(serializer.data) + + @action(detail=True, methods=["put"], url_path=r"subscriptions/set") + def set_subscriptions(self, request, pk=None): + """ + Replace the integration's subscriptions with the provided list. + Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...] + Any existing subscriptions not in the list will be deleted; missing ones will be created/updated. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response( + {"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND + ) + + data = request.data + if not isinstance(data, list): + return Response( + {"detail": "Expected a list of subscriptions"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate incoming items using serializer (without integration field) + # We'll attach the integration explicitly + valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES) + incoming = [] + for item in data: + if not isinstance(item, dict): + return Response( + {"detail": "Each subscription must be an object"}, + status=status.HTTP_400_BAD_REQUEST, + ) + event = item.get("event") + if event not in valid_events: + return Response( + {"detail": f"Invalid event: {event}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + incoming.append( + { + "event": event, + "enabled": bool(item.get("enabled", True)), + "payload_template": item.get("payload_template"), + } + ) + + incoming_events = {s["event"] for s in incoming} + + # Delete subscriptions that are no longer present + EventSubscription.objects.filter(integration=integration).exclude( + event__in=incoming_events + ).delete() + + # Upsert incoming subscriptions + updated = [] + for sub in incoming: + obj, _created = EventSubscription.objects.update_or_create( + integration=integration, + event=sub["event"], + defaults={ + "enabled": sub["enabled"], + "payload_template": sub.get("payload_template"), + }, + ) + updated.append(obj) + + serializer = EventSubscriptionSerializer(updated, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin]) + def test(self, request, pk=None): + """ + Execute a saved integration (connect) with a dummy payload to verify configuration. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND) + + # Build a dummy payload similar to system events + now = timezone.now().isoformat() + dummy_payload = { + "event": "test", + "timestamp": now, + "channel_name": "Test Channel", + "stream_name": "Test Stream", + "stream_url": "http://example.com/stream.m3u8", + "channel_url": "http://example.com/stream.m3u8", + "provider_name": "Test Provider", + "profile_used": "Default", + "test": True, + } + + # Choose handler based on saved type + if integration.type == "webhook": + handler = WebhookHandler(integration, None, dummy_payload) + elif integration.type == "script": + handler = ScriptHandler(integration, None, dummy_payload) + else: + return Response( + {"success": False, "error": f"Unsupported integration type: {integration.type}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + result = handler.execute() + return Response( + { + "success": bool(result.get("success")), + "type": integration.type, + "request_payload": dummy_payload, + "result": result, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + { + "success": False, + "type": integration.type, + "request_payload": dummy_payload, + "error": str(e), + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + + +class EventSubscriptionViewSet(viewsets.ModelViewSet): + queryset = EventSubscription.objects.all() + serializer_class = EventSubscriptionSerializer + + +class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet): + queryset = DeliveryLog.objects.all().order_by("-created_at") + serializer_class = DeliveryLogSerializer + filter_backends = [DjangoFilterBackend] + + # Support server-side pagination with page_size query param + class ConnectLogsPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = "page_size" + max_page_size = 250 + + pagination_class = ConnectLogsPagination + + def get_queryset(self): + qs = super().get_queryset() + + # Optional filters: integration id and type + integration_id = self.request.query_params.get("integration") + if integration_id: + qs = qs.filter(subscription__integration_id=integration_id) + + integration_type = self.request.query_params.get("type") + if integration_type: + qs = qs.filter(subscription__integration__type=integration_type) + + return qs diff --git a/apps/connect/apps.py b/apps/connect/apps.py new file mode 100644 index 00000000..db063fa4 --- /dev/null +++ b/apps/connect/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ConnectConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.connect' + verbose_name = "Connect Integrations" + label = 'dispatcharr_connect' diff --git a/apps/connect/handlers/__init__.py b/apps/connect/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/api.py b/apps/connect/handlers/api.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/base.py b/apps/connect/handlers/base.py new file mode 100644 index 00000000..d61b7c53 --- /dev/null +++ b/apps/connect/handlers/base.py @@ -0,0 +1,12 @@ +# connect/handlers/base.py +import abc + +class IntegrationHandler(abc.ABC): + def __init__(self, integration, subscription, payload): + self.integration = integration + self.subscription = subscription + self.payload = payload + + @abc.abstractmethod + def execute(self): + pass diff --git a/apps/connect/handlers/script.py b/apps/connect/handlers/script.py new file mode 100644 index 00000000..b6aef79c --- /dev/null +++ b/apps/connect/handlers/script.py @@ -0,0 +1,81 @@ +# connect/handlers/script.py +import os +import stat +import subprocess +from django.conf import settings +from .base import IntegrationHandler + + +def _is_path_allowed(real_path: str) -> bool: + # Ensure path is within one of the allowed directories + for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []): + base_abs = os.path.abspath(base) + os.sep + if real_path.startswith(base_abs): + return True + return False + + +class ScriptHandler(IntegrationHandler): + def execute(self): + raw_path = self.integration.config.get("path") + if not raw_path: + raise ValueError("Missing 'path' in integration config") + + # Resolve and validate path + real_path = os.path.abspath(os.path.realpath(raw_path)) + + if not os.path.exists(real_path): + raise FileNotFoundError(f"Script not found: {real_path}") + + if not _is_path_allowed(real_path): + raise PermissionError( + f"Script path '{real_path}' not within allowed directories: " + f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}" + ) + + if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True): + if not os.access(real_path, os.X_OK): + raise PermissionError(f"Script is not executable: {real_path}") + + if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True): + st = os.stat(real_path) + if st.st_mode & stat.S_IWOTH: + raise PermissionError( + f"Refusing to execute world-writable script: {real_path}" + ) + + # Build a sanitized minimal environment; avoid inheriting secrets + env = { + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + for key, value in (self.payload or {}).items(): + env_key = f"DISPATCHARR_{str(key).upper()}" + env[env_key] = "" if value is None else str(value) + + # Run with a timeout to prevent hanging scripts + timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10) + max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536) + + result = subprocess.run( + [real_path], + capture_output=True, + text=True, + env=env, + timeout=timeout, + cwd=os.path.dirname(real_path) or None, + ) + + # Truncate outputs to avoid excessive memory/logging + stdout = result.stdout or "" + stderr = result.stderr or "" + if len(stdout) > max_out: + stdout = stdout[:max_out] + "... [truncated]" + if len(stderr) > max_out: + stderr = stderr[:max_out] + "... [truncated]" + + return { + "exit_code": result.returncode, + "stdout": stdout, + "stderr": stderr, + "success": result.returncode == 0, + } diff --git a/apps/connect/handlers/webhook.py b/apps/connect/handlers/webhook.py new file mode 100644 index 00000000..ccca530a --- /dev/null +++ b/apps/connect/handlers/webhook.py @@ -0,0 +1,10 @@ +# connect/handlers/webhook.py +import requests +from .base import IntegrationHandler + +class WebhookHandler(IntegrationHandler): + def execute(self): + url = self.integration.config.get("url") + headers = self.integration.config.get("headers", {}) + response = requests.post(url, json=self.payload, headers=headers, timeout=10) + return {"status_code": response.status_code, "body": response.text, "success": response.ok} diff --git a/apps/connect/migrations/0001_initial.py b/apps/connect/migrations/0001_initial.py new file mode 100644 index 00000000..cd8f6b42 --- /dev/null +++ b/apps/connect/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2026-01-27 21:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EventSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)), + ('enabled', models.BooleanField(default=True)), + ('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)), + ], + ), + migrations.CreateModel( + name='Integration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)), + ('config', models.JSONField(default=dict)), + ('enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='DeliveryLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)), + ('request_payload', models.JSONField(blank=True, default=dict)), + ('response_payload', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')), + ], + ), + migrations.AddField( + model_name='eventsubscription', + name='integration', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'), + ), + ] diff --git a/apps/connect/migrations/__init__.py b/apps/connect/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/models.py b/apps/connect/models.py new file mode 100644 index 00000000..0b33a85e --- /dev/null +++ b/apps/connect/models.py @@ -0,0 +1,47 @@ +from django.db import models + +SUPPORTED_EVENTS = { + "channel_start": "Channel Started", + "channel_stop": "Channel Stopped", + "channel_reconnect": "Channel Reconnected", + "channel_error": "Channel Error", + "channel_failover": "Channel Failover", + "stream_switch": "Stream Switch", + "recording_start": "Recording Started", + "recording_end": "Recording Ended", + "epg_refresh": "EPG Refreshed", + "m3u_refresh": "M3U Refreshed", + "client_connect": "Client Connected", + "client_disconnect": "Client Disconnected", + "login_failed": "Login Failed", + "epg_blocked": "EPG Blocked", + "m3u_blocked": "M3U Blocked", +} + +class Integration(models.Model): + TYPE_CHOICES = [ + ("webhook", "Webhook"), + ("api", "API"), + ("script", "Custom Script"), + ] + name = models.CharField(max_length=255) + type = models.CharField(max_length=50, choices=TYPE_CHOICES) + config = models.JSONField(default=dict) + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class EventSubscription(models.Model): + EVENT_CHOICES = list(SUPPORTED_EVENTS.items()) + event = models.CharField(max_length=100, choices=EVENT_CHOICES) + integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions") + enabled = models.BooleanField(default=True) + payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload") + +class DeliveryLog(models.Model): + subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs") + status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")]) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/connect/serializers.py b/apps/connect/serializers.py new file mode 100644 index 00000000..832fc14b --- /dev/null +++ b/apps/connect/serializers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers +from .models import Integration, EventSubscription, DeliveryLog +import os + + +class EventSubscriptionSerializer(serializers.ModelSerializer): + class Meta: + model = EventSubscription + fields = [ + "id", + "event", + "enabled", + "payload_template", + "integration", + ] + + +class IntegrationSerializer(serializers.ModelSerializer): + subscriptions = EventSubscriptionSerializer(many=True, read_only=True) + + class Meta: + model = Integration + fields = [ + "id", + "name", + "type", + "config", + "enabled", + "created_at", + "subscriptions", + ] + + def validate(self, attrs): + type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None) + config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {}) + + if type == "script": + path = (config or {}).get("path") + if not path or not isinstance(path, str): + raise serializers.ValidationError({"config": "Script config must include a 'path' string"}) + + real_path = os.path.abspath(os.path.realpath(path)) + if not os.path.exists(real_path): + raise serializers.ValidationError({"config": f"Script path does not exist: {path}"}) + elif type == "webhook": + url = (config or {}).get("url") + if not url or not isinstance(url, str): + raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"}) + else: + raise serializers.ValidationError({"type": "Unsupported integration type"}) + + return attrs + + +class DeliveryLogSerializer(serializers.ModelSerializer): + subscription = EventSubscriptionSerializer(read_only=True) + + class Meta: + model = DeliveryLog + fields = [ + "id", + "subscription", + "status", + "request_payload", + "response_payload", + "error_message", + "created_at", + ] diff --git a/apps/connect/utils.py b/apps/connect/utils.py new file mode 100644 index 00000000..6144a6b3 --- /dev/null +++ b/apps/connect/utils.py @@ -0,0 +1,115 @@ +# connect/utils.py +import logging, json +from django.template import Template, Context +from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler +from apps.plugins.loader import PluginManager + +logger = logging.getLogger(__name__) + +HANDLERS = { + "webhook": WebhookHandler, + "script": ScriptHandler, +} + + +def trigger_event(event_name, payload): + if event_name not in SUPPORTED_EVENTS: + logger.debug(f"Unsupported event '{event_name}' - skipping") + return + + logger.debug( + f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}" + ) + subscriptions = EventSubscription.objects.filter( + event=event_name, enabled=True + ).select_related("integration") + + count = subscriptions.count() + logger.info(f"Found {count} connect subscription(s) for event '{event_name}'") + + # First, fetch all subscriptions and trigger + for sub in subscriptions: + integration = sub.integration + if not integration.enabled: + logger.debug( + f"Skipping disabled integration id={integration.id} name={integration.name}" + ) + continue + + # apply optional payload template + final_payload = payload + if sub.payload_template: + try: + template = Template(sub.payload_template) + rendered = template.render(Context(payload)) + final_payload = {"message": rendered} + except Exception as e: + logger.error( + f"Payload template render failed for subscription id={sub.id}: {e}" + ) + final_payload = payload + + handler_cls = HANDLERS.get(integration.type) + if not handler_cls: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=f"No handler for integration type '{integration.type}'", + ) + logger.error( + f"No handler for integration type '{integration.type}' (integration id={integration.id})" + ) + continue + + handler = handler_cls(integration, sub, final_payload) + logger.debug( + f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}" + ) + + try: + result = handler.execute() + DeliveryLog.objects.create( + subscription=sub, + status="success" if result.get("success") else "failed", + request_payload=final_payload, + response_payload=result, + ) + logger.info( + f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'" + ) + except Exception as e: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=str(e), + ) + logger.error( + f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}" + ) + + pm = PluginManager.get() + pm.discover_plugins(sync_db=False, use_cache=True) + plugins = pm.list_plugins() + + logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'") + for plugin in plugins: + if not plugin["enabled"]: + logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}") + continue + + logger.debug(json.dumps(plugin)) + for action in plugin["actions"]: + if "events" in action and event_name in action["events"]: + key = plugin["key"] + params = {"event": event_name, "payload": payload} + action_name = action.get("label") or action.get("id") + action_id = action.get("id") + logger.debug( + f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}" + ) + if action_id: + pm.run_action(key, action_id, params) diff --git a/apps/epg/api_urls.py b/apps/epg/api_urls.py index 2818e66b..ed4b3105 100644 --- a/apps/epg/api_urls.py +++ b/apps/epg/api_urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet +from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView app_name = 'epg' @@ -12,6 +12,7 @@ router.register(r'epgdata', EPGDataViewSet, basename='epgdata') urlpatterns = [ path('grid/', EPGGridAPIView.as_view(), name='epg_grid'), path('import/', EPGImportAPIView.as_view(), name='epg_import'), + path('current-programs/', CurrentProgramsAPIView.as_view(), name='current_programs'), ] urlpatterns += router.urls diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 2fc5a743..15613d4d 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,10 +1,10 @@ import logging, os -from rest_framework import viewsets, status +from rest_framework import viewsets, status, serializers from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.decorators import action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes from django.utils import timezone from datetime import timedelta from .models import EPGSource, ProgramData, EPGData # Added ProgramData @@ -31,7 +31,9 @@ class EPGSourceViewSet(viewsets.ModelViewSet): API endpoint that allows EPG sources to be viewed or edited. """ - queryset = EPGSource.objects.all() + queryset = EPGSource.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).all() serializer_class = EPGSourceSerializer def get_permissions(self): @@ -122,8 +124,8 @@ class EPGGridAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", + @extend_schema( + description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", responses={200: ProgramDataSerializer(many=True)}, ) def get(self, request, format=None): @@ -371,9 +373,8 @@ class EPGImportAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers an EPG data import", - responses={202: "EPG data import initiated"}, + @extend_schema( + description="Triggers an EPG data import", ) def post(self, request, format=None): logger.info("EPGImportAPIView: Received request to import EPG data.") @@ -417,3 +418,89 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet): except KeyError: return [Authenticated()] + +# ───────────────────────────── +# 6) Current Programs API +# ───────────────────────────── +class CurrentProgramsAPIView(APIView): + """ + Lightweight endpoint that returns currently playing programs for specified channel IDs. + Accepts POST with JSON body containing channel_ids array, or null/empty to fetch all channels. + """ + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Get currently playing programs for specified channels or all channels", + request=inline_serializer( + name="CurrentProgramsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.", + ), + }, + ), + responses={200: ProgramDataSerializer(many=True)}, + ) + def post(self, request, format=None): + # Get channel IDs from request body + channel_ids = request.data.get('channel_ids', None) + + # Import Channel model + from apps.channels.models import Channel + + # Build query for channels with EPG data + query = Channel.objects.filter(epg_data__isnull=False) + + # Filter by specific channel IDs if provided + if channel_ids is not None: + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be an array of integers or null"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + channel_ids = [int(id) for id in channel_ids] + except (ValueError, TypeError): + return Response( + {"error": "channel_ids must contain valid integers"}, + status=status.HTTP_400_BAD_REQUEST + ) + + query = query.filter(id__in=channel_ids) + + # Get channels with EPG data + channels = query.select_related('epg_data') + + # Get current time + now = timezone.now() + + # Build list of current programs + current_programs = [] + + for channel in channels: + # Query for current program + program = ProgramData.objects.filter( + epg=channel.epg_data, + start_time__lte=now, + end_time__gt=now + ).first() + + if program: + # Serialize program and add channel_id for easy mapping + program_data = ProgramDataSerializer(program).data + program_data['channel_id'] = channel.id + current_programs.append(program_data) + + return Response(current_programs, status=status.HTTP_200_OK) + diff --git a/apps/epg/migrations/0021_epgsource_priority.py b/apps/epg/migrations/0021_epgsource_priority.py new file mode 100644 index 00000000..f2696d67 --- /dev/null +++ b/apps/epg/migrations/0021_epgsource_priority.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-12-05 15:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0020_migrate_time_to_starttime_placeholders'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='priority', + field=models.PositiveIntegerField(default=0, help_text='Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel.'), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index e5f3847b..b3696edc 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -45,6 +45,10 @@ class EPGSource(models.Model): null=True, help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)" ) + priority = models.PositiveIntegerField( + default=0, + help_text="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel." + ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index bfb750fc..07775809 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -12,6 +12,7 @@ class EPGSourceSerializer(serializers.ModelSerializer): allow_null=True, validators=[validate_flexible_url] ) + cron_expression = serializers.CharField(required=False, allow_blank=True, default='') class Meta: model = EPGSource @@ -24,6 +25,8 @@ class EPGSourceSerializer(serializers.ModelSerializer): 'is_active', 'file_path', 'refresh_interval', + 'cron_expression', + 'priority', 'status', 'last_message', 'created_at', @@ -36,6 +39,42 @@ class EPGSourceSerializer(serializers.ModelSerializer): """Return the count of EPG data entries instead of all IDs to prevent large payloads""" return obj.epgs.count() + def to_representation(self, instance): + data = super().to_representation(instance) + # Derive cron_expression from the linked PeriodicTask's crontab (single source of truth) + # But first check if we have a transient _cron_expression (from create/update before signal runs) + cron_expr = '' + if hasattr(instance, '_cron_expression'): + cron_expr = instance._cron_expression + elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' + data['cron_expression'] = cron_expr + return data + + def update(self, instance, validated_data): + # Pop cron_expression before it reaches model fields + # If not present (partial update), preserve the existing cron from the PeriodicTask + if 'cron_expression' in validated_data: + cron_expr = validated_data.pop('cron_expression') + else: + cron_expr = '' + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' + instance._cron_expression = cron_expr + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + + def create(self, validated_data): + cron_expr = validated_data.pop('cron_expression', '') + instance = EPGSource(**validated_data) + instance._cron_expression = cron_expr + instance.save() + return instance + class ProgramDataSerializer(serializers.ModelSerializer): class Meta: model = ProgramData diff --git a/apps/epg/signals.py b/apps/epg/signals.py index e41d3aaf..97992ef3 100644 --- a/apps/epg/signals.py +++ b/apps/epg/signals.py @@ -2,7 +2,7 @@ from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from .models import EPGSource, EPGData from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id -from django_celery_beat.models import PeriodicTask, IntervalSchedule +from core.scheduling import create_or_update_periodic_task, delete_periodic_task from core.utils import is_protected_path, send_websocket_update import json import logging @@ -70,10 +70,11 @@ def create_dummy_epg_data(sender, instance, created, **kwargs): logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})") @receiver(post_save, sender=EPGSource) -def create_or_update_refresh_task(sender, instance, **kwargs): +def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs): """ Create or update a Celery Beat periodic task when an EPGSource is created/updated. Skip creating tasks for dummy EPG sources as they don't need refreshing. + Supports both interval-based and cron-based scheduling via the shared utility. """ # Skip task creation for dummy EPGs if instance.source_type == 'dummy': @@ -83,39 +84,48 @@ def create_or_update_refresh_task(sender, instance, **kwargs): instance.refresh_task.save(update_fields=['enabled']) return + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return + task_name = f"epg_source-refresh-{instance.id}" - interval, _ = IntervalSchedule.objects.get_or_create( - every=int(instance.refresh_interval), - period=IntervalSchedule.HOURS + should_be_enabled = instance.is_active + + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass + + task = create_or_update_periodic_task( + task_name=task_name, + celery_task_path="apps.epg.tasks.refresh_epg_data", + kwargs={"source_id": instance.id}, + interval_hours=int(instance.refresh_interval), + cron_expression=cron_expr, + enabled=should_be_enabled, ) - task, created = PeriodicTask.objects.get_or_create(name=task_name, defaults={ - "interval": interval, - "task": "apps.epg.tasks.refresh_epg_data", - "kwargs": json.dumps({"source_id": instance.id}), - "enabled": instance.refresh_interval != 0 and instance.is_active, - }) - - update_fields = [] - if created: - task.interval = interval - - if task.interval != interval: - task.interval = interval - update_fields.append("interval") - - # Check both refresh_interval and is_active to determine if task should be enabled - should_be_enabled = instance.refresh_interval != 0 and instance.is_active - if task.enabled != should_be_enabled: - task.enabled = should_be_enabled - update_fields.append("enabled") - - if update_fields: - task.save(update_fields=update_fields) - if instance.refresh_task != task: instance.refresh_task = task - instance.save(update_fields=["refresh_task"]) # Fixed field name + instance.save(update_fields=["refresh_task"]) @receiver(post_delete, sender=EPGSource) def delete_refresh_task(sender, instance, **kwargs): diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index c565dbf5..9dc597d3 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -24,7 +24,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from .models import EPGSource, EPGData, ProgramData -from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event +from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event logger = logging.getLogger(__name__) @@ -146,12 +146,15 @@ def refresh_all_epg_data(): return "EPG data refreshed." -@shared_task +@shared_task(time_limit=1800, soft_time_limit=1700) def refresh_epg_data(source_id): if not acquire_task_lock('refresh_epg_data', source_id): logger.debug(f"EPG refresh for {source_id} already running") return + lock_renewer = TaskLockRenewer('refresh_epg_data', source_id) + lock_renewer.start() + source = None try: # Try to get the EPG source @@ -168,6 +171,7 @@ def refresh_epg_data(source_id): logger.info(f"No orphaned task found for EPG source {source_id}") # Release the lock and exit + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -176,6 +180,7 @@ def refresh_epg_data(source_id): # The source exists but is not active, just skip processing if not source.is_active: logger.info(f"EPG source {source_id} is not active. Skipping.") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -184,6 +189,7 @@ def refresh_epg_data(source_id): # Skip refresh for dummy EPG sources - they don't need refreshing if source.source_type == 'dummy': logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) gc.collect() return @@ -194,6 +200,7 @@ def refresh_epg_data(source_id): fetch_success = fetch_xmltv(source) if not fetch_success: logger.error(f"Failed to fetch XMLTV for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -202,6 +209,7 @@ def refresh_epg_data(source_id): parse_channels_success = parse_channels_only(source) if not parse_channels_success: logger.error(f"Failed to parse channels for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -234,6 +242,7 @@ def refresh_epg_data(source_id): source = None # Force garbage collection before releasing the lock gc.collect() + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) @@ -286,11 +295,12 @@ def fetch_xmltv(source): logger.info(f"Fetching XMLTV data from source: {source.name}") try: # Get default user agent from settings - default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first() + stream_settings = CoreSettings.get_stream_settings() user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default - if default_user_agent_setting and default_user_agent_setting.value: + default_user_agent_id = stream_settings.get('default_user_agent') + if default_user_agent_id: try: - user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first() + user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first() if user_agent_obj and user_agent_obj.user_agent: user_agent = user_agent_obj.user_agent logger.debug(f"Using default user agent: {user_agent}") @@ -1125,12 +1135,15 @@ def parse_channels_only(source): -@shared_task +@shared_task(time_limit=3600, soft_time_limit=3500) def parse_programs_for_tvg_id(epg_id): if not acquire_task_lock('parse_epg_programs', epg_id): logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") return "Task already running" + lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) + lock_renewer.start() + source_file = None program_parser = None programs_to_create = [] @@ -1160,11 +1173,13 @@ def parse_programs_for_tvg_id(epg_id): # Skip program parsing for dummy EPG sources - they don't have program data files if epg_source.source_type == 'dummy': logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return if not Channel.objects.filter(epg_data=epg).exists(): logger.info(f"No channels matched to EPG {epg.tvg_id}") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1206,6 +1221,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, cannot parse programs" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1216,6 +1232,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, file missing after download" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1231,6 +1248,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"No URL provided, cannot fetch EPG data" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1378,7 +1396,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source = None # Add comprehensive cleanup before releasing lock cleanup_memory(log_usage=should_log_memory, force_collection=True) - # Memory tracking after processing + # Memory tracking after processing if process: try: mem_after = process.memory_info().rss / 1024 / 1024 @@ -1388,6 +1406,7 @@ def parse_programs_for_tvg_id(epg_id): process = None epg = None programs_processed = None + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) @@ -1650,7 +1669,7 @@ def parse_programs_for_source(epg_source, tvg_id=None): epg_source.status = EPGSource.STATUS_SUCCESS epg_source.last_message = ( f"Parsed {total_programs:,} programs for {channels_with_programs} channels " - f"(skipped {skipped_programs:,} programmes for {total_epg_count - mapped_count} unmapped channels)" + f"(skipped {skipped_programs:,} programs for {total_epg_count - mapped_count} unmapped channels)" ) epg_source.updated_at = timezone.now() epg_source.save(update_fields=['status', 'last_message', 'updated_at']) @@ -1672,8 +1691,8 @@ def parse_programs_for_source(epg_source, tvg_id=None): updated_at=epg_source.updated_at.isoformat()) logger.info(f"Completed parsing programs for source: {epg_source.name} - " - f"{total_programs:,} programs for {channels_with_programs} channels, " - f"skipped {skipped_programs:,} programmes for unmapped channels") + f"{total_programs:,} programs for {channels_with_programs} channels, " + f"skipped {skipped_programs:,} programs for unmapped channels") return True except Exception as e: @@ -1714,12 +1733,13 @@ def fetch_schedules_direct(source): logger.info(f"Fetching Schedules Direct data from source: {source.name}") try: # Get default user agent from settings - default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first() + stream_settings = CoreSettings.get_stream_settings() user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default + default_user_agent_id = stream_settings.get('default_user_agent') - if default_user_agent_setting and default_user_agent_setting.value: + if default_user_agent_id: try: - user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first() + user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first() if user_agent_obj and user_agent_obj.user_agent: user_agent = user_agent_obj.user_agent logger.debug(f"Using default user agent: {user_agent}") diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 8f1609d4..2227170e 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -4,8 +4,8 @@ from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse import logging -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.db import models from apps.channels.models import Channel, ChannelProfile, Stream @@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request, profile=None): uri_parts = ["hdhr"] @@ -100,9 +99,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request, profile=None): if profile is not None: @@ -141,9 +139,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request, profile=None): data = { @@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 40823259..f9dd42d2 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -3,8 +3,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from apps.channels.models import Channel from .models import HDHRDevice @@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") @@ -81,9 +80,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request): channels = Channel.objects.all().order_by("channel_number") @@ -102,9 +100,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request): data = { @@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 1f16f20f..26e182d9 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -6,8 +6,8 @@ from apps.accounts.permissions import ( permission_classes_by_action, permission_classes_by_method, ) -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache @@ -37,7 +37,9 @@ import json class M3UAccountViewSet(viewsets.ModelViewSet): """Handles CRUD operations for M3U accounts""" - queryset = M3UAccount.objects.prefetch_related("channel_group") + queryset = M3UAccount.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).prefetch_related("channel_group") serializer_class = M3UAccountSerializer def get_permissions(self): @@ -357,9 +359,8 @@ class RefreshM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of all active M3U accounts", - responses={202: "M3U refresh initiated"}, + @extend_schema( + description="Triggers a refresh of all active M3U accounts", ) def post(self, request, format=None): refresh_m3u_accounts.delay() @@ -380,9 +381,8 @@ class RefreshSingleM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of a single M3U account", - responses={202: "M3U account refresh initiated"}, + @extend_schema( + description="Triggers a refresh of a single M3U account", ) def post(self, request, account_id, format=None): refresh_single_m3u_account.delay(account_id) @@ -406,9 +406,8 @@ class RefreshAccountInfoAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of account information for a specific M3U profile", - responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"}, + @extend_schema( + description="Triggers a refresh of account information for a specific M3U profile", ) def post(self, request, profile_id, format=None): try: diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index a607dc07..22c4057a 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -139,6 +139,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True) auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True) auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True) + cron_expression = serializers.CharField(required=False, allow_blank=True, default="") class Meta: model = M3UAccount @@ -158,6 +159,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): "locked", "channel_groups", "refresh_interval", + "cron_expression", "custom_properties", "account_type", "username", @@ -188,9 +190,30 @@ class M3UAccountSerializer(serializers.ModelSerializer): data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True) data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True) data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True) + + # Derive cron_expression from the linked PeriodicTask's crontab (single source of truth) + # But first check if we have a transient _cron_expression (from create/update before signal runs) + cron_expr = "" + if hasattr(instance, '_cron_expression'): + cron_expr = instance._cron_expression + elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + data["cron_expression"] = cron_expr return data def update(self, instance, validated_data): + # Pop cron_expression before it reaches model fields + # If not present (partial update), preserve the existing cron from the PeriodicTask + if "cron_expression" in validated_data: + cron_expr = validated_data.pop("cron_expression") + else: + cron_expr = "" + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + instance._cron_expression = cron_expr + # Handle enable_vod preference and auto_enable_new_groups settings enable_vod = validated_data.pop("enable_vod", None) auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None) @@ -244,6 +267,9 @@ class M3UAccountSerializer(serializers.ModelSerializer): return instance def create(self, validated_data): + # Pop cron_expression — it's not a model field + cron_expr = validated_data.pop("cron_expression", "") + # Handle enable_vod preference and auto_enable_new_groups settings during creation enable_vod = validated_data.pop("enable_vod", False) auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True) @@ -260,7 +286,11 @@ class M3UAccountSerializer(serializers.ModelSerializer): custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series validated_data["custom_properties"] = custom_props - return super().create(validated_data) + # Build instance manually so we can attach transient attr before save triggers signal + instance = M3UAccount(**validated_data) + instance._cron_expression = cron_expr + instance.save() + return instance def get_filters(self, obj): filters = obj.filters.order_by("order") diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index d014ac92..3de67c90 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -3,7 +3,7 @@ from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from .models import M3UAccount from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id -from django_celery_beat.models import PeriodicTask, IntervalSchedule +from core.scheduling import create_or_update_periodic_task, delete_periodic_task import json import logging @@ -20,51 +20,53 @@ def refresh_account_on_save(sender, instance, created, **kwargs): refresh_m3u_groups.delay(instance.id) @receiver(post_save, sender=M3UAccount) -def create_or_update_refresh_task(sender, instance, **kwargs): +def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs): """ Create or update a Celery Beat periodic task when an M3UAccount is created/updated. + Supports both interval-based and cron-based scheduling via the shared utility. """ - task_name = f"m3u_account-refresh-{instance.id}" + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return - interval, _ = IntervalSchedule.objects.get_or_create( - every=int(instance.refresh_interval), - period=IntervalSchedule.HOURS + task_name = f"m3u_account-refresh-{instance.id}" + should_be_enabled = instance.is_active + + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass + + task = create_or_update_periodic_task( + task_name=task_name, + celery_task_path="apps.m3u.tasks.refresh_single_m3u_account", + kwargs={"account_id": instance.id}, + interval_hours=int(instance.refresh_interval), + cron_expression=cron_expr, + enabled=should_be_enabled, ) - # Task should be enabled only if refresh_interval != 0 AND account is active - should_be_enabled = (instance.refresh_interval != 0) and instance.is_active - - # First check if the task already exists to avoid validation errors - try: - task = PeriodicTask.objects.get(name=task_name) - # Task exists, just update it - updated_fields = [] - - if task.enabled != should_be_enabled: - task.enabled = should_be_enabled - updated_fields.append("enabled") - - if task.interval != interval: - task.interval = interval - updated_fields.append("interval") - - if updated_fields: - task.save(update_fields=updated_fields) - - # Ensure instance has the task - if instance.refresh_task_id != task.id: - M3UAccount.objects.filter(id=instance.id).update(refresh_task=task) - - except PeriodicTask.DoesNotExist: - # Create new task if it doesn't exist - refresh_task = PeriodicTask.objects.create( - name=task_name, - interval=interval, - task="apps.m3u.tasks.refresh_single_m3u_account", - kwargs=json.dumps({"account_id": instance.id}), - enabled=should_be_enabled, - ) - M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task) + # Ensure instance has the task linked + if instance.refresh_task_id != task.id: + M3UAccount.objects.filter(id=instance.id).update(refresh_task=task) @receiver(post_delete, sender=M3UAccount) def delete_refresh_task(sender, instance, **kwargs): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index cb82402e..a9413143 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -23,6 +23,7 @@ from core.utils import ( RedisClient, acquire_task_lock, release_task_lock, + TaskLockRenewer, natural_sort_key, log_system_event, ) @@ -66,7 +67,8 @@ def fetch_m3u_lines(account, use_cache=False): account.save(update_fields=["status", "last_message"]) response = requests.get( - account.server_url, headers=headers, stream=True + account.server_url, headers=headers, stream=True, + timeout=(30, 60), # 30s connect, 60s read between chunks ) # Log the actual response details for debugging @@ -126,119 +128,60 @@ def fetch_m3u_lines(account, use_cache=False): start_time = time.time() last_update_time = start_time progress = 0 - temp_content = b"" # Store content temporarily to validate before saving has_content = False - # First, let's collect the content and validate it - send_m3u_update(account.id, "downloading", 0) - for chunk in response.iter_content(chunk_size=8192): - if chunk: - temp_content += chunk - has_content = True + # Stream directly to a temp file to avoid holding the entire + # M3U in memory (large files can be 100MB+, which would use + # ~3x that in RAM in certain approaches). + temp_path = file_path + ".tmp" + try: + send_m3u_update(account.id, "downloading", 0) + with open(temp_path, "wb") as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + tmp_file.write(chunk) + has_content = True - downloaded += len(chunk) - elapsed_time = time.time() - start_time + downloaded += len(chunk) + elapsed_time = time.time() - start_time - # Calculate download speed in KB/s - speed = downloaded / elapsed_time / 1024 # in KB/s + # Calculate download speed in KB/s + speed = downloaded / elapsed_time / 1024 # in KB/s - # Calculate progress percentage - if total_size and total_size > 0: - progress = (downloaded / total_size) * 100 + # Calculate progress percentage + if total_size and total_size > 0: + progress = (downloaded / total_size) * 100 - # Time remaining (in seconds) - time_remaining = ( - (total_size - downloaded) / (speed * 1024) - if speed > 0 - else 0 - ) - - current_time = time.time() - if current_time - last_update_time >= 0.5: - last_update_time = current_time - if progress > 0: - # Update the account's last_message with detailed progress info - progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" - account.last_message = progress_msg - account.save(update_fields=["last_message"]) - - send_m3u_update( - account.id, - "downloading", - progress, - speed=speed, - elapsed_time=elapsed_time, - time_remaining=time_remaining, - message=progress_msg, + # Time remaining (in seconds) + time_remaining = ( + (total_size - downloaded) / (speed * 1024) + if speed > 0 + else 0 ) - # Check if we actually received any content - logger.info(f"Download completed. Has content: {has_content}, Content length: {len(temp_content)} bytes") - if not has_content or len(temp_content) == 0: - error_msg = f"Server responded successfully (HTTP {response.status_code}) but provided empty M3U file from URL: {account.server_url}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=["status", "last_message"]) - send_m3u_update( - account.id, - "downloading", - 100, - status="error", - error=error_msg, - ) - return [], False + current_time = time.time() + if current_time - last_update_time >= 0.5: + last_update_time = current_time + if progress > 0: + # Update the account's last_message with detailed progress info + progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" + account.last_message = progress_msg + account.save(update_fields=["last_message"]) - # Basic validation: check if content looks like an M3U file - try: - content_str = temp_content.decode('utf-8', errors='ignore') - content_lines = content_str.strip().split('\n') + send_m3u_update( + account.id, + "downloading", + progress, + speed=speed, + elapsed_time=elapsed_time, + time_remaining=time_remaining, + message=progress_msg, + ) - # Log first few lines for debugging (be careful not to log too much) - preview_lines = content_lines[:5] - logger.info(f"Content preview (first 5 lines): {preview_lines}") - logger.info(f"Total lines in content: {len(content_lines)}") - - # Check if it's a valid M3U file (should start with #EXTM3U or contain M3U-like content) - is_valid_m3u = False - - # First, check if this looks like an error response disguised as 200 OK - content_lower = content_str.lower() - if any(error_indicator in content_lower for error_indicator in [ - ' 0: + logger.info( + f"Found {deleted_count} stale group relationships for account {account.id}: " + f"{[rel.channel_group.name for rel in relations_to_delete]}" + ) + + # Delete the stale relationships + stale_relationships.delete() # Check if any of the deleted relationships left groups with no remaining associations orphaned_group_ids = [] @@ -656,6 +708,10 @@ def process_groups(account, groups): deleted_groups = list(ChannelGroup.objects.filter(id__in=orphaned_group_ids).values_list('name', flat=True)) ChannelGroup.objects.filter(id__in=orphaned_group_ids).delete() logger.info(f"Deleted {len(orphaned_group_ids)} orphaned groups that had no remaining associations: {deleted_groups}") + else: + logger.debug(f"No stale group relationships found for account {account.id}") + + return deleted_count def collect_xc_streams(account_id, enabled_groups): @@ -710,6 +766,7 @@ def collect_xc_streams(account_id, enabled_groups): "group-title": group_info["name"], # Preserve all XC stream properties as custom attributes "stream_id": str(stream.get("stream_id", "")), + "num": stream.get("num"), "category_id": category_id, "stream_type": stream.get("stream_type", ""), "added": stream.get("added", ""), @@ -718,7 +775,7 @@ def collect_xc_streams(account_id, enabled_groups): # Include any other properties that might be present **{k: str(v) for k, v in stream.items() if k not in [ "name", "stream_id", "epg_channel_id", "stream_icon", - "category_id", "stream_type", "added", "is_adult", "custom_sid" + "category_id", "stream_type", "added", "is_adult", "custom_sid", "num" ] and v is not None} } } @@ -786,13 +843,28 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): for stream in streams: name = stream["name"] + raw_stream_id = stream.get("stream_id", "") + provider_stream_id = None + if raw_stream_id: + try: + provider_stream_id = int(raw_stream_id) + except (ValueError, TypeError): + pass url = xc_client.get_stream_url(stream["stream_id"]) tvg_id = stream.get("epg_channel_id", "") tvg_logo = stream.get("stream_icon", "") group_title = group_name + stream_chno = stream.get("num") + # Convert stream_chno to float if valid, otherwise None + if stream_chno is not None: + try: + stream_chno = float(stream_chno) + except (ValueError, TypeError): + stream_chno = None stream_hash = Stream.generate_hash_key( - name, url, tvg_id, hash_keys, m3u_id=account_id + name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, + account_type='XC', stream_id=provider_stream_id ) stream_props = { "name": name, @@ -803,6 +875,10 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(group_id), "stream_hash": stream_hash, "custom_properties": stream, + "is_adult": int(stream.get("is_adult", 0)) == 1, + "is_stale": False, + "stream_id": provider_stream_id, + "stream_chno": stream_chno, } if stream_hash not in stream_hashes: @@ -817,7 +893,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno' ) } @@ -830,7 +906,10 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): obj.url != stream_props["url"] or obj.logo_url != stream_props["logo_url"] or obj.tvg_id != stream_props["tvg_id"] or - obj.custom_properties != stream_props["custom_properties"] + obj.custom_properties != stream_props["custom_properties"] or + obj.is_adult != stream_props["is_adult"] or + obj.stream_id != stream_props["stream_id"] or + obj.stream_chno != stream_props["stream_chno"] ) if changed: @@ -838,10 +917,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): setattr(obj, key, value) obj.last_seen = timezone.now() obj.updated_at = timezone.now() # Update timestamp only for changed streams + obj.is_stale = False streams_to_update.append(obj) else: # Always update last_seen, even if nothing else changed obj.last_seen = timezone.now() + obj.is_stale = False # Don't update updated_at for unchanged streams streams_to_update.append(obj) @@ -852,6 +933,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): stream_props["updated_at"] = ( timezone.now() ) # Set initial updated_at for new streams + stream_props["is_stale"] = False streams_to_create.append(Stream(**stream_props)) try: @@ -863,7 +945,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): # Simplified bulk update for better performance Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], batch_size=150 # Smaller batch size for XC processing ) @@ -966,7 +1048,42 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): ) continue - stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys, m3u_id=account_id) + # Determine provider-specific fields first + provider_stream_id = None + channel_num = None + account_type_for_hash = None + + if account.account_type == M3UAccount.Types.XC: + account_type_for_hash = 'XC' + raw_stream_id = stream_info["attributes"].get("stream_id", "") + if raw_stream_id: + try: + provider_stream_id = int(raw_stream_id) + except (ValueError, TypeError): + pass + raw_num = stream_info["attributes"].get("num") + if raw_num is not None: + try: + channel_num = float(raw_num) + except (ValueError, TypeError): + pass + else: + # For standard M3U accounts, check for tvg-chno or channel-number + tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "tvg-chno", None) + if tvg_chno is None: + tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "channel-number", None) + if tvg_chno is not None: + try: + channel_num = float(tvg_chno) + except (ValueError, TypeError): + pass + + # Generate hash once with all parameters + stream_hash = Stream.generate_hash_key( + name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, + account_type=account_type_for_hash, stream_id=provider_stream_id + ) + stream_props = { "name": name, "url": url, @@ -976,6 +1093,10 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, "custom_properties": stream_info["attributes"], + "is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1, + "is_stale": False, + "stream_id": provider_stream_id, + "stream_chno": channel_num, } if stream_hash not in stream_hashes: @@ -987,7 +1108,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno' ) } @@ -1000,7 +1121,10 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.url != stream_props["url"] or obj.logo_url != stream_props["logo_url"] or obj.tvg_id != stream_props["tvg_id"] or - obj.custom_properties != stream_props["custom_properties"] + obj.custom_properties != stream_props["custom_properties"] or + obj.is_adult != stream_props["is_adult"] or + obj.stream_id != stream_props["stream_id"] or + obj.stream_chno != stream_props["stream_chno"] ) # Always update last_seen @@ -1013,13 +1137,20 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.logo_url = stream_props["logo_url"] obj.tvg_id = stream_props["tvg_id"] obj.custom_properties = stream_props["custom_properties"] + obj.is_adult = stream_props["is_adult"] + obj.stream_id = stream_props["stream_id"] + obj.stream_chno = stream_props["stream_chno"] obj.updated_at = timezone.now() + # Always mark as not stale since we saw it in this refresh + obj.is_stale = False + streams_to_update.append(obj) else: # New stream stream_props["last_seen"] = timezone.now() stream_props["updated_at"] = timezone.now() + stream_props["is_stale"] = False streams_to_create.append(Stream(**stream_props)) try: @@ -1031,7 +1162,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): # Update all streams in a single bulk operation Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], batch_size=200 ) except Exception as e: @@ -1092,13 +1223,25 @@ def cleanup_streams(account_id, scan_start_time=timezone.now): @shared_task -def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): +def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_start_time=None): + """Refresh M3U groups for an account. + + Args: + account_id: ID of the M3U account + use_cache: Whether to use cached M3U file + full_refresh: Whether this is part of a full refresh + scan_start_time: Timestamp when the scan started (for consistent last_seen marking) + """ if not acquire_task_lock("refresh_m3u_account_groups", account_id): return f"Task already running for account_id={account_id}.", None + lock_renewer = TaskLockRenewer("refresh_m3u_account_groups", account_id) + lock_renewer.start() + try: account = M3UAccount.objects.get(id=account_id, is_active=True) except M3UAccount.DoesNotExist: + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return f"M3UAccount with ID={account_id} not found or inactive.", None @@ -1124,6 +1267,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): send_m3u_update( account_id, "processing_groups", 100, status="error", error=error_msg ) + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None @@ -1136,6 +1280,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): send_m3u_update( account_id, "processing_groups", 100, status="error", error=error_msg ) + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None @@ -1212,36 +1357,14 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): ) as xc_client: logger.info(f"XCClient instance created successfully") - # Authenticate with detailed error handling + # Queue async profile refresh task to run in background + # This prevents any delay in the main refresh process try: - logger.debug(f"Authenticating with XC server {server_url}") - auth_result = xc_client.authenticate() - logger.debug(f"Authentication response: {auth_result}") - - # Queue async profile refresh task to run in background - # This prevents any delay in the main refresh process - try: - logger.info(f"Queueing background profile refresh for account {account.name}") - refresh_account_profiles.delay(account.id) - except Exception as e: - logger.warning(f"Failed to queue profile refresh task: {str(e)}") - # Don't fail the main refresh if profile refresh can't be queued - + logger.info(f"Queueing background profile refresh for account {account.name}") + refresh_account_profiles.delay(account.id) except Exception as e: - error_msg = f"Failed to authenticate with XC server: {str(e)}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=["status", "last_message"]) - send_m3u_update( - account_id, - "processing_groups", - 100, - status="error", - error=error_msg, - ) - release_task_lock("refresh_m3u_account_groups", account_id) - return error_msg, None + logger.warning(f"Failed to queue profile refresh task: {str(e)}") + # Don't fail the main refresh if profile refresh can't be queued # Get categories with detailed error handling try: @@ -1267,6 +1390,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): status="error", error=error_msg, ) + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None @@ -1281,7 +1405,19 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): "xc_id": cat_id, } except Exception as e: - error_msg = f"Failed to get categories from XC server: {str(e)}" + # Determine if this is an authentication error or category retrieval error + error_str = str(e).lower() + # Check for authentication-related keywords or HTTP status codes commonly used for auth failures + is_auth_error = any(keyword in error_str for keyword in [ + 'auth', 'credential', 'login', 'unauthorized', 'forbidden', + '401', '403', '512', '513' # HTTP status codes: 401 Unauthorized, 403 Forbidden, 512-513 (non-standard auth failure) + ]) + + if is_auth_error: + error_msg = f"Failed to authenticate with XC server: {str(e)}" + else: + error_msg = f"Failed to get categories from XC server: {str(e)}" + logger.error(error_msg) account.status = M3UAccount.Status.ERROR account.last_message = error_msg @@ -1293,6 +1429,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): status="error", error=error_msg, ) + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None @@ -1309,6 +1446,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): status="error", error=error_msg, ) + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None except Exception as e: @@ -1320,6 +1458,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): send_m3u_update( account_id, "processing_groups", 100, status="error", error=error_msg ) + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None else: @@ -1327,6 +1466,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): lines, success = fetch_m3u_lines(account, use_cache) if not success: # If fetch failed, don't continue processing + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return f"Failed to fetch M3U data for account_id={account_id}.", None @@ -1419,8 +1559,9 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): send_m3u_update(account_id, "processing_groups", 0) - process_groups(account, groups) + process_groups(account, groups, scan_start_time) + lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) if not full_refresh: @@ -1544,6 +1685,13 @@ def sync_auto_channels(account_id, scan_start_time=None): channels_updated = 0 channels_deleted = 0 + # Get all channel numbers that are already in use by other channels (not auto-created by this account) + used_numbers = set( + Channel.objects.exclude( + auto_created=True, auto_created_by=account + ).values_list("channel_number", flat=True) + ) + for group_relation in auto_sync_groups: channel_group = group_relation.channel_group start_number = group_relation.auto_sync_channel_start or 1.0 @@ -1561,6 +1709,8 @@ def sync_auto_channels(account_id, scan_start_time=None): stream_profile_id = None custom_logo_id = None custom_epg_id = None # New option: select specific EPG source (takes priority over force_dummy_epg) + channel_numbering_mode = "fixed" # Default mode + channel_numbering_fallback = 1 # Default fallback for provider mode if group_relation.custom_properties: group_custom_props = group_relation.custom_properties force_dummy_epg = group_custom_props.get("force_dummy_epg", False) @@ -1578,6 +1728,8 @@ def sync_auto_channels(account_id, scan_start_time=None): ) stream_profile_id = group_custom_props.get("stream_profile_id") custom_logo_id = group_custom_props.get("custom_logo_id") + channel_numbering_mode = group_custom_props.get("channel_numbering_mode", "fixed") + channel_numbering_fallback = group_custom_props.get("channel_numbering_fallback", 1) # Determine which group to use for created channels target_group = channel_group @@ -1593,7 +1745,7 @@ def sync_auto_channels(account_id, scan_start_time=None): ) logger.info( - f"Processing auto sync for group: {channel_group.name} (start: {start_number})" + f"Processing auto sync for group: {channel_group.name} (mode: {channel_numbering_mode}, start: {start_number})" ) # Get all current streams in this group for this M3U account, filter out stale streams @@ -1733,21 +1885,35 @@ def sync_auto_channels(account_id, scan_start_time=None): channels_to_renumber = [] temp_channel_number = start_number - # Get all channel numbers that are already in use by other channels (not auto-created by this account) - used_numbers = set( - Channel.objects.exclude( - auto_created=True, auto_created_by=account - ).values_list("channel_number", flat=True) - ) - for stream in current_streams: if stream.id in existing_channel_map: channel = existing_channel_map[stream.id] - # Find next available number starting from temp_channel_number - target_number = temp_channel_number - while target_number in used_numbers: - target_number += 1 + # Determine target number based on numbering mode + if channel_numbering_mode == "provider": + # Use provider number if available, otherwise use fallback with next available logic + if stream.stream_chno is not None: + target_number = stream.stream_chno + # If provider number is already used, find next available + if target_number in used_numbers: + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + else: + # No provider number, use fallback and find next available + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + elif channel_numbering_mode == "next_available": + # Find next available starting from 1 + target_number = 1 + while target_number in used_numbers: + target_number += 1 + else: # fixed mode (default) + # Find next available number starting from temp_channel_number + target_number = temp_channel_number + while target_number in used_numbers: + target_number += 1 # Add this number to used_numbers so we don't reuse it in this batch used_numbers.add(target_number) @@ -1759,9 +1925,11 @@ def sync_auto_channels(account_id, scan_start_time=None): f"Will renumber channel '{channel.name}' to {target_number}" ) - temp_channel_number += 1.0 - if temp_channel_number % 1 != 0: # Has decimal - temp_channel_number = int(temp_channel_number) + 1.0 + # Only increment temp_channel_number in fixed mode + if channel_numbering_mode == "fixed": + temp_channel_number += 1.0 + if temp_channel_number % 1 != 0: # Has decimal + temp_channel_number = int(temp_channel_number) + 1.0 # Bulk update channel numbers if any need renumbering if channels_to_renumber: @@ -1956,10 +2124,31 @@ def sync_auto_channels(account_id, scan_start_time=None): else: # Create new channel - # Find next available channel number - target_number = current_channel_number - while target_number in used_numbers: - target_number += 1 + # Determine channel number based on numbering mode + if channel_numbering_mode == "provider": + # Use provider number if available, otherwise use fallback with next available logic + if stream.stream_chno is not None: + target_number = stream.stream_chno + # If provider number is already used, find next available from fallback + if target_number in used_numbers: + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + else: + # No provider number, use fallback and find next available + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + elif channel_numbering_mode == "next_available": + # Find next available starting from 1 + target_number = 1 + while target_number in used_numbers: + target_number += 1 + else: # fixed mode (default) + # Find next available channel number starting from current_channel_number + target_number = current_channel_number + while target_number in used_numbers: + target_number += 1 # Add this number to used_numbers used_numbers.add(target_number) @@ -2086,10 +2275,11 @@ def sync_auto_channels(account_id, scan_start_time=None): f"Created auto channel: {channel.channel_number} - {channel.name}" ) - # Increment channel number for next iteration - current_channel_number += 1.0 - if current_channel_number % 1 != 0: # Has decimal - current_channel_number = int(current_channel_number) + 1.0 + # Increment channel number for next iteration (only in fixed mode) + if channel_numbering_mode == "fixed": + current_channel_number += 1.0 + if current_channel_number % 1 != 0: # Has decimal + current_channel_number = int(current_channel_number) + 1.0 except Exception as e: logger.error( @@ -2201,10 +2391,12 @@ def get_transformed_credentials(account, profile=None): parsed_url = urllib.parse.urlparse(transformed_complete_url) path_parts = [part for part in parsed_url.path.split('/') if part] - if len(path_parts) >= 2: - # Extract username and password from path - transformed_username = path_parts[1] - transformed_password = path_parts[2] + if len(path_parts) >= 4 and path_parts[-1] == '1234.ts': + # Extract username and password from the known structure: + # .../{live}/{username}/{password}/1234.ts + # Using negative indices so sub-paths in the server URL don't shift extraction + transformed_username = path_parts[-3] + transformed_password = path_parts[-2] # Rebuild server URL without the username/password path transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}" @@ -2438,12 +2630,18 @@ def refresh_account_info(profile_id): release_task_lock("refresh_account_info", profile_id) return error_msg -@shared_task +@shared_task(time_limit=3600, soft_time_limit=3500) def refresh_single_m3u_account(account_id): """Splits M3U processing into chunks and dispatches them as parallel tasks.""" if not acquire_task_lock("refresh_single_m3u_account", account_id): return f"Task already running for account_id={account_id}." + # Keep the lock alive while this long-running task is working. + # Without renewal, the 300s lock TTL can expire during large + # downloads/parses, allowing duplicate tasks to start. + lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id) + lock_renewer.start() + # Record start time refresh_start_timestamp = timezone.now() # For the cleanup function start_time = time.time() # For tracking elapsed time as float @@ -2455,6 +2653,7 @@ def refresh_single_m3u_account(account_id): account = M3UAccount.objects.get(id=account_id, is_active=True) if not account.is_active: logger.debug(f"Account {account_id} is not active, skipping.") + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return @@ -2484,6 +2683,7 @@ def refresh_single_m3u_account(account_id): else: logger.debug(f"No orphaned task found for M3U account {account_id}") + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up" @@ -2526,7 +2726,7 @@ def refresh_single_m3u_account(account_id): if not extinf_data: try: logger.info(f"Calling refresh_m3u_groups for account {account_id}") - result = refresh_m3u_groups(account_id, full_refresh=True) + result = refresh_m3u_groups(account_id, full_refresh=True, scan_start_time=refresh_start_timestamp) logger.trace(f"refresh_m3u_groups result: {result}") # Check for completely empty result or missing groups @@ -2534,6 +2734,7 @@ def refresh_single_m3u_account(account_id): logger.error( f"Failed to refresh M3U groups for account {account_id}: {result}" ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account - download failed or other error" @@ -2567,6 +2768,7 @@ def refresh_single_m3u_account(account_id): status="error", error=f"Error refreshing M3U groups: {str(e)}", ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account" @@ -2590,6 +2792,7 @@ def refresh_single_m3u_account(account_id): status="error", error="No data available for processing", ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account, no data available" @@ -2806,9 +3009,26 @@ def refresh_single_m3u_account(account_id): id=-1 ).exists() # This will never find anything but ensures DB sync + # Mark streams that weren't seen in this refresh as stale (pending deletion) + stale_stream_count = Stream.objects.filter( + m3u_account=account, + last_seen__lt=refresh_start_timestamp + ).update(is_stale=True) + logger.info(f"Marked {stale_stream_count} streams as stale for account {account_id}") + + # Mark group relationships that weren't seen in this refresh as stale (pending deletion) + stale_group_count = ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + last_seen__lt=refresh_start_timestamp + ).update(is_stale=True) + logger.info(f"Marked {stale_group_count} group relationships as stale for account {account_id}") + # Now run cleanup streams_deleted = cleanup_streams(account_id, refresh_start_timestamp) + # Cleanup stale group relationships (follows same retention policy as streams) + cleanup_stale_group_relationships(account, refresh_start_timestamp) + # Run auto channel sync after successful refresh auto_sync_message = "" try: @@ -2883,8 +3103,9 @@ def refresh_single_m3u_account(account_id): account.last_message = f"Error processing M3U: {str(e)}" account.save(update_fields=["status", "last_message"]) raise # Re-raise the exception for Celery to handle - - release_task_lock("refresh_single_m3u_account", account_id) + finally: + lock_renewer.stop() + release_task_lock("refresh_single_m3u_account", account_id) # Aggressive garbage collection # Only delete variables if they exist diff --git a/apps/output/tests.py b/apps/output/tests.py index f87c8340..b1c7d589 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -1,5 +1,8 @@ from django.test import TestCase, Client from django.urls import reverse +from apps.channels.models import Channel, ChannelGroup +from apps.epg.models import EPGData, EPGSource +import xml.etree.ElementTree as ET class OutputM3UTest(TestCase): def setUp(self): @@ -37,3 +40,106 @@ class OutputM3UTest(TestCase): self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) + + +class OutputEPGXMLEscapingTest(TestCase): + """Test XML escaping of channel_id attributes in EPG generation""" + + def setUp(self): + self.client = Client() + self.group = ChannelGroup.objects.create(name="Test Group") + + def test_channel_id_with_ampersand(self): + """Test channel ID with ampersand is properly escaped""" + channel = Channel.objects.create( + channel_number=1.0, + name="Test Channel", + tvg_id="News & Sports", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + # Should contain escaped ampersand + self.assertIn('id="News & Sports"', content) + self.assertNotIn('id="News & Sports"', content) + + # Verify XML is parseable + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG is not valid XML: {e}") + + def test_channel_id_with_angle_brackets(self): + """Test channel ID with < and > characters""" + channel = Channel.objects.create( + channel_number=2.0, + name="HD Channel", + tvg_id="Channel ", + 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" ', + 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\\" "]') + 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}") diff --git a/apps/output/views.py b/apps/output/views.py index bc2bace5..e99c424b 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -7,7 +7,6 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from apps.epg.models import ProgramData from apps.accounts.models import User -from core.models import CoreSettings, NETWORK_ACCESS from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 @@ -129,13 +128,17 @@ def generate_m3u(request, profile_name=None, user=None): return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode())) if user is not None: - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level - channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number") + filters = {"user_level__lte": user.user_level} + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).order_by("channel_number") else: # User has specific limited profiles assigned filters = { @@ -143,6 +146,9 @@ def generate_m3u(request, profile_name=None, user=None): "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( @@ -161,18 +167,7 @@ def generate_m3u(request, profile_name=None, user=None): channelprofilemembership__enabled=True ).order_by('channel_number') else: - if profile_name is not None: - try: - channel_profile = ChannelProfile.objects.get(name=profile_name) - except ChannelProfile.DoesNotExist: - logger.warning("Requested channel profile (%s) during m3u generation does not exist", profile_name) - raise Http404(f"Channel profile '{profile_name}' not found") - channels = Channel.objects.filter( - channelprofilemembership__channel_profile=channel_profile, - channelprofilemembership__enabled=True, - ).order_by("channel_number") - else: - channels = Channel.objects.order_by("channel_number") + channels = Channel.objects.order_by("channel_number") # Check if the request wants to use direct logo URLs instead of cache use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' @@ -185,16 +180,27 @@ def generate_m3u(request, profile_name=None, user=None): tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() # Build EPG URL with query parameters if needed - epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint')) + # Check if this is an XC API request (has username/password in GET params and user is authenticated) + xc_username = request.GET.get('username') + xc_password = request.GET.get('password') + is_xc_request = user is not None and xc_username and xc_password - # Optionally preserve certain query parameters - preserved_params = ['tvg_id_source', 'cachedlogos', 'days'] - query_params = {k: v for k, v in request.GET.items() if k in preserved_params} - if query_params: - from urllib.parse import urlencode - epg_url = f"{epg_base_url}?{urlencode(query_params)}" + if is_xc_request: + # This is an XC API request - use XC-style EPG URL + base_url = build_absolute_uri_with_port(request, '') + epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" else: - epg_url = epg_base_url + # Regular request - use standard EPG endpoint + epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint')) + + # Optionally preserve certain query parameters + preserved_params = ['tvg_id_source', 'cachedlogos', 'days'] + query_params = {k: v for k, v in request.GET.items() if k in preserved_params} + if query_params: + from urllib.parse import urlencode + epg_url = f"{epg_base_url}?{urlencode(query_params)}" + else: + epg_url = epg_base_url # Add x-tvg-url and url-tvg attribute for EPG URL m3u_content = f'#EXTM3U x-tvg-url="{epg_url}" url-tvg="{epg_url}"\n' @@ -249,8 +255,12 @@ def generate_m3u(request, profile_name=None, user=None): f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' ) - # Determine the stream URL based on the direct parameter - if use_direct_urls: + # Determine the stream URL based on request type + if is_xc_request: + # XC API request - use XC-style stream URL format + base_url = build_absolute_uri_with_port(request, '') + stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}" + elif use_direct_urls: # Try to get the first stream's direct URL first_stream = channel.streams.order_by('channelstream__order').first() if first_stream and first_stream.url: @@ -258,12 +268,10 @@ def generate_m3u(request, profile_name=None, user=None): stream_url = first_stream.url else: # Fall back to proxy URL if no direct URL available - base_url = request.build_absolute_uri('/')[:-1] - stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") else: # Standard behavior - use proxy URL - base_url = request.build_absolute_uri('/')[:-1] - stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") m3u_content += extinf_line + stream_url + "\n" @@ -503,6 +511,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone program_duration = custom_properties.get('program_duration', 180) # Minutes title_template = custom_properties.get('title_template', '') + subtitle_template = custom_properties.get('subtitle_template', '') description_template = custom_properties.get('description_template', '') # Templates for upcoming/ended programs @@ -908,6 +917,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust title_parts.append(all_groups['title']) main_event_title = ' - '.join(title_parts) if title_parts else channel_name + if subtitle_template: + main_event_subtitle = format_template(subtitle_template, all_groups) + else: + main_event_subtitle = None + if description_template: main_event_description = format_template(description_template, all_groups) else: @@ -958,6 +972,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": upcoming_title, + "sub_title": None, # No subtitle for filler programs "description": upcoming_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -997,6 +1012,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": event_start_utc, "end_time": event_end_utc, "title": main_event_title, + "sub_title": main_event_subtitle, "description": main_event_description, "custom_properties": main_event_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1041,6 +1057,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": ended_title, + "sub_title": None, # No subtitle for filler programs "description": ended_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1101,6 +1118,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": program_title, + "sub_title": None, # No subtitle for filler programs "description": program_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, @@ -1128,6 +1146,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust title_parts.append(all_groups['title']) title = ' - '.join(title_parts) if title_parts else channel_name + if subtitle_template: + subtitle = format_template(subtitle_template, all_groups) + else: + subtitle = None + if description_template: description = format_template(description_template, all_groups) else: @@ -1164,6 +1187,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": title, + "sub_title": subtitle, "description": description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1200,9 +1224,14 @@ def generate_dummy_epg( # Create program entry with escaped channel name xml_lines.append( - f' ' + f' ' ) xml_lines.append(f" {html.escape(program['title'])}") + + # Add subtitle if available + if program.get('sub_title'): + xml_lines.append(f" {html.escape(program['sub_title'])}") + xml_lines.append(f" {html.escape(program['description'])}") # Add custom_properties if present @@ -1262,13 +1291,17 @@ def generate_epg(request, profile_name=None, user=None): # Get channels based on user/profile if user is not None: - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level - channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number") + filters = {"user_level__lte": user.user_level} + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).order_by("channel_number") else: # User has specific limited profiles assigned filters = { @@ -1276,6 +1309,9 @@ def generate_epg(request, profile_name=None, user=None): "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( @@ -1438,7 +1474,7 @@ def generate_epg(request, profile_name=None, user=None): else: tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) display_name = channel.name - xml_lines.append(f' ') + xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') xml_lines.append(f' ') xml_lines.append(" ") @@ -1513,8 +1549,13 @@ def generate_epg(request, profile_name=None, user=None): stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") # Create program entry with escaped channel name - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present @@ -1562,8 +1603,13 @@ def generate_epg(request, profile_name=None, user=None): start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present @@ -1624,7 +1670,7 @@ def generate_epg(request, profile_name=None, user=None): start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - program_xml = [f' '] + program_xml = [f' '] program_xml.append(f' {html.escape(prog.title)}') # Add subtitle if available @@ -1899,6 +1945,7 @@ def xc_get_user(request): return None user = get_object_or_404(User, username=username) + custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: @@ -2083,7 +2130,7 @@ def xc_get_live_categories(user): from django.db.models import Min response = [] - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access @@ -2120,7 +2167,7 @@ def xc_get_live_categories(user): def xc_get_live_streams(request, user, category_id=None): streams = [] - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access @@ -2129,6 +2176,9 @@ def xc_get_live_streams(request, user, category_id=None): filters = {"user_level__lte": user.user_level} if category_id is not None: filters["channel_group__id"] = category_id + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False channels = Channel.objects.filter(**filters).order_by("channel_number") else: # User has specific limited profiles assigned @@ -2139,6 +2189,9 @@ def xc_get_live_streams(request, user, category_id=None): } if category_id is not None: filters["channel_group__id"] = category_id + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: if not category_id: @@ -2192,10 +2245,10 @@ def xc_get_live_streams(request, user, category_id=None): ) ), "epg_channel_id": str(channel_num_int), - "added": int(channel.created_at.timestamp()), - "is_adult": 0, - "category_id": str(channel.channel_group.id), - "category_ids": [channel.channel_group.id], + "added": str(int(channel.created_at.timestamp())), + "is_adult": int(channel.is_adult), + "category_id": str(channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id), + "category_ids": [channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id], "custom_sid": None, "tv_archive": 0, "direct_source": "", @@ -2218,10 +2271,14 @@ def xc_get_epg(request, user, short=False): # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level - channel = Channel.objects.filter( - id=channel_id, - user_level__lte=user.user_level - ).first() + filters = { + "id": channel_id, + "user_level__lte": user.user_level + } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channel = Channel.objects.filter(**filters).first() else: # User has specific limited profiles assigned filters = { @@ -2230,6 +2287,9 @@ def xc_get_epg(request, user, short=False): "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False channel = Channel.objects.filter(**filters).distinct().first() if not channel: @@ -2269,7 +2329,7 @@ def xc_get_epg(request, user, short=False): # Get the mapped integer for this specific channel channel_num_int = channel_num_map.get(channel.id, int(channel.channel_number)) - limit = request.GET.get('limit', 4) + limit = int(request.GET.get('limit', 4)) if channel.epg_data: # Check if this is a dummy EPG that generates on-demand if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': @@ -2284,18 +2344,22 @@ def xc_get_epg(request, user, short=False): # Has stored programs, use them if short == False: programs = channel.epg_data.programs.filter( - start_time__gte=django_timezone.now() + end_time__gt=django_timezone.now() ).order_by('start_time') else: - programs = channel.epg_data.programs.all().order_by('start_time')[:limit] + programs = channel.epg_data.programs.filter( + end_time__gt=django_timezone.now() + ).order_by('start_time')[:limit] else: # Regular EPG with stored programs if short == False: programs = channel.epg_data.programs.filter( - start_time__gte=django_timezone.now() + end_time__gt=django_timezone.now() ).order_by('start_time') else: - programs = channel.epg_data.programs.all().order_by('start_time')[:limit] + programs = channel.epg_data.programs.filter( + end_time__gt=django_timezone.now() + ).order_by('start_time')[:limit] else: # No EPG data assigned, generate default dummy programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None) @@ -2303,31 +2367,41 @@ def xc_get_epg(request, user, short=False): output = {"epg_listings": []} for program in programs: - id = "0" - epg_id = "0" title = program['title'] if isinstance(program, dict) else program.title description = program['description'] if isinstance(program, dict) else program.description start = program["start_time"] if isinstance(program, dict) else program.start_time end = program["end_time"] if isinstance(program, dict) else program.end_time + # For database programs, use actual ID; for generated dummy programs, create synthetic ID + if isinstance(program, dict): + # Generated dummy program - create unique ID from channel + timestamp + program_id = str(abs(hash(f"{channel_id}_{int(start.timestamp())}"))) + else: + # Database program - use actual ID + program_id = str(program.id) + + # epg_id refers to the EPG source/channel mapping in XC panels + # Use the actual EPGData ID when available, otherwise fall back to 0 + epg_id = str(channel.epg_data.id) if channel.epg_data else "0" + program_output = { - "id": f"{id}", - "epg_id": f"{epg_id}", - "title": base64.b64encode(title.encode()).decode(), + "id": program_id, + "epg_id": epg_id, + "title": base64.b64encode((title or "").encode()).decode(), "lang": "", - "start": start.strftime("%Y%m%d%H%M%S"), - "end": end.strftime("%Y%m%d%H%M%S"), - "description": base64.b64encode(description.encode()).decode(), - "channel_id": channel_num_int, - "start_timestamp": int(start.timestamp()), - "stop_timestamp": int(end.timestamp()), + "start": start.strftime("%Y-%m-%d %H:%M:%S"), + "end": end.strftime("%Y-%m-%d %H:%M:%S"), + "description": base64.b64encode((description or "").encode()).decode(), + "channel_id": str(channel_num_int), + "start_timestamp": str(int(start.timestamp())), + "stop_timestamp": str(int(end.timestamp())), "stream_id": f"{channel_id}", } if short == False: program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0 - program_output["has_archive"] = "0" + program_output["has_archive"] = 0 output['epg_listings'].append(program_output) @@ -2486,6 +2560,8 @@ def xc_get_series(request, user, category_id=None): "episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "", "category_id": str(relation.category.id) if relation.category else "0", "category_ids": [int(relation.category.id)] if relation.category else [], + "tmdb_id": series.tmdb_id or "", + "imdb_id": series.imdb_id or "", }) return series_list @@ -2532,34 +2608,45 @@ def xc_get_series_info(request, user, series_id): except Exception as e: logger.error(f"Error refreshing series data for relation {series_relation.id}: {str(e)}") - # Get episodes for this series from the same M3U account - episode_relations = M3UEpisodeRelation.objects.filter( - episode__series=series, - m3u_account=series_relation.m3u_account - ).select_related('episode').order_by('episode__season_number', 'episode__episode_number') + # Get unique episodes for this series that have relations from any active M3U account + # We query episodes directly to avoid duplicates when multiple relations exist + # (e.g., same episode in different languages/qualities) + from apps.vod.models import Episode + episodes = Episode.objects.filter( + series=series, + m3u_relations__m3u_account__is_active=True + ).distinct().order_by('season_number', 'episode_number') # Group episodes by season seasons = {} - for relation in episode_relations: - episode = relation.episode + for episode in episodes: season_num = episode.season_number or 1 if season_num not in seasons: seasons[season_num] = [] - # Try to get the highest priority related M3UEpisodeRelation for this episode (for video/audio/bitrate) + # Get the highest priority relation for this episode (for container_extension, video/audio/bitrate) from apps.vod.models import M3UEpisodeRelation - first_relation = M3UEpisodeRelation.objects.filter( - episode=episode + best_relation = M3UEpisodeRelation.objects.filter( + episode=episode, + m3u_account__is_active=True ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + video = audio = bitrate = None - if first_relation and first_relation.custom_properties: - info = first_relation.custom_properties.get('info') - if info and isinstance(info, dict): - info_info = info.get('info') - if info_info and isinstance(info_info, dict): - video = info_info.get('video', {}) - audio = info_info.get('audio', {}) - bitrate = info_info.get('bitrate', 0) + container_extension = "mp4" + added_timestamp = str(int(episode.created_at.timestamp())) + + if best_relation: + container_extension = best_relation.container_extension or "mp4" + added_timestamp = str(int(best_relation.created_at.timestamp())) + if best_relation.custom_properties: + info = best_relation.custom_properties.get('info') + if info and isinstance(info, dict): + info_info = info.get('info') + if info_info and isinstance(info_info, dict): + video = info_info.get('video', {}) + audio = info_info.get('audio', {}) + bitrate = info_info.get('bitrate', 0) + if video is None: video = episode.custom_properties.get('video', {}) if episode.custom_properties else {} if audio is None: @@ -2572,8 +2659,8 @@ def xc_get_series_info(request, user, series_id): "season": season_num, "episode_num": episode.episode_number or 0, "title": episode.name, - "container_extension": relation.container_extension or "mp4", - "added": str(int(relation.created_at.timestamp())), + "container_extension": container_extension, + "added": added_timestamp, "custom_sid": None, "direct_source": "", "info": { @@ -2822,7 +2909,7 @@ def xc_get_vod_info(request, user, vod_id): "movie_data": { "stream_id": movie.id, "name": movie.name, - "added": int(movie_relation.created_at.timestamp()), + "added": str(int(movie_relation.created_at.timestamp())), "category_id": str(movie_relation.category.id) if movie_relation.category else "0", "category_ids": [int(movie_relation.category.id)] if movie_relation.category else [], "container_extension": movie_relation.container_extension or "mp4", @@ -2889,7 +2976,7 @@ def xc_series_stream(request, username, password, stream_id, extension): filters = {"episode_id": stream_id, "m3u_account__is_active": True} try: - episode_relation = M3UEpisodeRelation.objects.select_related('episode').get(**filters) + episode_relation = M3UEpisodeRelation.objects.select_related('episode').filter(**filters).order_by('-m3u_account__priority', 'id').first() except M3UEpisodeRelation.DoesNotExist: return JsonResponse({"error": "Episode not found"}, status=404) @@ -2922,19 +3009,16 @@ def get_host_and_port(request): if xfh: if ":" in xfh: host, port = xfh.split(":", 1) - # Omit standard ports from URLs, or omit if port doesn't match standard for scheme - # (e.g., HTTPS but port is 9191 = behind external reverse proxy) + # Omit standard ports from URLs if port == standard_port: return host, None - # If port doesn't match standard and X-Forwarded-Proto is set, likely behind external RP - if request.META.get("HTTP_X_FORWARDED_PROTO"): - host = xfh.split(":")[0] # Strip port, will check for proper port below - else: - return host, port + # Non-standard port in X-Forwarded-Host - return it + # This handles reverse proxies on non-standard ports (e.g., https://example.com:8443) + return host, port else: host = xfh - # Check for X-Forwarded-Port header (if we didn't already find a valid port) + # Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host) port = request.META.get("HTTP_X_FORWARDED_PORT") if port: # Omit standard ports from URLs @@ -2952,22 +3036,28 @@ def get_host_and_port(request): else: host = raw_host - # 3. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present) + # 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy) + port = request.META.get("HTTP_X_FORWARDED_PORT") + if port: + # Omit standard ports from URLs + return host, None if port == standard_port else port + + # 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present) # If so, assume standard port for the scheme (don't trust SERVER_PORT in this case) if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"): return host, None - # 4. Try SERVER_PORT from META (only if NOT behind reverse proxy) + # 5. Try SERVER_PORT from META (only if NOT behind reverse proxy) port = request.META.get("SERVER_PORT") if port: # Omit standard ports from URLs return host, None if port == standard_port else port - # 5. Dev fallback: guess port 5656 + # 6. Dev fallback: guess port 5656 if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"): return host, "5656" - # 6. Final fallback: assume standard port for scheme (omit from URL) + # 7. Final fallback: assume standard port for scheme (omit from URL) return host, None def build_absolute_uri_with_port(request, path): diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py index a229a07c..5ba85be2 100644 --- a/apps/plugins/api_urls.py +++ b/apps/plugins/api_urls.py @@ -7,6 +7,7 @@ from .api_views import ( PluginEnabledAPIView, PluginImportAPIView, PluginDeleteAPIView, + PluginLogoAPIView, ) app_name = "plugins" @@ -19,4 +20,5 @@ urlpatterns = [ path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"), path("plugins//run/", PluginRunAPIView.as_view(), name="run"), path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), + path("plugins//logo/", PluginLogoAPIView.as_view(), name="logo"), ] diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 0d68fc7d..624dcc4d 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -1,19 +1,21 @@ import logging +import re from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.decorators import api_view from django.conf import settings from django.core.files.uploadedfile import UploadedFile -import io +from django.http import FileResponse import os import zipfile import shutil import tempfile +from urllib.parse import urlparse from apps.accounts.permissions import ( Authenticated, permission_classes_by_method, ) +from dispatcharr.utils import network_access_allowed from .loader import PluginManager from .models import PluginConfig @@ -21,6 +23,42 @@ from .models import PluginConfig logger = logging.getLogger(__name__) +MAX_PLUGIN_IMPORT_FILES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILES", 2000) +MAX_PLUGIN_IMPORT_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_BYTES", 200 * 1024 * 1024) +MAX_PLUGIN_IMPORT_FILE_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILE_BYTES", 200 * 1024 * 1024) + + +def _parse_bool(value): + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("true", "1", "yes", "y", "on"): + return True + if normalized in ("false", "0", "no", "n", "off"): + return False + return None + + +def _sanitize_plugin_key(value: str) -> str: + base = os.path.basename(value or "") + base = base.replace(" ", "_").lower() + base = re.sub(r"[^a-z0-9_-]", "_", base) + base = base.strip("._-") + return base or "plugin" + + +def _absolutize_logo_url(request, url: str | None) -> str | None: + if not url or not request: + return url + parsed = urlparse(url) + if parsed.scheme: + return url + return request.build_absolute_uri(url) + + class PluginsListAPIView(APIView): def get_permissions(self): try: @@ -32,9 +70,12 @@ class PluginsListAPIView(APIView): def get(self, request): pm = PluginManager.get() - # Ensure registry is up-to-date on each request - pm.discover_plugins() - return Response({"plugins": pm.list_plugins()}) + # Prefer cached registry; reload explicitly via the reload endpoint + pm.discover_plugins(sync_db=False, use_cache=True) + plugins = pm.list_plugins() + for plugin in plugins: + plugin["logo_url"] = _absolutize_logo_url(request, plugin.get("logo_url")) + return Response({"plugins": plugins}) class PluginReloadAPIView(APIView): @@ -48,7 +89,8 @@ class PluginReloadAPIView(APIView): def post(self, request): pm = PluginManager.get() - pm.discover_plugins() + pm.stop_all_plugins(reason="reload") + pm.discover_plugins(force_reload=True) return Response({"success": True, "count": len(pm._registry)}) @@ -81,6 +123,19 @@ class PluginImportAPIView(APIView): if not file_members: shutil.rmtree(tmp_root, ignore_errors=True) return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST) + if len(file_members) > MAX_PLUGIN_IMPORT_FILES: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Archive has too many files"}, status=status.HTTP_400_BAD_REQUEST) + + total_size = 0 + for member in file_members: + total_size += member.file_size + if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Archive contains a file that is too large"}, status=status.HTTP_400_BAD_REQUEST) + if total_size > MAX_PLUGIN_IMPORT_BYTES: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Archive is too large"}, status=status.HTTP_400_BAD_REQUEST) for member in file_members: name = member.filename @@ -115,7 +170,31 @@ class PluginImportAPIView(APIView): plugin_key = os.path.basename(chosen.rstrip(os.sep)) if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): plugin_key = base_name - plugin_key = plugin_key.replace(" ", "_").lower() + plugin_key = _sanitize_plugin_key(plugin_key) + if len(plugin_key) > 128: + plugin_key = plugin_key[:128] + logo_bytes = None + try: + logo_candidates = [] + chosen_abs = os.path.abspath(chosen) + for dirpath, _, filenames in os.walk(tmp_root): + for filename in filenames: + if filename.lower() != "logo.png": + continue + full_path = os.path.join(dirpath, filename) + full_abs = os.path.abspath(full_path) + try: + in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs + except Exception: + in_chosen = False + depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep)) + logo_candidates.append((0 if in_chosen else 1, depth, full_path)) + if logo_candidates: + logo_candidates.sort() + with open(logo_candidates[0][2], "rb") as fh: + logo_bytes = fh.read() + except Exception: + logo_bytes = None final_dir = os.path.join(plugins_dir, plugin_key) if os.path.exists(final_dir): @@ -136,6 +215,12 @@ class PluginImportAPIView(APIView): shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item)) else: shutil.move(chosen, final_dir) + if logo_bytes: + try: + with open(os.path.join(final_dir, "logo.png"), "wb") as fh: + fh.write(logo_bytes) + except Exception: + pass # Cleanup temp shutil.rmtree(tmp_root, ignore_errors=True) target_dir = final_dir @@ -145,49 +230,50 @@ class PluginImportAPIView(APIView): except Exception: pass - # Reload discovery and validate plugin entry - pm.discover_plugins() - plugin = pm._registry.get(plugin_key) - if not plugin: - # Cleanup the copied folder to avoid leaving invalid plugin behind - try: - shutil.rmtree(target_dir, ignore_errors=True) - except Exception: - pass - return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST) - - # Extra validation: ensure Plugin.run exists - instance = getattr(plugin, "instance", None) - run_method = getattr(instance, "run", None) - if not callable(run_method): - try: - shutil.rmtree(target_dir, ignore_errors=True) - except Exception: - pass - return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST) - - # Find DB config to return enabled/ever_enabled + # Ensure DB config exists (untrusted plugins are registered without loading) try: - cfg = PluginConfig.objects.get(key=plugin_key) - enabled = cfg.enabled - ever_enabled = getattr(cfg, "ever_enabled", False) - except PluginConfig.DoesNotExist: - enabled = False - ever_enabled = False + cfg, _ = PluginConfig.objects.get_or_create( + key=plugin_key, + defaults={ + "name": plugin_key, + "version": "", + "description": "", + "settings": {}, + }, + ) + except Exception: + cfg = None - return Response({ - "success": True, - "plugin": { - "key": plugin.key, - "name": plugin.name, - "version": plugin.version, - "description": plugin.description, - "enabled": enabled, - "ever_enabled": ever_enabled, - "fields": plugin.fields or [], - "actions": plugin.actions or [], + # Reload discovery to register the plugin (trusted plugins will load) + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == plugin_key), None) + except Exception: + plugin_entry = None + + if not plugin_entry: + logo_path = os.path.join(plugins_dir, plugin_key, "logo.png") + logo_url = f"/api/plugins/plugins/{plugin_key}/logo/" if os.path.isfile(logo_path) else None + legacy = not os.path.isfile(os.path.join(plugins_dir, plugin_key, "plugin.json")) + plugin_entry = { + "key": plugin_key, + "name": cfg.name if cfg else plugin_key, + "version": cfg.version if cfg else "", + "description": cfg.description if cfg else "", + "enabled": cfg.enabled if cfg else False, + "ever_enabled": getattr(cfg, "ever_enabled", False) if cfg else False, + "fields": [], + "actions": [], + "trusted": bool(cfg and (cfg.ever_enabled or cfg.enabled)), + "loaded": False, + "missing": False, + "legacy": legacy, + "logo_url": logo_url, } - }) + + plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url")) + return Response({"success": True, "plugin": plugin_entry}) class PluginSettingsAPIView(APIView): @@ -254,21 +340,63 @@ class PluginEnabledAPIView(APIView): return [Authenticated()] def post(self, request, key): - enabled = request.data.get("enabled") - if enabled is None: + enabled_raw = request.data.get("enabled") + if enabled_raw is None: return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST) + enabled = _parse_bool(enabled_raw) + if enabled is None: + return Response({"success": False, "error": "Invalid 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST) try: cfg = PluginConfig.objects.get(key=key) - cfg.enabled = bool(enabled) + pm = PluginManager.get() + if not enabled and cfg.enabled: + try: + pm.stop_plugin(key, reason="disable") + except Exception: + logger.exception("Failed to stop plugin '%s' on disable", key) + cfg.enabled = enabled # Mark that this plugin has been enabled at least once if cfg.enabled and not cfg.ever_enabled: cfg.ever_enabled = True cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"]) - return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled}) + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == key), None) + except Exception: + plugin_entry = None + response = {"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled} + if plugin_entry: + plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url")) + response["plugin"] = plugin_entry + return Response(response) except PluginConfig.DoesNotExist: return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND) +class PluginLogoAPIView(APIView): + def get_permissions(self): + return [] + + def get(self, request, key): + if not network_access_allowed(request, "UI"): + return Response({"success": False, "error": "Network access denied"}, status=status.HTTP_403_FORBIDDEN) + pm = PluginManager.get() + pm.discover_plugins(use_cache=True) + plugins_dir = pm.plugins_dir + logo_path = os.path.join(plugins_dir, key, "logo.png") + lp = pm.get_plugin(key) + if lp and getattr(lp, "path", None): + logo_path = os.path.join(lp.path, "logo.png") + abs_plugins = os.path.abspath(plugins_dir) + os.sep + abs_target = os.path.abspath(logo_path) + if not abs_target.startswith(abs_plugins): + return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST) + if not os.path.isfile(logo_path): + return Response({"success": False, "error": "Logo not found"}, status=status.HTTP_404_NOT_FOUND) + return FileResponse(open(logo_path, "rb"), content_type="image/png") + + class PluginDeleteAPIView(APIView): def get_permissions(self): try: @@ -280,6 +408,10 @@ class PluginDeleteAPIView(APIView): def delete(self, request, key): pm = PluginManager.get() + try: + pm.stop_plugin(key, reason="delete") + except Exception: + logger.exception("Failed to stop plugin '%s' before delete", key) plugins_dir = pm.plugins_dir target_dir = os.path.join(plugins_dir, key) # Safety: ensure path inside plugins_dir @@ -302,5 +434,5 @@ class PluginDeleteAPIView(APIView): pass # Reload registry - pm.discover_plugins() + pm.discover_plugins(force_reload=True) return Response({"success": True}) diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py index 5422ae7e..4b53e08b 100644 --- a/apps/plugins/loader.py +++ b/apps/plugins/loader.py @@ -1,8 +1,12 @@ import importlib +import importlib.util import json import logging import os +import re import sys +import threading +import types from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -19,10 +23,17 @@ class LoadedPlugin: name: str version: str = "" description: str = "" + author: str = "" + help_url: str = "" module: Any = None instance: Any = None fields: List[Dict[str, Any]] = field(default_factory=list) actions: List[Dict[str, Any]] = field(default_factory=list) + trusted: bool = False + loaded: bool = False + path: Optional[str] = None + folder_name: Optional[str] = None + legacy: bool = False class PluginManager: @@ -39,70 +50,254 @@ class PluginManager: def __init__(self) -> None: self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins") self._registry: Dict[str, LoadedPlugin] = {} + self._package_names: Dict[str, str] = {} + self._alias_names: Dict[str, str] = {} + self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token") + self._last_reload_token = 0.0 + self._lock = threading.RLock() # Ensure plugins directory exists os.makedirs(self.plugins_dir, exist_ok=True) if self.plugins_dir not in sys.path: sys.path.append(self.plugins_dir) - def discover_plugins(self, *, sync_db: bool = True) -> Dict[str, LoadedPlugin]: + def discover_plugins( + self, + *, + sync_db: bool = True, + force_reload: bool = False, + use_cache: bool = False, + ) -> Dict[str, LoadedPlugin]: + token = self._get_reload_token() + if use_cache and not force_reload: + with self._lock: + if self._registry and token <= self._last_reload_token: + return self._registry + if token > self._last_reload_token: + force_reload = True + if force_reload: + self._touch_reload_token() + token = self._get_reload_token() + if sync_db: logger.info(f"Discovering plugins in {self.plugins_dir}") else: logger.debug(f"Discovering plugins (no DB sync) in {self.plugins_dir}") - self._registry.clear() + + with self._lock: + previous_packages = dict(self._package_names) + previous_aliases = dict(self._alias_names) + previous_paths = { + key: lp.path for key, lp in self._registry.items() if lp and lp.path + } try: + configs: Optional[Dict[str, PluginConfig]] = None + try: + configs = {c.key: c for c in PluginConfig.objects.all()} + except Exception: + # DB might not be ready; treat all plugins as untrusted + configs = None + + new_registry: Dict[str, LoadedPlugin] = {} + new_packages: Dict[str, str] = {} + new_aliases: Dict[str, str] = {} for entry in sorted(os.listdir(self.plugins_dir)): path = os.path.join(self.plugins_dir, entry) if not os.path.isdir(path): continue + has_pkg = os.path.exists(os.path.join(path, "__init__.py")) + has_pluginpy = os.path.exists(os.path.join(path, "plugin.py")) + if not (has_pkg or has_pluginpy): + continue + plugin_key = entry.replace(" ", "_").lower() + alias_name = self._resolve_alias_name(entry, path) + + if force_reload: + prev_alias = previous_aliases.get(plugin_key) + if prev_alias: + self._unload_alias(prev_alias) + prev_path = previous_paths.get(plugin_key) + if prev_path: + self._unload_path_modules(prev_path) + + cfg = configs.get(plugin_key) if configs else None + enabled = bool(cfg and cfg.enabled) + trusted = bool(cfg and (cfg.ever_enabled or cfg.enabled)) + + manifest, has_manifest = self._read_manifest(path) + legacy = not has_manifest + manifest_name = None + manifest_version = None + manifest_description = None + manifest_author = None + manifest_help_url = None + manifest_fields: List[Dict[str, Any]] = [] + manifest_actions: List[Dict[str, Any]] = [] + if has_manifest and isinstance(manifest, dict): + manifest_name = manifest.get("name") if isinstance(manifest.get("name"), str) else None + manifest_version = manifest.get("version") if isinstance(manifest.get("version"), str) else None + manifest_description = manifest.get("description") if isinstance(manifest.get("description"), str) else None + manifest_author = manifest.get("author") if isinstance(manifest.get("author"), str) else None + manifest_help_url = manifest.get("help_url") if isinstance(manifest.get("help_url"), str) else None + manifest_fields = self._normalize_fields(manifest.get("fields", [])) + manifest_actions = self._normalize_actions(manifest.get("actions", [])) + + display_name = manifest_name or entry + display_version = ( + manifest_version if manifest_version is not None else (cfg.version if cfg else "") + ) + display_description = ( + manifest_description if manifest_description is not None else (cfg.description if cfg else "") + ) + + def _make_placeholder() -> LoadedPlugin: + return LoadedPlugin( + key=plugin_key, + name=display_name, + version=display_version, + description=display_description, + author=manifest_author or "", + help_url=manifest_help_url or "", + fields=manifest_fields if has_manifest else [], + actions=manifest_actions if has_manifest else [], + trusted=trusted, + loaded=False, + path=path, + folder_name=entry, + legacy=legacy, + ) + + if not enabled: + new_registry[plugin_key] = _make_placeholder() + continue try: - self._load_plugin(plugin_key, path) + lp, package_name = self._load_plugin( + plugin_key, + path, + folder_name=entry, + force_reload=force_reload, + previous_package=previous_packages.get(plugin_key), + ) + if lp: + if manifest_name and (not lp.name or lp.name == plugin_key): + lp.name = manifest_name + if manifest_version is not None and not lp.version: + lp.version = manifest_version + if manifest_description is not None and not lp.description: + lp.description = manifest_description + if manifest_author is not None and not lp.author: + lp.author = manifest_author + if manifest_help_url is not None and not lp.help_url: + lp.help_url = manifest_help_url + if manifest_fields and not lp.fields: + lp.fields = manifest_fields + if manifest_actions and not lp.actions: + lp.actions = manifest_actions + lp.trusted = trusted + lp.loaded = True + lp.path = path + lp.folder_name = entry + lp.legacy = legacy + new_registry[plugin_key] = lp + if package_name: + new_packages[plugin_key] = package_name + if alias_name: + new_aliases[plugin_key] = alias_name + else: + new_registry[plugin_key] = _make_placeholder() except Exception: logger.exception(f"Failed to load plugin '{plugin_key}' from {path}") + new_registry[plugin_key] = _make_placeholder() - logger.info(f"Discovered {len(self._registry)} plugin(s)") + if force_reload: + # Remove stale modules for plugins that no longer exist + removed_keys = set(previous_packages.keys()) - set(new_packages.keys()) + for key in removed_keys: + self._unload_package(previous_packages[key]) + prev_alias = previous_aliases.get(key) + if prev_alias: + self._unload_alias(prev_alias) + prev_path = previous_paths.get(key) + if prev_path: + self._unload_path_modules(prev_path) + + with self._lock: + self._registry = new_registry + self._package_names = new_packages + self._alias_names = new_aliases + if token > self._last_reload_token: + self._last_reload_token = token + + logger.info(f"Discovered {len(new_registry)} plugin(s)") except FileNotFoundError: logger.warning(f"Plugins directory not found: {self.plugins_dir}") # Sync DB records (optional) if sync_db: try: - self._sync_db_with_registry() + self._sync_db_with_registry(new_registry if 'new_registry' in locals() else None) except Exception: # Defer sync if database is not ready (e.g., first startup before migrate) logger.exception("Deferring plugin DB sync; database not ready yet") return self._registry - def _load_plugin(self, key: str, path: str): + def _load_plugin( + self, + key: str, + path: str, + *, + folder_name: str, + force_reload: bool, + previous_package: Optional[str], + ) -> tuple[Optional[LoadedPlugin], Optional[str]]: # Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present. has_pkg = os.path.exists(os.path.join(path, "__init__.py")) has_pluginpy = os.path.exists(os.path.join(path, "plugin.py")) if not (has_pkg or has_pluginpy): logger.debug(f"Skipping {path}: no plugin.py or package") - return + return None, None - candidate_modules = [] - if has_pluginpy: - candidate_modules.append(f"{key}.plugin") - if has_pkg: - candidate_modules.append(key) + package_name = self._resolve_package_name(key) + alias_name = self._resolve_alias_name(folder_name, path) + + if force_reload and previous_package: + self._unload_package(previous_package) module = None plugin_cls = None last_error = None - for module_name in candidate_modules: + + # Ensure a package context exists for plugin.py (even without __init__.py) + if has_pluginpy: + self._ensure_namespace_package(package_name, path, alias=alias_name) + + module_name = f"{package_name}.plugin" + plugin_path = os.path.join(path, "plugin.py") try: - logger.debug(f"Importing plugin module {module_name}") - module = importlib.import_module(module_name) + logger.debug(f"Importing plugin module {module_name} from {plugin_path}") + module = self._load_module_from_path(module_name, plugin_path, is_package=False) + if alias_name: + self._register_alias_module(f"{alias_name}.plugin", module, path) plugin_cls = getattr(module, "Plugin", None) - if plugin_cls is not None: - break - else: + if plugin_cls is None: + logger.warning(f"Module {module_name} has no Plugin class") + except Exception as e: + last_error = e + logger.exception(f"Error importing module {module_name}") + + if plugin_cls is None and has_pkg: + module_name = package_name + init_path = os.path.join(path, "__init__.py") + try: + logger.debug(f"Importing plugin package {module_name} from {init_path}") + module = self._load_module_from_path(module_name, init_path, is_package=True) + self._register_alias_module(alias_name, module, path) + plugin_cls = getattr(module, "Plugin", None) + if plugin_cls is None: logger.warning(f"Module {module_name} has no Plugin class") except Exception as e: last_error = e @@ -111,32 +306,43 @@ class PluginManager: if plugin_cls is None: if last_error: raise last_error - else: - logger.warning(f"No Plugin class found for {key}; skipping") - return + logger.warning(f"No Plugin class found for {key}; skipping") + return None, package_name instance = plugin_cls() name = getattr(instance, "name", key) version = getattr(instance, "version", "") description = getattr(instance, "description", "") + author = getattr(instance, "author", "") + help_url = getattr(instance, "help_url", "") fields = getattr(instance, "fields", []) actions = getattr(instance, "actions", []) + fields = self._normalize_fields(fields) + actions = self._normalize_actions(actions) - self._registry[key] = LoadedPlugin( + lp = LoadedPlugin( key=key, name=name, version=version, description=description, + author=author or "", + help_url=help_url or "", module=module, instance=instance, fields=fields, actions=actions, + path=path, + folder_name=folder_name, ) + return lp, package_name - def _sync_db_with_registry(self): + def _sync_db_with_registry(self, registry: Optional[Dict[str, LoadedPlugin]] = None): + if registry is None: + with self._lock: + registry = dict(self._registry) with transaction.atomic(): - for key, lp in self._registry.items(): + for key, lp in registry.items(): obj, _ = PluginConfig.objects.get_or_create( key=key, defaults={ @@ -164,6 +370,8 @@ class PluginManager: from .models import PluginConfig plugins: List[Dict[str, Any]] = [] + with self._lock: + registry_snapshot = dict(self._registry) try: configs = {c.key: c for c in PluginConfig.objects.all()} except Exception as e: @@ -172,25 +380,33 @@ class PluginManager: configs = {} # First, include all discovered plugins - for key, lp in self._registry.items(): + for key, lp in registry_snapshot.items(): conf = configs.get(key) + trusted = bool(conf and (conf.ever_enabled or conf.enabled)) + logo_url = self._get_logo_url(key, path=lp.path) plugins.append( { "key": key, "name": lp.name, "version": lp.version, "description": lp.description, + "author": getattr(lp, "author", "") or "", + "help_url": getattr(lp, "help_url", "") or "", "enabled": conf.enabled if conf else False, "ever_enabled": getattr(conf, "ever_enabled", False) if conf else False, "fields": lp.fields or [], "settings": (conf.settings if conf else {}), "actions": lp.actions or [], "missing": False, + "trusted": trusted, + "loaded": bool(lp.loaded), + "legacy": bool(getattr(lp, "legacy", False)), + "logo_url": logo_url, } ) # Then, include any DB-only configs (files missing or failed to load) - discovered_keys = set(self._registry.keys()) + discovered_keys = set(registry_snapshot.keys()) for key, conf in configs.items(): if key in discovered_keys: continue @@ -200,19 +416,26 @@ class PluginManager: "name": conf.name, "version": conf.version, "description": conf.description, + "author": "", + "help_url": "", "enabled": conf.enabled, "ever_enabled": getattr(conf, "ever_enabled", False), "fields": [], "settings": conf.settings or {}, "actions": [], "missing": True, + "trusted": bool(conf.ever_enabled or conf.enabled), + "loaded": False, + "legacy": False, + "logo_url": self._get_logo_url(key), } ) return plugins def get_plugin(self, key: str) -> Optional[LoadedPlugin]: - return self._registry.get(key) + with self._lock: + return self._registry.get(key) def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]: cfg = PluginConfig.objects.get(key=key) @@ -223,19 +446,18 @@ class PluginManager: def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: lp = self.get_plugin(key) if not lp or not lp.instance: - raise ValueError(f"Plugin '{key}' not found") + # Attempt a lightweight re-discovery in case the registry was rebuilt + self.discover_plugins(sync_db=False, force_reload=False, use_cache=False) + lp = self.get_plugin(key) + if not lp or not lp.instance: + raise ValueError(f"Plugin '{key}' not found") cfg = PluginConfig.objects.get(key=key) if not cfg.enabled: raise PermissionError(f"Plugin '{key}' is disabled") params = params or {} - # Provide a context object to the plugin - context = { - "settings": cfg.settings or {}, - "logger": logger, - "actions": {a.get("id"): a for a in (lp.actions or [])}, - } + context = self._build_context(lp, cfg) # Run either via Celery if plugin provides a delayed method, or inline run_method = getattr(lp.instance, "run", None) @@ -252,3 +474,286 @@ class PluginManager: if isinstance(result, dict): return result return {"status": "ok", "result": result} + + def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool: + lp = self.get_plugin(key) + if not lp or not lp.instance: + return False + try: + cfg = PluginConfig.objects.get(key=key) + except PluginConfig.DoesNotExist: + return False + if not cfg.enabled: + return False + + context = self._build_context(lp, cfg) + if reason: + context["reason"] = reason + + stop_method = getattr(lp.instance, "stop", None) + if callable(stop_method): + try: + stop_method(context) + return True + except TypeError: + try: + stop_method() + return True + except Exception: + logger.exception("Plugin '%s' stop() failed", key) + return False + except Exception: + logger.exception("Plugin '%s' stop() failed", key) + return False + + run_method = getattr(lp.instance, "run", None) + if callable(run_method): + actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)} + if "stop" in actions: + try: + run_method("stop", {}, context) + return True + except Exception: + logger.exception("Plugin '%s' stop action failed", key) + return False + return False + + def stop_all_plugins(self, reason: Optional[str] = None) -> int: + stopped = 0 + with self._lock: + registry_snapshot = dict(self._registry) + for key in registry_snapshot.keys(): + if self.stop_plugin(key, reason=reason): + stopped += 1 + return stopped + + def _resolve_package_name(self, key: str) -> str: + safe_key = self._safe_module_name(key) + return f"_dispatcharr_plugin_{safe_key}" + + def _resolve_alias_name(self, folder_name: str, path: str) -> Optional[str]: + if not self._is_valid_identifier(folder_name): + return None + if self._is_reserved_module_name(folder_name, path): + return None + return folder_name + + def _is_valid_identifier(self, name: str) -> bool: + return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is not None + + def _safe_module_name(self, value: str) -> str: + safe = re.sub(r"[^0-9A-Za-z_]", "_", value) + if not safe or safe[0].isdigit(): + safe = f"p_{safe}" + return safe + + def _normalize_fields(self, fields: Any) -> List[Dict[str, Any]]: + try: + from .serializers import PluginFieldSerializer + except Exception: + return fields if isinstance(fields, list) else [] + if not isinstance(fields, list): + return [] + serializer = PluginFieldSerializer(data=fields, many=True) + if serializer.is_valid(): + return serializer.validated_data + normalized: List[Dict[str, Any]] = [] + for item in fields: + item_ser = PluginFieldSerializer(data=item) + if item_ser.is_valid(): + normalized.append(item_ser.validated_data) + else: + logger.warning("Invalid plugin field entry ignored: %s", item_ser.errors) + return normalized + + def _normalize_actions(self, actions: Any) -> List[Dict[str, Any]]: + try: + from .serializers import PluginActionSerializer + except Exception: + return actions if isinstance(actions, list) else [] + if not isinstance(actions, list): + return [] + serializer = PluginActionSerializer(data=actions, many=True) + if serializer.is_valid(): + return serializer.validated_data + normalized: List[Dict[str, Any]] = [] + for item in actions: + item_ser = PluginActionSerializer(data=item) + if item_ser.is_valid(): + normalized.append(item_ser.validated_data) + else: + logger.warning("Invalid plugin action entry ignored: %s", item_ser.errors) + return normalized + + def _merge_settings_with_defaults(self, settings: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]: + merged = dict(settings or {}) + for field_def in fields or []: + field_id = field_def.get("id") + if not field_id: + continue + if field_id not in merged and "default" in field_def: + merged[field_id] = field_def.get("default") + return merged + + def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig) -> Dict[str, Any]: + settings = self._merge_settings_with_defaults(cfg.settings or {}, lp.fields or []) + return { + "settings": settings, + "logger": logger, + "actions": {a.get("id"): a for a in (lp.actions or [])}, + } + + def _read_manifest(self, path: str) -> tuple[Optional[Dict[str, Any]], bool]: + manifest_path = os.path.join(path, "plugin.json") + if not os.path.isfile(manifest_path): + return None, False + try: + with open(manifest_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception: + logger.warning("Invalid plugin.json for plugin at %s", path) + return None, False + if not isinstance(data, dict): + logger.warning("plugin.json must be an object for plugin at %s", path) + return None, False + return data, True + + def _get_logo_url(self, key: str, *, path: Optional[str] = None) -> Optional[str]: + logo_path = os.path.join(self.plugins_dir, key, "logo.png") + if path: + logo_path = os.path.join(path, "logo.png") + try: + if os.path.isfile(logo_path): + return f"/api/plugins/plugins/{key}/logo/" + except Exception: + return None + return None + + def _ensure_namespace_package(self, package_name: str, path: str, *, alias: Optional[str] = None) -> None: + existing = sys.modules.get(package_name) + if existing and getattr(existing, "__path__", None): + return + pkg = types.ModuleType(package_name) + pkg.__path__ = [path] + pkg.__package__ = package_name + sys.modules[package_name] = pkg + self._register_alias_module(alias, pkg, path) + + def _register_alias_module( + self, + alias_name: Optional[str], + module: Any, + path: str, + *, + force: bool = False, + ) -> None: + if not alias_name: + return + if self._is_reserved_module_name(alias_name, path): + return + if alias_name in sys.modules: + if not force: + return + self._unload_alias(alias_name) + sys.modules[alias_name] = module + + def _is_reserved_module_name(self, name: str, path: str) -> bool: + if name in sys.builtin_module_names: + return True + if hasattr(sys, "stdlib_module_names") and name in sys.stdlib_module_names: + return True + existing = sys.modules.get(name) + if existing: + origin = getattr(existing, "__file__", None) + if origin is None: + return True + try: + if not os.path.abspath(origin).startswith(os.path.abspath(path)): + return True + except Exception: + return True + try: + spec = importlib.util.find_spec(name) + except Exception: + spec = None + if spec: + if spec.origin is None: + return True + try: + if not os.path.abspath(spec.origin).startswith(os.path.abspath(path)): + return True + except Exception: + return True + return False + + def _load_module_from_path(self, module_name: str, path: str, *, is_package: bool) -> Any: + importlib.invalidate_caches() + spec = importlib.util.spec_from_file_location( + module_name, + path, + submodule_search_locations=[os.path.dirname(path)] if is_package else None, + ) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load spec for {module_name} from {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + def _get_reload_token(self) -> float: + try: + return os.path.getmtime(self._reload_token_path) + except FileNotFoundError: + return 0.0 + except Exception: + return 0.0 + + def _touch_reload_token(self) -> None: + try: + os.makedirs(self.plugins_dir, exist_ok=True) + with open(self._reload_token_path, "a", encoding="utf-8"): + pass + os.utime(self._reload_token_path, None) + except Exception: + logger.debug("Failed to update plugin reload token", exc_info=True) + + def _unload_package(self, package_name: str) -> None: + if not package_name: + return + for name in list(sys.modules.keys()): + if name == package_name or name.startswith(f"{package_name}."): + sys.modules.pop(name, None) + + def _unload_alias(self, alias_name: str) -> None: + if not alias_name: + return + for name in list(sys.modules.keys()): + if name == alias_name or name.startswith(f"{alias_name}."): + sys.modules.pop(name, None) + + def _unload_path_modules(self, path: str) -> None: + if not path: + return + root = os.path.abspath(path) + for name, module in list(sys.modules.items()): + if not module: + continue + mod_path = getattr(module, "__file__", None) + if mod_path: + try: + abs_path = os.path.abspath(mod_path) + if abs_path == root or abs_path.startswith(f"{root}{os.sep}"): + sys.modules.pop(name, None) + continue + except Exception: + pass + mod_paths = getattr(module, "__path__", None) + if mod_paths: + try: + for pkg_path in mod_paths: + abs_pkg = os.path.abspath(pkg_path) + if abs_pkg == root or abs_pkg.startswith(f"{root}{os.sep}"): + sys.modules.pop(name, None) + break + except Exception: + continue diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py index cc7b1882..9f568054 100644 --- a/apps/plugins/serializers.py +++ b/apps/plugins/serializers.py @@ -5,15 +5,34 @@ class PluginActionSerializer(serializers.Serializer): id = serializers.CharField() label = serializers.CharField() description = serializers.CharField(required=False, allow_blank=True) + confirm = serializers.JSONField(required=False) + button_label = serializers.CharField(required=False, allow_blank=True) + button_variant = serializers.CharField(required=False, allow_blank=True) + button_color = serializers.CharField(required=False, allow_blank=True) + events = serializers.ListField( + child=serializers.CharField(), required=False, allow_empty=True + ) + + +class PluginFieldOptionSerializer(serializers.Serializer): + value = serializers.CharField() + label = serializers.CharField() class PluginFieldSerializer(serializers.Serializer): id = serializers.CharField() - label = serializers.CharField() - type = serializers.ChoiceField(choices=["string", "number", "boolean", "select"]) # simple types + label = serializers.CharField(required=False, allow_blank=True) + type = serializers.ChoiceField(choices=["string", "number", "boolean", "select", "text", "info"]) default = serializers.JSONField(required=False) help_text = serializers.CharField(required=False, allow_blank=True) - options = serializers.ListField(child=serializers.DictField(), required=False) + description = serializers.CharField(required=False, allow_blank=True) + placeholder = serializers.CharField(required=False, allow_blank=True) + input_type = serializers.CharField(required=False, allow_blank=True) + min = serializers.FloatField(required=False) + max = serializers.FloatField(required=False) + step = serializers.FloatField(required=False) + value = serializers.CharField(required=False, allow_blank=True) + options = PluginFieldOptionSerializer(many=True, required=False) class PluginSerializer(serializers.Serializer): @@ -21,8 +40,9 @@ class PluginSerializer(serializers.Serializer): name = serializers.CharField() version = serializers.CharField(allow_blank=True) description = serializers.CharField(allow_blank=True) + author = serializers.CharField(required=False, allow_blank=True) + help_url = serializers.CharField(required=False, allow_blank=True) enabled = serializers.BooleanField() fields = PluginFieldSerializer(many=True) settings = serializers.JSONField() actions = PluginActionSerializer(many=True) - diff --git a/apps/proxy/ts_proxy/client_manager.py b/apps/proxy/ts_proxy/client_manager.py index 80be26f1..e272fe0c 100644 --- a/apps/proxy/ts_proxy/client_manager.py +++ b/apps/proxy/ts_proxy/client_manager.py @@ -46,9 +46,11 @@ class ClientManager: # Import here to avoid potential import issues from apps.proxy.ts_proxy.channel_status import ChannelStatus import redis + from django.conf import settings - # Get all channels from Redis - redis_client = redis.Redis.from_url('redis://localhost:6379', decode_responses=True) + # Get all channels from Redis using settings + redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0') + redis_client = redis.Redis.from_url(redis_url, decode_responses=True) all_channels = [] cursor = 0 diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index 7d4a9f89..eb792736 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -149,11 +149,15 @@ class ProxyServer: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) pubsub_client = redis.Redis( host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=60, socket_connect_timeout=10, socket_keepalive=True, diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 391a041c..c6b0e88d 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -15,6 +15,7 @@ from ..redis_keys import RedisKeys from ..constants import EventType, ChannelState, ChannelMetadataField from ..url_utils import get_stream_info_for_switch from core.utils import log_system_event +from .log_parsers import LogParserFactory logger = logging.getLogger("ts_proxy") @@ -419,124 +420,51 @@ class ChannelService: @staticmethod def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None): - """Parse FFmpeg stream info line and store in Redis metadata and database""" + """ + Parse stream info from FFmpeg/VLC/Streamlink logs and store in Redis/DB. + Uses specialized parsers for each streaming tool. + """ try: - if stream_type == "input": - # Example lines: - # Input #0, mpegts, from 'http://example.com/stream.ts': - # Input #0, hls, from 'http://example.com/stream.m3u8': + # Use factory to parse the line based on stream type + parsed_data = LogParserFactory.parse(stream_type, stream_info_line) + + if not parsed_data: + return - # Extract input format (e.g., "mpegts", "hls", "flv", etc.) - input_match = re.search(r'Input #\d+,\s*([^,]+)', stream_info_line) - input_format = input_match.group(1).strip() if input_match else None + # Update Redis and database with parsed data + ChannelService._update_stream_info_in_redis( + channel_id, + parsed_data.get('video_codec'), + parsed_data.get('resolution'), + parsed_data.get('width'), + parsed_data.get('height'), + parsed_data.get('source_fps'), + parsed_data.get('pixel_format'), + parsed_data.get('video_bitrate'), + parsed_data.get('audio_codec'), + parsed_data.get('sample_rate'), + parsed_data.get('audio_channels'), + parsed_data.get('audio_bitrate'), + parsed_data.get('stream_type') + ) - # Store in Redis if we have valid data - if input_format: - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db(stream_id, stream_type=input_format) - - logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}") - - elif stream_type == "video": - # Example line: - # Stream #0:0: Video: h264 (Main), yuv420p(tv, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 2000 kb/s, 29.97 fps, 90k tbn - - # Extract video codec (e.g., "h264", "mpeg2video", etc.) - codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', stream_info_line) - video_codec = codec_match.group(1) if codec_match else None - - # Extract resolution (e.g., "1280x720") - be more specific to avoid hex values - # Look for resolution patterns that are realistic video dimensions - resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', stream_info_line) - if resolution_match: - width = int(resolution_match.group(1)) - height = int(resolution_match.group(2)) - # Validate that these look like reasonable video dimensions - if 100 <= width <= 10000 and 100 <= height <= 10000: - resolution = f"{width}x{height}" - else: - width = height = resolution = None - else: - width = height = resolution = None - - # Extract source FPS (e.g., "29.97 fps") - fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', stream_info_line) - source_fps = float(fps_match.group(1)) if fps_match else None - - # Extract pixel format (e.g., "yuv420p") - pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', stream_info_line) - pixel_format = None - if pixel_format_match: - pf = pixel_format_match.group(1).strip() - # Clean up pixel format (remove extra info in parentheses) - if '(' in pf: - pf = pf.split('(')[0].strip() - pixel_format = pf - - # Extract bitrate if present (e.g., "2000 kb/s") - video_bitrate = None - bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line) - if bitrate_match: - video_bitrate = float(bitrate_match.group(1)) - - # Store in Redis if we have valid data - if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db( - stream_id, - video_codec=video_codec, - resolution=resolution, - source_fps=source_fps, - pixel_format=pixel_format, - video_bitrate=video_bitrate - ) - - logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, " - f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, " - f"Video Bitrate: {video_bitrate} kb/s") - - elif stream_type == "audio": - # Example line: - # Stream #0:1[0x101]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 64 kb/s - - # Extract audio codec (e.g., "aac", "mp3", etc.) - codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', stream_info_line) - audio_codec = codec_match.group(1) if codec_match else None - - # Extract sample rate (e.g., "48000 Hz") - sample_rate_match = re.search(r'(\d+)\s*Hz', stream_info_line) - sample_rate = int(sample_rate_match.group(1)) if sample_rate_match else None - - # Extract channel layout (e.g., "stereo", "5.1", "mono") - # Look for common channel layouts - channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', stream_info_line, re.IGNORECASE) - channels = channel_match.group(1) if channel_match else None - - # Extract audio bitrate if present (e.g., "64 kb/s") - audio_bitrate = None - bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line) - if bitrate_match: - audio_bitrate = float(bitrate_match.group(1)) - - # Store in Redis if we have valid data - if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db( - stream_id, - audio_codec=audio_codec, - sample_rate=sample_rate, - audio_channels=channels, - audio_bitrate=audio_bitrate - ) + if stream_id: + ChannelService._update_stream_stats_in_db( + stream_id, + video_codec=parsed_data.get('video_codec'), + resolution=parsed_data.get('resolution'), + source_fps=parsed_data.get('source_fps'), + pixel_format=parsed_data.get('pixel_format'), + video_bitrate=parsed_data.get('video_bitrate'), + audio_codec=parsed_data.get('audio_codec'), + sample_rate=parsed_data.get('sample_rate'), + audio_channels=parsed_data.get('audio_channels'), + audio_bitrate=parsed_data.get('audio_bitrate'), + stream_type=parsed_data.get('stream_type') + ) except Exception as e: - logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}") + logger.debug(f"Error parsing {stream_type} stream info: {e}") @staticmethod def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None): diff --git a/apps/proxy/ts_proxy/services/log_parsers.py b/apps/proxy/ts_proxy/services/log_parsers.py new file mode 100644 index 00000000..95ee7a06 --- /dev/null +++ b/apps/proxy/ts_proxy/services/log_parsers.py @@ -0,0 +1,410 @@ +"""Log parsers for FFmpeg, Streamlink, and VLC output.""" +import re +import logging +from abc import ABC, abstractmethod +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + + +class BaseLogParser(ABC): + """Base class for log parsers""" + + # Map of stream_type -> method_name that this parser handles + STREAM_TYPE_METHODS: Dict[str, str] = {} + + @abstractmethod + def can_parse(self, line: str) -> Optional[str]: + """ + Check if this parser can handle the line. + Returns the stream_type if it can parse, None otherwise. + e.g., 'video', 'audio', 'vlc_video', 'vlc_audio', 'streamlink' + """ + pass + + @abstractmethod + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + pass + + @abstractmethod + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + pass + + @abstractmethod + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + pass + + +class FFmpegLogParser(BaseLogParser): + """Parser for FFmpeg log output""" + + STREAM_TYPE_METHODS = { + 'input': 'parse_input_format', + 'video': 'parse_video_stream', + 'audio': 'parse_audio_stream' + } + + def can_parse(self, line: str) -> Optional[str]: + """Check if this is an FFmpeg line we can parse""" + lower = line.lower() + + # Input format detection + if lower.startswith('input #'): + return 'input' + + # Stream info (only during input phase, but we'll let stream_manager handle phase tracking) + if 'stream #' in lower: + if 'video:' in lower: + return 'video' + elif 'audio:' in lower: + return 'audio' + + return None + + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + """Parse FFmpeg input format (e.g., mpegts, hls)""" + try: + input_match = re.search(r'Input #\d+,\s*([^,]+)', line) + input_format = input_match.group(1).strip() if input_match else None + + if input_format: + logger.debug(f"Input format info - Format: {input_format}") + return {'stream_type': input_format} + except Exception as e: + logger.debug(f"Error parsing FFmpeg input format: {e}") + + return None + + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse FFmpeg video stream info""" + try: + result = {} + + # Extract codec, resolution, fps, pixel format, bitrate + codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', line) + if codec_match: + result['video_codec'] = codec_match.group(1) + + resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', line) + if resolution_match: + width = int(resolution_match.group(1)) + height = int(resolution_match.group(2)) + if 100 <= width <= 10000 and 100 <= height <= 10000: + result['resolution'] = f"{width}x{height}" + result['width'] = width + result['height'] = height + + fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', line) + if fps_match: + result['source_fps'] = float(fps_match.group(1)) + + pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', line) + if pixel_format_match: + pf = pixel_format_match.group(1).strip() + if '(' in pf: + pf = pf.split('(')[0].strip() + result['pixel_format'] = pf + + bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line) + if bitrate_match: + result['video_bitrate'] = float(bitrate_match.group(1)) + + if result: + logger.info(f"Video stream info - Codec: {result.get('video_codec')}, " + f"Resolution: {result.get('resolution')}, " + f"Source FPS: {result.get('source_fps')}, " + f"Pixel Format: {result.get('pixel_format')}, " + f"Video Bitrate: {result.get('video_bitrate')} kb/s") + return result + + except Exception as e: + logger.debug(f"Error parsing FFmpeg video stream info: {e}") + + return None + + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse FFmpeg audio stream info""" + try: + result = {} + + codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', line) + if codec_match: + result['audio_codec'] = codec_match.group(1) + + sample_rate_match = re.search(r'(\d+)\s*Hz', line) + if sample_rate_match: + result['sample_rate'] = int(sample_rate_match.group(1)) + + channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', line, re.IGNORECASE) + if channel_match: + result['audio_channels'] = channel_match.group(1) + + bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line) + if bitrate_match: + result['audio_bitrate'] = float(bitrate_match.group(1)) + + if result: + return result + + except Exception as e: + logger.debug(f"Error parsing FFmpeg audio stream info: {e}") + + return None + + +class VLCLogParser(BaseLogParser): + """Parser for VLC log output""" + + STREAM_TYPE_METHODS = { + 'vlc_video': 'parse_video_stream', + 'vlc_audio': 'parse_audio_stream' + } + + def can_parse(self, line: str) -> Optional[str]: + """Check if this is a VLC line we can parse""" + lower = line.lower() + + # VLC TS demux codec detection + if 'ts demux debug' in lower and 'type=' in lower: + if 'video' in lower: + return 'vlc_video' + elif 'audio' in lower: + return 'vlc_audio' + + # VLC decoder output + if 'decoder' in lower and ('channels:' in lower or 'samplerate:' in lower or 'x' in line or 'fps' in lower): + if 'audio' in lower or 'channels:' in lower or 'samplerate:' in lower: + return 'vlc_audio' + else: + return 'vlc_video' + + # VLC transcode output for resolution/FPS + if 'stream_out_transcode' in lower and ('source fps' in lower or ('source ' in lower and 'x' in line)): + return 'vlc_video' + + return None + + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + return None + + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse VLC TS demux output and decoder info for video""" + try: + lower = line.lower() + result = {} + + # Codec detection from TS demux + video_codec_map = { + ('avc', 'h.264', 'type=0x1b'): "h264", + ('hevc', 'h.265', 'type=0x24'): "hevc", + ('mpeg-2', 'type=0x02'): "mpeg2video", + ('mpeg-4', 'type=0x10'): "mpeg4" + } + + for patterns, codec in video_codec_map.items(): + if any(p in lower for p in patterns): + result['video_codec'] = codec + break + + # Extract FPS from transcode output: "source fps 30/1" + fps_fraction_match = re.search(r'source fps\s+(\d+)/(\d+)', lower) + if fps_fraction_match: + numerator = int(fps_fraction_match.group(1)) + denominator = int(fps_fraction_match.group(2)) + if denominator > 0: + result['source_fps'] = numerator / denominator + + # Extract resolution from transcode output: "source 1280x720" + source_res_match = re.search(r'source\s+(\d{3,4})x(\d{3,4})', lower) + if source_res_match: + width = int(source_res_match.group(1)) + height = int(source_res_match.group(2)) + if 100 <= width <= 10000 and 100 <= height <= 10000: + result['resolution'] = f"{width}x{height}" + result['width'] = width + result['height'] = height + else: + # Fallback: generic resolution pattern + resolution_match = re.search(r'(\d{3,4})x(\d{3,4})', line) + if resolution_match: + width = int(resolution_match.group(1)) + height = int(resolution_match.group(2)) + if 100 <= width <= 10000 and 100 <= height <= 10000: + result['resolution'] = f"{width}x{height}" + result['width'] = width + result['height'] = height + + # Fallback: try to extract FPS from generic format + if 'source_fps' not in result: + fps_match = re.search(r'(\d+\.?\d*)\s*fps', lower) + if fps_match: + result['source_fps'] = float(fps_match.group(1)) + + return result if result else None + + except Exception as e: + logger.debug(f"Error parsing VLC video stream info: {e}") + + return None + + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse VLC TS demux output and decoder info for audio""" + try: + lower = line.lower() + result = {} + + # Codec detection from TS demux + audio_codec_map = { + ('type=0xf', 'adts'): "aac", + ('type=0x03', 'type=0x04'): "mp3", + ('type=0x06', 'type=0x81'): "ac3", + ('type=0x0b', 'lpcm'): "pcm" + } + + for patterns, codec in audio_codec_map.items(): + if any(p in lower for p in patterns): + result['audio_codec'] = codec + break + + # VLC decoder format: "AAC channels: 2 samplerate: 48000" + if 'channels:' in lower: + channels_match = re.search(r'channels:\s*(\d+)', lower) + if channels_match: + num_channels = int(channels_match.group(1)) + # Convert number to name + channel_names = {1: 'mono', 2: 'stereo', 6: '5.1', 8: '7.1'} + result['audio_channels'] = channel_names.get(num_channels, str(num_channels)) + + if 'samplerate:' in lower: + samplerate_match = re.search(r'samplerate:\s*(\d+)', lower) + if samplerate_match: + result['sample_rate'] = int(samplerate_match.group(1)) + + # Try to extract sample rate (Hz format) + sample_rate_match = re.search(r'(\d+)\s*hz', lower) + if sample_rate_match and 'sample_rate' not in result: + result['sample_rate'] = int(sample_rate_match.group(1)) + + # Try to extract channels (word format) + if 'audio_channels' not in result: + channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', lower) + if channel_match: + result['audio_channels'] = channel_match.group(1) + + return result if result else None + + except Exception as e: + logger.error(f"[VLC AUDIO PARSER] Error parsing VLC audio stream info: {e}") + + return None + + +class StreamlinkLogParser(BaseLogParser): + """Parser for Streamlink log output""" + + STREAM_TYPE_METHODS = { + 'streamlink': 'parse_video_stream' + } + + def can_parse(self, line: str) -> Optional[str]: + """Check if this is a Streamlink line we can parse""" + lower = line.lower() + + if 'opening stream:' in lower or 'available streams:' in lower: + return 'streamlink' + + return None + + def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]: + return None + + def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]: + """Parse Streamlink quality/resolution""" + try: + quality_match = re.search(r'(\d+p|\d+x\d+)', line) + if quality_match: + quality = quality_match.group(1) + + if 'x' in quality: + resolution = quality + width, height = map(int, quality.split('x')) + else: + resolutions = { + '2160p': ('3840x2160', 3840, 2160), + '1080p': ('1920x1080', 1920, 1080), + '720p': ('1280x720', 1280, 720), + '480p': ('854x480', 854, 480), + '360p': ('640x360', 640, 360) + } + resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080)) + + return { + 'video_codec': 'h264', + 'resolution': resolution, + 'width': width, + 'height': height, + 'pixel_format': 'yuv420p' + } + + except Exception as e: + logger.debug(f"Error parsing Streamlink video info: {e}") + + return None + + def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]: + return None + + +class LogParserFactory: + """Factory to get the appropriate log parser""" + + _parsers = { + 'ffmpeg': FFmpegLogParser(), + 'vlc': VLCLogParser(), + 'streamlink': StreamlinkLogParser() + } + + @classmethod + def _get_parser_and_method(cls, stream_type: str) -> Optional[tuple[BaseLogParser, str]]: + """Determine parser and method from stream_type""" + # Check each parser to see if it handles this stream_type + for parser in cls._parsers.values(): + method_name = parser.STREAM_TYPE_METHODS.get(stream_type) + if method_name: + return (parser, method_name) + + return None + + @classmethod + def parse(cls, stream_type: str, line: str) -> Optional[Dict[str, Any]]: + """ + Parse a log line based on stream type. + Returns parsed data or None if parsing fails. + """ + result = cls._get_parser_and_method(stream_type) + if not result: + return None + + parser, method_name = result + method = getattr(parser, method_name, None) + if method: + return method(line) + + return None + + @classmethod + def auto_parse(cls, line: str) -> Optional[tuple[str, Dict[str, Any]]]: + """ + Automatically detect which parser can handle this line and parse it. + Returns (stream_type, parsed_data) or None if no parser can handle it. + """ + # Try each parser to see if it can handle this line + for parser in cls._parsers.values(): + stream_type = parser.can_parse(line) + if stream_type: + # Parser can handle this line, now parse it + parsed_data = cls.parse(stream_type, line) + if parsed_data: + return (stream_type, parsed_data) + + return None diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index d9cecfa4..beb7e57c 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -107,6 +107,10 @@ class StreamManager: # Add this flag for tracking transcoding process status self.transcode_process_active = False + # Track stream command for efficient log parser routing + self.stream_command = None + self.parser_type = None # Will be set when transcode process starts + # Add tracking for data throughput self.bytes_processed = 0 self.last_bytes_update = time.time() @@ -476,6 +480,21 @@ class StreamManager: # Build and start transcode command self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent) + # Store stream command for efficient log parser routing + self.stream_command = stream_profile.command + # Map actual commands to parser types for direct routing + command_to_parser = { + 'ffmpeg': 'ffmpeg', + 'cvlc': 'vlc', + 'vlc': 'vlc', + 'streamlink': 'streamlink' + } + self.parser_type = command_to_parser.get(self.stream_command.lower()) + if self.parser_type: + logger.debug(f"Using {self.parser_type} parser for log parsing (command: {self.stream_command})") + else: + logger.debug(f"Unknown stream command '{self.stream_command}', will use auto-detection for log parsing") + # For UDP streams, remove any user_agent parameters from the command if hasattr(self, 'stream_type') and self.stream_type == StreamType.UDP: # Filter out any arguments that contain the user_agent value or related headers @@ -645,35 +664,51 @@ class StreamManager: if content_lower.startswith('output #') or 'encoder' in content_lower: self.ffmpeg_input_phase = False - # Only parse stream info if we're still in the input phase - if ("stream #" in content_lower and - ("video:" in content_lower or "audio:" in content_lower) and - self.ffmpeg_input_phase): + # Route to appropriate parser based on known command type + from .services.log_parsers import LogParserFactory + from .services.channel_service import ChannelService - from .services.channel_service import ChannelService - if "video:" in content_lower: - ChannelService.parse_and_store_stream_info(self.channel_id, content, "video", self.current_stream_id) - elif "audio:" in content_lower: - ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio", self.current_stream_id) + parse_result = None + + # If we know the parser type, use direct routing for efficiency + if self.parser_type: + # Get the appropriate parser and check what it can parse + parser = LogParserFactory._parsers.get(self.parser_type) + if parser: + stream_type = parser.can_parse(content) + if stream_type: + # Parser can handle this line, parse it directly + parsed_data = LogParserFactory.parse(stream_type, content) + if parsed_data: + parse_result = (stream_type, parsed_data) + else: + # Unknown command type - use auto-detection as fallback + parse_result = LogParserFactory.auto_parse(content) + + if parse_result: + stream_type, parsed_data = parse_result + # For FFmpeg, only parse during input phase + if stream_type in ['video', 'audio', 'input']: + if self.ffmpeg_input_phase: + ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id) + else: + # VLC and Streamlink can be parsed anytime + ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id) # Determine log level based on content if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']): - logger.error(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.error(f"Stream process error for channel {self.channel_id}: {content}") elif any(keyword in content_lower for keyword in ['warning', 'deprecated', 'ignoring']): - logger.warning(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.warning(f"Stream process warning for channel {self.channel_id}: {content}") elif content.startswith('frame=') or 'fps=' in content or 'speed=' in content: # Stats lines - log at trace level to avoid spam - logger.trace(f"FFmpeg stats for channel {self.channel_id}: {content}") + logger.trace(f"Stream stats for channel {self.channel_id}: {content}") elif any(keyword in content_lower for keyword in ['input', 'output', 'stream', 'video', 'audio']): # Stream info - log at info level - logger.info(f"FFmpeg info for channel {self.channel_id}: {content}") - if content.startswith('Input #0'): - # If it's input 0, parse stream info - from .services.channel_service import ChannelService - ChannelService.parse_and_store_stream_info(self.channel_id, content, "input", self.current_stream_id) + logger.info(f"Stream info for channel {self.channel_id}: {content}") else: # Everything else at debug level - logger.debug(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.debug(f"Stream process output for channel {self.channel_id}: {content}") except Exception as e: logger.error(f"Error logging stderr content for channel {self.channel_id}: {e}") diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 88c13ddf..3eb35b11 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -462,16 +462,21 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): session.headers.update(headers) # Make HEAD request first as it's faster and doesn't download content - head_response = session.head( - url, - timeout=timeout, - allow_redirects=True - ) + head_request_success = True + try: + head_response = session.head( + url, + timeout=timeout, + allow_redirects=True + ) + except requests.exceptions.RequestException as e: + head_request_success = False + logger.warning(f"Request error (HEAD), assuming HEAD not supported: {str(e)}") # If HEAD not supported, server will return 405 or other error - if 200 <= head_response.status_code < 300: + if head_request_success and (200 <= head_response.status_code < 300): # HEAD request successful - return True, head_response.url, head_response.status_code, "Valid (HEAD request)" + return True, url, head_response.status_code, "Valid (HEAD request)" # Try a GET request with stream=True to avoid downloading all content get_response = session.get( @@ -484,7 +489,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): # IMPORTANT: Check status code first before checking content if not (200 <= get_response.status_code < 300): logger.warning(f"Stream validation failed with HTTP status {get_response.status_code}") - return False, get_response.url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" + return False, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" # Only check content if status code is valid try: @@ -538,7 +543,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): get_response.close() # If we have content, consider it valid even with unrecognized content type - return is_valid, get_response.url, get_response.status_code, message + return is_valid, url, get_response.status_code, message except requests.exceptions.Timeout: return False, url, 0, "Timeout connecting to stream" diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index 00ca77aa..20b94b03 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -97,7 +97,20 @@ class PersistentVODConnection: # First check if we have a pre-stored content length from HEAD request try: import redis - r = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True) + from django.conf import settings + redis_host = getattr(settings, 'REDIS_HOST', 'localhost') + redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) + redis_db = int(getattr(settings, 'REDIS_DB', 0)) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{self.session_id}" stored_length = r.get(content_length_key) if stored_length: @@ -446,13 +459,12 @@ class VODConnectionManager: return False try: - # Check profile connection limits using standardized key - if not self._check_profile_limits(m3u_profile): + # Atomically check and reserve a profile connection slot (INCR-first) + if not self._check_and_reserve_profile_slot(m3u_profile): logger.warning(f"Profile {m3u_profile.name} connection limit exceeded") return False connection_key = self._get_connection_key(content_type, content_uuid, client_id) - profile_connections_key = self._get_profile_connections_key(m3u_profile.id) content_connections_key = self._get_content_connections_key(content_type, content_uuid) # Check if connection already exists to prevent duplicate counting @@ -460,6 +472,9 @@ class VODConnectionManager: logger.info(f"Connection already exists for {client_id} - {content_type} {content_name}") # Update activity but don't increment profile counter self.redis_client.hset(connection_key, "last_activity", str(time.time())) + # Roll back the reservation — connection already counted + if m3u_profile.max_streams > 0: + self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id)) return True # Connection data @@ -486,8 +501,7 @@ class VODConnectionManager: pipe.hset(connection_key, mapping=connection_data) pipe.expire(connection_key, self.connection_ttl) - # Increment profile connections using standardized method - pipe.incr(profile_connections_key) + # Profile counter already incremented atomically above — no pipe.incr needed # Add to content connections set pipe.sadd(content_connections_key, client_id) @@ -500,6 +514,9 @@ class VODConnectionManager: return True except Exception as e: + # Roll back the profile reservation on failure + if m3u_profile.max_streams > 0: + self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id)) logger.error(f"Error creating VOD connection: {e}") return False @@ -518,6 +535,41 @@ class VODConnectionManager: logger.error(f"Error checking profile limits: {e}") return False + def _check_and_reserve_profile_slot(self, m3u_profile: M3UAccountProfile) -> bool: + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET > check > INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Returns: + bool: True if slot was reserved (or unlimited), False if at capacity + """ + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + + # Atomically increment first — single Redis command eliminates race window + new_count = self.redis_client.incr(profile_connections_key) + + if new_count <= m3u_profile.max_streams: + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") + return True + + # Over capacity — roll back the increment + self.redis_client.decr(profile_connections_key) + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") + return False + + except Exception as e: + logger.error(f"Error reserving profile slot: {e}") + return False + def update_connection_activity(self, content_type: str, content_uuid: str, client_id: str, bytes_sent: int = 0, position_seconds: int = 0) -> bool: diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index e1fe4c4b..80eb65e2 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -24,6 +24,11 @@ from apps.m3u.models import M3UAccountProfile logger = logging.getLogger("vod_proxy") +def get_vod_client_stop_key(client_id): + """Get the Redis key for signaling a VOD client to stop""" + return f"vod_proxy:client:{client_id}:stop" + + def infer_content_type_from_url(url: str) -> Optional[str]: """ Infer MIME type from file extension in URL @@ -352,12 +357,12 @@ class RedisBackedVODConnection: logger.info(f"[{self.session_id}] Making request #{state.request_count} to {'final' if state.final_url else 'original'} URL") - # Make request + # Make request (10s connect, 10s read timeout - keeps lock time reasonable if client disconnects) response = self.local_session.get( target_url, headers=headers, stream=True, - timeout=(10, 30), + timeout=(10, 10), allow_redirects=allow_redirects ) response.raise_for_status() @@ -411,8 +416,22 @@ class RedisBackedVODConnection: logger.info(f"[{self.session_id}] Updated connection state: length={state.content_length}, type={state.content_type}") - # Save updated state - self._save_connection_state(state) + # Save updated state under lock to avoid overwriting concurrent + # active_streams changes (e.g., another stream's GeneratorExit decrement) + if self._acquire_lock(): + try: + current = self._get_connection_state() + if current: + # Preserve the current active_streams value — it may have been + # modified by concurrent increment/decrement operations while + # waiting for the upstream HTTP response. + state.active_streams = current.active_streams + self._save_connection_state(state) + finally: + self._release_lock() + else: + # Fallback: save without lock but skip active_streams to avoid overwrite + logger.warning(f"[{self.session_id}] Could not acquire lock for get_stream state save") self.local_response = response return response @@ -461,35 +480,44 @@ class RedisBackedVODConnection: return range_header def increment_active_streams(self): - """Increment active streams count in Redis""" + """Increment active streams count in Redis. Returns new active_streams count, or 0 on failure.""" if not self._acquire_lock(): - return False + logger.warning(f"[{self.session_id}] INCR-AS failed: could not acquire lock") + return 0 try: state = self._get_connection_state() if state: + old = state.active_streams state.active_streams += 1 state.last_activity = time.time() self._save_connection_state(state) - logger.debug(f"[{self.session_id}] Active streams incremented to {state.active_streams}") - return True - return False + logger.debug(f"[{self.session_id}] INCR-AS {old} -> {state.active_streams}") + return state.active_streams + logger.warning(f"[{self.session_id}] INCR-AS failed: no state") + return 0 finally: self._release_lock() def decrement_active_streams(self): """Decrement active streams count in Redis""" if not self._acquire_lock(): + logger.warning(f"[{self.session_id}] DECR-AS failed: could not acquire lock") return False try: state = self._get_connection_state() if state and state.active_streams > 0: + old = state.active_streams state.active_streams -= 1 state.last_activity = time.time() self._save_connection_state(state) - logger.debug(f"[{self.session_id}] Active streams decremented to {state.active_streams}") + logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}") return True + if not state: + logger.warning(f"[{self.session_id}] DECR-AS failed: no state") + else: + logger.warning(f"[{self.session_id}] DECR-AS failed: active_streams already {state.active_streams}") return False finally: self._release_lock() @@ -669,6 +697,41 @@ class MultiWorkerVODConnectionManager: logger.error(f"Error checking profile limits: {e}") return False + def _check_and_reserve_profile_slot(self, m3u_profile) -> bool: + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET > check > INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Returns: + bool: True if slot was reserved (or unlimited), False if at capacity + """ + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + + # Atomically increment first — single Redis command eliminates race window + new_count = self.redis_client.incr(profile_connections_key) + + if new_count <= m3u_profile.max_streams: + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") + return True + + # Over capacity — roll back the increment + self.redis_client.decr(profile_connections_key) + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") + return False + + except Exception as e: + logger.error(f"Error reserving profile slot: {e}") + return False + def _increment_profile_connections(self, m3u_profile): """Increment profile connection count""" try: @@ -707,6 +770,10 @@ class MultiWorkerVODConnectionManager: content_name = content_obj.name if hasattr(content_obj, 'name') else str(content_obj) client_id = session_id + # Track whether we incremented profile connections (for cleanup on error) + profile_connections_incremented = False + redis_connection = None + logger.info(f"[{client_id}] Worker {self.worker_id} - Redis-backed streaming request for {content_type} {content_name}") try: @@ -747,10 +814,11 @@ class MultiWorkerVODConnectionManager: if not existing_state: logger.info(f"[{client_id}] Worker {self.worker_id} - Creating new Redis-backed connection") - # Check profile limits before creating new connection - if not self._check_profile_limits(m3u_profile): + # Atomically check and reserve a profile connection slot (INCR-first) + if not self._check_and_reserve_profile_slot(m3u_profile): logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded") return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True # Apply timeshift parameters modified_stream_url = self._apply_timeshift_parameters(stream_url, utc_start, utc_end, offset) @@ -793,15 +861,43 @@ class MultiWorkerVODConnectionManager: worker_id=self.worker_id ): logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection") + # Roll back the profile slot reservation since connection failed + self._decrement_profile_connections(m3u_profile.id) + profile_connections_incremented = False return HttpResponse("Failed to create connection", status=500) - # Increment profile connections after successful connection creation - self._increment_profile_connections(m3u_profile) - logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata") else: logger.info(f"[{client_id}] Worker {self.worker_id} - Using existing Redis-backed connection") + # Immediately increment active_streams to prevent cleanup race condition. + # Without this, stream's GeneratorExit can see active_streams=0 + # and DECR the profile counter before the new generator starts. + if matching_session_id: + # Idle session reuse: active_streams already incremented at line 776 + # Always need to re-reserve profile slot (GeneratorExit DECRed it) + if not self._check_and_reserve_profile_slot(m3u_profile): + logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on session reuse") + redis_connection.decrement_active_streams() + return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True + else: + # Concurrent/reconnect: increment active_streams now (not in generator) + new_count = redis_connection.increment_active_streams() + if new_count == 1: + # 0→1 transition: previous stream's GeneratorExit already DECRed + # the profile counter, need to re-reserve the slot + if not self._check_and_reserve_profile_slot(m3u_profile): + logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on reconnect") + redis_connection.decrement_active_streams() + return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True + elif new_count == 0: + logger.error(f"[{client_id}] Failed to increment active streams") + return HttpResponse("Failed to reserve stream", status=500) + # else: new_count > 1, another stream is already active and profile + # counter already reflects it — no INCR needed + # Transfer ownership to current worker and update session activity if redis_connection._acquire_lock(): try: @@ -824,6 +920,12 @@ class MultiWorkerVODConnectionManager: if upstream_response is None: logger.warning(f"[{client_id}] Worker {self.worker_id} - Range not satisfiable") + if existing_state: + # Roll back the active_streams increment from the else branch + redis_connection.decrement_active_streams() + if profile_connections_incremented: + self._decrement_profile_connections(m3u_profile.id) + profile_connections_incremented = False return HttpResponse("Requested Range Not Satisfiable", status=416) # Get connection headers @@ -832,28 +934,41 @@ class MultiWorkerVODConnectionManager: # Create streaming generator def stream_generator(): decremented = False + stop_signal_detected = False try: logger.info(f"[{client_id}] Worker {self.worker_id} - Starting Redis-backed stream") - # Increment active streams (unless we already did it for session reuse) - if not matching_session_id: - # New session - increment active streams + # Increment active streams only for brand-new connections. + # For existing connections (session reuse or concurrent requests), + # active_streams was already incremented in the else branch above + # to prevent cleanup race conditions with GeneratorExit. + if not existing_state: redis_connection.increment_active_streams() else: - # Reused session - we already incremented when reserving the session - logger.debug(f"[{client_id}] Using pre-reserved session - active streams already incremented") + logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path") bytes_sent = 0 chunk_count = 0 + # Get the stop signal key for this client + stop_key = get_vod_client_stop_key(client_id) + for chunk in upstream_response.iter_content(chunk_size=8192): if chunk: yield chunk bytes_sent += len(chunk) chunk_count += 1 - # Update activity every 100 chunks in consolidated connection state + # Check for stop signal every 100 chunks if chunk_count % 100 == 0: + # Check if stop signal has been set + if self.redis_client and self.redis_client.exists(stop_key): + logger.info(f"[{client_id}] Worker {self.worker_id} - Stop signal detected, terminating stream") + # Delete the stop key + self.redis_client.delete(stop_key) + stop_signal_detected = True + break + # Update the connection state logger.debug(f"Client: [{client_id}] Worker: {self.worker_id} sent {chunk_count} chunks for VOD: {content_name}") if redis_connection._acquire_lock(): @@ -867,17 +982,28 @@ class MultiWorkerVODConnectionManager: finally: redis_connection._release_lock() - logger.info(f"[{client_id}] Worker {self.worker_id} - Redis-backed stream completed: {bytes_sent} bytes sent") + if stop_signal_detected: + logger.info(f"[{client_id}] Worker {self.worker_id} - Stream stopped by signal: {bytes_sent} bytes sent") + else: + logger.info(f"[{client_id}] Worker {self.worker_id} - Redis-backed stream completed: {bytes_sent} bytes sent") redis_connection.decrement_active_streams() decremented = True # Schedule smart cleanup if no active streams after normal completion if not redis_connection.has_active_streams(): + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on normal completion") + def delayed_cleanup(): time.sleep(1) # Wait 1 second # Smart cleanup: check active streams and ownership logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion") - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) import threading cleanup_thread = threading.Thread(target=delayed_cleanup) @@ -892,11 +1018,19 @@ class MultiWorkerVODConnectionManager: # Schedule smart cleanup if no active streams if not redis_connection.has_active_streams(): + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on client disconnect") + def delayed_cleanup(): time.sleep(1) # Wait 1 second # Smart cleanup: check active streams and ownership logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect") - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) import threading cleanup_thread = threading.Thread(target=delayed_cleanup) @@ -908,8 +1042,17 @@ class MultiWorkerVODConnectionManager: if not decremented: redis_connection.decrement_active_streams() decremented = True - # Smart cleanup on error - immediate cleanup since we're in error state - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + + # Decrement profile counter immediately if no other active streams + if not redis_connection.has_active_streams(): + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on stream error") + # Smart cleanup on error - immediate cleanup since we're in error state + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) yield b"Error: Stream interrupted" finally: @@ -1004,6 +1147,19 @@ class MultiWorkerVODConnectionManager: except Exception as e: logger.error(f"[{client_id}] Worker {self.worker_id} - Error in Redis-backed stream_content_with_session: {e}", exc_info=True) + + # Decrement profile connections if we incremented them but failed before streaming started + if profile_connections_incremented: + logger.info(f"[{client_id}] Connection error occurred after profile increment - decrementing profile connections") + self._decrement_profile_connections(m3u_profile.id) + + # Also clean up the Redis connection state since we won't be using it + if redis_connection: + try: + redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + except Exception as cleanup_error: + logger.error(f"[{client_id}] Error during cleanup after connection failure: {cleanup_error}") + return HttpResponse(f"Streaming error: {str(e)}", status=500) def _apply_timeshift_parameters(self, original_url, utc_start=None, utc_end=None, offset=None): diff --git a/apps/proxy/vod_proxy/urls.py b/apps/proxy/vod_proxy/urls.py index c06426ce..f48f70e0 100644 --- a/apps/proxy/vod_proxy/urls.py +++ b/apps/proxy/vod_proxy/urls.py @@ -21,4 +21,7 @@ urlpatterns = [ # VOD Stats path('stats/', views.VODStatsView.as_view(), name='vod_stats'), + + # Stop VOD client connection + path('stop_client/', views.stop_vod_client, name='stop_vod_client'), ] diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 12e1d071..e1fa8624 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -15,7 +15,7 @@ from django.views import View from apps.vod.models import Movie, Series, Episode from apps.m3u.models import M3UAccount, M3UAccountProfile from apps.proxy.vod_proxy.connection_manager import VODConnectionManager -from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url +from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key from .utils import get_client_info, create_vod_response logger = logging.getLogger(__name__) @@ -329,7 +329,20 @@ class VODStreamView(View): # Store the total content length in Redis for the persistent connection to use try: import redis - r = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True) + from django.conf import settings + redis_host = getattr(settings, 'REDIS_HOST', 'localhost') + redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) + redis_db = int(getattr(settings, 'REDIS_DB', 0)) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{session_id}" r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}") @@ -1001,3 +1014,59 @@ class VODStatsView(View): except Exception as e: logger.error(f"Error getting VOD stats: {e}") return JsonResponse({'error': str(e)}, status=500) + + +from rest_framework.decorators import api_view, permission_classes +from apps.accounts.permissions import IsAdmin + + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def stop_vod_client(request): + """Stop a specific VOD client connection using stop signal mechanism""" + try: + # Parse request body + import json + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + + client_id = data.get('client_id') + if not client_id: + return JsonResponse({'error': 'No client_id provided'}, status=400) + + logger.info(f"Request to stop VOD client: {client_id}") + + # Get Redis client + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return JsonResponse({'error': 'Redis not available'}, status=500) + + # Check if connection exists + connection_key = f"vod_persistent_connection:{client_id}" + connection_data = redis_client.hgetall(connection_key) + if not connection_data: + logger.warning(f"VOD connection not found: {client_id}") + return JsonResponse({'error': 'Connection not found'}, status=404) + + # Set a stop signal key that the worker will check + stop_key = get_vod_client_stop_key(client_id) + redis_client.setex(stop_key, 60, "true") # 60 second TTL + + logger.info(f"Set stop signal for VOD client: {client_id}") + + return JsonResponse({ + 'message': 'VOD client stop signal sent', + 'client_id': client_id, + 'stop_key': stop_key + }) + + except Exception as e: + logger.error(f"Error stopping VOD client: {e}", exc_info=True) + return JsonResponse({'error': str(e)}, status=500) + + diff --git a/apps/vod/admin.py b/apps/vod/admin.py index c660f310..6dadab3e 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -60,7 +60,7 @@ class M3USeriesRelationAdmin(admin.ModelAdmin): @admin.register(M3UEpisodeRelation) class M3UEpisodeRelationAdmin(admin.ModelAdmin): - list_display = ['episode', 'm3u_account', 'stream_id', 'created_at'] + list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at'] list_filter = ['m3u_account', 'created_at'] search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id'] readonly_fields = ['created_at', 'updated_at'] diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 8cc55a11..3bd984e6 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -62,7 +62,7 @@ class MovieFilter(django_filters.FilterSet): # Handle the format 'category_name|category_type' if '|' in value: - category_name, category_type = value.split('|', 1) + category_name, category_type = value.rsplit('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type @@ -219,7 +219,7 @@ class SeriesFilter(django_filters.FilterSet): # Handle the format 'category_name|category_type' if '|' in value: - category_name, category_type = value.split('|', 1) + category_name, category_type = value.rsplit('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type @@ -588,7 +588,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): if category: if '|' in category: - cat_name, cat_type = category.split('|', 1) + cat_name, cat_type = category.rsplit('|', 1) if cat_type == 'movie': where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" where_conditions[1] = "1=0" # Exclude series diff --git a/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py new file mode 100644 index 00000000..e4973627 --- /dev/null +++ b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-02-24 23:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'), + ] + + operations = [ + migrations.AddField( + model_name='m3uepisoderelation', + name='series_relation', + field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'), + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index 69aed808..dd7d1753 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -245,10 +245,13 @@ class M3UMovieRelation(models.Model): """Get the full stream URL for this movie from this provider""" # Build URL dynamically for XtreamCodes accounts if self.m3u_account.account_type == 'XC': - server_url = self.m3u_account.server_url.rstrip('/') + from core.xtream_codes import Client as XCClient + # Use XC client's URL normalization to handle malformed URLs + # (e.g., URLs with /player_api.php or query parameters) + normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) username = self.m3u_account.username password = self.m3u_account.password - return f"{server_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" else: # For other account types, we would need another way to build URLs return None @@ -258,6 +261,14 @@ class M3UEpisodeRelation(models.Model): """Links M3U accounts to Episodes with provider-specific information""" m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + series_relation = models.ForeignKey( + 'M3USeriesRelation', + on_delete=models.CASCADE, + related_name='episode_relations', + null=True, + blank=True, + help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed." + ) # Streaming information (provider-specific) stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") @@ -285,10 +296,12 @@ class M3UEpisodeRelation(models.Model): if self.m3u_account.account_type == 'XC': # For XtreamCodes accounts, build the URL dynamically - server_url = self.m3u_account.server_url.rstrip('/') + # Use XC client's URL normalization to handle malformed URLs + # (e.g., URLs with /player_api.php or query parameters) + normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) username = self.m3u_account.username password = self.m3u_account.password - return f"{server_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" else: # We might support non XC accounts in the future # For now, return None diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 1170543a..1b12c7b7 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -73,7 +73,9 @@ def refresh_vod_content(account_id): return f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds" except Exception as e: + import traceback logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}") + logger.error(f"Full traceback:\n{traceback.format_exc()}") # Send error notification send_m3u_update(account_id, "vod_refresh", 100, status="error", @@ -410,10 +412,10 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb') imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb') - # Clean empty string IDs - if tmdb_id == '': + # Clean empty string IDs and zero values (some providers use 0 to indicate no ID) + if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0': tmdb_id = None - if imdb_id == '': + if imdb_id == '' or imdb_id == 0 or imdb_id == '0': imdb_id = None # Create a unique key for this movie (priority: TMDB > IMDB > name+year) @@ -555,12 +557,19 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N # Handle logo assignment for existing movies logo_updated = False - if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: - new_logo = existing_logos[logo_url] - if movie.logo != new_logo: - movie._logo_to_update = new_logo + if logo_url and len(logo_url) <= 500: + if logo_url in existing_logos: + new_logo = existing_logos[logo_url] + if movie.logo_id != new_logo.id: + movie._logo_to_update = new_logo + logo_updated = True + elif movie.logo_id: + # Logo URL exists but logo creation failed or logo not found + # Clear the orphaned logo reference + logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference") + movie._logo_to_update = None logo_updated = True - elif (not logo_url or len(logo_url) > 500) and movie.logo: + elif (not logo_url or len(logo_url) > 500) and movie.logo_id: # Clear logo if no logo URL provided or URL is too long movie._logo_to_update = None logo_updated = True @@ -614,26 +623,41 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N # First, create new movies and get their IDs created_movies = {} if movies_to_create: - Movie.objects.bulk_create(movies_to_create, ignore_conflicts=True) + # Bulk query to check which movies already exist + tmdb_ids = [m.tmdb_id for m in movies_to_create if m.tmdb_id] + imdb_ids = [m.imdb_id for m in movies_to_create if m.imdb_id] + name_year_pairs = [(m.name, m.year) for m in movies_to_create if not m.tmdb_id and not m.imdb_id] - # Get the newly created movies with their IDs - # We need to re-fetch them to get the primary keys + existing_by_tmdb = {m.tmdb_id: m for m in Movie.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {} + existing_by_imdb = {m.imdb_id: m for m in Movie.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {} + + existing_by_name_year = {} + if name_year_pairs: + for movie in Movie.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True): + key = (movie.name, movie.year) + if key in name_year_pairs: + existing_by_name_year[key] = movie + + # Check each movie against the bulk query results + movies_actually_created = [] for movie in movies_to_create: - # Find the movie by its unique identifiers - if movie.tmdb_id: - db_movie = Movie.objects.filter(tmdb_id=movie.tmdb_id).first() - elif movie.imdb_id: - db_movie = Movie.objects.filter(imdb_id=movie.imdb_id).first() - else: - db_movie = Movie.objects.filter( - name=movie.name, - year=movie.year, - tmdb_id__isnull=True, - imdb_id__isnull=True - ).first() + existing = None + if movie.tmdb_id and movie.tmdb_id in existing_by_tmdb: + existing = existing_by_tmdb[movie.tmdb_id] + elif movie.imdb_id and movie.imdb_id in existing_by_imdb: + existing = existing_by_imdb[movie.imdb_id] + elif not movie.tmdb_id and not movie.imdb_id: + existing = existing_by_name_year.get((movie.name, movie.year)) - if db_movie: - created_movies[id(movie)] = db_movie + if existing: + created_movies[id(movie)] = existing + else: + movies_actually_created.append(movie) + created_movies[id(movie)] = movie + + # Bulk create only movies that don't exist + if movies_actually_created: + Movie.objects.bulk_create(movies_actually_created) # Update existing movies if movies_to_update: @@ -649,12 +673,16 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N movie.logo = movie._logo_to_update movie.save(update_fields=['logo']) - # Update relations to reference the correct movie objects + # Update relations to reference the correct movie objects (with PKs) for relation in relations_to_create: if id(relation.movie) in created_movies: relation.movie = created_movies[id(relation.movie)] - # Handle relations + for relation in relations_to_update: + if id(relation.movie) in created_movies: + relation.movie = created_movies[id(relation.movie)] + + # All movies now have PKs, safe to bulk create/update relations if relations_to_create: M3UMovieRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) @@ -724,10 +752,10 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id') imdb_id = series_data.get('imdb') or series_data.get('imdb_id') - # Clean empty string IDs - if tmdb_id == '': + # Clean empty string IDs and zero values (some providers use 0 to indicate no ID) + if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0': tmdb_id = None - if imdb_id == '': + if imdb_id == '' or imdb_id == 0 or imdb_id == '0': imdb_id = None # Create a unique key for this series (priority: TMDB > IMDB > name+year) @@ -886,12 +914,19 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= # Handle logo assignment for existing series logo_updated = False - if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: - new_logo = existing_logos[logo_url] - if series.logo != new_logo: - series._logo_to_update = new_logo + if logo_url and len(logo_url) <= 500: + if logo_url in existing_logos: + new_logo = existing_logos[logo_url] + if series.logo_id != new_logo.id: + series._logo_to_update = new_logo + logo_updated = True + elif series.logo_id: + # Logo URL exists but logo creation failed or logo not found + # Clear the orphaned logo reference + logger.warning(f"Logo URL provided but logo not found in database for series '{series.name}', clearing logo reference") + series._logo_to_update = None logo_updated = True - elif (not logo_url or len(logo_url) > 500) and series.logo: + elif (not logo_url or len(logo_url) > 500) and series.logo_id: # Clear logo if no logo URL provided or URL is too long series._logo_to_update = None logo_updated = True @@ -945,26 +980,41 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= # First, create new series and get their IDs created_series = {} if series_to_create: - Series.objects.bulk_create(series_to_create, ignore_conflicts=True) + # Bulk query to check which series already exist + tmdb_ids = [s.tmdb_id for s in series_to_create if s.tmdb_id] + imdb_ids = [s.imdb_id for s in series_to_create if s.imdb_id] + name_year_pairs = [(s.name, s.year) for s in series_to_create if not s.tmdb_id and not s.imdb_id] - # Get the newly created series with their IDs - # We need to re-fetch them to get the primary keys + existing_by_tmdb = {s.tmdb_id: s for s in Series.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {} + existing_by_imdb = {s.imdb_id: s for s in Series.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {} + + existing_by_name_year = {} + if name_year_pairs: + for series in Series.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True): + key = (series.name, series.year) + if key in name_year_pairs: + existing_by_name_year[key] = series + + # Check each series against the bulk query results + series_actually_created = [] for series in series_to_create: - # Find the series by its unique identifiers - if series.tmdb_id: - db_series = Series.objects.filter(tmdb_id=series.tmdb_id).first() - elif series.imdb_id: - db_series = Series.objects.filter(imdb_id=series.imdb_id).first() - else: - db_series = Series.objects.filter( - name=series.name, - year=series.year, - tmdb_id__isnull=True, - imdb_id__isnull=True - ).first() + existing = None + if series.tmdb_id and series.tmdb_id in existing_by_tmdb: + existing = existing_by_tmdb[series.tmdb_id] + elif series.imdb_id and series.imdb_id in existing_by_imdb: + existing = existing_by_imdb[series.imdb_id] + elif not series.tmdb_id and not series.imdb_id: + existing = existing_by_name_year.get((series.name, series.year)) - if db_series: - created_series[id(series)] = db_series + if existing: + created_series[id(series)] = existing + else: + series_actually_created.append(series) + created_series[id(series)] = series + + # Bulk create only series that don't exist + if series_actually_created: + Series.objects.bulk_create(series_actually_created) # Update existing series if series_to_update: @@ -980,12 +1030,16 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= series.logo = series._logo_to_update series.save(update_fields=['logo']) - # Update relations to reference the correct series objects + # Update relations to reference the correct series objects (with PKs) for relation in relations_to_create: if id(relation.series) in created_series: relation.series = created_series[id(relation.series)] - # Handle relations + for relation in relations_to_update: + if id(relation.series) in created_series: + relation.series = created_series[id(relation.series)] + + # All series now have PKs, safe to bulk create/update relations if relations_to_create: M3USeriesRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) @@ -1204,20 +1258,15 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N else: episodes_data = {} - # Clear existing episodes for this account to handle deletions - Episode.objects.filter( - series=series, - m3u_relations__m3u_account=account - ).delete() + # Fetch the series relation once — used both to pass into batch_process_episodes + # (so episode relations get the FK set) and to update metadata afterwards. + series_relation = M3USeriesRelation.objects.filter( + m3u_account=account, + external_series_id=external_series_id + ).first() # Process all episodes in batch - batch_process_episodes(account, series, episodes_data) - - # Update the series relation to mark episodes as fetched - series_relation = M3USeriesRelation.objects.filter( - series=series, - m3u_account=account - ).first() + batch_process_episodes(account, series, episodes_data, series_relation=series_relation) if series_relation: custom_props = series_relation.custom_properties or {} @@ -1231,8 +1280,19 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}") -def batch_process_episodes(account, series, episodes_data, scan_start_time=None): - """Process episodes in batches for better performance""" +def batch_process_episodes(account, series, episodes_data, scan_start_time=None, series_relation=None): + """Process episodes in batches for better performance. + + Note: Multiple streams can represent the same episode (e.g., different languages + or qualities). Each stream has a unique stream_id, but they share the same + season/episode number. We create one Episode record per (series, season, episode) + and multiple M3UEpisodeRelation records pointing to it. + + series_relation, when provided, is stored as a FK on each M3UEpisodeRelation so + that CASCADE correctly removes episode relations when their parent series relation + is deleted, and so that stale-stream cleanup is scoped precisely to relations that + came from this specific provider query. + """ if not episodes_data: return @@ -1249,12 +1309,13 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) logger.info(f"Batch processing {len(all_episodes_data)} episodes for series {series.name}") # Extract episode identifiers - episode_keys = [] + # Note: episode_keys may have duplicates when multiple streams represent same episode + episode_keys = set() # Use set to track unique episode keys episode_ids = [] for episode_data in all_episodes_data: season_num = episode_data['_season_number'] episode_num = episode_data.get('episode_num', 0) - episode_keys.append((series.id, season_num, episode_num)) + episode_keys.add((series.id, season_num, episode_num)) episode_ids.append(str(episode_data.get('id'))) # Pre-fetch existing episodes @@ -1277,12 +1338,25 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) relations_to_create = [] relations_to_update = [] + # Track episodes we're creating in this batch to avoid duplicates + # Key: (series_id, season_number, episode_number) -> Episode object + episodes_pending_creation = {} + for episode_data in all_episodes_data: try: episode_id = str(episode_data.get('id')) episode_name = episode_data.get('title', 'Unknown Episode') - season_number = episode_data['_season_number'] - episode_number = episode_data.get('episode_num', 0) + # Ensure season and episode numbers are integers (API may return strings) + try: + season_number = int(episode_data['_season_number']) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid season_number '{episode_data.get('_season_number')}' for episode '{episode_name}': {e}") + season_number = 0 + try: + episode_number = int(episode_data.get('episode_num', 0)) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid episode_num '{episode_data.get('episode_num')}' for episode '{episode_name}': {e}") + episode_number = 0 info = episode_data.get('info', {}) # Extract episode metadata @@ -1306,10 +1380,15 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) if backdrop: custom_props['backdrop_path'] = [backdrop] - # Find existing episode + # Find existing episode - check DB first, then pending creations episode_key = (series.id, season_number, episode_number) episode = existing_episodes.get(episode_key) + # Check if we already have this episode pending creation (multiple streams for same episode) + if not episode and episode_key in episodes_pending_creation: + episode = episodes_pending_creation[episode_key] + logger.debug(f"Reusing pending episode for S{season_number}E{episode_number} (stream_id: {episode_id})") + if episode: # Update existing episode updated = False @@ -1338,7 +1417,9 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) episode.custom_properties = custom_props if custom_props else None updated = True - if updated: + # Only add to update list if episode has a PK (exists in DB) and isn't already in list + # Episodes pending creation don't have PKs yet and will be created via bulk_create + if updated and episode.pk and episode not in episodes_to_update: episodes_to_update.append(episode) else: # Create new episode @@ -1356,16 +1437,19 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) custom_properties=custom_props if custom_props else None ) episodes_to_create.append(episode) + # Track this episode so subsequent streams with same season/episode can reuse it + episodes_pending_creation[episode_key] = episode # Handle episode relation if episode_id in existing_relations: # Update existing relation relation = existing_relations[episode_id] relation.episode = episode + relation.series_relation = series_relation relation.container_extension = episode_data.get('container_extension', 'mp4') relation.custom_properties = { 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, } relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan relations_to_update.append(relation) @@ -1374,11 +1458,12 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) relation = M3UEpisodeRelation( m3u_account=account, episode=episode, + series_relation=series_relation, stream_id=episode_id, container_extension=episode_data.get('container_extension', 'mp4'), custom_properties={ 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, }, last_seen=scan_start_time or timezone.now() # Mark as seen during this scan ) @@ -1389,9 +1474,43 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Execute batch operations with transaction.atomic(): - # Create new episodes + # Create new episodes - use ignore_conflicts in case of race conditions if episodes_to_create: - Episode.objects.bulk_create(episodes_to_create) + Episode.objects.bulk_create(episodes_to_create, ignore_conflicts=True) + + # Re-fetch the created episodes to get their PKs + # We need to do this because bulk_create with ignore_conflicts doesn't set PKs + created_episode_keys = [ + (ep.series_id, ep.season_number, ep.episode_number) + for ep in episodes_to_create + ] + db_episodes = Episode.objects.filter(series=series) + episode_pk_map = { + (ep.series_id, ep.season_number, ep.episode_number): ep + for ep in db_episodes + } + + # Update relations to point to the actual DB episodes with PKs + for relation in relations_to_create: + ep = relation.episode + key = (ep.series_id, ep.season_number, ep.episode_number) + if key in episode_pk_map: + relation.episode = episode_pk_map[key] + + # Filter out relations with unsaved episodes (no PK) + # This can happen if bulk_create had a conflict and ignore_conflicts=True didn't save the episode + valid_relations_to_create = [] + for relation in relations_to_create: + if relation.episode.pk is not None: + valid_relations_to_create.append(relation) + else: + season_num = relation.episode.season_number + episode_num = relation.episode.episode_number + logger.warning( + f"Skipping relation for episode S{season_num}E{episode_num} " + f"- episode not saved to database" + ) + relations_to_create = valid_relations_to_create # Update existing episodes if episodes_to_update: @@ -1400,16 +1519,35 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) 'tmdb_id', 'imdb_id', 'custom_properties' ]) - # Create new episode relations + # Create new episode relations - use ignore_conflicts for stream_id duplicates if relations_to_create: - M3UEpisodeRelation.objects.bulk_create(relations_to_create) + M3UEpisodeRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) # Update existing episode relations if relations_to_update: M3UEpisodeRelation.objects.bulk_update(relations_to_update, [ - 'episode', 'container_extension', 'custom_properties', 'last_seen' + 'episode', 'series_relation', 'container_extension', 'custom_properties', 'last_seen' ]) + # Delete relations for streams no longer returned by the provider. + # Scope to this series_relation FK (post-migration rows) plus any legacy NULL rows + # for the same account+series (pre-migration rows whose stream is now gone — the + # update path only backfills the FK for streams still present in the response). + # Falls back to account+series scope when series_relation is None (shouldn't occur). + if series_relation is not None: + stale_qs = M3UEpisodeRelation.objects.filter( + Q(series_relation=series_relation) | + Q(series_relation__isnull=True, m3u_account=account, episode__series=series) + ) + else: + stale_qs = M3UEpisodeRelation.objects.filter( + m3u_account=account, + episode__series=series + ) + removed_count = stale_qs.exclude(stream_id__in=episode_ids).delete()[0] + if removed_count: + logger.info(f"Removed {removed_count} episode relations no longer present in provider for series {series.name}") + logger.info(f"Batch processed episodes: {len(episodes_to_create)} new, {len(episodes_to_update)} updated, " f"{len(relations_to_create)} new relations, {len(relations_to_update)} updated relations") @@ -1494,16 +1632,12 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= stale_movie_count = stale_movie_relations.count() stale_movie_relations.delete() - # Clean up stale series relations + # Clean up stale series relations. + # Episode relations are removed via CASCADE on the series_relation FK. stale_series_relations = M3USeriesRelation.objects.filter(**base_filters) stale_series_count = stale_series_relations.count() stale_series_relations.delete() - # Clean up stale episode relations - stale_episode_relations = M3UEpisodeRelation.objects.filter(**base_filters) - stale_episode_count = stale_episode_relations.count() - stale_episode_relations.delete() - # Clean up movies with no relations (orphaned) # Safe to delete even during account-specific cleanup because if ANY account # has a relation, m3u_relations will not be null @@ -1520,11 +1654,8 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations") orphaned_series.delete() - # Episodes will be cleaned up via CASCADE when series are deleted - result = (f"Cleaned up {stale_movie_count} stale movie relations, " f"{stale_series_count} stale series relations, " - f"{stale_episode_count} stale episode relations, " f"{orphaned_movie_count} orphaned movies, and " f"{orphaned_series_count} orphaned series") @@ -2075,33 +2206,3 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False): except Exception as e: logger.error(f"Error refreshing advanced movie data for relation {m3u_movie_relation_id}: {str(e)}") return f"Error: {str(e)}" - - -def validate_logo_reference(obj, obj_type="object"): - """ - Validate that a VOD logo reference exists in the database. - If not, set it to None to prevent foreign key constraint violations. - - Args: - obj: Object with a logo attribute - obj_type: String description of the object type for logging - - Returns: - bool: True if logo was valid or None, False if logo was invalid and cleared - """ - if not hasattr(obj, 'logo') or not obj.logo: - return True - - if not obj.logo.pk: - # Logo doesn't have a primary key, so it's not saved - obj.logo = None - return False - - try: - # Verify the logo exists in the database - VODLogo.objects.get(pk=obj.logo.pk) - return True - except VODLogo.DoesNotExist: - logger.warning(f"VOD Logo with ID {obj.logo.pk} does not exist in database for {obj_type} '{getattr(obj, 'name', 'Unknown')}', setting to None") - obj.logo = None - return False diff --git a/core/api_urls.py b/core/api_urls.py index 75257db1..1ac58455 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -6,6 +6,7 @@ from .api_views import ( UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, + SystemNotificationViewSet, environment, version, rehash_streams_endpoint, @@ -17,6 +18,7 @@ router = DefaultRouter() router.register(r'useragents', UserAgentViewSet, basename='useragent') router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile') router.register(r'settings', CoreSettingsViewSet, basename='coresettings') +router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification') urlpatterns = [ path('settings/env/', environment, name='token_refresh'), path('version/', version, name='version'), diff --git a/core/api_views.py b/core/api_views.py index c50d7fa6..3d131ab9 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -3,20 +3,22 @@ import json import ipaddress import logging +from django.db import models from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.views import APIView from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import api_view, permission_classes, action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from .models import ( UserAgent, StreamProfile, CoreSettings, - STREAM_HASH_KEY, - NETWORK_ACCESS, + STREAM_SETTINGS_KEY, + DVR_SETTINGS_KEY, + NETWORK_ACCESS_KEY, PROXY_SETTINGS_KEY, ) from .serializers import ( @@ -68,16 +70,28 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): instance = self.get_object() + old_value = instance.value response = super().update(request, *args, **kwargs) - if instance.key == STREAM_HASH_KEY: - if instance.value != request.data["value"]: - rehash_streams.delay(request.data["value"].split(",")) - # If DVR pre/post offsets changed, reschedule upcoming recordings - try: - from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY - if instance.key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): - if instance.value != request.data.get("value"): + # If stream settings changed and m3u_hash_key is different, rehash streams + if instance.key == STREAM_SETTINGS_KEY: + new_value = request.data.get("value", {}) + if isinstance(new_value, dict) and isinstance(old_value, dict): + old_hash = old_value.get("m3u_hash_key", "") + new_hash = new_value.get("m3u_hash_key", "") + if old_hash != new_hash: + hash_keys = new_hash.split(",") if isinstance(new_hash, str) else new_hash + rehash_streams.delay(hash_keys) + + # If DVR settings changed and pre/post offsets are different, reschedule upcoming recordings + if instance.key == DVR_SETTINGS_KEY: + new_value = request.data.get("value", {}) + if isinstance(new_value, dict) and isinstance(old_value, dict): + old_pre = old_value.get("pre_offset_minutes") + new_pre = new_value.get("pre_offset_minutes") + old_post = old_value.get("post_offset_minutes") + new_post = new_value.get("post_offset_minutes") + if old_pre != new_pre or old_post != new_post: try: # Prefer async task if Celery is available from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change @@ -86,24 +100,23 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): # Fallback to synchronous implementation from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl reschedule_upcoming_recordings_for_offset_change_impl() - except Exception: - pass return response def create(self, request, *args, **kwargs): response = super().create(request, *args, **kwargs) - # If creating DVR pre/post offset settings, also reschedule upcoming recordings + # If creating DVR settings with offset values, reschedule upcoming recordings try: key = request.data.get("key") - from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY - if key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): - try: - from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change - reschedule_upcoming_recordings_for_offset_change.delay() - except Exception: - from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl - reschedule_upcoming_recordings_for_offset_change_impl() + if key == DVR_SETTINGS_KEY: + value = request.data.get("value", {}) + if isinstance(value, dict) and ("pre_offset_minutes" in value or "post_offset_minutes" in value): + try: + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change + reschedule_upcoming_recordings_for_offset_change.delay() + except Exception: + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl + reschedule_upcoming_recordings_for_offset_change_impl() except Exception: pass return response @@ -111,13 +124,13 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): def check(self, request, *args, **kwargs): data = request.data - if data.get("key") == NETWORK_ACCESS: + if data.get("key") == NETWORK_ACCESS_KEY: client_ip = ipaddress.ip_address(get_client_ip(request)) in_network = {} invalid = [] - value = json.loads(data.get("value", "{}")) + value = data.get("value", {}) for key, val in value.items(): in_network[key] = [] cidrs = val.split(",") @@ -143,7 +156,11 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) - return Response(in_network, status=status.HTTP_200_OK) + response_data = { + **in_network, + "client_ip": str(client_ip) + } + return Response(response_data, status=status.HTTP_200_OK) return Response({}, status=status.HTTP_200_OK) @@ -157,8 +174,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): """Get or create the proxy settings CoreSettings entry""" try: settings_obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY) - settings_data = json.loads(settings_obj.value) - except (CoreSettings.DoesNotExist, json.JSONDecodeError): + settings_data = settings_obj.value + except CoreSettings.DoesNotExist: # Create default settings settings_data = { "buffering_timeout": 15, @@ -171,7 +188,7 @@ class ProxySettingsViewSet(viewsets.ViewSet): key=PROXY_SETTINGS_KEY, defaults={ "name": "Proxy Settings", - "value": json.dumps(settings_data) + "value": settings_data } ) return settings_obj, settings_data @@ -193,8 +210,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): serializer = ProxySettingsSerializer(data=request.data) serializer.is_valid(raise_exception=True) - # Update the JSON data - settings_obj.value = json.dumps(serializer.validated_data) + # Update the JSON data - store as dict directly + settings_obj.value = serializer.validated_data settings_obj.save() return Response(serializer.validated_data) @@ -209,8 +226,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): serializer = ProxySettingsSerializer(data=updated_data) serializer.is_valid(raise_exception=True) - # Update the JSON data - settings_obj.value = json.dumps(serializer.validated_data) + # Update the JSON data - store as dict directly + settings_obj.value = serializer.validated_data settings_obj.save() return Response(serializer.validated_data) @@ -225,10 +242,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): -@swagger_auto_schema( - method="get", - operation_description="Endpoint for environment details", - responses={200: "Environment variables"}, +@extend_schema( + description="Endpoint for environment details", ) @api_view(["GET"]) @permission_classes([Authenticated]) @@ -298,10 +313,8 @@ def environment(request): ) -@swagger_auto_schema( - method="get", - operation_description="Get application version information", - responses={200: "Version information"}, +@extend_schema( + description="Get application version information", ) @api_view(["GET"]) @@ -317,10 +330,8 @@ def version(request): ) -@swagger_auto_schema( - method="post", - operation_description="Trigger rehashing of all streams", - responses={200: "Rehash task started"}, +@extend_schema( + description="Trigger rehashing of all streams", ) @api_view(["POST"]) @permission_classes([Authenticated]) @@ -328,8 +339,8 @@ def rehash_streams_endpoint(request): """Trigger the rehash streams task""" try: # Get the current hash keys from settings - hash_key_setting = CoreSettings.objects.get(key=STREAM_HASH_KEY) - hash_keys = hash_key_setting.value.split(",") + hash_key = CoreSettings.get_m3u_hash_key() + hash_keys = hash_key.split(",") if isinstance(hash_key, str) else hash_key # Queue the rehash task task = rehash_streams.delay(hash_keys) @@ -340,10 +351,10 @@ def rehash_streams_endpoint(request): "task_id": task.id }, status=status.HTTP_200_OK) - except CoreSettings.DoesNotExist: + except Exception as e: return Response({ "success": False, - "message": "Hash key settings not found" + "message": f"Error triggering rehash: {str(e)}" }, status=status.HTTP_400_BAD_REQUEST) except Exception as e: @@ -367,9 +378,8 @@ class TimezoneListView(APIView): def get_permissions(self): return [Authenticated()] - @swagger_auto_schema( - operation_description="Get list of all supported timezones", - responses={200: openapi.Response('List of timezones with grouping by region')} + @extend_schema( + description="Get list of all supported timezones", ) def get(self, request): import pytz @@ -457,3 +467,159 @@ def get_system_events(request): return Response({ 'error': 'Failed to fetch system events' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# ───────────────────────────── +# System Notifications API +# ───────────────────────────── +from .models import SystemNotification, NotificationDismissal +from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer +from django.utils import timezone as dj_timezone + + +class SystemNotificationViewSet(viewsets.ModelViewSet): + """ + API endpoint for system notifications. + Users can view active notifications and dismiss them. + Admins can create and manage notifications. + """ + serializer_class = SystemNotificationSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Return notifications based on user permissions. + Filter out expired and dismissed notifications for regular users. + Evaluate conditions for developer notifications. + """ + from core.developer_notifications import evaluate_conditions + from django.core.cache import cache + + user = self.request.user + now = dj_timezone.now() + + queryset = SystemNotification.objects.filter(is_active=True) + + # Filter out expired notifications + queryset = queryset.filter( + models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now) + ) + + # Filter admin-only notifications for non-admins + if getattr(user, 'user_level', 0) < 10: + queryset = queryset.filter(admin_only=False) + + # For developer notifications, evaluate conditions + # Cache the evaluation per notification to avoid repeated condition checks + notifications_to_exclude = [] + developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER) + + for notification in developer_notifications: + action_data = notification.action_data or {} + conditions = action_data.get('condition', []) + + if not conditions: + continue + + # Cache key based on notification ID and current settings + # Cache for 5 minutes to balance freshness with performance + cache_key = f'dev_notif_condition_{notification.id}_{user.id}' + should_show = cache.get(cache_key) + + if should_show is None: + should_show = evaluate_conditions(conditions, user) + cache.set(cache_key, should_show, timeout=300) # 5 minutes + + if not should_show: + notifications_to_exclude.append(notification.id) + + if notifications_to_exclude: + queryset = queryset.exclude(id__in=notifications_to_exclude) + + return queryset + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + def list(self, request): + """ + List all active notifications for the current user. + Optionally filter by dismissed status. + """ + queryset = self.get_queryset() + + # Optional: filter out already dismissed notifications + include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true' + if not include_dismissed: + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + queryset = queryset.exclude(id__in=dismissed_ids) + + serializer = self.get_serializer(queryset, many=True) + return Response({ + 'notifications': serializer.data, + 'count': len(serializer.data), + 'unread_count': queryset.count() + }) + + @action(detail=True, methods=['post'], url_path='dismiss') + def dismiss(self, request, pk=None): + """Dismiss a notification for the current user.""" + notification = self.get_object() + action_taken = request.data.get('action_taken', None) + + dismissal, created = NotificationDismissal.objects.get_or_create( + user=request.user, + notification=notification, + defaults={'action_taken': action_taken} + ) + + if not created and action_taken: + dismissal.action_taken = action_taken + dismissal.save() + + return Response({ + 'success': True, + 'message': 'Notification dismissed', + 'notification_key': notification.notification_key + }) + + @action(detail=False, methods=['post'], url_path='dismiss-all') + def dismiss_all(self, request): + """Dismiss all notifications for the current user.""" + notifications = self.get_queryset() + + # Get notifications not yet dismissed + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + to_dismiss = notifications.exclude(id__in=dismissed_ids) + + # Create dismissals for all + dismissals = [ + NotificationDismissal(user=request.user, notification=n) + for n in to_dismiss + ] + NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True) + + return Response({ + 'success': True, + 'dismissed_count': len(dismissals) + }) + + @action(detail=False, methods=['get'], url_path='count') + def unread_count(self, request): + """Get count of unread notifications.""" + queryset = self.get_queryset() + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + unread_count = queryset.exclude(id__in=dismissed_ids).count() + + return Response({ + 'unread_count': unread_count + }) + diff --git a/core/apps.py b/core/apps.py index 96ccfb3b..f2780bd1 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig from django.conf import settings -import os, logging +import logging # Define TRACE level (5 is below DEBUG which is 10) TRACE = 5 @@ -15,6 +15,7 @@ def trace(self, message, *args, **kwargs): # Add the trace method to the Logger class logging.Logger.trace = trace + class CoreConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core' @@ -22,3 +23,38 @@ class CoreConfig(AppConfig): def ready(self): # Import signals to ensure they get registered import core.signals + from dispatcharr.app_initialization import should_skip_initialization + + # Sync developer notifications and check for version updates on startup + # Only run in the main process (not in management commands, migrations, or workers) + if should_skip_initialization(): + return + + self._sync_developer_notifications() + self._check_version_update() + + def _sync_developer_notifications(self): + """Sync developer notifications from JSON file to database.""" + from django.db import connection + import logging + + logger = logging.getLogger(__name__) + + + try: + from core.developer_notifications import sync_developer_notifications + sync_developer_notifications() + except Exception as e: + logger.warning(f"Failed to sync developer notifications on startup: {e}") + + def _check_version_update(self): + """Check for version updates on startup.""" + import logging + + logger = logging.getLogger(__name__) + + try: + from core.tasks import check_for_version_update + check_for_version_update.delay() + except Exception as e: + logger.warning(f"Failed to check for version updates on startup: {e}") diff --git a/core/developer_notifications.py b/core/developer_notifications.py new file mode 100644 index 00000000..f4214b43 --- /dev/null +++ b/core/developer_notifications.py @@ -0,0 +1,412 @@ +""" +Developer Notification Sync Service + +Handles syncing developer-defined notifications from the JSON file to the database. +This ensures users receive important notifications from the development team +about recommended settings, security updates, and other announcements. + +JSON Schema (see fixtures/developer_notifications.json): + { + "id": str, # REQUIRED - Unique identifier (notification_key) + "title": str, # REQUIRED - Notification heading + "message": str, # REQUIRED - Notification body text + + "notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info') + "priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal') + "min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null) + "max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null) + "created_at": str, # OPTIONAL - ISO timestamp for tracking + "expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null) + "condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: []) + "user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all') + "action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access") + "action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set) + } + +Condition Checks: + Conditions are function names from CONDITION_CHECKS registry that evaluate + whether a notification should be shown. All conditions must pass (AND logic). + + Available conditions: + - 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere + + To add new conditions: + 1. Define a function: check_your_condition(user) -> bool + 2. Add to CONDITION_CHECKS registry + 3. Reference in JSON: "condition": ["your_condition"] + +Sync Behavior: + - Runs on startup (see apps.py) + - Runs when relevant settings change (see signals.py) + - Adds new notifications if in version range and not expired + - Updates existing notifications with latest data + - Removes notifications that are: + * No longer in JSON file + * Out of current version range + * Past expiration date + - Sends websocket event to refresh frontend + - Cache invalidated when triggering settings change +""" +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +from django.conf import settings +from django.db import models +from django.utils import timezone +from packaging import version + +from version import __version__ + +logger = logging.getLogger(__name__) + +# Path to developer notifications JSON file +NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json' + + +# ───────────────────────────── +# Condition Checks +# ───────────────────────────── +# Each condition function receives (user) and returns True if the notification should show + +def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool: + """ + Check if network access settings for a specific endpoint are insecure (allow all). + + Args: + user: The user object (unused but required for condition check signature) + endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API') + + Returns: + True if the notification should show (insecure settings detected) + """ + from core.models import CoreSettings, NETWORK_ACCESS_KEY + + try: + network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {}) + + # Empty settings are secure (defaults to local network only) + if not network_settings: + return False + + # Get the specific endpoint's allowed networks (stored as comma-separated string) + allowed_networks_str = network_settings.get(endpoint, '') + if not allowed_networks_str: + return False + + # Parse comma-separated network addresses + allowed_networks = [net.strip() for net in allowed_networks_str.split(',')] + + # Check if settings allow access from anywhere (insecure) + if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks: + return True + + return False + except Exception as e: + logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}") + return False + + +# Registry of all available condition checks +CONDITION_CHECKS: dict[str, Callable] = { + 'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'), + # Add more conditions here as needed + # 'transcode_not_configured': check_transcode_not_configured, + # 'no_backup_configured': check_no_backup_configured, +} + + +# ───────────────────────────── +# Version Utilities +# ───────────────────────────── + +def parse_version(version_str: str | None) -> version.Version | None: + """Parse a version string, returning None if invalid or empty.""" + if not version_str: + return None + try: + return version.parse(version_str) + except Exception: + return None + + +def is_version_in_range( + current_version: str, + min_version: str | None, + max_version: str | None +) -> bool: + """Check if current version is within the specified range.""" + current = parse_version(current_version) + if not current: + return True # If we can't parse version, show notification + + min_ver = parse_version(min_version) + max_ver = parse_version(max_version) + + if min_ver and current < min_ver: + return False + if max_ver and current > max_ver: + return False + + return True + + +# ───────────────────────────── +# Notification Evaluation +# ───────────────────────────── + +def evaluate_conditions(conditions: list[str] | str | None, user) -> bool: + """ + Evaluate notification conditions for a user. + All conditions must pass (AND logic). + """ + if not conditions: + return True + + # Normalize to list + if isinstance(conditions, str): + conditions = [conditions] + + for condition in conditions: + if condition not in CONDITION_CHECKS: + logger.warning(f"Unknown condition: {condition}") + continue + + try: + if not CONDITION_CHECKS[condition](user): + return False + except Exception as e: + logger.error(f"Error evaluating condition {condition}: {e}") + # On error, skip this condition (fail open) + continue + + return True + + +def should_show_notification(notification_data: dict, user) -> bool: + """ + Determine if a notification should be shown to a specific user. + Checks version range, user level, and conditions. + """ + # Check version range + if not is_version_in_range( + __version__, + notification_data.get('min_version'), + notification_data.get('max_version') + ): + return False + + # Check user level + user_level = notification_data.get('user_level', 'all') + if user_level == 'admin' and getattr(user, 'user_level', 0) < 10: + return False + + # Check conditions + conditions = notification_data.get('condition', []) + if not evaluate_conditions(conditions, user): + return False + + return True + + +# ───────────────────────────── +# Sync Service +# ───────────────────────────── + +def load_developer_notifications() -> list[dict]: + """Load notifications from the JSON file.""" + if not NOTIFICATIONS_FILE.exists(): + logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}") + return [] + + try: + with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get('notifications', []) + except json.JSONDecodeError as e: + logger.error(f"Error parsing developer notifications JSON: {e}") + return [] + except Exception as e: + logger.error(f"Error loading developer notifications: {e}") + return [] + + +def sync_developer_notifications() -> dict[str, int]: + """ + Sync developer notifications from JSON file to database. + + - Adds new notifications that don't exist in the DB + - Removes DB notifications that are no longer in the JSON file + - Updates existing notifications if they've changed + + Returns a dict with counts of added, updated, and removed notifications. + """ + from core.models import SystemNotification + + results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0} + + notifications = load_developer_notifications() + json_notification_keys = set() + notifications_to_remove = set() # Track notifications to remove (out of range or expired) + + for notif_data in notifications: + notification_id = notif_data.get('id') + if not notification_id: + logger.warning("Notification missing 'id' field, skipping") + results['skipped'] += 1 + continue + + json_notification_keys.add(notification_id) + + # Check version constraints (only add if current version is in range) + if not is_version_in_range( + __version__, + notif_data.get('min_version'), + notif_data.get('max_version') + ): + logger.debug(f"Notification {notification_id} not in version range, marking for removal") + results['skipped'] += 1 + notifications_to_remove.add(notification_id) + continue + + # Parse expires_at if provided + expires_at = None + if notif_data.get('expires_at'): + try: + expires_at = datetime.fromisoformat( + notif_data['expires_at'].replace('Z', '+00:00') + ) + # Skip if already expired and mark for removal + if expires_at < timezone.now(): + logger.debug(f"Notification {notification_id} has expired, marking for removal") + results['skipped'] += 1 + notifications_to_remove.add(notification_id) + continue + except (ValueError, TypeError) as e: + logger.warning(f"Invalid expires_at for {notification_id}: {e}") + + # Map notification_type from JSON to model choices + type_mapping = { + 'version_update': SystemNotification.NotificationType.VERSION_UPDATE, + 'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION, + 'announcement': SystemNotification.NotificationType.ANNOUNCEMENT, + 'warning': SystemNotification.NotificationType.WARNING, + 'info': SystemNotification.NotificationType.INFO, + } + notification_type = type_mapping.get( + notif_data.get('notification_type', 'info'), + SystemNotification.NotificationType.INFO + ) + + # Map priority + priority_mapping = { + 'low': SystemNotification.Priority.LOW, + 'normal': SystemNotification.Priority.NORMAL, + 'high': SystemNotification.Priority.HIGH, + 'critical': SystemNotification.Priority.CRITICAL, + } + priority = priority_mapping.get( + notif_data.get('priority', 'normal'), + SystemNotification.Priority.NORMAL + ) + + # Prepare action_data + action_data = { + 'action_url': notif_data.get('action_url'), + 'action_text': notif_data.get('action_text'), + 'condition': notif_data.get('condition', []), + 'min_version': notif_data.get('min_version'), + 'max_version': notif_data.get('max_version'), + 'user_level': notif_data.get('user_level', 'all'), + } + + # Determine if admin-only based on user_level + admin_only = notif_data.get('user_level', 'all') == 'admin' + + # Create or update the notification + notification, created = SystemNotification.objects.update_or_create( + notification_key=notification_id, + defaults={ + 'notification_type': notification_type, + 'priority': priority, + 'source': SystemNotification.Source.DEVELOPER, + 'title': notif_data.get('title', 'Notification'), + 'message': notif_data.get('message', ''), + 'action_data': action_data, + 'is_active': True, + 'admin_only': admin_only, + 'expires_at': expires_at, + } + ) + + if created: + logger.info(f"Added developer notification: {notification_id}") + results['added'] += 1 + else: + logger.debug(f"Updated developer notification: {notification_id}") + results['updated'] += 1 + + # Remove developer notifications that are: + # - No longer in the JSON file, OR + # - Out of version range for the current version, OR + # - Expired + removed_count, _ = SystemNotification.objects.filter( + source=SystemNotification.Source.DEVELOPER + ).filter( + models.Q(notification_key__in=notifications_to_remove) | + ~models.Q(notification_key__in=json_notification_keys) + ).delete() + + if removed_count: + logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)") + results['removed'] = removed_count + + logger.info( + f"Developer notification sync complete: " + f"{results['added']} added, {results['updated']} updated, " + f"{results['removed']} removed, {results['skipped']} skipped" + ) + + # Send websocket notification to frontend to refresh notifications + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + 'type': 'notifications_cleared', + }) + logger.debug("Sent websocket notification for notifications refresh") + except Exception as e: + logger.warning(f"Failed to send websocket update: {e}") + + return results + + +def get_user_developer_notifications(user) -> list: + """ + Get all developer notifications that should be shown to a specific user. + Evaluates conditions and user_level for each notification. + """ + from core.models import SystemNotification + + # Get all active developer notifications + notifications = SystemNotification.objects.filter( + source=SystemNotification.Source.DEVELOPER, + is_active=True + ) + + # Filter by admin_only based on user + if getattr(user, 'user_level', 0) < 10: + notifications = notifications.filter(admin_only=False) + + # Filter by conditions + result = [] + for notification in notifications: + action_data = notification.action_data or {} + + # Evaluate conditions + conditions = action_data.get('condition', []) + if evaluate_conditions(conditions, user): + result.append(notification) + + return result diff --git a/core/fixtures/developer_notifications.json b/core/fixtures/developer_notifications.json new file mode 100644 index 00000000..0040682e --- /dev/null +++ b/core/fixtures/developer_notifications.json @@ -0,0 +1,43 @@ +{ + "_schema_documentation": { + "description": "Developer notification definitions. Each notification is evaluated at sync time.", + "fields": { + "id": "[REQUIRED] Unique identifier (notification_key in database).", + "notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'", + "priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'", + "title": "[REQUIRED] Notification title/heading.", + "message": "[REQUIRED] Detailed notification message body.", + "min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'", + "max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'", + "created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.", + "expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'", + "condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']", + "user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'", + "action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'", + "action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'" + }, + "notes": [ + "Notifications are synced from this file to database on startup and when relevant settings change", + "Out-of-range versions are automatically removed from database", + "Expired notifications are automatically removed from database", + "Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)" + ] + }, + "notifications": [ + { + "id": "network_security_m3u_epg", + "notification_type": "warning", + "priority": "high", + "title": "Network Access Security Warning", + "message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.", + "min_version": null, + "max_version": null, + "created_at": "2026-02-02T00:00:00Z", + "expires_at": null, + "condition": ["m3u_epg_network_insecure"], + "user_level": "admin", + "action_url": "/settings#network-access", + "action_text": "Review Settings" + } + ] +} \ No newline at end of file diff --git a/core/fixtures/initial_data.json b/core/fixtures/initial_data.json index c037fa78..889f0d24 100644 --- a/core/fixtures/initial_data.json +++ b/core/fixtures/initial_data.json @@ -23,7 +23,7 @@ "model": "core.streamprofile", "pk": 1, "fields": { - "name": "ffmpeg", + "name": "FFmpeg", "command": "ffmpeg", "parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", "is_active": true, @@ -34,11 +34,22 @@ "model": "core.streamprofile", "pk": 2, "fields": { - "name": "streamlink", + "name": "Streamlink", "command": "streamlink", "parameters": "{streamUrl} best --stdout", "is_active": true, "user_agent": "1" } + }, + { + "model": "core.streamprofile", + "pk": 3, + "fields": { + "name": "VLC", + "command": "cvlc", + "parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", + "is_active": true, + "user_agent": "1" + } } ] diff --git a/core/management/commands/reset_network_access.py b/core/management/commands/reset_network_access.py index 3b0e5a55..a31d247c 100644 --- a/core/management/commands/reset_network_access.py +++ b/core/management/commands/reset_network_access.py @@ -1,13 +1,13 @@ # your_app/management/commands/update_column.py from django.core.management.base import BaseCommand -from core.models import CoreSettings, NETWORK_ACCESS +from core.models import CoreSettings, NETWORK_ACCESS_KEY class Command(BaseCommand): help = "Reset network access settings" def handle(self, *args, **options): - setting = CoreSettings.objects.get(key=NETWORK_ACCESS) - setting.value = "{}" + setting = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY) + setting.value = {} setting.save() diff --git a/core/migrations/0019_add_vlc_stream_profile.py b/core/migrations/0019_add_vlc_stream_profile.py new file mode 100644 index 00000000..c3f72592 --- /dev/null +++ b/core/migrations/0019_add_vlc_stream_profile.py @@ -0,0 +1,42 @@ +# Generated migration to add VLC stream profile + +from django.db import migrations + +def add_vlc_profile(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + UserAgent = apps.get_model("core", "UserAgent") + + # Check if VLC profile already exists + if not StreamProfile.objects.filter(name="VLC").exists(): + # Get the TiviMate user agent (should be pk=1) + try: + tivimate_ua = UserAgent.objects.get(pk=1) + except UserAgent.DoesNotExist: + # Fallback: get first available user agent + tivimate_ua = UserAgent.objects.first() + if not tivimate_ua: + # No user agents exist, skip creating profile + return + + StreamProfile.objects.create( + name="VLC", + command="cvlc", + parameters="-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", + is_active=True, + user_agent=tivimate_ua, + locked=True, # Make it read-only like ffmpeg/streamlink + ) + +def remove_vlc_profile(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + StreamProfile.objects.filter(name="VLC").delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_alter_systemevent_event_type'), + ] + + operations = [ + migrations.RunPython(add_vlc_profile, remove_vlc_profile), + ] diff --git a/core/migrations/0020_change_coresettings_value_to_jsonfield.py b/core/migrations/0020_change_coresettings_value_to_jsonfield.py new file mode 100644 index 00000000..ac6ad089 --- /dev/null +++ b/core/migrations/0020_change_coresettings_value_to_jsonfield.py @@ -0,0 +1,267 @@ +# Generated migration to change CoreSettings value field to JSONField and consolidate settings + +import json +from django.db import migrations, models + + +def convert_string_to_json(apps, schema_editor): + """Convert existing string values to appropriate JSON types before changing column type""" + CoreSettings = apps.get_model("core", "CoreSettings") + + for setting in CoreSettings.objects.all(): + value = setting.value + + if not value: + # Empty strings become empty string in JSON + setting.value = json.dumps("") + setting.save(update_fields=['value']) + continue + + # Try to parse as JSON if it looks like JSON (objects/arrays) + if value.startswith('{') or value.startswith('['): + try: + parsed = json.loads(value) + # Store as JSON string temporarily (column is still CharField) + setting.value = json.dumps(parsed) + setting.save(update_fields=['value']) + continue + except (json.JSONDecodeError, ValueError): + pass + + # Try to parse as number + try: + # Check if it's an integer + if '.' not in value and value.lstrip('-').isdigit(): + setting.value = json.dumps(int(value)) + setting.save(update_fields=['value']) + continue + # Check if it's a float + float_val = float(value) + setting.value = json.dumps(float_val) + setting.save(update_fields=['value']) + continue + except (ValueError, AttributeError): + pass + + # Check for booleans + if value.lower() in ('true', 'false', '1', '0', 'yes', 'no', 'on', 'off'): + bool_val = value.lower() in ('true', '1', 'yes', 'on') + setting.value = json.dumps(bool_val) + setting.save(update_fields=['value']) + continue + + # Default: store as JSON string + setting.value = json.dumps(value) + setting.save(update_fields=['value']) + + +def consolidate_settings(apps, schema_editor): + """Consolidate individual setting rows into grouped JSON objects.""" + CoreSettings = apps.get_model("core", "CoreSettings") + + # Helper to get setting value + def get_value(key, default=None): + try: + obj = CoreSettings.objects.get(key=key) + return obj.value if obj.value is not None else default + except CoreSettings.DoesNotExist: + return default + + # STREAM SETTINGS + stream_settings = { + "default_user_agent": get_value("default-user-agent"), + "default_stream_profile": get_value("default-stream-profile"), + "m3u_hash_key": get_value("m3u-hash-key", ""), + "preferred_region": get_value("preferred-region"), + "auto_import_mapped_files": get_value("auto-import-mapped-files"), + } + CoreSettings.objects.update_or_create( + key="stream_settings", + defaults={"name": "Stream Settings", "value": stream_settings} + ) + + # DVR SETTINGS + dvr_settings = { + "tv_template": get_value("dvr-tv-template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"), + "movie_template": get_value("dvr-movie-template", "Movies/{title} ({year}).mkv"), + "tv_fallback_dir": get_value("dvr-tv-fallback-dir", "TV_Shows"), + "tv_fallback_template": get_value("dvr-tv-fallback-template", "TV_Shows/{show}/{start}.mkv"), + "movie_fallback_template": get_value("dvr-movie-fallback-template", "Movies/{start}.mkv"), + "comskip_enabled": bool(get_value("dvr-comskip-enabled", False)), + "comskip_custom_path": get_value("dvr-comskip-custom-path", ""), + "pre_offset_minutes": int(get_value("dvr-pre-offset-minutes", 0) or 0), + "post_offset_minutes": int(get_value("dvr-post-offset-minutes", 0) or 0), + "series_rules": get_value("dvr-series-rules", []), + } + CoreSettings.objects.update_or_create( + key="dvr_settings", + defaults={"name": "DVR Settings", "value": dvr_settings} + ) + + # BACKUP SETTINGS - using underscore keys (not dashes) + backup_settings = { + "schedule_enabled": get_value("backup_schedule_enabled") if get_value("backup_schedule_enabled") is not None else True, + "schedule_frequency": get_value("backup_schedule_frequency") or "daily", + "schedule_time": get_value("backup_schedule_time") or "03:00", + "schedule_day_of_week": get_value("backup_schedule_day_of_week") if get_value("backup_schedule_day_of_week") is not None else 0, + "retention_count": get_value("backup_retention_count") if get_value("backup_retention_count") is not None else 3, + "schedule_cron_expression": get_value("backup_schedule_cron_expression") or "", + } + CoreSettings.objects.update_or_create( + key="backup_settings", + defaults={"name": "Backup Settings", "value": backup_settings} + ) + + # SYSTEM SETTINGS + system_settings = { + "time_zone": get_value("system-time-zone", "UTC"), + "max_system_events": int(get_value("max-system-events", 100) or 100), + } + CoreSettings.objects.update_or_create( + key="system_settings", + defaults={"name": "System Settings", "value": system_settings} + ) + + # Rename proxy-settings to proxy_settings (if it exists with old name) + try: + old_proxy = CoreSettings.objects.get(key="proxy-settings") + old_proxy.key = "proxy_settings" + old_proxy.save() + except CoreSettings.DoesNotExist: + pass + + # Ensure proxy_settings exists with defaults if not present + proxy_obj, proxy_created = CoreSettings.objects.get_or_create( + key="proxy_settings", + defaults={ + "name": "Proxy Settings", + "value": { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + } + } + ) + + # Rename network-access to network_access (if it exists with old name) + try: + old_network = CoreSettings.objects.get(key="network-access") + old_network.key = "network_access" + old_network.save() + except CoreSettings.DoesNotExist: + pass + + # Ensure network_access exists with defaults if not present + network_obj, network_created = CoreSettings.objects.get_or_create( + key="network_access", + defaults={ + "name": "Network Access", + "value": {} + } + ) + # Delete old individual setting rows (keep only the new grouped settings) + grouped_keys = ["stream_settings", "dvr_settings", "backup_settings", "system_settings", "proxy_settings", "network_access"] + CoreSettings.objects.exclude(key__in=grouped_keys).delete() + + +def reverse_migration(apps, schema_editor): + """Reverse migration: split grouped settings and convert JSON back to strings""" + CoreSettings = apps.get_model("core", "CoreSettings") + + # Helper to create individual setting + def create_setting(key, name, value): + # Convert value back to string representation for CharField + if isinstance(value, str): + str_value = value + elif isinstance(value, bool): + str_value = "true" if value else "false" + elif isinstance(value, (int, float)): + str_value = str(value) + elif isinstance(value, (dict, list)): + str_value = json.dumps(value) + elif value is None: + str_value = "" + else: + str_value = str(value) + + CoreSettings.objects.update_or_create( + key=key, + defaults={"name": name, "value": str_value} + ) + + # Split stream_settings + try: + stream = CoreSettings.objects.get(key="stream_settings") + if isinstance(stream.value, dict): + create_setting("default_user_agent", "Default User Agent", stream.value.get("default_user_agent")) + create_setting("default_stream_profile", "Default Stream Profile", stream.value.get("default_stream_profile")) + create_setting("stream_hash_key", "Stream Hash Key", stream.value.get("m3u_hash_key", "")) + create_setting("preferred_region", "Preferred Region", stream.value.get("preferred_region")) + create_setting("auto_import_mapped_files", "Auto Import Mapped Files", stream.value.get("auto_import_mapped_files")) + stream.delete() + except CoreSettings.DoesNotExist: + pass + + # Split dvr_settings + try: + dvr = CoreSettings.objects.get(key="dvr_settings") + if isinstance(dvr.value, dict): + create_setting("dvr_tv_template", "DVR TV Template", dvr.value.get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv")) + create_setting("dvr_movie_template", "DVR Movie Template", dvr.value.get("movie_template", "Movies/{title} ({year}).mkv")) + create_setting("dvr_tv_fallback_dir", "DVR TV Fallback Dir", dvr.value.get("tv_fallback_dir", "TV_Shows")) + create_setting("dvr_tv_fallback_template", "DVR TV Fallback Template", dvr.value.get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv")) + create_setting("dvr_movie_fallback_template", "DVR Movie Fallback Template", dvr.value.get("movie_fallback_template", "Movies/{start}.mkv")) + create_setting("dvr_comskip_enabled", "DVR Comskip Enabled", dvr.value.get("comskip_enabled", False)) + create_setting("dvr_comskip_custom_path", "DVR Comskip Custom Path", dvr.value.get("comskip_custom_path", "")) + create_setting("dvr_pre_offset_minutes", "DVR Pre Offset Minutes", dvr.value.get("pre_offset_minutes", 0)) + create_setting("dvr_post_offset_minutes", "DVR Post Offset Minutes", dvr.value.get("post_offset_minutes", 0)) + create_setting("dvr_series_rules", "DVR Series Rules", dvr.value.get("series_rules", [])) + dvr.delete() + except CoreSettings.DoesNotExist: + pass + + # Split backup_settings + try: + backup = CoreSettings.objects.get(key="backup_settings") + if isinstance(backup.value, dict): + create_setting("backup_schedule_enabled", "Backup Schedule Enabled", backup.value.get("schedule_enabled", False)) + create_setting("backup_schedule_frequency", "Backup Schedule Frequency", backup.value.get("schedule_frequency", "weekly")) + create_setting("backup_schedule_time", "Backup Schedule Time", backup.value.get("schedule_time", "02:00")) + create_setting("backup_schedule_day_of_week", "Backup Schedule Day of Week", backup.value.get("schedule_day_of_week", 0)) + create_setting("backup_retention_count", "Backup Retention Count", backup.value.get("retention_count", 7)) + create_setting("backup_schedule_cron_expression", "Backup Schedule Cron Expression", backup.value.get("schedule_cron_expression", "")) + backup.delete() + except CoreSettings.DoesNotExist: + pass + + # Split system_settings + try: + system = CoreSettings.objects.get(key="system_settings") + if isinstance(system.value, dict): + create_setting("system_time_zone", "System Time Zone", system.value.get("time_zone", "UTC")) + create_setting("max_system_events", "Max System Events", system.value.get("max_system_events", 100)) + system.delete() + except CoreSettings.DoesNotExist: + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_add_vlc_stream_profile'), + ] + + operations = [ + # First, convert all data to valid JSON strings while column is still CharField + migrations.RunPython(convert_string_to_json, migrations.RunPython.noop), + # Then change the field type to JSONField + migrations.AlterField( + model_name='coresettings', + name='value', + field=models.JSONField(blank=True, default=dict), + ), + # Finally, consolidate individual settings into grouped JSON objects + migrations.RunPython(consolidate_settings, reverse_migration), + ] diff --git a/core/migrations/0021_systemnotification_notificationdismissal.py b/core/migrations/0021_systemnotification_notificationdismissal.py new file mode 100644 index 00000000..66eb9b18 --- /dev/null +++ b/core/migrations/0021_systemnotification_notificationdismissal.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2026-02-02 20:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_change_coresettings_value_to_jsonfield'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SystemNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_key', models.CharField(db_index=True, max_length=255, unique=True)), + ('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)), + ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)), + ('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)), + ('title', models.CharField(max_length=255)), + ('message', models.TextField()), + ('action_data', models.JSONField(blank=True, default=dict)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('admin_only', models.BooleanField(default=False)), + ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-priority', '-created_at'], + 'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')], + }, + ), + migrations.CreateModel( + name='NotificationDismissal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dismissed_at', models.DateTimeField(auto_now_add=True)), + ('action_taken', models.CharField(blank=True, max_length=50, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)), + ('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')), + ], + options={ + 'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')], + 'unique_together': {('user', 'notification')}, + }, + ), + ] diff --git a/core/models.py b/core/models.py index b9166f66..10f9073f 100644 --- a/core/models.py +++ b/core/models.py @@ -1,4 +1,7 @@ # core/models.py + +from shlex import split as shlex_split + from django.conf import settings from django.db import models from django.utils.text import slugify @@ -133,7 +136,7 @@ class StreamProfile(models.Model): # Split the command and iterate through each part to apply replacements cmd = [self.command] + [ self._replace_in_part(part, replacements) - for part in self.parameters.split() + for part in shlex_split(self.parameters) # use shlex to handle quoted strings ] return cmd @@ -145,24 +148,14 @@ class StreamProfile(models.Model): return part -DEFAULT_USER_AGENT_KEY = slugify("Default User-Agent") -DEFAULT_STREAM_PROFILE_KEY = slugify("Default Stream Profile") -STREAM_HASH_KEY = slugify("M3U Hash Key") -PREFERRED_REGION_KEY = slugify("Preferred Region") -AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files") -NETWORK_ACCESS = slugify("Network Access") -PROXY_SETTINGS_KEY = slugify("Proxy Settings") -DVR_TV_TEMPLATE_KEY = slugify("DVR TV Template") -DVR_MOVIE_TEMPLATE_KEY = slugify("DVR Movie Template") -DVR_SERIES_RULES_KEY = slugify("DVR Series Rules") -DVR_TV_FALLBACK_DIR_KEY = slugify("DVR TV Fallback Dir") -DVR_TV_FALLBACK_TEMPLATE_KEY = slugify("DVR TV Fallback Template") -DVR_MOVIE_FALLBACK_TEMPLATE_KEY = slugify("DVR Movie Fallback Template") -DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled") -DVR_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path") -DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes") -DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes") -SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone") +# Setting group keys +STREAM_SETTINGS_KEY = "stream_settings" +DVR_SETTINGS_KEY = "dvr_settings" +BACKUP_SETTINGS_KEY = "backup_settings" +PROXY_SETTINGS_KEY = "proxy_settings" +NETWORK_ACCESS_KEY = "network_access" +SYSTEM_SETTINGS_KEY = "system_settings" +EPG_SETTINGS_KEY = "epg_settings" class CoreSettings(models.Model): @@ -173,208 +166,189 @@ class CoreSettings(models.Model): name = models.CharField( max_length=255, ) - value = models.CharField( - max_length=255, + value = models.JSONField( + default=dict, + blank=True, ) def __str__(self): return "Core Settings" + # Helper methods to get/set grouped settings + @classmethod + def _get_group(cls, key, defaults=None): + """Get a settings group, returning defaults if not found.""" + try: + return cls.objects.get(key=key).value or (defaults or {}) + except cls.DoesNotExist: + return defaults or {} + + @classmethod + def _update_group(cls, key, name, updates): + """Update specific fields in a settings group.""" + obj, created = cls.objects.get_or_create( + key=key, + defaults={"name": name, "value": {}} + ) + current = obj.value if isinstance(obj.value, dict) else {} + current.update(updates) + obj.value = current + obj.save() + return current + + # Stream Settings + @classmethod + def get_stream_settings(cls): + """Get all stream-related settings.""" + return cls._get_group(STREAM_SETTINGS_KEY, { + "default_user_agent": None, + "default_stream_profile": None, + "m3u_hash_key": "", + "preferred_region": None, + "auto_import_mapped_files": None, + }) + @classmethod def get_default_user_agent_id(cls): - """Retrieve a system profile by name (or return None if not found).""" - return cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value + return cls.get_stream_settings().get("default_user_agent") @classmethod def get_default_stream_profile_id(cls): - return cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value + return cls.get_stream_settings().get("default_stream_profile") @classmethod def get_m3u_hash_key(cls): - return cls.objects.get(key=STREAM_HASH_KEY).value + return cls.get_stream_settings().get("m3u_hash_key", "") @classmethod def get_preferred_region(cls): - """Retrieve the preferred region setting (or return None if not found).""" - try: - return cls.objects.get(key=PREFERRED_REGION_KEY).value - except cls.DoesNotExist: - return None + return cls.get_stream_settings().get("preferred_region") @classmethod def get_auto_import_mapped_files(cls): - """Retrieve the preferred region setting (or return None if not found).""" - try: - return cls.objects.get(key=AUTO_IMPORT_MAPPED_FILES).value - except cls.DoesNotExist: - return None + return cls.get_stream_settings().get("auto_import_mapped_files") + + # EPG Settings + @classmethod + def get_epg_settings(cls): + """Get all EPG-related settings.""" + return cls._get_group(EPG_SETTINGS_KEY, { + "epg_match_mode": "default", + "epg_match_ignore_prefixes": [], + "epg_match_ignore_suffixes": [], + "epg_match_ignore_custom": [], + }) @classmethod - def get_proxy_settings(cls): - """Retrieve proxy settings as dict (or return defaults if not found).""" - try: - import json - settings_json = cls.objects.get(key=PROXY_SETTINGS_KEY).value - return json.loads(settings_json) - except (cls.DoesNotExist, json.JSONDecodeError): - # Return defaults if not found or invalid JSON - return { - "buffering_timeout": 15, - "buffering_speed": 1.0, - "redis_chunk_ttl": 60, - "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, - } + def get_epg_match_ignore_prefixes(cls): + return cls.get_epg_settings().get("epg_match_ignore_prefixes", []) + + @classmethod + def get_epg_match_ignore_suffixes(cls): + return cls.get_epg_settings().get("epg_match_ignore_suffixes", []) + + @classmethod + def get_epg_match_ignore_custom(cls): + return cls.get_epg_settings().get("epg_match_ignore_custom", []) + + # DVR Settings + @classmethod + def get_dvr_settings(cls): + """Get all DVR-related settings.""" + return cls._get_group(DVR_SETTINGS_KEY, { + "tv_template": "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv", + "movie_template": "Movies/{title} ({year}).mkv", + "tv_fallback_dir": "TV_Shows", + "tv_fallback_template": "TV_Shows/{show}/{start}.mkv", + "movie_fallback_template": "Movies/{start}.mkv", + "comskip_enabled": False, + "comskip_custom_path": "", + "pre_offset_minutes": 0, + "post_offset_minutes": 0, + "series_rules": [], + }) @classmethod def get_dvr_tv_template(cls): - try: - return cls.objects.get(key=DVR_TV_TEMPLATE_KEY).value - except cls.DoesNotExist: - # Default: relative to recordings root (/data/recordings) - return "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv" + return cls.get_dvr_settings().get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv") @classmethod def get_dvr_movie_template(cls): - try: - return cls.objects.get(key=DVR_MOVIE_TEMPLATE_KEY).value - except cls.DoesNotExist: - return "Movies/{title} ({year}).mkv" + return cls.get_dvr_settings().get("movie_template", "Movies/{title} ({year}).mkv") @classmethod def get_dvr_tv_fallback_dir(cls): - """Folder name to use when a TV episode has no season/episode information. - Defaults to 'TV_Show' to match existing behavior but can be overridden in settings. - """ - try: - return cls.objects.get(key=DVR_TV_FALLBACK_DIR_KEY).value or "TV_Shows" - except cls.DoesNotExist: - return "TV_Shows" + return cls.get_dvr_settings().get("tv_fallback_dir", "TV_Shows") @classmethod def get_dvr_tv_fallback_template(cls): - """Full path template used when season/episode are missing for a TV airing.""" - try: - return cls.objects.get(key=DVR_TV_FALLBACK_TEMPLATE_KEY).value - except cls.DoesNotExist: - # default requested by user - return "TV_Shows/{show}/{start}.mkv" + return cls.get_dvr_settings().get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv") @classmethod def get_dvr_movie_fallback_template(cls): - """Full path template used when movie metadata is incomplete.""" - try: - return cls.objects.get(key=DVR_MOVIE_FALLBACK_TEMPLATE_KEY).value - except cls.DoesNotExist: - return "Movies/{start}.mkv" + return cls.get_dvr_settings().get("movie_fallback_template", "Movies/{start}.mkv") @classmethod def get_dvr_comskip_enabled(cls): - """Return boolean-like string value ('true'/'false') for comskip enablement.""" - try: - val = cls.objects.get(key=DVR_COMSKIP_ENABLED_KEY).value - return str(val).lower() in ("1", "true", "yes", "on") - except cls.DoesNotExist: - return False + return bool(cls.get_dvr_settings().get("comskip_enabled", False)) @classmethod def get_dvr_comskip_custom_path(cls): - """Return configured comskip.ini path or empty string if unset.""" - try: - return cls.objects.get(key=DVR_COMSKIP_CUSTOM_PATH_KEY).value - except cls.DoesNotExist: - return "" + return cls.get_dvr_settings().get("comskip_custom_path", "") @classmethod def set_dvr_comskip_custom_path(cls, path: str | None): - """Persist the comskip.ini path setting, normalizing nulls to empty string.""" value = (path or "").strip() - obj, _ = cls.objects.get_or_create( - key=DVR_COMSKIP_CUSTOM_PATH_KEY, - defaults={"name": "DVR Comskip Custom Path", "value": value}, - ) - if obj.value != value: - obj.value = value - obj.save(update_fields=["value"]) + cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"comskip_custom_path": value}) return value @classmethod def get_dvr_pre_offset_minutes(cls): - """Minutes to start recording before scheduled start (default 0).""" - try: - val = cls.objects.get(key=DVR_PRE_OFFSET_MINUTES_KEY).value - return int(val) - except cls.DoesNotExist: - return 0 - except Exception: - try: - return int(float(val)) - except Exception: - return 0 + return int(cls.get_dvr_settings().get("pre_offset_minutes", 0) or 0) @classmethod def get_dvr_post_offset_minutes(cls): - """Minutes to stop recording after scheduled end (default 0).""" - try: - val = cls.objects.get(key=DVR_POST_OFFSET_MINUTES_KEY).value - return int(val) - except cls.DoesNotExist: - return 0 - except Exception: - try: - return int(float(val)) - except Exception: - return 0 - - @classmethod - def get_system_time_zone(cls): - """Return configured system time zone or fall back to Django settings.""" - try: - value = cls.objects.get(key=SYSTEM_TIME_ZONE_KEY).value - if value: - return value - except cls.DoesNotExist: - pass - return getattr(settings, "TIME_ZONE", "UTC") or "UTC" - - @classmethod - def set_system_time_zone(cls, tz_name: str | None): - """Persist the desired system time zone identifier.""" - value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC" - obj, _ = cls.objects.get_or_create( - key=SYSTEM_TIME_ZONE_KEY, - defaults={"name": "System Time Zone", "value": value}, - ) - if obj.value != value: - obj.value = value - obj.save(update_fields=["value"]) - return value + return int(cls.get_dvr_settings().get("post_offset_minutes", 0) or 0) @classmethod def get_dvr_series_rules(cls): - """Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}""" - import json - try: - raw = cls.objects.get(key=DVR_SERIES_RULES_KEY).value - rules = json.loads(raw) if raw else [] - if isinstance(rules, list): - return rules - return [] - except cls.DoesNotExist: - # Initialize empty if missing - cls.objects.create(key=DVR_SERIES_RULES_KEY, name="DVR Series Rules", value="[]") - return [] + return cls.get_dvr_settings().get("series_rules", []) @classmethod def set_dvr_series_rules(cls, rules): - import json - try: - obj, _ = cls.objects.get_or_create(key=DVR_SERIES_RULES_KEY, defaults={"name": "DVR Series Rules", "value": "[]"}) - obj.value = json.dumps(rules) - obj.save(update_fields=["value"]) - return rules - except Exception: - return rules + cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"series_rules": rules}) + return rules + + # Proxy Settings + @classmethod + def get_proxy_settings(cls): + """Get proxy settings.""" + return cls._get_group(PROXY_SETTINGS_KEY, { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + }) + + # System Settings + @classmethod + def get_system_settings(cls): + """Get all system-related settings.""" + return cls._get_group(SYSTEM_SETTINGS_KEY, { + "time_zone": getattr(settings, "TIME_ZONE", "UTC") or "UTC", + "max_system_events": 100, + }) + + @classmethod + def get_system_time_zone(cls): + return cls.get_system_settings().get("time_zone") or getattr(settings, "TIME_ZONE", "UTC") or "UTC" + + @classmethod + def set_system_time_zone(cls, tz_name: str | None): + value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC" + cls._update_group(SYSTEM_SETTINGS_KEY, "System Settings", {"time_zone": value}) + return value class SystemEvent(models.Model): @@ -420,3 +394,153 @@ class SystemEvent(models.Model): def __str__(self): return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}" + + +class SystemNotification(models.Model): + """ + Stores system notifications that users can view and dismiss. + Used for version updates, recommended settings, announcements, etc. + """ + class NotificationType(models.TextChoices): + VERSION_UPDATE = 'version_update', 'Version Update Available' + SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change' + ANNOUNCEMENT = 'announcement', 'System Announcement' + WARNING = 'warning', 'Warning' + INFO = 'info', 'Information' + + class Priority(models.TextChoices): + LOW = 'low', 'Low' + NORMAL = 'normal', 'Normal' + HIGH = 'high', 'High' + CRITICAL = 'critical', 'Critical' + + class Source(models.TextChoices): + SYSTEM = 'system', 'System Generated' + DEVELOPER = 'developer', 'Developer Notification' + + # Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer') + # This allows deduplication and targeted dismissals + notification_key = models.CharField(max_length=255, unique=True, db_index=True) + + notification_type = models.CharField( + max_length=50, + choices=NotificationType.choices, + default=NotificationType.INFO, + db_index=True + ) + priority = models.CharField( + max_length=20, + choices=Priority.choices, + default=Priority.NORMAL + ) + + # Source of the notification (system-generated vs developer-defined) + source = models.CharField( + max_length=20, + choices=Source.choices, + default=Source.SYSTEM, + db_index=True + ) + + title = models.CharField(max_length=255) + message = models.TextField() + + # Optional action data (e.g., setting key/value for recommendations, release URL for versions) + action_data = models.JSONField(default=dict, blank=True) + + # Whether this notification is currently active + is_active = models.BooleanField(default=True, db_index=True) + + # Admin-only notifications require admin privileges to view + admin_only = models.BooleanField(default=False) + + # Auto-expire after this date (null = never expires) + expires_at = models.DateTimeField(null=True, blank=True, db_index=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-priority', '-created_at'] + indexes = [ + models.Index(fields=['is_active', '-created_at']), + models.Index(fields=['notification_type', 'is_active']), + models.Index(fields=['source', 'is_active']), + ] + + def __str__(self): + return f"[{self.notification_type}] {self.title}" + + @classmethod + def create_version_notification(cls, version, release_url=None, release_notes=None): + """Create or update a version update notification. Returns (notification, created) tuple.""" + key = f"version-{version}" + notification, created = cls.objects.update_or_create( + notification_key=key, + defaults={ + 'notification_type': cls.NotificationType.VERSION_UPDATE, + 'priority': cls.Priority.HIGH, + 'title': f'Version {version} Available', + 'message': f'A new version of Dispatcharr ({version}) is available.', + 'action_data': { + 'version': version, + 'release_url': release_url, + 'release_notes': release_notes, + }, + 'is_active': True, + 'admin_only': True, + } + ) + return notification, created + + @classmethod + def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None): + """Create a setting recommendation notification. Returns (notification, created) tuple.""" + key = f"setting-{setting_key}" + notification, created = cls.objects.update_or_create( + notification_key=key, + defaults={ + 'notification_type': cls.NotificationType.SETTING_RECOMMENDATION, + 'priority': cls.Priority.NORMAL, + 'title': f'Recommended Setting: {setting_key}', + 'message': reason, + 'action_data': { + 'setting_key': setting_key, + 'recommended_value': recommended_value, + 'current_value': current_value, + }, + 'is_active': True, + 'admin_only': True, + } + ) + return notification, created + + +class NotificationDismissal(models.Model): + """ + Tracks which users have dismissed which notifications. + Allows users to dismiss notifications once without seeing them again. + """ + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='dismissed_notifications' + ) + notification = models.ForeignKey( + SystemNotification, + on_delete=models.CASCADE, + related_name='dismissals' + ) + dismissed_at = models.DateTimeField(auto_now_add=True) + + # Optional: track if user accepted/applied the recommendation + action_taken = models.CharField(max_length=50, blank=True, null=True) + + class Meta: + unique_together = ['user', 'notification'] + indexes = [ + models.Index(fields=['user', 'notification']), + ] + + def __str__(self): + return f"{self.user.username} dismissed {self.notification.notification_key}" diff --git a/core/scheduling.py b/core/scheduling.py new file mode 100644 index 00000000..0b4b78c6 --- /dev/null +++ b/core/scheduling.py @@ -0,0 +1,203 @@ +""" +Reusable scheduling utilities for creating/updating/deleting +Celery Beat periodic tasks with interval or cron-based schedules. +""" + +import json +import logging + +from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask + +from core.models import CoreSettings + +logger = logging.getLogger(__name__) + + +def parse_cron_expression(cron_expression): + """ + Parse a 5-part cron expression into its components. + + Args: + cron_expression: A string like "0 3 * * *" + + Returns: + dict with keys: minute, hour, day_of_month, month_of_year, day_of_week + + Raises: + ValueError: If the expression is not valid 5-part cron. + """ + parts = cron_expression.strip().split() + if len(parts) != 5: + raise ValueError( + "Cron expression must have 5 parts: minute hour day month weekday" + ) + return { + "minute": parts[0], + "hour": parts[1], + "day_of_month": parts[2], + "month_of_year": parts[3], + "day_of_week": parts[4], + } + + +def create_or_update_periodic_task( + task_name, + celery_task_path, + kwargs=None, + interval_hours=0, + cron_expression="", + enabled=True, +): + """ + Create or update a Celery Beat PeriodicTask. Supports both interval + (hours) and cron-based scheduling. + + When *cron_expression* is provided and non-empty it takes precedence + over *interval_hours*. An interval_hours of 0 (with no cron) means + the task is disabled. + + Args: + task_name: Unique PeriodicTask name. + celery_task_path: Dotted path to the Celery task function. + kwargs: dict of keyword arguments passed to the task. + interval_hours: Interval in hours (0 = disabled when no cron). + cron_expression: 5-part cron string (empty = use interval). + enabled: Whether the task should be enabled. + + Returns: + The PeriodicTask instance (created or updated). + """ + task_kwargs = json.dumps(kwargs or {}) + + # Determine effective enabled state + use_cron = bool(cron_expression and cron_expression.strip()) + should_be_enabled = enabled and (use_cron or interval_hours > 0) + + # Retrieve existing task (if any) to track old schedule objects + old_interval = None + old_crontab = None + try: + existing = PeriodicTask.objects.get(name=task_name) + old_interval = existing.interval + old_crontab = existing.crontab + except PeriodicTask.DoesNotExist: + existing = None + + if use_cron: + # ---- Cron-based schedule ---- + cron_parts = parse_cron_expression(cron_expression) + system_tz = CoreSettings.get_system_time_zone() + + crontab, _ = CrontabSchedule.objects.get_or_create( + minute=cron_parts["minute"], + hour=cron_parts["hour"], + day_of_week=cron_parts["day_of_week"], + day_of_month=cron_parts["day_of_month"], + month_of_year=cron_parts["month_of_year"], + timezone=system_tz, + ) + + defaults = { + "task": celery_task_path, + "crontab": crontab, + "interval": None, + "enabled": should_be_enabled, + "kwargs": task_kwargs, + } + + task, created = PeriodicTask.objects.update_or_create( + name=task_name, defaults=defaults + ) + + # Clean up old interval if we switched from interval → cron + if old_interval: + _cleanup_orphaned_interval(old_interval) + # Clean up old crontab if it changed + if old_crontab and old_crontab.id != crontab.id: + _cleanup_orphaned_crontab(old_crontab) + + else: + # ---- Interval-based schedule ---- + interval, _ = IntervalSchedule.objects.get_or_create( + every=max(int(interval_hours), 1) if interval_hours else 1, + period=IntervalSchedule.HOURS, + ) + + defaults = { + "task": celery_task_path, + "interval": interval, + "crontab": None, + "enabled": should_be_enabled, + "kwargs": task_kwargs, + } + + task, created = PeriodicTask.objects.update_or_create( + name=task_name, defaults=defaults + ) + + # Clean up old crontab if we switched from cron → interval + if old_crontab: + _cleanup_orphaned_crontab(old_crontab) + # Clean up old interval if it changed + if old_interval and old_interval.id != interval.id: + _cleanup_orphaned_interval(old_interval) + + action = "Created" if created else "Updated" + mode = "cron" if use_cron else "interval" + logger.info(f"{action} periodic task '{task_name}' ({mode}, enabled={should_be_enabled})") + return task + + +def delete_periodic_task(task_name): + """ + Delete a PeriodicTask by name and clean up orphaned schedules. + + Args: + task_name: The unique name of the PeriodicTask. + + Returns: + True if a task was found and deleted, False otherwise. + """ + try: + task = PeriodicTask.objects.get(name=task_name) + except PeriodicTask.DoesNotExist: + logger.warning(f"No PeriodicTask found with name '{task_name}'") + return False + + old_interval = task.interval + old_crontab = task.crontab + task_id = task.id + + task.delete() + logger.info(f"Deleted periodic task '{task_name}' (id={task_id})") + + if old_interval: + _cleanup_orphaned_interval(old_interval) + if old_crontab: + _cleanup_orphaned_crontab(old_crontab) + + return True + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _cleanup_orphaned_interval(interval_schedule): + """Delete an IntervalSchedule if no PeriodicTasks reference it.""" + if interval_schedule is None: + return + if PeriodicTask.objects.filter(interval=interval_schedule).exists(): + return + logger.debug(f"Cleaning up orphaned IntervalSchedule {interval_schedule.id}") + interval_schedule.delete() + + +def _cleanup_orphaned_crontab(crontab_schedule): + """Delete a CrontabSchedule if no PeriodicTasks reference it.""" + if crontab_schedule is None: + return + if PeriodicTask.objects.filter(crontab=crontab_schedule).exists(): + return + logger.debug(f"Cleaning up orphaned CrontabSchedule {crontab_schedule.id}") + crontab_schedule.delete() diff --git a/core/serializers.py b/core/serializers.py index c6029bc4..03ba33e8 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -3,7 +3,7 @@ import json import ipaddress from rest_framework import serializers -from .models import CoreSettings, UserAgent, StreamProfile, NETWORK_ACCESS +from .models import CoreSettings, UserAgent, StreamProfile, NETWORK_ACCESS_KEY class UserAgentSerializer(serializers.ModelSerializer): @@ -40,10 +40,10 @@ class CoreSettingsSerializer(serializers.ModelSerializer): fields = "__all__" def update(self, instance, validated_data): - if instance.key == NETWORK_ACCESS: + if instance.key == NETWORK_ACCESS_KEY: errors = False invalid = {} - value = json.loads(validated_data.get("value")) + value = validated_data.get("value") for key, val in value.items(): cidrs = val.split(",") for cidr in cidrs: @@ -64,7 +64,12 @@ class CoreSettingsSerializer(serializers.ModelSerializer): } ) - return super().update(instance, validated_data) + result = super().update(instance, validated_data) + + # Note: Cache invalidation and notification sync is handled by post_save signal + # in core/signals.py to ensure it happens even if settings are updated elsewhere + + return result class ProxySettingsSerializer(serializers.Serializer): """Serializer for proxy settings stored as JSON in CoreSettings""" @@ -98,3 +103,45 @@ class ProxySettingsSerializer(serializers.Serializer): if value < 0 or value > 60: raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds") return value + + +class SystemNotificationSerializer(serializers.ModelSerializer): + """Serializer for system notifications.""" + is_dismissed = serializers.SerializerMethodField() + + class Meta: + from .models import SystemNotification + model = SystemNotification + fields = [ + 'id', + 'notification_key', + 'notification_type', + 'priority', + 'title', + 'message', + 'action_data', + 'is_active', + 'admin_only', + 'expires_at', + 'created_at', + 'is_dismissed', + 'source', + ] + read_only_fields = ['created_at'] + + def get_is_dismissed(self, obj): + """Check if the current user has dismissed this notification.""" + request = self.context.get('request') + if request and request.user.is_authenticated: + return obj.dismissals.filter(user=request.user).exists() + return False + + +class NotificationDismissalSerializer(serializers.ModelSerializer): + """Serializer for notification dismissals.""" + + class Meta: + from .models import NotificationDismissal + model = NotificationDismissal + fields = ['id', 'notification', 'dismissed_at', 'action_taken'] + read_only_fields = ['dismissed_at'] diff --git a/core/signals.py b/core/signals.py index 6844a890..bbb73dd9 100644 --- a/core/signals.py +++ b/core/signals.py @@ -1,9 +1,39 @@ -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, post_save from django.dispatch import receiver from django.core.exceptions import ValidationError -from .models import StreamProfile +from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY @receiver(pre_delete, sender=StreamProfile) def prevent_deletion_if_locked(sender, instance, **kwargs): if instance.locked: raise ValidationError("This profile is locked and cannot be deleted.") + +@receiver(post_save, sender=CoreSettings) +def handle_network_access_update(sender, instance, **kwargs): + """Invalidate cache and sync notifications when network access settings change.""" + if instance.key == NETWORK_ACCESS_KEY: + from django.core.cache import cache + from core.developer_notifications import sync_developer_notifications + import logging + + logger = logging.getLogger(__name__) + + # Invalidate all notification condition caches + try: + cache.delete_pattern('dev_notif_condition_*') + logger.info("Invalidated notification condition cache due to network access settings update") + except Exception as e: + logger.warning(f"Failed to delete cache pattern: {e}") + # Fallback: try to clear entire cache (if delete_pattern not supported) + try: + cache.clear() + except Exception: + pass + + # Re-sync developer notifications to re-evaluate conditions + # (websocket notification is sent by sync_developer_notifications) + try: + sync_developer_notifications() + logger.info("Re-synced developer notifications after network access settings update") + except Exception as e: + logger.error(f"Failed to sync developer notifications: {e}") diff --git a/core/tasks.py b/core/tasks.py index 257d8660..5f52ead9 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -480,15 +480,18 @@ def rehash_streams(keys): try: batch_size = 1000 - queryset = Stream.objects.all() # Track statistics total_processed = 0 duplicates_merged = 0 + # hash_keys maps new_hash -> stream_id for streams we've already processed hash_keys = {} + # Track IDs of streams that have been deleted to avoid stale references + deleted_stream_ids = set() - total_records = queryset.count() - logger.info(f"Starting rehash of {total_records} streams with keys: {keys}") + # Get initial count for progress reporting + initial_total_records = Stream.objects.count() + logger.info(f"Starting rehash of {initial_total_records} streams with keys: {keys}") # Send initial WebSocket update send_websocket_update( @@ -499,102 +502,115 @@ def rehash_streams(keys): "type": "stream_rehash", "action": "starting", "progress": 0, - "total_records": total_records, - "message": f"Starting rehash of {total_records} streams" + "total_records": initial_total_records, + "message": f"Starting rehash of {initial_total_records} streams" } ) - for start in range(0, total_records, batch_size): + # Use ID-based pagination to handle deletions correctly + # This ensures we don't skip records when items are deleted + last_processed_id = 0 + batch_number = 0 + + while True: + batch_number += 1 batch_processed = 0 batch_duplicates = 0 with transaction.atomic(): - batch = queryset[start:start + batch_size] + # Fetch batch by ID ordering, using select_for_update to lock records + # This prevents race conditions and ensures we process each record exactly once + batch = list( + Stream.objects.filter(id__gt=last_processed_id) + .select_for_update(skip_locked=True, of=('self',)) + .select_related('channel_group', 'm3u_account') + .order_by('id')[:batch_size] + ) + + if not batch: + # No more records to process + break for obj in batch: - # Generate new hash - new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys, m3u_id=obj.m3u_account_id) + # Update the last processed ID for next batch + last_processed_id = obj.id - # Check if this hash already exists in our tracking dict or in database + # Generate new hash - handle XC accounts differently + group_name = obj.channel_group.name if obj.channel_group else None + account_type = obj.m3u_account.account_type if obj.m3u_account else None + stream_id_val = obj.stream_id if hasattr(obj, 'stream_id') else None + + new_hash = Stream.generate_hash_key( + obj.name, obj.url, obj.tvg_id, keys, + m3u_id=obj.m3u_account_id, group=group_name, + account_type=account_type, stream_id=stream_id_val + ) + + # Check if this hash already exists in our tracking dict if new_hash in hash_keys: - # Found duplicate in current batch - merge the streams existing_stream_id = hash_keys[new_hash] - existing_stream = Stream.objects.get(id=existing_stream_id) - # Move any channel relationships from duplicate to existing stream - # Handle potential unique constraint violations - for channel_stream in ChannelStream.objects.filter(stream_id=obj.id): - # Check if this channel already has a relationship with the target stream - existing_relationship = ChannelStream.objects.filter( - channel_id=channel_stream.channel_id, - stream_id=existing_stream_id - ).first() + # Verify the target stream still exists and hasn't been deleted + if existing_stream_id in deleted_stream_ids: + # The target was deleted, so this stream becomes the new canonical one + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + batch_processed += 1 + continue - if existing_relationship: - # Relationship already exists, just delete the duplicate - channel_stream.delete() - else: - # Safe to update the relationship - channel_stream.stream_id = existing_stream_id - channel_stream.save() + try: + existing_stream = Stream.objects.get(id=existing_stream_id) + except Stream.DoesNotExist: + # Target stream was deleted externally, make this the canonical one + deleted_stream_ids.add(existing_stream_id) + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + batch_processed += 1 + continue - # Update the existing stream with the most recent data - if obj.updated_at > existing_stream.updated_at: - existing_stream.name = obj.name - existing_stream.url = obj.url - existing_stream.logo_url = obj.logo_url - existing_stream.tvg_id = obj.tvg_id - existing_stream.m3u_account = obj.m3u_account - existing_stream.channel_group = obj.channel_group - existing_stream.custom_properties = obj.custom_properties - existing_stream.last_seen = obj.last_seen - existing_stream.updated_at = obj.updated_at - existing_stream.save() + # Determine which stream to keep based on channel ordering + stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj) - # Delete the duplicate - obj.delete() + # Move channel relationships from the stream being deleted to the one being kept + _merge_stream_relationships(stream_to_delete, stream_to_keep) + + # Delete the duplicate FIRST to free up the unique hash constraint + deleted_stream_ids.add(stream_to_delete.id) + stream_to_delete.delete() batch_duplicates += 1 + + # Now safely set the hash on the kept stream (after deletion freed it up) + if stream_to_keep.stream_hash != new_hash: + stream_to_keep.stream_hash = new_hash + stream_to_keep.save(update_fields=['stream_hash']) + + # Update hash_keys to point to the kept stream + hash_keys[new_hash] = stream_to_keep.id else: - # Check if hash already exists in database (from previous batches or existing data) + # Check if hash already exists in database (from streams not yet processed) existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first() if existing_stream: - # Found duplicate in database - merge the streams - # Move any channel relationships from duplicate to existing stream - # Handle potential unique constraint violations - for channel_stream in ChannelStream.objects.filter(stream_id=obj.id): - # Check if this channel already has a relationship with the target stream - existing_relationship = ChannelStream.objects.filter( - channel_id=channel_stream.channel_id, - stream_id=existing_stream.id - ).first() + # Found duplicate in database - determine which to keep based on channel ordering + stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj) - if existing_relationship: - # Relationship already exists, just delete the duplicate - channel_stream.delete() - else: - # Safe to update the relationship - channel_stream.stream_id = existing_stream.id - channel_stream.save() + # Move channel relationships from the stream being deleted to the one being kept + _merge_stream_relationships(stream_to_delete, stream_to_keep) - # Update the existing stream with the most recent data - if obj.updated_at > existing_stream.updated_at: - existing_stream.name = obj.name - existing_stream.url = obj.url - existing_stream.logo_url = obj.logo_url - existing_stream.tvg_id = obj.tvg_id - existing_stream.m3u_account = obj.m3u_account - existing_stream.channel_group = obj.channel_group - existing_stream.custom_properties = obj.custom_properties - existing_stream.last_seen = obj.last_seen - existing_stream.updated_at = obj.updated_at - existing_stream.save() - - # Delete the duplicate - obj.delete() + # Delete the duplicate FIRST to free up the unique hash constraint + deleted_stream_ids.add(stream_to_delete.id) + stream_to_delete.delete() batch_duplicates += 1 - hash_keys[new_hash] = existing_stream.id + + # Now safely set the hash on the kept stream (after deletion freed it up) + if stream_to_keep.stream_hash != new_hash: + stream_to_keep.stream_hash = new_hash + stream_to_keep.save(update_fields=['stream_hash']) + + hash_keys[new_hash] = stream_to_keep.id else: - # Update hash for this stream + # No duplicate - update hash for this stream obj.stream_hash = new_hash obj.save(update_fields=['stream_hash']) hash_keys[new_hash] = obj.id @@ -604,10 +620,9 @@ def rehash_streams(keys): total_processed += batch_processed duplicates_merged += batch_duplicates - # Calculate progress percentage - progress_percent = int((total_processed / total_records) * 100) - current_batch = start // batch_size + 1 - total_batches = (total_records // batch_size) + 1 + # Calculate progress percentage based on initial count + # Cap at 99% until we're actually done to avoid showing 100% prematurely + progress_percent = min(99, int((total_processed / max(initial_total_records, 1)) * 100)) # Send progress update via WebSocket send_websocket_update( @@ -618,15 +633,14 @@ def rehash_streams(keys): "type": "stream_rehash", "action": "processing", "progress": progress_percent, - "batch": current_batch, - "total_batches": total_batches, + "batch": batch_number, "processed": total_processed, "duplicates_merged": duplicates_merged, - "message": f"Processed batch {current_batch}/{total_batches}: {batch_processed} streams, {batch_duplicates} duplicates merged" + "message": f"Processed batch {batch_number}: {batch_processed} streams, {batch_duplicates} duplicates merged" } ) - logger.info(f"Rehashed batch {current_batch}/{total_batches}: " + logger.info(f"Rehashed batch {batch_number}: " f"{batch_processed} processed, {batch_duplicates} duplicates merged") logger.info(f"Rehashing complete: {total_processed} streams processed, " @@ -653,7 +667,7 @@ def rehash_streams(keys): return f"Successfully rehashed {total_processed} streams" except Exception as e: - logger.error(f"Error during stream rehash: {e}") + logger.error(f"Error during stream rehash: {e}", exc_info=True) raise finally: # Always release all acquired M3U locks @@ -662,6 +676,83 @@ def rehash_streams(keys): logger.info(f"Released M3U task locks for {len(acquired_locks)} accounts") +def _merge_stream_relationships(source_stream, target_stream): + """ + Move channel relationships from source_stream to target_stream. + Handles unique constraint violations by preserving existing relationships. + Preserves the best ordering when merging relationships. + """ + for channel_stream in ChannelStream.objects.filter(stream_id=source_stream.id): + # Check if this channel already has a relationship with the target stream + existing_relationship = ChannelStream.objects.filter( + channel_id=channel_stream.channel_id, + stream_id=target_stream.id + ).first() + + if existing_relationship: + # Relationship already exists - keep the one with better ordering (lower order value) + if channel_stream.order < existing_relationship.order: + existing_relationship.order = channel_stream.order + existing_relationship.save(update_fields=['order']) + # Delete the duplicate relationship + channel_stream.delete() + else: + # Safe to update the relationship + channel_stream.stream_id = target_stream.id + channel_stream.save() + + +def _get_best_channel_order(stream): + """ + Get the best (lowest) channel order for a stream. + Returns None if stream has no channel relationships. + Lower order value = better/higher position in the channel list. + """ + best_order = ChannelStream.objects.filter(stream_id=stream.id).order_by('order').values_list('order', flat=True).first() + return best_order + + +def _determine_stream_to_keep(stream_a, stream_b): + """ + Determine which stream should be kept when merging duplicates. + + Priority: + 1. Stream with better (lower) channel order wins + 2. If both have same order or neither has channel relationships, + keep the one with more recent updated_at + 3. If still tied, keep the one with the lower ID (more stable) + + Returns: (stream_to_keep, stream_to_delete) + """ + order_a = _get_best_channel_order(stream_a) + order_b = _get_best_channel_order(stream_b) + + # If one has channel relationships and the other doesn't, keep the one with relationships + if order_a is not None and order_b is None: + return (stream_a, stream_b) + if order_b is not None and order_a is None: + return (stream_b, stream_a) + + # If both have channel relationships, keep the one with better (lower) order + if order_a is not None and order_b is not None: + if order_a < order_b: + return (stream_a, stream_b) + elif order_b < order_a: + return (stream_b, stream_a) + # Same order, fall through to other criteria + + # Neither has relationships, or same order - use updated_at + if stream_a.updated_at > stream_b.updated_at: + return (stream_a, stream_b) + elif stream_b.updated_at > stream_a.updated_at: + return (stream_b, stream_a) + + # Same updated_at - keep lower ID for stability + if stream_a.id < stream_b.id: + return (stream_a, stream_b) + return (stream_b, stream_a) + + @shared_task def cleanup_vod_persistent_connections(): """Clean up stale VOD persistent connections""" @@ -674,3 +765,227 @@ def cleanup_vod_persistent_connections(): except Exception as e: logger.error(f"Error during VOD persistent connection cleanup: {e}") + + +@shared_task +def check_for_version_update(): + """ + Check for new Dispatcharr versions on GitHub and create a notification if available. + This task should be run periodically (e.g., daily) via Celery Beat. + + For dev builds (identified by __timestamp__), checks for stable releases only. + For production builds, checks for stable releases. + + Note: Dev builds are container images from the dev branch and don't have GitHub releases. + This checks if a stable release is available so dev users know when to upgrade. + """ + import requests + from datetime import datetime, timezone + from packaging import version as pkg_version + from version import __version__, __timestamp__ + from core.models import SystemNotification + from core.utils import send_websocket_notification + + try: + is_dev_build = __timestamp__ is not None + DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'} + if is_dev_build: + # Check Docker Hub for newer dev builds + docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev" + + response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10) + + if response.status_code != 200: + logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}") + return + + dev_tag_data = response.json() + docker_last_updated = dev_tag_data.get("last_updated") + + if not docker_last_updated: + logger.warning("No last_updated timestamp found in Docker Hub response") + return + + # Parse timestamps for comparison + local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc) + docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00')) + + # Calculate difference in minutes + diff_minutes = (docker_dt - local_dt).total_seconds() / 60 + + # Threshold to account for build/push time differences + THRESHOLD_MINUTES = 10 + + if diff_minutes > THRESHOLD_MINUTES: + logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)") + + # Delete any old version update notifications (both dev and stable, in case user switched) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + if deleted_count > 0: + logger.debug(f"Deleted {deleted_count} old dev build notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + # Create notification for new dev build + notification, created = SystemNotification.objects.get_or_create( + notification_key=f'version-dev-{docker_last_updated}', + defaults={ + 'notification_type': 'version_update', + 'title': 'New Dev Build Available', + 'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)', + 'priority': 'medium', + 'action_data': { + 'current_version': __version__, + 'current_timestamp': __timestamp__, + 'docker_updated': docker_last_updated, + 'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags' + }, + 'is_active': True, + 'admin_only': True, + } + ) + + if created: + # Only send WebSocket for newly created notifications + send_websocket_notification(notification) + logger.info(f"New dev build notification created and sent via WebSocket") + else: + logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})") + + # Delete all version update notifications when up to date (both dev and stable) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + + if deleted_count > 0: + logger.info(f"Deleted {deleted_count} outdated dev build notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + else: + # Production build - check GitHub for stable releases + github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest" + headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS} + response = requests.get( + github_api_url, + headers=headers, + timeout=10 + ) + + if response.status_code != 200: + logger.warning(f"Failed to check for updates: HTTP {response.status_code}") + return + + release_data = response.json() + latest_version = release_data.get("tag_name", "").lstrip("v") + release_url = release_data.get("html_url", "") + + if not latest_version: + logger.warning("No version tag found in GitHub release") + return + + # Compare versions + current = pkg_version.parse(__version__) + latest = pkg_version.parse(latest_version) + if latest > current: + logger.info(f"New stable version available: {latest_version} (current: {__version__})") + + # Delete any old version update notifications (superseded by this one) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).exclude( + notification_key=f"version-{latest_version}" + ).delete()[0] + if deleted_count > 0: + logger.debug(f"Deleted {deleted_count} old version notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + # Create or update the notification for the new version + notification, created = SystemNotification.create_version_notification( + version=latest_version, + release_url=release_url, + ) + + if created: + # Only send WebSocket for newly created notifications + send_websocket_notification(notification) + logger.info(f"New version notification created and sent via WebSocket") + else: + logger.debug(f"Dispatcharr is up to date (v{__version__})") + + # Delete ALL version update notifications when up to date (no longer needed) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + + if deleted_count > 0: + logger.info(f"Deleted {deleted_count} outdated version notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + except requests.RequestException as e: + logger.warning(f"Network error checking for updates: {e}") + except Exception as e: + logger.error(f"Error checking for version updates: {e}") + + +def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None): + """ + Create a setting recommendation notification. + This is a helper function that can be called from anywhere in the codebase. + + Args: + setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout') + recommended_value: The recommended value for the setting + reason: Why this setting is recommended + current_value: The current value (optional) + + Returns: + The created SystemNotification instance + """ + from core.models import SystemNotification + from core.utils import send_websocket_notification + + notification, created = SystemNotification.create_setting_recommendation( + setting_key=setting_key, + recommended_value=recommended_value, + reason=reason, + current_value=current_value + ) + + # Only send via WebSocket for newly created notifications + if created: + send_websocket_notification(notification) + + return notification + diff --git a/core/utils.py b/core/utils.py index a2f4f987..965df0e1 100644 --- a/core/utils.py +++ b/core/utils.py @@ -142,7 +142,97 @@ class RedisClient: @classmethod def get_client(cls, max_retries=5, retry_interval=1): if cls._client is None: - cls._client = cls._init_client(decode_responses=True, max_retries=max_retries, retry_interval=retry_interval) + retry_count = 0 + while retry_count < max_retries: + try: + # Get connection parameters from settings or environment + redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) + redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) + redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) + + # Use standardized settings + socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5) + socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5) + health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30) + socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True) + retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True) + + # Create Redis client with better defaults + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + socket_timeout=socket_timeout, + socket_connect_timeout=socket_connect_timeout, + socket_keepalive=socket_keepalive, + health_check_interval=health_check_interval, + retry_on_timeout=retry_on_timeout + ) + + # Validate connection with ping + client.ping() + client.flushdb() + + # Disable persistence on first connection - improves performance + # Only try to disable if not in a read-only environment + try: + client.config_set('save', '') # Disable RDB snapshots + client.config_set('appendonly', 'no') # Disable AOF logging + + # Set optimal memory settings with environment variable support + # Get max memory from environment or use a larger default (512MB instead of 256MB) + #max_memory = os.environ.get('REDIS_MAX_MEMORY', '512mb') + #eviction_policy = os.environ.get('REDIS_EVICTION_POLICY', 'allkeys-lru') + + # Apply memory settings + #client.config_set('maxmemory-policy', eviction_policy) + #client.config_set('maxmemory', max_memory) + + #logger.info(f"Redis configured with maxmemory={max_memory}, policy={eviction_policy}") + + # Disable protected mode when in debug mode + if os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true': + client.config_set('protected-mode', 'no') # Disable protected mode in debug + logger.warning("Redis protected mode disabled for debug environment") + + logger.trace("Redis persistence disabled for better performance") + except redis.exceptions.ResponseError as e: + # Improve error handling for Redis configuration errors + if "OOM" in str(e): + logger.error(f"Redis OOM during configuration: {e}") + # Try to increase maxmemory as an emergency measure + try: + client.config_set('maxmemory', '768mb') + logger.warning("Applied emergency Redis memory increase to 768MB") + except: + pass + else: + logger.error(f"Redis configuration error: {e}") + + logger.info(f"Connected to Redis at {redis_host}:{redis_port}/{redis_db}") + + cls._client = client + break + + except (ConnectionError, TimeoutError) as e: + retry_count += 1 + if retry_count >= max_retries: + logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}") + return None + else: + # Use exponential backoff for retries + wait_time = retry_interval * (2 ** (retry_count - 1)) + logger.warning(f"Redis connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})") + time.sleep(wait_time) + + except Exception as e: + logger.error(f"Unexpected error connecting to Redis: {e}") + return None + return cls._client @classmethod @@ -163,6 +253,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings but without socket timeouts for PubSub # Important: socket_timeout is None for PubSub operations @@ -176,6 +268,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=None, # Critical: No timeout for PubSub operations socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, @@ -230,6 +324,75 @@ def release_task_lock(task_name, id): # Remove the lock redis_client.delete(lock_id) + +class TaskLockRenewer: + """Periodically renews a Redis task lock to prevent expiry during long-running tasks. + + Use as a context manager after acquiring a lock: + + if acquire_task_lock("my_task", task_id): + with TaskLockRenewer("my_task", task_id): + # ... long-running work ... + release_task_lock("my_task", task_id) + + A daemon thread extends the lock TTL at regular intervals so that + slow downloads or large parsing jobs don't lose their lock mid-operation. + """ + + def __init__(self, task_name, id, ttl=300, renewal_interval=120): + self.task_name = task_name + self.id = id + self.ttl = ttl + self.renewal_interval = renewal_interval + self.lock_id = f"task_lock_{task_name}_{id}" + self._stop_event = threading.Event() + self._thread = None + + def _renew_loop(self): + """Background loop that extends the lock TTL until stopped.""" + while not self._stop_event.wait(self.renewal_interval): + try: + redis_client = RedisClient.get_client() + if redis_client.exists(self.lock_id): + redis_client.expire(self.lock_id, self.ttl) + logger.debug( + f"Renewed lock {self.lock_id} TTL to {self.ttl}s" + ) + else: + # Lock was deleted externally (e.g. manual release) — stop renewing + logger.warning( + f"Lock {self.lock_id} no longer exists, stopping renewal" + ) + break + except Exception as e: + logger.error(f"Error renewing lock {self.lock_id}: {e}") + + def start(self): + """Start the background renewal thread.""" + self._stop_event.clear() + self._thread = threading.Thread( + target=self._renew_loop, daemon=True, + name=f"lock-renew-{self.task_name}-{self.id}" + ) + self._thread.start() + return self + + def stop(self): + """Stop the renewal thread.""" + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + self._thread = None + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + return False + + def send_websocket_update(group_name, event_type, data, collect_garbage=False): """ Standardized function to send WebSocket updates with proper memory management. @@ -405,6 +568,83 @@ def validate_flexible_url(value): # If it doesn't match our flexible patterns, raise the original error raise ValidationError("Enter a valid URL.") +def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details): + try: + from apps.connect.utils import trigger_event + from apps.channels.models import Channel, Stream + from core.models import StreamProfile + from core.utils import RedisClient + + payload = dict(details) + + channel_obj = None + if channel_id: + try: + channel_obj = Channel.objects.get(uuid=channel_id) + payload["channel_name"] = channel_obj.name + except Exception: + payload["channel_name"] = channel_name or None + else: + payload["channel_name"] = channel_name or None + + # Resolve current stream info + stream_id = details.get("stream_id") + stream_obj = None + if not stream_id and channel_obj: + try: + redis = RedisClient.get_client() + sid = redis.get(f"channel_stream:{channel_obj.id}") + if sid: + stream_id = int(sid) + except Exception: + stream_id = None + + if stream_id: + try: + stream_obj = Stream.objects.get(id=stream_id) + except Exception: + stream_obj = None + + # Populate stream details + payload["stream_name"] = getattr(stream_obj, "name", None) + payload["stream_url"] = getattr(stream_obj, "url", None) + + # Channel URL: use stream URL as best-effort + payload["channel_url"] = payload.get("stream_url") + + # Provider name from M3U account + provider_name = None + try: + if stream_obj and stream_obj.m3u_account: + provider_name = stream_obj.m3u_account.name + except Exception: + provider_name = None + payload["provider_name"] = provider_name + + # Profile used + profile_used = None + try: + if stream_id: + redis = RedisClient.get_client() + pid = redis.get(f"stream_profile:{stream_id}") + if pid: + profile = StreamProfile.objects.filter(id=int(pid)).first() + profile_used = profile.name if profile else None + except Exception: + profile_used = None + + payload["profile_used"] = profile_used + + # remove empty keys + for k in list(payload.keys()): + if not payload[k]: + del payload[k] + + trigger_event(event_type, payload) + + except Exception as e: + # Don't fail main path if connect dispatch fails + pass def log_system_event(event_type, channel_id=None, channel_name=None, **details): """ @@ -431,10 +671,17 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details): details=details ) + # Trigger connect integrations for specific events + dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details) + # Get max events from settings (default 100) try: - max_events_setting = CoreSettings.objects.filter(key='max-system-events').first() - max_events = int(max_events_setting.value) if max_events_setting else 100 + from .models import CoreSettings + system_settings = CoreSettings.objects.filter(key='system_settings').first() + if system_settings and isinstance(system_settings.value, dict): + max_events = int(system_settings.value.get('max_system_events', 100)) + else: + max_events = 100 except Exception: max_events = 100 @@ -449,3 +696,75 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details): except Exception as e: # Don't let event logging break the main application logger.error(f"Failed to log system event {event_type}: {e}") + + +def send_websocket_notification(notification): + """ + Send a system notification to all connected WebSocket clients. + + Args: + notification: A SystemNotification model instance or dict with notification data + + Example: + from core.models import SystemNotification + notification = SystemNotification.create_version_notification('0.19.0', 'https://...') + send_websocket_notification(notification) + """ + try: + channel_layer = get_channel_layer() + + # Convert model instance to dict if needed + if hasattr(notification, 'id'): + notification_data = { + 'id': notification.id, + 'notification_key': notification.notification_key, + 'notification_type': notification.notification_type, + 'priority': notification.priority, + 'title': notification.title, + 'message': notification.message, + 'action_data': notification.action_data, + 'is_active': notification.is_active, + 'admin_only': notification.admin_only, + 'created_at': notification.created_at.isoformat() if notification.created_at else None, + } + else: + notification_data = notification + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + 'type': 'system_notification', + 'notification': notification_data, + } + } + ) + logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}") + except Exception as e: + logger.error(f"Failed to send WebSocket notification: {e}") + + +def send_notification_dismissed(notification_key): + """ + Notify all connected clients that a notification was dismissed. + Useful for syncing dismissal state across multiple browser tabs/sessions. + + Args: + notification_key: The unique key of the dismissed notification + """ + try: + channel_layer = get_channel_layer() + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + 'type': 'notification_dismissed', + 'notification_key': notification_key, + } + } + ) + except Exception as e: + logger.error(f"Failed to send notification dismissed event: {e}") diff --git a/core/views.py b/core/views.py index d10df027..082cccc3 100644 --- a/core/views.py +++ b/core/views.py @@ -1,5 +1,6 @@ # core/views.py import os +from shlex import split as shlex_split import sys import subprocess import logging @@ -37,7 +38,17 @@ def stream_view(request, channel_uuid): """ try: redis_host = getattr(settings, "REDIS_HOST", "localhost") - redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, db=int(getattr(settings, "REDIS_DB", "0"))) + redis_port = int(getattr(settings, "REDIS_PORT", 6379)) + redis_db = int(getattr(settings, "REDIS_DB", "0")) + redis_password = getattr(settings, "REDIS_PASSWORD", "") + redis_user = getattr(settings, "REDIS_USER", "") + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(uuid=channel_uuid) @@ -129,7 +140,7 @@ def stream_view(request, channel_uuid): stream_profile = channel.stream_profile if not stream_profile: logger.error("No stream profile set for channel ID=%s, using default", channel.id) - stream_profile = StreamProfile.objects.get(id=CoreSettings.objects.get(key="default-stream-profile").value) + stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id()) logger.debug("Stream profile used: %s", stream_profile.name) @@ -142,7 +153,7 @@ def stream_view(request, channel_uuid): logger.debug("Formatted parameters: %s", parameters) # Build the final command. - cmd = [stream_profile.command] + parameters.split() + cmd = [stream_profile.command] + shlex_split(parameters) logger.debug("Executing command: %s", cmd) try: diff --git a/debian_install.sh b/debian_install.sh index bda506b1..3452e5c1 100755 --- a/debian_install.sh +++ b/debian_install.sh @@ -160,15 +160,28 @@ EOSU # 6) Setup Python Environment ############################################################################## +install_uv() { + echo ">>> Installing UV package manager..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +} + setup_python_env() { - echo ">>> Setting up Python virtual environment..." + echo ">>> Setting up Python virtual environment with UV..." + # Install UV globally first + install_uv + su - "$DISPATCH_USER" < /dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="\$HOME/.local/bin:\$PATH" +fi +# Create venv and install dependencies using UV +uv venv env --python $PYTHON_BIN +uv sync --python env/bin/python --no-install-project --no-dev EOSU ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg" } diff --git a/dispatcharr/app_initialization.py b/dispatcharr/app_initialization.py new file mode 100644 index 00000000..e2fc12d6 --- /dev/null +++ b/dispatcharr/app_initialization.py @@ -0,0 +1,52 @@ +"""Utilities for managing app initialization across multiple processes.""" + +import sys +import os +import psutil +import logging + +logger = logging.getLogger(__name__) + + +def _is_worker_process(): + """Check if this process is a worker spawned by uwsgi/gunicorn.""" + try: + parent = psutil.Process(os.getppid()) + parent_name = parent.name() + return parent_name in ['uwsgi', 'gunicorn'] + except Exception: + # If we can't determine, assume it's not a worker (safe default) + return False + + +def should_skip_initialization(): + """ + Determine if app initialization should be skipped in this process. + + Returns True if: + - A management command is being run (migrate, celery, shell, etc.) + - The development server (daphne) is running + - This is a worker process (not the master) + + This prevents redundant initialization across multiple worker processes. + """ + # Skip management commands and background services + skip_commands = [ + 'celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell', + 'collectstatic', 'loaddata' + ] + if any(cmd in sys.argv for cmd in skip_commands): + logger.debug(f"Skipping initialization due to command: {sys.argv}") + return True + + # Skip daphne development server (single process, no need to guard) + if 'daphne' in sys.argv[0] if sys.argv else False: + logger.debug(f"Skipping initialization in daphne development server. Command: {sys.argv}") + return True + + # Skip if this is a worker process spawned by uwsgi/gunicorn + if _is_worker_process(): + logger.debug(f"Skipping initialization in worker process. Command: {sys.argv}") + return True + + return False diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py index fe62be43..801d2ba3 100644 --- a/dispatcharr/persistent_lock.py +++ b/dispatcharr/persistent_lock.py @@ -73,8 +73,20 @@ class PersistentLock: # Example usage (for testing purposes only): if __name__ == "__main__": - # Connect to Redis on localhost; adjust connection parameters as needed. - client = redis.Redis(host="localhost", port=6379, db=0) + import os + # Connect to Redis using environment variables; adjust connection parameters as needed. + redis_host = os.environ.get("REDIS_HOST", "localhost") + redis_port = int(os.environ.get("REDIS_PORT", 6379)) + redis_db = int(os.environ.get("REDIS_DB", 0)) + redis_password = os.environ.get("REDIS_PASSWORD", "") + redis_user = os.environ.get("REDIS_USER", "") + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) lock = PersistentLock(client, "lock:example_account", lock_timeout=120) if lock.acquire(): diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index d6c29dd9..c8350895 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -1,12 +1,16 @@ import os from pathlib import Path from datetime import timedelta +from urllib.parse import quote_plus BASE_DIR = Path(__file__).resolve().parent.parent -SECRET_KEY = "REPLACE_ME_WITH_A_REAL_SECRET" +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") +REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) REDIS_DB = os.environ.get("REDIS_DB", "0") +REDIS_USER = os.environ.get("REDIS_USER", "") +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") # Set DEBUG to True for development, False for production if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": @@ -20,6 +24,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") INSTALLED_APPS = [ "apps.api", "apps.accounts", + "apps.backups.apps.BackupsConfig", "apps.channels.apps.ChannelsConfig", "apps.dashboard", "apps.epg", @@ -29,9 +34,10 @@ INSTALLED_APPS = [ "apps.proxy.apps.ProxyConfig", "apps.proxy.ts_proxy", "apps.vod.apps.VODConfig", + "apps.connect.apps.ConnectConfig", "core", "daphne", - "drf_yasg", + "drf_spectacular", "channels", "django.contrib.admin", "django.contrib.auth", @@ -118,7 +124,12 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [(REDIS_HOST, 6379, REDIS_DB)], # Ensure Redis is running + "hosts": ["redis://{redis_auth}{host}:{port}/{db}".format( + redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "", + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB + )], # URL format supports authentication }, }, } @@ -150,21 +161,34 @@ AUTH_PASSWORD_VALIDATORS = [ ] REST_FRAMEWORK = { - "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_RENDERER_CLASSES": [ "rest_framework.renderers.JSONRenderer", "rest_framework.renderers.BrowsableAPIRenderer", ], "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", + "apps.accounts.authentication.ApiKeyAuthentication", ], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } -SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": { - "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"} - } +SPECTACULAR_SETTINGS = { + "TITLE": "Dispatcharr API", + "DESCRIPTION": "API documentation for Dispatcharr", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SECURITY": [{"BearerAuth": []}], + "COMPONENTS": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.", + } + } + }, } LANGUAGE_CODE = "en-us" @@ -184,8 +208,21 @@ STATICFILES_DIRS = [ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" -CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0") -CELERY_RESULT_BACKEND = CELERY_BROKER_URL +# Build default Redis URL from components for Celery with optional authentication +# Build auth string conditionally with URL encoding for special characters +if REDIS_PASSWORD: + encoded_password = quote_plus(REDIS_PASSWORD) + if REDIS_USER: + encoded_user = quote_plus(REDIS_USER) + redis_auth = f"{encoded_user}:{encoded_password}@" + else: + redis_auth = f":{encoded_password}@" +else: + redis_auth = "" + +_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url) +CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) # Configure Redis key prefix CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = { @@ -221,11 +258,23 @@ CELERY_BEAT_SCHEDULE = { "task": "apps.channels.tasks.maintain_recurring_recordings", "schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead }, + # Check for version updates daily + "check-version-updates": { + "task": "core.tasks.check_for_version_update", + "schedule": 86400.0, # Once every 24 hours + }, } MEDIA_ROOT = BASE_DIR / "media" MEDIA_URL = "/media/" +# Backup settings +BACKUP_ROOT = os.environ.get("BACKUP_ROOT", "/data/backups") +BACKUP_DATA_DIRS = [ + os.environ.get("LOGOS_DIR", "/data/logos"), + os.environ.get("UPLOADS_DIR", "/data/uploads"), + os.environ.get("PLUGINS_DIR", "/data/plugins"), +] SERVER_IP = "127.0.0.1" @@ -242,7 +291,7 @@ SIMPLE_JWT = { } # Redis connection settings -REDIS_URL = "redis://localhost:6379/0" +REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url) REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds @@ -364,3 +413,18 @@ LOGGING = { "level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO' }, } + +# Connect script execution safety settings +# Allowed base directories for custom scripts; real paths must be inside +_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/scripts") +CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p] + +# Max execution time (seconds) for scripts +CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10")) + +# Truncate stdout/stderr to this many characters to avoid large outputs +CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536")) + +# Require executable bit and disallow world-writable files +CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True +CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 890d0c2d..092fb0cb 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -3,35 +3,20 @@ from django.urls import path, include, re_path from django.conf import settings from django.conf.urls.static import static from django.views.generic import TemplateView, RedirectView -from rest_framework import permissions -from drf_yasg.views import get_schema_view -from drf_yasg import openapi from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.ts_proxy.views import stream_xc from apps.output.views import xc_movie_stream, xc_series_stream -# Define schema_view for Swagger -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version="v1", - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@dispatcharr.local"), - license=openapi.License(name="Creative Commons by-nc-sa"), - ), - public=True, - permission_classes=(permissions.AllowAny,), -) - urlpatterns = [ # API Routes path("api/", include(("apps.api.urls", "api"), namespace="api")), path("api", RedirectView.as_view(url="/api/", permanent=True)), - # Admin - path("admin", RedirectView.as_view(url="/admin/", permanent=True)), - path("admin/", admin.site.urls), + # Swagger redirects (Swagger UI is served at /api/swagger/) + path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)), + path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)), # Outputs path("output", RedirectView.as_view(url="/output/", permanent=True)), path("output/", include(("apps.output.urls", "output"), namespace="output")), @@ -67,12 +52,9 @@ urlpatterns = [ xc_series_stream, name="xc_series_stream", ), - - re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), - # ReDoc UI - path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), - # Optionally, serve the raw Swagger JSON - path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"), + # Admin + path("admin", RedirectView.as_view(url="/admin/", permanent=True)), + path("admin/", admin.site.urls), # VOD proxy is now handled by the main proxy URLs above # Catch-all routes should always be last diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 56243b7a..c8fe58ad 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -3,7 +3,7 @@ import json import ipaddress from django.http import JsonResponse from django.core.exceptions import ValidationError -from core.models import CoreSettings, NETWORK_ACCESS +from core.models import CoreSettings, NETWORK_ACCESS_KEY def json_error_response(message, status=400): @@ -39,12 +39,23 @@ def get_client_ip(request): def network_access_allowed(request, settings_key): - network_access = json.loads(CoreSettings.objects.get(key=NETWORK_ACCESS).value) + try: + network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value + except CoreSettings.DoesNotExist: + network_access = {} + local_cidrs = ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"] + # Set defaults based on endpoint type + if settings_key == "M3U_EPG": + # M3U/EPG endpoints: local IPv4 and IPv6 only by default + default_cidrs = local_cidrs + else: + # Other endpoints: allow all by default + default_cidrs = ["0.0.0.0/0", "::/0"] cidrs = ( network_access[settings_key].split(",") if settings_key in network_access - else ["0.0.0.0/0", "::/0"] + else default_cidrs ) network_allowed = False diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index d37d8958..d2e8ceaa 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -1,29 +1,54 @@ FROM lscr.io/linuxserver/ffmpeg:latest ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy ENV VIRTUAL_ENV=/dispatcharrpy ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy -# --- Install Python 3.13 and system dependencies --- +# --- Install Python 3.13 and build dependencies --- # Note: Hardware acceleration (VA-API, VDPAU, NVENC) already included in base ffmpeg image RUN apt-get update && apt-get install --no-install-recommends -y \ ca-certificates software-properties-common gnupg2 curl wget \ && add-apt-repository ppa:deadsnakes/ppa \ && apt-get update \ && apt-get install --no-install-recommends -y \ - python3.13 python3.13-dev python3.13-venv \ + python3.13 python3.13-dev python3.13-venv libpython3.13 \ python-is-python3 python3-pip \ - libpcre3 libpcre3-dev libpq-dev procps \ - build-essential gcc pciutils \ - nginx streamlink comskip\ - && apt-get clean && rm -rf /var/lib/apt/lists/* + libpcre3 libpcre3-dev libpq-dev procps pciutils \ + nginx streamlink comskip \ + vlc-bin vlc-plugin-base \ + build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build -# --- Create Python virtual environment --- -RUN python3.13 -m venv $VIRTUAL_ENV && $VIRTUAL_ENV/bin/pip install --upgrade pip +# --- Install UV --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -# --- Install Python dependencies --- -COPY requirements.txt /tmp/requirements.txt -RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt +# --- Create Python virtual environment and install dependencies --- +WORKDIR /tmp/build +COPY pyproject.toml /tmp/build/ +COPY version.py /tmp/build/ +COPY README.md /tmp/build/ +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ + rm -rf /tmp/build +WORKDIR / + +# --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- +RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ + cd /tmp && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ + tar -xzf numpy-*.tar.gz && \ + cd numpy-*/ && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ + mv dist/*.whl /opt/ && \ + cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ + uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip + +# --- Clean up build dependencies to reduce image size --- +RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ + apt-get autoremove -y --purge && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /root/.cache /tmp/* # --- Set up Redis 7.x --- RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ diff --git a/docker/Dockerfile b/docker/Dockerfile index dc437227..3e30a825 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -30,14 +30,17 @@ WORKDIR /app COPY . /app # Copy nginx configuration COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default +# Fix line endings and make entrypoint scripts executable +RUN for f in /app/docker/entrypoint*.sh; do \ + if [ -f "$f" ]; then \ + sed -i 's/\r$//' "$f" && chmod +x "$f"; \ + fi; \ + done # Clean out existing frontend folder RUN rm -rf /app/frontend # Copy built frontend assets COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist -# Run Django collectstatic -RUN python manage.py collectstatic --noinput - # Add timestamp argument ARG TIMESTAMP diff --git a/docker/build-dev.sh b/docker/build-dev.sh index b02c314e..703e06c6 100755 --- a/docker/build-dev.sh +++ b/docker/build-dev.sh @@ -1,11 +1,69 @@ #!/bin/bash -docker build --build-arg BRANCH=dev -t dispatcharr/dispatcharr:dev -f Dockerfile .. +set -e -# Get version information -VERSION=$(python -c "import sys; sys.path.append('..'); import version; print(version.__version__)") +# Default values +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +VERSION=$(python3 -c "import sys; sys.path.append('${ROOT_DIR}'); import version; print(version.__version__)") +REGISTRY="dispatcharr" # Registry or private repo to push to +IMAGE="dispatcharr" # Image that we're building +BRANCH="dev" +ARCH="" # Architectures to build for, e.g. linux/amd64,linux/arm64 +PUSH=false -# Build with version tag -docker build --build-arg BRANCH=dev \ - -t dispatcharr/dispatcharr:dev \ - -t dispatcharr/dispatcharr:${VERSION} \ - -f Dockerfile .. +usage() { + cat <<-EOF + To test locally: + ./build-dev.sh + + To build and push to registry: + ./build-dev.sh -p + + To build and push to a private registry: + ./build-dev.sh -p -r myregistry:5000 + + To build for -both- x86_64 and arm_64: + ./build-dev.sh -p -a linux/amd64,linux/arm64 + + Do it all: + ./build-dev.sh -p -r myregistry:5000 -a linux/amd64,linux/arm64 + EOF + exit 0 +} + +# Parse options +while getopts "pr:a:b:i:h" opt; do + case $opt in + r) REGISTRY="$OPTARG" ;; + a) ARCH="$OPTARG" ;; + b) BRANCH="$OPTARG" ;; + i) IMAGE="$OPTARG" ;; + p) PUSH=true ;; + h) usage ;; + \?) + echo "Invalid option: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +BUILD_ARGS="BRANCH=$BRANCH" +ARCH_ARGS=() +if [ -n "$ARCH" ]; then + ARCH_ARGS=(--platform "$ARCH") +fi + +echo docker build --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE" +docker build -f "${SCRIPT_DIR}/Dockerfile" --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE" "$ROOT_DIR" +docker tag "$IMAGE" "$IMAGE":"$BRANCH" +docker tag "$IMAGE" "$IMAGE":"$VERSION" + +if [ "$PUSH" = "true" ]; then + for TAG in latest "$VERSION" "$BRANCH"; do + docker tag "$IMAGE" "$REGISTRY/$IMAGE:$TAG" + docker push -q "$REGISTRY/$IMAGE:$TAG" + done + echo "Images pushed successfully." +else + echo "Please run 'docker push $IMAGE:$BRANCH' and 'docker push $IMAGE:${VERSION}' when ready" +fi diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index fe5e1507..2b1fd2ae 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -14,6 +14,10 @@ services: - REDIS_HOST=localhost - CELERY_BROKER_URL=redis://localhost:6379/0 - DISPATCHARR_LOG_LEVEL=info + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features + #- USE_LEGACY_NUMPY=true # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) # Negative values require cap_add: SYS_NICE (uncomment below) diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml index d9dbef0e..c576cfd1 100644 --- a/docker/docker-compose.debug.yml +++ b/docker/docker-compose.debug.yml @@ -18,6 +18,10 @@ services: - REDIS_HOST=localhost - CELERY_BROKER_URL=redis://localhost:6379/0 - DISPATCHARR_LOG_LEVEL=trace + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features + #- USE_LEGACY_NUMPY=true # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) # Negative values require cap_add: SYS_NICE (uncomment below) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index d1bb3680..b20c3296 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -17,6 +17,10 @@ services: - REDIS_HOST=localhost - CELERY_BROKER_URL=redis://localhost:6379/0 - DISPATCHARR_LOG_LEVEL=debug + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features + #- USE_LEGACY_NUMPY=true # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) # Negative values require cap_add: SYS_NICE (uncomment below) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index aaa63990..bb4397d4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,11 @@ +# Dispatcharr - Modular Deployment Configuration +# This compose file runs Dispatcharr in modular mode with separate containers +# for web, celery workers, PostgreSQL database, and Redis cache. + services: + # ============================================================================ + # Web Service + # ============================================================================ web: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web @@ -9,64 +16,126 @@ services: depends_on: - db - redis + + # --- Environment Configuration --- environment: + # Deployment Mode + - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db + - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - - CELERY_BROKER_URL=redis://redis:6379/0 + - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info + + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features: + #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) - # Negative values require cap_add: SYS_NICE (uncomment below) + # Negative values require cap_add: SYS_NICE (see below) #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) - #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority) - # + + # --- Advanced Configuration --- # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - # Optional for hardware acceleration + + # --- Hardware Acceleration (Optional) --- + # Uncomment for GPU access (transcoding acceleration) #group_add: # - video - # #- render # Uncomment if your GPU requires it + # #- render # Uncomment if your GPU requires it #devices: # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) + + # NVIDIA GPU Support (requires NVIDIA Container Toolkit) # Uncomment the following lines for NVIDIA GPU support - # NVidia GPU support (requires NVIDIA Container Toolkit) #deploy: # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + # ============================================================================ + # Celery Service - Background task worker + # ============================================================================ celery: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery depends_on: - db - redis + - web volumes: - - ../:/app + - ./data:/data extra_hosts: - "host.docker.internal:host-gateway" + entrypoint: ["/app/docker/entrypoint.celery.sh"] + + # --- Environment Configuration --- environment: + # Deployment Mode + - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db + - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret - - REDIS_HOST=redis - - CELERY_BROKER_URL=redis://redis:6379/0 - command: > - bash -c " - cd /app && - nice -n 5 celery -A dispatcharr worker -l info - " + # Redis Connection + - REDIS_HOST=redis + - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging + - DISPATCHARR_LOG_LEVEL=info + + # Process Priority Configuration (Optional) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features: + #- USE_LEGACY_NUMPY=true + + # Django Configuration + - DJANGO_SETTINGS_MODULE=dispatcharr.settings + - PYTHONUNBUFFERED=1 + + # --- Advanced Configuration --- + # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0) + #cap_add: + # - SYS_NICE + + # ============================================================================ + # PostgreSQL + # ============================================================================ db: - image: postgres:14 + image: postgres:17 container_name: dispatcharr_db ports: - "5436:5432" @@ -76,10 +145,48 @@ services: - POSTGRES_PASSWORD=secret volumes: - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatcharr"] + interval: 5s + timeout: 5s + retries: 5 + # ============================================================================ + # Redis + # ============================================================================ redis: image: redis:latest container_name: dispatcharr_redis + # --- Authentication Configuration (Optional) --- + # By default, Redis runs without authentication. + # Choose ONE of the following options if authentication is required: + + # Option 1: Password-only authentication (Redis <6 or default user) + #command: ["redis-server", "--requirepass", "your_strong_redis_password"] + + # Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration) + #command: ["redis-server", "/etc/redis/redis.conf"] + #volumes: + # - ./redis.conf:/etc/redis/redis.conf:ro + + # --- Health Check Configuration --- + healthcheck: + # Default: No authentication + test: ["CMD", "redis-cli", "ping"] + + # If using Option 1 (password-only), uncomment this instead: + #test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"] + + # If using Option 2 (Redis 6+ ACL), uncomment this instead: + #test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"] + + interval: 5s + timeout: 5s + retries: 5 + +# ============================================================================== +# Volumes +# ============================================================================== volumes: postgres_data: diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh new file mode 100644 index 00000000..e5c2f340 --- /dev/null +++ b/docker/entrypoint.celery.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +cd /app +source /dispatcharrpy/bin/activate + +# Function to echo with timestamp +echo_with_timestamp() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +# Wait for Django secret key +echo 'Waiting for Django secret key...' +while [ ! -f /data/jwt ]; do sleep 1; done +export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)" + +# --- NumPy version switching for legacy hardware --- +if [ "$USE_LEGACY_NUMPY" = "true" ]; then + # Check if NumPy was compiled with baseline support + if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then + echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." + uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl + echo_with_timestamp "✅ Legacy NumPy installed" + else + echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" + fi +fi + +# Wait for migrations to complete (check that NO unapplied migrations remain) +echo 'Waiting for migrations to complete...' +until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do + echo_with_timestamp 'Migrations not ready yet, waiting...' + sleep 2 +done + +# Start Celery +echo 'Migrations complete, starting Celery...' +celery -A dispatcharr beat -l info & + +# Default to nice level 5 (lower priority) - safe for unprivileged containers +# Negative values require SYS_NICE capability +NICE_LEVEL="${CELERY_NICE_LEVEL:-5}" +if [ "$NICE_LEVEL" -lt 0 ] 2>/dev/null; then + echo "Warning: CELERY_NICE_LEVEL=$NICE_LEVEL is negative, requires SYS_NICE capability" +fi +nice -n "$NICE_LEVEL" celery -A dispatcharr worker -l info --autoscale=6,1 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fa0eea01..b79af952 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -36,10 +36,28 @@ export POSTGRES_PORT=${POSTGRES_PORT:-5432} export PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1) export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" export REDIS_HOST=${REDIS_HOST:-localhost} +export REDIS_PORT=${REDIS_PORT:-6379} export REDIS_DB=${REDIS_DB:-0} +export REDIS_PASSWORD=${REDIS_PASSWORD:-} +export REDIS_USER=${REDIS_USER:-} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' export LD_LIBRARY_PATH='/usr/local/lib' +export SECRET_FILE="/data/jwt" +# Ensure Django secret key exists or generate a new one +if [ ! -f "$SECRET_FILE" ]; then + echo "Generating new Django secret key..." + old_umask=$(umask) + umask 077 + tmpfile="$(mktemp "${SECRET_FILE}.XXXXXX")" || { echo "mktemp failed"; exit 1; } + python3 - <<'PY' >"$tmpfile" || { echo "secret generation failed"; rm -f "$tmpfile"; exit 1; } +import secrets +print(secrets.token_urlsafe(64)) +PY + mv -f "$tmpfile" "$SECRET_FILE" || { echo "move failed"; rm -f "$tmpfile"; exit 1; } + umask $old_umask +fi +export DJANGO_SECRET_KEY="$(tr -d '\r\n' < "$SECRET_FILE")" # Process priority configuration # UWSGI_NICE_LEVEL: Absolute nice value for uWSGI/streaming (default: 0 = normal priority) @@ -85,12 +103,12 @@ export POSTGRES_DIR=/data/db if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then # Define all variables to process variables=( - PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED + PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH - CELERY_NICE_LEVEL UWSGI_NICE_LEVEL + CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) # Process each variable for both profile.d and environment @@ -123,25 +141,60 @@ fi # Run init scripts echo "Starting user setup..." . /app/docker/init/01-user-setup.sh + +# Initialize PostgreSQL (script handles modular vs internal mode internally) echo "Setting up PostgreSQL..." . /app/docker/init/02-postgres.sh + echo "Starting init process..." . /app/docker/init/03-init-dispatcharr.sh -# Start PostgreSQL -echo "Starting Postgres..." -su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" -# Wait for PostgreSQL to be ready -until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do - echo_with_timestamp "Waiting for PostgreSQL to be ready..." - sleep 1 -done -postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') -echo "✅ Postgres started with PID $postgres_pid" -pids+=("$postgres_pid") +# Start PostgreSQL if NOT in modular mode (using external database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + echo "Starting Postgres..." + su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" + # Wait for PostgreSQL to be ready + until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL to be ready..." + sleep 1 + done + postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') + echo "✅ Postgres started with PID $postgres_pid" + pids+=("$postgres_pid") +else + echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}" + # Wait for external PostgreSQL to be ready using pg_isready (checks actual protocol readiness) + echo_with_timestamp "Waiting for external PostgreSQL to be ready..." + until $PG_BINDIR/pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -q >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." + sleep 1 + done + echo "✅ External PostgreSQL is ready" -# Ensure database encoding is UTF8 -. /app/docker/init/02-postgres.sh + # Check PostgreSQL version compatibility + check_external_postgres_version || exit 1 +fi + +# Wait for Redis to be ready (modular mode uses external Redis) +if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}" + echo_with_timestamp "Waiting for external Redis to be ready..." + until python3 -c " +import socket, sys +try: + s = socket.create_connection(('${REDIS_HOST}', ${REDIS_PORT}), timeout=2) + s.close() + sys.exit(0) +except Exception: + sys.exit(1) +" 2>/dev/null; do + echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..." + sleep 1 + done + echo "✅ External Redis is ready" +fi + +# Ensure database encoding is UTF8 (handles both internal and external databases) ensure_utf8_encoding if [[ "$DISPATCHARR_ENV" = "dev" ]]; then @@ -159,9 +212,22 @@ else pids+=("$nginx_pid") fi -cd /app -python manage.py migrate --noinput -python manage.py collectstatic --noinput + +# --- NumPy version switching for legacy hardware --- +if [ "$USE_LEGACY_NUMPY" = "true" ]; then + # Check if NumPy was compiled with baseline support + if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then + echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." + uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl + echo_with_timestamp "✅ Legacy NumPy installed" + else + echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" + fi +fi + +# Run Django commands as non-root user to prevent permission issues +su - $POSTGRES_USER -c "cd /app && python manage.py migrate --noinput" +su - $POSTGRES_USER -c "cd /app && python manage.py collectstatic --noinput" # Select proper uwsgi config based on environment if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then @@ -170,6 +236,9 @@ if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then elif [ "$DISPATCHARR_DEBUG" = "true" ]; then echo "🚀 Starting uwsgi in debug mode..." uwsgi_file="/app/docker/uwsgi.debug.ini" +elif [ "$DISPATCHARR_ENV" = "modular" ]; then + echo "🚀 Starting uwsgi in modular mode..." + uwsgi_file="/app/docker/uwsgi.modular.ini" else echo "🚀 Starting uwsgi in production mode..." uwsgi_file="/app/docker/uwsgi.ini" @@ -187,7 +256,7 @@ fi # Users can override via UWSGI_NICE_LEVEL environment variable in docker-compose # Start with nice as root, then use setpriv to drop privileges to dispatch user # This preserves both the nice value and environment variables -nice -n $UWSGI_NICE_LEVEL su -p - "$POSTGRES_USER" -c "cd /app && exec uwsgi $uwsgi_args" & uwsgi_pid=$! +nice -n $UWSGI_NICE_LEVEL su - "$POSTGRES_USER" -c "cd /app && exec $VIRTUAL_ENV/bin/uwsgi $uwsgi_args" & uwsgi_pid=$! echo "✅ uwsgi started with PID $uwsgi_pid (nice $UWSGI_NICE_LEVEL)" pids+=("$uwsgi_pid") diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh index d7041265..1b0e0a27 100644 --- a/docker/init/01-user-setup.sh +++ b/docker/init/01-user-setup.sh @@ -6,17 +6,17 @@ export PGID=${PGID:-1000} # Check if group with PGID exists if getent group "$PGID" >/dev/null 2>&1; then - # Group exists, check if it's named 'dispatch' + # Group exists, check if it's named correctly (should match POSTGRES_USER) existing_group=$(getent group "$PGID" | cut -d: -f1) - if [ "$existing_group" != "dispatch" ]; then - # Rename the existing group to 'dispatch' - groupmod -n "dispatch" "$existing_group" - echo "Group $existing_group with GID $PGID renamed to dispatch" + if [ "$existing_group" != "$POSTGRES_USER" ]; then + # Rename the existing group to match POSTGRES_USER + groupmod -n "$POSTGRES_USER" "$existing_group" + echo "Group $existing_group with GID $PGID renamed to $POSTGRES_USER" fi else - # Group doesn't exist, create it - groupadd -g "$PGID" dispatch - echo "Group dispatch with GID $PGID created" + # Group doesn't exist, create it with same name as POSTGRES_USER + groupadd -g "$PGID" "$POSTGRES_USER" + echo "Group $POSTGRES_USER with GID $PGID created" fi # Create user if it doesn't exist @@ -86,5 +86,5 @@ if getent group video >/dev/null 2>&1; then fi fi -# Run nginx as specified user -sed -i "s/user www-data;/user $POSTGRES_USER;/g" /etc/nginx/nginx.conf +# Run nginx as specified user (replace any existing user directive on line 1) +sed -i "1s/^user .*/user $POSTGRES_USER;/" /etc/nginx/nginx.conf diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 233a5fb8..353a9beb 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -1,123 +1,127 @@ #!/bin/bash -# Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove -# some time in the future. -if [ -e "/data/postgresql.conf" ]; then - echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..." - # Create a temporary directory outside of /data - mkdir -p /tmp/postgres_migration +# Skip internal PostgreSQL setup in modular mode (using external database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then - # Move the PostgreSQL files to the temporary directory - mv /data/* /tmp/postgres_migration/ + # Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove + # some time in the future. + if [ -e "/data/postgresql.conf" ]; then + echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..." - # Create the target directory - mkdir -p $POSTGRES_DIR + # Create a temporary directory outside of /data + mkdir -p /tmp/postgres_migration - # Move the files from temporary directory to the final location - mv /tmp/postgres_migration/* $POSTGRES_DIR/ + # Move the PostgreSQL files to the temporary directory + mv /data/* /tmp/postgres_migration/ - # Clean up the temporary directory - rmdir /tmp/postgres_migration + # Create the target directory + mkdir -p $POSTGRES_DIR - # Set proper ownership and permissions for PostgreSQL data directory - chown -R postgres:postgres $POSTGRES_DIR - chmod 700 $POSTGRES_DIR + # Move the files from temporary directory to the final location + mv /tmp/postgres_migration/* $POSTGRES_DIR/ - echo "Migration completed successfully." -fi + # Clean up the temporary directory + rmdir /tmp/postgres_migration -PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION" + # Set proper ownership and permissions for PostgreSQL data directory + chown -R postgres:postgres $POSTGRES_DIR + chmod 700 $POSTGRES_DIR -# Detect current version from data directory, if present -if [ -f "$PG_VERSION_FILE" ]; then - CURRENT_VERSION=$(cat "$PG_VERSION_FILE") -else - CURRENT_VERSION="" -fi + echo "Migration completed successfully." + fi -# Only run upgrade if current version is set and not the target -if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then - echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..." - # Set binary paths for upgrade if needed - OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin" - NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" - PG_INSTALLED_BY_SCRIPT=0 - if [ ! -d "$OLD_BINDIR" ]; then - echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..." - apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION - if [ $? -ne 0 ]; then - echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting." - exit 1 + PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION" + + # Detect current version from data directory, if present + if [ -f "$PG_VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$PG_VERSION_FILE") + else + CURRENT_VERSION="" + fi + + # Only run upgrade if current version is set and not the target + if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then + echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..." + # Set binary paths for upgrade if needed + OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin" + NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" + PG_INSTALLED_BY_SCRIPT=0 + if [ ! -d "$OLD_BINDIR" ]; then + echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..." + apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION + if [ $? -ne 0 ]; then + echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting." + exit 1 + fi + PG_INSTALLED_BY_SCRIPT=1 + fi + + # Prepare new data directory + NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION" + + # Remove new data directory if it already exists (from a failed/partial upgrade) + if [ -d "$NEW_POSTGRES_DIR" ]; then + echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues." + rm -rf "$NEW_POSTGRES_DIR" + fi + + mkdir -p "$NEW_POSTGRES_DIR" + chown -R postgres:postgres "$NEW_POSTGRES_DIR" + chmod 700 "$NEW_POSTGRES_DIR" + + # Initialize new data directory + echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..." + su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR" + echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..." + # Run pg_upgrade + su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR" + + # Move old data directory for backup, move new into place + mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)" + mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR" + + echo "Upgrade complete. Old data directory backed up." + + # Uninstall PostgreSQL if we installed it just for upgrade + if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then + echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..." + apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION + apt autoremove -y fi - PG_INSTALLED_BY_SCRIPT=1 fi - # Prepare new data directory - NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION" + # Initialize PostgreSQL database + if [ -z "$(ls -A $POSTGRES_DIR)" ]; then + echo "Initializing PostgreSQL database..." + mkdir -p $POSTGRES_DIR + chown -R postgres:postgres $POSTGRES_DIR + chmod 700 $POSTGRES_DIR - # Remove new data directory if it already exists (from a failed/partial upgrade) - if [ -d "$NEW_POSTGRES_DIR" ]; then - echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues." - rm -rf "$NEW_POSTGRES_DIR" - fi + # Initialize PostgreSQL + su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}" + # Configure PostgreSQL + echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf" + echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf" - mkdir -p "$NEW_POSTGRES_DIR" - chown -R postgres:postgres "$NEW_POSTGRES_DIR" - chmod 700 "$NEW_POSTGRES_DIR" + # Start PostgreSQL + echo "Starting Postgres..." + su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" + # Wait for PostgreSQL to be ready + until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo "Waiting for PostgreSQL to be ready..." + sleep 1 + done - # Initialize new data directory - echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..." - su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR" - echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..." - # Run pg_upgrade - su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR" + postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') - # Move old data directory for backup, move new into place - mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)" - mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR" - - echo "Upgrade complete. Old data directory backed up." - - # Uninstall PostgreSQL if we installed it just for upgrade - if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then - echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..." - apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION - apt autoremove -y - fi -fi - -# Initialize PostgreSQL database -if [ -z "$(ls -A $POSTGRES_DIR)" ]; then - echo "Initializing PostgreSQL database..." - mkdir -p $POSTGRES_DIR - chown -R postgres:postgres $POSTGRES_DIR - chmod 700 $POSTGRES_DIR - - # Initialize PostgreSQL - su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}" - # Configure PostgreSQL - echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf" - echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf" - - # Start PostgreSQL - echo "Starting Postgres..." - su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" - # Wait for PostgreSQL to be ready - until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do - echo "Waiting for PostgreSQL to be ready..." - sleep 1 - done - - postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') - - # Setup database if needed - if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then - # Create PostgreSQL database - echo "Creating PostgreSQL database..." - su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}" - # Create user, set ownership, and grant privileges - echo "Creating PostgreSQL user..." - su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" </dev/null | tr -d ' ') + else + # Internal database: use Unix socket as postgres user + CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();\"" | tr -d ' ') + fi + if [ "$CURRENT_ENCODING" != "UTF8" ]; then echo "Database $POSTGRES_DB encoding is $CURRENT_ENCODING, converting to UTF8..." DUMP_FILE="/tmp/${POSTGRES_DB}_utf8_dump_$(date +%s).sql" - # Dump database (include permissions and ownership) - su - postgres -c "pg_dump -p ${POSTGRES_PORT} $POSTGRES_DB > $DUMP_FILE" - # Drop and recreate database with UTF8 encoding using template0 - su - postgres -c "dropdb -p ${POSTGRES_PORT} $POSTGRES_DB" - # Recreate database with UTF8 encoding - su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 ${POSTGRES_DB}" - - - # Restore data - su - postgres -c "psql -p ${POSTGRES_PORT} -d $POSTGRES_DB < $DUMP_FILE" - #configure_db + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + # External database: use TCP connection with password + # Dump database (include permissions and ownership) + PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } + # Drop and recreate database with UTF8 encoding using template0 + PGPASSWORD="$POSTGRES_PASSWORD" dropdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" || { echo "Drop failed"; return 1; } + # Recreate database with UTF8 encoding + PGPASSWORD="$POSTGRES_PASSWORD" createdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" --encoding=UTF8 --template=template0 "$POSTGRES_DB" || { echo "Create failed"; return 1; } + # Restore data + PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$DUMP_FILE" || { echo "Restore failed"; return 1; } + else + # Internal database: use Unix socket as postgres user + # Dump database (include permissions and ownership) + su - postgres -c "pg_dump -p ${POSTGRES_PORT} ${POSTGRES_DB}" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } + # Drop and recreate database with UTF8 encoding using template0 + su - postgres -c "dropdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" || { echo "Drop failed"; return 1; } + # Recreate database with UTF8 encoding and correct owner + su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 --owner=${POSTGRES_USER} ${POSTGRES_DB}" || { echo "Create failed"; return 1; } + # Restore data + cat "$DUMP_FILE" | su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" || { echo "Restore failed"; return 1; } + fi rm -f "$DUMP_FILE" - echo "Database $POSTGRES_DB converted to UTF8 and permissions set." + echo "✅ Database $POSTGRES_DB converted to UTF8." + else + echo "✅ Database encoding is UTF8" fi } + +check_external_postgres_version() { + # Only check for modular deployments + if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + return 0 + fi + + echo "🔍 Checking external PostgreSQL version compatibility..." + + # Get minimum required version from base image (set in entrypoint.sh) + # PG_VERSION is from DispatcharrBase + MIN_REQUIRED_VERSION=$PG_VERSION + + # Query external PostgreSQL version + EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "postgres" -tAc "SHOW server_version;" 2>/dev/null | grep -oE '^[0-9]+') + + if [ -z "$EXTERNAL_VERSION" ]; then + echo "❌ ERROR: Unable to determine external PostgreSQL version" + echo " Could not connect to database at ${POSTGRES_HOST}:${POSTGRES_PORT}" + echo " Please verify your database connection settings." + return 1 + fi + + # Compare versions + if [[ "$EXTERNAL_VERSION" -lt "$MIN_REQUIRED_VERSION" ]]; then + # FAIL: Version too old + echo "" + echo "❌ ERROR: PostgreSQL version mismatch" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " External Database: PostgreSQL $EXTERNAL_VERSION" + echo " Required Version: PostgreSQL $MIN_REQUIRED_VERSION or higher" + echo "" + echo " Your external PostgreSQL database is too old for Dispatcharr." + echo " Please upgrade to PostgreSQL $MIN_REQUIRED_VERSION or higher." + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + return 1 + + elif [[ "$EXTERNAL_VERSION" -eq "$MIN_REQUIRED_VERSION" ]]; then + # MATCH: Exact version match + echo "✅ PostgreSQL version check passed" + echo " External Database: PostgreSQL $EXTERNAL_VERSION (matches target version)" + + else + # HIGHER: Newer version + echo "✅ PostgreSQL version check passed" + echo " External Database: PostgreSQL $EXTERNAL_VERSION" + echo " Target Version: PostgreSQL $MIN_REQUIRED_VERSION" + echo " ℹ️ Your database is newer than the target version." + echo " PostgreSQL version should be compatible with Dispatcharr." + fi + + return 0 +} diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index bfe081e2..c0a2d634 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -15,6 +15,7 @@ DATA_DIRS=( APP_DIRS=( "/app/logo_cache" "/app/media" + "/app/static" ) # Create all directories @@ -29,9 +30,21 @@ if [ "$(id -u)" = "0" ] && [ -d "/app" ]; then chown $PUID:$PGID /app fi fi - +# Configure nginx port +if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then + echo "⚠️ Warning: DISPATCHARR_PORT is not a valid integer, using default port 9191" + DISPATCHARR_PORT=9191 +fi sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default +# Configure nginx based on IPv6 availability +if ip -6 addr show | grep -q "inet6"; then + echo "✅ IPv6 is available, enabling IPv6 in nginx" +else + echo "⚠️ IPv6 not available, disabling IPv6 in nginx" + sed -i '/listen \[::\]:/d' /etc/nginx/sites-enabled/default +fi + # NOTE: mac doesn't run as root, so only manage permissions # if this script is running as root if [ "$(id -u)" = "0" ]; then diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh index fc8ef18a..d2e6dd0d 100644 --- a/docker/init/99-init-dev.sh +++ b/docker/init/99-init-dev.sh @@ -16,13 +16,13 @@ if [ ! -e "/tmp/init" ]; then # Install frontend dependencies cd /app/frontend && npm install - # Install pip dependencies - cd /app && pip install -r requirements.txt + # Install Python dependencies using UV + cd /app && uv sync --python $UV_PROJECT_ENVIRONMENT/bin/python --no-install-project --no-dev # Install debugpy for remote debugging if [ "$DISPATCHARR_DEBUG" = "true" ]; then echo "=== setting up debugpy ===" - pip install debugpy + uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python debugpy fi if [[ "$DISPATCHARR_ENV" = "dev" ]]; then diff --git a/docker/nginx.conf b/docker/nginx.conf index 020bc99a..e08d08f2 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -35,6 +35,13 @@ server { root /data; } + # Internal location for X-Accel-Redirect backup downloads + # Django handles auth, nginx serves the file directly + location /protected-backups/ { + internal; + alias /data/backups/; + } + location /api/logos/(?\d+)/cache/ { proxy_pass http://127.0.0.1:5656; proxy_cache logo_cache; @@ -52,7 +59,7 @@ server { } # admin disabled when not in dev mode - location /admin { + location ~ ^/admin/?$ { return 301 /login; } diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini index 3de890a5..69c040f2 100644 --- a/docker/uwsgi.debug.ini +++ b/docker/uwsgi.debug.ini @@ -20,7 +20,6 @@ module = scripts.debug_wrapper:application virtualenv = /dispatcharrpy master = true env = DJANGO_SETTINGS_MODULE=dispatcharr.settings - socket = /app/uwsgi.sock chmod-socket = 777 vacuum = true diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index f8fe8ab7..920bac48 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -21,6 +21,7 @@ module = dispatcharr.wsgi:application virtualenv = /dispatcharrpy master = true env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +env = USE_NGINX_ACCEL=true socket = /app/uwsgi.sock chmod-socket = 777 vacuum = true @@ -36,6 +37,7 @@ http-keepalive = 1 buffer-size = 65536 # Increase buffer for large payloads post-buffering = 4096 # Reduce buffering for real-time streaming http-timeout = 600 # Prevent disconnects from long streams +socket-timeout = 600 # Prevent write timeouts when client buffers lazy-apps = true # Improve memory efficiency # Async mode (use gevent for high concurrency) @@ -57,4 +59,4 @@ logformat-strftime = true log-date = %%Y-%%m-%%d %%H:%%M:%%S,000 # Use formatted time with environment variable for log level log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms -log-buffering = 1024 # Add buffer size limit for logging \ No newline at end of file +log-buffering = 1024 # Add buffer size limit for logging diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini new file mode 100644 index 00000000..3220a8d8 --- /dev/null +++ b/docker/uwsgi.modular.ini @@ -0,0 +1,59 @@ +[uwsgi] +; Modular deployment mode - external PostgreSQL, Redis, and Celery +; Remove file creation commands since we're not logging to files anymore +; exec-pre = mkdir -p /data/logs +; exec-pre = touch /data/logs/uwsgi.log +; exec-pre = chmod 666 /data/logs/uwsgi.log + +; First run Redis availability check script once +exec-pre = python /app/scripts/wait_for_redis.py + +; Start Daphne for WebSocket support (required for real-time features) +; Redis and Celery run in separate containers in modular mode +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application + +# Core settings +chdir = /app +module = dispatcharr.wsgi:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +env = USE_NGINX_ACCEL=true +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true +static-map = /static=/app/static + +# Worker management +workers = 4 + +# Optimize for streaming +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 # Increase buffer for large payloads +post-buffering = 4096 # Reduce buffering for real-time streaming +http-timeout = 600 # Prevent disconnects from long streams +socket-timeout = 600 # Prevent write timeouts when client buffers +lazy-apps = true # Improve memory efficiency + +# Async mode (use gevent for high concurrency) +gevent = 400 # Each unused greenlet costs ~2-4KB of memory +# Higher values have minimal performance impact when idle, but provide capacity for traffic spikes +# If memory usage becomes an issue, reduce this value + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false + +# Logging configuration +# Enable console logging (stdout) +log-master = true +# Enable strftime formatting for timestamps +logformat-strftime = true +log-date = %%Y-%%m-%%d %%H:%%M:%%S,000 +# Use formatted time with environment variable for log level +log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms +log-buffering = 1024 # Add buffer size limit for logging diff --git a/docs/images/channels.png b/docs/images/channels.png new file mode 100644 index 00000000..87236f1f Binary files /dev/null and b/docs/images/channels.png differ diff --git a/docs/images/guide.png b/docs/images/guide.png new file mode 100644 index 00000000..37cfa132 Binary files /dev/null and b/docs/images/guide.png differ diff --git a/docs/images/m3u-epg-manager.png b/docs/images/m3u-epg-manager.png new file mode 100644 index 00000000..34bea87d Binary files /dev/null and b/docs/images/m3u-epg-manager.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png new file mode 100644 index 00000000..9106fbe5 Binary files /dev/null and b/docs/images/settings.png differ diff --git a/docs/images/stats.png b/docs/images/stats.png new file mode 100644 index 00000000..a664bb7d Binary files /dev/null and b/docs/images/stats.png differ diff --git a/docs/images/vod-library.png b/docs/images/vod-library.png new file mode 100644 index 00000000..6effdb70 Binary files /dev/null and b/docs/images/vod-library.png differ diff --git a/fixtures.json b/fixtures.json index 2d42f84e..3c31f926 100644 --- a/fixtures.json +++ b/fixtures.json @@ -36,7 +36,7 @@ "model": "core.streamprofile", "pk": 1, "fields": { - "profile_name": "ffmpeg", + "profile_name": "FFmpeg", "command": "ffmpeg", "parameters": "-i {streamUrl} -c:a copy -c:v copy -f mpegts pipe:1", "is_active": true, @@ -46,13 +46,23 @@ { "model": "core.streamprofile", "fields": { - "profile_name": "streamlink", + "profile_name": "Streamlink", "command": "streamlink", "parameters": "{streamUrl} best --stdout", "is_active": true, "user_agent": "1" } }, + { + "model": "core.streamprofile", + "fields": { + "profile_name": "VLC", + "command": "cvlc", + "parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", + "is_active": true, + "user_agent": "1" + } + }, { "model": "core.coresettings", "fields": { diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ec2b712d..b7837cf2 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,7 +1,7 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; export default [ { ignores: ['dist'] }, @@ -30,4 +30,4 @@ export default [ ], }, }, -] +]; diff --git a/frontend/index.html b/frontend/index.html index 86207098..d2f6a575 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,10 +4,24 @@ - - - - + + + + Dispatcharr diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 780aabe1..9b16acc8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "@mantine/charts": "~8.0.1", "@mantine/core": "~8.0.1", "@mantine/dates": "~8.0.1", @@ -22,13 +23,13 @@ "@tanstack/react-table": "^8.21.2", "allotment": "^1.20.4", "dayjs": "^1.11.13", - "formik": "^2.4.6", "hls.js": "^1.5.20", "lucide-react": "^0.511.0", "mpegts.js": "^1.8.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-draggable": "^4.4.6", + "react-hook-form": "^7.70.0", "react-pro-sidebar": "^1.1.0", "react-router-dom": "^7.3.0", "react-virtualized": "^9.22.6", @@ -50,16 +51,23 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react-swc": "^4.1.0", - "eslint": "^9.21.0", + "eslint": "^9.27.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", "jsdom": "^27.0.0", "prettier": "^3.5.3", - "vite": "^6.2.0", + "vite": "^7.1.7", "vitest": "^3.2.4" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -68,30 +76,31 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.4.tgz", - "integrity": "sha512-cKjSKvWGmAziQWbCouOsFwb14mp1betm8Y7Fn+yglDMUUu3r9DCbJ9iJbeFDenLMqFbIMC0pQP8K+B8LAxX3OQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.1.0" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.5.tgz", - "integrity": "sha512-kI2MX9pmImjxWT8nxDZY+MuN6r1jJGe7WxizEbsAEPB/zxfW5wYLIiPG1v3UKgEOOP8EsDkp0ZL99oRFAdPM8g==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1" + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -102,73 +111,82 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/generator": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", - "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.10", - "@babel/types": "^7.26.10", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.10" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -178,75 +196,63 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz", - "integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -260,13 +266,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -280,17 +286,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -304,21 +310,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -332,16 +338,16 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", - "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", "dev": true, "funding": [ { @@ -353,18 +359,12 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } + "license": "MIT-0" }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -378,7 +378,7 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@dnd-kit/accessibility": { @@ -487,9 +487,9 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" @@ -545,9 +545,9 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -595,9 +595,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", - "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -612,9 +612,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", - "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -629,9 +629,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", - "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -646,9 +646,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", - "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -663,9 +663,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", - "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -680,9 +680,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", - "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -697,9 +697,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", - "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -714,9 +714,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", - "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -731,9 +731,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", - "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -748,9 +748,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", - "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -765,9 +765,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", - "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -782,9 +782,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", - "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -799,9 +799,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", - "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -816,9 +816,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", - "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -833,9 +833,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", - "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -850,9 +850,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", - "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -867,9 +867,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", - "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -884,9 +884,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", - "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -901,9 +901,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", - "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -918,9 +918,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", - "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -935,9 +935,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", - "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -951,10 +951,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", - "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -969,9 +986,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", - "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -986,9 +1003,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", - "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1003,9 +1020,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", - "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1020,9 +1037,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.0.tgz", - "integrity": "sha512-RoV8Xs9eNwiDvhv7M+xcL4PWyRyIXRY/FLp3buU4h1EYfdF7unWUy3dOjPqb3C7rMUewIcqwW850PgS8h1o1yg==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1052,9 +1069,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -1062,13 +1079,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1077,19 +1094,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", - "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1100,9 +1120,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1112,7 +1132,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -1137,19 +1157,22 @@ } }, "node_modules/@eslint/js": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", - "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1157,35 +1180,53 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, @@ -1205,12 +1246,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -1223,6 +1264,18 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1234,33 +1287,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1276,9 +1315,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1290,17 +1329,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1312,15 +1347,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1328,38 +1354,32 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "license": "Apache-2.0" - }, "node_modules/@mantine/charts": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.0.1.tgz", - "integrity": "sha512-yntk4siXpQGSj83tDwftJw6fHTOBS6c/VWinjvTW29ptEdjBCxbKFfyyDc9UGVVuO7ovbdtpfCZBpuN2I7HPCA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.0.2.tgz", + "integrity": "sha512-hVS1+CT+7e3+ZbU1xx7Nyx/5ZBSxzS+68SKeVLeOZPGl9Wx35CY1oLn0n53vQPWV2WFKd0u0Bq3d1iuaDpkzGA==", "license": "MIT", "peerDependencies": { - "@mantine/core": "8.0.1", - "@mantine/hooks": "8.0.1", + "@mantine/core": "8.0.2", + "@mantine/hooks": "8.0.2", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x", "recharts": "^2.13.3" } }, "node_modules/@mantine/core": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.0.1.tgz", - "integrity": "sha512-4ezaxKjChSPtawamQ3KrJq+x506uTouXlL0Z5fP+t105KnyxMrAJUENhbh2ivD4pq9Zh1BFiD9IWzyu3IXFR8w==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.0.2.tgz", + "integrity": "sha512-2Ps7bRTeTbRwAKTCL9xdflPz0pwOlTq6ohyTbDZMCADqecf09GHI7GiX+HJatqbPZ2t8jK0fN1b48YhjJaxTqg==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.26.28", @@ -1370,46 +1390,46 @@ "type-fest": "^4.27.0" }, "peerDependencies": { - "@mantine/hooks": "8.0.1", + "@mantine/hooks": "8.0.2", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/dates": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.1.tgz", - "integrity": "sha512-YCmV5jiGE9Ts2uhNS217IA1Hd5kAa8oaEtfnU0bS1sL36zKEf2s6elmzY718XdF8tFil0jJWAj0jiCrA3/udMg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.2.tgz", + "integrity": "sha512-V1xU00gECfykA4UFln8ulPsPHvaTncsg9zUbzCwqwEAYlZFG3Nnj5eBzzpV3IN1LNDPEVGb1gAOM6jZ+fi2uRQ==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { - "@mantine/core": "8.0.1", - "@mantine/hooks": "8.0.1", + "@mantine/core": "8.0.2", + "@mantine/hooks": "8.0.2", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/dropzone": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz", - "integrity": "sha512-8PH5yrtA/ebCIwjs0m4J9qOvEyS/P4XmNlHrw0E389/qq64Ol7+/ZH7Xtiq64IaY8kvsMW1XHaV0c+bdYrijiA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.2.tgz", + "integrity": "sha512-dWsz99QjWOQy7wDx4zzvBrPQ6l3201kg0iugk2Dm+MmN9mlboychz/LIZzoCGsodtQRLAsoTlN2zOqhsiggRfw==", "license": "MIT", "dependencies": { "react-dropzone": "14.3.8" }, "peerDependencies": { - "@mantine/core": "8.0.1", - "@mantine/hooks": "8.0.1", + "@mantine/core": "8.0.2", + "@mantine/hooks": "8.0.2", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/form": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.0.1.tgz", - "integrity": "sha512-lQ94gn/9p60C+tKEW7psQ1tZHod58Q0bXLbRDadRKMwnqBb2WFoIuaQWPDo7ox+PqyOv28dtflgS+Lm95EbBhg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.0.2.tgz", + "integrity": "sha512-vSp9BfrhC9o7RMRYMaND2UAflXO4i6c5F1qPkiM2FID6ye2RJxW8YHaGa3kA0VfBbhDw9sFBbl8p7ttE4RPzcw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1420,34 +1440,34 @@ } }, "node_modules/@mantine/hooks": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.0.1.tgz", - "integrity": "sha512-GvLdM4Ro3QcDyIgqrdXsUZmeeKye2TNL/k3mEr9JhM5KacHQjr83JPp0u9eLobn7kiyBqpLTYmVYAbmjJdCxHw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.0.2.tgz", + "integrity": "sha512-0jpEdC0KIAZ54D5kd9rJudrEm6vkvnrL9yYHnkuNbxokXSzDdYA/wpHnKR5WW+u6fW4JF6A6A7gN1vXKeC9MSw==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" } }, "node_modules/@mantine/notifications": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.0.1.tgz", - "integrity": "sha512-7TX9OyAmUcok3qffnheS7gTAMKDczETy8XEYDr38Sy/XIoXLjM+3CwO+a/vfd1F9oW2LvkahkHT0Ey+vBOVd0Q==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.0.2.tgz", + "integrity": "sha512-whSuoCCZxQF3VM40sumCte9tA79to8OCV/vv0z8PeVTj/eKlaTR+P9LKigO9ovhuNELrvvO3Rxcnno5aMBz0oA==", "license": "MIT", "dependencies": { - "@mantine/store": "8.0.1", + "@mantine/store": "8.0.2", "react-transition-group": "4.4.5" }, "peerDependencies": { - "@mantine/core": "8.0.1", - "@mantine/hooks": "8.0.1", + "@mantine/core": "8.0.2", + "@mantine/hooks": "8.0.2", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/store": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.0.1.tgz", - "integrity": "sha512-3wfUDeiERXJEI+MGgRAbh+9aY35D9oE4UzquLqZh8cIiH5i5g64Y/eJx3PfjHgO5+Zeu6lbgTgL6k4lg4a2SBQ==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.0.2.tgz", + "integrity": "sha512-/LuizGWAXjVnLLZ55f0QYotiqb8GlHpIb4KRf4LqRkbsA6UAZEVb6beuk0vI2Azf6vfuh7sTHu1xVC5zI6C+Cw==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" @@ -1464,16 +1484,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.35", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", - "integrity": "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz", - "integrity": "sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", "cpu": [ "arm" ], @@ -1485,9 +1505,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.35.0.tgz", - "integrity": "sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", "cpu": [ "arm64" ], @@ -1499,9 +1519,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.35.0.tgz", - "integrity": "sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", "cpu": [ "arm64" ], @@ -1513,9 +1533,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.35.0.tgz", - "integrity": "sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", "cpu": [ "x64" ], @@ -1527,9 +1547,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.35.0.tgz", - "integrity": "sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", "cpu": [ "arm64" ], @@ -1541,9 +1561,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.35.0.tgz", - "integrity": "sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", "cpu": [ "x64" ], @@ -1555,9 +1575,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.35.0.tgz", - "integrity": "sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", "cpu": [ "arm" ], @@ -1569,9 +1589,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.35.0.tgz", - "integrity": "sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", "cpu": [ "arm" ], @@ -1583,9 +1603,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.35.0.tgz", - "integrity": "sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", "cpu": [ "arm64" ], @@ -1597,9 +1617,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.35.0.tgz", - "integrity": "sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", "cpu": [ "arm64" ], @@ -1610,10 +1630,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.35.0.tgz", - "integrity": "sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", "cpu": [ "loong64" ], @@ -1624,10 +1644,38 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.35.0.tgz", - "integrity": "sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", "cpu": [ "ppc64" ], @@ -1639,9 +1687,23 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.35.0.tgz", - "integrity": "sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", "cpu": [ "riscv64" ], @@ -1653,9 +1715,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.35.0.tgz", - "integrity": "sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", "cpu": [ "s390x" ], @@ -1667,9 +1729,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.35.0.tgz", - "integrity": "sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", "cpu": [ "x64" ], @@ -1681,9 +1743,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.35.0.tgz", - "integrity": "sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", "cpu": [ "x64" ], @@ -1694,10 +1756,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.35.0.tgz", - "integrity": "sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", "cpu": [ "arm64" ], @@ -1709,9 +1799,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.35.0.tgz", - "integrity": "sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", "cpu": [ "ia32" ], @@ -1722,10 +1812,10 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.35.0.tgz", - "integrity": "sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", "cpu": [ "x64" ], @@ -1736,6 +1826,26 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/core": { "name": "@swc/wasm", "version": "1.13.20", @@ -1744,10 +1854,197 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", + "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", + "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", + "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", + "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", + "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", + "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", + "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", + "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", + "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", + "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@swc/wasm": { - "version": "1.13.20", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.13.20.tgz", - "integrity": "sha512-NJzN+QrbdwXeVTfTYiHkqv13zleOCQA52NXBOrwKvjxWJQecRqakjUhUP2z8lqs7eWVthko4Cilqs+VeBrwo3Q==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.11.tgz", + "integrity": "sha512-230rdYZf8ux3nIwISOQNCFrxzxpL/UFY4Khv/3UsvpEdo709j/+Tg80yXWW3DXETeZNPBV72QpvEBhXsl7Lc9g==", "dev": true, "license": "Apache-2.0" }, @@ -1805,9 +2102,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", - "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -1832,9 +2129,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1881,19 +2178,20 @@ "license": "MIT" }, "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, "node_modules/@types/d3-color": { @@ -1933,9 +2231,9 @@ } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -1961,22 +2259,12 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", - "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", - "license": "MIT", - "dependencies": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1991,28 +2279,29 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.16", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.16.tgz", - "integrity": "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", - "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@videojs/http-streaming": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.0.tgz", - "integrity": "sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw==", + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.4.tgz", + "integrity": "sha512-XAvdG2dolBuV2Fx8bu1kjmQ2D4TonGzZH68Pgv/O9xMSFWdZtITSMFismeQLEAtMmGwze8qNJp3RgV+jStrJqg==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", @@ -2058,14 +2347,14 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", - "integrity": "sha512-Ff690TUck0Anlh7wdIcnsVMhofeEVgm44Y4OYdeeEEPSKyZHzDI9gfVBvySEhDfXtBp8tLCbfsVKPWEMEjq8/g==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", + "integrity": "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.35", - "@swc/core": "^1.13.5" + "@rolldown/pluginutils": "1.0.0-rc.2", + "@swc/core": "^1.15.11" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -2074,6 +2363,45 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/@vitejs/plugin-react-swc/node_modules/@swc/core": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", + "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.11", + "@swc/core-darwin-x64": "1.15.11", + "@swc/core-linux-arm-gnueabihf": "1.15.11", + "@swc/core-linux-arm64-gnu": "1.15.11", + "@swc/core-linux-arm64-musl": "1.15.11", + "@swc/core-linux-x64-gnu": "1.15.11", + "@swc/core-linux-x64-musl": "1.15.11", + "@swc/core-win32-arm64-msvc": "1.15.11", + "@swc/core-win32-ia32-msvc": "1.15.11", + "@swc/core-win32-x64-msvc": "1.15.11" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -2190,18 +2518,18 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2244,9 +2572,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2261,17 +2589,17 @@ } }, "node_modules/allotment": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.4.tgz", - "integrity": "sha512-LMM5Xe5nLePFOLAlW/5k3ARqznYGUyNekV4xJrfDKn1jimW3nlZE6hT/Tu0T8s0VgAkr9s2P7+uM0WvJKn5DAw==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.5.tgz", + "integrity": "sha512-7i4NT7ieXEyAd5lBrXmE7WHz/e7hRuo97+j+TwrPE85ha6kyFURoc76nom0dWSZ1pTKVEAMJy/+f3/Isfu/41A==", "license": "MIT", "dependencies": { "classnames": "^2.3.0", "eventemitter3": "^5.0.0", + "fast-deep-equal": "^3.1.3", "lodash.clamp": "^4.0.0", "lodash.debounce": "^4.0.0", - "lodash.isequal": "^4.5.0", - "use-resize-observer": "^9.0.0" + "usehooks-ts": "^3.1.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -2356,11 +2684,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/bidi-js": { "version": "1.0.3", @@ -2373,14 +2704,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/cac": { @@ -2437,9 +2770,9 @@ } }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -2481,13 +2814,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2495,12 +2821,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cosmiconfig": { @@ -2565,24 +2895,25 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", - "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { "node": ">=20" } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/d3-array": { @@ -2616,9 +2947,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -2707,23 +3038,33 @@ } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { "node": ">=20" } }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { @@ -2773,15 +3114,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2834,9 +3166,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -2856,9 +3188,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", - "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2869,31 +3201,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.1", - "@esbuild/android-arm": "0.25.1", - "@esbuild/android-arm64": "0.25.1", - "@esbuild/android-x64": "0.25.1", - "@esbuild/darwin-arm64": "0.25.1", - "@esbuild/darwin-x64": "0.25.1", - "@esbuild/freebsd-arm64": "0.25.1", - "@esbuild/freebsd-x64": "0.25.1", - "@esbuild/linux-arm": "0.25.1", - "@esbuild/linux-arm64": "0.25.1", - "@esbuild/linux-ia32": "0.25.1", - "@esbuild/linux-loong64": "0.25.1", - "@esbuild/linux-mips64el": "0.25.1", - "@esbuild/linux-ppc64": "0.25.1", - "@esbuild/linux-riscv64": "0.25.1", - "@esbuild/linux-s390x": "0.25.1", - "@esbuild/linux-x64": "0.25.1", - "@esbuild/netbsd-arm64": "0.25.1", - "@esbuild/netbsd-x64": "0.25.1", - "@esbuild/openbsd-arm64": "0.25.1", - "@esbuild/openbsd-x64": "0.25.1", - "@esbuild/sunos-x64": "0.25.1", - "@esbuild/win32-arm64": "0.25.1", - "@esbuild/win32-ia32": "0.25.1", - "@esbuild/win32-x64": "0.25.1" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escape-string-regexp": { @@ -2909,33 +3242,32 @@ } }, "node_modules/eslint": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", - "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.1.0", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.22.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2983,9 +3315,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.19.tgz", - "integrity": "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2993,9 +3325,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3010,9 +3342,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3023,15 +3355,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3041,9 +3373,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3097,15 +3429,15 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3119,9 +3451,9 @@ "license": "MIT" }, "node_modules/fast-equals": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", - "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3228,31 +3560,6 @@ "dev": true, "license": "ISC" }, - "node_modules/formik": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", - "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], - "license": "Apache-2.0", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.1", - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3345,9 +3652,9 @@ } }, "node_modules/hls.js": { - "version": "1.5.20", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz", - "integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==", + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", "license": "Apache-2.0" }, "node_modules/hoist-non-react-statics": { @@ -3359,17 +3666,23 @@ "react-is": "^16.7.0" } }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/http-proxy-agent": { @@ -3400,19 +3713,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3539,9 +3839,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3552,35 +3852,35 @@ } }, "node_modules/jsdom": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", - "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/dom-selector": "^6.5.4", - "cssstyle": "^5.3.0", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", "data-urls": "^6.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^7.3.0", - "rrweb-cssom": "^0.8.0", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0", - "ws": "^8.18.2", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -3686,15 +3986,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.clamp": { @@ -3709,13 +4003,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3743,11 +4030,11 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", - "integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -3783,9 +4070,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3806,9 +4093,10 @@ "license": "MIT" }, "node_modules/min-document": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", - "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", "dependencies": { "dom-walk": "^0.1.0" } @@ -3824,16 +4112,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mpd-parser": { @@ -3885,9 +4176,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.9", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz", - "integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -4000,9 +4291,9 @@ } }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, "license": "MIT", "dependencies": { @@ -4096,9 +4387,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -4116,7 +4407,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4135,9 +4426,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -4178,13 +4469,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4205,6 +4489,12 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/property-expr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", @@ -4222,33 +4512,33 @@ } }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.4" } }, "node_modules/react-draggable": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", - "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", "license": "MIT", "dependencies": { - "clsx": "^1.1.1", + "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { @@ -4256,15 +4546,6 @@ "react-dom": ">= 16.3.0" } }, - "node_modules/react-draggable/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react-dropzone": { "version": "14.3.8", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", @@ -4282,16 +4563,27 @@ "react": ">= 16.8 || 18.0.0" } }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", - "license": "MIT" + "node_modules/react-hook-form": { + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT" }, "node_modules/react-lifecycles-compat": { @@ -4327,9 +4619,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", - "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -4374,9 +4666,9 @@ } }, "node_modules/react-router": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", - "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4396,12 +4688,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", - "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "license": "MIT", "dependencies": { - "react-router": "7.6.0" + "react-router": "7.13.0" }, "engines": { "node": ">=20.0.0" @@ -4536,9 +4828,9 @@ } }, "node_modules/recharts": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", - "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", "license": "MIT", "dependencies": { "clsx": "^2.0.0", @@ -4593,12 +4885,6 @@ "node": ">=8" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4610,12 +4896,12 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -4639,13 +4925,13 @@ } }, "node_modules/rollup": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.35.0.tgz", - "integrity": "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -4655,42 +4941,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.35.0", - "@rollup/rollup-android-arm64": "4.35.0", - "@rollup/rollup-darwin-arm64": "4.35.0", - "@rollup/rollup-darwin-x64": "4.35.0", - "@rollup/rollup-freebsd-arm64": "4.35.0", - "@rollup/rollup-freebsd-x64": "4.35.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.35.0", - "@rollup/rollup-linux-arm-musleabihf": "4.35.0", - "@rollup/rollup-linux-arm64-gnu": "4.35.0", - "@rollup/rollup-linux-arm64-musl": "4.35.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.35.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0", - "@rollup/rollup-linux-riscv64-gnu": "4.35.0", - "@rollup/rollup-linux-s390x-gnu": "4.35.0", - "@rollup/rollup-linux-x64-gnu": "4.35.0", - "@rollup/rollup-linux-x64-musl": "4.35.0", - "@rollup/rollup-win32-arm64-msvc": "4.35.0", - "@rollup/rollup-win32-ia32-msvc": "4.35.0", - "@rollup/rollup-win32-x64-msvc": "4.35.0", + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", "fsevents": "~2.3.2" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4705,15 +4983,15 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/shebang-command": { @@ -4773,9 +5051,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, @@ -4806,9 +5084,9 @@ } }, "node_modules/strip-literal": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", - "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4864,9 +5142,9 @@ "license": "MIT" }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/tiny-case": { @@ -4881,12 +5159,6 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4949,22 +5221,22 @@ } }, "node_modules/tldts": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.15.tgz", - "integrity": "sha512-heYRCiGLhtI+U/D0V8YM3QRwPfsLJiP+HX+YwiHZTnWzjIKC+ZCxQRYlzvOoTEc6KIP62B1VeAN63diGCng2hg==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.15" + "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.15.tgz", - "integrity": "sha512-YBkp2VfS9VTRMPNL2PA6PMESmxV1JEVoAr5iBlZnB5JG3KUrWzNCB3yNNkRa2FZkqClaBgfNYCp8PgpYmpjkZw==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, @@ -5020,9 +5292,9 @@ } }, "node_modules/type-fest": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", - "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -5107,19 +5379,6 @@ } } }, - "node_modules/use-resize-observer": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", - "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==", - "license": "MIT", - "dependencies": { - "@juggle/resize-observer": "^3.3.1" - }, - "peerDependencies": { - "react": "16.8.0 - 18", - "react-dom": "16.8.0 - 18" - } - }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -5142,6 +5401,21 @@ } } }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -5165,13 +5439,13 @@ } }, "node_modules/video.js": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.22.0.tgz", - "integrity": "sha512-xge2kpjsvC0zgFJ1cqt+wTqsi21+huFswlonPFh7qiplypsb4FN/D2Rz6bWdG/S9eQaPHfWHsarmJL/7D3DHoA==", + "version": "8.23.7", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.7.tgz", + "integrity": "sha512-cG4HOygYt+Z8j6Sf5DuK6OgEOoM+g9oGP6vpqoZRaD13aHE4PMITbyjJUXZcIQbgB0wJEadBRaVm5lJIzo2jAA==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "^3.17.0", + "@babel/runtime": "^7.28.4", + "@videojs/http-streaming": "^3.17.3", "@videojs/vhs-utils": "^4.1.1", "@videojs/xhr": "2.7.0", "aes-decrypter": "^4.0.2", @@ -5216,24 +5490,24 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -5242,14 +5516,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -5400,9 +5674,9 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5411,23 +5685,9 @@ }, "node_modules/webworkify-webpack": { "version": "2.1.5", - "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git", - "integrity": "sha512-W8Bg+iLq52d2GFvwabPNCIDCgMHcW3g68Tr8zwpJliEz2cKBIKYL3T0VdYeZWhz5rOxWRBBEdF931fquSO6iCQ==", + "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef", "license": "MIT" }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -5496,9 +5756,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -5535,9 +5795,9 @@ "license": "MIT" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "optional": true, @@ -5547,6 +5807,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { @@ -5563,9 +5826,9 @@ } }, "node_modules/yup": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", - "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "license": "MIT", "dependencies": { "property-expr": "^2.0.5", @@ -5587,9 +5850,9 @@ } }, "node_modules/zustand": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", - "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index fea6b73e..16197c31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,11 +23,12 @@ "@mantine/form": "~8.0.1", "@mantine/hooks": "~8.0.1", "@mantine/notifications": "~8.0.1", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-table": "^8.21.2", "allotment": "^1.20.4", "dayjs": "^1.11.13", - "formik": "^2.4.6", "hls.js": "^1.5.20", + "react-hook-form": "^7.70.0", "lucide-react": "^0.511.0", "mpegts.js": "^1.8.0", "react": "^19.1.0", @@ -54,18 +55,22 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react-swc": "^4.1.0", - "eslint": "^9.21.0", + "eslint": "^9.27.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", "jsdom": "^27.0.0", "prettier": "^3.5.3", - "vite": "^6.2.0", + "vite": "^7.1.7", "vitest": "^3.2.4" }, "resolutions": { "vite": "7.1.7", "react": "19.1.0", "react-dom": "19.1.0" + }, + "overrides": { + "js-yaml": "^4.1.1", + "minimatch": "^10.2.1" } } diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js index e143dfef..7787ae62 100644 --- a/frontend/prettier.config.js +++ b/frontend/prettier.config.js @@ -7,4 +7,5 @@ export default { printWidth: 80, // Wrap lines at 80 characters bracketSpacing: true, // Add spaces inside object braces arrowParens: 'always', // Always include parentheses around arrow function parameters + endOfLine: 'lf', // Enforce LF for all files }; diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest index 45dc8a20..fa99de77 100644 --- a/frontend/public/site.webmanifest +++ b/frontend/public/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3c7c3877..7950e8ae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,4 @@ -// frontend/src/App.js -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { BrowserRouter as Router, Route, @@ -15,11 +14,12 @@ import Stats from './pages/Stats'; import DVR from './pages/DVR'; import Settings from './pages/Settings'; import PluginsPage from './pages/Plugins'; +import ConnectPage from './pages/Connect'; +import ConnectLogsPage from './pages/ConnectLogs'; import Users from './pages/Users'; import LogosPage from './pages/Logos'; import VODsPage from './pages/VODs'; import useAuthStore from './store/auth'; -import useLogosStore from './store/logos'; import FloatingVideo from './components/FloatingVideo'; import { WebsocketProvider } from './WebSocket'; import { Box, AppShell, MantineProvider } from '@mantine/core'; @@ -40,25 +40,30 @@ const defaultRoute = '/channels'; const App = () => { const [open, setOpen] = useState(true); - const [backgroundLoadingStarted, setBackgroundLoadingStarted] = - useState(false); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isInitialized = useAuthStore((s) => s.isInitialized); const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated); const logout = useAuthStore((s) => s.logout); const initData = useAuthStore((s) => s.initData); const initializeAuth = useAuthStore((s) => s.initializeAuth); const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists); + const authCheckStarted = useRef(false); + const superuserCheckStarted = useRef(false); + const toggleDrawer = () => { setOpen(!open); }; // Check if a superuser exists on first load. useEffect(() => { + if (superuserCheckStarted.current) return; + superuserCheckStarted.current = true; + async function checkSuperuser() { try { const response = await API.fetchSuperUser(); - if (!response.superuser_exists) { + if (response && response.superuser_exists === false) { setSuperuserExists(false); } } catch (error) { @@ -72,20 +77,19 @@ const App = () => { } } checkSuperuser(); - }, []); + }, [setSuperuserExists]); // Authentication check useEffect(() => { + if (authCheckStarted.current) return; + authCheckStarted.current = true; + const checkAuth = async () => { try { const loggedIn = await initializeAuth(); if (loggedIn) { await initData(); - // Start background logo loading after app is fully initialized (only once) - if (!backgroundLoadingStarted) { - setBackgroundLoadingStarted(true); - useLogosStore.getState().startBackgroundLoading(); - } + // Logos are now loaded at the end of initData, no need for background loading } else { await logout(); } @@ -96,7 +100,7 @@ const App = () => { }; checkAuth(); - }, [initializeAuth, initData, logout, backgroundLoadingStarted]); + }, [initializeAuth, initData, logout]); return ( { height: 0, }} navbar={{ - width: isAuthenticated - ? open - ? drawerWidth - : miniDrawerWidth - : 0, + width: + isAuthenticated && isInitialized + ? open + ? drawerWidth + : miniDrawerWidth + : 0, }} > - {isAuthenticated && ( + {isAuthenticated && isInitialized && ( { > - {isAuthenticated ? ( + {isAuthenticated && isInitialized ? ( <> } /> } /> @@ -149,6 +154,11 @@ const App = () => { } /> } /> } /> + } /> + } + /> } /> } /> } /> @@ -161,7 +171,11 @@ const App = () => { path="*" element={ } diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 40035d33..c18a7635 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -424,7 +424,7 @@ export const WebsocketProvider = ({ children }) => { // Refresh channels data and logos try { await API.requeryChannels(); - await useChannelsStore.getState().fetchChannels(); + await useChannelsStore.getState().fetchChannelIds(); // Get updated channel data and extract logo IDs to load const channels = useChannelsStore.getState().channels; @@ -489,7 +489,7 @@ export const WebsocketProvider = ({ children }) => { // Refresh channels data try { await API.requeryChannels(); - await useChannelsStore.getState().fetchChannels(); + await useChannelsStore.getState().fetchChannelIds(); } catch (e) { console.warn( 'Failed to refresh channels after name setting:', @@ -700,6 +700,17 @@ export const WebsocketProvider = ({ children }) => { withCloseButton: true, // Allow manual close loading: false, // Remove loading indicator }); + // Requery streams and channels after rehash completes + try { + await API.requeryChannels(); + await API.requeryStreams(); + await useChannelsStore.getState().fetchChannelIds(); + } catch (error) { + console.error( + 'Error refreshing channels/streams after rehash:', + error + ); + } } else if (parsedEvent.data.action === 'blocked') { // Handle blocked rehash attempt notifications.show({ @@ -755,7 +766,9 @@ export const WebsocketProvider = ({ children }) => { // Refresh the channels table to show new channels try { await API.requeryChannels(); - await useChannelsStore.getState().fetchChannels(); + await API.requeryStreams(); + useChannelsStore.getState().fetchChannelIds(); + await fetchChannelProfiles(); console.log('Channels refreshed after bulk creation'); } catch (error) { console.error( @@ -832,6 +845,59 @@ export const WebsocketProvider = ({ children }) => { break; } + case 'system_notification': { + // Handle real-time system notifications (version updates, setting recommendations, etc.) + const notificationData = parsedEvent.data.notification; + if (notificationData) { + // Import and update the notifications store + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore + .getState() + .addNotification(notificationData); + + // Show a toast notification for high priority items + if ( + notificationData.priority === 'high' || + notificationData.priority === 'critical' + ) { + const color = + notificationData.notification_type === 'version_update' + ? 'green' + : notificationData.notification_type === 'warning' + ? 'orange' + : 'blue'; + + notifications.show({ + title: notificationData.title, + message: notificationData.message, + color, + autoClose: 10000, + }); + } + } + break; + } + + case 'notification_dismissed': { + // Handle notification dismissed from another session + const { notification_key } = parsedEvent.data; + if (notification_key) { + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore + .getState() + .dismissNotification(notification_key); + } + break; + } + + case 'notifications_cleared': { + // Handle bulk notification clearing (e.g., when version is updated) + API.getNotifications(); + break; + } + default: console.error( `Unknown websocket event type: ${parsedEvent.data?.type}` diff --git a/frontend/src/api.js b/frontend/src/api.js index 7eda6a3f..915633f7 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -10,7 +10,10 @@ import useStreamProfilesStore from './store/streamProfiles'; import useSettingsStore from './store/settings'; import { notifications } from '@mantine/notifications'; import useChannelsTableStore from './store/channelsTable'; +import useStreamsTableStore from './store/streamsTable'; import useUsersStore from './store/users'; +import useConnectStore from './store/connect'; +import Limiter from './utils'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV @@ -103,6 +106,41 @@ export default class API { return await useAuthStore.getState().getToken(); } + /** + * Fetch all pages for a paginated endpoint when you already know totalCount. + * Builds page calls from totalCount and pageSize and aggregates all results. + * - endpoint: path like "/api/channels/channels/" + * - params: URLSearchParams for filters (will not be mutated) + * - totalCount: total number of matching items + * - pageSize: items per page + * Returns a flat array of results. Supports both array and {results, next} responses. + */ + static async fetchAllByCount(endpoint, params, totalCount, pageSize = 200) { + const total = Number(totalCount) || 0; + const size = Number(pageSize) || 200; + const totalPages = Math.max(1, Math.ceil(total / size)); + + const requests = []; + for (let page = 1; page <= totalPages; page++) { + const q = new URLSearchParams(params || new URLSearchParams()); + q.set('page', String(page)); + q.set('page_size', String(size)); + const url = `${host}${endpoint}?${q.toString()}`; + requests.push(request(url)); + } + + const responses = await Promise.all(requests); + const all = []; + for (const data of responses) { + if (Array.isArray(data)) { + all.push(...data); + } else if (Array.isArray(data?.results)) { + all.push(...data.results); + } + } + return all; + } + static async fetchSuperUser() { try { return await request(`${host}/api/accounts/initialize-superuser/`, { @@ -170,21 +208,69 @@ export default class API { static async logout() { return await request(`${host}/api/accounts/auth/logout/`, { - auth: true, // Send JWT token so backend can identify the user + auth: true, // Send JWT token so backend can identify the user method: 'POST', }); } static async getChannels() { try { - const response = await request(`${host}/api/channels/channels/`); + // Paginate through channels to avoid heavy single response + const pageSize = 200; + const allChannels = []; - return response; + // Get first page to get total results count + const data = await request( + `${host}/api/channels/channels/?page=1&page_size=${pageSize}` + ); + + // Backward compatibility: if endpoint returns an array (legacy), just return it + if (Array.isArray(data)) { + return data; + } + + allChannels.concat(Array.isArray(data?.results) ? data.results : []); + + const totalPages = Math.max(1, Math.ceil(data.count / pageSize)) - 1; + const apiCalls = []; + for (let page = 2; page <= totalPages; page++) { + apiCalls.push( + new Promise(async (resolve) => { + const response = await request( + `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` + ); + + return resolve( + Array.isArray(response?.results) ? response.results : [] + ); + }) + ); + } + + const allResults = await Limiter.all(5, apiCalls); + + return allResults; } catch (e) { errorNotification('Failed to retrieve channels', e); } } + /** + * Retrieve a lightweight summary of channels (id, name, logo_id, + * channel_number, uuid, epg_data_id, channel_group_id). + * Designed for the TV Guide where full channel data is not needed. + */ + static async getChannelsSummary(params = new URLSearchParams()) { + try { + const url = `${host}/api/channels/channels/summary/?${params.toString()}`; + const data = await request(url); + return Array.isArray(data) ? data : []; + } catch (e) { + errorNotification('Failed to retrieve channel summary', e); + return []; + } + } + static async queryChannels(params) { try { API.lastQueryParams = params; @@ -197,10 +283,73 @@ export default class API { return response; } catch (e) { + // Handle invalid page error by resetting to page 1 and retrying + if (e.body?.detail === 'Invalid page.') { + const currentPagination = useChannelsTableStore.getState().pagination; + + // Only retry if we're not already on page 1 + if (currentPagination.pageIndex > 0) { + // Reset to page 1 + useChannelsTableStore.getState().setPagination({ + ...currentPagination, + pageIndex: 0, + }); + + // Update params to page 1 and retry + const newParams = new URLSearchParams(params); + newParams.set('page', '1'); + + const response = await request( + `${host}/api/channels/channels/?${newParams.toString()}` + ); + + useChannelsTableStore.getState().queryChannels(response, newParams); + return response; + } + } + errorNotification('Failed to fetch channels', e); } } + /** + * Retrieve channels matching the provided query params, paging until complete. + * Does NOT touch any table/store state; returns a plain array. + */ + static async getChannelsForParams(params) { + try { + const pageSize = 200; + const query = new URLSearchParams(params); + let page = 1; + let all = []; + + while (true) { + query.set('page', String(page)); + query.set('page_size', String(pageSize)); + const url = `${host}/api/channels/channels/?${query.toString()}`; + const data = await request(url); + + if (Array.isArray(data)) { + // Legacy array response + all = data; + break; + } + + const results = Array.isArray(data?.results) ? data.results : []; + all = all.concat(results); + + const hasMore = Boolean(data?.next); + if (!hasMore || results.length === 0) break; + page += 1; + } + + return all; + } catch (e) { + errorNotification('Failed to retrieve channels for query', e); + throw e; + } + } + static async requeryChannels() { try { const [response, ids] = await Promise.all([ @@ -217,11 +366,40 @@ export default class API { return response; } catch (e) { + // Handle invalid page error by resetting to page 1 and retrying + if (e.body?.detail === 'Invalid page.') { + const currentPagination = useChannelsTableStore.getState().pagination; + + // Only retry if we're not already on page 1 + if (currentPagination.pageIndex > 0) { + // Reset to page 1 + useChannelsTableStore.getState().setPagination({ + ...currentPagination, + pageIndex: 0, + }); + + // Update params to page 1 and retry + const newParams = new URLSearchParams(API.lastQueryParams); + newParams.set('page', '1'); + API.lastQueryParams = newParams; + + const [response, ids] = await Promise.all([ + request(`${host}/api/channels/channels/?${newParams.toString()}`), + API.getAllChannelIds(newParams), + ]); + + useChannelsTableStore.getState().queryChannels(response, newParams); + useChannelsTableStore.getState().setAllQueryIds(ids); + + return response; + } + } + errorNotification('Failed to fetch channels', e); } } - static async getAllChannelIds(params) { + static async getAllChannelIds(params = new URLSearchParams()) { try { const response = await request( `${host}/api/channels/channels/ids/?${params.toString()}` @@ -331,11 +509,21 @@ export default class API { channelData.channel_number === '' || channelData.channel_number === null || channelData.channel_number === undefined || - (typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '') + (typeof channelData.channel_number === 'string' && + channelData.channel_number.trim() === '') ) { delete channelData.channel_number; } + // Add channel profile IDs based on current selection + const selectedProfileId = useChannelsStore.getState().selectedProfileId; + if (selectedProfileId && selectedProfileId !== '0') { + // Specific profile selected - add only to that profile + channelData.channel_profile_ids = [parseInt(selectedProfileId)]; + } + // If selectedProfileId is '0' or not set, don't include channel_profile_ids + // which will trigger the backend's default behavior of adding to all profiles + if (channel.logo_file) { // Must send FormData for file upload body = new FormData(); @@ -371,6 +559,7 @@ export default class API { }); useChannelsStore.getState().removeChannels([id]); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete channel', e); } @@ -385,6 +574,7 @@ export default class API { }); useChannelsStore.getState().removeChannels(channel_ids); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete channels', e); } @@ -438,6 +628,9 @@ export default class API { ); useChannelsStore.getState().updateChannel(response); + if (Object.prototype.hasOwnProperty.call(payload, 'streams')) { + await API.requeryStreams(); + } return response; } catch (e) { errorNotification('Failed to update channel', e); @@ -495,6 +688,61 @@ export default class API { } } + // Server-side regex rename of channel names for selected IDs + static async bulkRegexRenameChannels( + channelIds, + find, + replace = '', + flags = 'g' + ) { + try { + const response = await request( + `${host}/api/channels/channels/edit/bulk-regex/`, + { + method: 'POST', + body: { + channel_ids: channelIds, + find, + replace, + flags, + }, + } + ); + + // Optional success notification + if (response?.success) { + notifications.show({ + title: 'Channel Names Updated', + message: `Renamed ${response.updated_count} channel(s) via regex`, + color: 'green', + autoClose: 4000, + }); + } + + return response; + } catch (e) { + errorNotification('Failed to apply regex renames', e); + } + } + + static async reorderChannel(channelId, insertAfterId) { + try { + const response = await request( + `${host}/api/channels/channels/${channelId}/reorder/`, + { + method: 'POST', + body: { + insert_after_id: insertAfterId, + }, + } + ); + + return response; + } catch (e) { + errorNotification('Failed to reorder channel', e); + } + } + static async setChannelEPG(channelId, epgDataId) { try { const response = await request( @@ -621,13 +869,18 @@ export default class API { useChannelsStore.getState().addChannel(response); } + await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to create channel', e); } } - static async createChannelsFromStreamsAsync(streamIds, channelProfileIds = null, startingChannelNumber = null) { + static async createChannelsFromStreamsAsync( + streamIds, + channelProfileIds = null, + startingChannelNumber = null + ) { try { const requestBody = { stream_ids: streamIds, @@ -696,6 +949,46 @@ export default class API { } } + static async queryStreamsTable(params) { + try { + API.lastStreamQueryParams = params; + useStreamsTableStore.getState().setLastQueryParams(params); + + const response = await request( + `${host}/api/channels/streams/?${params.toString()}` + ); + + useStreamsTableStore.getState().queryStreams(response, params); + + return response; + } catch (e) { + errorNotification('Failed to fetch streams', e); + } + } + + static async requeryStreams() { + const params = + useStreamsTableStore.getState().lastQueryParams || + API.lastStreamQueryParams; + if (!params) { + return null; + } + + try { + const [response, ids] = await Promise.all([ + request(`${host}/api/channels/streams/?${params.toString()}`), + API.getAllStreamIds(params), + ]); + + useStreamsTableStore.getState().queryStreams(response, params); + useStreamsTableStore.getState().setAllQueryIds(ids); + + return response; + } catch (e) { + errorNotification('Failed to fetch streams', e); + } + } + static async getAllStreamIds(params) { try { const response = await request( @@ -718,6 +1011,20 @@ export default class API { } } + static async getStreamFilterOptions(params) { + try { + const response = await request( + `${host}/api/channels/streams/filter-options/?${params.toString()}` + ); + + return response; + } catch (e) { + errorNotification('Failed to retrieve filter options', e); + // Return safe defaults to prevent crashes during container startup + return { groups: [], m3u_accounts: [] }; + } + } + static async addStream(values) { try { const response = await request(`${host}/api/channels/streams/`, { @@ -729,6 +1036,7 @@ export default class API { useStreamsStore.getState().addStream(response); } + await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to add stream', e); @@ -747,6 +1055,7 @@ export default class API { useStreamsStore.getState().updateStream(response); } + await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to update stream', e); @@ -760,6 +1069,7 @@ export default class API { }); useStreamsStore.getState().removeStreams([id]); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete stream', e); } @@ -773,6 +1083,7 @@ export default class API { }); useStreamsStore.getState().removeStreams(ids); + await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete streams', e); } @@ -944,9 +1255,6 @@ export default class API { }); usePlaylistsStore.getState().removePlaylists([id]); - // @TODO: MIGHT need to optimize this later if someone has thousands of channels - // but I'm feeling laze right now - // useChannelsStore.getState().fetchChannels(); } catch (e) { errorNotification(`Failed to delete playlist ${id}`, e); } @@ -1023,6 +1331,20 @@ export default class API { } } + static async getCurrentPrograms(channelIds = null) { + try { + const response = await request(`${host}/api/epg/current-programs/`, { + method: 'POST', + body: { channel_ids: channelIds }, + }); + + return response; + } catch (e) { + console.error('Failed to retrieve current programs', e); + return []; + } + } + // Notice there's a duplicated "refreshPlaylist" method above; // you might want to rename or remove one if it's not needed. @@ -1147,9 +1469,15 @@ export default class API { errorNotification('Failed to retrieve timezones', e); // Return fallback data instead of throwing return { - timezones: ['UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific'], + timezones: [ + 'UTC', + 'US/Eastern', + 'US/Central', + 'US/Mountain', + 'US/Pacific', + ], grouped: {}, - count: 5 + count: 5, }; } } @@ -1273,16 +1601,22 @@ export default class API { static async refreshAccountInfo(profileId) { try { - const response = await request(`${host}/api/m3u/refresh-account-info/${profileId}/`, { - method: 'POST', - }); + const response = await request( + `${host}/api/m3u/refresh-account-info/${profileId}/`, + { + method: 'POST', + } + ); return response; } catch (e) { // If it's a structured error response, return it instead of throwing if (e.body && typeof e.body === 'object') { return e.body; } - errorNotification(`Failed to refresh account info for profile ${profileId}`, e); + errorNotification( + `Failed to refresh account info for profile ${profileId}`, + e + ); throw e; } } @@ -1349,6 +1683,190 @@ export default class API { } } + // Backup API (async with Celery task polling) + static async listBackups() { + try { + const response = await request(`${host}/api/backups/`); + return response || []; + } catch (e) { + errorNotification('Failed to load backups', e); + throw e; + } + } + + static async getBackupStatus(taskId, token = null) { + try { + let url = `${host}/api/backups/status/${taskId}/`; + if (token) { + url += `?token=${encodeURIComponent(token)}`; + } + const response = await request(url, { auth: !token }); + return response; + } catch (e) { + throw e; + } + } + + static async waitForBackupTask(taskId, onProgress, token = null) { + const pollInterval = 2000; // Poll every 2 seconds + const maxAttempts = 300; // Max 10 minutes (300 * 2s) + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const status = await API.getBackupStatus(taskId, token); + + if (onProgress) { + onProgress(status); + } + + if (status.state === 'completed') { + return status.result; + } else if (status.state === 'failed') { + throw new Error(status.error || 'Task failed'); + } + } catch (e) { + throw e; + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + throw new Error('Task timed out'); + } + + static async createBackup(onProgress) { + try { + // Start the backup task + const response = await request(`${host}/api/backups/create/`, { + method: 'POST', + }); + + // Wait for the task to complete using token for auth + const result = await API.waitForBackupTask( + response.task_id, + onProgress, + response.task_token + ); + return result; + } catch (e) { + errorNotification('Failed to create backup', e); + throw e; + } + } + + static async uploadBackup(file) { + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await request(`${host}/api/backups/upload/`, { + method: 'POST', + body: formData, + }); + return response; + } catch (e) { + errorNotification('Failed to upload backup', e); + throw e; + } + } + + static async deleteBackup(filename) { + try { + const encodedFilename = encodeURIComponent(filename); + await request(`${host}/api/backups/${encodedFilename}/delete/`, { + method: 'DELETE', + }); + } catch (e) { + errorNotification('Failed to delete backup', e); + throw e; + } + } + + static async getDownloadToken(filename) { + // Get a download token from the server + try { + const response = await request( + `${host}/api/backups/${encodeURIComponent(filename)}/download-token/` + ); + return response.token; + } catch (e) { + throw e; + } + } + + static async downloadBackup(filename) { + try { + // Get a download token first (requires auth) + const token = await API.getDownloadToken(filename); + const encodedFilename = encodeURIComponent(filename); + + // Build the download URL with token + const downloadUrl = `${host}/api/backups/${encodedFilename}/download/?token=${encodeURIComponent(token)}`; + + // Use direct browser navigation instead of fetch to avoid CORS issues + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + return { filename }; + } catch (e) { + errorNotification('Failed to download backup', e); + throw e; + } + } + + static async restoreBackup(filename, onProgress) { + try { + // Start the restore task + const encodedFilename = encodeURIComponent(filename); + const response = await request( + `${host}/api/backups/${encodedFilename}/restore/`, + { + method: 'POST', + } + ); + + // Wait for the task to complete using token for auth + // Token-based auth allows status polling even after DB restore invalidates user sessions + const result = await API.waitForBackupTask( + response.task_id, + onProgress, + response.task_token + ); + return result; + } catch (e) { + errorNotification('Failed to restore backup', e); + throw e; + } + } + + static async getBackupSchedule() { + try { + const response = await request(`${host}/api/backups/schedule/`); + return response; + } catch (e) { + errorNotification('Failed to get backup schedule', e); + throw e; + } + } + + static async updateBackupSchedule(settings) { + try { + const response = await request(`${host}/api/backups/schedule/update/`, { + method: 'PUT', + body: settings, + }); + return response; + } catch (e) { + errorNotification('Failed to update backup schedule', e); + throw e; + } + } + static async getVersion() { try { const response = await request(`${host}/api/core/version/`, { @@ -1393,17 +1911,27 @@ export default class API { return response; } catch (e) { // Show only the concise error message for plugin import - const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin'; - notifications.show({ title: 'Import failed', message: msg, color: 'red' }); + const msg = + (e?.body && (e.body.error || e.body.detail)) || + e?.message || + 'Failed to import plugin'; + notifications.show({ + title: 'Import failed', + message: msg, + color: 'red', + }); throw e; } } static async deletePlugin(key) { try { - const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, { - method: 'DELETE', - }); + const response = await request( + `${host}/api/plugins/plugins/${key}/delete/`, + { + method: 'DELETE', + } + ); return response; } catch (e) { errorNotification('Failed to delete plugin', e); @@ -1422,15 +1950,19 @@ export default class API { return response?.settings || {}; } catch (e) { errorNotification('Failed to update plugin settings', e); + throw e; } } static async runPluginAction(key, action, params = {}) { try { - const response = await request(`${host}/api/plugins/plugins/${key}/run/`, { - method: 'POST', - body: { action, params }, - }); + const response = await request( + `${host}/api/plugins/plugins/${key}/run/`, + { + method: 'POST', + body: { action, params }, + } + ); return response; } catch (e) { errorNotification('Failed to run plugin action', e); @@ -1439,10 +1971,13 @@ export default class API { static async setPluginEnabled(key, enabled) { try { - const response = await request(`${host}/api/plugins/plugins/${key}/enabled/`, { - method: 'POST', - body: { enabled }, - }); + const response = await request( + `${host}/api/plugins/plugins/${key}/enabled/`, + { + method: 'POST', + body: { enabled }, + } + ); return response; } catch (e) { errorNotification('Failed to update plugin enabled state', e); @@ -1514,6 +2049,19 @@ export default class API { } } + static async stopVODClient(clientId) { + try { + const response = await request(`${host}/proxy/vod/stop_client/`, { + method: 'POST', + body: { client_id: clientId }, + }); + + return response; + } catch (e) { + errorNotification('Failed to stop VOD client', e); + } + } + static async stopChannel(id) { try { const response = await request(`${host}/proxy/ts/stop/${id}`, { @@ -1611,7 +2159,7 @@ export default class API { if (!logoIds || logoIds.length === 0) return []; const params = new URLSearchParams(); - logoIds.forEach(id => params.append('ids', id)); + logoIds.forEach((id) => params.append('ids', id)); // Disable pagination for ID-based queries to get all matching logos params.append('no_pagination', 'true'); @@ -1922,6 +2470,24 @@ export default class API { } } + static async duplicateChannelProfile(id, name) { + try { + const response = await request( + `${host}/api/channels/profiles/${id}/duplicate/`, + { + method: 'POST', + body: { name }, + } + ); + + useChannelsStore.getState().addProfile(response); + + return response; + } catch (e) { + errorNotification(`Failed to duplicate channel profile ${id}`, e); + } + } + static async deleteChannelProfile(id) { try { await request(`${host}/api/channels/profiles/${id}/`, { @@ -2060,10 +2626,13 @@ export default class API { static async updateRecurringRule(ruleId, payload) { try { - const response = await request(`${host}/api/channels/recurring-rules/${ruleId}/`, { - method: 'PATCH', - body: payload, - }); + const response = await request( + `${host}/api/channels/recurring-rules/${ruleId}/`, + { + method: 'PATCH', + body: payload, + } + ); return response; } catch (e) { errorNotification(`Failed to update recurring rule ${ruleId}`, e); @@ -2082,9 +2651,13 @@ export default class API { static async deleteRecording(id) { try { - await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' }); + await request(`${host}/api/channels/recordings/${id}/`, { + method: 'DELETE', + }); // Optimistically remove locally for instant UI update - try { useChannelsStore.getState().removeRecording(id); } catch {} + try { + useChannelsStore.getState().removeRecording(id); + } catch {} } catch (e) { errorNotification(`Failed to delete recording ${id}`, e); } @@ -2092,9 +2665,12 @@ export default class API { static async runComskip(recordingId) { try { - const resp = await request(`${host}/api/channels/recordings/${recordingId}/comskip/`, { - method: 'POST', - }); + const resp = await request( + `${host}/api/channels/recordings/${recordingId}/comskip/`, + { + method: 'POST', + } + ); // Refresh recordings list to reflect comskip status when done later // This endpoint just queues the task; the websocket/refresh will update eventually return resp; @@ -2131,7 +2707,10 @@ export default class API { static async deleteSeriesRule(tvgId) { try { - await request(`${host}/api/channels/series-rules/${tvgId}/`, { method: 'DELETE' }); + const encodedTvgId = encodeURIComponent(tvgId); + await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, { + method: 'DELETE', + }); notifications.show({ title: 'Series rule removed' }); } catch (e) { errorNotification('Failed to remove series rule', e); @@ -2141,9 +2720,12 @@ export default class API { static async deleteAllUpcomingRecordings() { try { - const resp = await request(`${host}/api/channels/recordings/bulk-delete-upcoming/`, { - method: 'POST', - }); + const resp = await request( + `${host}/api/channels/recordings/bulk-delete-upcoming/`, + { + method: 'POST', + } + ); notifications.show({ title: `Removed ${resp.removed || 0} upcoming` }); useChannelsStore.getState().fetchRecordings(); return resp; @@ -2164,12 +2746,19 @@ export default class API { } } - static async bulkRemoveSeriesRecordings({ tvg_id, title = null, scope = 'title' }) { + static async bulkRemoveSeriesRecordings({ + tvg_id, + title = null, + scope = 'title', + }) { try { - const resp = await request(`${host}/api/channels/series-rules/bulk-remove/`, { - method: 'POST', - body: { tvg_id, title, scope }, - }); + const resp = await request( + `${host}/api/channels/series-rules/bulk-remove/`, + { + method: 'POST', + body: { tvg_id, title, scope }, + } + ); notifications.show({ title: `Removed ${resp.removed || 0} scheduled` }); return resp; } catch (e) { @@ -2236,8 +2825,6 @@ export default class API { color: 'blue', }); - // First fetch the complete channel data - await useChannelsStore.getState().fetchChannels(); // Then refresh the current table view this.requeryChannels(); } @@ -2288,6 +2875,62 @@ export default class API { } } + static async generateApiKey({ user_id = null, name = '' } = {}) { + try { + const body = {}; + if (user_id) body.user_id = user_id; + if (name) body.name = name; + + const response = await request( + `${host}/api/accounts/api-keys/generate/`, + { + method: 'POST', + body, + } + ); + + // If the backend returned an updated user, refresh the users store + try { + if (response && response.user) { + useUsersStore.getState().updateUser(response.user); + } + } catch (e) { + // ignore store update errors + } + + return response; + } catch (e) { + errorNotification('Failed to generate API key', e); + } + } + + static async revokeApiKey({ user_id = null } = {}) { + try { + const body = {}; + if (user_id) { + body.user_id = user_id; + } + + const response = await request(`${host}/api/accounts/api-keys/revoke/`, { + method: 'POST', + body, + }); + + // If the backend returned an updated user, refresh the users store + try { + if (response && response.user) { + useUsersStore.getState().updateUser(response.user); + } + } catch (e) { + // ignore store update errors + } + + return response; + } catch (e) { + errorNotification('Failed to revoke API key', e); + } + } + static async updateUser(id, body) { try { const response = await request(`${host}/api/accounts/users/${id}/`, { @@ -2331,13 +2974,10 @@ export default class API { try { // Use POST for large ID lists to avoid URL length limitations if (ids.length > 50) { - const response = await request( - `${host}/api/channels/streams/by-ids/`, - { - method: 'POST', - body: { ids }, - } - ); + const response = await request(`${host}/api/channels/streams/by-ids/`, { + method: 'POST', + body: { ids }, + }); return response; } else { // Use GET for small ID lists for backward compatibility @@ -2354,6 +2994,23 @@ export default class API { } } + static async getChannelsByUUIDs(uuids) { + try { + // Use POST for large lists + const response = await request( + `${host}/api/channels/channels/by-uuids/`, + { + method: 'POST', + body: { uuids }, + } + ); + return response; + } catch (e) { + errorNotification('Failed to retrieve channels by UUIDs', e); + throw e; + } + } + // VOD Methods static async getMovies(params = new URLSearchParams()) { try { @@ -2363,8 +3020,9 @@ export default class API { return response; } catch (e) { // Don't show error notification for "Invalid page" errors as they're handled gracefully - const isInvalidPage = e.body?.detail?.includes('Invalid page') || - e.message?.includes('Invalid page'); + const isInvalidPage = + e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve movies', e); @@ -2381,8 +3039,9 @@ export default class API { return response; } catch (e) { // Don't show error notification for "Invalid page" errors as they're handled gracefully - const isInvalidPage = e.body?.detail?.includes('Invalid page') || - e.message?.includes('Invalid page'); + const isInvalidPage = + e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve series', e); @@ -2393,7 +3052,10 @@ export default class API { static async getAllContent(params = new URLSearchParams()) { try { - console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`); + console.log( + 'Calling getAllContent with URL:', + `${host}/api/vod/all/?${params.toString()}` + ); const response = await request( `${host}/api/vod/all/?${params.toString()}` ); @@ -2406,8 +3068,9 @@ export default class API { console.error('Error message:', e.message); // Don't show error notification for "Invalid page" errors as they're handled gracefully - const isInvalidPage = e.body?.detail?.includes('Invalid page') || - e.message?.includes('Invalid page'); + const isInvalidPage = + e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve content', e); @@ -2510,4 +3173,225 @@ export default class API { errorNotification('Failed to retrieve system events', e); } } + + // ───────────────────────────── + // System Notifications + // ───────────────────────────── + + /** + * Get all active notifications for the current user + * @param {boolean} includeDismissed - Whether to include already dismissed notifications + */ + static async getNotifications(includeDismissed = false) { + try { + const params = new URLSearchParams(); + if (includeDismissed) { + params.append('include_dismissed', 'true'); + } + const response = await request( + `${host}/api/core/notifications/?${params.toString()}` + ); + + // Update the store with fetched notifications + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore.getState().setNotifications(response.notifications); + + return response; + } catch (e) { + errorNotification('Failed to retrieve notifications', e); + } + } + + // Get unread notification count + static async getNotificationCount() { + try { + const response = await request(`${host}/api/core/notifications/count/`); + + // Update the store with the count + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore.getState().setUnreadCount(response.unread_count); + + return response; + } catch (e) { + // Silent fail for count - not critical + console.error('Failed to get notification count:', e); + return { unread_count: 0 }; + } + } + + /** + * Dismiss a specific notification + * @param {number} notificationId - The notification ID to dismiss + * @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored') + */ + static async dismissNotification(notificationId, actionTaken = null) { + try { + const body = {}; + if (actionTaken) { + body.action_taken = actionTaken; + } + + const response = await request( + `${host}/api/core/notifications/${notificationId}/dismiss/`, + { + method: 'POST', + body, + } + ); + + // Update the store + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore + .getState() + .dismissNotification(response.notification_key); + + return response; + } catch (e) { + errorNotification('Failed to dismiss notification', e); + } + } + + // Dismiss all notifications + static async dismissAllNotifications() { + try { + const response = await request( + `${host}/api/core/notifications/dismiss-all/`, + { + method: 'POST', + } + ); + + // Update the store + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore.getState().dismissAllNotifications(); + + return response; + } catch (e) { + errorNotification('Failed to dismiss all notifications', e); + } + } + + static async getConnectIntegrations() { + try { + return await request(`${host}/api/connect/integrations/`); + } catch (e) { + errorNotification('Failed to fetch connect integrations', e); + } + } + + static async createConnectIntegration(values) { + try { + const response = await request(`${host}/api/connect/integrations/`, { + method: 'POST', + body: values, + }); + + useConnectStore.getState().addIntegration(response); + + return response; + } catch (e) { + errorNotification('Failed to create integration', e); + } + } + + static async updateConnectIntegration(id, values) { + try { + const response = await request( + `${host}/api/connect/integrations/${id}/`, + { + method: 'PUT', + body: values, + } + ); + + if (response.id) { + useConnectStore.getState().updateIntegration(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update integration', e); + } + } + + static async deleteConnectIntegration(id) { + try { + await request(`${host}/api/connect/integrations/${id}/`, { + method: 'DELETE', + }); + + useConnectStore.getState().removeIntegration(id); + + return true; + } catch (e) { + errorNotification('Failed to delete integration', e); + throw e; + } + } + + static async createConnectSubscription(values) { + try { + await request(`${host}/api/connect/subscriptions/`, { + method: 'POST', + body: values, + }); + + return true; + } catch (e) { + errorNotification('Failed to create subscription', e); + } + } + + static async listConnectSubscriptions(integrationId) { + try { + return await request( + `${host}/api/connect/integrations/${integrationId}/subscriptions/` + ); + } catch (e) { + errorNotification('Failed to fetch subscriptions', e); + } + } + + static async setConnectSubscriptions(integrationId, subscriptions) { + // subscriptions: [{ event, enabled, payload_template }] + console.log(subscriptions); + try { + const response = await request( + `${host}/api/connect/integrations/${integrationId}/subscriptions/set/`, + { + method: 'PUT', + body: subscriptions, + } + ); + + useConnectStore + .getState() + .updateIntegrationSubscriptions(integrationId, response); + + return true; + } catch (e) { + errorNotification('Failed to set subscriptions', e); + throw e; + } + } + + static async getConnectLogs(params = {}) { + try { + const search = new URLSearchParams(); + if (params.page) search.set('page', params.page); + if (params.page_size) search.set('page_size', params.page_size); + if (params.type) search.set('type', params.type); + if (params.integration) search.set('integration', params.integration); + + return await request( + `${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}` + ); + } catch (e) { + errorNotification('Failed to fetch connect logs', e); + } + } } diff --git a/frontend/src/assets/site.webmanifest b/frontend/src/assets/site.webmanifest index 45dc8a20..fa99de77 100644 --- a/frontend/src/assets/site.webmanifest +++ b/frontend/src/assets/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 73805513..94fb169c 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -16,6 +16,7 @@ import useWarningsStore from '../store/warnings'; * @param {string} props.actionKey - Unique key for this type of action (used for suppression) * @param {Function} props.onSuppressChange - Called when "don't show again" option changes * @param {string} [props.size='md'] - Size of the modal + * @param {boolean} [props.loading=false] - Whether the confirm button should show loading state */ const ConfirmationDialog = ({ opened, @@ -31,6 +32,7 @@ const ConfirmationDialog = ({ zIndex = 1000, showDeleteFileOption = false, deleteFileLabel = 'Also delete files from disk', + loading = false, }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -93,10 +95,16 @@ const ConfirmationDialog = ({ )} - - diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 00000000..83fa4de7 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + state = { hasError: false }; + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return
Something went wrong
; + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx new file mode 100644 index 00000000..6ee94243 --- /dev/null +++ b/frontend/src/components/Field.jsx @@ -0,0 +1,83 @@ +import { + NumberInput, + Select, + Switch, + Text, + Textarea, + TextInput, +} from '@mantine/core'; +import React from 'react'; + +export const Field = ({ field, value, onChange }) => { + const description = field.help_text ?? field.description ?? field.value; + const common = { label: field.label, description }; + const effective = value ?? field.default; + switch (field.type) { + case 'info': + return ( +
+ {field.label && ( + + {field.label} + + )} + {description && ( + + {description} + + )} +
+ ); + case 'boolean': + return ( + onChange(field.id, e.currentTarget.checked)} + label={field.label} + description={description} + /> + ); + case 'number': + return ( + onChange(field.id, v)} + placeholder={field.placeholder} + {...common} + /> + ); + case 'select': + return ( +