[
  {
    "path": ".dockerignore",
    "content": "# Git\n.git/\n.gitignore\n\n# Python\n__pycache__/\n*.py[cod]\n.venv/\n.ruff_cache/\n.pytest_cache/\nuv.lock\n\n# App data (user-specific)\ncache/\n.cache/\nsettings.json\n*.pem\n\n# Tools (not needed in container, except specific scripts)\ntools/\n!tools/install-ffmpeg.sh\n!tools/patches/\n!tools/install-ai_upscale.sh\n!tools/export-tensorrt.py\n\n# Tests\n*_test.py\nconftest.py\n\n# Docs\n*.md\n!README.md\nscreenshots/\nLICENSE\n\n# Docker\nDockerfile\ndocker-compose.yml\n.dockerignore\n"
  },
  {
    "path": ".github/workflows/ai-upscale.yml",
    "content": "name: AI Upscale Image\n\non:\n  workflow_dispatch:  # Manual trigger\n  push:\n    branches: [main]\n    paths:\n      - \"Dockerfile.ai_upscale\"\n      - \"entrypoint-ai_upscale.sh\"\n      - \".github/workflows/ai-upscale.yml\"\n  workflow_run:\n    # Also trigger after ffmpeg-base completes to pick up new ffmpeg image\n    workflows: [\"FFmpeg Base Image\"]\n    types: [completed]\n    branches: [main]\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}-ai-upscale\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    # Skip if triggered by failed ffmpeg workflow\n    if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}\n    permissions:\n      contents: read\n      packages: write\n    strategy:\n      matrix:\n        include:\n          - nvidia: 'cuda:12.4'\n            base_image: 'ubuntu:22.04'\n          - nvidia: 'cuda:12.6'\n            base_image: 'ubuntu:24.04'\n          - nvidia: 'cuda:12.8'\n            base_image: 'ubuntu:24.04'\n          - nvidia: 'cuda:13.0'\n            base_image: 'ubuntu:24.04'\n            latest: true\n\n    steps:\n      - name: Free disk space\n        run: |\n          # Remove large unnecessary packages to free up space for torch/tensorrt\n          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache\n          sudo apt-get clean\n          df -h\n\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.workflow_run.head_sha || github.sha }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Parse CUDA version\n        id: cuda\n        run: |\n          # Extract \"12.4\" from \"cuda:12.4\"\n          CUDA_VER=\"${{ matrix.nvidia }}\"\n          CUDA_VER=\"${CUDA_VER#cuda:}\"\n          echo \"version=$CUDA_VER\" >> $GITHUB_OUTPUT\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=raw,value=cuda${{ steps.cuda.outputs.version }}\n            type=raw,value=latest,enable=${{ matrix.latest == true }}\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: Dockerfile.ai_upscale\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            FFMPEG_IMAGE=ghcr.io/${{ github.repository }}-ffmpeg:cuda${{ steps.cuda.outputs.version }}\n            NVIDIA=${{ matrix.nvidia }}\n            BASE_IMAGE=${{ matrix.base_image }}\n          no-cache: true\n\n      - name: Verify image\n        run: |\n          # Free space: BuildKit uses separate storage, prune it before pulling\n          docker buildx prune -af || true\n          docker system prune -af || true\n          df -h\n          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cuda${{ steps.cuda.outputs.version }}\n          # Verify everything that should be enabled is actually linked\n          # Note: --entrypoint bypasses the default entrypoint which tries to build TensorRT engines (requires GPU)\n          docker run --rm --entrypoint sh ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cuda${{ steps.cuda.outputs.version }} -c \"\n            echo '=== Verifying AI Upscale build ==='\n            LDD_OUTPUT=\\$(ldd /usr/local/bin/ffmpeg)\n\n            # Verify ffmpeg binaries exist\n            for bin in ffmpeg ffprobe ffplay; do\n              test -x /usr/local/bin/\\$bin || { echo \\\"ERROR: \\$bin not found\\\"; exit 1; }\n              echo \\\"OK: \\$bin exists\\\"\n            done\n\n            # Verify non-NVIDIA dependencies are satisfied\n            # Note: All NVIDIA libraries (libnvinfer, libcuda, libcudart) are loaded via dlopen,\n            # so ffmpeg works even without NVIDIA GPU/drivers installed\n            MISSING=\\$(echo \\\"\\$LDD_OUTPUT\\\" | grep 'not found' | grep -v -E 'libnvinfer|libnvonnxparser|libcudart|libcuda' || true)\n            if [ -n \\\"\\$MISSING\\\" ]; then\n              echo 'ERROR: Missing non-NVIDIA libraries:'\n              echo \\\"\\$MISSING\\\"\n              exit 1\n            fi\n            echo 'OK: All non-NVIDIA dependencies satisfied'\n\n            # Verify ffmpeg has NO hard CUDA dependency (all CUDA/TensorRT loaded via dlopen)\n            if echo \\\"\\$LDD_OUTPUT\\\" | grep -q 'libcudart'; then\n              echo 'ERROR: libcudart linked at compile time (should use dlopen)'\n              exit 1\n            fi\n            echo 'OK: No libcudart dependency (CUDA Driver API via dlopen)'\n            echo 'OK: libnvinfer loaded via dlopen (not in ldd output)'\n\n            # Verify Python AI packages installed (use pip show, not import - import needs CUDA runtime)\n            echo '=== Verifying Python packages ==='\n            pip3 show torch >/dev/null 2>&1 || { echo 'ERROR: torch not installed'; exit 1; }\n            echo 'OK: torch installed'\n            pip3 show onnx >/dev/null 2>&1 || { echo 'ERROR: onnx not installed'; exit 1; }\n            echo 'OK: onnx installed'\n            pip3 show tensorrt >/dev/null 2>&1 || { echo 'ERROR: tensorrt not installed'; exit 1; }\n            echo 'OK: tensorrt installed'\n\n            # Verify AI upscale scripts exist\n            echo '=== Verifying AI upscale scripts ==='\n            test -x /app/tools/install-ai_upscale.sh || { echo 'ERROR: install-ai_upscale.sh not found'; exit 1; }\n            echo 'OK: install-ai_upscale.sh exists'\n            test -f /app/tools/export-tensorrt.py || { echo 'ERROR: export-tensorrt.py not found'; exit 1; }\n            echo 'OK: export-tensorrt.py exists'\n\n            echo ''\n            echo '=== All verifications passed ==='\n          \"\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  workflow_dispatch:  # Manual trigger\n  push:\n    branches: [main]\n    paths:\n      # Only trigger on files used by main Dockerfile\n      - \"Dockerfile\"\n      - \"*.py\"\n      - \"pyproject.toml\"\n      - \"templates/**\"\n      - \"static/**\"\n      - \"entrypoint.sh\"\n      - \".github/workflows/ci.yml\"\n  pull_request:\n    branches: [main]\n  workflow_run:\n    # Trigger after ffmpeg-base completes to pick up new ffmpeg image\n    workflows: [\"FFmpeg Base Image\"]\n    types: [completed]\n    branches: [main]\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n  FFMPEG_IMAGE: ghcr.io/${{ github.repository }}-ffmpeg:latest\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    # Skip if triggered by failed ffmpeg workflow\n    if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          # For workflow_run, checkout the commit that triggered ffmpeg build\n          ref: ${{ github.event.workflow_run.head_sha || github.sha }}\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.11\"\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v4\n\n      - name: Install dependencies\n        run: uv sync --group dev\n\n      - name: Lint with ruff\n        run: uv run ruff check .\n\n      - name: Type check with basedpyright\n        run: uv run basedpyright\n\n      - name: Run tests\n        run: uv run pytest\n\n  build:\n    runs-on: ubuntu-latest\n    needs: test\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.workflow_run.head_sha || github.sha }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Container Registry\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Verify base image exists\n        run: |\n          if ! docker manifest inspect ${{ env.FFMPEG_IMAGE }} > /dev/null 2>&1; then\n            echo \"ERROR: Base image ${{ env.FFMPEG_IMAGE }} not found\"\n            echo \"Run the 'FFmpeg Base Image' workflow first\"\n            exit 1\n          fi\n          echo \"Base image verified: ${{ env.FFMPEG_IMAGE }}\"\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=raw,value=latest,enable={{is_default_branch}}\n            type=ref,event=branch\n            type=sha,prefix=\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            FFMPEG_IMAGE=${{ env.FFMPEG_IMAGE }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/ffmpeg-base.yml",
    "content": "name: FFmpeg Base Image\n\non:\n  schedule:\n    # Build daily at 3 AM UTC\n    - cron: \"0 3 * * *\"\n  push:\n    branches: [main]\n    paths:\n      - \"Dockerfile.ffmpeg\"\n      - \"tools/install-ffmpeg.sh\"\n      - \".github/workflows/ffmpeg-base.yml\"\n  workflow_dispatch:\n    inputs:\n      ffmpeg_version:\n        description: \"FFmpeg version (e.g., 7.1 or snapshot)\"\n        required: false\n        default: \"snapshot\"\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}-ffmpeg\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    strategy:\n      matrix:\n        include:\n          - nvidia: 'cuda:12.4'\n            base_image: 'ubuntu:22.04'\n          - nvidia: 'cuda:12.6'\n            base_image: 'ubuntu:24.04'\n          - nvidia: 'cuda:12.8'\n            base_image: 'ubuntu:24.04'\n          - nvidia: 'cuda:13.0'\n            base_image: 'ubuntu:24.04'\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Parse CUDA version\n        id: cuda\n        run: |\n          # Extract \"12.4\" from \"cuda:12.4\"\n          CUDA_VER=\"${{ matrix.nvidia }}\"\n          CUDA_VER=\"${CUDA_VER#cuda:}\"\n          echo \"version=$CUDA_VER\" >> $GITHUB_OUTPUT\n\n      - name: Generate build date\n        id: date\n        run: |\n          echo \"date=$(date -u +'%Y-%m-%d')\" >> $GITHUB_OUTPUT\n          echo \"datetime=$(date -u +'%Y-%m-%dT%H:%M:%SZ')\" >> $GITHUB_OUTPUT\n\n      - name: Determine FFmpeg version\n        id: version\n        run: |\n          VERSION=\"${{ github.event.inputs.ffmpeg_version || 'snapshot' }}\"\n          echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n          if [ \"$VERSION\" = \"snapshot\" ]; then\n            echo \"tag=${{ steps.date.outputs.date }}\" >> $GITHUB_OUTPUT\n          else\n            echo \"tag=$VERSION\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=raw,value=cuda${{ steps.cuda.outputs.version }}\n            type=raw,value=latest,enable=${{ steps.cuda.outputs.version == '13.0' }}\n            type=raw,value=${{ steps.date.outputs.date }}-cuda${{ steps.cuda.outputs.version }},enable=${{ steps.version.outputs.version == 'snapshot' }}\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: Dockerfile.ffmpeg\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            BUILD_DATE=${{ steps.date.outputs.datetime }}\n            FFMPEG_VERSION=${{ steps.version.outputs.version }}\n            NVIDIA=${{ matrix.nvidia }}\n            FFMPEG_BASE_IMAGE=${{ matrix.base_image }}\n          no-cache: true\n\n      - name: Verify image\n        run: |\n          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cuda${{ steps.cuda.outputs.version }}\n          # Verify ffmpeg build - check binaries and shared library dependencies\n          # Note: Static libraries (libx264, libx265, etc.) are compiled into the binary\n          # and won't appear in ldd output. If they were missing, the build would have failed.\n          docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cuda${{ steps.cuda.outputs.version }} sh -c \"\n            echo '=== Verifying ffmpeg build ==='\n\n            # Verify all binaries exist\n            for bin in ffmpeg ffprobe ffplay; do\n              test -x /usr/local/bin/\\$bin || { echo \\\"ERROR: \\$bin not found\\\"; exit 1; }\n              echo \\\"OK: \\$bin exists\\\"\n            done\n\n            # Get ldd output for shared library checks\n            LDD_OUTPUT=\\$(ldd /usr/local/bin/ffmpeg)\n\n            # Verify non-NVIDIA shared dependencies are satisfied\n            MISSING=\\$(echo \\\"\\$LDD_OUTPUT\\\" | grep 'not found' | grep -v -E 'libnvinfer|libnvonnxparser|libcudart|libcuda' || true)\n            if [ -n \\\"\\$MISSING\\\" ]; then\n              echo 'ERROR: Missing non-NVIDIA libraries:'\n              echo \\\"\\$MISSING\\\"\n              exit 1\n            fi\n            echo 'OK: All non-NVIDIA shared dependencies satisfied'\n\n            # Verify NVIDIA libraries (all loaded via dlopen - no hard dependencies)\n            # - CUDA Driver API (libcuda): loaded via dlopen when TensorRT backend used\n            # - TensorRT (libnvinfer): loaded via dlopen when TensorRT backend used\n            echo '=== Verifying NVIDIA libraries ==='\n            # Verify ffmpeg has NO hard CUDA dependency\n            if echo \\\"\\$LDD_OUTPUT\\\" | grep -q 'libcudart'; then\n              echo 'ERROR: libcudart linked at compile time (should use dlopen)'\n              exit 1\n            fi\n            echo 'OK: No libcudart dependency (uses CUDA Driver API via dlopen)'\n            ls /usr/lib/x86_64-linux-gnu/libnvinfer.so* >/dev/null 2>&1 || { echo 'ERROR: TensorRT (libnvinfer) not installed'; exit 1; }\n            echo 'OK: libnvinfer installed (loaded via dlopen)'\n\n            # Verify libva is linked (we build it as shared for runtime)\n            echo \\\"\\$LDD_OUTPUT\\\" | grep -q libva || { echo 'ERROR: libva not linked'; exit 1; }\n            echo 'OK: libva linked'\n\n            echo ''\n            echo '=== All verifications passed ==='\n          \"\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n  FFMPEG_IMAGE: ghcr.io/${{ github.repository }}-ffmpeg:latest\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      packages: write\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=semver,pattern={{major}}\n            type=raw,value=latest\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            FFMPEG_IMAGE=${{ env.FFMPEG_IMAGE }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n      - name: Generate release notes\n        id: notes\n        run: |\n          echo \"## Docker Image\" >> notes.md\n          echo \"\" >> notes.md\n          echo \"Pull the image:\" >> notes.md\n          echo \"\\`\\`\\`bash\" >> notes.md\n          echo \"docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}\" >> notes.md\n          echo \"\\`\\`\\`\" >> notes.md\n          echo \"\" >> notes.md\n          echo \"## What's Changed\" >> notes.md\n          git log $(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git rev-list --max-parents=0 HEAD)..HEAD --pretty=format:\"- %s\" >> notes.md || true\n\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@v2\n        with:\n          body_path: notes.md\n          generate_release_notes: true\n"
  },
  {
    "path": ".gitignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n.venv/\n.ruff_cache/\n.pytest_cache/\n\n# Debugging\n**/*.out\n**/*.log\n\n# UV\nuv.lock\n\n# App data\n.cache/\ncache/\n\n# Certificates\n*.pem\n\n# Tools - user data and generated files\n**/*.gz\n**/*.json\n**/*.m3u\n**/*.xml\ntools/.zap2xml/\n"
  },
  {
    "path": "Dockerfile",
    "content": "# netv application image\n#\n# Default build uses pre-built FFmpeg with full hardware support:\n#   docker compose build\n#\n# Alternative: use apt FFmpeg (fewer codecs, no NVENC/QSV):\n#   FFMPEG_IMAGE=ubuntu:24.04 docker compose build\n#\n# The optimized FFmpeg base image includes:\n# - NVENC (NVIDIA hardware encoding)\n# - VAAPI (Intel/AMD hardware encoding)\n# - QSV/VPL (Intel QuickSync)\n# - All major codecs (x264, x265, VP9, AV1, etc.)\n\nARG FFMPEG_IMAGE=ghcr.io/jvdillon/netv-ffmpeg:latest\nFROM ${FFMPEG_IMAGE}\n\nENV DEBIAN_FRONTEND=noninteractive\n\n# Install dependencies\n# - If using apt ffmpeg (ubuntu base): install ffmpeg + python\n# - If using compiled ffmpeg (netv-ffmpeg base): ffmpeg already present, just install python\n# Note: The conditional must be evaluated in shell, not in Dockerfile syntax\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n        gosu \\\n        python3 \\\n        python3-pip && \\\n    # Conditionally install ffmpeg if not present from base image\n    if [ ! -x /usr/local/bin/ffmpeg ] && [ ! -x /usr/bin/ffmpeg ]; then \\\n        apt-get install -y --no-install-recommends ffmpeg; \\\n    fi && \\\n    rm -rf /var/lib/apt/lists/*\n\n# App setup\nWORKDIR /app\n\n# Copy application files with verification\nCOPY pyproject.toml README.md ./\nCOPY *.py ./\nCOPY templates/ templates/\nCOPY static/ static/\n\n# Verify critical files exist\nRUN test -f pyproject.toml || { echo \"ERROR: pyproject.toml not found\"; exit 1; }\n\n# Install Python dependencies\n# --ignore-installed: avoids \"Cannot uninstall X, RECORD file not found\" for apt packages\n# --break-system-packages: required for PEP 668 (Ubuntu 24.04+), doesn't exist in pip 22.0 (Ubuntu 22.04)\n# Using try-fallback approach for maximum compatibility\nRUN if python3 -m pip install --help 2>&1 | grep -q -- '--break-system-packages'; then \\\n        python3 -m pip install --no-cache-dir --ignore-installed --break-system-packages .; \\\n    else \\\n        python3 -m pip install --no-cache-dir --ignore-installed .; \\\n    fi\n\n# Runtime config\nEXPOSE 8000\n\n# Environment variables (see README for details)\nENV NETV_PORT=8000\nENV NETV_HTTPS=\"\"\nENV LOG_LEVEL=INFO\n\n# Create non-root user (entrypoint handles permissions and group membership)\nRUN useradd -m netv\n\n# Copy entrypoint and set permissions with validation\nCOPY entrypoint.sh /app/\nRUN chmod +x /app/entrypoint.sh && \\\n    test -x /app/entrypoint.sh || { echo \"ERROR: entrypoint.sh not executable\"; exit 1; }\n\n# Healthcheck with improved error handling\n# Note: start-period allows time for application startup\nHEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \\\n    CMD python3 -c \"import urllib.request; r=urllib.request.urlopen('http://localhost:8000/', timeout=5); exit(0 if r.status==200 else 1)\" 2>/dev/null || exit 1\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile.ai_upscale",
    "content": "# netv with AI Upscale (TensorRT super-resolution)\n#\n# This image includes everything needed for AI upscaling:\n# - FFmpeg with TensorRT DNN backend\n# - Python + torch + tensorrt for building engines\n# - Auto-builds TensorRT engines on first start (GPU-specific)\n#\n# REQUIREMENTS:\n#   - Docker BuildKit (DOCKER_BUILDKIT=1 or use docker buildx)\n#   - NVIDIA GPU with 8GB+ VRAM recommended\n#   - nvidia-container-toolkit installed on host\n#\n# Build:\n#   docker build -f Dockerfile.ai_upscale -t netv-ai-upscale .\n#\n# Run (engines are cached in volume):\n#   docker run --gpus all -v netv-models:/models -p 8000:8000 netv-ai-upscale\n#\n# Note: First start takes ~2-3 minutes to build TensorRT engines for your GPU.\n# Subsequent starts are instant (engines cached in /models volume).\n\nARG FFMPEG_IMAGE=ghcr.io/jvdillon/netv-ffmpeg:latest\nFROM ${FFMPEG_IMAGE}\n\n# Build metadata (passed from workflow, used for documentation/debugging)\nARG NVIDIA=cuda:13.0\nARG BASE_IMAGE=ubuntu:24.04\n\n# Store build info as labels\nLABEL org.opencontainers.image.description=\"netv with AI Upscale (TensorRT)\"\nLABEL ai.netv.cuda=\"${NVIDIA}\"\nLABEL ai.netv.base=\"${BASE_IMAGE}\"\n\nENV DEBIAN_FRONTEND=noninteractive\n\n# Install dependencies\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    gosu \\\n    python3 \\\n    python3-pip \\\n    && rm -rf /var/lib/apt/lists/*\n\n# App setup\nWORKDIR /app\n\n# Copy application files with verification\nCOPY pyproject.toml README.md ./\nCOPY *.py ./\nCOPY templates/ templates/\nCOPY static/ static/\nCOPY tools/ tools/\n\n# Verify critical files exist\nRUN test -f pyproject.toml || { echo \"ERROR: pyproject.toml not found\"; exit 1; } && \\\n    test -f tools/export-tensorrt.py || { echo \"ERROR: export-tensorrt.py not found\"; exit 1; }\n\n# Install Python dependencies with version constraints for stability\n# --ignore-installed: avoids \"Cannot uninstall X, RECORD file not found\" for apt packages\n# --break-system-packages: required for PEP 668 (Ubuntu 24.04+), doesn't exist in pip 22.0 (Ubuntu 22.04)\n# Version constraints: use compatible versions that work together\nRUN if python3 -m pip install --help 2>&1 | grep -q -- '--break-system-packages'; then \\\n        PIP_OPTS=\"--no-cache-dir --ignore-installed --break-system-packages\"; \\\n    else \\\n        PIP_OPTS=\"--no-cache-dir --ignore-installed\"; \\\n    fi && \\\n    # Install with minimum version constraints for compatibility\n    python3 -m pip install $PIP_OPTS \\\n        'torch>=2.1.0' \\\n        'onnx>=1.14.0' \\\n        'tensorrt>=9.0' \\\n        . && \\\n    # Verify packages installed correctly\n    python3 -c \"import torch; import onnx; import tensorrt; print(f'Installed: torch={torch.__version__}, onnx={onnx.__version__}, tensorrt={tensorrt.__version__}')\" && \\\n    # Remove Windows-only TensorRT libraries to save ~500MB\n    # Log what we're deleting for debugging\n    echo \"Removing Windows-only TensorRT libraries...\" && \\\n    find /usr -name '*_win_*.so*' -type f 2>/dev/null | head -5 | xargs -I{} echo \"  Removing: {}\" && \\\n    find /usr -name '*_win_*.so*' -type f -delete 2>/dev/null || true && \\\n    find /usr -name '*_win.so*' -type f -delete 2>/dev/null || true && \\\n    echo \"Cleanup complete\"\n\n# Runtime config\nEXPOSE 8000\n\n# Environment variables (see README for details)\nENV NETV_PORT=8000\nENV NETV_HTTPS=\"\"\nENV LOG_LEVEL=INFO\nENV SR_ENGINE_DIR=/models\n\n# Create non-root user\nRUN useradd -m netv\n\n# Copy entrypoint and set permissions with validation\nCOPY entrypoint-ai_upscale.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh && \\\n    test -x /app/entrypoint.sh || { echo \"ERROR: entrypoint.sh not executable\"; exit 1; }\n\n# Create models directory with proper permissions (will be a volume mount point)\nRUN mkdir -p /models && \\\n    chown netv:netv /models && \\\n    chmod 755 /models && \\\n    test -d /models && test -w /models || { echo \"ERROR: /models not writable\"; exit 1; }\nVOLUME /models\n\n# Healthcheck with improved error handling\n# Note: start-period=60s allows time for TensorRT engine compilation on first start\nHEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \\\n    CMD python3 -c \"import urllib.request; r=urllib.request.urlopen('http://localhost:8000/', timeout=5); exit(0 if r.status==200 else 1)\" 2>/dev/null || exit 1\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile.ffmpeg",
    "content": "# FFmpeg Docker image with hardware acceleration (NVENC, VAAPI, QSV, AMF)\n#\n# REQUIREMENTS:\n#   - Docker BuildKit (DOCKER_BUILDKIT=1 or use docker buildx)\n#   - 20GB+ disk space for build\n#\n# Uses install-ffmpeg.sh as single source of truth for build configuration.\n#\n# NVIDIA GPU compatibility:\n#   FFmpeg compiles CUDA code to PTX (parallel thread execution) assembly,\n#   NOT to GPU-specific SASS binary code. PTX is forward-compatible: the\n#   NVIDIA driver JIT-compiles PTX to the actual GPU at runtime.\n#\n#   This means a binary built with -arch=sm_52 (Maxwell) runs on ALL GPUs\n#   from Maxwell through Blackwell and beyond. The only cost is JIT compilation\n#   on first run (cached by driver for subsequent runs).\n#\n#   For Docker builds, we use NVCC_GENCODE=minimum (sm_52 for CUDA <13, sm_75 for CUDA 13+)\n#   to maximize GPU compatibility. For local builds, install-ffmpeg.sh defaults to\n#   NVCC_GENCODE=native for best performance on the build machine.\n\n# =============================================================================\n# Builder stage: compile FFmpeg using install-ffmpeg.sh\n# =============================================================================\nARG FFMPEG_BASE_IMAGE=ubuntu:24.04\nFROM ${FFMPEG_BASE_IMAGE} AS builder\n\n# Build configuration - these are passed to install-ffmpeg.sh via environment\n# Hardware acceleration\nARG ENABLE_NVIDIA_CUDA=1\nARG ENABLE_AMD_AMF=1\nARG ENABLE_TENSORRT=1\nARG ENABLE_LIBTORCH=0\nARG LIBTORCH_VERSION=2.5.0\nARG LIBTORCH_VARIANT=cu124\n# Library builds\nARG BUILD_LIBPLACEBO=1\nARG LIBPLACEBO_GIT_REF=\nARG BUILD_LIBX265=1\nARG BUILD_LIBAOM=1\nARG BUILD_LIBWEBP=1\nARG BUILD_LIBVPL=1\nARG BUILD_LIBDAV1D=1\nARG BUILD_LIBSVTAV1=1\nARG BUILD_LIBVMAF=1\nARG BUILD_LIBVA=1\nARG BUILD_LIBJXL=1\nARG BUILD_LIBX264=1\nARG FFMPEG_VERSION=snapshot\nARG NVIDIA=cuda:12.8\nARG NVCC_GENCODE=minimum\n\nENV DEBIAN_FRONTEND=noninteractive\n# Override install-ffmpeg.sh paths for container\nENV SRC_DIR=/src\nENV BUILD_DIR=/opt/ffmpeg_build\nENV BIN_DIR=/opt/bin\nENV LIB_DIR=/opt/lib\n# Pass build args to script via env\nENV ENABLE_NVIDIA_CUDA=${ENABLE_NVIDIA_CUDA}\nENV ENABLE_AMD_AMF=${ENABLE_AMD_AMF}\nENV ENABLE_TENSORRT=${ENABLE_TENSORRT}\nENV ENABLE_LIBTORCH=${ENABLE_LIBTORCH}\nENV LIBTORCH_VERSION=${LIBTORCH_VERSION}\nENV LIBTORCH_VARIANT=${LIBTORCH_VARIANT}\nENV BUILD_LIBPLACEBO=${BUILD_LIBPLACEBO}\nENV LIBPLACEBO_GIT_REF=${LIBPLACEBO_GIT_REF}\nENV BUILD_LIBX265=${BUILD_LIBX265}\nENV BUILD_LIBAOM=${BUILD_LIBAOM}\nENV BUILD_LIBWEBP=${BUILD_LIBWEBP}\nENV BUILD_LIBVPL=${BUILD_LIBVPL}\nENV BUILD_LIBDAV1D=${BUILD_LIBDAV1D}\nENV BUILD_LIBSVTAV1=${BUILD_LIBSVTAV1}\nENV BUILD_LIBVMAF=${BUILD_LIBVMAF}\nENV BUILD_LIBVA=${BUILD_LIBVA}\nENV BUILD_LIBJXL=${BUILD_LIBJXL}\nENV BUILD_LIBX264=${BUILD_LIBX264}\nENV FFMPEG_VERSION=${FFMPEG_VERSION}\nENV NVIDIA=${NVIDIA}\nENV NVCC_GENCODE=${NVCC_GENCODE}\n\n# Pre-configure timezone to prevent tzdata interactive prompts\nENV DEBIAN_FRONTEND=noninteractive\nENV TZ=Etc/UTC\nRUN ln -fs /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone\n\n# Install sudo (install-ffmpeg.sh uses it, works as no-op when root)\nRUN apt-get update && apt-get install -y sudo\n\n# Copy build script and patches\nCOPY tools/install-ffmpeg.sh /tmp/\nCOPY tools/patches /tmp/patches\n\n# Run the build script\n# Extract CUDA version from NVIDIA arg (e.g., \"cuda:13.0\" -> \"13.0\")\n# Note: echo BUILD_ARGS forces cache invalidation when any build arg changes\nRUN echo \"BUILD_ARGS: NVIDIA=${NVIDIA} FFMPEG_VERSION=${FFMPEG_VERSION} NVCC_GENCODE=${NVCC_GENCODE} \\\n    ENABLE_NVIDIA_CUDA=${ENABLE_NVIDIA_CUDA} ENABLE_AMD_AMF=${ENABLE_AMD_AMF} ENABLE_TENSORRT=${ENABLE_TENSORRT} \\\n    ENABLE_LIBTORCH=${ENABLE_LIBTORCH} BUILD_LIBPLACEBO=${BUILD_LIBPLACEBO} BUILD_LIBX265=${BUILD_LIBX265} \\\n    BUILD_LIBAOM=${BUILD_LIBAOM} BUILD_LIBWEBP=${BUILD_LIBWEBP} BUILD_LIBVPL=${BUILD_LIBVPL} \\\n    BUILD_LIBDAV1D=${BUILD_LIBDAV1D} BUILD_LIBSVTAV1=${BUILD_LIBSVTAV1} BUILD_LIBVMAF=${BUILD_LIBVMAF} \\\n    BUILD_LIBVA=${BUILD_LIBVA} BUILD_LIBJXL=${BUILD_LIBJXL} BUILD_LIBX264=${BUILD_LIBX264}\" && \\\n    chmod +x /tmp/install-ffmpeg.sh && \\\n    CUDA_VERSION=\"${NVIDIA#cuda:}\" && \\\n    export CUDA_VERSION && \\\n    /tmp/install-ffmpeg.sh\n\n# =============================================================================\n# Runtime stage: minimal image with just FFmpeg binaries\n# =============================================================================\nARG FFMPEG_BASE_IMAGE=ubuntu:24.04\nFROM ${FFMPEG_BASE_IMAGE}\n\nARG BUILD_DATE\nARG FFMPEG_VERSION=snapshot\nARG ENABLE_NVIDIA_CUDA=1\nARG ENABLE_AMD_AMF=1\nARG ENABLE_LIBTORCH=0\nARG BUILD_LIBPLACEBO=1\nARG BUILD_LIBX265=1\nARG BUILD_LIBAOM=1\nARG BUILD_LIBWEBP=1\nARG BUILD_LIBVPL=1\nARG BUILD_LIBDAV1D=1\nARG BUILD_LIBSVTAV1=1\nARG BUILD_LIBVMAF=1\nARG BUILD_LIBVA=1\nARG BUILD_LIBJXL=1\nARG BUILD_LIBX264=1\n\nLABEL org.opencontainers.image.created=\"${BUILD_DATE}\"\nLABEL org.opencontainers.image.title=\"netv-ffmpeg\"\nLABEL org.opencontainers.image.description=\"FFmpeg with NVENC, VAAPI, QSV, AMF hardware acceleration\"\nLABEL org.opencontainers.image.version=\"${FFMPEG_VERSION}\"\n\nENV DEBIAN_FRONTEND=noninteractive\n\n# Add Intel graphics PPA for newer Xe driver support (Ubuntu 24.04+ only)\nRUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n    --mount=type=cache,target=/var/lib/apt,sharing=locked \\\n    . /etc/os-release && \\\n    if [ \"$VERSION_CODENAME\" != \"jammy\" ]; then \\\n        apt-get update && apt-get install -y --no-install-recommends software-properties-common && \\\n        add-apt-repository -y ppa:kobuk-team/intel-graphics && \\\n        apt-get update; \\\n    fi\n\n# Add NVIDIA repo and install TensorRT runtime (for TensorRT DNN backend)\n# Note: TensorRT is loaded via dlopen, so ffmpeg works even if these aren't installed,\n# but including them means the Docker image works out of the box with --gpus all\nARG ENABLE_TENSORRT=1\nRUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n    --mount=type=cache,target=/var/lib/apt,sharing=locked \\\n    if [ \"$ENABLE_TENSORRT\" = \"1\" ]; then \\\n        . /etc/os-release && \\\n        UBUNTU_VER=$(echo \"$VERSION_ID\" | tr -d '.') && \\\n        apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && \\\n        curl -fsSL \"https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VER}/x86_64/cuda-keyring_1.1-1_all.deb\" \\\n            -o /tmp/cuda-keyring.deb && \\\n        dpkg -i /tmp/cuda-keyring.deb && rm /tmp/cuda-keyring.deb && \\\n        apt-get update && \\\n        if [ \"$VERSION_CODENAME\" = \"jammy\" ]; then \\\n            apt-get install -y --no-install-recommends libnvinfer8 libnvinfer-plugin8; \\\n        else \\\n            apt-get install -y --no-install-recommends libnvinfer10 libnvinfer-plugin10; \\\n        fi && \\\n        rm -rf /var/lib/apt/lists/*; \\\n    fi\n\n# Runtime libraries for FFmpeg\n# Note: x265, libaom, libwebp, libvpl, libdav1d are statically linked when built from source\nRUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n    --mount=type=cache,target=/var/lib/apt,sharing=locked \\\n    . /etc/os-release && \\\n    if [ \"$VERSION_CODENAME\" = \"jammy\" ]; then \\\n        LIBVPX=libvpx7; \\\n        LIBSRT=libsrt1.4-openssl; \\\n        LIBUNISTRING=libunistring2; \\\n        LIBASOUND=libasound2; \\\n        LIBSNDIO=libsndio7.0; \\\n    else \\\n        LIBVPX=libvpx9; \\\n        LIBSRT=libsrt1.5-openssl; \\\n        LIBUNISTRING=libunistring5; \\\n        LIBASOUND=libasound2t64; \\\n        LIBSNDIO=libsndio7.0; \\\n    fi && \\\n    apt-get update && apt-get install -y --no-install-recommends \\\n    # Core codec libs\n    libass9 \\\n    libbluray2 \\\n    libfdk-aac2 \\\n    libmp3lame0 \\\n    libopus0 \\\n    libvorbis0a \\\n    libvorbisenc2 \\\n    $LIBVPX \\\n    # Text/font rendering\n    libfontconfig1 \\\n    libfreetype6 \\\n    libfribidi0 \\\n    libharfbuzz0b \\\n    # Audio/video processing\n    librubberband2 \\\n    libsoxr0 \\\n    libvidstab1.1 \\\n    libzimg2 \\\n    libnuma1 \\\n    # Network/crypto\n    $LIBSRT \\\n    libssl3 \\\n    # X11/display\n    libxcb1 \\\n    libxcb-shm0 \\\n    libxcb-shape0 \\\n    libxcb-xfixes0 \\\n    libxv1 \\\n    libx11-6 \\\n    libxext6 \\\n    # Hardware accel:\n    # - NVENC/CUDA: provided by nvidia-container-toolkit from host (no pkg needed)\n    # - VAAPI: intel-media-va-driver-non-free (Intel), mesa-va-drivers (AMD), libva from source (below)\n    # - OpenCL: ocl-icd-libopencl1 (ICD loader), backend from host (NVIDIA) or mesa-opencl-icd (AMD)\n    # - Vulkan: libvulkan1 (conditional below), driver from host\n    libvdpau1 \\\n    intel-media-va-driver-non-free \\\n    mesa-va-drivers \\\n    ocl-icd-libopencl1 \\\n    # Intel oneVPL/QSV runtime for Intel GPU hardware encoding (modern Intel CPUs)\n    libmfx-gen1.2 \\\n    # Other deps\n    zlib1g \\\n    $LIBUNISTRING \\\n    liblzma5 \\\n    liblzo2-2 \\\n    $LIBASOUND \\\n    libdrm2 \\\n    $LIBSNDIO \\\n    libsdl2-2.0-0 \\\n    libpulse0\n\n# Conditional runtime libs for apt-based packages (when not built from source)\nRUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n    --mount=type=cache,target=/var/lib/apt,sharing=locked \\\n    . /etc/os-release && \\\n    HWY_PKG=\"libhwy1t64\" && \\\n    if [ \"$VERSION_CODENAME\" = \"jammy\" ]; then HWY_PKG=\"libhwy0\"; fi && \\\n    APT_PKGS=\"\" && \\\n    [ \"$BUILD_LIBX265\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libx265-199\" ; \\\n    [ \"$BUILD_LIBAOM\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libaom3\" ; \\\n    [ \"$BUILD_LIBWEBP\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libwebp7 libwebpmux3\" ; \\\n    [ \"$BUILD_LIBVPL\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libvpl2\" ; \\\n    [ \"$BUILD_LIBDAV1D\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libdav1d7\" ; \\\n    [ \"$BUILD_LIBSVTAV1\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libsvtav1enc1d1\" ; \\\n    [ \"$BUILD_LIBVMAF\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libvmaf3\" ; \\\n    [ \"$BUILD_LIBPLACEBO\" = \"1\" ] && APT_PKGS=\"$APT_PKGS libvulkan1\" ; \\\n    [ \"$BUILD_LIBVA\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libva2 libva-drm2 libva-x11-2\" ; \\\n    [ \"$BUILD_LIBJXL\" = \"1\" ] && APT_PKGS=\"$APT_PKGS libbrotli1 $HWY_PKG\" ; \\\n    [ \"$BUILD_LIBJXL\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libjxl0.7\" ; \\\n    [ \"$BUILD_LIBX264\" != \"1\" ] && APT_PKGS=\"$APT_PKGS libx264-164\" ; \\\n    if [ -n \"$APT_PKGS\" ]; then apt-get update && apt-get install -y --no-install-recommends $APT_PKGS; fi\n\n# Tell libva where to find system drivers and which driver to use\n# Our libva is built with prefix=/opt/ffmpeg_build but drivers are system-installed\n# Default LIBVA_DRIVER_NAME=iHD (Intel iHD driver, supports Xe kernel driver, Gen8+)\n# Override at runtime for other GPUs:\n#   - AMD: LIBVA_DRIVER_NAME=radeonsi\n#   - Older Intel: LIBVA_DRIVER_NAME=i965\n#   - Auto-detect: unset LIBVA_DRIVER_NAME (let libva auto-select)\nENV LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri\n# Note: Set to empty to allow auto-detection, or override at container runtime\nARG LIBVA_DRIVER_NAME_DEFAULT=iHD\nENV LIBVA_DRIVER_NAME=${LIBVA_DRIVER_NAME_DEFAULT}\n\n# Copy FFmpeg binaries from builder and verify they exist\nCOPY --from=builder /opt/bin/ffmpeg /usr/local/bin/\nCOPY --from=builder /opt/bin/ffprobe /usr/local/bin/\nCOPY --from=builder /opt/bin/ffplay /usr/local/bin/\n\n# Verify FFmpeg binaries are executable and have expected size (not empty)\nRUN for bin in ffmpeg ffprobe ffplay; do \\\n        if [ ! -x \"/usr/local/bin/$bin\" ]; then \\\n            echo \"ERROR: $bin not executable or not found\"; exit 1; \\\n        fi; \\\n        SIZE=$(stat -c%s \"/usr/local/bin/$bin\" 2>/dev/null || echo 0); \\\n        if [ \"$SIZE\" -lt 1000000 ]; then \\\n            echo \"ERROR: $bin seems too small (${SIZE} bytes), may be corrupt\"; exit 1; \\\n        fi; \\\n    done && echo \"FFmpeg binaries verified\"\n\n# Copy built libva if compiled from source (for Intel Xe kernel driver support)\n# libva needs to be in /opt/lib to match the rpath embedded in ffmpeg binary\nRUN --mount=type=bind,from=builder,source=/opt/lib,target=/tmp/ffmpeg_libs \\\n    if [ \"$BUILD_LIBVA\" = \"1\" ] && [ -f /tmp/ffmpeg_libs/libva.so ]; then \\\n        mkdir -p /opt/lib && \\\n        cp -a /tmp/ffmpeg_libs/libva*.so* /opt/lib/ && \\\n        echo \"/opt/lib\" > /etc/ld.so.conf.d/ffmpeg.conf && \\\n        ldconfig; \\\n    fi\n\n# Copy VMAF model files if built from source (needed for -vf libvmaf filter)\nRUN --mount=type=bind,from=builder,source=/opt/ffmpeg_build/share,target=/tmp/ffmpeg_share \\\n    if [ \"$BUILD_LIBVMAF\" = \"1\" ] && [ -d /tmp/ffmpeg_share/libvmaf ]; then \\\n        mkdir -p /usr/local/share && \\\n        cp -a /tmp/ffmpeg_share/libvmaf /usr/local/share/; \\\n    fi\n\n# Copy LibTorch shared libraries if torch enabled (needed for DNN filters)\nRUN --mount=type=bind,from=builder,source=/src,target=/tmp/src \\\n    if [ \"$ENABLE_LIBTORCH\" = \"1\" ] && [ -d /tmp/src/libtorch/lib ]; then \\\n        mkdir -p /opt/lib && \\\n        cp -a /tmp/src/libtorch/lib/*.so* /opt/lib/ && \\\n        echo \"/opt/lib\" > /etc/ld.so.conf.d/libtorch.conf && \\\n        ldconfig; \\\n    fi\n\n# Verify all dependencies are satisfied (exclude NVIDIA libs - provided at runtime by nvidia-container-toolkit)\n# NVIDIA libraries excluded: libnvinfer, libnvonnxparser, libcudart, libcuda, libnvcuvid, libnvrtc\nRUN set -e && \\\n    echo \"=== Checking library dependencies ===\" && \\\n    LDD_OUTPUT=$(ldd /usr/local/bin/ffmpeg 2>&1) && \\\n    ALL_MISSING=$(echo \"$LDD_OUTPUT\" | grep \"not found\" || true) && \\\n    if [ -n \"$ALL_MISSING\" ]; then \\\n        echo \"Libraries reported as 'not found':\" && \\\n        echo \"$ALL_MISSING\" && \\\n        # Filter out NVIDIA libraries (provided by nvidia-container-toolkit at runtime)\n        MISSING=$(echo \"$ALL_MISSING\" | grep -v -E \"libnv|libcuda|libcublas|libcurand|libcufft\" || true) && \\\n        if [ -n \"$MISSING\" ]; then \\\n            echo \"ERROR: Non-NVIDIA libraries missing:\" && \\\n            echo \"$MISSING\" && \\\n            exit 1; \\\n        fi && \\\n        echo \"All missing libs are NVIDIA (provided at runtime)\"; \\\n    fi && \\\n    echo \"All FFmpeg dependencies satisfied\"\n\n# Capture ffmpeg capabilities (may fail if TensorRT enabled - NVIDIA libs only available at runtime)\nRUN ffmpeg -version > /ffmpeg-version.txt && \\\n    ffmpeg -hide_banner -encoders > /ffmpeg-encoders.txt && \\\n    ffmpeg -hide_banner -decoders > /ffmpeg-decoders.txt && \\\n    ffmpeg -hide_banner -filters > /ffmpeg-filters.txt || \\\n    echo \"Skipped (NVIDIA libs not available during build)\"\n\nCMD [\"ffmpeg\", \"-version\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to the Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   Copyright 2024\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# neTV\n\nA minimal, self-hosted web interface for IPTV streams.\n\n![EPG Guide](screenshots/epg.png)\n\n![Player](screenshots/player.png)\n\n![VOD](screenshots/vod.png)\n\n![Series](screenshots/series.png)\n\n![Settings](screenshots/settings.png)\n\n## Why This Exists\n\nWe built neTV because we couldn't find a clean, lightweight interface for\nXtream IPTV services. Existing solutions were either bloated media centers or\nclunky apps that didn't work well across devices.\n\n**neTV is intentionally minimal.** It does one thing: play your IPTV streams\nwith a clean UI that works on desktop, tablet, mobile, and Chromecast.\n\nWe also prioritize **keyboard navigation** throughout (though still rough\naround the edges). The entire app is theoretically usable with just arrow keys,\nEnter, and Escape -- perfect for media PCs, HTPCs, or anyone who prefers\nkeeping hands on the keyboard (like me).\n\n### Disclaimer\n\nThis is a **player only** -- it does not provide any content. You must have your\nown IPTV subscription that provides Xtream Codes API access or M3U playlists.\nUsers are responsible for ensuring they have legal rights to access any content\nthrough their IPTV providers.\n\n## Features\n\n- **Live TV** with EPG grid guide\n- **Movies & Series** with metadata, seasons, episodes\n- **AI Upscale** - Real-time 4x upscaling via TensorRT (720p → 4K @ 85fps)\n- **Chromecast** support (HTTPS required)\n- **Closed captions** with style customization\n- **Search** across all content (supports regex)\n- **Favorites** with drag-and-drop ordering\n- **Resume playback** for VOD content\n- **Responsive** - works on desktop, tablet, mobile\n- **Keyboard navigation** - 10-foot UI friendly\n\n### Transcoding\n\nExtensively optimized for minimal latency and CPU usage:\n\n- **Smart passthrough** - h264+aac streams remux without re-encoding (zero CPU)\n- **Full GPU pipeline** - NVDEC decode → NVENC/VAAPI encode, CPU stays idle\n- **Probe caching** - Streams probed once, series episodes share probe data\n- **Interlace detection** - Auto-deinterlaces OTA/cable, skips progressive\n- **Smart seeking** - Reuses segments for backward seeks, only transcodes gaps\n- **Session recovery** - VOD sessions survive restarts, resume where you left off\n- **HTTPS passthrough** - Auto-proxies HTTP streams when behind HTTPS\n\n### 4K AI Upscaling\n\nReal-time 4x upscaling using Real-ESRGAN via TensorRT. Transforms 480p/720p/1080p\ncontent to pristine 4K at 85fps (RTX 5090). Perfect for older shows and low-bitrate streams.\n\n| Before (720p source) | After (4K AI Upscale) |\n|---|---|\n| ![Before](screenshots/ai-upscale_price-is-right_disabled.png) | ![After](screenshots/ai-upscale_price-is-right_enabled.png) |\n| ![Before](screenshots/ai-upscale_cleopatra_disabled.png) | ![After](screenshots/ai-upscale_cleopatra_enabled.png) |\n| ![Before](screenshots/ai-upscale_batman_disabled.png) | ![After](screenshots/ai-upscale_batman_enabled.png) |\n\nRequires Nvidia GPU and the [AI Upscale Docker image](#ai-upscale-image-nvidia-gpu).\nThe Settings page shows AI Upscale options when TensorRT engines are available.\n\n## Alternatives\n\nIf you want a full-featured media center, you might be happier with:\n\n- **[Jellyfin](https://jellyfin.org/)** - Free, open-source media system\n- **[Emby](https://emby.media/)** - Media server with IPTV support\n- **[Plex](https://plex.tv/)** - Popular media platform with live TV\n\nThese are excellent, mature projects with large communities. neTV exists for\nusers who find them overkill and just want a simple IPTV player.\n\n| | neTV | [nodecast-tv] | [Jellyfin] | [Emby] | [Plex] |\n|---|---|---|---|---|---|\n| **Focus** | IPTV | IPTV | General media | General media | General media |\n| **Xtream Codes** | ✅ | ✅ | ❌ | ❌ | ❌ |\n| **M3U playlists** | ✅ | ✅ | ✅ | ✅ | ⚠️ Via [xTeVe] |\n| **XMLTV EPG** | ✅ | ⚠️ Via provider | ✅ | ✅ | ✅ |\n| **Local media** | ❌ | ❌ | ✅ | ✅ | ✅ |\n| **Live TV** | ✅ | ✅ | ✅ | ✅ | ✅ |\n| **VOD (movies/series)** | ✅ | ✅ | ✅ | ✅ | ✅ |\n| **DVR recording** | ❌ | ❌ | ✅ | ✅ | ⚠️ Pass |\n| **Catchup/timeshift** | ❌ | ❌ | ⚠️ Plugin | ⚠️ Plugin | ❌ |\n| **Live rewind buffer** | ✅ | ❌ | ⚠️ Via DVR | ⚠️ Via DVR | ⚠️ Via DVR |\n| **Resume playback** | ✅ | ❌ | ✅ | ✅ | ✅ |\n| **Multi-user** | ✅ | ✅ | ✅ | ✅ | ✅ |\n| **User roles** | ⚠️ Admin/viewer | ⚠️ Admin/viewer | ✅ Granular | ✅ Granular | ✅ Granular |\n| **Stream limits** | ✅ Per-user, per-source | ❌ | ⚠️ Per-user | ⚠️ Per-user | ⚠️ Per-user |\n| **Library permissions** | N/A | N/A | ✅ Per-library | ✅ Per-library | ✅ Per-library |\n| **Favorites** | ✅ Drag-and-drop | ✅ | ✅ | ✅ | ✅ |\n| **Search** | ✅ Regex | ✅ Basic | ✅ Basic | ✅ Basic | ✅ Basic |\n| **Video transcoding** | ✅ | ❌ | ✅ | ✅ | ✅ |\n| **Audio transcoding** | ✅ | ✅ | ✅ | ✅ | ✅ |\n| **Transcode only if needed** | ✅ Auto mode | ❌ | ⚠️ Per-library | ⚠️ Per-library | ⚠️ Per-client |\n| **NVENC** | ✅ | ❌ | ✅ | ✅ | ⚠️ Pass |\n| **VAAPI** | ✅ | ❌ | ✅ | ✅ | ⚠️ Pass |\n| **QSV** | ✅ | ❌ | ✅ | ✅ | ⚠️ Pass |\n| **AI Upscale (4x)** | ✅ TensorRT | ❌ | ⚠️ Plugin | ❌ | ❌ |\n| **Software fallback** | ✅ | ❌ Browser | ✅ | ✅ | ✅ |\n| **Legacy GPU** | ✅ Any | ❌ No (browser) | ✅ Any | ✅ Any | ⚠️ Driver 450+ |\n| **ffprobe caching** | ✅ Dynamic | ❌ None | ⚠️ Offline | ⚠️ Offline | ⚠️ Offline |\n| **Episode probe reuse** | ✅ MRU | ❌ No | ⚠️ Per-file | ⚠️ Per-file | ⚠️ Per-file |\n| **Session recovery** | ✅ Yes | ❌ No | ⚠️ Via DB | ⚠️ Via DB | ⚠️ Via DB |\n| **Auto deinterlace** | ✅ Yes | ❌ No | ⚠️ Manual | ⚠️ Manual | ⚠️ Manual |\n| **Subtitles** | ⚠️ WebVTT | ❌ No | ✅ Full | ✅ Full | ✅ Full |\n| **Chromecast** | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes |\n| **Keyboard/remote** | ✅ 10-foot UI | ⚠️ Basic | ✅ 10-foot UI | ✅ 10-foot UI | ✅ 10-foot UI |\n| **Mobile apps** | ⚠️ Web only | ⚠️ Web only | ✅ Native | ✅ Native | ✅ Native |\n| **Subscription** | ✅ Free | ✅ Free | ✅ Free | ⚠️ Premiere | ⚠️ Pass |\n| **Setup complexity** | ✅ Minimal | ✅ Minimal | ⚠️ Moderate | ⚠️ Moderate | ⚠️ Moderate |\n| **License** | Apache 2.0 | GPL v3 | GPL v2 | GPL v2 | Proprietary |\n| **Stack** | Python, FFmpeg | Node.js | .NET, FFmpeg | .NET, FFmpeg | Proprietary |\n\n*Corrections welcome — [open an issue](https://github.com/jvdillon/netv/issues).*\n\n[nodecast-tv]: https://github.com/technomancer702/nodecast-tv\n[Jellyfin]: https://jellyfin.org\n[Emby]: https://emby.media\n[Plex]: https://plex.tv\n[xTeVe]: https://github.com/xteve-project/xTeVe\n\n## Installation\n\n### Docker\n\nCreate a `docker-compose.yml`:\n\n```yaml\nservices:\n  netv:\n    image: ghcr.io/jvdillon/netv:latest\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./cache:/app/cache\n      - /etc/localtime:/etc/localtime:ro\n    devices:\n      - /dev/dri:/dev/dri  # for hardware transcoding (remove if no GPU)\n    restart: unless-stopped\n```\n\nThen run:\n\n```bash\ndocker compose up -d\n```\n\nOpen http://localhost:8000. To update: `docker compose pull && docker compose up -d`\n\n#### Optional: Nonfree (proprietary) FFMPEG optimized for Nvidia or AMD and/or Intel GPU\n\nWe provide a custom built ffmpeg with Nvidia, AMD, and Intel _proprietary\nsupport_ for GPUs. Notably, essential packages are built from source and often\n_significantly_ newer than what is baked into Ubuntu 2024 (LTS).\n\nThe custom built ffmpeg is not required unless you want:\n- best possible GPU performance,\n- bleeding edge capability,\n- to use AMD discrete GPU,\n- realtime AI upscaling (Nvidia only).\n\nNote: the custom built ffmpeg will generally work even if a dependency is not\navailable. In such cases the specific capability will not be available but\nother capabilities will still work. In this sense the custom built ffmpeg is a\n\"kitchen sink\" build.\n\n| | Custom ffmpeg | Ubuntu ffmpeg |\n|---|---|---|\n| Intel or AMD Integrated GPU (VAAPI) | ✅ | ✅ |\n| Intel Integrated GPU (QSV QuickSync) | ✅ | ✅ |\n| Nvidia Discrete GPU (NVENC via LLVM) | ❌ | ✅ |\n| Nvidia Discrete GPU (NVENC via nvcc) | ✅ | ❌ |\n| AMD Discrete GPU (AMF) | ✅ | ❌ |\n| Fraunhofer FDK AAC | ✅ | ❌ |\n| Realtime AI Upscale (Nvidia TensorRT/Cuda) | ✅ | ❌ |\n| AV1 Vulkan | ✅ | ❌ |\n| Torch (Nvidia Cuda) | ⚠️ Optional | ❌ |\n\nFor Nvidia, you will need the [nvidia-container-toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html).\n\nTo determine which ffmpeg build for Cuda, check your driver and compute capability:\n```bash\nnvidia-smi --query-gpu=driver_version,compute_cap --format=csv,noheader\n# Example: 580.87.02, 8.6 → Driver 580, compute ≥7.5 → use cuda13.0\n```\n\nFind your CUDA version ([source](https://docs.nvidia.com/cuda/cuda-toolkit-release-notes/index.html)):\n\n| Driver | < 7.5 (Maxwell/Pascal/Volta) | ≥ 7.5 (Turing+) |\n|--------|------------------------------|-----------------|\n| 550 | cuda12.4 | cuda12.4 |\n| 560 | cuda12.6 | cuda12.6 |\n| 570 | cuda12.8 | cuda12.8 |\n| 580+ | cuda12.8 | cuda13.0 |\n\nThen run:\n```bash\nFFMPEG_IMAGE=ghcr.io/jvdillon/netv-ffmpeg:<cuda-version> docker compose --profile nvidia up -d\n```\n\nFor AMD or Intel, it does not matter which version you choose nor do you need Cuda installed.\n\n#### Optional: AI Upscaling (Nvidia GPU only)\n\nFor real-time 2x or 4x AI upscaling (4x: 720p → 4K at ~39fps or 480p → 4K at ~85fps on RTX 5090):\n\n```bash\ngit clone https://github.com/jvdillon/netv.git\ncd netv\ndocker build -f Dockerfile.ai_upscale -t netv-ai-upscale .\ndocker run --gpus all -v netv-models:/models -v ./cache:/app/cache -p 8000:8000 netv-ai-upscale\n```\n\nFirst start builds TensorRT engines for your GPU (~2-3 min). Engines are cached in the\n`netv-models` volume for instant subsequent starts.\n\nRequirements:\n- Nvidia GPU (RTX 20xx or newer recommended)\n- [nvidia-container-toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)\n- Driver 535+ (CUDA 12.x)\n\n#### Docker Custom Builds\n\nFor customization or development:\n\n```bash\ngit clone https://github.com/jvdillon/netv.git\ncd netv\ndocker compose build                              # optimized FFmpeg (default)\n# FFMPEG_IMAGE=ubuntu:24.04 docker compose build  # or stock FFmpeg\ndocker compose up -d\n```\n\nTo update: `git pull && docker compose build && docker compose up -d`\n\n#### Options\n\n```bash\nNETV_PORT=9000 docker compose up -d        # custom port\nNETV_HTTPS=1 docker compose up -d          # enable HTTPS (mount certs first)\n```\n\n### Debian/Ubuntu (`systemd`)\n\nFor peak FFMPEG performance, Chromecast (requires HTTPS), and auto-start:\n\n```bash\n# 1. Install prerequisites (uv, Python)\n./tools/install-prereqs.sh\n\n# 2. (Optional) Get HTTPS certificates (required for Chromecast)\n./tools/install-letsencrypt.sh yourdomain.com\n\n# 3. (Optional) Build FFmpeg (required for optimal Nvidia encoding efficiency)\n./tools/install-ffmpeg.sh\n\n# 4. (Optional) Build AI Upscale engines (requires Nvidia GPU + TensorRT)\nuv sync --group ai_upscale\n./tools/install-ai_upscale.sh\n\n# 5. Install systemd service\nsudo ./tools/install-netv.sh # default port=8000 or --port 9000\n```\n\nManage with:\n\n```bash\nsudo systemctl status netv       # Check status\nsudo systemctl restart netv      # Restart after updates\njournalctl -u netv -f            # View logs\nsudo systemctl edit netv --full  # Change port or other settings\nsudo ./tools/uninstall-netv.sh   # Uninstall\n```\n\n### Development/Testing\n\nRequires Python 3.11+ and [uv](https://docs.astral.sh/uv/):\n\n```bash\ngit clone https://github.com/jvdillon/netv.git\ncd netv\nuv run ./main.py --port 8000  # --https\n```\n\nOr with pip:\n\n```bash\npip install .\n./main.py --port 8000\n```\n\nOpen http://localhost:8000, create an admin account, and add your IPTV source.\n\n### Additional Gems\n\nThere's also some useful applications in `tools/`:\n- `zap2xml.py`: Scrape guide data into XML (I `crontab` this at 5am daily).\n- `alignm3u.py`: Useful for reworking your HDHomeRun m3u to align with guide.\n- `xtream2m3u.py`: Dump xtream to m3u, useful for making Emby work with IPTV.\n\n## Troubleshooting\n\n### Debug Logging\n\nEnable verbose logs to diagnose EPG, M3U parsing, or other issues.\n\n**Docker:**\n\nIn `docker-compose.yml`, change `LOG_LEVEL=INFO` to `LOG_LEVEL=DEBUG`, then restart:\n\n```bash\ndocker compose down && docker compose up -d\ndocker compose logs -f\n```\n\n**Systemd:**\n\n```bash\nsudo systemctl edit netv\n```\n\nAdd:\n\n```ini\n[Service]\nEnvironment=\"LOG_LEVEL=DEBUG\"\n```\n\nThen restart and view logs:\n\n```bash\nsudo systemctl restart netv\njournalctl -u netv -f\n```\n\n**Manual / Development:**\n\n```bash\nLOG_LEVEL=DEBUG ./main.py\n# or\n./main.py --debug\n```\n\n## Q&A\n\n### Where can I get free IPTV?\n\nCheck out [iptv-org/iptv](https://github.com/iptv-org/iptv) -- a community-maintained\ncollection of publicly available IPTV channels from around the world.\n\n### Where can I get TV guide data?\n\nThe free choice is [iptv-org/epg](https://github.com/iptv-org/epg), but this\nhas never worked reliably for me.\n\nFor a more robust solution, consider [Schedules Direct](https://schedulesdirect.org/) --\nyour membership helps fund Open Source projects.\n\nAlternatively you can use `tools/zap2xml.py`. I've used this for over a year\nand found it to be very reliable -- it scrapes guide data from zap2it/gracenote.\n\n### How do I set up HDHomeRun?\n\nHDHomeRun devices provide an M3U playlist, but it lacks EPG channel IDs. Use the\n`tools/` to fetch guide data and align it:\n\n```bash\n# 1. Get your HDHomeRun lineup (replace IP with your device's IP)\nwget http://192.168.1.87/lineup.m3u -O tools/lineup.m3u\n\n# 2. Fetch TV guide data for your area\n./tools/zap2xml.py --zip 90210\n\n# 3. Align the M3U with the guide (adds tvg-id for EPG matching)\n./tools/alignm3u.py --input tools/lineup.m3u --xmltv tools/xmltv.xml --output tools/ota.m3u\n```\n\nThen add `tools/ota.m3u` as an M3U source in neTV settings.\n\nAnd set up a cron job to refresh the guide daily (e.g.,\n`0 5 * * *  /usr/bin/python3 /path/to/netv/tools/zap2xml.py --zip 90210 && cp /path/to/netv/tools/xmltv.xml /var/www/html/`).\n\n### How do I enable hardware transcoding?\n\nHardware transcoding is auto-detected. Check Settings to see available encoders.\n\n- **Intel/AMD (VAAPI)**: Works automatically if `/dev/dri` exists.\n- **Nvidia**: Requires [nvidia-container-toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html).\n  See [Nvidia GPU (NVENC)](#nvidia-gpu-nvenc) installation section for driver/compute compatibility table.\n- **No GPU / VPS**: If `/dev/dri` doesn't exist, comment out the `devices` section\n  in `docker-compose.yml` or compose will fail to start\n\n### How do I install CUDA on Ubuntu?\n\nTested on Ubuntu 24.04 LTS, 25.04, and 25.10.\n\n```bash\n# Step 1: Remove existing Nvidia packages\nsudo apt purge -y '^nv.*' '^libnv.*' '^cuda-.*' '^libcuda-.*' '^cudnn[0-9]*-.*' '^libcudnn[0-9]*-.*'\nsudo apt autoremove -y\n\n# Step 2: Add Nvidia CUDA repository\nwget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb\nsudo dpkg -i cuda-keyring_1.1-1_all.deb\nsudo apt modernize-sources || true\nsudo apt update\n\n# Step 3: Install driver and CUDA toolkit\n# For Turing+ GPUs (RTX 20 series and newer, compute >=7.5):\nsudo apt install -y nvidia-open cuda-toolkit-13 cudnn9-cuda-13 libcudnn9-dev-cuda-13 libnvinfer-bin\n\n# For Maxwell/Pascal GPUs (GTX 900/1000 series, compute <7.5):\n# Driver 590 dropped support. Pin to 580 and use CUDA 12.8.\n# Note: Maxwell/Pascal requires nvidia-driver (proprietary), not nvidia-open.\n# sudo apt install -y nvidia-driver-pinning-580\n# sudo apt install -y nvidia-driver-580 cuda-toolkit-12-8 cudnn9-cuda-12-8 libcudnn9-dev-cuda-12 libnvinfer-bin\n# sudo update-alternatives --set cuda /usr/local/cuda-12.8\n\n# Step 4: Configure environment\ntee -a ~/.bashrc << 'EOF'\nexport CUDA_HOME=/usr/local/cuda\nif [ -d $CUDA_HOME ]; then\n    export PATH=\"${CUDA_HOME}/bin${PATH:+:${PATH}}\"\n    export LD_LIBRARY_PATH=\"${CUDA_HOME}/lib64${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}\"\nfi\nunset CUDA_HOME\nEOF\nsource ~/.bashrc\n\n# Step 5: Verify installation\nnvidia-smi --query-gpu=name,compute_cap,driver_version --format=csv,noheader\nnvcc --version\n```\n\n### What are the keyboard shortcuts?\n\n| Key | Action |\n|-----|--------|\n| `Space` / `k` | Play/pause |\n| `f` | Fullscreen |\n| `m` | Mute |\n| `c` | Toggle captions |\n| `i` | Toggle info overlay |\n| `←` / `→` | Seek ±10s |\n| `↑` / `↓` | Volume |\n| `j` | Jump to time |\n| `Esc` | Back / close |\n\n### What Does \"neTV\" Mean?\n\nYes.\n\nWe leave pronunciation and meaning as an exercise for your idiom:\n\n- **N-E-T-V** -- \"Any TV\", say it out loud\n- **≠TV** -- \"Not Equals TV\", because we're `!=` traditional cable\n- **Net-V** -- \"Net Vision\", because it streams video over your network\n- **Ni!-TV** -- For the [Knights who say Ni](https://www.youtube.com/watch?v=zIV4poUZAQo)\n\nWe will also accept a shrubbery. One that looks nice. And not too expensive.\n\n## Support\n\nIf you find neTV useful, consider buying me a coffee:\n\n[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=flat&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/jvdillon)\n\n## License\n\nApache License 2.0\n"
  },
  {
    "path": "__init__.py",
    "content": ""
  },
  {
    "path": "auth.py",
    "content": "\"\"\"Authentication: users, passwords, tokens, JWT.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport hashlib\nimport hmac\nimport json\nimport pathlib\nimport secrets\nimport time\n\n\nAPP_DIR = pathlib.Path(__file__).parent\n# Use old \"cache\" if it exists (backwards compat), otherwise \".cache\"\n_OLD_CACHE = APP_DIR / \"cache\"\nCACHE_DIR = _OLD_CACHE if _OLD_CACHE.exists() else APP_DIR / \".cache\"\nSERVER_SETTINGS_FILE = CACHE_DIR / \"server_settings.json\"\nUSERS_DIR = CACHE_DIR / \"users\"\nTOKEN_EXPIRY = 86400 * 7  # 7 days\n\n\ndef _get_settings_file() -> pathlib.Path:\n    \"\"\"Get the settings file.\"\"\"\n    return SERVER_SETTINGS_FILE\n\n\ndef _get_secret_key() -> str:\n    \"\"\"Get or generate secret key (persisted in settings).\"\"\"\n    settings_file = _get_settings_file()\n    settings = {}\n    if settings_file.exists():\n        settings = json.loads(settings_file.read_text())\n    if \"secret_key\" not in settings:\n        settings[\"secret_key\"] = secrets.token_hex(32)\n        settings_file.write_text(json.dumps(settings, indent=2))\n    return settings[\"secret_key\"]\n\n\ndef _hash_password(password: str, salt: str | None = None) -> str:\n    \"\"\"Hash password with salt using PBKDF2.\"\"\"\n    if salt is None:\n        salt = secrets.token_hex(16)\n    key = hashlib.pbkdf2_hmac(\"sha256\", password.encode(), salt.encode(), 100000)\n    return f\"{salt}:{key.hex()}\"\n\n\ndef _verify_hashed_password(password: str, hashed: str) -> bool:\n    \"\"\"Verify password against hash.\"\"\"\n    if \":\" not in hashed:\n        return False  # Invalid hash format\n    salt, _ = hashed.split(\":\", 1)\n    return hmac.compare_digest(_hash_password(password, salt), hashed)\n\n\ndef _get_users() -> dict[str, dict[str, Any]]:\n    \"\"\"Get users from settings. Returns empty dict if no users configured.\n\n    User format: {username: {password: str, admin: bool}}\n    \"\"\"\n    settings_file = _get_settings_file()\n    if settings_file.exists():\n        settings = json.loads(settings_file.read_text())\n        return settings.get(\"users\", {})\n    return {}\n\n\ndef get_all_usernames() -> list[str]:\n    \"\"\"Get list of all usernames.\"\"\"\n    return list(_get_users().keys())\n\n\ndef is_setup_required() -> bool:\n    \"\"\"Check if initial setup is required (no users configured).\"\"\"\n    return len(_get_users()) == 0\n\n\ndef create_user(username: str, password: str, admin: bool = False) -> None:\n    \"\"\"Create a new user with hashed password.\"\"\"\n    settings_file = _get_settings_file()\n    settings = {}\n    if settings_file.exists():\n        settings = json.loads(settings_file.read_text())\n    users = settings.get(\"users\", {})\n    # First user is always admin\n    if len(users) == 0:\n        admin = True\n    users[username] = {\"password\": _hash_password(password), \"admin\": admin}\n    settings[\"users\"] = users\n    settings_file.write_text(json.dumps(settings, indent=2))\n    # Create user directory for per-user settings\n    user_dir = USERS_DIR / username\n    user_dir.mkdir(parents=True, exist_ok=True)\n\n\ndef _ensure_one_admin(users: dict[str, dict[str, Any]]) -> None:\n    \"\"\"Ensure at least one user is admin. Promotes first user if needed.\"\"\"\n    if not users or any(u.get(\"admin\") for u in users.values()):\n        return\n    next(iter(users.values()))[\"admin\"] = True\n\n\ndef delete_user(username: str) -> bool:\n    \"\"\"Delete a user. Returns True if deleted, False if not found.\"\"\"\n    settings_file = _get_settings_file()\n    if not settings_file.exists():\n        return False\n    settings = json.loads(settings_file.read_text())\n    users = settings.get(\"users\", {})\n    if username not in users:\n        return False\n    del users[username]\n    _ensure_one_admin(users)\n    settings[\"users\"] = users\n    settings_file.write_text(json.dumps(settings, indent=2))\n    return True\n\n\ndef verify_password(username: str, password: str) -> bool:\n    \"\"\"Verify username and password.\"\"\"\n    users = _get_users()\n    user_data = users.get(username, {\"password\": _hash_password(\"dummy\")})\n    stored = user_data[\"password\"]\n    valid = _verify_hashed_password(password, stored)\n    return valid and username in users\n\n\ndef change_password(username: str, new_password: str) -> bool:\n    \"\"\"Change a user's password. Returns True if successful.\"\"\"\n    settings_file = _get_settings_file()\n    if not settings_file.exists():\n        return False\n    settings = json.loads(settings_file.read_text())\n    users = settings.get(\"users\", {})\n    if username not in users:\n        return False\n    users[username][\"password\"] = _hash_password(new_password)\n    settings[\"users\"] = users\n    settings_file.write_text(json.dumps(settings, indent=2))\n    return True\n\n\ndef is_admin(username: str) -> bool:\n    \"\"\"Check if user is an admin.\"\"\"\n    users = _get_users()\n    user_data = users.get(username, {})\n    return user_data.get(\"admin\", False)\n\n\ndef set_admin(username: str, admin: bool) -> bool:\n    \"\"\"Set admin status for a user. Returns True if successful.\"\"\"\n    settings_file = _get_settings_file()\n    if not settings_file.exists():\n        return False\n    settings = json.loads(settings_file.read_text())\n    users = settings.get(\"users\", {})\n    if username not in users:\n        return False\n    users[username][\"admin\"] = admin\n    _ensure_one_admin(users)\n    settings[\"users\"] = users\n    settings_file.write_text(json.dumps(settings, indent=2))\n    return True\n\n\ndef get_users_with_admin() -> list[dict[str, Any]]:\n    \"\"\"Get list of users with their admin status and limits.\"\"\"\n    users = _get_users()\n    return [\n        {\n            \"username\": u,\n            \"admin\": d.get(\"admin\", False),\n            \"max_streams_per_source\": d.get(\"max_streams_per_source\", {}),\n            \"unavailable_groups\": d.get(\"unavailable_groups\", []),\n        }\n        for u, d in users.items()\n    ]\n\n\ndef get_user_limits(username: str) -> dict[str, Any]:\n    \"\"\"Get user's stream limits and group restrictions.\"\"\"\n    users = _get_users()\n    user_data = users.get(username, {})\n    return {\n        \"max_streams_per_source\": user_data.get(\"max_streams_per_source\", {}),\n        \"unavailable_groups\": user_data.get(\"unavailable_groups\", []),\n    }\n\n\ndef set_user_limits(\n    username: str,\n    max_streams_per_source: dict[str, int] | None = None,\n    unavailable_groups: list[str] | None = None,\n) -> bool:\n    \"\"\"Set user's stream limits and/or group restrictions. Returns True if successful.\"\"\"\n    settings_file = _get_settings_file()\n    if not settings_file.exists():\n        return False\n    settings = json.loads(settings_file.read_text())\n    users = settings.get(\"users\", {})\n    if username not in users:\n        return False\n    if max_streams_per_source is not None:\n        users[username][\"max_streams_per_source\"] = max_streams_per_source\n    if unavailable_groups is not None:\n        users[username][\"unavailable_groups\"] = unavailable_groups\n    settings[\"users\"] = users\n    settings_file.write_text(json.dumps(settings, indent=2))\n    return True\n\n\ndef create_token(payload: dict[str, Any]) -> str:\n    \"\"\"Create a signed JWT-like token.\"\"\"\n    payload = {**payload, \"exp\": int(time.time()) + TOKEN_EXPIRY}\n    data = json.dumps(payload, separators=(\",\", \":\")).encode()\n    sig = hmac.new(_get_secret_key().encode(), data, hashlib.sha256).hexdigest()\n    return f\"{data.hex()}.{sig}\"\n\n\ndef verify_token(token: str) -> dict[str, Any] | None:\n    \"\"\"Verify token and return payload, or None if invalid/expired.\"\"\"\n    try:\n        data_hex, sig = token.split(\".\")\n        data = bytes.fromhex(data_hex)\n        expected = hmac.new(_get_secret_key().encode(), data, hashlib.sha256).hexdigest()\n        if not hmac.compare_digest(sig, expected):\n            return None\n        payload = json.loads(data)\n        if payload.get(\"exp\", 0) < time.time():\n            return None\n        return payload\n    except Exception:\n        return None\n"
  },
  {
    "path": "auth_test.py",
    "content": "\"\"\"Tests for auth.py.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest import mock\n\nimport json\n\nimport pytest\n\n\n@pytest.fixture\ndef auth_module(tmp_path: Path):\n    \"\"\"Import auth module with temp settings file.\"\"\"\n    import auth\n\n    # Patch settings files to temp location\n    original_server = auth.SERVER_SETTINGS_FILE\n    original_users = auth.USERS_DIR\n    auth.SERVER_SETTINGS_FILE = tmp_path / \"server_settings.json\"\n    auth.USERS_DIR = tmp_path / \"users\"\n    auth.USERS_DIR.mkdir(exist_ok=True)\n    yield auth\n    auth.SERVER_SETTINGS_FILE = original_server\n    auth.USERS_DIR = original_users\n\n\nclass TestPasswordHashing:\n    def test_hash_password_creates_salt(self, auth_module):\n        hashed = auth_module._hash_password(\"mypassword\")\n        assert \":\" in hashed\n        salt, key = hashed.split(\":\")\n        assert len(salt) == 32  # 16 bytes hex\n        assert len(key) == 64  # 32 bytes hex\n\n    def test_hash_password_with_salt_deterministic(self, auth_module):\n        salt = \"a\" * 32\n        h1 = auth_module._hash_password(\"test\", salt)\n        h2 = auth_module._hash_password(\"test\", salt)\n        assert h1 == h2\n\n    def test_verify_hashed_password_correct(self, auth_module):\n        hashed = auth_module._hash_password(\"secret\")\n        assert auth_module._verify_hashed_password(\"secret\", hashed)\n\n    def test_verify_hashed_password_wrong(self, auth_module):\n        hashed = auth_module._hash_password(\"secret\")\n        assert not auth_module._verify_hashed_password(\"wrong\", hashed)\n\n\nclass TestUserManagement:\n    def test_is_setup_required_no_users(self, auth_module):\n        assert auth_module.is_setup_required()\n\n    def test_create_user_and_verify(self, auth_module):\n        auth_module.create_user(\"admin\", \"password123\")\n        assert not auth_module.is_setup_required()\n        assert auth_module.verify_password(\"admin\", \"password123\")\n        assert not auth_module.verify_password(\"admin\", \"wrongpass\")\n        assert not auth_module.verify_password(\"nobody\", \"password123\")\n\n\nclass TestTokens:\n    def test_create_and_verify_token(self, auth_module):\n        payload = {\"user\": \"admin\", \"role\": \"admin\"}\n        token = auth_module.create_token(payload)\n        result = auth_module.verify_token(token)\n        assert result is not None\n        assert result[\"user\"] == \"admin\"\n        assert result[\"role\"] == \"admin\"\n        assert \"exp\" in result\n\n    def test_token_format(self, auth_module):\n        token = auth_module.create_token({\"test\": 1})\n        assert \".\" in token\n        _, sig = token.split(\".\")\n        assert len(sig) == 64  # sha256 hex\n\n    def test_invalid_token_rejected(self, auth_module):\n        assert auth_module.verify_token(\"invalid\") is None\n        assert auth_module.verify_token(\"abc.def\") is None\n        assert auth_module.verify_token(\"\") is None\n\n    def test_tampered_token_rejected(self, auth_module):\n        token = auth_module.create_token({\"user\": \"admin\"})\n        # Tamper with signature\n        data, _ = token.split(\".\")\n        tampered = f\"{data}.{'0' * 64}\"\n        assert auth_module.verify_token(tampered) is None\n\n    def test_expired_token_rejected(self, auth_module):\n        # Create token with expired time\n        with mock.patch.object(auth_module, \"TOKEN_EXPIRY\", -1):\n            token = auth_module.create_token({\"user\": \"admin\"})\n        assert auth_module.verify_token(token) is None\n\n\nclass TestSecretKey:\n    def test_get_secret_key_generates_and_persists(self, auth_module):\n        key1 = auth_module._get_secret_key()\n        assert len(key1) == 64  # 32 bytes hex\n\n        # Should return same key\n        key2 = auth_module._get_secret_key()\n        assert key1 == key2\n\n        # Should be persisted (uses _get_settings_file which returns legacy if server doesn't exist)\n        settings_file = auth_module._get_settings_file()\n        settings = json.loads(settings_file.read_text())\n        assert settings[\"secret_key\"] == key1\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "cache.py",
    "content": "\"\"\"File cache, settings, sources management.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\nfrom dataclasses import dataclass, field\nfrom typing import Any\n\nimport hashlib\nimport json\nimport logging\nimport pathlib\nimport subprocess\nimport threading\nimport time\nimport urllib.parse\n\n\nlog = logging.getLogger(__name__)\n\n\n# ===========================================================================\n# VAAPI Auto-Detection\n# ===========================================================================\n\n\ndef _get_gpu_vendor() -> str | None:\n    \"\"\"Detect GPU vendor ID via lspci or sysfs. Returns '8086' (Intel) or '1002' (AMD).\"\"\"\n    # Try lspci first (works on bare metal)\n    try:\n        result = subprocess.run([\"lspci\", \"-nn\"], capture_output=True, text=True, timeout=5)\n        for line in result.stdout.splitlines():\n            if \"VGA\" in line or \"Display\" in line or \"3D\" in line:\n                if \"[8086:\" in line:\n                    return \"8086\"\n                if \"[1002:\" in line:\n                    return \"1002\"\n    except Exception:\n        pass\n\n    # Fallback: check sysfs (works in containers)\n    drm_path = pathlib.Path(\"/sys/class/drm\")\n    if drm_path.exists():\n        for card in drm_path.iterdir():\n            if card.name.startswith(\"card\") and card.name[4:].isdigit():\n                vendor_file = card / \"device\" / \"vendor\"\n                if vendor_file.exists():\n                    vendor = vendor_file.read_text().strip().replace(\"0x\", \"\")\n                    if vendor in (\"8086\", \"1002\"):\n                        return vendor\n    return None\n\n\ndef _detect_vaapi_device() -> str | None:\n    \"\"\"Auto-detect the VAAPI render device. Returns '/dev/dri/renderD128' or None.\"\"\"\n    render = pathlib.Path(\"/dev/dri/renderD128\")\n    return str(render) if render.exists() else None\n\n\ndef _detect_libva_driver() -> str | None:\n    \"\"\"Auto-detect LIBVA driver name. Returns 'iHD', 'i965', 'radeonsi', or None.\"\"\"\n    vendor = _get_gpu_vendor()\n    if vendor == \"8086\":\n        # iHD for Intel Gen8+ (Broadwell 2014+), supports Xe driver\n        # Fall back to i965 for older Intel GPUs\n        dri_path = _detect_dri_path()\n        if dri_path and pathlib.Path(f\"{dri_path}/iHD_drv_video.so\").exists():\n            return \"iHD\"\n        return \"i965\"\n    if vendor == \"1002\":\n        return \"radeonsi\"\n    return None\n\n\ndef _detect_dri_path() -> str | None:\n    \"\"\"Auto-detect the system DRI drivers path.\n\n    Returns path like '/usr/lib/x86_64-linux-gnu/dri' or None.\n    \"\"\"\n    # Check common locations in order of preference\n    candidates = [\n        \"/usr/lib/x86_64-linux-gnu/dri\",  # Debian/Ubuntu\n        \"/usr/lib64/dri\",  # Fedora/RHEL\n        \"/usr/lib/dri\",  # Arch\n    ]\n    for path in candidates:\n        if pathlib.Path(path).is_dir():\n            return path\n    return None\n\n\n# Cached detection results (computed once at import)\nVAAPI_DEVICE = _detect_vaapi_device()\nLIBVA_DRIVER = _detect_libva_driver()\nDRI_PATH = _detect_dri_path()\n\nAPP_DIR = pathlib.Path(__file__).parent\n# Use old \"cache\" if it exists (backwards compat), otherwise \".cache\"\n_OLD_CACHE = APP_DIR / \"cache\"\nCACHE_DIR = _OLD_CACHE if _OLD_CACHE.exists() else APP_DIR / \".cache\"\nCACHE_DIR.mkdir(exist_ok=True)\nSERVER_SETTINGS_FILE = CACHE_DIR / \"server_settings.json\"\nUSERS_DIR = CACHE_DIR / \"users\"\nUSERS_DIR.mkdir(exist_ok=True)\nLOGOS_DIR = CACHE_DIR / \"logos\"\nLOGOS_DIR.mkdir(exist_ok=True)\n\n# Cache TTLs in seconds\nLIVE_CACHE_TTL = 2 * 3600  # 2 hours\nEPG_CACHE_TTL = 6 * 3600  # 6 hours\nVOD_CACHE_TTL = 12 * 3600  # 12 hours\nSERIES_CACHE_TTL = 12 * 3600  # 12 hours\nINFO_CACHE_TTL = 7 * 24 * 3600  # 7 days max for series/movie info\nINFO_CACHE_STALE = 24 * 3600  # Refresh in background after 24 hours\nLOGO_CACHE_TTL = 7 * 24 * 3600  # 7 days for logos (server-side)\nLOGO_BROWSER_TTL = 24 * 3600  # 1 day for browser cache (re-validates before server expires)\nLOGO_MAX_SIZE = 1024 * 1024  # 1MB max logo size\n\n# In-memory cache\n_cache: dict[str, Any] = {}\n_cache_lock = threading.Lock()\n\n\ndef _parse_json_file(path: str) -> tuple[Any, float] | None:\n    \"\"\"Parse JSON file - runs in separate process to avoid GIL blocking.\"\"\"\n    try:\n        with open(path) as f:\n            data = json.load(f)\n        return data.get(\"data\"), data.get(\"timestamp\", 0)\n    except Exception:\n        return None\n\n\ndef load_file_cache(name: str, use_process: bool = False) -> tuple[Any, float] | None:\n    \"\"\"Load cached data from file. Returns (data, timestamp) or None.\n\n    Args:\n        name: Cache file name (without .json extension)\n        use_process: If True, parse in separate process to avoid GIL blocking\n    \"\"\"\n    path = CACHE_DIR / f\"{name}.json\"\n    if not path.exists():\n        return None\n    if use_process:\n        import concurrent.futures\n\n        with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:\n            future = executor.submit(_parse_json_file, str(path))\n            return future.result(timeout=60)\n    try:\n        data = json.loads(path.read_text())\n        return data.get(\"data\"), data.get(\"timestamp\", 0)\n    except Exception:\n        return None\n\n\ndef save_file_cache(name: str, data: Any) -> None:\n    \"\"\"Save data to cache file with current timestamp.\"\"\"\n    path = CACHE_DIR / f\"{name}.json\"\n    path.write_text(json.dumps({\"data\": data, \"timestamp\": time.time()}))\n\n\ndef clear_all_caches() -> None:\n    \"\"\"Clear memory cache except EPG (file cache preserved for restart).\"\"\"\n    with _cache_lock:\n        epg = _cache.get(\"epg\")\n        _cache.clear()\n        if epg:\n            _cache[\"epg\"] = epg\n\n\ndef clear_all_file_caches() -> int:\n    \"\"\"Clear all data file caches (live, vod, series). Returns count deleted.\"\"\"\n    cache_files = [\"live_data.json\", \"vod_data.json\", \"series_data.json\"]\n    deleted = 0\n    for name in cache_files:\n        path = CACHE_DIR / name\n        if path.exists():\n            path.unlink()\n            deleted += 1\n    # Also clear memory cache\n    clear_all_caches()\n    return deleted\n\n\ndef get_cache() -> dict[str, Any]:\n    \"\"\"Get reference to memory cache.\"\"\"\n    return _cache\n\n\ndef get_cache_lock() -> threading.Lock:\n    \"\"\"Get cache lock.\"\"\"\n    return _cache_lock\n\n\ndef _sanitize_name(name: str) -> str:\n    \"\"\"Sanitize a name for use as a directory/file name.\"\"\"\n    # Remove path traversal and special chars\n    name = name.replace(\"..\", \"\").replace(\"/\", \"_\").replace(\"\\\\\", \"_\")\n    name = \"\".join(c for c in name if c.isalnum() or c in \"-_ \")\n    return name[:224] or \"default\"\n\n\ndef _url_to_filename(url: str) -> str:\n    \"\"\"Derive a readable filename from URL with hash suffix to avoid collisions.\"\"\"\n    # Always include hash suffix to avoid collisions\n    url_hash = hashlib.md5(url.encode()).hexdigest()[:8]\n    parsed = urllib.parse.urlparse(url)\n    path = parsed.path.rstrip(\"/\")\n    if path:\n        # Get last path component\n        name = path.split(\"/\")[-1]\n        # Strip extension, we'll add our own\n        if \".\" in name:\n            name = name.rsplit(\".\", 1)[0]\n        name = _sanitize_name(name)\n        if name and len(name) >= 2:\n            return f\"{name}_{url_hash}\"\n    return url_hash\n\n\ndef get_cached_logo(source_name: str, url: str) -> pathlib.Path | None:\n    \"\"\"Get cached logo path if valid and not expired. Returns None if not cached.\"\"\"\n    safe_source = _sanitize_name(source_name)\n    filename = _url_to_filename(url)\n    source_dir = LOGOS_DIR / safe_source\n    if not source_dir.exists():\n        return None\n    # Look for file with any extension\n    for ext in (\"png\", \"jpg\", \"jpeg\", \"gif\", \"webp\", \"svg\"):\n        path = source_dir / f\"{filename}.{ext}\"\n        if path.exists():\n            age = time.time() - path.stat().st_mtime\n            if age < LOGO_CACHE_TTL:\n                return path\n            # Expired, delete it\n            path.unlink(missing_ok=True)\n    return None\n\n\ndef save_logo(source_name: str, url: str, data: bytes, content_type: str) -> pathlib.Path:\n    \"\"\"Save logo to cache. Returns the saved path.\"\"\"\n    safe_source = _sanitize_name(source_name)\n    filename = _url_to_filename(url)\n    source_dir = LOGOS_DIR / safe_source\n    source_dir.mkdir(parents=True, exist_ok=True)\n    # Determine extension from content-type\n    ext_map = {\n        \"image/png\": \"png\",\n        \"image/jpeg\": \"jpg\",\n        \"image/gif\": \"gif\",\n        \"image/webp\": \"webp\",\n        \"image/svg+xml\": \"svg\",\n    }\n    ext = ext_map.get(content_type.split(\";\")[0].strip(), \"png\")\n    path = source_dir / f\"{filename}.{ext}\"\n    # Atomic write: write to temp file then rename\n    tmp = path.with_suffix(\".tmp\")\n    tmp.write_bytes(data)\n    tmp.rename(path)\n    return path\n\n\ndef get_cached_info(cache_key: str, fetch_fn: Callable[[], Any], force: bool = False) -> Any:\n    \"\"\"Get info from memory cache, file cache, or fetch. Stale-while-revalidate.\"\"\"\n    cached = load_file_cache(cache_key)\n    cached_data, cached_ts = cached if cached else (None, 0)\n    age = time.time() - cached_ts\n\n    if force and cached_data:\n        _cache.pop(cache_key, None)\n        cached_data = None\n\n    if cache_key in _cache and not force:\n        if cached_ts and age > INFO_CACHE_STALE:\n\n            def bg_refresh() -> None:\n                try:\n                    data = fetch_fn()\n                    _cache[cache_key] = data\n                    save_file_cache(cache_key, data)\n                    log.info(\"Background refreshed %s\", cache_key)\n                except Exception as e:\n                    log.warning(\"Background refresh failed for %s: %s\", cache_key, e)\n\n            threading.Thread(target=bg_refresh, daemon=True).start()\n        return _cache[cache_key]\n\n    if cached_data and age < INFO_CACHE_TTL:\n        _cache[cache_key] = cached_data\n        if age > INFO_CACHE_STALE:\n\n            def bg_refresh() -> None:\n                try:\n                    data = fetch_fn()\n                    _cache[cache_key] = data\n                    save_file_cache(cache_key, data)\n                    log.info(\"Background refreshed %s\", cache_key)\n                except Exception as e:\n                    log.warning(\"Background refresh failed for %s: %s\", cache_key, e)\n\n            threading.Thread(target=bg_refresh, daemon=True).start()\n        return cached_data\n\n    data = fetch_fn()\n    _cache[cache_key] = data\n    save_file_cache(cache_key, data)\n    return data\n\n\ndef _test_encoder(cmd: list[str], timeout: int = 5, env: dict | None = None) -> tuple[bool, str]:\n    \"\"\"Test if an encoder works. Returns (success, error_message).\"\"\"\n    try:\n        run_env = None\n        if env:\n            import os\n\n            run_env = os.environ.copy()\n            run_env.update(env)\n        result = subprocess.run(cmd, capture_output=True, timeout=timeout, env=run_env)\n        if result.returncode == 0:\n            return True, \"\"\n        stderr = result.stderr.decode(errors=\"replace\").strip()\n        # Extract the most relevant error line\n        for line in stderr.split(\"\\n\"):\n            if line and not line.startswith(\"[\"):\n                return False, line\n        return False, stderr if stderr else \"unknown error\"\n    except subprocess.TimeoutExpired:\n        return False, \"timeout\"\n    except FileNotFoundError:\n        return False, \"ffmpeg not found\"\n    except Exception as e:\n        return False, str(e)\n\n\ndef detect_encoders() -> dict[str, bool]:\n    \"\"\"Detect available FFmpeg H.264 encoders by testing actual hardware.\"\"\"\n    log.info(\"Detecting hardware encoders...\")\n    encoders = {\n        \"nvenc\": False,\n        \"amf\": False,\n        \"qsv\": False,\n        \"vaapi\": False,\n    }\n\n    # Test input: 1 frame of 256x256 black (64x64 is below NVENC minimum on newer GPUs)\n    test_input = [\"-f\", \"lavfi\", \"-i\", \"color=black:s=256x256:d=0.04\", \"-frames:v\", \"1\"]\n    base_cmd = [\"ffmpeg\", \"-hide_banner\", \"-loglevel\", \"error\", \"-y\"]\n    null_out = [\"-f\", \"null\", \"-\"]\n\n    # NVENC: try nvenc directly\n    ok, err = _test_encoder(base_cmd + test_input + [\"-c:v\", \"h264_nvenc\"] + null_out)\n    encoders[\"nvenc\"] = ok\n    if ok:\n        log.info(\"  NVENC (h264_nvenc): available\")\n    else:\n        log.info(\"  NVENC (h264_nvenc): unavailable - %s\", err)\n\n    # AMF: try amf directly\n    ok, err = _test_encoder(base_cmd + test_input + [\"-c:v\", \"h264_amf\"] + null_out)\n    encoders[\"amf\"] = ok\n    if ok:\n        log.info(\"  AMF (h264_amf): available\")\n    else:\n        log.info(\"  AMF (h264_amf): unavailable - %s\", err)\n\n    # QSV: needs hwaccel init\n    ok, err = _test_encoder(\n        base_cmd\n        + [\"-hwaccel\", \"qsv\", \"-hwaccel_output_format\", \"qsv\"]\n        + test_input\n        + [\"-c:v\", \"h264_qsv\"]\n        + null_out\n    )\n    encoders[\"qsv\"] = ok\n    if ok:\n        log.info(\"  QSV (h264_qsv): available\")\n    else:\n        log.info(\"  QSV (h264_qsv): unavailable - %s\", err)\n\n    # VA-API: needs device, hwupload, and driver env vars for hybrid GPU systems\n    vaapi_baseline_only = False\n    if VAAPI_DEVICE and LIBVA_DRIVER and DRI_PATH:\n        vaapi_env = {\n            \"LIBVA_DRIVER_NAME\": LIBVA_DRIVER,\n            \"LIBVA_DRIVERS_PATH\": DRI_PATH,\n        }\n        # Try high profile first, fall back to constrained_baseline for older GPUs\n        ok, err = _test_encoder(\n            base_cmd\n            + [\"-init_hw_device\", f\"vaapi=va:{VAAPI_DEVICE}\"]\n            + test_input\n            + [\"-vf\", \"format=nv12,hwupload\", \"-c:v\", \"h264_vaapi\"]\n            + null_out,\n            env=vaapi_env,\n        )\n        if not ok:\n            # Some older AMD GPUs (GCN 1.0) only support baseline profile\n            ok, err = _test_encoder(\n                base_cmd\n                + [\"-init_hw_device\", f\"vaapi=va:{VAAPI_DEVICE}\"]\n                + test_input\n                + [\n                    \"-vf\",\n                    \"format=nv12,hwupload\",\n                    \"-c:v\",\n                    \"h264_vaapi\",\n                    \"-profile:v\",\n                    \"constrained_baseline\",\n                ]\n                + null_out,\n                env=vaapi_env,\n            )\n            if ok:\n                vaapi_baseline_only = True\n        encoders[\"vaapi\"] = ok\n        encoders[\"vaapi_baseline_only\"] = vaapi_baseline_only\n        if ok:\n            profile_note = \" (baseline only)\" if vaapi_baseline_only else \"\"\n            log.info(\n                \"  VAAPI (h264_vaapi): available%s (device=%s, driver=%s)\",\n                profile_note,\n                VAAPI_DEVICE,\n                LIBVA_DRIVER,\n            )\n        else:\n            log.info(\"  VAAPI (h264_vaapi): unavailable - %s\", err)\n    else:\n        log.info(\"  VAAPI (h264_vaapi): unavailable - no Intel/AMD GPU detected\")\n\n    return encoders\n\n\nAVAILABLE_ENCODERS = detect_encoders()\n\n\ndef refresh_encoders() -> dict[str, bool]:\n    \"\"\"Re-detect available encoders and update the cache.\"\"\"\n    global AVAILABLE_ENCODERS\n    AVAILABLE_ENCODERS = detect_encoders()\n    return AVAILABLE_ENCODERS\n\n\ndef _default_encoder() -> str:\n    \"\"\"Return first available encoder option.\n\n    Preference order: nvenc > amf > qsv > vaapi > software\n    For nvenc/amf, prefer +vaapi fallback if VAAPI is available.\n    \"\"\"\n    if AVAILABLE_ENCODERS.get(\"nvenc\"):\n        return \"nvenc+vaapi\" if AVAILABLE_ENCODERS.get(\"vaapi\") else \"nvenc+software\"\n    if AVAILABLE_ENCODERS.get(\"amf\"):\n        return \"amf+vaapi\" if AVAILABLE_ENCODERS.get(\"vaapi\") else \"amf+software\"\n    if AVAILABLE_ENCODERS.get(\"qsv\"):\n        return \"qsv\"\n    if AVAILABLE_ENCODERS.get(\"vaapi\"):\n        return \"vaapi\"\n    return \"software\"\n\n\n@dataclass(slots=True)\nclass Source:\n    id: str\n    name: str\n    type: str  # \"xtream\", \"m3u\", or \"epg\"\n    url: str\n    username: str = \"\"\n    password: str = \"\"\n    epg_timeout: int = 120  # seconds\n    epg_schedule: list[str] = field(default_factory=list)  # [\"03:00\", \"15:00\"]\n    epg_enabled: bool = True  # Whether to fetch EPG from this source\n    epg_url: str = \"\"  # EPG URL (auto-detected from M3U/Xtream, or manual override)\n    deinterlace_fallback: bool = True  # Deinterlace when probe is skipped (for OTA/HDHomeRun)\n    max_streams: int = 0  # Max concurrent streams from this source (0 = unlimited)\n\n\ndef load_server_settings() -> dict[str, Any]:\n    \"\"\"Load server-wide settings.\"\"\"\n    if SERVER_SETTINGS_FILE.exists():\n        data: dict[str, Any] = json.loads(SERVER_SETTINGS_FILE.read_text())\n    else:\n        data = {}\n    data.setdefault(\"transcode_mode\", \"auto\")\n\n    # Migrate old transcode_hw values to new format\n    old_hw = data.get(\"transcode_hw\", \"\")\n    if old_hw == \"nvidia\":\n        data[\"transcode_hw\"] = (\n            \"nvenc+vaapi\" if AVAILABLE_ENCODERS.get(\"vaapi\") else \"nvenc+software\"\n        )\n    elif old_hw == \"intel\":\n        data[\"transcode_hw\"] = \"qsv\"\n    # \"vaapi\" and \"software\" remain unchanged\n\n    data.setdefault(\"transcode_hw\", _default_encoder())\n    data.setdefault(\"vod_transcode_cache_mins\", 60)\n    # 0 = no caching (dead sessions cleaned immediately)\n    data.setdefault(\"live_transcode_cache_secs\", 0)\n    data.setdefault(\"live_dvr_mins\", 0)  # 0 = disabled (default 30 sec buffer)\n    data.setdefault(\"transcode_dir\", \"\")  # Empty = system temp dir\n    data.setdefault(\"probe_live\", True)\n    data.setdefault(\"probe_movies\", True)\n    data.setdefault(\"probe_series\", False)\n    data.setdefault(\"sources\", [])\n    data.setdefault(\"users\", {})\n    data.setdefault(\"user_agent_preset\", \"tivimate\")\n    data.setdefault(\"user_agent_custom\", \"\")\n    return data\n\n\ndef save_server_settings(settings: dict[str, Any]) -> None:\n    \"\"\"Save server-wide settings.\"\"\"\n    SERVER_SETTINGS_FILE.write_text(json.dumps(settings, indent=2))\n\n\ndef _validate_username(username: str) -> None:\n    \"\"\"Validate username to prevent path traversal and length attacks.\"\"\"\n    if (\n        not username\n        or len(username) > 64\n        or \"..\" in username\n        or \"/\" in username\n        or \"\\\\\" in username\n    ):\n        raise ValueError(\"Invalid username\")\n\n\ndef load_user_settings(username: str) -> dict[str, Any]:\n    \"\"\"Load per-user settings.\"\"\"\n    _validate_username(username)\n    user_file = USERS_DIR / username / \"settings.json\"\n    if user_file.exists():\n        data = json.loads(user_file.read_text())\n    else:\n        data = {}\n    data.setdefault(\"guide_filter\", [])\n    data.setdefault(\"captions_enabled\", True)\n    data.setdefault(\"watch_history\", {})\n    data.setdefault(\"favorites\", {\"series\": {}, \"movies\": {}})\n    data.setdefault(\"cc_lang\", \"\")\n    data.setdefault(\"cc_style\", {})\n    data.setdefault(\"cast_host\", \"\")\n    return data\n\n\ndef save_user_settings(username: str, settings: dict[str, Any]) -> None:\n    \"\"\"Save per-user settings.\"\"\"\n    _validate_username(username)\n    user_dir = USERS_DIR / username\n    user_dir.mkdir(exist_ok=True)\n    (user_dir / \"settings.json\").write_text(json.dumps(settings, indent=2))\n\n\ndef get_watch_position(username: str, stream_url: str) -> dict[str, Any] | None:\n    \"\"\"Get saved watch position for a stream. Returns None if not found or >=95% watched.\"\"\"\n    settings = load_user_settings(username)\n    history = settings.get(\"watch_history\", {})\n    entry = history.get(stream_url)\n    if not entry:\n        return None\n    # Reset if >=95% watched\n    if entry.get(\"duration\", 0) > 0:\n        pct = entry.get(\"position\", 0) / entry[\"duration\"]\n        if pct >= 0.95:\n            return None\n    return entry\n\n\ndef save_watch_position(username: str, stream_url: str, position: float, duration: float) -> None:\n    \"\"\"Save watch position for a stream.\"\"\"\n    settings = load_user_settings(username)\n    history = settings.setdefault(\"watch_history\", {})\n    history[stream_url] = {\n        \"position\": position,\n        \"duration\": duration,\n        \"updated\": time.time(),\n    }\n    # Keep only last 200 entries\n    if len(history) > 200:\n        sorted_entries = sorted(history.items(), key=lambda x: x[1].get(\"updated\", 0), reverse=True)\n        settings[\"watch_history\"] = dict(sorted_entries[:200])\n    save_user_settings(username, settings)\n\n\ndef get_sources() -> list[Source]:\n    \"\"\"Get list of configured sources.\"\"\"\n    settings = load_server_settings()\n    return [Source(**s) for s in settings.get(\"sources\", [])]\n\n\ndef update_source_epg_url(source_id: str, epg_url: str) -> None:\n    \"\"\"Update a source's epg_url in settings (only if currently empty).\"\"\"\n    if not epg_url:\n        return\n    settings = load_server_settings()\n    for s in settings.get(\"sources\", []):\n        if s[\"id\"] == source_id and not s.get(\"epg_url\"):\n            s[\"epg_url\"] = epg_url\n            save_server_settings(settings)\n            log.info(\"Saved EPG URL for source %s: %s\", source_id, epg_url)\n            break\n"
  },
  {
    "path": "cache_test.py",
    "content": "\"\"\"Tests for cache.py.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest import mock\n\nimport subprocess\n\nimport pytest\n\nimport cache\n\n\n@pytest.fixture\ndef cache_module(tmp_path: Path):\n    \"\"\"Import cache module with temp directories.\"\"\"\n\n    # Patch paths to temp locations\n    original_server_settings = cache.SERVER_SETTINGS_FILE\n    original_users_dir = cache.USERS_DIR\n    original_cache_dir = cache.CACHE_DIR\n    cache.SERVER_SETTINGS_FILE = tmp_path / \"server_settings.json\"\n    cache.USERS_DIR = tmp_path / \"users\"\n    cache.USERS_DIR.mkdir(exist_ok=True)\n    cache.CACHE_DIR = tmp_path / \"cache\"\n    cache.CACHE_DIR.mkdir(exist_ok=True)\n\n    # Clear memory cache\n    cache._cache.clear()\n\n    yield cache\n\n    cache.SERVER_SETTINGS_FILE = original_server_settings\n    cache.USERS_DIR = original_users_dir\n    cache.CACHE_DIR = original_cache_dir\n    cache._cache.clear()\n\n\nclass TestFileCache:\n    def test_save_and_load_file_cache(self, cache_module):\n        cache_module.save_file_cache(\"test\", {\"key\": \"value\"})\n        result = cache_module.load_file_cache(\"test\")\n        assert result is not None\n        data, ts = result\n        assert data == {\"key\": \"value\"}\n        assert ts > 0\n\n    def test_load_nonexistent_cache(self, cache_module):\n        assert cache_module.load_file_cache(\"nonexistent\") is None\n\n    def test_load_corrupted_cache(self, cache_module):\n        path = cache_module.CACHE_DIR / \"corrupted.json\"\n        path.write_text(\"not valid json\")\n        assert cache_module.load_file_cache(\"corrupted\") is None\n\n\nclass TestMemoryCache:\n    def test_get_cache_returns_reference(self, cache_module):\n        cache = cache_module.get_cache()\n        cache[\"test\"] = 123\n        assert cache_module.get_cache()[\"test\"] == 123\n\n    def test_clear_all_caches_preserves_epg(self, cache_module):\n        cache = cache_module.get_cache()\n        cache[\"epg\"] = {\"data\": \"epg\"}\n        cache[\"live\"] = {\"data\": \"live\"}\n        cache_module.clear_all_caches()\n        assert \"epg\" in cache\n        assert \"live\" not in cache\n\n\nclass TestCachedInfo:\n    def test_get_cached_info_calls_fetch(self, cache_module):\n        fetch_fn = mock.Mock(return_value={\"result\": 42})\n        result = cache_module.get_cached_info(\"test_key\", fetch_fn)\n        assert result == {\"result\": 42}\n        fetch_fn.assert_called_once()\n\n    def test_get_cached_info_uses_memory_cache(self, cache_module):\n        fetch_fn = mock.Mock(return_value={\"result\": 1})\n        cache_module.get_cached_info(\"key1\", fetch_fn)\n        cache_module.get_cached_info(\"key1\", fetch_fn)\n        # Only called once - second call uses memory cache\n        fetch_fn.assert_called_once()\n\n    def test_get_cached_info_force_bypasses_memory(self, cache_module):\n        fetch_fn = mock.Mock(return_value={\"result\": 1})\n        cache_module.get_cached_info(\"key2\", fetch_fn)\n        cache_module.get_cached_info(\"key2\", fetch_fn, force=True)\n        assert fetch_fn.call_count == 2\n\n\nclass TestSettings:\n    def test_load_settings_defaults(self, cache_module):\n        settings = cache_module.load_server_settings()\n        assert settings[\"sources\"] == []\n        assert settings[\"transcode_mode\"] == \"auto\"\n        assert settings[\"transcode_hw\"] in (\n            \"nvenc+vaapi\",\n            \"nvenc+software\",\n            \"amf+vaapi\",\n            \"amf+software\",\n            \"qsv\",\n            \"vaapi\",\n            \"software\",\n        )\n        assert settings[\"probe_movies\"] is True\n\n    def test_save_and_load_settings(self, cache_module):\n        settings = {\"sources\": [{\"id\": \"s1\", \"name\": \"Test\"}], \"custom\": True}\n        cache_module.save_server_settings(settings)\n        loaded = cache_module.load_server_settings()\n        assert loaded[\"sources\"] == [{\"id\": \"s1\", \"name\": \"Test\"}]\n        assert loaded[\"custom\"] is True\n\n\nclass TestUserSettings:\n    def test_load_user_settings_defaults(self, cache_module):\n        settings = cache_module.load_user_settings(\"testuser\")\n        assert settings[\"guide_filter\"] == []\n        assert settings[\"captions_enabled\"] is True\n        assert settings[\"watch_history\"] == {}\n\n    def test_save_and_load_user_settings(self, cache_module):\n        settings = {\"guide_filter\": [\"cat1\", \"cat2\"], \"captions_enabled\": False}\n        cache_module.save_user_settings(\"testuser\", settings)\n        loaded = cache_module.load_user_settings(\"testuser\")\n        assert loaded[\"guide_filter\"] == [\"cat1\", \"cat2\"]\n        assert loaded[\"captions_enabled\"] is False\n\n    def test_watch_position_save_and_get(self, cache_module):\n        cache_module.save_watch_position(\"user1\", \"http://video.url\", 120.5, 3600.0)\n        entry = cache_module.get_watch_position(\"user1\", \"http://video.url\")\n        assert entry is not None\n        assert entry[\"position\"] == 120.5\n        assert entry[\"duration\"] == 3600.0\n\n    def test_watch_position_resets_at_95_percent(self, cache_module):\n        # Save at 96% watched\n        cache_module.save_watch_position(\"user1\", \"http://video.url\", 960.0, 1000.0)\n        entry = cache_module.get_watch_position(\"user1\", \"http://video.url\")\n        assert entry is None  # Should be reset\n\n\nclass TestSource:\n    def test_source_dataclass(self, cache_module):\n        source = cache_module.Source(\n            id=\"test\",\n            name=\"Test Source\",\n            type=\"xtream\",\n            url=\"http://example.com\",\n        )\n        assert source.id == \"test\"\n        assert source.username == \"\"\n        assert source.epg_timeout == 120\n        assert source.epg_enabled is True\n\n    def test_get_sources_empty(self, cache_module):\n        sources = cache_module.get_sources()\n        assert sources == []\n\n    def test_get_sources_from_settings(self, cache_module):\n        settings = {\n            \"sources\": [\n                {\n                    \"id\": \"s1\",\n                    \"name\": \"Source 1\",\n                    \"type\": \"m3u\",\n                    \"url\": \"http://example.com/playlist.m3u\",\n                }\n            ]\n        }\n        cache_module.save_server_settings(settings)\n        sources = cache_module.get_sources()\n        assert len(sources) == 1\n        assert sources[0].id == \"s1\"\n        assert sources[0].type == \"m3u\"\n\n\nclass TestUpdateSourceEpgUrl:\n    def test_update_source_epg_url(self, cache_module):\n        settings = {\"sources\": [{\"id\": \"s1\", \"name\": \"S1\", \"type\": \"m3u\", \"url\": \"http://x\"}]}\n        cache_module.save_server_settings(settings)\n        cache_module.update_source_epg_url(\"s1\", \"http://epg.example.com\")\n        loaded = cache_module.load_server_settings()\n        assert loaded[\"sources\"][0][\"epg_url\"] == \"http://epg.example.com\"\n\n    def test_update_source_epg_url_not_overwrite(self, cache_module):\n        settings = {\n            \"sources\": [\n                {\n                    \"id\": \"s1\",\n                    \"name\": \"S1\",\n                    \"type\": \"m3u\",\n                    \"url\": \"http://x\",\n                    \"epg_url\": \"http://existing\",\n                }\n            ]\n        }\n        cache_module.save_server_settings(settings)\n        cache_module.update_source_epg_url(\"s1\", \"http://new\")\n        loaded = cache_module.load_server_settings()\n        assert loaded[\"sources\"][0][\"epg_url\"] == \"http://existing\"\n\n    def test_update_source_epg_url_empty_noop(self, cache_module):\n        settings = {\"sources\": [{\"id\": \"s1\", \"name\": \"S1\", \"type\": \"m3u\", \"url\": \"http://x\"}]}\n        cache_module.save_server_settings(settings)\n        cache_module.update_source_epg_url(\"s1\", \"\")\n        loaded = cache_module.load_server_settings()\n        assert \"epg_url\" not in loaded[\"sources\"][0]\n\n\nclass TestEncoderDetection:\n    \"\"\"Tests for encoder detection functions.\"\"\"\n\n    def test_test_encoder_success(self):\n        \"\"\"Test _test_encoder returns (True, '') on successful command.\"\"\"\n        with mock.patch(\"subprocess.run\") as mock_run:\n            mock_run.return_value = mock.Mock(returncode=0)\n            ok, err = cache._test_encoder([\"echo\", \"test\"])\n            assert ok is True\n            assert err == \"\"\n            mock_run.assert_called_once()\n\n    def test_test_encoder_failure(self):\n        \"\"\"Test _test_encoder returns (False, error) on non-zero return code.\"\"\"\n        with mock.patch(\"subprocess.run\") as mock_run:\n            mock_run.return_value = mock.Mock(returncode=1, stderr=b\"encoder not found\")\n            ok, err = cache._test_encoder([\"false\"])\n            assert ok is False\n            assert \"encoder not found\" in err\n\n    def test_test_encoder_timeout(self):\n        \"\"\"Test _test_encoder returns (False, 'timeout') on timeout.\"\"\"\n        with mock.patch(\"subprocess.run\") as mock_run:\n            mock_run.side_effect = subprocess.TimeoutExpired(cmd=[\"test\"], timeout=5)\n            ok, err = cache._test_encoder([\"sleep\", \"100\"], timeout=5)\n            assert ok is False\n            assert err == \"timeout\"\n\n    def test_test_encoder_exception(self):\n        \"\"\"Test _test_encoder returns (False, error) on exception.\"\"\"\n        with mock.patch(\"subprocess.run\") as mock_run:\n            mock_run.side_effect = FileNotFoundError(\"ffmpeg not found\")\n            ok, err = cache._test_encoder([\"nonexistent_command\"])\n            assert ok is False\n            assert err == \"ffmpeg not found\"\n\n    def test_detect_encoders_all_available(self):\n        \"\"\"Test detect_encoders when all hardware is available.\"\"\"\n        with (\n            mock.patch.object(cache, \"_test_encoder\", return_value=(True, \"\")),\n            mock.patch.object(cache, \"VAAPI_DEVICE\", \"/dev/dri/renderD128\"),\n            mock.patch.object(cache, \"LIBVA_DRIVER\", \"i965\"),\n            mock.patch.object(cache, \"DRI_PATH\", \"/usr/lib/x86_64-linux-gnu/dri\"),\n        ):\n            result = cache.detect_encoders()\n            assert result == {\n                \"nvenc\": True,\n                \"amf\": True,\n                \"qsv\": True,\n                \"vaapi\": True,\n                \"vaapi_baseline_only\": False,\n            }\n\n    def test_detect_encoders_none_available(self):\n        \"\"\"Test detect_encoders when no hardware is available.\"\"\"\n        with mock.patch.object(cache, \"_test_encoder\", return_value=(False, \"not found\")):\n            result = cache.detect_encoders()\n            assert result == {\n                \"nvenc\": False,\n                \"amf\": False,\n                \"qsv\": False,\n                \"vaapi\": False,\n            }\n\n    def test_detect_encoders_partial(self):\n        \"\"\"Test detect_encoders with mixed hardware availability.\"\"\"\n\n        def mock_test(cmd, timeout=5, env=None):\n            # Return True only for VAAPI\n            if \"h264_vaapi\" in cmd:\n                return True, \"\"\n            return False, \"not available\"\n\n        with (\n            mock.patch.object(cache, \"_test_encoder\", side_effect=mock_test),\n            mock.patch.object(cache, \"VAAPI_DEVICE\", \"/dev/dri/renderD128\"),\n            mock.patch.object(cache, \"LIBVA_DRIVER\", \"i965\"),\n            mock.patch.object(cache, \"DRI_PATH\", \"/usr/lib/x86_64-linux-gnu/dri\"),\n        ):\n            result = cache.detect_encoders()\n            assert result[\"nvenc\"] is False\n            assert result[\"amf\"] is False\n            assert result[\"qsv\"] is False\n            assert result[\"vaapi\"] is True\n\n    def test_detect_encoders_nvenc_only(self):\n        \"\"\"Test detect_encoders when only NVENC is available.\"\"\"\n\n        def mock_test(cmd, timeout=5, env=None):\n            if \"h264_nvenc\" in cmd:\n                return True, \"\"\n            return False, \"not available\"\n\n        with mock.patch.object(cache, \"_test_encoder\", side_effect=mock_test):\n            result = cache.detect_encoders()\n            assert result[\"nvenc\"] is True\n            assert result[\"amf\"] is False\n            assert result[\"qsv\"] is False\n            assert result[\"vaapi\"] is False\n\n    def test_detect_encoders_vaapi_command_structure(self):\n        \"\"\"Test detect_encoders passes correct VAAPI command structure when GPU detected.\"\"\"\n        captured_cmds = []\n        captured_envs = []\n\n        def capture_cmd(cmd, timeout=5, env=None):\n            captured_cmds.append(cmd)\n            captured_envs.append(env)\n            return False, \"test\"\n\n        # Mock auto-detected VAAPI device\n        with (\n            mock.patch.object(cache, \"_test_encoder\", side_effect=capture_cmd),\n            mock.patch.object(cache, \"VAAPI_DEVICE\", \"/dev/dri/renderD128\"),\n            mock.patch.object(cache, \"LIBVA_DRIVER\", \"i965\"),\n            mock.patch.object(cache, \"DRI_PATH\", \"/usr/lib/x86_64-linux-gnu/dri\"),\n        ):\n            cache.detect_encoders()\n\n        # Find VAAPI commands (now 2: High profile first, then baseline fallback)\n        vaapi_cmds = [c for c in captured_cmds if \"h264_vaapi\" in c]\n        assert len(vaapi_cmds) == 2\n        # First command: High profile (default, no -profile:v)\n        assert \"-init_hw_device\" in vaapi_cmds[0]\n        assert \"hwupload\" in \" \".join(vaapi_cmds[0])\n        assert \"constrained_baseline\" not in \" \".join(vaapi_cmds[0])\n        # Second command: constrained_baseline fallback\n        assert \"constrained_baseline\" in \" \".join(vaapi_cmds[1])\n\n    def test_detect_encoders_qsv_command_structure(self):\n        \"\"\"Test detect_encoders passes correct QSV command structure.\"\"\"\n        captured_cmds = []\n\n        def capture_cmd(cmd, timeout=5, env=None):\n            captured_cmds.append(cmd)\n            return False, \"test\"\n\n        with mock.patch.object(cache, \"_test_encoder\", side_effect=capture_cmd):\n            cache.detect_encoders()\n\n        # Find QSV command\n        qsv_cmd = [c for c in captured_cmds if \"h264_qsv\" in c][0]\n        assert \"-hwaccel\" in qsv_cmd\n        assert \"qsv\" in qsv_cmd\n        assert \"-hwaccel_output_format\" in qsv_cmd\n\n    def test_refresh_encoders_updates_global(self):\n        \"\"\"Test refresh_encoders updates AVAILABLE_ENCODERS.\"\"\"\n        original = cache.AVAILABLE_ENCODERS.copy()\n\n        with mock.patch.object(\n            cache,\n            \"detect_encoders\",\n            return_value={\"nvenc\": True, \"amf\": True, \"qsv\": True, \"vaapi\": True},\n        ):\n            result = cache.refresh_encoders()\n            assert cache.AVAILABLE_ENCODERS == {\n                \"nvenc\": True,\n                \"amf\": True,\n                \"qsv\": True,\n                \"vaapi\": True,\n            }\n            assert result == cache.AVAILABLE_ENCODERS\n\n        # Restore original\n        cache.AVAILABLE_ENCODERS = original\n\n    def test_default_encoder_prefers_nvenc_with_vaapi(self):\n        \"\"\"Test _default_encoder prefers NVENC+VAAPI when both available.\"\"\"\n        original = cache.AVAILABLE_ENCODERS.copy()\n        cache.AVAILABLE_ENCODERS = {\n            \"nvenc\": True,\n            \"amf\": True,\n            \"qsv\": True,\n            \"vaapi\": True,\n        }\n        try:\n            assert cache._default_encoder() == \"nvenc+vaapi\"\n        finally:\n            cache.AVAILABLE_ENCODERS = original\n\n    def test_default_encoder_nvenc_without_vaapi(self):\n        \"\"\"Test _default_encoder uses NVENC+software when VAAPI unavailable.\"\"\"\n        original = cache.AVAILABLE_ENCODERS.copy()\n        cache.AVAILABLE_ENCODERS = {\n            \"nvenc\": True,\n            \"amf\": False,\n            \"qsv\": False,\n            \"vaapi\": False,\n        }\n        try:\n            assert cache._default_encoder() == \"nvenc+software\"\n        finally:\n            cache.AVAILABLE_ENCODERS = original\n\n    def test_default_encoder_falls_back_to_amf(self):\n        \"\"\"Test _default_encoder falls back to AMF when NVENC unavailable.\"\"\"\n        original = cache.AVAILABLE_ENCODERS.copy()\n        cache.AVAILABLE_ENCODERS = {\n            \"nvenc\": False,\n            \"amf\": True,\n            \"qsv\": True,\n            \"vaapi\": True,\n        }\n        try:\n            assert cache._default_encoder() == \"amf+vaapi\"\n        finally:\n            cache.AVAILABLE_ENCODERS = original\n\n    def test_default_encoder_falls_back_to_qsv(self):\n        \"\"\"Test _default_encoder falls back to QSV when NVENC/AMF unavailable.\"\"\"\n        original = cache.AVAILABLE_ENCODERS.copy()\n        cache.AVAILABLE_ENCODERS = {\n            \"nvenc\": False,\n            \"amf\": False,\n            \"qsv\": True,\n            \"vaapi\": True,\n        }\n        try:\n            assert cache._default_encoder() == \"qsv\"\n        finally:\n            cache.AVAILABLE_ENCODERS = original\n\n    def test_default_encoder_falls_back_to_vaapi(self):\n        \"\"\"Test _default_encoder falls back to VAAPI when NVENC/AMF/QSV unavailable.\"\"\"\n        original = cache.AVAILABLE_ENCODERS.copy()\n        cache.AVAILABLE_ENCODERS = {\n            \"nvenc\": False,\n            \"amf\": False,\n            \"qsv\": False,\n            \"vaapi\": True,\n        }\n        try:\n            assert cache._default_encoder() == \"vaapi\"\n        finally:\n            cache.AVAILABLE_ENCODERS = original\n\n    def test_default_encoder_falls_back_to_software(self):\n        \"\"\"Test _default_encoder falls back to software as last resort.\"\"\"\n        original = cache.AVAILABLE_ENCODERS.copy()\n        cache.AVAILABLE_ENCODERS = {\n            \"nvenc\": False,\n            \"amf\": False,\n            \"qsv\": False,\n            \"vaapi\": False,\n        }\n        try:\n            assert cache._default_encoder() == \"software\"\n        finally:\n            cache.AVAILABLE_ENCODERS = original\n\n\nclass TestLogoCache:\n    \"\"\"Tests for logo caching functions.\"\"\"\n\n    def test_sanitize_name_removes_path_traversal(self):\n        assert \"..\" not in cache._sanitize_name(\"../../../etc/passwd\")\n        assert \"/\" not in cache._sanitize_name(\"foo/bar\")\n        assert \"\\\\\" not in cache._sanitize_name(\"foo\\\\bar\")\n\n    def test_sanitize_name_keeps_safe_chars(self):\n        assert cache._sanitize_name(\"my-source_123\") == \"my-source_123\"\n        assert cache._sanitize_name(\"Source Name\") == \"Source Name\"\n\n    def test_sanitize_name_truncates_long_names(self):\n        long_name = \"a\" * 300\n        result = cache._sanitize_name(long_name)\n        assert len(result) == 224\n\n    def test_sanitize_name_empty_returns_default(self):\n        assert cache._sanitize_name(\"\") == \"default\"\n        assert cache._sanitize_name(\"!!!\") == \"default\"\n\n    def test_url_to_filename_extracts_name(self):\n        result = cache._url_to_filename(\"http://example.com/logos/channel1.png\")\n        assert result.startswith(\"channel1_\")\n        assert len(result) == len(\"channel1_\") + 8  # name + underscore + 8 char hash\n\n    def test_url_to_filename_strips_extension(self):\n        result = cache._url_to_filename(\"http://example.com/logo.png\")\n        assert not result.endswith(\".png\")\n        assert result.startswith(\"logo_\")\n\n    def test_url_to_filename_hash_differs_by_url(self):\n        r1 = cache._url_to_filename(\"http://example.com/a/logo.png\")\n        r2 = cache._url_to_filename(\"http://example.com/b/logo.png\")\n        # Same base name but different hashes\n        assert r1.startswith(\"logo_\")\n        assert r2.startswith(\"logo_\")\n        assert r1 != r2\n\n    def test_url_to_filename_fallback_to_hash(self):\n        result = cache._url_to_filename(\"http://example.com/\")\n        assert len(result) == 8  # Just the hash\n\n    def test_save_and_get_cached_logo(self, cache_module, tmp_path):\n        cache_module.LOGOS_DIR = tmp_path / \"logos\"\n        cache_module.LOGOS_DIR.mkdir()\n\n        # Save a logo\n        data = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * 100  # Fake PNG\n        path = cache_module.save_logo(\n            \"TestSource\", \"http://example.com/logo.png\", data, \"image/png\"\n        )\n        assert path.exists()\n        assert path.suffix == \".png\"\n        assert path.read_bytes() == data\n\n        # Get cached logo\n        cached = cache_module.get_cached_logo(\"TestSource\", \"http://example.com/logo.png\")\n        assert cached == path\n\n    def test_get_cached_logo_returns_none_when_missing(self, cache_module, tmp_path):\n        cache_module.LOGOS_DIR = tmp_path / \"logos\"\n        cache_module.LOGOS_DIR.mkdir()\n\n        cached = cache_module.get_cached_logo(\"NoSource\", \"http://missing.com/logo.png\")\n        assert cached is None\n\n    def test_get_cached_logo_expires(self, cache_module, tmp_path):\n        import time\n\n        cache_module.LOGOS_DIR = tmp_path / \"logos\"\n        cache_module.LOGOS_DIR.mkdir()\n\n        # Save a logo\n        data = b\"\\x89PNG\" + b\"\\x00\" * 100\n        path = cache_module.save_logo(\"TestSource\", \"http://example.com/old.png\", data, \"image/png\")\n\n        # Backdate the file\n        old_time = time.time() - cache_module.LOGO_CACHE_TTL - 100\n        import os\n\n        os.utime(path, (old_time, old_time))\n\n        # Should be expired\n        cached = cache_module.get_cached_logo(\"TestSource\", \"http://example.com/old.png\")\n        assert cached is None\n        assert not path.exists()  # Should be deleted\n\n    def test_save_logo_content_type_mapping(self, cache_module, tmp_path):\n        cache_module.LOGOS_DIR = tmp_path / \"logos\"\n        cache_module.LOGOS_DIR.mkdir()\n\n        data = b\"test\"\n        assert cache_module.save_logo(\"s\", \"http://a.com/1\", data, \"image/jpeg\").suffix == \".jpg\"\n        assert cache_module.save_logo(\"s\", \"http://a.com/2\", data, \"image/gif\").suffix == \".gif\"\n        assert cache_module.save_logo(\"s\", \"http://a.com/3\", data, \"image/webp\").suffix == \".webp\"\n        assert cache_module.save_logo(\"s\", \"http://a.com/4\", data, \"image/svg+xml\").suffix == \".svg\"\n        assert cache_module.save_logo(\"s\", \"http://a.com/5\", data, \"unknown/type\").suffix == \".png\"\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "# Docker Compose for neTV\n#\n# Build:\n#   docker compose build                              # Default (optimized FFmpeg)\n#   FFMPEG_IMAGE=ubuntu:24.04 docker compose build    # Alternative (apt FFmpeg)\n#\n# Run:\n#   docker compose up -d                    # Auto-detects hardware (Intel/AMD)\n#   docker compose --profile nvidia up -d   # NVIDIA GPU (driver 580+, Turing+)\n#\n# NVIDIA with older drivers/GPUs (see README for driver/compute compatibility):\n#   FFMPEG_IMAGE=ghcr.io/jvdillon/netv-ffmpeg:cuda12.8 docker compose --profile nvidia up -d\n#\n# Local CUDA 12.4 FFmpeg build (from Dockerfile.ffmpeg):\n#   docker build --progress plain --build-arg NVIDIA=cuda:12.4 --build-arg FFMPEG_BASE_IMAGE=ubuntu:22.04 -f Dockerfile.ffmpeg -t netv-ffmpeg:cuda12.4 .\n#   FFMPEG_IMAGE=netv-ffmpeg:cuda12.4 docker compose --profile nvidia build\n#   FFMPEG_IMAGE=netv-ffmpeg:cuda12.4 docker compose --profile nvidia up -d\n#\n# No GPU or /dev/dri? Comment out the 'devices' section below.\n\nservices:\n  netv:\n    build:\n      context: .\n      args:\n        FFMPEG_IMAGE: ${FFMPEG_IMAGE:-ghcr.io/jvdillon/netv-ffmpeg:latest}\n    image: netv\n    container_name: netv\n    ports:\n      - \"${NETV_PORT:-8000}:8000\"\n    environment:\n      - NETV_PORT=8000\n      - NETV_HTTPS=${NETV_HTTPS:-}\n      - LOG_LEVEL=INFO  # DEBUG for verbose logging\n    volumes:\n      - ./cache:/app/cache\n      - /etc/localtime:/etc/localtime:ro  # Use host timezone for EPG\n      # For HTTPS, also mount your certificates:\n      # - /etc/letsencrypt:/etc/letsencrypt:ro\n      # Use system RAM instead of using local storage to store transcodes\n      # - /dev/shm:/tmp\n    restart: unless-stopped\n    # Hardware acceleration (Intel/AMD) - comment out if no /dev/dri\n    devices:\n      - /dev/dri:/dev/dri\n\n  # NVIDIA GPU: docker compose --profile nvidia up -d\n  netv-nvidia:\n    extends:\n      service: netv\n    container_name: netv\n    profiles:\n      - nvidia\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: all\n              capabilities: [gpu]\n"
  },
  {
    "path": "entrypoint-ai_upscale.sh",
    "content": "#!/bin/sh\nset -e\n# Entrypoint for AI Upscale image\n#\n# Same as base entrypoint, plus:\n# - Auto-builds TensorRT engines on first start if missing\n\n# Fix cache directory ownership\nmkdir -p /app/cache\nif [ \"$(stat -c '%U' /app/cache)\" != \"netv\" ]; then\n    chown -R netv:netv /app/cache 2>/dev/null || true\nfi\n# Ensure writable even on filesystems that ignore chown (e.g., some NAS mounts)\nif ! gosu netv sh -c \"touch /app/cache/.perm_test && rm /app/cache/.perm_test\" 2>/dev/null; then\n    chmod -R u+rwX,g+rwX /app/cache 2>/dev/null || true\n    chmod g+s /app/cache 2>/dev/null || true\nfi\n# Final verification - warn if still not writable\nif ! gosu netv sh -c \"touch /app/cache/.perm_test && rm /app/cache/.perm_test\" 2>/dev/null; then\n    echo \"WARNING: /app/cache is not writable by netv user\"\n    echo \"Cache operations may fail. Check volume permissions.\"\nfi\n\n# Fix models directory ownership\nmkdir -p /models\nif [ \"$(stat -c '%U' /models)\" != \"netv\" ]; then\n    chown -R netv:netv /models 2>/dev/null || true\nfi\nif ! gosu netv sh -c \"touch /models/.perm_test && rm /models/.perm_test\" 2>/dev/null; then\n    chmod -R u+rwX,g+rwX /models 2>/dev/null || true\nfi\nif ! gosu netv sh -c \"touch /models/.perm_test && rm /models/.perm_test\" 2>/dev/null; then\n    echo \"WARNING: /models is not writable by netv user\"\n    echo \"TensorRT engine caching may fail. Check volume permissions.\"\nfi\n\n# Add netv user to render device group (for VAAPI hardware encoding)\nif [ -e /dev/dri/renderD128 ]; then\n    RENDER_GID=$(stat -c '%g' /dev/dri/renderD128)\n    RENDER_ADDED=false\n    if groupadd --gid \"$RENDER_GID\" hostrender 2>/dev/null; then\n        :  # Created new group\n    fi\n    if usermod -aG hostrender netv 2>/dev/null; then\n        RENDER_ADDED=true\n    fi\n    if [ \"$RENDER_ADDED\" = \"false\" ]; then\n        echo \"WARNING: Could not add netv to render group (GID $RENDER_GID)\"\n        if [ \"$RENDER_GID\" = \"65534\" ]; then\n            echo \"  GID 65534 (nogroup) indicates Docker user namespace mapping issue.\"\n            echo \"  This is usually harmless - VAAPI may still work if container has device access.\"\n            echo \"  To fix: ensure 'render' group exists on host and user is in it, or use --privileged\"\n        else\n            echo \"  VAAPI hardware encoding may not be available.\"\n            echo \"  To fix on host: sudo usermod -aG render \\$USER (then restart Docker)\"\n        fi\n    fi\nfi\n\n# Build TensorRT engines if missing (first run only)\n# Builds both recommended models: 4x-compact (quality) and 2x-liveaction-span (fast)\nif ! ls /models/4x-compact_*p_fp16.engine >/dev/null 2>&1; then\n    echo \"========================================\"\n    echo \"AI Upscale: First start detected\"\n    echo \"========================================\"\n    echo \"Building TensorRT engines for your GPU...\"\n    echo \"Models: 4x-compact (quality), 2x-liveaction-span (fast)\"\n    echo \"This only happens once (cached in /models volume).\"\n    echo \"\"\n    # Run as netv user so files have correct ownership\n    if ! gosu netv env MODEL_DIR=/models MODEL=\"recommended\" /app/tools/install-ai_upscale.sh; then\n        echo \"ERROR: Failed to build TensorRT engines\"\n        echo \"Check GPU compatibility and CUDA installation\"\n        exit 1\n    fi\n\n    # Verify engines were created\n    if ! ls /models/4x-compact_*p_fp16.engine >/dev/null 2>&1; then\n        echo \"ERROR: TensorRT engines not found after build\"\n        echo \"Build may have succeeded but produced no output\"\n        exit 1\n    fi\nfi\n\n# Drop to netv user and run the app\nexec gosu netv python3 main.py --port \"${NETV_PORT:-8000}\" ${NETV_HTTPS:+--https}\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/sh\nset -e\n# Entrypoint: fix permissions and drop to netv user\n#\n# Handles two common Docker issues:\n# 1. Bind-mounted ./cache owned by host user (permission denied)\n# 2. /dev/dri/renderD128 GID mismatch (VAAPI unavailable)\n\n# Fix cache directory ownership (skip if already correct to avoid slow recursive chown)\n# Build/runtime note: this only applies to bind-mounted cache (e.g., NAS),\n# not to image layers, so it does not affect build reproducibility.\nmkdir -p /app/cache\nif [ \"$(stat -c '%U' /app/cache)\" != \"netv\" ]; then\n    chown -R netv:netv /app/cache 2>/dev/null || true\nfi\n# Ensure writable even on filesystems that ignore chown (e.g., some NAS mounts)\nif ! gosu netv sh -c \"touch /app/cache/.perm_test && rm /app/cache/.perm_test\" 2>/dev/null; then\n    chmod -R u+rwX,g+rwX /app/cache 2>/dev/null || true\n    chmod g+s /app/cache 2>/dev/null || true\nfi\n# Final verification - warn if still not writable\nif ! gosu netv sh -c \"touch /app/cache/.perm_test && rm /app/cache/.perm_test\" 2>/dev/null; then\n    echo \"WARNING: /app/cache is not writable by netv user\"\n    echo \"Cache operations may fail. Check volume permissions.\"\nfi\nmkdir -p /app/cache/users\nif [ \"$(stat -c '%U' /app/cache/users)\" != \"netv\" ]; then\n    chown -R netv:netv /app/cache/users 2>/dev/null || true\nfi\n# Ensure writable even on filesystems that ignore chown (e.g., some NAS mounts)\nif ! gosu netv sh -c \"touch /app/cache/users/.perm_test && rm /app/cache/users/.perm_test\" 2>/dev/null; then\n    chmod -R u+rwX,g+rwX /app/cache/users 2>/dev/null || true\n    chmod g+s /app/cache/users 2>/dev/null || true\nfi\n# Final verification - warn if still not writable\nif ! gosu netv sh -c \"touch /app/cache/users/.perm_test && rm /app/cache/users/.perm_test\" 2>/dev/null; then\n    echo \"WARNING: /app/cache/users is not writable by netv user\"\n    echo \"Cache operations may fail. Check volume permissions.\"\nfi\n\n# Add netv user to render device group (for VAAPI hardware encoding)\nif [ -e /dev/dri/renderD128 ]; then\n    RENDER_GID=$(stat -c '%g' /dev/dri/renderD128)\n    RENDER_ADDED=false\n    if groupadd --gid \"$RENDER_GID\" hostrender 2>/dev/null; then\n        :  # Created new group\n    fi\n    if usermod -aG hostrender netv 2>/dev/null; then\n        RENDER_ADDED=true\n    fi\n    if [ \"$RENDER_ADDED\" = \"false\" ]; then\n        echo \"WARNING: Could not add netv to render group (GID $RENDER_GID)\"\n        if [ \"$RENDER_GID\" = \"65534\" ]; then\n            echo \"  GID 65534 (nogroup) indicates Docker user namespace mapping issue.\"\n            echo \"  This is usually harmless - VAAPI may still work if container has device access.\"\n            echo \"  To fix: ensure 'render' group exists on host and user is in it, or use --privileged\"\n        else\n            echo \"  VAAPI hardware encoding may not be available.\"\n            echo \"  To fix on host: sudo usermod -aG render \\$USER (then restart Docker)\"\n        fi\n    fi\nfi\n\n# Drop to netv user and run the app\nexec gosu netv python3 main.py --port \"${NETV_PORT:-8000}\" ${NETV_HTTPS:+--https}\n"
  },
  {
    "path": "epg.py",
    "content": "\"\"\"EPG storage and XMLTV parsing.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom datetime import UTC, datetime, timedelta, timezone\nfrom pathlib import Path\n\nimport contextlib\nimport gzip\nimport logging\nimport re\nimport sqlite3\nimport threading\nimport time\n\nimport defusedxml.ElementTree as ET  # Safe XML parsing\n\nfrom util import safe_urlopen\n\n\nlog = logging.getLogger(__name__)\n\n\n# =============================================================================\n# Data Types\n# =============================================================================\n\n\n@dataclass(slots=True)\nclass Program:\n    channel_id: str\n    title: str\n    start: datetime\n    stop: datetime\n    desc: str = \"\"\n    source_id: str = \"\"\n\n\n# =============================================================================\n# SQLite Storage\n# =============================================================================\n\n_DB_PATH: Path | None = None\n_local = threading.local()\n\n\ndef init(cache_dir: Path) -> None:\n    \"\"\"Initialize EPG database.\"\"\"\n    global _DB_PATH\n    _DB_PATH = cache_dir / \"epg.db\"\n    conn = _get_conn()\n    conn.executescript(\"\"\"\n        CREATE TABLE IF NOT EXISTS channels (\n            id TEXT PRIMARY KEY,\n            name TEXT,\n            source_id TEXT\n        );\n        CREATE TABLE IF NOT EXISTS icons (\n            channel_id TEXT PRIMARY KEY,\n            url TEXT\n        );\n        CREATE TABLE IF NOT EXISTS programs (\n            id INTEGER PRIMARY KEY,\n            channel_id TEXT,\n            title TEXT,\n            start_ts REAL,\n            stop_ts REAL,\n            desc TEXT,\n            source_id TEXT\n        );\n        CREATE INDEX IF NOT EXISTS idx_programs_channel_time\n            ON programs(channel_id, start_ts, stop_ts);\n        CREATE INDEX IF NOT EXISTS idx_programs_time\n            ON programs(start_ts);\n    \"\"\")\n    conn.commit()\n\n\ndef _get_conn() -> sqlite3.Connection:\n    \"\"\"Get thread-local database connection.\"\"\"\n    if not hasattr(_local, \"conn\") or _local.conn is None:\n        if _DB_PATH is None:\n            raise RuntimeError(\"EPG database not initialized\")\n        _local.conn = sqlite3.connect(_DB_PATH, timeout=30.0)\n        _local.conn.row_factory = sqlite3.Row\n        _local.conn.execute(\"PRAGMA journal_mode=WAL\")\n    return _local.conn\n\n\ndef clear() -> None:\n    \"\"\"Clear all EPG data.\"\"\"\n    conn = _get_conn()\n    conn.executescript(\"DELETE FROM programs; DELETE FROM channels; DELETE FROM icons;\")\n    conn.commit()\n\n\ndef clear_source(source_id: str) -> None:\n    \"\"\"Clear EPG data for a specific source.\"\"\"\n    conn = _get_conn()\n    conn.execute(\"DELETE FROM programs WHERE source_id = ?\", (source_id,))\n    conn.execute(\"DELETE FROM channels WHERE source_id = ?\", (source_id,))\n    conn.commit()\n\n\ndef insert_channel(channel_id: str, name: str, source_id: str) -> None:\n    \"\"\"Insert or update a channel.\"\"\"\n    conn = _get_conn()\n    conn.execute(\n        \"INSERT OR REPLACE INTO channels (id, name, source_id) VALUES (?, ?, ?)\",\n        (channel_id, name, source_id),\n    )\n\n\ndef insert_icon(channel_id: str, url: str) -> None:\n    \"\"\"Insert or update a channel icon.\"\"\"\n    conn = _get_conn()\n    conn.execute(\n        \"INSERT OR REPLACE INTO icons (channel_id, url) VALUES (?, ?)\",\n        (channel_id, url),\n    )\n\n\ndef insert_programs(programs: list[tuple[str, str, float, float, str, str]]) -> None:\n    \"\"\"Bulk insert programs. Each tuple: (channel_id, title, start_ts, stop_ts, desc, source_id).\"\"\"\n    conn = _get_conn()\n    conn.executemany(\n        \"INSERT INTO programs (channel_id, title, start_ts, stop_ts, desc, source_id) VALUES (?, ?, ?, ?, ?, ?)\",\n        programs,\n    )\n\n\ndef commit() -> None:\n    \"\"\"Commit current transaction.\"\"\"\n    _get_conn().commit()\n\n\ndef get_icon(channel_id: str) -> str:\n    \"\"\"Get icon URL for a channel.\"\"\"\n    conn = _get_conn()\n    row = conn.execute(\"SELECT url FROM icons WHERE channel_id = ?\", (channel_id,)).fetchone()\n    return row[\"url\"] if row else \"\"\n\n\ndef get_programs_in_range(\n    channel_id: str,\n    start: datetime,\n    end: datetime,\n    preferred_source_id: str = \"\",\n) -> list[Program]:\n    \"\"\"Get programs for a channel within a time range.\"\"\"\n    conn = _get_conn()\n    start_ts = start.timestamp()\n    end_ts = end.timestamp()\n\n    rows = conn.execute(\n        \"\"\"\n        SELECT channel_id, title, start_ts, stop_ts, desc, source_id\n        FROM programs\n        WHERE channel_id = ? AND stop_ts > ? AND start_ts < ?\n        ORDER BY start_ts\n        \"\"\",\n        (channel_id, start_ts, end_ts),\n    ).fetchall()\n\n    programs = [\n        Program(\n            channel_id=row[\"channel_id\"],\n            title=row[\"title\"],\n            start=datetime.fromtimestamp(row[\"start_ts\"], tz=UTC),\n            stop=datetime.fromtimestamp(row[\"stop_ts\"], tz=UTC),\n            desc=row[\"desc\"] or \"\",\n            source_id=row[\"source_id\"] or \"\",\n        )\n        for row in rows\n    ]\n\n    if not preferred_source_id or len(programs) <= 1:\n        return programs\n\n    # Deduplicate overlapping programs, preferring the preferred source\n    result: list[Program] = []\n    for p in programs:\n        dominated = False\n        for i, existing in enumerate(result):\n            if p.start < existing.stop and p.stop > existing.start:\n                if p.source_id == preferred_source_id and existing.source_id != preferred_source_id:\n                    result[i] = p\n                dominated = True\n                break\n        if not dominated:\n            result.append(p)\n    return sorted(result, key=lambda p: p.start)\n\n\n_MAX_IN_CLAUSE = 500  # SQLite limit is 999, stay well below\n\n\ndef _dedupe_programs(programs: list[Program], preferred_source_id: str) -> list[Program]:\n    \"\"\"Deduplicate overlapping programs, preferring the preferred source.\"\"\"\n    if not preferred_source_id or len(programs) <= 1:\n        return programs\n    result: list[Program] = []\n    for p in programs:\n        dominated = False\n        for i, existing in enumerate(result):\n            # Check for overlap\n            if p.start < existing.stop and p.stop > existing.start:\n                # Prefer the preferred source\n                if p.source_id == preferred_source_id and existing.source_id != preferred_source_id:\n                    result[i] = p\n                dominated = True\n                break\n        if not dominated:\n            result.append(p)\n    return sorted(result, key=lambda p: p.start)\n\n\ndef get_programs_batch(\n    channel_ids: list[str],\n    start: datetime,\n    end: datetime,\n    preferred_sources: dict[str, str] | None = None,\n) -> dict[str, list[Program]]:\n    \"\"\"Get programs for multiple channels in a single query.\n\n    Args:\n        channel_ids: List of EPG channel IDs to query\n        start: Start of time window\n        end: End of time window\n        preferred_sources: Optional dict mapping channel_id -> preferred source_id\n            for deduplication. If provided, overlapping programs from the preferred\n            source will be kept over programs from other sources.\n    \"\"\"\n    if not channel_ids:\n        return {}\n    conn = _get_conn()\n    start_ts = start.timestamp()\n    end_ts = end.timestamp()\n    result: dict[str, list[Program]] = {ch: [] for ch in channel_ids}\n\n    # Process in chunks to avoid huge IN clauses\n    for i in range(0, len(channel_ids), _MAX_IN_CLAUSE):\n        chunk = channel_ids[i : i + _MAX_IN_CLAUSE]\n        placeholders = \",\".join(\"?\" * len(chunk))\n        rows = conn.execute(\n            f\"\"\"\n            SELECT channel_id, title, start_ts, stop_ts, desc, source_id\n            FROM programs\n            WHERE channel_id IN ({placeholders}) AND stop_ts > ? AND start_ts < ?\n            ORDER BY channel_id, start_ts\n            \"\"\",\n            [*chunk, start_ts, end_ts],\n        ).fetchall()\n        for row in rows:\n            result[row[\"channel_id\"]].append(\n                Program(\n                    channel_id=row[\"channel_id\"],\n                    title=row[\"title\"],\n                    start=datetime.fromtimestamp(row[\"start_ts\"], tz=UTC),\n                    stop=datetime.fromtimestamp(row[\"stop_ts\"], tz=UTC),\n                    desc=row[\"desc\"] or \"\",\n                    source_id=row[\"source_id\"] or \"\",\n                )\n            )\n\n    # Deduplicate overlapping programs if preferred_sources provided\n    if preferred_sources:\n        for ch_id in result:\n            if ch_id in preferred_sources and result[ch_id]:\n                result[ch_id] = _dedupe_programs(result[ch_id], preferred_sources[ch_id])\n\n    channels_with_programs = sum(1 for progs in result.values() if progs)\n    log.debug(\n        \"EPG batch query: requested %d channel IDs, found programs for %d\",\n        len(channel_ids),\n        channels_with_programs,\n    )\n    return result\n\n\ndef get_icons_batch(channel_ids: list[str]) -> dict[str, str]:\n    \"\"\"Get icons for multiple channels in a single query.\"\"\"\n    if not channel_ids:\n        return {}\n    conn = _get_conn()\n    result: dict[str, str] = {}\n    for i in range(0, len(channel_ids), _MAX_IN_CLAUSE):\n        chunk = channel_ids[i : i + _MAX_IN_CLAUSE]\n        placeholders = \",\".join(\"?\" * len(chunk))\n        rows = conn.execute(\n            f\"SELECT channel_id, url FROM icons WHERE channel_id IN ({placeholders})\",\n            chunk,\n        ).fetchall()\n        for row in rows:\n            result[row[\"channel_id\"]] = row[\"url\"]\n    return result\n\n\ndef has_programs() -> bool:\n    \"\"\"Check if there are any programs in the database.\"\"\"\n    conn = _get_conn()\n    row = conn.execute(\"SELECT 1 FROM programs LIMIT 1\").fetchone()\n    return row is not None\n\n\ndef get_program_count() -> int:\n    \"\"\"Get total program count.\"\"\"\n    conn = _get_conn()\n    row = conn.execute(\"SELECT COUNT(*) FROM programs\").fetchone()\n    return row[0] if row else 0\n\n\ndef get_channel_count() -> int:\n    \"\"\"Get total channel count.\"\"\"\n    conn = _get_conn()\n    row = conn.execute(\"SELECT COUNT(*) FROM channels\").fetchone()\n    return row[0] if row else 0\n\n\ndef prune_old_programs(before: datetime) -> int:\n    \"\"\"Delete programs that ended before the given time. Returns count deleted.\"\"\"\n    conn = _get_conn()\n    cursor = conn.execute(\"DELETE FROM programs WHERE stop_ts < ?\", (before.timestamp(),))\n    conn.commit()\n    return cursor.rowcount\n\n\n# =============================================================================\n# XMLTV Parsing\n# =============================================================================\n\n\ndef _parse_epg_time(s: str) -> datetime:\n    \"\"\"Parse XMLTV time format: 20241130120000 +0000 or 20241130120000+0530.\"\"\"\n    s = s.replace(\" \", \"\")\n    if len(s) >= 14:\n        dt = datetime.strptime(s[:14], \"%Y%m%d%H%M%S\")\n        if len(s) > 14:\n            tz_str = s[14:]\n            sign = -1 if tz_str[0] == \"-\" else 1\n            tz_hours = int(tz_str[1:3]) if len(tz_str) >= 3 else 0\n            tz_mins = int(tz_str[3:5]) if len(tz_str) >= 5 else 0\n            offset = timedelta(hours=tz_hours, minutes=tz_mins)\n            dt = dt.replace(tzinfo=timezone(sign * offset))\n        return dt\n    return datetime.now(UTC)\n\n\ndef _sanitize_epg_xml(xml_str: str) -> str:\n    \"\"\"Try to fix corrupted EPG XML by extracting valid elements.\"\"\"\n    channels = re.findall(r\"<channel\\s+[^>]*>.*?</channel>\", xml_str, re.DOTALL)\n    programmes = re.findall(\n        r'<programme\\s+start=\"[^\"<>]+\"\\s+stop=\"[^\"<>]+\"\\s+channel=\"[^\"<>]+\"[^>]*>.*?</programme>',\n        xml_str,\n        re.DOTALL,\n    )\n    log.info(\"Sanitized EPG: extracted %d channels, %d programmes\", len(channels), len(programmes))\n    return '<?xml version=\"1.0\"?>\\n<tv>\\n' + \"\\n\".join(channels) + \"\\n\".join(programmes) + \"\\n</tv>\"\n\n\ndef fetch_epg(\n    epg_url: str,\n    cache_dir: Path,\n    timeout: int = 120,\n    source_id: str = \"\",\n    user_agent: str | None = None,\n) -> int:\n    \"\"\"Fetch and parse XMLTV EPG data directly into sqlite.\n\n    Args:\n        epg_url: URL of the XMLTV EPG feed\n        cache_dir: Directory for debug files if parsing fails\n        timeout: Request timeout in seconds\n        source_id: Source identifier for multi-source support\n        user_agent: User-Agent header to send. If None, uses default.\n\n    Returns:\n        Number of programs inserted.\n    \"\"\"\n    with safe_urlopen(epg_url, timeout=timeout, user_agent=user_agent) as resp:\n        content = resp.read()\n        with contextlib.suppress(Exception):\n            content = gzip.decompress(content)\n        xml_str = content.decode(\"utf-8\")\n\n    try:\n        root = ET.fromstring(xml_str)\n    except ET.ParseError as e:\n        debug_file = cache_dir / f\"epg_debug_{int(time.time())}.xml\"\n        debug_file.write_text(xml_str)\n        log.warning(\"EPG parse failed (%s), attempting sanitization...\", e)\n        try:\n            sanitized = _sanitize_epg_xml(xml_str)\n            root = ET.fromstring(sanitized)\n            log.info(\"Sanitized EPG parsed successfully\")\n        except ET.ParseError as e2:\n            log.error(\"Sanitized EPG also failed: %s\", e2)\n            raise\n\n    # Parse channels directly into sqlite\n    channel_ids: set[str] = set()\n    for ch in root.findall(\"channel\"):\n        ch_id = ch.get(\"id\", \"\")\n        channel_ids.add(ch_id)\n        name_el = ch.find(\"display-name\")\n        name = name_el.text if name_el is not None and name_el.text else ch_id\n        insert_channel(ch_id, name, source_id)\n        icon_el = ch.find(\"icon\")\n        if icon_el is not None:\n            insert_icon(ch_id, icon_el.get(\"src\", \"\"))\n\n    # Parse programs in batches\n    batch: list[tuple[str, str, float, float, str, str]] = []\n    batch_size = 10000\n    program_count = 0\n    program_channel_ids: set[str] = set()\n\n    for prog in root.findall(\"programme\"):\n        ch_id = prog.get(\"channel\", \"\")\n        program_channel_ids.add(ch_id)\n        start_str = prog.get(\"start\", \"\")\n        stop_str = prog.get(\"stop\", \"\")\n\n        title_el = prog.find(\"title\")\n        title = title_el.text if title_el is not None and title_el.text else \"Unknown\"\n\n        desc_el = prog.find(\"desc\")\n        desc = desc_el.text if desc_el is not None and desc_el.text else \"\"\n\n        try:\n            start = _parse_epg_time(start_str)\n            stop = _parse_epg_time(stop_str)\n        except Exception:\n            continue\n\n        batch.append((ch_id, title, start.timestamp(), stop.timestamp(), desc, source_id))\n        program_count += 1\n\n        if len(batch) >= batch_size:\n            insert_programs(batch)\n            batch.clear()\n\n    if batch:\n        insert_programs(batch)\n\n    commit()\n    log.debug(\n        \"EPG parsed: %d channels, %d unique program channel IDs, %d programs\",\n        len(channel_ids),\n        len(program_channel_ids),\n        program_count,\n    )\n    return program_count\n"
  },
  {
    "path": "epg_test.py",
    "content": "\"\"\"Tests for epg.py - EPG storage and parsing.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import UTC, datetime, timedelta\nfrom pathlib import Path\n\nimport pytest\n\nfrom epg import Program\n\nimport epg\n\n\n@pytest.fixture\ndef db(tmp_path: Path):\n    \"\"\"Initialize EPG database in temp directory.\"\"\"\n    epg.init(tmp_path)\n    yield epg\n    # Clear thread-local connection\n    if hasattr(epg._local, \"conn\"):\n        epg._local.conn.close()\n        epg._local.conn = None\n\n\nclass TestInit:\n    \"\"\"Tests for database initialization.\"\"\"\n\n    def test_init_creates_tables(self, db):\n        conn = db._get_conn()\n        tables = conn.execute(\"SELECT name FROM sqlite_master WHERE type='table'\").fetchall()\n        table_names = {t[\"name\"] for t in tables}\n        assert \"channels\" in table_names\n        assert \"icons\" in table_names\n        assert \"programs\" in table_names\n\n\nclass TestChannels:\n    \"\"\"Tests for channel operations.\"\"\"\n\n    def test_insert_channel(self, db):\n        db.insert_channel(\"ch1\", \"Channel One\", \"src1\")\n        db.commit()\n\n        conn = db._get_conn()\n        row = conn.execute(\"SELECT * FROM channels WHERE id = ?\", (\"ch1\",)).fetchone()\n        assert row[\"name\"] == \"Channel One\"\n        assert row[\"source_id\"] == \"src1\"\n\n    def test_insert_channel_upsert(self, db):\n        db.insert_channel(\"ch1\", \"Old Name\", \"src1\")\n        db.insert_channel(\"ch1\", \"New Name\", \"src1\")\n        db.commit()\n\n        conn = db._get_conn()\n        rows = conn.execute(\"SELECT * FROM channels WHERE id = ?\", (\"ch1\",)).fetchall()\n        assert len(rows) == 1\n        assert rows[0][\"name\"] == \"New Name\"\n\n\nclass TestIcons:\n    \"\"\"Tests for icon operations.\"\"\"\n\n    def test_insert_icon(self, db):\n        db.insert_icon(\"ch1\", \"http://example.com/icon.png\")\n        db.commit()\n\n        result = db.get_icon(\"ch1\")\n        assert result == \"http://example.com/icon.png\"\n\n    def test_get_icon_not_found(self, db):\n        result = db.get_icon(\"nonexistent\")\n        assert result == \"\"\n\n    def test_get_icons_batch(self, db):\n        db.insert_icon(\"ch1\", \"http://example.com/1.png\")\n        db.insert_icon(\"ch2\", \"http://example.com/2.png\")\n        db.insert_icon(\"ch3\", \"http://example.com/3.png\")\n        db.commit()\n\n        result = db.get_icons_batch([\"ch1\", \"ch3\"])\n        assert result == {\n            \"ch1\": \"http://example.com/1.png\",\n            \"ch3\": \"http://example.com/3.png\",\n        }\n\n    def test_get_icons_batch_empty(self, db):\n        result = db.get_icons_batch([])\n        assert result == {}\n\n\nclass TestPrograms:\n    \"\"\"Tests for program operations.\"\"\"\n\n    def test_insert_programs(self, db):\n        now = datetime.now(UTC)\n        programs = [\n            (\n                \"ch1\",\n                \"Show 1\",\n                now.timestamp(),\n                (now + timedelta(hours=1)).timestamp(),\n                \"Desc 1\",\n                \"src1\",\n            ),\n            (\n                \"ch1\",\n                \"Show 2\",\n                (now + timedelta(hours=1)).timestamp(),\n                (now + timedelta(hours=2)).timestamp(),\n                \"Desc 2\",\n                \"src1\",\n            ),\n        ]\n        db.insert_programs(programs)\n        db.commit()\n\n        count = db.get_program_count()\n        assert count == 2\n\n    def test_get_programs_in_range(self, db):\n        now = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)\n        programs = [\n            (\n                \"ch1\",\n                \"Show 1\",\n                now.timestamp(),\n                (now + timedelta(hours=1)).timestamp(),\n                \"Desc 1\",\n                \"src1\",\n            ),\n            (\n                \"ch1\",\n                \"Show 2\",\n                (now + timedelta(hours=1)).timestamp(),\n                (now + timedelta(hours=2)).timestamp(),\n                \"Desc 2\",\n                \"src1\",\n            ),\n            (\n                \"ch1\",\n                \"Show 3\",\n                (now + timedelta(hours=2)).timestamp(),\n                (now + timedelta(hours=3)).timestamp(),\n                \"Desc 3\",\n                \"src1\",\n            ),\n        ]\n        db.insert_programs(programs)\n        db.commit()\n\n        # Query for middle hour\n        result = db.get_programs_in_range(\n            \"ch1\",\n            now + timedelta(minutes=30),\n            now + timedelta(hours=1, minutes=30),\n        )\n        assert len(result) == 2\n        assert result[0].title == \"Show 1\"\n        assert result[1].title == \"Show 2\"\n\n    def test_get_programs_in_range_empty(self, db):\n        now = datetime.now(UTC)\n        result = db.get_programs_in_range(\"ch1\", now, now + timedelta(hours=1))\n        assert result == []\n\n    def test_get_programs_batch(self, db):\n        now = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)\n        programs = [\n            (\"ch1\", \"Show A\", now.timestamp(), (now + timedelta(hours=1)).timestamp(), \"\", \"src1\"),\n            (\"ch2\", \"Show B\", now.timestamp(), (now + timedelta(hours=1)).timestamp(), \"\", \"src1\"),\n        ]\n        db.insert_programs(programs)\n        db.commit()\n\n        result = db.get_programs_batch(\n            [\"ch1\", \"ch2\", \"ch3\"],\n            now,\n            now + timedelta(hours=1),\n        )\n        assert len(result[\"ch1\"]) == 1\n        assert len(result[\"ch2\"]) == 1\n        assert len(result[\"ch3\"]) == 0\n        assert result[\"ch1\"][0].title == \"Show A\"\n        assert result[\"ch2\"][0].title == \"Show B\"\n\n    def test_get_programs_batch_empty_channels(self, db):\n        result = db.get_programs_batch([], datetime.now(UTC), datetime.now(UTC))\n        assert result == {}\n\n    def test_has_programs_false(self, db):\n        assert db.has_programs() is False\n\n    def test_has_programs_true(self, db):\n        now = datetime.now(UTC)\n        db.insert_programs(\n            [(\"ch1\", \"Show\", now.timestamp(), (now + timedelta(hours=1)).timestamp(), \"\", \"src1\")]\n        )\n        db.commit()\n        assert db.has_programs() is True\n\n    def test_get_program_count(self, db):\n        now = datetime.now(UTC)\n        assert db.get_program_count() == 0\n\n        db.insert_programs(\n            [\n                (\n                    \"ch1\",\n                    \"Show 1\",\n                    now.timestamp(),\n                    (now + timedelta(hours=1)).timestamp(),\n                    \"\",\n                    \"src1\",\n                ),\n                (\n                    \"ch1\",\n                    \"Show 2\",\n                    (now + timedelta(hours=1)).timestamp(),\n                    (now + timedelta(hours=2)).timestamp(),\n                    \"\",\n                    \"src1\",\n                ),\n            ]\n        )\n        db.commit()\n\n        assert db.get_program_count() == 2\n\n    def test_get_channel_count(self, db):\n        assert db.get_channel_count() == 0\n\n        db.insert_channel(\"ch1\", \"Channel 1\", \"src1\")\n        db.insert_channel(\"ch2\", \"Channel 2\", \"src1\")\n        db.commit()\n\n        assert db.get_channel_count() == 2\n\n\nclass TestClear:\n    \"\"\"Tests for clear operations.\"\"\"\n\n    def test_clear_all(self, db):\n        now = datetime.now(UTC)\n        db.insert_channel(\"ch1\", \"Channel 1\", \"src1\")\n        db.insert_icon(\"ch1\", \"http://example.com/icon.png\")\n        db.insert_programs(\n            [(\"ch1\", \"Show\", now.timestamp(), (now + timedelta(hours=1)).timestamp(), \"\", \"src1\")]\n        )\n        db.commit()\n\n        db.clear()\n\n        assert db.get_channel_count() == 0\n        assert db.get_program_count() == 0\n        assert db.get_icon(\"ch1\") == \"\"\n\n    def test_clear_source(self, db):\n        now = datetime.now(UTC)\n        db.insert_channel(\"ch1\", \"Channel 1\", \"src1\")\n        db.insert_channel(\"ch2\", \"Channel 2\", \"src2\")\n        db.insert_programs(\n            [\n                (\n                    \"ch1\",\n                    \"Show 1\",\n                    now.timestamp(),\n                    (now + timedelta(hours=1)).timestamp(),\n                    \"\",\n                    \"src1\",\n                ),\n                (\n                    \"ch2\",\n                    \"Show 2\",\n                    now.timestamp(),\n                    (now + timedelta(hours=1)).timestamp(),\n                    \"\",\n                    \"src2\",\n                ),\n            ]\n        )\n        db.commit()\n\n        db.clear_source(\"src1\")\n\n        assert db.get_channel_count() == 1\n        assert db.get_program_count() == 1\n\n\nclass TestPrune:\n    \"\"\"Tests for prune operations.\"\"\"\n\n    def test_prune_old_programs(self, db):\n        now = datetime.now(UTC)\n        old = now - timedelta(days=2)\n        db.insert_programs(\n            [\n                (\n                    \"ch1\",\n                    \"Old Show\",\n                    old.timestamp(),\n                    (old + timedelta(hours=1)).timestamp(),\n                    \"\",\n                    \"src1\",\n                ),\n                (\n                    \"ch1\",\n                    \"New Show\",\n                    now.timestamp(),\n                    (now + timedelta(hours=1)).timestamp(),\n                    \"\",\n                    \"src1\",\n                ),\n            ]\n        )\n        db.commit()\n\n        deleted = db.prune_old_programs(now - timedelta(days=1))\n\n        assert deleted == 1\n        assert db.get_program_count() == 1\n\n\nclass TestPreferredSource:\n    \"\"\"Tests for preferred source deduplication.\"\"\"\n\n    def test_prefer_source_in_range(self, db):\n        now = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)\n        # Two overlapping programs from different sources\n        db.insert_programs(\n            [\n                (\n                    \"ch1\",\n                    \"From Src1\",\n                    now.timestamp(),\n                    (now + timedelta(hours=1)).timestamp(),\n                    \"\",\n                    \"src1\",\n                ),\n                (\n                    \"ch1\",\n                    \"From Src2\",\n                    now.timestamp(),\n                    (now + timedelta(hours=1)).timestamp(),\n                    \"\",\n                    \"src2\",\n                ),\n            ]\n        )\n        db.commit()\n\n        # Prefer src2\n        result = db.get_programs_in_range(\n            \"ch1\", now, now + timedelta(hours=1), preferred_source_id=\"src2\"\n        )\n        assert len(result) == 1\n        assert result[0].title == \"From Src2\"\n\n        # Prefer src1\n        result = db.get_programs_in_range(\n            \"ch1\", now, now + timedelta(hours=1), preferred_source_id=\"src1\"\n        )\n        assert len(result) == 1\n        assert result[0].title == \"From Src1\"\n\n\nclass TestProgram:\n    \"\"\"Tests for Program dataclass.\"\"\"\n\n    def test_program_dataclass(self):\n        now = datetime.now(UTC)\n        p = Program(\n            channel_id=\"ch1\",\n            title=\"Test Show\",\n            start=now,\n            stop=now + timedelta(hours=1),\n            desc=\"Description\",\n            source_id=\"src1\",\n        )\n        assert p.channel_id == \"ch1\"\n        assert p.title == \"Test Show\"\n        assert p.desc == \"Description\"\n        assert p.source_id == \"src1\"\n\n    def test_program_defaults(self):\n        now = datetime.now(UTC)\n        p = Program(channel_id=\"ch1\", title=\"Test\", start=now, stop=now + timedelta(hours=1))\n        assert p.desc == \"\"\n        assert p.source_id == \"\"\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "ffmpeg_command.py",
    "content": "\"\"\"FFmpeg command building and media probing.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\nfrom contextlib import suppress\nfrom dataclasses import dataclass\nfrom typing import Any, Literal\n\nimport json\nimport logging\nimport pathlib\nimport re\nimport subprocess\nimport tempfile\nimport threading\nimport time\n\n# Import VAAPI auto-detection results (avoid circular import by importing constants only)\nfrom cache import AVAILABLE_ENCODERS, VAAPI_DEVICE\n\n\nlog = logging.getLogger(__name__)\n\nHwAccel = Literal[\n    \"nvenc+vaapi\", \"nvenc+software\", \"amf+vaapi\", \"amf+software\", \"qsv\", \"vaapi\", \"software\"\n]\n\n\ndef _parse_hw(hw: HwAccel) -> tuple[str, str]:\n    \"\"\"Parse hw into (encoder, fallback). e.g. 'nvenc+vaapi' -> ('nvenc', 'vaapi')\"\"\"\n    if \"+\" in hw:\n        encoder, fallback = hw.split(\"+\", 1)\n        return encoder, fallback\n    return hw, \"software\"  # standalone options fallback to software\n\n\n# Timing constants\n_HLS_SEGMENT_DURATION_SEC = 3.0  # Short segments for faster startup/seeking\n_PROBE_CACHE_TTL_SEC = 3_600\n_SERIES_PROBE_CACHE_TTL_SEC = 7 * 24 * 3_600  # 7 days\n_PROBE_TIMEOUT_SEC = 30\n\n# Segment file naming\nSEG_PREFIX = \"seg\"  # Segment files are named seg000.ts, seg001.ts, etc.\nDEFAULT_LIVE_BUFFER_SECS = 30.0  # Default live buffer when DVR disabled\n\nTEXT_SUBTITLE_CODECS = {\n    \"subrip\",\n    \"ass\",\n    \"ssa\",\n    \"mov_text\",\n    \"webvtt\",\n    \"srt\",\n}\n\n# User-Agent presets\n_USER_AGENT_PRESETS = {\n    \"vlc\": \"VLC/3.0.20 LibVLC/3.0.20\",\n    \"chrome\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n    \"tivimate\": \"TiviMate/4.7.0\",\n}\n\n# NVDEC capabilities by minimum compute capability\n# https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new\n_NVDEC_MIN_COMPUTE: dict[str, float] = {\n    \"h264\": 5.0,  # Maxwell+\n    \"hevc\": 6.0,  # Pascal+ (HEVC 10-bit requires Pascal; Maxwell GM206 is edge case we ignore)\n    \"av1\": 8.0,  # Ampere+\n}\n\n# VAAPI/QSV: static conservative lists (unlike NVIDIA, no clean runtime probe available).\n# Could parse `vainfo` output, but format varies by driver (i965 vs iHD vs radeonsi).\n# These codecs are nearly universal on any GPU from the last decade.\n_VAAPI_SAFE_CODECS = {\"h264\", \"hevc\", \"mpeg2video\", \"vp8\", \"vp9\", \"vc1\", \"av1\"}\n_QSV_SAFE_CODECS = {\"h264\", \"hevc\", \"mpeg2video\", \"vp9\", \"vc1\", \"av1\"}\n\n# Max resolution height by setting\n_MAX_RES_HEIGHT: dict[str, int] = {\n    \"4k\": 2160,\n    \"1080p\": 1080,\n    \"720p\": 720,\n    \"480p\": 480,\n}\n\n# Quality presets -> QP/CRF values (lower = higher quality)\n_QUALITY_QP: dict[str, int] = {\"high\": 20, \"medium\": 28, \"low\": 35}\n_QUALITY_CRF: dict[str, int] = {\"high\": 20, \"medium\": 26, \"low\": 32}\n\n# Module state\n_probe_lock = threading.Lock()\n_probe_cache: dict[str, tuple[float, MediaInfo | None, list[SubtitleStream]]] = {}\n_series_probe_cache: dict[int, dict[str, Any]] = {}\n_gpu_nvdec_codecs: set[str] | None = None  # None = not probed yet\n_has_libplacebo: bool | None = None  # None = not probed yet\n_load_settings: Callable[[], dict[str, Any]] = dict\n\n# Super-resolution configuration (set by init())\n# Directory containing TensorRT engines: {model}_{height}p_fp16.engine\n_sr_engine_dir: str = \"\"\n\n# Use old \"cache\" if it exists (backwards compat), otherwise \".cache\"\n_OLD_CACHE = pathlib.Path(__file__).parent / \"cache\"\n_CACHE_DIR = _OLD_CACHE if _OLD_CACHE.exists() else pathlib.Path(__file__).parent / \".cache\"\n_SERIES_PROBE_CACHE_FILE = _CACHE_DIR / \"series_probe_cache.json\"\n\n_LANG_NAMES = {\n    \"eng\": \"English\",\n    \"spa\": \"Spanish\",\n    \"fre\": \"French\",\n    \"ger\": \"German\",\n    \"por\": \"Portuguese\",\n    \"ita\": \"Italian\",\n    \"jpn\": \"Japanese\",\n    \"kor\": \"Korean\",\n    \"chi\": \"Chinese\",\n    \"ara\": \"Arabic\",\n    \"rus\": \"Russian\",\n    \"und\": \"Unknown\",\n}\n\n\n@dataclass(slots=True)\nclass SubtitleStream:\n    index: int\n    lang: str\n    name: str\n\n\n@dataclass(slots=True)\nclass MediaInfo:\n    video_codec: str\n    audio_codec: str\n    pix_fmt: str\n    audio_channels: int = 0\n    audio_sample_rate: int = 0\n    audio_profile: str = \"\"  # e.g. \"LC\", \"HE-AAC\", \"HE-AACv2\"\n    subtitle_codecs: list[str] | None = None\n    duration: float = 0.0\n    height: int = 0\n    video_bitrate: int = 0  # bits per second, 0 if unknown\n    interlaced: bool = False  # True if field_order indicates interlaced\n    is_10bit: bool = False  # True if pix_fmt indicates 10-bit color\n    is_hdr: bool = False  # True if color transfer indicates HDR\n    is_hls: bool = False  # True if format is HLS (for input options)\n\n\ndef init(\n    load_settings: Callable[[], dict[str, Any]],\n    sr_engine_dir: str = \"\",\n) -> None:\n    \"\"\"Initialize module with settings loader and optional AI Upscale config.\"\"\"\n    global _load_settings, _sr_engine_dir\n    _load_settings = load_settings\n    _sr_engine_dir = sr_engine_dir\n    _load_series_probe_cache()\n    if _sr_engine_dir:\n        log.info(\"AI Upscale enabled: engine_dir=%s\", _sr_engine_dir)\n\n\ndef get_settings() -> dict[str, Any]:\n    \"\"\"Get current settings.\"\"\"\n    return _load_settings()\n\n\ndef get_ffmpeg_env() -> dict[str, str] | None:\n    \"\"\"Get environment for ffmpeg subprocess. Returns None (ffmpeg has libtorch via rpath).\"\"\"\n    # ffmpeg is built with -Wl,-rpath pointing to libtorch, so no LD_LIBRARY_PATH needed\n    return None\n\n\ndef _find_sr_engine(model_name: str, source_height: int) -> tuple[str, int, int, int] | None:\n    \"\"\"Find the best matching SR engine file for the given model and resolution.\n\n    Returns (engine_path, input_height, input_width, scale_factor) or None if not found.\n    \"\"\"\n    import pathlib\n\n    engine_dir = pathlib.Path(_sr_engine_dir)\n    if not engine_dir.exists():\n        return None\n\n    # Find all engines for this model\n    # Engine naming: {model}_{height}p_fp16.engine\n    engines: list[tuple[int, pathlib.Path]] = []\n    for engine in engine_dir.glob(f\"{model_name}_*p_fp16.engine\"):\n        # Extract height from filename\n        name = engine.stem  # e.g., \"2x-liveaction-span_1080p_fp16\"\n        parts = name.rsplit(\"_\", 2)\n        if len(parts) >= 3:\n            height_str = parts[1].rstrip(\"p\")\n            if height_str.isdigit():\n                engines.append((int(height_str), engine))\n\n    if not engines:\n        return None\n\n    # Determine scale factor from model name prefix (e.g., \"2x-\", \"4x-\")\n    scale_match = re.match(r\"^(\\d+)x-\", model_name)\n    if scale_match:\n        scale = int(scale_match.group(1))\n    elif model_name == \"realesrgan\":\n        # Legacy model name - was 4x\n        scale = 4\n    else:\n        log.error(\n            \"SR: cannot determine scale from model name: %s (expected Nx- prefix)\", model_name\n        )\n        return None\n\n    # Sort by height ascending\n    engines.sort(key=lambda x: x[0])\n\n    # Select appropriate engine based on source height\n    if source_height <= 0:\n        # Probe failed - use highest resolution engine\n        engine_height, engine_path = engines[-1]\n        log.warning(\"SR: probe failed, using %dp engine\", engine_height)\n    else:\n        # Find engine closest to but >= source height, or use largest if source is bigger\n        engine_height, engine_path = engines[-1]  # default to largest\n        for h, p in engines:\n            if h >= source_height:\n                engine_height, engine_path = h, p\n                break\n\n    # Calculate width assuming 16:9 aspect ratio, rounded to multiple of 8\n    engine_width = ((engine_height * 16 // 9) + 7) // 8 * 8\n\n    return str(engine_path), engine_height, engine_width, scale\n\n\ndef _build_sr_filter(source_height: int, target_height: int) -> str:\n    \"\"\"Build AI Upscale filter string if needed. Returns empty string if disabled.\n\n    SR is controlled by sr_model setting - if a model is selected, SR is applied\n    when source height < target height.\n    \"\"\"\n    if not _sr_engine_dir:\n        return \"\"\n\n    # Get selected model from settings\n    settings = _load_settings()\n    model_name = settings.get(\"sr_model\", \"\")\n    if not model_name:\n        return \"\"  # SR disabled (Off selected)\n\n    # Find engine for this model and resolution\n    engine_info = _find_sr_engine(model_name, source_height)\n    if not engine_info:\n        log.warning(\"SR: no engine found for model=%s, source=%dp\", model_name, source_height)\n        return \"\"\n\n    engine_path, engine_height, engine_width, scale = engine_info\n\n    # Apply SR when source resolution is below target (upscaling scenario)\n    if target_height and source_height >= target_height:\n        log.info(\n            \"SR: skipping %s - source %dp >= target %dp\", model_name, source_height, target_height\n        )\n        return \"\"\n\n    log.info(\n        \"SR: applying %s (%dx) to %dp -> %dp\",\n        model_name,\n        scale,\n        source_height,\n        target_height or (source_height * scale),\n    )\n\n    # Build SR filter chain:\n    # 1. Scale to engine's expected input size (preserving aspect with padding if needed)\n    # 2. Convert to RGB (model expects 3-channel RGB input)\n    # 3. hwupload to GPU (critical for performance - keeps data on GPU)\n    # 4. Apply SR via TensorRT dnn_processing (outputs Nx resolution on GPU)\n    # 5. Scale down on GPU to target resolution\n    sr_filter = (\n        f\"scale={engine_width}:{engine_height}:force_original_aspect_ratio=decrease,\"\n        f\"pad={engine_width}:{engine_height}:(ow-iw)/2:(oh-ih)/2,\"\n        f\"format=rgb24,\"\n        f\"hwupload,\"\n        f\"dnn_processing=dnn_backend=tensorrt:model={engine_path}\"\n    )\n    if target_height:\n        # After dnn_processing, data is on GPU - use scale_cuda with explicit params\n        sr_filter += f\",scale_cuda=w=-2:h={target_height}\"\n    return sr_filter\n\n\ndef get_hls_segment_duration() -> float:\n    \"\"\"Get HLS segment duration in seconds.\"\"\"\n    return _HLS_SEGMENT_DURATION_SEC\n\n\n# ===========================================================================\n# GPU Detection\n# ===========================================================================\n\n\ndef _get_gpu_nvdec_codecs() -> set[str]:\n    \"\"\"Get supported NVDEC codecs, probing GPU on first call.\"\"\"\n    global _gpu_nvdec_codecs\n    if _gpu_nvdec_codecs is not None:\n        return _gpu_nvdec_codecs\n    _gpu_nvdec_codecs = set()\n    try:\n        result = subprocess.run(\n            [\"nvidia-smi\", \"--query-gpu=name,compute_cap\", \"--format=csv,noheader\"],\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        if result.returncode != 0:\n            log.info(\"No NVIDIA GPU detected\")\n            return _gpu_nvdec_codecs\n        # Parse \"NVIDIA GeForce GTX TITAN X, 5.2\"\n        line = result.stdout.strip().split(\"\\n\")[0]\n        parts = line.rsplit(\",\", 1)\n        if len(parts) != 2:\n            return _gpu_nvdec_codecs\n        gpu_name = parts[0].strip()\n        compute_cap = float(parts[1].strip())\n        _gpu_nvdec_codecs = {\n            codec for codec, min_cap in _NVDEC_MIN_COMPUTE.items() if compute_cap >= min_cap\n        }\n        log.info(\n            \"GPU: %s (compute %.1f) NVDEC: %s\",\n            gpu_name,\n            compute_cap,\n            _gpu_nvdec_codecs or \"none\",\n        )\n    except Exception as e:\n        log.debug(\"GPU probe failed: %s\", e)\n    return _gpu_nvdec_codecs\n\n\ndef _has_libplacebo_filter() -> bool:\n    \"\"\"Check if FFmpeg has libplacebo filter available (for GPU HDR tone mapping).\"\"\"\n    global _has_libplacebo\n    if _has_libplacebo is not None:\n        return _has_libplacebo\n    _has_libplacebo = False\n    try:\n        result = subprocess.run(\n            [\"ffmpeg\", \"-filters\"],\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        _has_libplacebo = \"libplacebo\" in result.stdout\n        log.info(\"libplacebo filter available: %s\", _has_libplacebo)\n    except Exception as e:\n        log.debug(\"libplacebo probe failed: %s\", e)\n    return _has_libplacebo\n\n\n# ===========================================================================\n# User-Agent\n# ===========================================================================\n\n\ndef get_user_agent() -> str | None:\n    \"\"\"Get user-agent string from settings, or None to use FFmpeg default.\"\"\"\n    settings = _load_settings()\n    preset = settings.get(\"user_agent_preset\", \"default\")\n    if preset == \"default\":\n        return None\n    if preset == \"custom\":\n        return settings.get(\"user_agent_custom\") or None\n    return _USER_AGENT_PRESETS.get(preset)\n\n\n# ===========================================================================\n# Transcode Directory\n# ===========================================================================\n\n\ndef get_transcode_dir() -> pathlib.Path:\n    \"\"\"Get the transcode output directory. Falls back to system temp if not set or inaccessible.\"\"\"\n    custom_dir = _load_settings().get(\"transcode_dir\", \"\")\n    if custom_dir:\n        path = pathlib.Path(custom_dir)\n        try:\n            path.mkdir(parents=True, exist_ok=True)\n            return path\n        except (PermissionError, OSError) as e:\n            log.warning(\"Transcode dir %s inaccessible (%s), using temp dir\", custom_dir, e)\n    return pathlib.Path(tempfile.gettempdir())\n\n\n# ===========================================================================\n# Series Probe Cache Persistence\n# ===========================================================================\n\n\ndef _load_series_probe_cache() -> None:\n    \"\"\"Load series probe cache from disk.\"\"\"\n    if not _SERIES_PROBE_CACHE_FILE.exists():\n        return\n    try:\n        data = json.loads(_SERIES_PROBE_CACHE_FILE.read_text())\n        count = 0\n        with _probe_lock:\n            for sid_str, series_data in data.items():\n                sid = int(sid_str)\n                if sid not in _series_probe_cache:\n                    _series_probe_cache[sid] = {\n                        \"name\": series_data.get(\"name\", \"\"),\n                        \"mru\": series_data.get(\"mru\"),\n                        \"episodes\": {},\n                    }\n                else:\n                    _series_probe_cache[sid].setdefault(\"name\", series_data.get(\"name\", \"\"))\n                    _series_probe_cache[sid].setdefault(\"mru\", series_data.get(\"mru\"))\n                    _series_probe_cache[sid].setdefault(\"episodes\", {})\n                for eid_str, entry in series_data.get(\"episodes\", {}).items():\n                    eid = int(eid_str)\n                    if eid in _series_probe_cache[sid][\"episodes\"]:\n                        continue\n                    # Use .get() for all fields to handle corrupt/incomplete cache\n                    video_codec = entry.get(\"video_codec\", \"\")\n                    if not video_codec:\n                        continue  # Skip entries without video codec\n                    media_info = MediaInfo(\n                        video_codec=video_codec,\n                        audio_codec=entry.get(\"audio_codec\", \"\"),\n                        pix_fmt=entry.get(\"pix_fmt\", \"\"),\n                        audio_channels=entry.get(\"audio_channels\", 0),\n                        audio_sample_rate=entry.get(\"audio_sample_rate\", 0),\n                        subtitle_codecs=entry.get(\"subtitle_codecs\"),\n                        duration=entry.get(\"duration\", 0),\n                        height=entry.get(\"height\", 0),\n                        video_bitrate=entry.get(\"video_bitrate\", 0),\n                        interlaced=entry.get(\"interlaced\", False),\n                        is_10bit=entry.get(\"is_10bit\", False),\n                        is_hdr=entry.get(\"is_hdr\", False),\n                        is_hls=entry.get(\"is_hls\", False),\n                    )\n                    subs = [\n                        SubtitleStream(s[\"index\"], s.get(\"lang\", \"und\"), s.get(\"name\", \"\"))\n                        for s in entry.get(\"subtitles\", [])\n                    ]\n                    _series_probe_cache[sid][\"episodes\"][eid] = (\n                        entry.get(\"time\", 0),\n                        media_info,\n                        subs,\n                    )\n                    count += 1\n        log.info(\"Loaded %d series probe cache entries\", count)\n    except Exception as e:\n        log.warning(\"Failed to load series probe cache: %s\", e)\n\n\ndef _save_series_probe_cache() -> None:\n    \"\"\"Save series probe cache to disk.\"\"\"\n    with _probe_lock:\n        data: dict[str, dict[str, Any]] = {}\n        for sid, series_data in _series_probe_cache.items():\n            episodes = series_data.get(\"episodes\", {})\n            data[str(sid)] = {\n                \"name\": series_data.get(\"name\", \"\"),\n                \"mru\": series_data.get(\"mru\"),\n                \"episodes\": {},\n            }\n            for eid, (cache_time, media_info, subs) in episodes.items():\n                if media_info is None:\n                    continue\n                data[str(sid)][\"episodes\"][str(eid)] = {\n                    \"time\": cache_time,\n                    \"video_codec\": media_info.video_codec,\n                    \"audio_codec\": media_info.audio_codec,\n                    \"pix_fmt\": media_info.pix_fmt,\n                    \"audio_channels\": media_info.audio_channels,\n                    \"audio_sample_rate\": media_info.audio_sample_rate,\n                    \"subtitle_codecs\": media_info.subtitle_codecs,\n                    \"duration\": media_info.duration,\n                    \"height\": media_info.height,\n                    \"video_bitrate\": media_info.video_bitrate,\n                    \"interlaced\": media_info.interlaced,\n                    \"is_10bit\": media_info.is_10bit,\n                    \"is_hdr\": media_info.is_hdr,\n                    \"subtitles\": [{\"index\": s.index, \"lang\": s.lang, \"name\": s.name} for s in subs],\n                }\n    try:\n        _SERIES_PROBE_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)\n        _SERIES_PROBE_CACHE_FILE.write_text(json.dumps(data, indent=2))\n    except Exception as e:\n        log.warning(\"Failed to save series probe cache: %s\", e)\n\n\n# ===========================================================================\n# Probe Cache Management\n# ===========================================================================\n\n\ndef get_series_probe_cache_stats() -> list[dict[str, Any]]:\n    \"\"\"Get stats about cached series probes for settings UI.\"\"\"\n    with _probe_lock:\n        log.info(\n            \"get_series_probe_cache_stats: cache has %d series: %s\",\n            len(_series_probe_cache),\n            list(_series_probe_cache.keys()),\n        )\n        result = []\n        for series_id, series_data in _series_probe_cache.items():\n            episodes = series_data.get(\"episodes\", {})\n            if not episodes:\n                continue\n            # Get most recent entry for display info\n            most_recent = max(episodes.values(), key=lambda x: x[0])\n            _, media_info, subs = most_recent\n            if media_info is None:\n                continue\n            # Build episode list\n            episode_list = []\n            for eid, (_, emedia, esubs) in episodes.items():\n                if emedia:\n                    episode_list.append(\n                        {\n                            \"episode_id\": eid,\n                            \"duration\": emedia.duration,\n                            \"subtitle_count\": len(esubs),\n                        }\n                    )\n            result.append(\n                {\n                    \"series_id\": series_id,\n                    \"name\": series_data.get(\"name\", \"\"),\n                    \"mru\": series_data.get(\"mru\"),\n                    \"episode_count\": len(episodes),\n                    \"video_codec\": media_info.video_codec,\n                    \"audio_codec\": media_info.audio_codec,\n                    \"subtitle_count\": len(subs),\n                    \"episodes\": sorted(episode_list, key=lambda x: x[\"episode_id\"]),\n                }\n            )\n        return sorted(result, key=lambda x: x.get(\"name\") or str(x[\"series_id\"]))\n\n\ndef clear_all_probe_cache() -> int:\n    \"\"\"Clear all probe caches. Returns count of entries cleared.\"\"\"\n    with _probe_lock:\n        url_count = len(_probe_cache)\n        series_count = sum(len(s.get(\"episodes\", {})) for s in _series_probe_cache.values())\n        _probe_cache.clear()\n        _series_probe_cache.clear()\n    _save_series_probe_cache()\n    log.info(\"Cleared probe cache: %d URL entries, %d series entries\", url_count, series_count)\n    return url_count + series_count\n\n\ndef invalidate_series_probe_cache(series_id: int, episode_id: int | None = None) -> None:\n    \"\"\"Invalidate cached probe for series/episode.\n\n    If episode_id is None, clears entire series. Otherwise clears just that episode.\n    \"\"\"\n    with _probe_lock:\n        if series_id not in _series_probe_cache:\n            return\n        if episode_id is None:\n            del _series_probe_cache[series_id]\n            log.info(\"Cleared probe cache for series=%d\", series_id)\n        else:\n            series_data = _series_probe_cache[series_id]\n            episodes = series_data.get(\"episodes\", {})\n            if episode_id in episodes:\n                del episodes[episode_id]\n                log.info(\n                    \"Cleared probe cache for series=%d episode=%d\",\n                    series_id,\n                    episode_id,\n                )\n    _save_series_probe_cache()\n\n\ndef clear_series_mru(series_id: int) -> None:\n    \"\"\"Clear only the MRU for a series, keeping episode cache intact.\"\"\"\n    with _probe_lock:\n        if series_id not in _series_probe_cache:\n            return\n        if \"mru\" in _series_probe_cache[series_id]:\n            del _series_probe_cache[series_id][\"mru\"]\n            log.info(\"Cleared MRU for series=%d\", series_id)\n    _save_series_probe_cache()\n\n\ndef restore_probe_cache_entry(\n    url: str,\n    media_info: MediaInfo,\n    subs: list[SubtitleStream],\n    series_id: int | None = None,\n    episode_id: int | None = None,\n) -> None:\n    \"\"\"Restore a probe cache entry (used during session recovery).\"\"\"\n    now = time.time()\n    with _probe_lock:\n        if url not in _probe_cache:\n            _probe_cache[url] = (now, media_info, subs)\n        if series_id is not None:\n            if series_id not in _series_probe_cache:\n                _series_probe_cache[series_id] = {\"name\": \"\", \"episodes\": {}}\n            _series_probe_cache[series_id].setdefault(\"episodes\", {})\n            eid = episode_id or 0\n            if eid not in _series_probe_cache[series_id][\"episodes\"]:\n                _series_probe_cache[series_id][\"episodes\"][eid] = (now, media_info, subs)\n\n\n# ===========================================================================\n# Media Probing\n# ===========================================================================\n\n\ndef _lang_display_name(code: str) -> str:\n    return _LANG_NAMES.get(code, code.upper())\n\n\ndef resolve_hls_master_playlist(url: str) -> str:\n    \"\"\"Resolve HLS master playlist to highest bandwidth variant URL.\n\n    If the URL points to an HLS master playlist (contains #EXT-X-STREAM-INF),\n    this fetches and parses it to find the variant with the highest bandwidth.\n    Returns the resolved variant URL, or the original URL if not a master playlist\n    or on any error.\n    \"\"\"\n    from urllib.parse import urljoin\n\n    import urllib.request\n\n    if not url.endswith(\".m3u8\") and \".m3u8?\" not in url:\n        return url  # Not an m3u8, return as-is\n\n    try:\n        req = urllib.request.Request(url)\n        user_agent = get_user_agent()\n        if user_agent:\n            req.add_header(\"User-Agent\", user_agent)\n        with urllib.request.urlopen(req, timeout=10) as response:\n            content = response.read().decode(\"utf-8\", errors=\"replace\")\n\n        # Check if this is a master playlist (has #EXT-X-STREAM-INF)\n        if \"#EXT-X-STREAM-INF\" not in content:\n            return url  # Not a master playlist\n\n        # Parse variants: each #EXT-X-STREAM-INF is followed by a URL line\n        lines = content.strip().split(\"\\n\")\n        variants: list[tuple[int, str]] = []\n        for i, line in enumerate(lines):\n            if line.startswith(\"#EXT-X-STREAM-INF:\"):\n                # Extract BANDWIDTH from the tag\n                bandwidth = 0\n                for attr in line.split(\":\")[1].split(\",\"):\n                    if attr.startswith(\"BANDWIDTH=\"):\n                        with suppress(ValueError):\n                            bandwidth = int(attr.split(\"=\")[1])\n                        break\n                # Next non-comment line is the variant URL\n                for j in range(i + 1, len(lines)):\n                    variant_line = lines[j].strip()\n                    if variant_line and not variant_line.startswith(\"#\"):\n                        # Resolve relative URL\n                        variant_url = urljoin(url, variant_line)\n                        variants.append((bandwidth, variant_url))\n                        break\n\n        if not variants:\n            log.warning(\"HLS master playlist has no variants: %s\", url[:80])\n            return url\n\n        # Select highest bandwidth variant\n        variants.sort(key=lambda x: x[0], reverse=True)\n        best_bandwidth, best_url = variants[0]\n        log.info(\n            \"HLS master playlist resolved: %d variants, selected %d bps: %s\",\n            len(variants),\n            best_bandwidth,\n            best_url[:80],\n        )\n        return best_url\n\n    except Exception as e:\n        log.warning(\"Failed to resolve HLS master playlist %s: %s\", url[:80], e)\n        return url\n\n\ndef probe_media(\n    url: str,\n    series_id: int | None = None,\n    episode_id: int | None = None,\n    series_name: str = \"\",\n) -> tuple[MediaInfo | None, list[SubtitleStream]]:\n    \"\"\"Probe media, returns (media_info, subtitles).\"\"\"\n    # Check series/episode cache first\n    cache_hit_result: tuple[MediaInfo, list[SubtitleStream]] | None = None\n    save_mru = False\n    if series_id is not None:\n        with _probe_lock:\n            series_data = _series_probe_cache.get(series_id)\n            if series_data:\n                episodes = series_data.get(\"episodes\", {})\n                mru_eid = series_data.get(\"mru\")\n                # Try exact episode first\n                if episode_id is not None and episode_id in episodes:\n                    cache_time, media_info, subtitles = episodes[episode_id]\n                    if time.time() - cache_time < _SERIES_PROBE_CACHE_TTL_SEC:\n                        # Update MRU to this episode\n                        if series_data.get(\"mru\") != episode_id:\n                            series_data[\"mru\"] = episode_id\n                            save_mru = True\n                        log.info(\n                            \"Probe cache hit for series=%d episode=%d\",\n                            series_id,\n                            episode_id,\n                        )\n                        cache_hit_result = (media_info, subtitles)\n                # Fall back to MRU if set\n                elif mru_eid is not None and mru_eid in episodes:\n                    cache_time, media_info, subtitles = episodes[mru_eid]\n                    if time.time() - cache_time < _SERIES_PROBE_CACHE_TTL_SEC:\n                        log.info(\n                            \"Probe cache hit for series=%d (fallback from mru=%d)\",\n                            series_id,\n                            mru_eid,\n                        )\n                        cache_hit_result = (media_info, subtitles)\n        # Save MRU update outside the lock to avoid deadlock\n        if save_mru:\n            _save_series_probe_cache()\n        if cache_hit_result:\n            return cache_hit_result\n\n    # Check URL cache (for movies, or series cache miss)\n    with _probe_lock:\n        cached = _probe_cache.get(url)\n        if cached:\n            cache_time, media_info, subtitles = cached\n            if time.time() - cache_time < _PROBE_CACHE_TTL_SEC:\n                log.info(\"Probe cache hit for %s\", url[:50])\n                return media_info, subtitles\n    log.info(\n        \"Probe cache miss for %s (series=%s, episode=%s)\",\n        url[:50],\n        series_id,\n        episode_id,\n    )\n\n    # Build base probe command\n    # MPEG-TS streams (HDHomeRun, live TV) need ~1MB to reach first keyframe\n    # which contains the sequence header with dimensions. GOP at 15Mbps = ~1-2MB.\n    base_cmd = [\n        \"ffprobe\",\n        \"-probesize\",\n        \"1000000\",  # Had to increase for HDHomerun; was 50000.\n        \"-analyzeduration\",\n        \"1500000\",  # Had to increase for HDHomerun; was 500000.\n        \"-v\",\n        \"quiet\",\n        \"-print_format\",\n        \"json\",\n        \"-show_streams\",\n        \"-show_format\",\n    ]\n    user_agent = get_user_agent()\n    if user_agent:\n        base_cmd.extend([\"-user_agent\", user_agent])\n\n    # Try probe without forcing HLS first, retry with HLS options if it fails\n    is_hls = False\n    data = None\n    for force_hls in (False, True):\n        try:\n            cmd = base_cmd.copy()\n            if force_hls:\n                cmd.extend([\"-f\", \"hls\", \"-extension_picky\", \"0\"])\n            cmd.append(url)\n            log.info(\"Probing%s: %s\", \" (HLS mode)\" if force_hls else \"\", \" \".join(cmd))\n            result = subprocess.run(\n                cmd,\n                check=False,\n                capture_output=True,\n                text=True,\n                timeout=_PROBE_TIMEOUT_SEC,\n            )\n            if result.returncode == 0:\n                data = json.loads(result.stdout)\n                # Check detected format or if we forced HLS\n                format_name = data.get(\"format\", {}).get(\"format_name\", \"\").lower()\n                is_hls = force_hls or \"hls\" in format_name\n                break\n        except Exception as e:\n            log.warning(\"Probe failed%s: %s\", \" (HLS mode)\" if force_hls else \"\", e)\n            continue\n\n    if data is None:\n        return None, []\n\n    video_codec = audio_codec = pix_fmt = audio_profile = \"\"\n    audio_channels = audio_sample_rate = 0\n    subtitle_codecs: list[str] = []\n    subtitles: list[SubtitleStream] = []\n\n    height = 0\n    video_bitrate = 0\n    interlaced = False\n    is_10bit = False\n    is_hdr = False\n    for stream in data.get(\"streams\", []):\n        codec = stream.get(\"codec_name\", \"\").lower()\n        codec_type = stream.get(\"codec_type\", \"\")\n        if codec_type == \"video\" and not video_codec:\n            video_codec = codec\n            pix_fmt = stream.get(\"pix_fmt\", \"\")\n            height = stream.get(\"height\", 0) or 0\n            # Detect interlacing from field_order (tt, bb, tb, bt = interlaced)\n            field_order = stream.get(\"field_order\", \"\").lower()\n            interlaced = field_order in (\"tt\", \"bb\", \"tb\", \"bt\")\n            # Detect 10-bit from pix_fmt (e.g. yuv420p10le, p010le)\n            # Check for \"p10\" or \"10le/10be\" to avoid false positive on yuv410p\n            is_10bit = \"p10\" in pix_fmt or \"10le\" in pix_fmt or \"10be\" in pix_fmt\n            # Detect HDR from color_transfer (PQ = smpte2084, HLG = arib-std-b67)\n            color_transfer = stream.get(\"color_transfer\", \"\").lower()\n            is_hdr = color_transfer in (\"smpte2084\", \"arib-std-b67\")\n            # Try to get bitrate from stream, fall back to format\n            with suppress(ValueError, TypeError):\n                video_bitrate = int(stream.get(\"bit_rate\", 0) or 0)\n        elif codec_type == \"audio\" and not audio_codec:\n            audio_codec = codec\n            audio_channels = stream.get(\"channels\", 0)\n            audio_sample_rate = int(stream.get(\"sample_rate\", 0) or 0)\n            audio_profile = stream.get(\"profile\", \"\")\n        elif codec_type == \"subtitle\":\n            subtitle_codecs.append(codec)\n            if codec in TEXT_SUBTITLE_CODECS:\n                idx = stream.get(\"index\")\n                if idx is not None:\n                    tags = stream.get(\"tags\", {})\n                    lang = tags.get(\"language\", \"und\").lower()\n                    name = tags.get(\"name\") or tags.get(\"title\") or _lang_display_name(lang)\n                    subtitles.append(\n                        SubtitleStream(\n                            index=idx,\n                            lang=lang,\n                            name=name,\n                        )\n                    )\n\n    duration = 0.0\n    fmt = data.get(\"format\", {})\n    if fmt.get(\"duration\"):\n        with suppress(ValueError, TypeError):\n            duration = float(fmt[\"duration\"])\n    # Fall back to format bitrate if stream bitrate unavailable (common for MKV)\n    if not video_bitrate and fmt.get(\"bit_rate\"):\n        with suppress(ValueError, TypeError):\n            video_bitrate = int(fmt[\"bit_rate\"])\n\n    if not video_codec:\n        return None, []\n\n    media_info = MediaInfo(\n        video_codec=video_codec,\n        audio_codec=audio_codec,\n        pix_fmt=pix_fmt,\n        audio_channels=audio_channels,\n        audio_sample_rate=audio_sample_rate,\n        audio_profile=audio_profile,\n        subtitle_codecs=subtitle_codecs or None,\n        duration=duration,\n        height=height,\n        video_bitrate=video_bitrate,\n        interlaced=interlaced,\n        is_10bit=is_10bit,\n        is_hdr=is_hdr,\n        is_hls=is_hls,\n    )\n    # Only cache if we got valid video info (height > 0)\n    if height <= 0:\n        log.warning(\"Probe returned invalid height=%d, not caching: %s\", height, url[:80])\n        return media_info, subtitles\n    with _probe_lock:\n        _probe_cache[url] = (time.time(), media_info, subtitles)\n        # Cache by series_id/episode_id if provided\n        if series_id is not None:\n            if series_id not in _series_probe_cache:\n                _series_probe_cache[series_id] = {\"name\": series_name, \"episodes\": {}}\n            elif not _series_probe_cache[series_id].get(\"name\") and series_name:\n                _series_probe_cache[series_id][\"name\"] = series_name\n            eid = episode_id if episode_id is not None else 0\n            _series_probe_cache[series_id].setdefault(\"episodes\", {})[eid] = (\n                time.time(),\n                media_info,\n                subtitles,\n            )\n            # Set MRU to this episode\n            old_mru = _series_probe_cache[series_id].get(\"mru\")\n            _series_probe_cache[series_id][\"mru\"] = eid\n            log.info(\n                \"Probe cached: series=%s episode=%s, mru changed from %s to %s\",\n                series_id,\n                eid,\n                old_mru,\n                eid,\n            )\n    if series_id is not None:\n        _save_series_probe_cache()\n    return media_info, subtitles\n\n\n# ===========================================================================\n# FFmpeg Command Building\n# ===========================================================================\n\n\ndef _build_video_args(\n    *,\n    copy_video: bool,\n    hw: HwAccel,\n    deinterlace: bool,\n    use_hw_pipeline: bool,\n    max_resolution: str,\n    quality: str,\n    is_hdr: bool = False,\n    source_height: int = 0,\n) -> tuple[list[str], list[str]]:\n    \"\"\"Build video args. Returns (pre_input_args, post_input_args).\"\"\"\n    if copy_video:\n        return [], [\"-c:v\", \"copy\"]\n\n    # Parse hw into encoder and fallback\n    enc_type, fallback = _parse_hw(hw)\n\n    max_h = _MAX_RES_HEIGHT.get(max_resolution)\n\n    # Check if SR should be applied (discrete GPUs only)\n    sr_filter = \"\"\n    sr_model = _load_settings().get(\"sr_model\", \"\")\n    if sr_model and enc_type in (\"nvenc\", \"amf\") and _sr_engine_dir:\n        sr_filter = _build_sr_filter(source_height, max_h or 0)\n        # SR requires CPU frames, so disable hw pipeline when SR active\n        if sr_filter:\n            use_hw_pipeline = False\n\n    # Fall back gracefully if VAAPI is needed but no device was detected\n    needs_vaapi = enc_type == \"vaapi\" or fallback == \"vaapi\"\n    if needs_vaapi and not VAAPI_DEVICE:\n        if enc_type == \"vaapi\":\n            # Pure VAAPI encoder requested but not available - fall back to software\n            log.warning(\"VAAPI unavailable (no Intel/AMD GPU), falling back to software encoding\")\n            enc_type = \"software\"\n        else:\n            # VAAPI fallback requested but not available - use software decode instead\n            log.warning(\"VAAPI fallback unavailable (no Intel/AMD GPU), using software decode\")\n        fallback = \"software\"\n\n    # Height expr for scale filter (scale down only, -2 keeps width divisible by 2)\n    h = f\"min(ih\\\\,{max_h})\" if max_h else None\n    qp = _QUALITY_QP.get(quality, 28)\n\n    if enc_type == \"nvenc\":\n        if use_hw_pipeline:\n            # CUDA decode path\n            pre = [\n                \"-hwaccel\",\n                \"cuda\",\n                \"-hwaccel_output_format\",\n                \"cuda\",\n                \"-extra_hw_frames\",\n                \"3\",\n            ]\n            scale = f\"scale_cuda=-2:{h}:format=nv12\" if h else \"scale_cuda=format=nv12\"\n            deint = \"yadif_cuda=0,\" if deinterlace else \"\"  # mode=0 keeps original framerate\n            # HDR tone mapping: prefer libplacebo (Vulkan GPU), fall back to CPU zscale+tonemap\n            if is_hdr:\n                if _has_libplacebo_filter():\n                    tonemap = \"hwdownload,format=p010le,libplacebo=tonemapping=hable:colorspace=bt709:color_primaries=bt709:color_trc=bt709,format=nv12,hwupload_cuda,\"\n                else:\n                    tonemap = \"hwdownload,format=p010le,zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=nv12,hwupload_cuda,\"\n            else:\n                tonemap = \"\"\n            vf = f\"{deint}{tonemap}{scale}\"\n        elif fallback == \"vaapi\":\n            # VAAPI decode + VAAPI filters + hwdownload + hwupload_cuda for NVENC\n            pre = [\n                \"-hwaccel\",\n                \"vaapi\",\n                \"-hwaccel_output_format\",\n                \"vaapi\",\n                \"-hwaccel_device\",\n                VAAPI_DEVICE,\n            ]\n            scale = f\"scale_vaapi=w=-2:h={h}:format=nv12\" if h else \"scale_vaapi=format=nv12\"\n            tonemap = \"tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,\" if is_hdr else \"\"\n            deint = \"deinterlace_vaapi,\" if deinterlace else \"\"\n            vf = f\"{deint}{tonemap}{scale},hwdownload,format=nv12,hwupload_cuda\"\n        else:\n            # Software decode, upload to GPU for scaling/encoding\n            pre = []\n            scale = f\"scale_cuda=-2:{h}:format=nv12\" if h else \"scale_cuda=format=nv12\"\n            # HDR tone mapping: prefer libplacebo (Vulkan GPU), fall back to CPU zscale+tonemap\n            # Deinterlace before tonemap (CPU yadif) for consistency with hw decode path\n            if sr_filter:\n                # SR path: CPU decode -> deinterlace -> SR (GPU) -> encode\n                # SR filter ends with scale_cuda, outputs CUDA frames ready for nvenc\n                # Need init_hw_device for TensorRT dnn_processing to use GPU\n                pre = [\"-init_hw_device\", \"cuda=cu\", \"-filter_hw_device\", \"cu\"]\n                deint = \"yadif=0,\" if deinterlace else \"\"\n                vf = f\"{deint}{sr_filter}\"\n            elif is_hdr:\n                deint = \"yadif=0,\" if deinterlace else \"\"  # CPU deinterlace before tonemap\n                if _has_libplacebo_filter():\n                    tonemap = \"libplacebo=tonemapping=hable:colorspace=bt709:color_primaries=bt709:color_trc=bt709,format=nv12,hwupload_cuda,\"\n                else:\n                    tonemap = \"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=nv12,hwupload_cuda,\"\n                vf = f\"{deint}{tonemap}{scale}\"\n            else:\n                deint = \"yadif_cuda=0,\" if deinterlace else \"\"  # GPU deinterlace after upload\n                tonemap = \"format=nv12,hwupload_cuda,\"\n                vf = f\"{tonemap}{deint}{scale}\"\n        preset = \"p4\" if deinterlace or sr_filter else \"p2\"\n        encoder = \"h264_nvenc\"\n        # Lookahead for better quality, B-frames for compression, AQ for adaptive quantization\n        enc_opts = [\n            \"-preset\",\n            preset,\n            \"-rc\",\n            \"constqp\",\n            \"-qp\",\n            str(qp),\n            \"-rc-lookahead\",\n            \"32\",\n            \"-bf\",\n            \"3\",\n            \"-spatial-aq\",\n            \"1\",\n            \"-temporal-aq\",\n            \"1\",\n        ]\n\n    elif enc_type == \"amf\":\n        # AMF has no hardware decode - always uses fallback for decode/filter\n        if fallback == \"vaapi\":\n            # VAAPI decode + VAAPI filters + hwdownload for AMF encode\n            pre = [\n                \"-hwaccel\",\n                \"vaapi\",\n                \"-hwaccel_output_format\",\n                \"vaapi\",\n                \"-hwaccel_device\",\n                VAAPI_DEVICE,\n            ]\n            scale = f\"scale_vaapi=w=-2:h={h}:format=nv12\" if h else \"scale_vaapi=format=nv12\"\n            tonemap = \"tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,\" if is_hdr else \"\"\n            deint = \"deinterlace_vaapi,\" if deinterlace else \"\"\n            vf = f\"{deint}{tonemap}{scale},hwdownload,format=nv12\"\n        else:\n            # Software decode + software filters\n            pre = []\n            if is_hdr:\n                tonemap = \"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=nv12,\"\n            else:\n                tonemap = \"\"\n            deint = \"yadif=0,\" if deinterlace else \"\"\n            scale = f\"scale=-2:{h}\" if h else \"\"\n            vf = f\"{deint}{tonemap}{scale},format=nv12\".strip(\",\").replace(\",,\", \",\")\n        encoder = \"h264_amf\"\n        enc_opts = [\n            \"-rc\",\n            \"cqp\",\n            \"-qp_i\",\n            str(qp),\n            \"-qp_p\",\n            str(qp),\n            \"-quality\",\n            \"balanced\",\n        ]\n\n    elif enc_type == \"vaapi\":\n        if use_hw_pipeline:\n            pre = [\n                \"-hwaccel\",\n                \"vaapi\",\n                \"-hwaccel_output_format\",\n                \"vaapi\",\n                \"-hwaccel_device\",\n                VAAPI_DEVICE,\n                \"-extra_hw_frames\",\n                \"3\",\n            ]\n            scale = f\"scale_vaapi=w=-2:h={h}:format=nv12\" if h else \"scale_vaapi=format=nv12\"\n            # HDR tone mapping on VAAPI\n            tonemap = \"tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,\" if is_hdr else \"\"\n            vf = f\"deinterlace_vaapi,{tonemap}{scale}\" if deinterlace else f\"{tonemap}{scale}\"\n        else:\n            # Software decode, upload to GPU for scaling/encoding\n            pre = [\"-vaapi_device\", VAAPI_DEVICE]\n            scale = f\"scale_vaapi=w=-2:h={h}:format=nv12\" if h else \"scale_vaapi=format=nv12\"\n            tonemap = \"tonemap_vaapi=format=nv12:t=bt709:m=bt709:p=bt709,\" if is_hdr else \"\"\n            deint = \"deinterlace_vaapi,\" if deinterlace else \"\"\n            vf = f\"format=nv12,hwupload,{deint}{tonemap}{scale}\"\n        encoder = \"h264_vaapi\"\n        # Use baseline profile for older GPUs (e.g., AMD GCN 1.0) that don't support High profile\n        if AVAILABLE_ENCODERS.get(\"vaapi_baseline_only\"):\n            # Baseline doesn't support B-frames\n            enc_opts = [\"-rc_mode\", \"CQP\", \"-qp\", str(qp), \"-profile:v\", \"constrained_baseline\"]\n        else:\n            enc_opts = [\"-rc_mode\", \"CQP\", \"-qp\", str(qp), \"-bf\", \"3\"]\n\n    elif enc_type == \"qsv\":\n        if use_hw_pipeline:\n            pre = [\"-hwaccel\", \"qsv\", \"-hwaccel_output_format\", \"qsv\"]\n            scale = f\"scale_qsv=w=-2:h={h}:format=nv12\" if h else \"scale_qsv=format=nv12\"\n            # Combine deinterlace and tonemap into single vpp_qsv call when possible\n            if deinterlace and is_hdr:\n                vf = f\"vpp_qsv=deinterlace=2:tonemap=1:format=nv12,{scale}\"\n            elif deinterlace:\n                vf = f\"vpp_qsv=deinterlace=2,{scale}\"\n            elif is_hdr:\n                vf = f\"vpp_qsv=tonemap=1:format=nv12,{scale}\"\n            else:\n                vf = scale\n        else:\n            # Software decode, upload to GPU for scaling/encoding\n            pre = [\"-init_hw_device\", \"qsv=hw\", \"-filter_hw_device\", \"hw\"]\n            scale = f\"scale_qsv=w=-2:h={h}:format=nv12\" if h else \"scale_qsv=format=nv12\"\n            # Combine deinterlace and tonemap into single vpp_qsv call when possible\n            if deinterlace and is_hdr:\n                vf = f\"format=nv12,hwupload=extra_hw_frames=64,vpp_qsv=deinterlace=2:tonemap=1:format=nv12,{scale}\"\n            elif deinterlace:\n                vf = f\"format=nv12,hwupload=extra_hw_frames=64,vpp_qsv=deinterlace=2,{scale}\"\n            elif is_hdr:\n                vf = (\n                    f\"format=nv12,hwupload=extra_hw_frames=64,vpp_qsv=tonemap=1:format=nv12,{scale}\"\n                )\n            else:\n                vf = f\"format=nv12,hwupload=extra_hw_frames=64,{scale}\"\n        encoder = \"h264_qsv\"\n        enc_opts = [\n            \"-global_quality\",\n            str(qp),\n            \"-bf\",\n            \"3\",\n            \"-look_ahead\",\n            \"1\",\n            \"-look_ahead_depth\",\n            \"40\",\n        ]\n\n    elif enc_type == \"software\":\n        pre = []\n        # HDR tone mapping on CPU\n        if is_hdr:\n            tonemap = \"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,\"\n        else:\n            tonemap = \"\"\n        deint = \"yadif=0,\" if deinterlace else \"\"  # mode=0 keeps original framerate\n        if h:\n            vf = f\"{deint}{tonemap}scale=-2:{h},format=yuv420p\"\n        else:\n            vf = f\"{deint}{tonemap}format=yuv420p\".rstrip(\",\")\n        crf = _QUALITY_CRF.get(quality, 26)\n        encoder = \"libx264\"\n        enc_opts = [\"-preset\", \"veryfast\", \"-crf\", str(crf), \"-bf\", \"3\"]\n\n    else:\n        raise ValueError(f\"Unrecognized hardware encoder: '{enc_type}'.\")\n\n    post = [\"-vf\", vf, \"-c:v\", encoder, *enc_opts, \"-g\", \"60\"]\n    return pre, post\n\n\ndef _build_audio_args(*, copy_audio: bool, audio_sample_rate: int) -> list[str]:\n    \"\"\"Build audio args.\"\"\"\n    if copy_audio:\n        return [\"-c:a\", \"copy\"]\n    rate = str(audio_sample_rate) if audio_sample_rate in (44100, 48000) else \"48000\"\n    return [\"-c:a\", \"aac\", \"-ac\", \"2\", \"-ar\", rate, \"-b:a\", \"192k\", \"-profile:a\", \"aac_low\"]\n\n\ndef get_live_hls_list_size() -> int:\n    \"\"\"Get hls_list_size for live streams based on DVR setting.\"\"\"\n    dvr_mins = _load_settings().get(\"live_dvr_mins\", 0)\n    if dvr_mins <= 0:\n        # Default buffer when DVR disabled\n        return int(DEFAULT_LIVE_BUFFER_SECS / _HLS_SEGMENT_DURATION_SEC)\n    # DVR enabled: calculate segments from minutes\n    return int(dvr_mins * 60 / _HLS_SEGMENT_DURATION_SEC)\n\n\ndef build_hls_ffmpeg_cmd(\n    input_url: str,\n    hw: HwAccel,\n    output_dir: str,\n    is_vod: bool = False,\n    subtitles: list[SubtitleStream] | None = None,\n    media_info: MediaInfo | None = None,\n    max_resolution: str = \"1080p\",\n    quality: str = \"high\",\n    user_agent: str | None = None,\n    deinterlace_fallback: bool | None = None,\n) -> list[str]:\n    \"\"\"Build ffmpeg command for HLS transcoding.\"\"\"\n    # Check if we can copy streams directly (compatible codecs, no processing needed)\n    max_h = _MAX_RES_HEIGHT.get(max_resolution, 9999)\n    needs_scale = media_info and media_info.height > max_h\n\n    # SR requires re-encode (can't copy video when SR is active)\n    sr_active = bool(_sr_engine_dir and _load_settings().get(\"sr_model\", \"\"))\n\n    copy_video = bool(\n        media_info\n        and media_info.video_codec == \"h264\"\n        and media_info.pix_fmt == \"yuv420p\"\n        and not needs_scale\n        and not sr_active\n        and not media_info.interlaced  # Can't copy if deinterlacing needed\n    )\n    copy_audio = bool(\n        media_info\n        and media_info.audio_codec == \"aac\"\n        and media_info.audio_channels <= 2\n        and media_info.audio_sample_rate in (44100, 48000)\n        # HE-AAC has browser compatibility issues - only copy LC-AAC\n        and \"HE\" not in media_info.audio_profile\n    )\n\n    # Full hardware pipeline if GPU supports the codec\n    # Parse hw to get encoder type\n    enc_type, _ = _parse_hw(hw)\n    codec = media_info.video_codec if media_info else \"\"\n    use_hw_pipeline = bool(\n        not copy_video\n        and media_info\n        and (\n            (enc_type == \"nvenc\" and codec in _get_gpu_nvdec_codecs())\n            or (enc_type == \"vaapi\" and codec in _VAAPI_SAFE_CODECS)\n            or (enc_type == \"qsv\" and codec in _QSV_SAFE_CODECS)\n            # AMF never has hw decode pipeline - always False\n        )\n    )\n\n    # Deinterlace: use probe result if available, else use fallback setting\n    # (fallback defaults to True for live, False for VOD when not explicitly set)\n    fallback = deinterlace_fallback if deinterlace_fallback is not None else (not is_vod)\n    # If probe failed (height=0), don't trust interlaced flag - use fallback\n    probe_valid = media_info is not None and media_info.height > 0\n    deinterlace = media_info.interlaced if probe_valid and media_info else fallback\n\n    # Build component arg lists\n    video_pre, video_post = _build_video_args(\n        copy_video=copy_video,\n        hw=hw,\n        deinterlace=deinterlace,\n        use_hw_pipeline=use_hw_pipeline,\n        max_resolution=max_resolution,\n        quality=quality,\n        is_hdr=media_info.is_hdr if media_info else False,\n        source_height=media_info.height if media_info else 0,\n    )\n    audio_args = _build_audio_args(\n        copy_audio=copy_audio,\n        audio_sample_rate=media_info.audio_sample_rate if media_info else 0,\n    )\n\n    # Base args\n    cmd = [\n        \"ffmpeg\",\n        \"-hide_banner\",\n        \"-loglevel\",\n        \"error\",\n        \"-noautorotate\",\n    ]\n\n    # Hwaccel args (before -i)\n    cmd.extend(video_pre)\n\n    # Probe args (only when no media_info, since we already probed)\n    if media_info is None:\n        probe_size = \"50000\" if is_vod else \"5000000\"\n        analyze_dur = \"500000\" if is_vod else \"5000000\"\n        cmd.extend([\"-probesize\", probe_size, \"-analyzeduration\", analyze_dur])\n\n    # Input args\n    cmd.extend(\n        [\n            \"-fflags\",\n            \"+discardcorrupt+genpts\",\n            \"-err_detect\",\n            \"ignore_err\",\n            \"-reconnect\",\n            \"1\",\n            \"-reconnect_streamed\",\n            \"1\",\n            \"-reconnect_on_network_error\",\n            \"1\",\n            \"-reconnect_on_http_error\",\n            \"4xx,5xx\",\n            \"-reconnect_delay_max\",\n            \"30\",\n        ]\n    )\n    if user_agent:\n        cmd.extend([\"-user_agent\", user_agent])\n    # Use HLS demuxer options if probe detected HLS format\n    if media_info and media_info.is_hls:\n        cmd.extend([\"-f\", \"hls\", \"-extension_picky\", \"0\"])\n    cmd.extend([\"-i\", input_url])\n\n    # Subtitle extraction\n    for i, sub in enumerate(subtitles or []):\n        cmd.extend(\n            [\n                \"-map\",\n                f\"0:{sub.index}\",\n                \"-c:s\",\n                \"webvtt\",\n                \"-flush_packets\",\n                \"1\",\n                f\"{output_dir}/sub{i}.vtt\",\n            ]\n        )\n\n    # Stream mapping + video + audio\n    cmd.extend([\"-map\", \"0:v:0\", \"-map\", \"0:a:0\"])\n    cmd.extend(video_post)\n    cmd.extend(audio_args)\n\n    # HLS output args\n    cmd.extend(\n        [\n            \"-max_delay\",\n            \"5000000\",\n            \"-f\",\n            \"hls\",\n            \"-hls_time\",\n            str(int(_HLS_SEGMENT_DURATION_SEC)),\n            \"-hls_list_size\",\n            \"0\" if is_vod else str(get_live_hls_list_size()),\n            \"-hls_segment_filename\",\n            f\"{output_dir}/{SEG_PREFIX}%03d.ts\",\n        ]\n    )\n    if is_vod:\n        cmd.extend(\n            [\n                \"-hls_init_time\",\n                \"2\",\n                \"-hls_flags\",\n                \"independent_segments\",\n                \"-hls_playlist_type\",\n                \"event\",\n            ]\n        )\n    else:\n        cmd.extend([\"-hls_flags\", \"delete_segments\"])\n\n    cmd.append(f\"{output_dir}/stream.m3u8\")\n    return cmd\n"
  },
  {
    "path": "ffmpeg_command_test.py",
    "content": "\"\"\"Tests for ffmpeg command generation and media probing.\"\"\"\n\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport json\nimport tempfile\n\nimport pytest\n\nfrom ffmpeg_command import (\n    _MAX_RES_HEIGHT,\n    HwAccel,\n    MediaInfo,\n    SubtitleStream,\n    _build_audio_args,\n    _build_video_args,\n    _get_gpu_nvdec_codecs,\n    build_hls_ffmpeg_cmd,\n    clear_all_probe_cache,\n    clear_series_mru,\n    get_live_hls_list_size,\n    get_series_probe_cache_stats,\n    get_transcode_dir,\n    get_user_agent,\n    invalidate_series_probe_cache,\n    probe_media,\n    restore_probe_cache_entry,\n)\n\n\n@pytest.fixture(autouse=True)\ndef mock_vaapi_device():\n    \"\"\"Mock VAAPI_DEVICE for all tests to allow VAAPI tests on CI without hardware.\"\"\"\n    with patch(\"ffmpeg_command.VAAPI_DEVICE\", \"/dev/dri/renderD128\"):\n        yield\n\n\nclass FakeMediaInfo:\n    \"\"\"Fake media info for testing.\"\"\"\n\n    def __init__(\n        self,\n        video_codec: str = \"h264\",\n        audio_codec: str = \"aac\",\n        pix_fmt: str = \"yuv420p\",\n        audio_channels: int = 2,\n        audio_sample_rate: int = 48000,\n        audio_profile: str = \"LC\",\n        height: int = 1080,\n        interlaced: bool = False,\n        is_10bit: bool = False,\n        is_hdr: bool = False,\n        is_hls: bool = False,\n    ):\n        self.video_codec = video_codec\n        self.audio_codec = audio_codec\n        self.pix_fmt = pix_fmt\n        self.audio_channels = audio_channels\n        self.audio_sample_rate = audio_sample_rate\n        self.audio_profile = audio_profile\n        self.height = height\n        self.interlaced = interlaced\n        self.is_10bit = is_10bit\n        self.is_hdr = is_hdr\n        self.is_hls = is_hls\n\n\n# =============================================================================\n# Video Args Tests\n# =============================================================================\n\n\nclass TestBuildVideoArgs:\n    \"\"\"Tests for _build_video_args.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"hw\",\n        [\"nvenc+vaapi\", \"nvenc+software\", \"amf+vaapi\", \"amf+software\", \"qsv\", \"vaapi\", \"software\"],\n    )\n    @pytest.mark.parametrize(\"deinterlace\", [True, False])\n    @pytest.mark.parametrize(\"max_resolution\", [\"1080p\", \"720p\", \"4k\"])\n    def test_all_hw_combinations(self, hw: HwAccel, deinterlace: bool, max_resolution: str):\n        \"\"\"Test all hardware/deinterlace/resolution combinations produce valid args.\"\"\"\n        pre, post = _build_video_args(\n            copy_video=False,\n            hw=hw,\n            deinterlace=deinterlace,\n            use_hw_pipeline=(hw not in (\"software\", \"nvenc+software\", \"amf+software\")),\n            max_resolution=max_resolution,\n            quality=\"high\",\n        )\n\n        if hw in (\"nvenc+vaapi\", \"nvenc+software\") or hw in (\"amf+vaapi\", \"amf+software\"):\n            assert pre == [] or \"-hwaccel\" in pre\n        elif hw == \"qsv\":\n            assert \"-hwaccel\" in pre\n            assert \"qsv\" in pre\n        elif hw == \"vaapi\":\n            assert \"-hwaccel\" in pre\n            assert \"vaapi\" in pre\n        else:\n            assert pre == []\n\n        assert \"-vf\" in post\n        assert \"-c:v\" in post\n        assert \"-g\" in post\n        assert \"60\" in post\n\n    @pytest.mark.parametrize(\n        \"hw\",\n        [\"nvenc+vaapi\", \"nvenc+software\", \"amf+vaapi\", \"amf+software\", \"qsv\", \"vaapi\", \"software\"],\n    )\n    def test_copy_video(self, hw: HwAccel):\n        \"\"\"Test copy_video returns minimal args.\"\"\"\n        pre, post = _build_video_args(\n            copy_video=True,\n            hw=hw,\n            deinterlace=False,\n            use_hw_pipeline=False,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n        )\n        assert pre == []\n        assert post == [\"-c:v\", \"copy\"]\n\n    def test_nvenc_hw_pipeline_filters(self):\n        \"\"\"Test NVENC with hw pipeline uses CUDA filters.\"\"\"\n        pre, post = _build_video_args(\n            copy_video=False,\n            hw=\"nvenc+software\",\n            deinterlace=True,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n        )\n        assert \"-hwaccel\" in pre\n        vf = post[post.index(\"-vf\") + 1]\n        assert \"yadif_cuda\" in vf\n        assert \"scale_cuda\" in vf\n\n    def test_nvenc_sw_fallback_filters(self):\n        \"\"\"Test NVENC without hw pipeline uses SW decode + GPU processing.\"\"\"\n        pre, post = _build_video_args(\n            copy_video=False,\n            hw=\"nvenc+software\",\n            deinterlace=True,\n            use_hw_pipeline=False,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n        )\n        assert pre == []\n        vf = post[post.index(\"-vf\") + 1]\n        # Upload to GPU, then deinterlace (mode=0 for original framerate) and scale on GPU\n        assert \"hwupload_cuda\" in vf\n        assert \"yadif_cuda=0\" in vf\n        assert \"scale_cuda\" in vf\n\n    def test_vaapi_filters(self):\n        \"\"\"Test VAAPI uses VAAPI filters.\"\"\"\n        pre, post = _build_video_args(\n            copy_video=False,\n            hw=\"vaapi\",\n            deinterlace=True,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n        )\n        vf = post[post.index(\"-vf\") + 1]\n        assert \"deinterlace_vaapi\" in vf\n        assert \"scale_vaapi\" in vf\n\n    def test_qsv_filters(self):\n        \"\"\"Test QSV uses QSV filters.\"\"\"\n        pre, post = _build_video_args(\n            copy_video=False,\n            hw=\"qsv\",\n            deinterlace=True,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n        )\n        vf = post[post.index(\"-vf\") + 1]\n        assert \"vpp_qsv\" in vf\n        assert \"scale_qsv\" in vf\n\n    def test_software_filters(self):\n        \"\"\"Test software uses yadif (mode=0 for original framerate) and scale.\"\"\"\n        pre, post = _build_video_args(\n            copy_video=False,\n            hw=\"software\",\n            deinterlace=True,\n            use_hw_pipeline=False,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n        )\n        assert pre == []\n        vf = post[post.index(\"-vf\") + 1]\n        assert \"yadif=0\" in vf\n\n    @pytest.mark.parametrize(\n        \"quality,expected_qp\", [(\"high\", \"20\"), (\"medium\", \"28\"), (\"low\", \"35\")]\n    )\n    def test_quality_presets(self, quality: str, expected_qp: str):\n        \"\"\"Test quality presets map to correct QP values.\"\"\"\n        _, post = _build_video_args(\n            copy_video=False,\n            hw=\"vaapi\",\n            deinterlace=False,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=quality,\n        )\n        assert expected_qp in post\n\n    def test_invalid_hw_raises(self):\n        \"\"\"Test invalid hardware raises ValueError.\"\"\"\n        with pytest.raises(ValueError, match=\"Unrecognized hardware\"):\n            _build_video_args(\n                copy_video=False,\n                hw=\"invalid\",  # type: ignore\n                deinterlace=False,\n                use_hw_pipeline=False,\n                max_resolution=\"1080p\",\n                quality=\"high\",\n            )\n\n    @patch(\"ffmpeg_command._has_libplacebo_filter\", return_value=True)\n    def test_nvenc_hdr_with_libplacebo(self, mock_placebo):\n        \"\"\"Test NVENC HDR uses libplacebo when available.\"\"\"\n        _, post = _build_video_args(\n            copy_video=False,\n            hw=\"nvenc+software\",\n            deinterlace=False,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n            is_hdr=True,\n        )\n        vf = post[post.index(\"-vf\") + 1]\n        assert \"libplacebo\" in vf\n        assert \"tonemapping=hable\" in vf\n        # Should download from CUDA, process, re-upload\n        assert \"hwdownload\" in vf\n        assert \"hwupload_cuda\" in vf\n\n    @patch(\"ffmpeg_command._has_libplacebo_filter\", return_value=False)\n    def test_nvenc_hdr_zscale_fallback(self, mock_placebo):\n        \"\"\"Test NVENC HDR falls back to zscale when libplacebo unavailable.\"\"\"\n        _, post = _build_video_args(\n            copy_video=False,\n            hw=\"nvenc+software\",\n            deinterlace=False,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n            is_hdr=True,\n        )\n        vf = post[post.index(\"-vf\") + 1]\n        assert \"zscale\" in vf\n        assert \"tonemap=hable\" in vf\n        assert \"libplacebo\" not in vf\n\n    @patch(\"ffmpeg_command._has_libplacebo_filter\", return_value=True)\n    def test_nvenc_hdr_deinterlace_order(self, mock_placebo):\n        \"\"\"Test NVENC HDR hw decode deinterlaces BEFORE tonemap.\"\"\"\n        _, post = _build_video_args(\n            copy_video=False,\n            hw=\"nvenc+software\",\n            deinterlace=True,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n            is_hdr=True,\n        )\n        vf = post[post.index(\"-vf\") + 1]\n        # Deinterlace should come before tonemap in hw decode path\n        deint_pos = vf.find(\"yadif_cuda\")\n        tonemap_pos = vf.find(\"libplacebo\")\n        assert deint_pos < tonemap_pos, f\"deinterlace should come before tonemap: {vf}\"\n\n    @patch(\"ffmpeg_command._has_libplacebo_filter\", return_value=True)\n    def test_nvenc_sw_hdr_deinterlace_order(self, mock_placebo):\n        \"\"\"Test NVENC HDR sw decode uses CPU deinterlace before tonemap.\"\"\"\n        _, post = _build_video_args(\n            copy_video=False,\n            hw=\"nvenc+software\",\n            deinterlace=True,\n            use_hw_pipeline=False,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n            is_hdr=True,\n        )\n        vf = post[post.index(\"-vf\") + 1]\n        # SW decode HDR should use CPU yadif before tonemap\n        assert \"yadif=0\" in vf  # CPU deinterlace, not yadif_cuda\n        deint_pos = vf.find(\"yadif=0\")\n        tonemap_pos = vf.find(\"libplacebo\")\n        assert deint_pos < tonemap_pos, f\"CPU deinterlace should come before tonemap: {vf}\"\n\n    def test_vaapi_hdr_tonemap(self):\n        \"\"\"Test VAAPI HDR uses tonemap_vaapi filter.\"\"\"\n        _, post = _build_video_args(\n            copy_video=False,\n            hw=\"vaapi\",\n            deinterlace=False,\n            use_hw_pipeline=True,\n            max_resolution=\"1080p\",\n            quality=\"high\",\n            is_hdr=True,\n        )\n        vf = post[post.index(\"-vf\") + 1]\n        assert \"tonemap_vaapi\" in vf\n\n\n# =============================================================================\n# Audio Args Tests\n# =============================================================================\n\n\nclass TestBuildAudioArgs:\n    \"\"\"Tests for _build_audio_args.\"\"\"\n\n    def test_copy_audio(self):\n        \"\"\"Test copy_audio returns copy args.\"\"\"\n        args = _build_audio_args(copy_audio=True, audio_sample_rate=48000)\n        assert args == [\"-c:a\", \"copy\"]\n\n    @pytest.mark.parametrize(\n        \"sample_rate,expected\",\n        [\n            (44100, \"44100\"),\n            (48000, \"48000\"),\n            (96000, \"48000\"),\n            (0, \"48000\"),\n        ],\n    )\n    def test_sample_rates(self, sample_rate: int, expected: str):\n        \"\"\"Test sample rate handling.\"\"\"\n        args = _build_audio_args(copy_audio=False, audio_sample_rate=sample_rate)\n        assert \"-ar\" in args\n        assert expected in args\n\n\n# =============================================================================\n# HLS Command Tests\n# =============================================================================\n\n\nclass TestBuildHlsFfmpegCmd:\n    \"\"\"Tests for build_hls_ffmpeg_cmd.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"hw\",\n        [\"nvenc+vaapi\", \"nvenc+software\", \"amf+vaapi\", \"amf+software\", \"qsv\", \"vaapi\", \"software\"],\n    )\n    @pytest.mark.parametrize(\"is_vod\", [True, False])\n    def test_command_structure(self, hw: HwAccel, is_vod: bool):\n        \"\"\"Test command has correct structure for all hw/vod combinations.\"\"\"\n        cmd = build_hls_ffmpeg_cmd(\n            \"http://test/stream\",\n            hw,\n            \"/tmp/output\",\n            is_vod=is_vod,\n        )\n\n        assert cmd[0] == \"ffmpeg\"\n        assert \"-i\" in cmd\n        assert \"-map\" in cmd\n        assert \"-c:v\" in cmd\n        assert \"-c:a\" in cmd\n        assert \"-f\" in cmd\n        assert \"hls\" in cmd\n\n        i_idx = cmd.index(\"-i\")\n        if \"-hwaccel\" in cmd:\n            hwaccel_idx = cmd.index(\"-hwaccel\")\n            assert hwaccel_idx < i_idx, \"hwaccel must come before -i\"\n\n        if \"-vf\" in cmd:\n            vf_idx = cmd.index(\"-vf\")\n            assert vf_idx > i_idx, \"-vf must come after -i\"\n\n    def test_vod_hls_flags(self):\n        \"\"\"Test VOD has correct HLS flags.\"\"\"\n        cmd = build_hls_ffmpeg_cmd(\"http://test\", \"software\", \"/tmp\", is_vod=True)\n        assert \"-hls_playlist_type\" in cmd\n        assert \"event\" in cmd\n        assert \"-hls_list_size\" in cmd\n        assert cmd[cmd.index(\"-hls_list_size\") + 1] == \"0\"\n\n    def test_live_hls_flags(self):\n        \"\"\"Test live has correct HLS flags.\"\"\"\n        cmd = build_hls_ffmpeg_cmd(\"http://test\", \"software\", \"/tmp\", is_vod=False)\n        assert \"delete_segments\" in cmd\n        assert \"-hls_list_size\" in cmd\n        assert cmd[cmd.index(\"-hls_list_size\") + 1] == \"10\"\n\n    def test_copy_video_with_compatible_media(self):\n        \"\"\"Test copy_video is used for compatible VOD media.\"\"\"\n        media = FakeMediaInfo(video_codec=\"h264\", pix_fmt=\"yuv420p\", height=1080)\n        cmd = build_hls_ffmpeg_cmd(\n            \"http://test\",\n            \"vaapi\",\n            \"/tmp\",\n            is_vod=True,\n            media_info=media,  # type: ignore\n            max_resolution=\"1080p\",\n        )\n        assert \"-c:v\" in cmd\n        assert cmd[cmd.index(\"-c:v\") + 1] == \"copy\"\n        assert \"-hwaccel\" not in cmd\n\n    def test_no_copy_for_10bit(self):\n        \"\"\"Test 10-bit content is transcoded, not copied.\"\"\"\n        media = FakeMediaInfo(video_codec=\"h264\", pix_fmt=\"yuv420p10le\", height=1080)\n        cmd = build_hls_ffmpeg_cmd(\n            \"http://test\",\n            \"vaapi\",\n            \"/tmp\",\n            is_vod=True,\n            media_info=media,  # type: ignore\n        )\n        assert cmd[cmd.index(\"-c:v\") + 1] != \"copy\"\n        assert \"-hwaccel\" in cmd\n\n    def test_no_copy_when_scaling_needed(self):\n        \"\"\"Test scaling requirement prevents copy.\"\"\"\n        media = FakeMediaInfo(video_codec=\"h264\", pix_fmt=\"yuv420p\", height=2160)\n        cmd = build_hls_ffmpeg_cmd(\n            \"http://test\",\n            \"vaapi\",\n            \"/tmp\",\n            is_vod=True,\n            media_info=media,  # type: ignore\n            max_resolution=\"1080p\",\n        )\n        assert cmd[cmd.index(\"-c:v\") + 1] != \"copy\"\n\n    def test_user_agent(self):\n        \"\"\"Test user agent is included when provided.\"\"\"\n        cmd = build_hls_ffmpeg_cmd(\n            \"http://test\",\n            \"software\",\n            \"/tmp\",\n            user_agent=\"TestAgent/1.0\",\n        )\n        assert \"-user_agent\" in cmd\n        assert \"TestAgent/1.0\" in cmd\n\n    def test_probe_args_without_media_info(self):\n        \"\"\"Test probe args are added when no media_info.\"\"\"\n        cmd = build_hls_ffmpeg_cmd(\"http://test\", \"software\", \"/tmp\", media_info=None)\n        assert \"-probesize\" in cmd\n        assert \"-analyzeduration\" in cmd\n\n    def test_no_probe_args_with_media_info(self):\n        \"\"\"Test probe args are skipped when media_info provided.\"\"\"\n        media = FakeMediaInfo()\n        cmd = build_hls_ffmpeg_cmd(\"http://test\", \"software\", \"/tmp\", media_info=media)  # type: ignore\n        assert \"-probesize\" not in cmd\n\n    def test_subtitle_extraction(self):\n        \"\"\"Test subtitle streams are extracted.\"\"\"\n        subs = [\n            SubtitleStream(index=2, lang=\"eng\", name=\"English\"),\n            SubtitleStream(index=3, lang=\"spa\", name=\"Spanish\"),\n        ]\n        cmd = build_hls_ffmpeg_cmd(\"http://test\", \"software\", \"/tmp/out\", subtitles=subs)\n        assert \"-map\" in cmd\n        assert \"0:2\" in cmd\n        assert \"0:3\" in cmd\n        assert \"/tmp/out/sub0.vtt\" in cmd\n        assert \"/tmp/out/sub1.vtt\" in cmd\n\n\n# =============================================================================\n# Aspect Ratio Tests\n# =============================================================================\n\n\nclass TestAspectRatioHandling:\n    \"\"\"Tests for various aspect ratio content.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"input_height,max_res,should_scale\",\n        [\n            (1080, \"1080p\", False),\n            (1080, \"720p\", True),\n            (720, \"1080p\", False),\n            (2160, \"1080p\", True),\n            (1600, \"1080p\", True),\n            (1600, \"4k\", False),\n        ],\n    )\n    def test_scaling_decisions(self, input_height: int, max_res: str, should_scale: bool):\n        \"\"\"Test correct scaling decisions for various input heights.\"\"\"\n        media = FakeMediaInfo(height=input_height, pix_fmt=\"yuv420p10le\")\n        cmd = build_hls_ffmpeg_cmd(\n            \"http://test\",\n            \"vaapi\",\n            \"/tmp\",\n            is_vod=True,\n            media_info=media,  # type: ignore\n            max_resolution=max_res,\n        )\n        vf = cmd[cmd.index(\"-vf\") + 1]\n        max_h = _MAX_RES_HEIGHT.get(max_res, 9999)\n        # Comma is escaped in FFmpeg filter expressions\n        height_expr = f\"min(ih\\\\,{max_h})\"\n        assert height_expr in vf, f\"Expected {height_expr} in {vf}\"\n\n\n# =============================================================================\n# GPU Detection Tests\n# =============================================================================\n\n\nclass TestGpuDetection:\n    \"\"\"Tests for GPU/NVDEC detection.\"\"\"\n\n    def test_nvidia_gpu_detected(self):\n        \"\"\"Test NVIDIA GPU detection parses compute capability.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._gpu_nvdec_codecs = None  # Reset cache\n\n        mock_result = MagicMock()\n        mock_result.returncode = 0\n        mock_result.stdout = \"NVIDIA GeForce RTX 3080, 8.6\\n\"\n\n        with patch(\"subprocess.run\", return_value=mock_result):\n            codecs = _get_gpu_nvdec_codecs()\n            assert \"h264\" in codecs\n            assert \"hevc\" in codecs\n            assert \"av1\" in codecs\n\n    def test_no_nvidia_gpu(self):\n        \"\"\"Test handling when no NVIDIA GPU present.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._gpu_nvdec_codecs = None\n\n        mock_result = MagicMock()\n        mock_result.returncode = 1\n\n        with patch(\"subprocess.run\", return_value=mock_result):\n            codecs = _get_gpu_nvdec_codecs()\n            assert codecs == set()\n\n    def test_older_nvidia_gpu(self):\n        \"\"\"Test older GPU with limited NVDEC support.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._gpu_nvdec_codecs = None\n\n        mock_result = MagicMock()\n        mock_result.returncode = 0\n        mock_result.stdout = \"NVIDIA GeForce GTX 960, 5.2\\n\"\n\n        with patch(\"subprocess.run\", return_value=mock_result):\n            codecs = _get_gpu_nvdec_codecs()\n            assert \"h264\" in codecs\n            assert \"hevc\" not in codecs\n            assert \"av1\" not in codecs\n\n\n# =============================================================================\n# User Agent Tests\n# =============================================================================\n\n\nclass TestUserAgent:\n    \"\"\"Tests for user agent handling.\"\"\"\n\n    def test_default_user_agent(self):\n        \"\"\"Test default preset returns None.\"\"\"\n        with patch(\"ffmpeg_command._load_settings\", return_value={\"user_agent_preset\": \"default\"}):\n            assert get_user_agent() is None\n\n    def test_vlc_user_agent(self):\n        \"\"\"Test VLC preset.\"\"\"\n        with patch(\"ffmpeg_command._load_settings\", return_value={\"user_agent_preset\": \"vlc\"}):\n            ua = get_user_agent()\n            assert ua is not None\n            assert \"VLC\" in ua\n\n    def test_chrome_user_agent(self):\n        \"\"\"Test Chrome preset.\"\"\"\n        with patch(\"ffmpeg_command._load_settings\", return_value={\"user_agent_preset\": \"chrome\"}):\n            ua = get_user_agent()\n            assert ua is not None\n            assert \"Chrome\" in ua\n\n    def test_custom_user_agent(self):\n        \"\"\"Test custom user agent.\"\"\"\n        with patch(\n            \"ffmpeg_command._load_settings\",\n            return_value={\"user_agent_preset\": \"custom\", \"user_agent_custom\": \"MyAgent/1.0\"},\n        ):\n            assert get_user_agent() == \"MyAgent/1.0\"\n\n    def test_custom_empty_returns_none(self):\n        \"\"\"Test empty custom user agent returns None.\"\"\"\n        with patch(\n            \"ffmpeg_command._load_settings\",\n            return_value={\"user_agent_preset\": \"custom\", \"user_agent_custom\": \"\"},\n        ):\n            assert get_user_agent() is None\n\n\n# =============================================================================\n# Transcode Directory Tests\n# =============================================================================\n\n\nclass TestTranscodeDir:\n    \"\"\"Tests for transcode directory handling.\"\"\"\n\n    def test_default_transcode_dir(self):\n        \"\"\"Test default uses system temp.\"\"\"\n        with patch(\"ffmpeg_command._load_settings\", return_value={}):\n            path = get_transcode_dir()\n            assert path == Path(tempfile.gettempdir())\n\n    def test_custom_transcode_dir(self, tmp_path):\n        \"\"\"Test custom directory is used and created.\"\"\"\n        custom_dir = tmp_path / \"custom_transcode\"\n        with patch(\n            \"ffmpeg_command._load_settings\", return_value={\"transcode_dir\": str(custom_dir)}\n        ):\n            path = get_transcode_dir()\n            assert path == custom_dir\n            assert custom_dir.exists()\n\n\n# =============================================================================\n# HLS List Size Tests\n# =============================================================================\n\n\nclass TestHlsListSize:\n    \"\"\"Tests for HLS list size calculation.\"\"\"\n\n    def test_default_list_size(self):\n        \"\"\"Test default (DVR disabled) uses 10 segments.\"\"\"\n        with patch(\"ffmpeg_command._load_settings\", return_value={}):\n            assert get_live_hls_list_size() == 10\n\n    def test_dvr_enabled_list_size(self):\n        \"\"\"Test DVR enabled calculates segments from minutes.\"\"\"\n        with patch(\"ffmpeg_command._load_settings\", return_value={\"live_dvr_mins\": 5}):\n            # 5 min = 300 sec / 3 sec per segment = 100 segments\n            assert get_live_hls_list_size() == 100\n\n\n# =============================================================================\n# Probe Media Tests\n# =============================================================================\n\n\nclass TestProbeMedia:\n    \"\"\"Tests for media probing.\"\"\"\n\n    def test_probe_success(self):\n        \"\"\"Test successful probe parses media info.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n\n        probe_output = {\n            \"streams\": [\n                {\n                    \"codec_type\": \"video\",\n                    \"codec_name\": \"h264\",\n                    \"pix_fmt\": \"yuv420p\",\n                    \"height\": 1080,\n                    \"field_order\": \"progressive\",\n                },\n                {\n                    \"codec_type\": \"audio\",\n                    \"codec_name\": \"aac\",\n                    \"channels\": 2,\n                    \"sample_rate\": \"48000\",\n                },\n            ],\n            \"format\": {\"duration\": \"3600.0\"},\n        }\n\n        mock_result = MagicMock()\n        mock_result.returncode = 0\n        mock_result.stdout = json.dumps(probe_output)\n\n        with (\n            patch(\"subprocess.run\", return_value=mock_result),\n            patch(\"ffmpeg_command._load_settings\", return_value={}),\n        ):\n            media_info, subs = probe_media(\"http://test/video.mp4\")\n\n        assert media_info is not None\n        assert media_info.video_codec == \"h264\"\n        assert media_info.audio_codec == \"aac\"\n        assert media_info.height == 1080\n        assert media_info.duration == 3600.0\n        assert not media_info.interlaced\n\n    def test_probe_interlaced_detection(self):\n        \"\"\"Test interlaced content detection.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n\n        probe_output = {\n            \"streams\": [\n                {\n                    \"codec_type\": \"video\",\n                    \"codec_name\": \"mpeg2video\",\n                    \"pix_fmt\": \"yuv420p\",\n                    \"height\": 1080,\n                    \"field_order\": \"tt\",  # Top field first = interlaced\n                },\n            ],\n            \"format\": {},\n        }\n\n        mock_result = MagicMock()\n        mock_result.returncode = 0\n        mock_result.stdout = json.dumps(probe_output)\n\n        with (\n            patch(\"subprocess.run\", return_value=mock_result),\n            patch(\"ffmpeg_command._load_settings\", return_value={}),\n        ):\n            media_info, _ = probe_media(\"http://test/interlaced.ts\")\n\n        assert media_info is not None\n        assert media_info.interlaced is True\n\n    @pytest.mark.parametrize(\n        \"pix_fmt,expected\",\n        [\n            (\"yuv420p10le\", True),  # 10-bit little endian\n            (\"yuv420p10be\", True),  # 10-bit big endian\n            (\"yuv422p10le\", True),  # 10-bit 4:2:2\n            (\"p010le\", True),  # CUDA/VAAPI 10-bit format\n            (\"yuv420p\", False),  # 8-bit\n            (\"yuv410p\", False),  # 4:1:0 chroma, NOT 10-bit (was a false positive)\n            (\"nv12\", False),  # 8-bit NV12\n        ],\n    )\n    def test_probe_10bit_detection(self, pix_fmt: str, expected: bool):\n        \"\"\"Test 10-bit content detection from pix_fmt.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n\n        probe_output = {\n            \"streams\": [\n                {\n                    \"codec_type\": \"video\",\n                    \"codec_name\": \"hevc\",\n                    \"pix_fmt\": pix_fmt,\n                    \"height\": 2160,\n                },\n            ],\n            \"format\": {},\n        }\n\n        mock_result = MagicMock()\n        mock_result.returncode = 0\n        mock_result.stdout = json.dumps(probe_output)\n\n        with (\n            patch(\"subprocess.run\", return_value=mock_result),\n            patch(\"ffmpeg_command._load_settings\", return_value={}),\n        ):\n            media_info, _ = probe_media(f\"http://test/{pix_fmt}.mkv\")\n\n        assert media_info is not None\n        assert media_info.is_10bit is expected, f\"pix_fmt={pix_fmt} should be is_10bit={expected}\"\n\n    @pytest.mark.parametrize(\n        \"color_transfer,expected\",\n        [\n            (\"smpte2084\", True),  # PQ (HDR10, HDR10+, Dolby Vision)\n            (\"arib-std-b67\", True),  # HLG\n            (\"bt709\", False),  # SDR\n            (\"bt2020-10\", False),  # Wide gamut but not HDR transfer\n            (\"\", False),  # Unknown/missing\n        ],\n    )\n    def test_probe_hdr_detection(self, color_transfer: str, expected: bool):\n        \"\"\"Test HDR content detection from color_transfer.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n\n        probe_output = {\n            \"streams\": [\n                {\n                    \"codec_type\": \"video\",\n                    \"codec_name\": \"hevc\",\n                    \"pix_fmt\": \"yuv420p10le\",\n                    \"height\": 2160,\n                    \"color_transfer\": color_transfer,\n                },\n            ],\n            \"format\": {},\n        }\n\n        mock_result = MagicMock()\n        mock_result.returncode = 0\n        mock_result.stdout = json.dumps(probe_output)\n\n        with (\n            patch(\"subprocess.run\", return_value=mock_result),\n            patch(\"ffmpeg_command._load_settings\", return_value={}),\n        ):\n            media_info, _ = probe_media(f\"http://test/{color_transfer or 'unknown'}.mkv\")\n\n        assert media_info is not None\n        assert media_info.is_hdr is expected, (\n            f\"color_transfer={color_transfer} should be is_hdr={expected}\"\n        )\n\n    def test_probe_failure(self):\n        \"\"\"Test probe failure returns None.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n\n        mock_result = MagicMock()\n        mock_result.returncode = 1\n\n        with (\n            patch(\"subprocess.run\", return_value=mock_result),\n            patch(\"ffmpeg_command._load_settings\", return_value={}),\n        ):\n            media_info, subs = probe_media(\"http://test/bad.mp4\")\n\n        assert media_info is None\n        assert subs == []\n\n    def test_probe_cache_hit(self):\n        \"\"\"Test probe cache returns cached result.\"\"\"\n        import time\n\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n\n        cached_info = MediaInfo(\n            video_codec=\"h264\",\n            audio_codec=\"aac\",\n            pix_fmt=\"yuv420p\",\n        )\n        ffmpeg_command._probe_cache[\"http://cached\"] = (time.time(), cached_info, [])\n\n        with (\n            patch(\"subprocess.run\") as mock_run,\n            patch(\"ffmpeg_command._load_settings\", return_value={}),\n        ):\n            media_info, _ = probe_media(\"http://cached\")\n\n        mock_run.assert_not_called()\n        assert media_info == cached_info\n\n    def test_probe_extracts_subtitles(self):\n        \"\"\"Test subtitle stream extraction.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n\n        probe_output = {\n            \"streams\": [\n                {\"codec_type\": \"video\", \"codec_name\": \"h264\", \"pix_fmt\": \"yuv420p\"},\n                {\n                    \"codec_type\": \"subtitle\",\n                    \"codec_name\": \"subrip\",\n                    \"index\": 2,\n                    \"tags\": {\"language\": \"eng\", \"title\": \"English\"},\n                },\n                {\n                    \"codec_type\": \"subtitle\",\n                    \"codec_name\": \"ass\",\n                    \"index\": 3,\n                    \"tags\": {\"language\": \"jpn\"},\n                },\n            ],\n            \"format\": {},\n        }\n\n        mock_result = MagicMock()\n        mock_result.returncode = 0\n        mock_result.stdout = json.dumps(probe_output)\n\n        with (\n            patch(\"subprocess.run\", return_value=mock_result),\n            patch(\"ffmpeg_command._load_settings\", return_value={}),\n        ):\n            _, subs = probe_media(\"http://test/subs.mkv\")\n\n        assert len(subs) == 2\n        assert subs[0].index == 2\n        assert subs[0].lang == \"eng\"\n        assert subs[0].name == \"English\"\n        assert subs[1].index == 3\n        assert subs[1].lang == \"jpn\"\n\n\n# =============================================================================\n# Probe Cache Management Tests\n# =============================================================================\n\n\nclass TestProbeCacheManagement:\n    \"\"\"Tests for probe cache management functions.\"\"\"\n\n    def test_clear_all_probe_cache(self):\n        \"\"\"Test clearing all probe caches.\"\"\"\n        import time\n\n        import ffmpeg_command\n\n        # Clear first to ensure known state\n        ffmpeg_command._probe_cache.clear()\n        ffmpeg_command._series_probe_cache.clear()\n\n        ffmpeg_command._probe_cache[\"url1\"] = (time.time(), None, [])\n        ffmpeg_command._probe_cache[\"url2\"] = (time.time(), None, [])\n        ffmpeg_command._series_probe_cache[123] = {\"episodes\": {1: (time.time(), None, [])}}\n\n        with patch(\"ffmpeg_command._save_series_probe_cache\"):\n            count = clear_all_probe_cache()\n\n        assert count == 3\n        assert len(ffmpeg_command._probe_cache) == 0\n        assert len(ffmpeg_command._series_probe_cache) == 0\n\n    def test_invalidate_series_probe_cache_entire_series(self):\n        \"\"\"Test invalidating entire series cache.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._series_probe_cache[123] = {\n            \"name\": \"Test\",\n            \"episodes\": {1: (0, None, []), 2: (0, None, [])},\n        }\n\n        with patch(\"ffmpeg_command._save_series_probe_cache\"):\n            invalidate_series_probe_cache(123)\n\n        assert 123 not in ffmpeg_command._series_probe_cache\n\n    def test_invalidate_series_probe_cache_single_episode(self):\n        \"\"\"Test invalidating single episode cache.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._series_probe_cache[123] = {\n            \"name\": \"Test\",\n            \"episodes\": {1: (0, None, []), 2: (0, None, [])},\n        }\n\n        with patch(\"ffmpeg_command._save_series_probe_cache\"):\n            invalidate_series_probe_cache(123, episode_id=1)\n\n        assert 123 in ffmpeg_command._series_probe_cache\n        assert 1 not in ffmpeg_command._series_probe_cache[123][\"episodes\"]\n        assert 2 in ffmpeg_command._series_probe_cache[123][\"episodes\"]\n\n    def test_clear_series_mru(self):\n        \"\"\"Test clearing series MRU.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._series_probe_cache[123] = {\n            \"name\": \"Test\",\n            \"mru\": 5,\n            \"episodes\": {1: (0, None, [])},\n        }\n\n        with patch(\"ffmpeg_command._save_series_probe_cache\"):\n            clear_series_mru(123)\n\n        assert \"mru\" not in ffmpeg_command._series_probe_cache[123]\n        assert \"episodes\" in ffmpeg_command._series_probe_cache[123]\n\n    def test_restore_probe_cache_entry(self):\n        \"\"\"Test restoring probe cache entry.\"\"\"\n        import ffmpeg_command\n\n        ffmpeg_command._probe_cache.clear()\n        ffmpeg_command._series_probe_cache.clear()\n\n        media_info = MediaInfo(video_codec=\"h264\", audio_codec=\"aac\", pix_fmt=\"yuv420p\")\n        subs = [SubtitleStream(index=2, lang=\"eng\", name=\"English\")]\n\n        restore_probe_cache_entry(\"http://test\", media_info, subs, series_id=123, episode_id=5)\n\n        assert \"http://test\" in ffmpeg_command._probe_cache\n        assert 123 in ffmpeg_command._series_probe_cache\n        assert 5 in ffmpeg_command._series_probe_cache[123][\"episodes\"]\n\n    def test_get_series_probe_cache_stats(self):\n        \"\"\"Test getting cache stats for UI.\"\"\"\n        import time\n\n        import ffmpeg_command\n\n        ffmpeg_command._series_probe_cache.clear()\n        ffmpeg_command._series_probe_cache[123] = {\n            \"name\": \"Test Series\",\n            \"mru\": 2,\n            \"episodes\": {\n                1: (time.time(), MediaInfo(\"h264\", \"aac\", \"yuv420p\"), []),\n                2: (time.time(), MediaInfo(\"h264\", \"aac\", \"yuv420p\"), []),\n            },\n        }\n\n        stats = get_series_probe_cache_stats()\n\n        assert len(stats) == 1\n        assert stats[0][\"series_id\"] == 123\n        assert stats[0][\"name\"] == \"Test Series\"\n        assert stats[0][\"episode_count\"] == 2\n\n\nclass TestResolveHlsMasterPlaylist:\n    \"\"\"Tests for resolve_hls_master_playlist function.\"\"\"\n\n    def test_non_m3u8_url_returns_unchanged(self):\n        \"\"\"Non-m3u8 URLs should be returned unchanged.\"\"\"\n        from ffmpeg_command import resolve_hls_master_playlist\n\n        url = \"http://example.com/video.mp4\"\n        assert resolve_hls_master_playlist(url) == url\n\n    def test_master_playlist_selects_highest_bandwidth(self):\n        \"\"\"Master playlist should resolve to highest bandwidth variant.\"\"\"\n        from unittest.mock import MagicMock, patch\n\n        from ffmpeg_command import resolve_hls_master_playlist\n\n        master_playlist = \"\"\"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720\nmid.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080\nhigh.m3u8\n\"\"\"\n        mock_response = MagicMock()\n        mock_response.read.return_value = master_playlist.encode(\"utf-8\")\n        mock_response.__enter__ = MagicMock(return_value=mock_response)\n        mock_response.__exit__ = MagicMock(return_value=False)\n\n        with patch(\"urllib.request.urlopen\", return_value=mock_response):\n            result = resolve_hls_master_playlist(\"http://example.com/master.m3u8\")\n\n        assert result == \"http://example.com/high.m3u8\"\n\n    def test_media_playlist_returns_unchanged(self):\n        \"\"\"Media playlist (not master) should return original URL.\"\"\"\n        from unittest.mock import MagicMock, patch\n\n        from ffmpeg_command import resolve_hls_master_playlist\n\n        media_playlist = \"\"\"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n#EXTINF:10.0,\nsegment0.ts\n#EXTINF:10.0,\nsegment1.ts\n\"\"\"\n        mock_response = MagicMock()\n        mock_response.read.return_value = media_playlist.encode(\"utf-8\")\n        mock_response.__enter__ = MagicMock(return_value=mock_response)\n        mock_response.__exit__ = MagicMock(return_value=False)\n\n        with patch(\"urllib.request.urlopen\", return_value=mock_response):\n            result = resolve_hls_master_playlist(\"http://example.com/stream.m3u8\")\n\n        assert result == \"http://example.com/stream.m3u8\"\n\n    def test_fetch_error_returns_original_url(self):\n        \"\"\"On fetch error, should return original URL.\"\"\"\n        from unittest.mock import patch\n\n        from ffmpeg_command import resolve_hls_master_playlist\n\n        with patch(\"urllib.request.urlopen\", side_effect=Exception(\"Network error\")):\n            result = resolve_hls_master_playlist(\"http://example.com/master.m3u8\")\n\n        assert result == \"http://example.com/master.m3u8\"\n\n    def test_relative_url_resolved_correctly(self):\n        \"\"\"Relative variant URLs should be resolved against base URL.\"\"\"\n        from unittest.mock import MagicMock, patch\n\n        from ffmpeg_command import resolve_hls_master_playlist\n\n        master_playlist = \"\"\"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=3000000\n../streams/1080p/index.m3u8\n\"\"\"\n        mock_response = MagicMock()\n        mock_response.read.return_value = master_playlist.encode(\"utf-8\")\n        mock_response.__enter__ = MagicMock(return_value=mock_response)\n        mock_response.__exit__ = MagicMock(return_value=False)\n\n        with patch(\"urllib.request.urlopen\", return_value=mock_response):\n            result = resolve_hls_master_playlist(\"http://example.com/hls/master.m3u8\")\n\n        assert result == \"http://example.com/streams/1080p/index.m3u8\"\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "ffmpeg_session.py",
    "content": "\"\"\"FFmpeg session lifecycle management.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any\n\nimport asyncio\nimport contextlib\nimport json\nimport logging\nimport pathlib\nimport re\nimport shutil\nimport tempfile\nimport threading\nimport time\nimport uuid\n\nfrom fastapi import HTTPException\n\nfrom ffmpeg_command import (\n    SEG_PREFIX,\n    HwAccel,\n    MediaInfo,\n    SubtitleStream,\n    build_hls_ffmpeg_cmd,\n    get_ffmpeg_env,\n    get_hls_segment_duration,\n    get_settings,\n    get_transcode_dir,\n    get_user_agent,\n    invalidate_series_probe_cache,\n    probe_media,\n    resolve_hls_master_playlist,\n    restore_probe_cache_entry,\n)\n\n\nlog = logging.getLogger(__name__)\n\n# Timing constants\n_POLL_INTERVAL_SEC = 0.2\n_QUICK_FAILURE_THRESHOLD_SEC = 10.0\n_HEARTBEAT_TIMEOUT_SEC = 30.0  # 30 sec without progress poll = dead\n\n# Wait timeouts (seconds)\n_PLAYLIST_WAIT_TIMEOUT_SEC = 30.0\n_PLAYLIST_WAIT_SEEK_TIMEOUT_SEC = 40.0\n_REUSE_ACTIVE_WAIT_TIMEOUT_SEC = 15.0\n_RESUME_WAIT_TIMEOUT_SEC = 10.0\n_RESUME_SEGMENT_WAIT_TIMEOUT_SEC = 5.0\n\n# Size thresholds\n_MIN_SEGMENT_SIZE_BYTES = 1_000\n\n# Module state\n_transcode_sessions: dict[str, dict[str, Any]] = {}\n_url_to_session: dict[str, str] = {}  # URL -> session_id (all content types)\n_transcode_lock = threading.Lock()\n_background_tasks: set[asyncio.Task[None]] = set()\n\n\nclass _DeadProcess:\n    \"\"\"Placeholder for dead/recovered processes.\"\"\"\n\n    returncode = -1\n\n    def terminate(self) -> None:\n        pass\n\n    def kill(self) -> None:\n        pass\n\n\n# ===========================================================================\n# Cache Timeout Helpers\n# ===========================================================================\n\n\ndef get_vod_cache_timeout() -> int:\n    \"\"\"Get VOD session cache timeout in seconds.\"\"\"\n    return get_settings().get(\"vod_transcode_cache_mins\", 60) * 60\n\n\ndef get_live_cache_timeout() -> int:\n    \"\"\"Get live session cache timeout in seconds.\"\"\"\n    return get_settings().get(\"live_transcode_cache_secs\", 0)\n\n\n# ===========================================================================\n# Session Validity\n# ===========================================================================\n\n\ndef _is_process_alive(proc: Any) -> bool:\n    \"\"\"Check if process is still running.\"\"\"\n    if proc is None:\n        return False\n    if isinstance(proc, _DeadProcess):\n        return False\n    if hasattr(proc, \"returncode\"):\n        return proc.returncode is None\n    return False\n\n\ndef is_session_valid(session: dict[str, Any]) -> bool:\n    \"\"\"Check if session is still valid (not expired).\n\n    A session is valid if:\n    - Has received a heartbeat (progress poll) within timeout, AND\n    - Process is still running, OR process is dead but within cache timeout\n    \"\"\"\n    last_access = session.get(\"last_access\", session[\"started\"])\n    time_since_heartbeat = time.time() - last_access\n\n    # No heartbeat in 30 sec = dead regardless of process state\n    if time_since_heartbeat > _HEARTBEAT_TIMEOUT_SEC:\n        return False\n\n    # Active process with recent heartbeat = valid\n    if _is_process_alive(session.get(\"process\")):\n        return True\n\n    # Dead process: check cache timeout\n    is_vod = session.get(\"is_vod\", False)\n    cache_timeout = get_vod_cache_timeout() if is_vod else get_live_cache_timeout()\n    if cache_timeout <= 0:\n        return False  # No caching of dead sessions\n    return time_since_heartbeat < cache_timeout\n\n\ndef _kill_process(proc: Any) -> bool:\n    \"\"\"Kill process gracefully (SIGTERM then SIGKILL), return True if killed.\"\"\"\n    try:\n        # Try graceful termination first (lets ffmpeg flush buffers)\n        proc.terminate()\n        # Give it a moment to exit cleanly\n        for _ in range(10):  # 100ms total\n            if proc.returncode is not None:\n                return True\n            time.sleep(0.01)\n        # Force kill if still running\n        proc.kill()\n        return True\n    except (ProcessLookupError, OSError):\n        return False\n\n\n# ===========================================================================\n# Session Start/Stop\n# ===========================================================================\n\n\ndef stop_session(session_id: str, force: bool = False) -> None:\n    \"\"\"Stop a transcode session.\"\"\"\n    with _transcode_lock:\n        session = _transcode_sessions.get(session_id)\n        if not session:\n            return\n\n        # Skip stop if session was accessed recently (race with seeking/resume,\n        # or multiple users watching same stream)\n        if not force and time.time() - session.get(\"last_access\", 0) < 5.0:\n            log.info(\"Ignoring stop for recently-accessed session %s\", session_id)\n            return\n\n        if _kill_process(session[\"process\"]):\n            log.info(\"Killed ffmpeg for session %s\", session_id)\n\n        # Cache session if timeout > 0\n        is_vod = session.get(\"is_vod\", False)\n        cache_timeout = get_vod_cache_timeout() if is_vod else get_live_cache_timeout()\n        if not force and cache_timeout > 0:\n            session[\"last_access\"] = time.time()\n            log.info(\n                \"Session %s cached (vod=%s, ffmpeg stopped, segments kept)\",\n                session_id,\n                is_vod,\n            )\n            return\n\n        _transcode_sessions.pop(session_id, None)\n        url = session.get(\"url\")\n        if url:\n            _url_to_session.pop(url, None)\n        dir_to_remove = session[\"dir\"]\n\n    shutil.rmtree(dir_to_remove, ignore_errors=True)\n    log.info(\"Stopped transcode session %s\", session_id)\n\n\ndef cleanup_expired_sessions() -> None:\n    \"\"\"Clean up all expired sessions (VOD and live).\"\"\"\n    with _transcode_lock:\n        expired = [\n            sid\n            for sid, session in list(_transcode_sessions.items())\n            if not is_session_valid(session)\n        ]\n    for session_id in expired:\n        stop_session(session_id, force=True)\n\n\ndef shutdown() -> None:\n    \"\"\"Kill all running ffmpeg processes for clean shutdown.\"\"\"\n    with _transcode_lock:\n        for session_id, session in list(_transcode_sessions.items()):\n            proc = session.get(\"process\")\n            if proc and _kill_process(proc):\n                log.info(\"Shutdown: killed ffmpeg for session %s\", session_id)\n        _transcode_sessions.clear()\n\n\n# ===========================================================================\n# Stream Limits\n# ===========================================================================\n\n\ndef get_user_sessions(username: str) -> list[tuple[str, dict[str, Any]]]:\n    \"\"\"Get all active sessions for a user, sorted by start time (oldest first).\"\"\"\n    with _transcode_lock:\n        sessions = [\n            (sid, s) for sid, s in _transcode_sessions.items() if s.get(\"username\") == username\n        ]\n    return sorted(sessions, key=lambda x: x[1].get(\"started\", 0))\n\n\ndef get_source_sessions(source_id: str) -> list[tuple[str, dict[str, Any]]]:\n    \"\"\"Get all active sessions for a source, sorted by start time (oldest first).\"\"\"\n    with _transcode_lock:\n        sessions = [\n            (sid, s) for sid, s in _transcode_sessions.items() if s.get(\"source_id\") == source_id\n        ]\n    return sorted(sessions, key=lambda x: x[1].get(\"started\", 0))\n\n\ndef enforce_stream_limits(\n    username: str,\n    source_id: str | None,\n    user_max: int,\n    source_max: int,\n) -> str | None:\n    \"\"\"Enforce stream limits, stopping oldest sessions if needed.\n\n    Returns error message if source is at capacity and user can't reclaim,\n    or None if limits are satisfied.\n    \"\"\"\n    # Check source limit first (hard limit - can only reclaim own slots)\n    if source_id and source_max > 0:\n        source_sessions = get_source_sessions(source_id)\n        if len(source_sessions) >= source_max:\n            user_source_sessions = [\n                (sid, s) for sid, s in source_sessions if s.get(\"username\") == username\n            ]\n            if user_source_sessions:\n                oldest_sid, _ = user_source_sessions[0]\n                log.info(\n                    \"Source %s at limit (%d), stopping user %s's oldest session %s\",\n                    source_id,\n                    source_max,\n                    username,\n                    oldest_sid,\n                )\n                stop_session(oldest_sid, force=True)\n            else:\n                return f\"Source at capacity ({source_max} streams)\"\n\n    # Check user limit (soft limit - auto-rotate oldest)\n    if user_max > 0:\n        user_sessions = get_user_sessions(username)\n        if len(user_sessions) >= user_max:\n            oldest_sid, _ = user_sessions[0]\n            log.info(\n                \"User %s at limit (%d), stopping oldest session %s\",\n                username,\n                user_max,\n                oldest_sid,\n            )\n            stop_session(oldest_sid, force=True)\n\n    return None\n\n\n# ===========================================================================\n# Session Recovery (Startup)\n# ===========================================================================\n\n\ndef cleanup_and_recover_sessions() -> None:\n    \"\"\"Clean up orphaned transcode dirs and recover valid VOD sessions.\n\n    Called on startup to:\n    1. Remove all orphaned dirs (no session.json - leftover live sessions)\n    2. Remove expired VOD dirs (older than cache timeout)\n    3. Recover valid VOD sessions for resume\n    \"\"\"\n    cache_timeout = get_vod_cache_timeout()\n    now = time.time()\n    removed = recovered = 0\n\n    for d in get_transcode_dir().glob(\"netv_transcode_*\"):\n        if not d.is_dir():\n            continue\n\n        info_file = d / \"session.json\"\n        try:\n            mtime = d.stat().st_mtime\n        except OSError:\n            shutil.rmtree(d, ignore_errors=True)\n            removed += 1\n            continue\n\n        # No session.json = orphaned (live session or failed VOD)\n        if not info_file.exists():\n            shutil.rmtree(d, ignore_errors=True)\n            removed += 1\n            continue\n\n        # Expired VOD session\n        if now - mtime > cache_timeout:\n            shutil.rmtree(d, ignore_errors=True)\n            removed += 1\n            continue\n\n        # No segments = nothing to recover\n        if not list(d.glob(f\"{SEG_PREFIX}*.ts\")):\n            shutil.rmtree(d, ignore_errors=True)\n            removed += 1\n            continue\n\n        # Try to recover VOD session\n        try:\n            info = json.loads(info_file.read_text())\n            if not (info.get(\"is_vod\") and info.get(\"url\")):\n                shutil.rmtree(d, ignore_errors=True)\n                removed += 1\n                continue\n\n            session_id = info[\"session_id\"]\n            url = info[\"url\"]\n            new_seek = info.get(\"seek_offset\", 0)\n\n            with _transcode_lock:\n                _transcode_sessions[session_id] = {\n                    \"dir\": str(d),\n                    \"process\": _DeadProcess(),\n                    \"started\": info.get(\"started\", mtime),\n                    \"url\": url,\n                    \"is_vod\": True,\n                    \"last_access\": now,  # Use current time, not mtime, to avoid immediate expiration\n                    \"subtitles\": info.get(\"subtitles\") or info.get(\"subtitle_indices\"),\n                    \"duration\": info.get(\"duration\", 0),\n                    \"seek_offset\": new_seek,\n                    \"series_id\": info.get(\"series_id\"),\n                    \"episode_id\": info.get(\"episode_id\"),\n                    \"username\": info.get(\"username\", \"\"),\n                    \"source_id\": info.get(\"source_id\", \"\"),\n                }\n                # Prefer session with seek_offset or more recent mtime\n                existing_id = _url_to_session.get(url)\n                if existing_id:\n                    existing = _transcode_sessions.get(existing_id, {})\n                    existing_seek = existing.get(\"seek_offset\", 0)\n                    existing_mtime = existing.get(\"last_access\", 0)\n                    if (new_seek > 0 and existing_seek == 0) or (\n                        existing_seek == 0 and new_seek == 0 and mtime > existing_mtime\n                    ):\n                        _url_to_session[url] = session_id\n                else:\n                    _url_to_session[url] = session_id\n\n            # Restore probe cache\n            if p := info.get(\"probe\"):\n                media_info = MediaInfo(\n                    video_codec=p.get(\"video_codec\", \"\"),\n                    audio_codec=p.get(\"audio_codec\", \"\"),\n                    pix_fmt=p.get(\"pix_fmt\", \"\"),\n                    audio_channels=p.get(\"audio_channels\", 0),\n                    audio_sample_rate=p.get(\"audio_sample_rate\", 0),\n                    subtitle_codecs=p.get(\"subtitle_codecs\"),\n                    duration=info.get(\"duration\", 0),\n                    height=p.get(\"height\", 0),\n                    video_bitrate=p.get(\"video_bitrate\", 0),\n                    interlaced=p.get(\"interlaced\", False),\n                )\n                subs = [\n                    SubtitleStream(s[\"index\"], s.get(\"lang\", \"und\"), s.get(\"name\", \"\"))\n                    for s in (info.get(\"subtitles\") or [])\n                    if isinstance(s, dict) and \"index\" in s\n                ]\n                restore_probe_cache_entry(\n                    url,\n                    media_info,\n                    subs,\n                    info.get(\"series_id\"),\n                    info.get(\"episode_id\"),\n                )\n            recovered += 1\n            log.debug(\"Recovered VOD session %s for %s\", session_id, url[:50])\n        except Exception as e:\n            log.warning(\"Failed to recover session from %s: %s\", d, e)\n            shutil.rmtree(d, ignore_errors=True)\n            removed += 1\n\n    if removed or recovered:\n        log.info(\n            \"Startup cleanup: removed %d orphaned dirs, recovered %d VOD sessions\",\n            removed,\n            recovered,\n        )\n\n\n# ===========================================================================\n# FFmpeg Monitoring\n# ===========================================================================\n\n\nasync def _monitor_ffmpeg_stderr(\n    process: asyncio.subprocess.Process,\n    session_id: str,\n    stderr_lines: list[str] | None = None,\n) -> None:\n    assert process.stderr is not None\n    while True:\n        line = await process.stderr.readline()\n        if not line:\n            break\n        text = line.decode().rstrip()\n        if stderr_lines is not None:\n            stderr_lines.append(text)\n        is_fatal = \"fatal\" in text.lower() or \"aborting\" in text.lower()\n        level = logging.WARNING if is_fatal else logging.DEBUG\n        log.log(level, \"ffmpeg:%s %s\", session_id, text)\n\n\nasync def _monitor_resume_ffmpeg(\n    process: asyncio.subprocess.Process,\n    session_id: str,\n    url: str,\n) -> None:\n    start_time = time.time()\n    await _monitor_ffmpeg_stderr(process, session_id)\n    await process.wait()\n    if process.returncode != 0:\n        log.warning(\n            \"Resume ffmpeg exited with code %s for session %s\",\n            process.returncode,\n            session_id,\n        )\n        if time.time() - start_time < _QUICK_FAILURE_THRESHOLD_SEC:\n            log.info(\"Resume failed quickly, invalidating session %s\", session_id)\n            with _transcode_lock:\n                _url_to_session.pop(url, None)\n                session = _transcode_sessions.pop(session_id, None)\n            # Clean up output directory\n            if session:\n                shutil.rmtree(session[\"dir\"], ignore_errors=True)\n\n\nasync def _monitor_seek_ffmpeg(\n    process: asyncio.subprocess.Process,\n    session_id: str,\n) -> None:\n    await _monitor_ffmpeg_stderr(process, session_id)\n    await process.wait()\n    if process.returncode != 0:\n        log.warning(\n            \"Seek ffmpeg exited with code %s for session %s\",\n            process.returncode,\n            session_id,\n        )\n\n\ndef _spawn_background_task(coro: Any) -> None:\n    task = asyncio.create_task(coro)\n    _background_tasks.add(task)\n    task.add_done_callback(_background_tasks.discard)\n\n\n# ===========================================================================\n# Playlist Helpers\n# ===========================================================================\n\n\nasync def _wait_for_playlist(\n    playlist_path: pathlib.Path,\n    process: asyncio.subprocess.Process,\n    min_segments: int = 1,\n    timeout_sec: float = _PLAYLIST_WAIT_TIMEOUT_SEC,\n) -> bool:\n    \"\"\"Wait for playlist with min_segments, checking process health.\"\"\"\n    output_dir = playlist_path.parent\n    deadline = time.monotonic() + timeout_sec\n    while time.monotonic() < deadline:\n        if process.returncode is not None:\n            return False\n        if playlist_path.exists():\n            content = playlist_path.read_text()\n            seg_count = content.count(\"#EXTINF\")\n            if seg_count >= min_segments:\n                seg_files = list(output_dir.glob(f\"{SEG_PREFIX}*.ts\"))\n                if len(seg_files) >= min_segments:\n                    first_seg = min(seg_files, key=lambda f: f.name)\n                    if (\n                        first_seg.stat().st_size > _MIN_SEGMENT_SIZE_BYTES\n                        and process.returncode is None\n                    ):\n                        return True\n        await asyncio.sleep(_POLL_INTERVAL_SEC)\n    return False\n\n\ndef _calc_hls_duration(playlist_path: pathlib.Path, segment_count: int) -> float:\n    \"\"\"Calculate HLS duration from playlist or estimate from segment count.\"\"\"\n    if playlist_path.exists():\n        durations = re.findall(r\"#EXTINF:([\\d.]+)\", playlist_path.read_text())\n        if durations:\n            return sum(float(d) for d in durations)\n    return segment_count * get_hls_segment_duration()\n\n\ndef _build_subtitle_tracks(\n    session_id: str,\n    sub_info: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    if not sub_info or not isinstance(sub_info[0], dict):\n        return []\n    return [\n        {\n            \"url\": f\"/subs/{session_id}/sub{i}.vtt\",\n            \"lang\": s[\"lang\"],\n            \"label\": s[\"name\"],\n            \"default\": i == 0,\n        }\n        for i, s in enumerate(sub_info)\n    ]\n\n\ndef _regenerate_playlist(output_dir: pathlib.Path, start_segment: int) -> None:\n    \"\"\"Regenerate HLS playlist starting from a specific segment (for smart seek).\"\"\"\n    playlist_path = output_dir / \"stream.m3u8\"\n    seg_duration = get_hls_segment_duration()\n\n    # Find all existing segments from start_segment onwards\n    segments = []\n    for seg_file in sorted(output_dir.glob(f\"{SEG_PREFIX}*.ts\")):\n        try:\n            seg_num = int(seg_file.stem[len(SEG_PREFIX) :])\n            if seg_num >= start_segment and seg_file.stat().st_size > _MIN_SEGMENT_SIZE_BYTES:\n                segments.append((seg_num, seg_file.name))\n        except ValueError:\n            pass\n\n    if not segments:\n        return\n\n    # Build playlist\n    lines = [\n        \"#EXTM3U\",\n        \"#EXT-X-VERSION:3\",\n        f\"#EXT-X-TARGETDURATION:{int(seg_duration) + 1}\",\n        f\"#EXT-X-MEDIA-SEQUENCE:{start_segment}\",\n        \"#EXT-X-PLAYLIST-TYPE:EVENT\",\n    ]\n\n    for _, seg_name in segments:\n        lines.append(f\"#EXTINF:{seg_duration:.6f},\")\n        lines.append(seg_name)\n\n    playlist_path.write_text(\"\\n\".join(lines) + \"\\n\")\n    log.debug(\"Regenerated playlist with %d segments starting at %d\", len(segments), start_segment)\n\n\n# ===========================================================================\n# Session Snapshots\n# ===========================================================================\n\n\n@dataclass(slots=True)\nclass _SessionSnapshot:\n    \"\"\"Immutable snapshot of session state for lock-free access.\"\"\"\n\n    output_dir: str\n    process: Any\n    seek_offset: float\n    subtitles: list[dict[str, Any]]\n    duration: float\n\n\ndef _get_session_snapshot(session_id: str) -> _SessionSnapshot | None:\n    \"\"\"Get atomic snapshot of session state under lock.\"\"\"\n    with _transcode_lock:\n        session = _transcode_sessions.get(session_id)\n        if not session:\n            return None\n        session[\"last_access\"] = time.time()\n        return _SessionSnapshot(\n            output_dir=session[\"dir\"],\n            process=session[\"process\"],\n            seek_offset=session.get(\"seek_offset\", 0),\n            subtitles=session.get(\"subtitles\") or [],\n            duration=session.get(\"duration\", 0),\n        )\n\n\ndef _update_session_process(session_id: str, process: Any) -> bool:\n    \"\"\"Atomically update session process. Returns False if session gone.\"\"\"\n    with _transcode_lock:\n        session = _transcode_sessions.get(session_id)\n        if not session:\n            return False\n        session[\"process\"] = process\n        return True\n\n\ndef _build_session_response(\n    session_id: str,\n    snap: _SessionSnapshot,\n    playlist_path: pathlib.Path,\n) -> dict[str, Any]:\n    \"\"\"Build response dict for existing session, recalculating duration.\"\"\"\n    segments = list(playlist_path.parent.glob(f\"{SEG_PREFIX}*.ts\"))\n    return {\n        \"session_id\": session_id,\n        \"playlist\": f\"/transcode/{session_id}/stream.m3u8\",\n        \"subtitles\": _build_subtitle_tracks(session_id, snap.subtitles),\n        \"duration\": snap.duration,\n        \"seek_offset\": snap.seek_offset,\n        \"transcoded_duration\": _calc_hls_duration(playlist_path, len(segments)),\n    }\n\n\n# ===========================================================================\n# Existing Session Handling\n# ===========================================================================\n\n\ndef _get_existing_session(url: str) -> tuple[str | None, bool, float]:\n    \"\"\"Get existing session info atomically. Returns (session_id, is_valid, seek_offset).\"\"\"\n    with _transcode_lock:\n        existing_id = _url_to_session.get(url)\n        if not existing_id:\n            return None, False, 0.0\n        session = _transcode_sessions.get(existing_id)\n        if not session:\n            return None, False, 0.0\n        return (\n            existing_id,\n            is_session_valid(session),\n            session.get(\"seek_offset\", 0),\n        )\n\n\nasync def _handle_existing_vod_session(\n    existing_id: str,\n    url: str,\n    hw: HwAccel,\n    do_probe: bool,\n    max_resolution: str = \"1080p\",\n    quality: str = \"high\",\n) -> dict[str, Any] | None:\n    \"\"\"Handle existing VOD session: reuse active, return cached, or append.\n\n    Returns None to trigger fresh start if session is invalid.\n    \"\"\"\n    snap = _get_session_snapshot(existing_id)\n    if not snap:\n        return None\n\n    playlist_path = pathlib.Path(snap.output_dir) / \"stream.m3u8\"\n    segments = sorted(pathlib.Path(snap.output_dir).glob(f\"{SEG_PREFIX}*.ts\"))\n\n    # Case 1: Active session - reuse it\n    if snap.process.returncode is None:\n        log.info(\"Reusing active session %s\", existing_id)\n        await _wait_for_playlist(\n            playlist_path,\n            snap.process,\n            min_segments=1,\n            timeout_sec=_REUSE_ACTIVE_WAIT_TIMEOUT_SEC,\n        )\n        return _build_session_response(existing_id, snap, playlist_path)\n\n    # Case 2: Dead session with no segments - invalid\n    if not segments:\n        stop_session(existing_id, force=True)\n        with _transcode_lock:\n            _url_to_session.pop(url, None)\n        return None\n\n    # Case 3: Dead session with seek_offset - return cached content\n    if snap.seek_offset > 0:\n        log.info(\n            \"Returning cached session %s (seek_offset=%.1f)\",\n            existing_id,\n            snap.seek_offset,\n        )\n        return _build_session_response(existing_id, snap, playlist_path)\n\n    # Case 4: Dead session, no seek_offset - append new content\n    hls_duration = _calc_hls_duration(playlist_path, len(segments))\n    log.info(\"Resuming session %s from %.1fs\", existing_id, hls_duration)\n\n    # Resolve HLS master playlist to highest bandwidth variant\n    url = await asyncio.to_thread(resolve_hls_master_playlist, url)\n\n    media_info = (\n        (await asyncio.to_thread(probe_media, url, None, None, \"\"))[0] if do_probe else None\n    )\n    cmd = build_hls_ffmpeg_cmd(\n        url,\n        hw,\n        snap.output_dir,\n        True,\n        None,\n        media_info,\n        max_resolution,\n        quality,\n        get_user_agent(),\n        None,\n    )\n\n    i_idx = cmd.index(\"-i\")\n    cmd.insert(i_idx, str(hls_duration))\n    cmd.insert(i_idx, \"-ss\")\n    try:\n        hls_flags_idx = cmd.index(\"-hls_flags\")\n        cmd[hls_flags_idx + 1] += \"+append_list\"\n    except ValueError:\n        cmd.extend([\"-hls_flags\", \"append_list\"])\n    cmd.extend([\"-start_number\", str(len(segments))])\n\n    process = await asyncio.create_subprocess_exec(\n        *cmd,\n        stdin=asyncio.subprocess.DEVNULL,\n        stdout=asyncio.subprocess.PIPE,\n        stderr=asyncio.subprocess.PIPE,\n        env=get_ffmpeg_env(),\n    )\n    if not _update_session_process(existing_id, process):\n        _kill_process(process)\n        return None\n\n    _spawn_background_task(_monitor_resume_ffmpeg(process, existing_id, url))\n    log.info(\"Started resume ffmpeg pid=%s for %s\", process.pid, existing_id)\n\n    deadline = time.monotonic() + _RESUME_SEGMENT_WAIT_TIMEOUT_SEC\n    next_seg = f\"{SEG_PREFIX}{len(segments):03d}.ts\"\n    while time.monotonic() < deadline:\n        if process.returncode is not None:\n            log.warning(\"Resume ffmpeg died immediately for %s\", existing_id)\n            return None\n        if (pathlib.Path(snap.output_dir) / next_seg).exists():\n            break\n        await asyncio.sleep(_POLL_INTERVAL_SEC)\n\n    await _wait_for_playlist(\n        playlist_path,\n        process,\n        min_segments=1,\n        timeout_sec=_RESUME_WAIT_TIMEOUT_SEC,\n    )\n    return _build_session_response(existing_id, snap, playlist_path)\n\n\nasync def _try_reuse_session(\n    existing_id: str,\n    url: str,\n    is_vod: bool,\n    content_type: str,\n) -> dict[str, Any] | None:\n    \"\"\"Try to reuse an existing valid session. Returns response or None if can't reuse.\"\"\"\n    if is_vod:\n        settings = get_settings()\n        return await _handle_existing_vod_session(\n            existing_id,\n            url,\n            settings.get(\"transcode_hw\", \"software\"),\n            settings.get(\n                {\"movie\": \"probe_movies\", \"series\": \"probe_series\"}.get(content_type, \"\"), False\n            ),\n            settings.get(\"max_resolution\", \"1080p\"),\n            settings.get(\"quality\", \"high\"),\n        )\n\n    # Live: return existing session if snapshot available\n    snap = _get_session_snapshot(existing_id)\n    if not snap:\n        return None\n    playlist_path = pathlib.Path(snap.output_dir) / \"stream.m3u8\"\n    return _build_session_response(existing_id, snap, playlist_path)\n\n\ndef _cleanup_invalid_session(url: str, session_id: str) -> None:\n    \"\"\"Clean up an invalid/expired session.\"\"\"\n    with _transcode_lock:\n        _url_to_session.pop(url, None)\n    stop_session(session_id, force=True)\n\n\n# ===========================================================================\n# Core Transcode Logic\n# ===========================================================================\n\n\nasync def _do_start_transcode(\n    url: str,\n    content_type: str,\n    series_id: int | None,\n    episode_id: int | None,\n    old_seek_offset: float,\n    series_name: str = \"\",\n    deinterlace_fallback: bool = True,\n    username: str = \"\",\n    source_id: str = \"\",\n) -> dict[str, Any]:\n    \"\"\"Core transcode logic. Raises HTTPException on failure.\"\"\"\n    # Resolve HLS master playlist to highest bandwidth variant\n    url = await asyncio.to_thread(resolve_hls_master_playlist, url)\n\n    settings = get_settings()\n    hw = settings.get(\"transcode_hw\", \"software\")\n    max_resolution = settings.get(\"max_resolution\", \"1080p\")\n    quality = settings.get(\"quality\", \"high\")\n    is_vod = content_type in (\"movie\", \"series\")\n    probe_key = {\"movie\": \"probe_movies\", \"series\": \"probe_series\", \"live\": \"probe_live\"}\n    do_probe = settings.get(probe_key.get(content_type, \"\"), False)\n\n    session_id = str(uuid.uuid4())\n    output_dir = tempfile.mkdtemp(\n        prefix=f\"netv_transcode_{session_id}_\",\n        dir=get_transcode_dir(),\n    )\n    playlist_path = pathlib.Path(output_dir) / \"stream.m3u8\"\n\n    media_info: MediaInfo | None = None\n    subtitles: list[SubtitleStream] = []\n    if do_probe:\n        media_info, subtitles = await asyncio.to_thread(\n            probe_media, url, series_id, episode_id, series_name\n        )\n        if media_info:\n            subs_str = (\n                \",\".join(media_info.subtitle_codecs) if media_info.subtitle_codecs else \"none\"\n            )\n            if subtitles:\n                subs_str += f\" [extract:{','.join(s.lang for s in subtitles)}]\"\n            bitrate_str = (\n                f\"{media_info.video_bitrate / 1_000_000:.1f}Mbps\"\n                if media_info.video_bitrate\n                else \"?\"\n            )\n            log.info(\n                \"Probe: video=%s/%s/%dp/%s%s audio=%s/%dch/%dHz duration=%.0fs subs=%s\",\n                media_info.video_codec,\n                media_info.pix_fmt,\n                media_info.height,\n                bitrate_str,\n                \"/interlaced\" if media_info.interlaced else \"\",\n                media_info.audio_codec,\n                media_info.audio_channels,\n                media_info.audio_sample_rate,\n                media_info.duration,\n                subs_str,\n            )\n\n    cmd = build_hls_ffmpeg_cmd(\n        url,\n        hw,\n        output_dir,\n        is_vod,\n        subtitles,\n        media_info,\n        max_resolution,\n        quality,\n        get_user_agent(),\n        deinterlace_fallback,\n    )\n    if old_seek_offset > 0:\n        i_idx = cmd.index(\"-i\")\n        cmd.insert(i_idx, str(old_seek_offset))\n        cmd.insert(i_idx, \"-ss\")\n        log.info(\"Applying seek_offset=%.1f from previous session\", old_seek_offset)\n\n    log.info(\n        \"Starting transcode session %s (vod=%s): %s\",\n        session_id,\n        is_vod,\n        \" \".join(cmd),\n    )\n\n    process = await asyncio.create_subprocess_exec(\n        *cmd,\n        stdin=asyncio.subprocess.DEVNULL,\n        stdout=asyncio.subprocess.PIPE,\n        stderr=asyncio.subprocess.PIPE,\n        env=get_ffmpeg_env(),\n    )\n\n    stderr_lines: list[str] = []\n    _spawn_background_task(_monitor_ffmpeg_stderr(process, session_id, stderr_lines))\n\n    sub_info = [{\"index\": s.index, \"lang\": s.lang, \"name\": s.name} for s in subtitles]\n    total_duration = media_info.duration if media_info else 0.0\n\n    with _transcode_lock:\n        _transcode_sessions[session_id] = {\n            \"dir\": output_dir,\n            \"process\": process,\n            \"started\": time.time(),\n            \"url\": url,\n            \"is_vod\": is_vod,\n            \"last_access\": time.time(),\n            \"subtitles\": sub_info,\n            \"duration\": total_duration,\n            \"seek_offset\": old_seek_offset,\n            \"series_id\": series_id,\n            \"episode_id\": episode_id,\n            \"username\": username,\n            \"source_id\": source_id,\n        }\n        _url_to_session[url] = session_id\n\n    if is_vod:\n        session_info: dict[str, Any] = {\n            \"session_id\": session_id,\n            \"url\": url,\n            \"is_vod\": True,\n            \"started\": time.time(),\n            \"subtitles\": sub_info,\n            \"duration\": total_duration,\n            \"seek_offset\": old_seek_offset,\n            \"series_id\": series_id,\n            \"episode_id\": episode_id,\n            \"username\": username,\n            \"source_id\": source_id,\n        }\n        if media_info:\n            session_info[\"probe\"] = {\n                \"video_codec\": media_info.video_codec,\n                \"audio_codec\": media_info.audio_codec,\n                \"pix_fmt\": media_info.pix_fmt,\n                \"audio_channels\": media_info.audio_channels,\n                \"audio_sample_rate\": media_info.audio_sample_rate,\n                \"subtitle_codecs\": media_info.subtitle_codecs,\n                \"height\": media_info.height,\n                \"video_bitrate\": media_info.video_bitrate,\n                \"interlaced\": media_info.interlaced,\n            }\n        (pathlib.Path(output_dir) / \"session.json\").write_text(json.dumps(session_info))\n\n    timeout = _PLAYLIST_WAIT_SEEK_TIMEOUT_SEC if old_seek_offset > 0 else _PLAYLIST_WAIT_TIMEOUT_SEC\n    if not await _wait_for_playlist(\n        playlist_path,\n        process,\n        min_segments=2,\n        timeout_sec=timeout,\n    ):\n        # Wait for process to fully exit and stderr to be captured\n        with contextlib.suppress(TimeoutError):\n            await asyncio.wait_for(process.wait(), timeout=1.0)\n        # Give stderr monitor time to process final output\n        await asyncio.sleep(0.1)\n        error_msg = \"\\n\".join(stderr_lines[-10:]) if stderr_lines else \"unknown\"\n        log.error(\n            \"ffmpeg:%s failed (exit %d): %s\",\n            session_id,\n            process.returncode or -1,\n            error_msg,\n        )\n        stop_session(session_id)\n        raise HTTPException(500, \"Transcode failed - check server logs for details\")\n\n    return {\n        \"session_id\": session_id,\n        \"playlist\": f\"/transcode/{session_id}/stream.m3u8\",\n        \"subtitles\": _build_subtitle_tracks(session_id, sub_info),\n        \"duration\": total_duration,\n        \"seek_offset\": old_seek_offset,\n    }\n\n\nasync def start_transcode(\n    url: str,\n    content_type: str = \"live\",\n    series_id: int | None = None,\n    episode_id: int | None = None,\n    series_name: str = \"\",\n    deinterlace_fallback: bool = True,\n    username: str = \"\",\n    source_id: str = \"\",\n    user_max_streams: int = 0,\n    source_max_streams: int = 0,\n) -> dict[str, Any]:\n    \"\"\"Start or reuse a transcode session.\"\"\"\n    # Enforce stream limits\n    if username:\n        error = enforce_stream_limits(username, source_id, user_max_streams, source_max_streams)\n        if error:\n            raise HTTPException(status_code=429, detail=error)\n\n    is_vod = content_type in (\"movie\", \"series\")\n    existing_id, is_valid, old_seek_offset = _get_existing_session(url)\n\n    # Try to reuse existing valid session\n    if existing_id and is_valid:\n        log.info(\"Found valid existing session %s (vod=%s)\", existing_id, is_vod)\n        result = await _try_reuse_session(existing_id, url, is_vod, content_type)\n        if result:\n            return result\n\n    # Clean up any existing invalid session\n    if existing_id:\n        log.info(\"Cleaning up invalid session %s\", existing_id)\n        _cleanup_invalid_session(url, existing_id)\n\n    # Start fresh transcode (with retry for series probe cache staleness)\n    try:\n        return await _do_start_transcode(\n            url,\n            content_type,\n            series_id,\n            episode_id,\n            old_seek_offset,\n            series_name,\n            deinterlace_fallback,\n            username,\n            source_id,\n        )\n    except HTTPException:\n        if series_id is None:\n            raise\n        log.info(\"Transcode failed, clearing probe cache and retrying\")\n        invalidate_series_probe_cache(series_id, episode_id)\n        return await _do_start_transcode(\n            url,\n            content_type,\n            series_id,\n            episode_id,\n            old_seek_offset,\n            series_name,\n            deinterlace_fallback,\n            username,\n            source_id,\n        )\n\n\n# ===========================================================================\n# Session Query/Update\n# ===========================================================================\n\n\ndef get_session(session_id: str) -> dict[str, Any] | None:\n    \"\"\"Get a copy of session dict (safe to use outside lock).\"\"\"\n    with _transcode_lock:\n        session = _transcode_sessions.get(session_id)\n        return dict(session) if session else None\n\n\ndef touch_session(session_id: str) -> bool:\n    \"\"\"Update session last_access timestamp (heartbeat). Returns True if session exists.\"\"\"\n    with _transcode_lock:\n        session = _transcode_sessions.get(session_id)\n        if session:\n            session[\"last_access\"] = time.time()\n            return True\n        return False\n\n\ndef get_session_progress(session_id: str) -> dict[str, Any] | None:\n    \"\"\"Get transcode progress for a session.\"\"\"\n    touch_session(session_id)\n\n    session = get_session(session_id)\n    if not session:\n        return None\n    playlist_path = pathlib.Path(session[\"dir\"]) / \"stream.m3u8\"\n    if not playlist_path.exists():\n        return {\"segment_count\": 0, \"duration\": 0.0}\n    durations = re.findall(r\"#EXTINF:([\\d.]+)\", playlist_path.read_text())\n    return {\n        \"segment_count\": len(durations),\n        \"duration\": sum(float(d) for d in durations),\n    }\n\n\ndef clear_url_session(url: str) -> str | None:\n    \"\"\"Clear URL-to-session mapping.\"\"\"\n    with _transcode_lock:\n        return _url_to_session.pop(url, None)\n\n\n# ===========================================================================\n# Seek\n# ===========================================================================\n\n\n@dataclass(slots=True)\nclass _SeekSessionInfo:\n    \"\"\"Snapshot of session info needed for seek.\"\"\"\n\n    url: str\n    output_dir: str\n    process: Any\n    subtitles: list[dict[str, Any]]\n    series_id: int | None\n    episode_id: int | None\n\n\ndef _get_seek_session_info(session_id: str) -> _SeekSessionInfo | None:\n    \"\"\"Get session info for seek atomically. Returns None if not VOD.\"\"\"\n    with _transcode_lock:\n        session = _transcode_sessions.get(session_id)\n        if not session or not session.get(\"is_vod\"):\n            return None\n        return _SeekSessionInfo(\n            url=session[\"url\"],\n            output_dir=session[\"dir\"],\n            process=session[\"process\"],\n            subtitles=session.get(\"subtitles\") or [],\n            series_id=session.get(\"series_id\"),\n            episode_id=session.get(\"episode_id\"),\n        )\n\n\ndef _update_seek_session(\n    session_id: str,\n    url: str,\n    process: Any,\n    seek_time: float,\n) -> bool:\n    \"\"\"Update session after seek. Returns False if session gone.\"\"\"\n    with _transcode_lock:\n        session = _transcode_sessions.get(session_id)\n        if not session:\n            return False\n        session[\"process\"] = process\n        session[\"seek_offset\"] = seek_time\n        if url:\n            _url_to_session[url] = session_id\n        return True\n\n\nasync def seek_transcode(session_id: str, seek_time: float) -> dict[str, Any]:\n    \"\"\"Seek to a specific time in a VOD session.\"\"\"\n    info = _get_seek_session_info(session_id)\n    if not info:\n        raise HTTPException(404, \"Session not found or not VOD\")\n\n    settings = get_settings()\n    hw = settings.get(\"transcode_hw\", \"software\")\n    max_resolution = settings.get(\"max_resolution\", \"1080p\")\n    quality = settings.get(\"quality\", \"high\")\n    seg_duration = get_hls_segment_duration()\n    segment_num = int(seek_time / seg_duration)\n\n    output_path = pathlib.Path(info.output_dir)\n    target_segment = output_path / f\"{SEG_PREFIX}{segment_num:03d}.ts\"\n\n    # Smart seek: if target segment exists, no need to restart ffmpeg\n    if target_segment.exists() and target_segment.stat().st_size > _MIN_SEGMENT_SIZE_BYTES:\n        log.info(\n            \"Smart seek: segment %d exists for time %.1fs, skipping ffmpeg restart\",\n            segment_num,\n            seek_time,\n        )\n        with _transcode_lock:\n            session = _transcode_sessions.get(session_id)\n            if session:\n                session[\"seek_offset\"] = seek_time\n        _regenerate_playlist(output_path, segment_num)\n        return {\"session_id\": session_id, \"playlist\": f\"/transcode/{session_id}/stream.m3u8\"}\n\n    # Kill existing process\n    if _kill_process(info.process):\n        log.info(\"Killed ffmpeg for seek in session %s\", session_id)\n\n    # Clear playlist but keep segments (for backward seeks later)\n    playlist_file = output_path / \"stream.m3u8\"\n    playlist_file.unlink(missing_ok=True)\n    # Only clear segments AFTER target (we might seek back to earlier ones)\n    for seg_file in output_path.glob(f\"{SEG_PREFIX}*.ts\"):\n        try:\n            seg_num = int(seg_file.stem[len(SEG_PREFIX) :])\n            if seg_num >= segment_num:\n                seg_file.unlink(missing_ok=True)\n        except ValueError:\n            pass\n    for vtt_file in output_path.glob(\"sub*.vtt\"):\n        vtt_file.unlink(missing_ok=True)\n\n    # Resolve HLS master playlist to highest bandwidth variant\n    url = await asyncio.to_thread(resolve_hls_master_playlist, info.url)\n\n    # Use probe_series if series_id, else probe_movies\n    probe_setting = \"probe_series\" if info.series_id else \"probe_movies\"\n    do_probe = settings.get(probe_setting, False)\n    if do_probe:\n        media_info = (\n            await asyncio.to_thread(\n                probe_media,\n                url,\n                info.series_id,\n                info.episode_id,\n            )\n        )[0]\n    else:\n        media_info = None\n\n    subtitles: list[SubtitleStream] = []\n    for s in info.subtitles:\n        if isinstance(s, dict) and \"index\" in s:\n            subtitles.append(\n                SubtitleStream(\n                    index=s[\"index\"],\n                    lang=s.get(\"lang\", \"und\"),\n                    name=s.get(\"name\", \"Unknown\"),\n                )\n            )\n\n    cmd = build_hls_ffmpeg_cmd(\n        url,\n        hw,\n        info.output_dir,\n        True,\n        subtitles or None,\n        media_info,\n        max_resolution,\n        quality,\n        get_user_agent(),\n        None,\n    )\n    i_idx = cmd.index(\"-i\")\n    cmd.insert(i_idx, str(seek_time))\n    cmd.insert(i_idx, \"-ss\")\n    # Shift output timestamps so subtitles start at 0 after seek\n    f_idx = cmd.index(\"-f\")\n    cmd.insert(f_idx, str(-seek_time))\n    cmd.insert(f_idx, \"-output_ts_offset\")\n    cmd.extend([\"-start_number\", str(segment_num)])\n\n    log.info(\n        \"Seek transcode %s to %.1fs (seg %d): %s\",\n        session_id,\n        seek_time,\n        segment_num,\n        \" \".join(cmd),\n    )\n\n    process = await asyncio.create_subprocess_exec(\n        *cmd,\n        stdin=asyncio.subprocess.DEVNULL,\n        stdout=asyncio.subprocess.PIPE,\n        stderr=asyncio.subprocess.PIPE,\n        env=get_ffmpeg_env(),\n    )\n\n    if not _update_seek_session(session_id, info.url, process, seek_time):\n        _kill_process(process)\n        raise HTTPException(404, \"Session disappeared during seek\")\n\n    # Persist seek_offset\n    session_json = output_path / \"session.json\"\n    if session_json.exists():\n        try:\n            data = json.loads(session_json.read_text())\n            data[\"seek_offset\"] = seek_time\n            session_json.write_text(json.dumps(data))\n        except Exception as e:\n            log.warning(\"Failed to update session.json for %s: %s\", session_id, e)\n\n    _spawn_background_task(_monitor_seek_ffmpeg(process, session_id))\n\n    if not await _wait_for_playlist(\n        playlist_file,\n        process,\n        min_segments=2,\n        timeout_sec=_PLAYLIST_WAIT_TIMEOUT_SEC,\n    ):\n        raise HTTPException(500, \"Seek transcode timed out waiting for playlist\")\n\n    log.info(\"Seek ready: %s\", playlist_file)\n\n    return {\n        \"ok\": True,\n        \"segment\": segment_num,\n        \"time\": seek_time,\n    }\n"
  },
  {
    "path": "ffmpeg_session_test.py",
    "content": "\"\"\"Tests for ffmpeg session management.\"\"\"\n\nfrom unittest.mock import patch\n\nimport json\nimport pathlib\nimport tempfile\nimport time\n\nfrom ffmpeg_session import (\n    _HEARTBEAT_TIMEOUT_SEC,\n    _build_subtitle_tracks,\n    _calc_hls_duration,\n    _DeadProcess,\n    _is_process_alive,\n    _kill_process,\n    _regenerate_playlist,\n    _transcode_lock,\n    _transcode_sessions,\n    _url_to_session,\n    cleanup_and_recover_sessions,\n    cleanup_expired_sessions,\n    clear_url_session,\n    enforce_stream_limits,\n    get_live_cache_timeout,\n    get_session,\n    get_session_progress,\n    get_source_sessions,\n    get_user_sessions,\n    get_vod_cache_timeout,\n    is_session_valid,\n    shutdown,\n    stop_session,\n    touch_session,\n)\n\n\nclass FakeProcess:\n    \"\"\"Fake async process for testing.\"\"\"\n\n    def __init__(self, alive: bool = True, killed: bool = False):\n        self.returncode = None if alive else 0\n        self._killed = killed\n\n    def terminate(self) -> None:\n        if self._killed:\n            raise ProcessLookupError(\"No such process\")\n        self.returncode = -15  # SIGTERM\n\n    def kill(self) -> None:\n        if self._killed:\n            raise ProcessLookupError(\"No such process\")\n        self.returncode = -9  # SIGKILL\n\n\ndef _clear_session_state():\n    \"\"\"Clear all session state for test isolation.\"\"\"\n    with _transcode_lock:\n        _transcode_sessions.clear()\n        _url_to_session.clear()\n\n\n# =============================================================================\n# Process Lifecycle Tests\n# =============================================================================\n\n\nclass TestIsProcessAlive:\n    \"\"\"Tests for _is_process_alive.\"\"\"\n\n    def test_none_is_dead(self):\n        assert _is_process_alive(None) is False\n\n    def test_dead_process_placeholder(self):\n        assert _is_process_alive(_DeadProcess()) is False\n\n    def test_alive_process(self):\n        proc = FakeProcess(alive=True)\n        assert _is_process_alive(proc) is True\n\n    def test_dead_process(self):\n        proc = FakeProcess(alive=False)\n        assert _is_process_alive(proc) is False\n\n\nclass TestKillProcess:\n    \"\"\"Tests for _kill_process.\"\"\"\n\n    def test_kill_alive_process(self):\n        proc = FakeProcess(alive=True)\n        assert _kill_process(proc) is True\n        # SIGTERM (-15) is used first; if process exits, SIGKILL (-9) isn't needed\n        assert proc.returncode == -15\n\n    def test_kill_already_dead(self):\n        proc = FakeProcess(alive=True, killed=True)\n        assert _kill_process(proc) is False\n\n\n# =============================================================================\n# Session Validity Tests\n# =============================================================================\n\n\nclass TestIsSessionValid:\n    \"\"\"Tests for is_session_valid with heartbeat timeout.\"\"\"\n\n    def test_active_process_with_recent_heartbeat(self):\n        \"\"\"Active process + recent heartbeat = valid.\"\"\"\n        session = {\n            \"process\": FakeProcess(alive=True),\n            \"started\": time.time(),\n            \"last_access\": time.time(),\n            \"is_vod\": False,\n        }\n        assert is_session_valid(session) is True\n\n    def test_active_process_stale_heartbeat(self):\n        \"\"\"Active process but no heartbeat in 5+ min = invalid.\"\"\"\n        session = {\n            \"process\": FakeProcess(alive=True),\n            \"started\": time.time() - 400,\n            \"last_access\": time.time() - 400,  # 6+ min ago\n            \"is_vod\": False,\n        }\n        assert is_session_valid(session) is False\n\n    def test_dead_process_live_session_no_cache(self):\n        \"\"\"Dead process, live session, cache=0 = invalid.\"\"\"\n        with patch(\"ffmpeg_session.get_live_cache_timeout\", return_value=0):\n            session = {\n                \"process\": FakeProcess(alive=False),\n                \"started\": time.time(),\n                \"last_access\": time.time(),\n                \"is_vod\": False,\n            }\n            assert is_session_valid(session) is False\n\n    def test_dead_process_vod_session_within_cache(self):\n        \"\"\"Dead process, VOD session, within cache timeout = valid.\"\"\"\n        with patch(\"ffmpeg_session.get_vod_cache_timeout\", return_value=3600):\n            session = {\n                \"process\": FakeProcess(alive=False),\n                \"started\": time.time() - 10,\n                \"last_access\": time.time() - 10,  # 10 sec ago (within 30 sec heartbeat)\n                \"is_vod\": True,\n            }\n            assert is_session_valid(session) is True\n\n    def test_dead_process_vod_session_expired_cache(self):\n        \"\"\"Dead process, VOD session, past cache timeout = invalid.\"\"\"\n        with patch(\"ffmpeg_session.get_vod_cache_timeout\", return_value=60):\n            session = {\n                \"process\": FakeProcess(alive=False),\n                \"started\": time.time() - 120,\n                \"last_access\": time.time() - 120,  # 2 min ago, cache is 1 min\n                \"is_vod\": True,\n            }\n            assert is_session_valid(session) is False\n\n    def test_heartbeat_timeout_boundary(self):\n        \"\"\"Test exactly at heartbeat timeout boundary.\"\"\"\n        # Just under timeout = valid (if process alive)\n        session = {\n            \"process\": FakeProcess(alive=True),\n            \"started\": time.time() - (_HEARTBEAT_TIMEOUT_SEC - 1),\n            \"last_access\": time.time() - (_HEARTBEAT_TIMEOUT_SEC - 1),\n            \"is_vod\": False,\n        }\n        assert is_session_valid(session) is True\n\n        # Just over timeout = invalid\n        session[\"last_access\"] = time.time() - (_HEARTBEAT_TIMEOUT_SEC + 1)\n        assert is_session_valid(session) is False\n\n    def test_missing_last_access_uses_started(self):\n        \"\"\"If last_access missing, falls back to started time.\"\"\"\n        session = {\n            \"process\": FakeProcess(alive=True),\n            \"started\": time.time(),\n            \"is_vod\": False,\n        }\n        assert is_session_valid(session) is True\n\n\n# =============================================================================\n# Cache Timeout Tests\n# =============================================================================\n\n\nclass TestCacheTimeouts:\n    \"\"\"Tests for cache timeout getters.\"\"\"\n\n    def test_vod_cache_timeout_default(self):\n        \"\"\"VOD cache default is 60 min = 3600 sec.\"\"\"\n        with patch(\"ffmpeg_session.get_settings\", return_value={}):\n            assert get_vod_cache_timeout() == 3600\n\n    def test_vod_cache_timeout_custom(self):\n        \"\"\"VOD cache from settings.\"\"\"\n        with patch(\"ffmpeg_session.get_settings\", return_value={\"vod_transcode_cache_mins\": 30}):\n            assert get_vod_cache_timeout() == 1800\n\n    def test_live_cache_timeout_default(self):\n        \"\"\"Live cache default is 0 (no caching).\"\"\"\n        with patch(\"ffmpeg_session.get_settings\", return_value={}):\n            assert get_live_cache_timeout() == 0\n\n    def test_live_cache_timeout_custom(self):\n        \"\"\"Live cache from settings.\"\"\"\n        with patch(\"ffmpeg_session.get_settings\", return_value={\"live_transcode_cache_secs\": 30}):\n            assert get_live_cache_timeout() == 30\n\n\n# =============================================================================\n# Session Start/Stop Tests\n# =============================================================================\n\n\nclass TestStopSession:\n    \"\"\"Tests for stop_session.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_stop_nonexistent_session(self):\n        \"\"\"Stopping nonexistent session is a no-op.\"\"\"\n        stop_session(\"nonexistent\")  # Should not raise\n\n    def test_stop_session_force(self):\n        \"\"\"Force stop removes session.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            session_id = \"test-123\"\n            with _transcode_lock:\n                _transcode_sessions[session_id] = {\n                    \"process\": FakeProcess(alive=True),\n                    \"dir\": tmp,\n                    \"url\": \"http://test\",\n                    \"last_access\": time.time(),\n                }\n                _url_to_session[\"http://test\"] = session_id\n\n            stop_session(session_id, force=True)\n\n            assert session_id not in _transcode_sessions\n            assert \"http://test\" not in _url_to_session\n\n    def test_stop_session_skip_recent_vod(self):\n        \"\"\"Skip stop for recently-accessed VOD session (race protection for seeking).\"\"\"\n        session_id = \"test-456\"\n        with _transcode_lock:\n            _transcode_sessions[session_id] = {\n                \"process\": FakeProcess(alive=True),\n                \"dir\": \"/tmp/test\",\n                \"url\": \"http://test\",\n                \"is_vod\": True,  # Grace period only applies to VOD\n                \"last_access\": time.time(),  # Just now\n            }\n\n        stop_session(session_id, force=False)\n\n        # VOD session should still exist because it was recently accessed\n        assert session_id in _transcode_sessions\n\n    def test_stop_session_skips_recent_live(self):\n        \"\"\"Live sessions also get grace period for multi-user support.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            session_id = \"test-live\"\n            with _transcode_lock:\n                _transcode_sessions[session_id] = {\n                    \"process\": FakeProcess(alive=True),\n                    \"dir\": tmp,\n                    \"url\": \"http://live\",\n                    \"is_vod\": False,\n                    \"last_access\": time.time(),  # Just now\n                }\n                _url_to_session[\"http://live\"] = session_id\n\n            with patch(\"ffmpeg_session.get_live_cache_timeout\", return_value=0):\n                stop_session(session_id, force=False)\n\n            # Live session should still exist because it was recently accessed\n            assert session_id in _transcode_sessions\n\n    def test_stop_session_multi_user_grace_period(self):\n        \"\"\"Stopping session while another user watching should preserve session.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            session_id = \"test-shared\"\n            with _transcode_lock:\n                _transcode_sessions[session_id] = {\n                    \"process\": FakeProcess(alive=True),\n                    \"dir\": tmp,\n                    \"url\": \"http://shared-stream\",\n                    \"is_vod\": False,\n                    \"last_access\": time.time() - 10,  # User A started 10 sec ago\n                }\n                _url_to_session[\"http://shared-stream\"] = session_id\n\n            # User B accesses stream (simulates progress poll or segment request)\n            touch_session(session_id)\n\n            # User A disconnects and triggers stop\n            with patch(\"ffmpeg_session.get_live_cache_timeout\", return_value=0):\n                stop_session(session_id, force=False)\n\n            # Session should survive because User B just accessed it\n            assert session_id in _transcode_sessions\n            assert _transcode_sessions[session_id][\"process\"].returncode is None\n\n    def test_stop_session_caches_vod(self):\n        \"\"\"Stop caches VOD session instead of removing it.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            session_id = \"test-vod\"\n            with _transcode_lock:\n                _transcode_sessions[session_id] = {\n                    \"process\": FakeProcess(alive=True),\n                    \"dir\": tmp,\n                    \"url\": \"http://vod\",\n                    \"is_vod\": True,\n                    \"last_access\": time.time() - 10,  # Old enough to stop\n                }\n                _url_to_session[\"http://vod\"] = session_id\n\n            with patch(\"ffmpeg_session.get_vod_cache_timeout\", return_value=3600):\n                stop_session(session_id, force=False)\n\n            # Session should still exist (cached)\n            assert session_id in _transcode_sessions\n\n\nclass TestCleanupExpiredSessions:\n    \"\"\"Tests for cleanup_expired_sessions.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_cleanup_removes_expired(self):\n        \"\"\"Cleanup removes expired sessions.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            session_id = \"expired-session\"\n            with _transcode_lock:\n                _transcode_sessions[session_id] = {\n                    \"process\": FakeProcess(alive=False),\n                    \"dir\": tmp,\n                    \"url\": \"http://expired\",\n                    \"is_vod\": False,\n                    \"started\": time.time() - 400,\n                    \"last_access\": time.time() - 400,  # Expired\n                }\n\n            with patch(\"ffmpeg_session.get_live_cache_timeout\", return_value=0):\n                cleanup_expired_sessions()\n\n            assert session_id not in _transcode_sessions\n\n    def test_cleanup_keeps_valid(self):\n        \"\"\"Cleanup keeps valid sessions.\"\"\"\n        session_id = \"valid-session\"\n        with _transcode_lock:\n            _transcode_sessions[session_id] = {\n                \"process\": FakeProcess(alive=True),\n                \"dir\": \"/tmp/test\",\n                \"url\": \"http://valid\",\n                \"is_vod\": False,\n                \"started\": time.time(),\n                \"last_access\": time.time(),\n            }\n\n        cleanup_expired_sessions()\n\n        assert session_id in _transcode_sessions\n\n\nclass TestShutdown:\n    \"\"\"Tests for shutdown.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_shutdown_kills_all_processes(self):\n        \"\"\"Shutdown kills all processes and clears sessions.\"\"\"\n        proc1 = FakeProcess(alive=True)\n        proc2 = FakeProcess(alive=True)\n\n        with _transcode_lock:\n            _transcode_sessions[\"s1\"] = {\"process\": proc1, \"dir\": \"/tmp/1\"}\n            _transcode_sessions[\"s2\"] = {\"process\": proc2, \"dir\": \"/tmp/2\"}\n\n        shutdown()\n\n        # SIGTERM (-15) is used first; if process exits, SIGKILL (-9) isn't needed\n        assert proc1.returncode == -15\n        assert proc2.returncode == -15\n        assert len(_transcode_sessions) == 0\n\n\n# =============================================================================\n# Stream Limits Tests\n# =============================================================================\n\n\nclass TestGetUserSessions:\n    \"\"\"Tests for get_user_sessions.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_get_user_sessions_filters_by_username(self):\n        \"\"\"Returns only sessions for specified user.\"\"\"\n        with _transcode_lock:\n            _transcode_sessions[\"s1\"] = {\"username\": \"alice\", \"started\": 1}\n            _transcode_sessions[\"s2\"] = {\"username\": \"bob\", \"started\": 2}\n            _transcode_sessions[\"s3\"] = {\"username\": \"alice\", \"started\": 3}\n\n        sessions = get_user_sessions(\"alice\")\n        assert len(sessions) == 2\n        assert sessions[0][0] == \"s1\"  # Sorted by start time\n        assert sessions[1][0] == \"s3\"\n\n    def test_get_user_sessions_empty(self):\n        \"\"\"Returns empty list for user with no sessions.\"\"\"\n        sessions = get_user_sessions(\"nobody\")\n        assert sessions == []\n\n\nclass TestGetSourceSessions:\n    \"\"\"Tests for get_source_sessions.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_get_source_sessions_filters_by_source(self):\n        \"\"\"Returns only sessions for specified source.\"\"\"\n        with _transcode_lock:\n            _transcode_sessions[\"s1\"] = {\"source_id\": \"src1\", \"started\": 1}\n            _transcode_sessions[\"s2\"] = {\"source_id\": \"src2\", \"started\": 2}\n            _transcode_sessions[\"s3\"] = {\"source_id\": \"src1\", \"started\": 3}\n\n        sessions = get_source_sessions(\"src1\")\n        assert len(sessions) == 2\n        assert sessions[0][0] == \"s1\"\n        assert sessions[1][0] == \"s3\"\n\n\nclass TestEnforceStreamLimits:\n    \"\"\"Tests for enforce_stream_limits.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_no_limits_returns_none(self):\n        \"\"\"No limits set = no error.\"\"\"\n        result = enforce_stream_limits(\"alice\", None, 0, 0)\n        assert result is None\n\n    def test_user_limit_stops_oldest(self):\n        \"\"\"User at limit stops their oldest session.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            with _transcode_lock:\n                _transcode_sessions[\"s1\"] = {\n                    \"username\": \"alice\",\n                    \"started\": 1,\n                    \"process\": FakeProcess(alive=True),\n                    \"dir\": tmp,\n                    \"url\": \"http://1\",\n                    \"last_access\": 0,  # Old enough to stop\n                }\n                _transcode_sessions[\"s2\"] = {\n                    \"username\": \"alice\",\n                    \"started\": 2,\n                    \"process\": FakeProcess(alive=True),\n                    \"dir\": \"/tmp/2\",\n                    \"url\": \"http://2\",\n                    \"last_access\": time.time(),\n                }\n\n            result = enforce_stream_limits(\"alice\", None, 2, 0)\n\n            assert result is None\n            assert \"s1\" not in _transcode_sessions\n            assert \"s2\" in _transcode_sessions\n\n    def test_source_limit_stops_user_session(self):\n        \"\"\"Source at limit stops user's oldest session on that source.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            with _transcode_lock:\n                _transcode_sessions[\"s1\"] = {\n                    \"username\": \"alice\",\n                    \"source_id\": \"src1\",\n                    \"started\": 1,\n                    \"process\": FakeProcess(alive=True),\n                    \"dir\": tmp,\n                    \"url\": \"http://1\",\n                    \"last_access\": 0,\n                }\n\n            result = enforce_stream_limits(\"alice\", \"src1\", 0, 1)\n\n            assert result is None\n            assert \"s1\" not in _transcode_sessions\n\n    def test_source_limit_returns_error_for_other_user(self):\n        \"\"\"Source at limit with other user's session returns error.\"\"\"\n        with _transcode_lock:\n            _transcode_sessions[\"s1\"] = {\n                \"username\": \"bob\",\n                \"source_id\": \"src1\",\n                \"started\": 1,\n                \"process\": FakeProcess(alive=True),\n                \"dir\": \"/tmp/1\",\n                \"url\": \"http://1\",\n            }\n\n        result = enforce_stream_limits(\"alice\", \"src1\", 0, 1)\n\n        assert result == \"Source at capacity (1 streams)\"\n        assert \"s1\" in _transcode_sessions  # Not stopped\n\n\n# =============================================================================\n# Session Query/Update Tests\n# =============================================================================\n\n\nclass TestGetSession:\n    \"\"\"Tests for get_session.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_get_existing_session(self):\n        \"\"\"Returns copy of session dict.\"\"\"\n        with _transcode_lock:\n            _transcode_sessions[\"test\"] = {\"dir\": \"/tmp\", \"url\": \"http://test\"}\n\n        session = get_session(\"test\")\n        assert session is not None\n        assert session[\"dir\"] == \"/tmp\"\n\n    def test_get_nonexistent_session(self):\n        \"\"\"Returns None for nonexistent session.\"\"\"\n        assert get_session(\"nonexistent\") is None\n\n\nclass TestTouchSession:\n    \"\"\"Tests for touch_session (heartbeat).\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_touch_updates_last_access(self):\n        \"\"\"Touch updates last_access timestamp.\"\"\"\n        old_time = time.time() - 100\n        with _transcode_lock:\n            _transcode_sessions[\"test\"] = {\"last_access\": old_time}\n\n        result = touch_session(\"test\")\n\n        assert result is True\n        assert _transcode_sessions[\"test\"][\"last_access\"] > old_time\n\n    def test_touch_nonexistent_returns_false(self):\n        \"\"\"Touch returns False for nonexistent session.\"\"\"\n        assert touch_session(\"nonexistent\") is False\n\n\nclass TestGetSessionProgress:\n    \"\"\"Tests for get_session_progress.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_progress_with_playlist(self):\n        \"\"\"Returns progress from playlist.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            playlist = pathlib.Path(tmp) / \"stream.m3u8\"\n            playlist.write_text(\"#EXTM3U\\n#EXTINF:3.0,\\nseg0.ts\\n#EXTINF:3.0,\\nseg1.ts\\n\")\n\n            with _transcode_lock:\n                _transcode_sessions[\"test\"] = {\"dir\": tmp, \"last_access\": 0}\n\n            progress = get_session_progress(\"test\")\n\n            assert progress is not None\n            assert progress[\"segment_count\"] == 2\n            assert progress[\"duration\"] == 6.0\n\n    def test_progress_no_playlist(self):\n        \"\"\"Returns zero progress without playlist.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            with _transcode_lock:\n                _transcode_sessions[\"test\"] = {\"dir\": tmp, \"last_access\": 0}\n\n            progress = get_session_progress(\"test\")\n\n            assert progress == {\"segment_count\": 0, \"duration\": 0.0}\n\n    def test_progress_nonexistent_session(self):\n        \"\"\"Returns None for nonexistent session.\"\"\"\n        assert get_session_progress(\"nonexistent\") is None\n\n\nclass TestClearUrlSession:\n    \"\"\"Tests for clear_url_session.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_clear_existing_url(self):\n        \"\"\"Clears existing URL mapping.\"\"\"\n        with _transcode_lock:\n            _url_to_session[\"http://test\"] = \"session-123\"\n\n        result = clear_url_session(\"http://test\")\n\n        assert result == \"session-123\"\n        assert \"http://test\" not in _url_to_session\n\n    def test_clear_nonexistent_url(self):\n        \"\"\"Returns None for nonexistent URL.\"\"\"\n        result = clear_url_session(\"http://nonexistent\")\n        assert result is None\n\n\n# =============================================================================\n# Playlist Helper Tests\n# =============================================================================\n\n\nclass TestCalcHlsDuration:\n    \"\"\"Tests for _calc_hls_duration.\"\"\"\n\n    def test_duration_from_playlist(self):\n        \"\"\"Calculates duration from EXTINF entries.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            playlist = pathlib.Path(tmp) / \"stream.m3u8\"\n            playlist.write_text(\"#EXTM3U\\n#EXTINF:3.5,\\nseg0.ts\\n#EXTINF:3.0,\\nseg1.ts\\n\")\n\n            duration = _calc_hls_duration(playlist, 2)\n\n            assert duration == 6.5\n\n    def test_duration_estimate_from_segments(self):\n        \"\"\"Estimates duration when playlist missing.\"\"\"\n        with patch(\"ffmpeg_session.get_hls_segment_duration\", return_value=3.0):\n            playlist = pathlib.Path(\"/nonexistent/stream.m3u8\")\n            duration = _calc_hls_duration(playlist, 5)\n\n            assert duration == 15.0\n\n\nclass TestBuildSubtitleTracks:\n    \"\"\"Tests for _build_subtitle_tracks.\"\"\"\n\n    def test_builds_track_list(self):\n        \"\"\"Builds subtitle track list.\"\"\"\n        sub_info = [\n            {\"index\": 2, \"lang\": \"eng\", \"name\": \"English\"},\n            {\"index\": 3, \"lang\": \"jpn\", \"name\": \"Japanese\"},\n        ]\n\n        tracks = _build_subtitle_tracks(\"session-123\", sub_info)\n\n        assert len(tracks) == 2\n        assert tracks[0][\"url\"] == \"/subs/session-123/sub0.vtt\"\n        assert tracks[0][\"lang\"] == \"eng\"\n        assert tracks[0][\"label\"] == \"English\"\n        assert tracks[0][\"default\"] is True\n        assert tracks[1][\"default\"] is False\n\n    def test_empty_sub_info(self):\n        \"\"\"Returns empty list for no subtitles.\"\"\"\n        assert _build_subtitle_tracks(\"s\", []) == []\n        assert _build_subtitle_tracks(\"s\", None) == []  # type: ignore[arg-type]\n\n    def test_non_dict_sub_info(self):\n        \"\"\"Returns empty list for old format (indices only).\"\"\"\n        assert _build_subtitle_tracks(\"s\", [2, 3]) == []  # type: ignore[arg-type]\n\n\nclass TestRegeneratePlaylist:\n    \"\"\"Tests for _regenerate_playlist.\"\"\"\n\n    def test_regenerates_playlist_from_segments(self):\n        \"\"\"Regenerates playlist from segment files.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output_dir = pathlib.Path(tmp)\n            # Create segment files\n            (output_dir / \"seg000.ts\").write_bytes(b\"x\" * 2000)\n            (output_dir / \"seg001.ts\").write_bytes(b\"x\" * 2000)\n            (output_dir / \"seg002.ts\").write_bytes(b\"x\" * 2000)\n\n            with patch(\"ffmpeg_session.get_hls_segment_duration\", return_value=3.0):\n                _regenerate_playlist(output_dir, start_segment=1)\n\n            playlist = output_dir / \"stream.m3u8\"\n            assert playlist.exists()\n            content = playlist.read_text()\n            assert \"#EXT-X-MEDIA-SEQUENCE:1\" in content\n            assert \"seg001.ts\" in content\n            assert \"seg002.ts\" in content\n            assert \"seg000.ts\" not in content  # Before start_segment\n\n    def test_regenerate_skips_small_segments(self):\n        \"\"\"Skips segments smaller than threshold.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output_dir = pathlib.Path(tmp)\n            (output_dir / \"seg000.ts\").write_bytes(b\"x\" * 500)  # Too small\n            (output_dir / \"seg001.ts\").write_bytes(b\"x\" * 2000)  # OK\n\n            with patch(\"ffmpeg_session.get_hls_segment_duration\", return_value=3.0):\n                _regenerate_playlist(output_dir, start_segment=0)\n\n            content = (output_dir / \"stream.m3u8\").read_text()\n            assert \"seg001.ts\" in content\n            assert \"seg000.ts\" not in content\n\n\n# =============================================================================\n# Session Recovery Tests\n# =============================================================================\n\n\nclass TestCleanupAndRecoverSessions:\n    \"\"\"Tests for cleanup_and_recover_sessions.\"\"\"\n\n    def setup_method(self):\n        _clear_session_state()\n\n    def teardown_method(self):\n        _clear_session_state()\n\n    def test_removes_orphaned_dirs(self):\n        \"\"\"Removes dirs without session.json.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            transcode_dir = pathlib.Path(tmp)\n            orphan = transcode_dir / \"netv_transcode_orphan\"\n            orphan.mkdir()\n            (orphan / \"seg000.ts\").write_bytes(b\"data\")\n\n            with (\n                patch(\"ffmpeg_session.get_transcode_dir\", return_value=transcode_dir),\n                patch(\"ffmpeg_session.get_vod_cache_timeout\", return_value=3600),\n            ):\n                cleanup_and_recover_sessions()\n\n            assert not orphan.exists()\n\n    def test_recovers_valid_vod_session(self):\n        \"\"\"Recovers valid VOD session with segments.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            transcode_dir = pathlib.Path(tmp)\n            vod_dir = transcode_dir / \"netv_transcode_vod123\"\n            vod_dir.mkdir()\n\n            # Create session.json\n            session_info = {\n                \"session_id\": \"vod123\",\n                \"url\": \"http://movie.mp4\",\n                \"is_vod\": True,\n                \"started\": time.time(),\n                \"duration\": 3600,\n            }\n            (vod_dir / \"session.json\").write_text(json.dumps(session_info))\n            (vod_dir / \"seg000.ts\").write_bytes(b\"x\" * 2000)\n\n            with (\n                patch(\"ffmpeg_session.get_transcode_dir\", return_value=transcode_dir),\n                patch(\"ffmpeg_session.get_vod_cache_timeout\", return_value=3600),\n            ):\n                cleanup_and_recover_sessions()\n\n            assert \"vod123\" in _transcode_sessions\n            assert _url_to_session.get(\"http://movie.mp4\") == \"vod123\"\n\n    def test_removes_expired_vod_session(self):\n        \"\"\"Removes expired VOD session (older than cache timeout).\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            transcode_dir = pathlib.Path(tmp)\n            vod_dir = transcode_dir / \"netv_transcode_expired\"\n            vod_dir.mkdir()\n\n            session_info = {\n                \"session_id\": \"expired\",\n                \"url\": \"http://old.mp4\",\n                \"is_vod\": True,\n            }\n            (vod_dir / \"session.json\").write_text(json.dumps(session_info))\n            (vod_dir / \"seg000.ts\").write_bytes(b\"x\" * 2000)\n\n            # Very short cache timeout\n            with (\n                patch(\"ffmpeg_session.get_transcode_dir\", return_value=transcode_dir),\n                patch(\"ffmpeg_session.get_vod_cache_timeout\", return_value=0),\n            ):\n                cleanup_and_recover_sessions()\n\n            assert not vod_dir.exists()\n            assert \"expired\" not in _transcode_sessions\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "m3u.py",
    "content": "\"\"\"M3U parsing, live/VOD/series data loading.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport logging\nimport re\nimport threading\nimport time\n\nfrom cache import (\n    LIVE_CACHE_TTL,\n    SERIES_CACHE_TTL,\n    VOD_CACHE_TTL,\n    get_cache,\n    get_cache_lock,\n    get_sources,\n    load_file_cache,\n    save_file_cache,\n    update_source_epg_url,\n)\nfrom util import safe_urlopen\nfrom xtream import XtreamClient\n\n\nlog = logging.getLogger(__name__)\n\n_refresh_in_progress: set[str] = set()\n_fetch_locks: dict[str, threading.Lock] = {\n    \"live\": threading.Lock(),\n    \"vod\": threading.Lock(),\n    \"series\": threading.Lock(),\n    \"epg\": threading.Lock(),\n}\n\n\ndef parse_m3u(content: str, source_id: str) -> tuple[list[dict], list[dict], str]:\n    \"\"\"Parse M3U content, return (categories, streams, epg_url).\"\"\"\n    categories: dict[str, dict] = {}\n    streams: list[dict] = []\n    stream_id_counter = 0\n    epg_url = \"\"\n\n    lines = content.strip().split(\"\\n\")\n    i = 0\n    while i < len(lines):\n        line = lines[i].strip()\n        if line.startswith(\"#EXTM3U\"):\n            match = re.search(r'(?:url-tvg|x-tvg-url)=\"([^\"]*)\"', line)\n            if match:\n                epg_url = match.group(1)\n        elif line.startswith(\"#EXTINF:\"):\n            attrs: dict[str, str] = {}\n            match = re.search(r\"#EXTINF:[^,]*,(.*)\", line)\n            name = match.group(1).strip() if match else \"Unknown\"\n\n            for attr_match in re.finditer(r'(\\w+[-\\w]*)=\"([^\"]*)\"', line):\n                attrs[attr_match.group(1)] = attr_match.group(2)\n\n            i += 1\n            while i < len(lines) and (not lines[i].strip() or lines[i].startswith(\"#\")):\n                i += 1\n            url = lines[i].strip() if i < len(lines) else \"\"\n\n            group = attrs.get(\"group-title\", \"Uncategorized\")\n            if group not in categories:\n                cat_slug = re.sub(r\"[^a-zA-Z0-9]+\", \"_\", group).strip(\"_\").lower()\n                cat_id = f\"{source_id}_{cat_slug}\"\n                categories[group] = {\n                    \"category_id\": cat_id,\n                    \"category_name\": group,\n                    \"parent_id\": 0,\n                    \"source_id\": source_id,\n                }\n\n            stream_id_counter += 1\n            streams.append(\n                {\n                    \"stream_id\": f\"{source_id}_{stream_id_counter}\",\n                    \"name\": name,\n                    \"stream_icon\": attrs.get(\"tvg-logo\", \"\"),\n                    \"epg_channel_id\": attrs.get(\"tvg-id\", \"\"),\n                    \"category_ids\": [categories[group][\"category_id\"]],\n                    \"direct_url\": url,\n                    \"source_id\": source_id,\n                }\n            )\n        i += 1\n\n    streams_with_epg = sum(1 for s in streams if s.get(\"epg_channel_id\"))\n    log.debug(\n        \"M3U parsed: %d streams (%d with tvg-id, %d without), %d categories\",\n        len(streams),\n        streams_with_epg,\n        len(streams) - streams_with_epg,\n        len(categories),\n    )\n    return list(categories.values()), streams, epg_url\n\n\ndef fetch_m3u(url: str, source_id: str, timeout: int = 30) -> tuple[list[dict], list[dict], str]:\n    \"\"\"Fetch and parse M3U from URL, return (categories, streams, epg_url).\"\"\"\n    with safe_urlopen(url, timeout=timeout) as resp:\n        content = resp.read().decode(\"utf-8\")\n    return parse_m3u(content, source_id)\n\n\ndef _fetch_all_live_data() -> tuple[list[dict], list[dict], list[tuple[str, int, str]]]:\n    \"\"\"Fetch live categories/streams from all sources.\"\"\"\n    all_categories: list[dict] = []\n    all_streams: list[dict] = []\n    epg_urls: list[tuple[str, int, str]] = []\n\n    for source in get_sources():\n        try:\n            if source.type == \"xtream\":\n                client = XtreamClient(source.url, source.username, source.password)\n                cats = client.get_live_categories()\n                streams = client.get_live_streams()\n                for c in cats:\n                    c[\"source_id\"] = source.id\n                    c[\"category_id\"] = f\"{source.id}_{c['category_id']}\"\n                for s in streams:\n                    s[\"source_id\"] = source.id\n                    s[\"source_type\"] = \"xtream\"\n                    s[\"source_url\"] = source.url\n                    s[\"source_username\"] = source.username\n                    s[\"source_password\"] = source.password\n                    orig_cats = s.get(\"category_ids\") or [s.get(\"category_id\")]\n                    s[\"category_ids\"] = [f\"{source.id}_{c}\" for c in orig_cats if c]\n                all_categories.extend(cats)\n                all_streams.extend(streams)\n                if source.epg_enabled:\n                    epg_urls.append((client.epg_url, source.epg_timeout, source.id))\n            elif source.type == \"m3u\":\n                cats, streams, epg_url = fetch_m3u(source.url, source.id)\n                all_categories.extend(cats)\n                all_streams.extend(streams)\n                if epg_url and source.epg_enabled:\n                    epg_urls.append((epg_url, source.epg_timeout, source.id))\n            elif source.type == \"epg\":\n                if source.epg_enabled:\n                    epg_urls.append((source.url, source.epg_timeout, source.id))\n        except Exception as e:\n            log.error(\"Error loading source %s: %s\", source.name, e)\n\n    return all_categories, all_streams, epg_urls\n\n\ndef fetch_source_live_data(source: Any) -> tuple[list[dict], list[dict], str | None, int]:\n    \"\"\"Fetch live data for a single source. Returns (cats, streams, epg_url, epg_timeout).\"\"\"\n    cats: list[dict] = []\n    streams: list[dict] = []\n    epg_url: str | None = None\n\n    if source.type == \"xtream\":\n        client = XtreamClient(source.url, source.username, source.password)\n        cats = client.get_live_categories()\n        streams = client.get_live_streams()\n        for c in cats:\n            c[\"source_id\"] = source.id\n            c[\"category_id\"] = f\"{source.id}_{c['category_id']}\"\n        for s in streams:\n            s[\"source_id\"] = source.id\n            s[\"source_type\"] = \"xtream\"\n            s[\"source_url\"] = source.url\n            s[\"source_username\"] = source.username\n            s[\"source_password\"] = source.password\n            orig_cats = s.get(\"category_ids\") or [s.get(\"category_id\")]\n            s[\"category_ids\"] = [f\"{source.id}_{c}\" for c in orig_cats if c]\n        detected_epg = client.epg_url\n        update_source_epg_url(source.id, detected_epg)\n        epg_url = detected_epg if source.epg_enabled else None\n    elif source.type == \"m3u\":\n        cats, streams, detected_epg = fetch_m3u(source.url, source.id)\n        update_source_epg_url(source.id, detected_epg)\n        epg_url = detected_epg if source.epg_enabled else None\n    elif source.type == \"epg\":\n        epg_url = source.url\n\n    return cats, streams, epg_url, source.epg_timeout\n\n\ndef fetch_source_vod_data(source: Any) -> tuple[list[dict], list[dict]]:\n    \"\"\"Fetch VOD data for a single Xtream source.\"\"\"\n    if source.type != \"xtream\":\n        return [], []\n    client = XtreamClient(source.url, source.username, source.password)\n    cats = client.get_vod_categories()\n    streams = client.get_vod_streams()\n    # Tag with source_id for playback\n    for c in cats:\n        c[\"source_id\"] = source.id\n    for s in streams:\n        s[\"source_id\"] = source.id\n    return cats, streams\n\n\ndef parse_epg_urls(raw: list) -> list[tuple[str, int, str]]:\n    \"\"\"Convert JSON list back to tuples (JSON stores tuples as lists).\"\"\"\n    return [(u[0], u[1], u[2]) for u in raw if isinstance(u, (list, tuple)) and len(u) >= 3]\n\n\ndef load_all_live_data() -> tuple[list[dict], list[dict], list[tuple[str, int, str]]]:\n    \"\"\"Load live data with file cache and stale-while-revalidate.\"\"\"\n    _cache = get_cache()\n    _cache_lock = get_cache_lock()\n    cached = load_file_cache(\"live_data\")\n    now = time.time()\n\n    if cached:\n        data, ts = cached\n        cats, streams = data[\"cats\"], data[\"streams\"]\n        epg_urls = parse_epg_urls(data.get(\"epg_urls\", []))\n        age = now - ts\n\n        if age > LIVE_CACHE_TTL and \"live\" not in _refresh_in_progress:\n            _refresh_in_progress.add(\"live\")\n\n            def refresh() -> None:\n                try:\n                    log.info(\"Refreshing live data in background\")\n                    new_cats, new_streams, new_epg_urls = _fetch_all_live_data()\n                    save_file_cache(\n                        \"live_data\",\n                        {\"cats\": new_cats, \"streams\": new_streams, \"epg_urls\": new_epg_urls},\n                    )\n                    with _cache_lock:\n                        _cache.pop(\"live_categories\", None)\n                        _cache.pop(\"live_streams\", None)\n                        _cache[\"epg_urls\"] = new_epg_urls\n                    log.info(\"Live data refreshed\")\n                finally:\n                    _refresh_in_progress.discard(\"live\")\n\n            threading.Thread(target=refresh, daemon=True).start()\n\n        return cats, streams, epg_urls\n\n    with _fetch_locks[\"live\"]:\n        cached = load_file_cache(\"live_data\")\n        if cached:\n            data, _ = cached\n            return data[\"cats\"], data[\"streams\"], parse_epg_urls(data.get(\"epg_urls\", []))\n        log.info(\"No live cache, fetching\")\n        cats, streams, epg_urls = _fetch_all_live_data()\n        save_file_cache(\"live_data\", {\"cats\": cats, \"streams\": streams, \"epg_urls\": epg_urls})\n        return cats, streams, epg_urls\n\n\ndef _fetch_vod_data() -> tuple[list[dict], list[dict]]:\n    \"\"\"Fetch VOD categories and streams from all Xtream sources.\"\"\"\n    all_cats: list[dict] = []\n    all_streams: list[dict] = []\n    for source in get_sources():\n        if source.type != \"xtream\":\n            continue\n        try:\n            client = XtreamClient(source.url, source.username, source.password)\n            cats = client.get_vod_categories()\n            streams = client.get_vod_streams()\n            # Tag with source_id for playback and access control\n            for c in cats:\n                c[\"source_id\"] = source.id\n            for s in streams:\n                s[\"source_id\"] = source.id\n            all_cats.extend(cats)\n            all_streams.extend(streams)\n        except Exception as e:\n            log.warning(\"Failed to fetch VOD from source %s: %s\", source.id, e)\n    return all_cats, all_streams\n\n\ndef load_vod_data() -> tuple[list[dict], list[dict]]:\n    \"\"\"Load VOD data with file cache and stale-while-revalidate.\"\"\"\n    _cache = get_cache()\n    _cache_lock = get_cache_lock()\n    cached = load_file_cache(\"vod_data\")\n    now = time.time()\n\n    if cached:\n        data, ts = cached\n        cats, streams = data[\"cats\"], data[\"streams\"]\n        age = now - ts\n\n        if age > VOD_CACHE_TTL and \"vod\" not in _refresh_in_progress:\n            _refresh_in_progress.add(\"vod\")\n\n            def refresh() -> None:\n                try:\n                    log.info(\"Refreshing VOD data in background\")\n                    new_cats, new_streams = _fetch_vod_data()\n                    save_file_cache(\"vod_data\", {\"cats\": new_cats, \"streams\": new_streams})\n                    with _cache_lock:\n                        _cache.pop(\"vod_categories\", None)\n                        _cache.pop(\"vod_streams\", None)\n                    log.info(\"VOD data refreshed\")\n                finally:\n                    _refresh_in_progress.discard(\"vod\")\n\n            threading.Thread(target=refresh, daemon=True).start()\n\n        return cats, streams\n\n    with _fetch_locks[\"vod\"]:\n        cached = load_file_cache(\"vod_data\")\n        if cached:\n            data, _ = cached\n            return data[\"cats\"], data[\"streams\"]\n        log.info(\"No VOD cache, fetching\")\n        cats, streams = _fetch_vod_data()\n        if cats or streams:\n            save_file_cache(\"vod_data\", {\"cats\": cats, \"streams\": streams})\n        return cats, streams\n\n\ndef _fetch_series_data() -> tuple[list[dict], list[dict]]:\n    \"\"\"Fetch series categories and list from all Xtream sources.\"\"\"\n    all_cats: list[dict] = []\n    all_series: list[dict] = []\n    for source in get_sources():\n        if source.type != \"xtream\":\n            continue\n        try:\n            client = XtreamClient(source.url, source.username, source.password)\n            cats = client.get_series_categories()\n            series = client.get_series()\n            # Tag with source_id for playback and access control\n            for c in cats:\n                c[\"source_id\"] = source.id\n            for s in series:\n                s[\"source_id\"] = source.id\n            all_cats.extend(cats)\n            all_series.extend(series)\n        except Exception as e:\n            log.warning(\"Failed to fetch series from source %s: %s\", source.id, e)\n    return all_cats, all_series\n\n\ndef load_series_data() -> tuple[list[dict], list[dict]]:\n    \"\"\"Load series data with file cache and stale-while-revalidate.\"\"\"\n    _cache = get_cache()\n    _cache_lock = get_cache_lock()\n    cached = load_file_cache(\"series_data\")\n    now = time.time()\n\n    if cached:\n        data, ts = cached\n        cats, series = data[\"cats\"], data[\"series\"]\n        age = now - ts\n\n        if age > SERIES_CACHE_TTL and \"series\" not in _refresh_in_progress:\n            _refresh_in_progress.add(\"series\")\n\n            def refresh() -> None:\n                try:\n                    log.info(\"Refreshing series data in background\")\n                    new_cats, new_series = _fetch_series_data()\n                    save_file_cache(\"series_data\", {\"cats\": new_cats, \"series\": new_series})\n                    with _cache_lock:\n                        _cache.pop(\"series_categories\", None)\n                        _cache.pop(\"series\", None)\n                    log.info(\"Series data refreshed\")\n                finally:\n                    _refresh_in_progress.discard(\"series\")\n\n            threading.Thread(target=refresh, daemon=True).start()\n\n        return cats, series\n\n    with _fetch_locks[\"series\"]:\n        cached = load_file_cache(\"series_data\")\n        if cached:\n            data, _ = cached\n            return data[\"cats\"], data[\"series\"]\n        log.info(\"No series cache, fetching\")\n        cats, series = _fetch_series_data()\n        if cats or series:\n            save_file_cache(\"series_data\", {\"cats\": cats, \"series\": series})\n        return cats, series\n\n\ndef get_first_xtream_client() -> XtreamClient | None:\n    \"\"\"Get the first available Xtream client (for VOD/series).\"\"\"\n    for source in get_sources():\n        if source.type == \"xtream\":\n            return XtreamClient(source.url, source.username, source.password)\n    return None\n\n\ndef get_xtream_client_by_source(source_id: str) -> XtreamClient | None:\n    \"\"\"Get Xtream client for a specific source ID.\"\"\"\n    for source in get_sources():\n        if source.id == source_id and source.type == \"xtream\":\n            return XtreamClient(source.url, source.username, source.password)\n    return None\n\n\ndef get_first_xtream_source_and_client() -> tuple[str, XtreamClient] | tuple[None, None]:\n    \"\"\"Get the first available Xtream source ID and client.\"\"\"\n    for source in get_sources():\n        if source.type == \"xtream\":\n            return source.id, XtreamClient(source.url, source.username, source.password)\n    return None, None\n\n\ndef get_fetch_lock(name: str) -> threading.Lock:\n    \"\"\"Get fetch lock by name.\"\"\"\n    return _fetch_locks[name]\n\n\ndef get_refresh_in_progress() -> set[str]:\n    \"\"\"Get refresh in progress set.\"\"\"\n    return _refresh_in_progress\n"
  },
  {
    "path": "m3u_test.py",
    "content": "\"\"\"Tests for m3u.py.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\n\n@pytest.fixture\ndef m3u_module(tmp_path: Path):\n    \"\"\"Import m3u module with mocked cache.\"\"\"\n    import cache\n\n    cache.SERVER_SETTINGS_FILE = tmp_path / \"server_settings.json\"\n    cache.USERS_DIR = tmp_path / \"users\"\n    cache.USERS_DIR.mkdir(exist_ok=True)\n    cache.CACHE_DIR = tmp_path / \"cache\"\n    cache.CACHE_DIR.mkdir(exist_ok=True)\n    cache.get_cache().clear()\n\n    import m3u\n\n    yield m3u\n\n    cache.get_cache().clear()\n\n\nclass TestParseM3u:\n    def test_parse_basic_m3u(self, m3u_module):\n        content = \"\"\"#EXTM3U\n#EXTINF:-1 tvg-id=\"ch1\" tvg-logo=\"http://logo.png\" group-title=\"News\",Channel One\nhttp://stream.example.com/ch1.m3u8\n#EXTINF:-1 tvg-id=\"ch2\" group-title=\"Sports\",Channel Two\nhttp://stream.example.com/ch2.m3u8\n\"\"\"\n        cats, streams, _ = m3u_module.parse_m3u(content, \"src1\")\n\n        assert len(cats) == 2\n        assert any(c[\"category_name\"] == \"News\" for c in cats)\n        assert any(c[\"category_name\"] == \"Sports\" for c in cats)\n\n        assert len(streams) == 2\n        assert streams[0][\"name\"] == \"Channel One\"\n        assert streams[0][\"epg_channel_id\"] == \"ch1\"\n        assert streams[0][\"stream_icon\"] == \"http://logo.png\"\n        assert streams[0][\"direct_url\"] == \"http://stream.example.com/ch1.m3u8\"\n        assert streams[0][\"source_id\"] == \"src1\"\n\n    def test_parse_m3u_with_epg_url(self, m3u_module):\n        content = \"\"\"#EXTM3U url-tvg=\"http://epg.example.com/guide.xml\"\n#EXTINF:-1,Test Channel\nhttp://test.stream\n\"\"\"\n        _, _, epg_url = m3u_module.parse_m3u(content, \"src1\")\n        assert epg_url == \"http://epg.example.com/guide.xml\"\n\n    def test_parse_m3u_x_tvg_url(self, m3u_module):\n        content = \"\"\"#EXTM3U x-tvg-url=\"http://alt.epg.com/guide.xml\"\n#EXTINF:-1,Test Channel\nhttp://test.stream\n\"\"\"\n        _, _, epg_url = m3u_module.parse_m3u(content, \"src1\")\n        assert epg_url == \"http://alt.epg.com/guide.xml\"\n\n    def test_parse_m3u_uncategorized(self, m3u_module):\n        content = \"\"\"#EXTM3U\n#EXTINF:-1,No Group Channel\nhttp://stream.example.com/nogroupch.m3u8\n\"\"\"\n        cats, streams, _ = m3u_module.parse_m3u(content, \"src1\")\n        assert len(cats) == 1\n        assert cats[0][\"category_name\"] == \"Uncategorized\"\n        assert streams[0][\"category_ids\"][0].endswith(\"_uncategorized\")\n\n    def test_parse_m3u_category_ids_prefixed(self, m3u_module):\n        content = \"\"\"#EXTM3U\n#EXTINF:-1 group-title=\"Movies\",Test\nhttp://test\n\"\"\"\n        cats, streams, _ = m3u_module.parse_m3u(content, \"mysource\")\n        assert cats[0][\"category_id\"].startswith(\"mysource_\")\n        assert streams[0][\"category_ids\"][0].startswith(\"mysource_\")\n\n    def test_parse_m3u_empty(self, m3u_module):\n        cats, streams, epg_url = m3u_module.parse_m3u(\"\", \"src1\")\n        assert cats == []\n        assert streams == []\n        assert epg_url == \"\"\n\n\nclass TestParseEpgUrls:\n    def test_parse_tuple_list(self, m3u_module):\n        raw = [[\"http://epg1.com\", 120, \"src1\"], [\"http://epg2.com\", 60, \"src2\"]]\n        result = m3u_module.parse_epg_urls(raw)\n        assert len(result) == 2\n        assert result[0] == (\"http://epg1.com\", 120, \"src1\")\n        assert result[1] == (\"http://epg2.com\", 60, \"src2\")\n\n    def test_parse_tuple_passthrough(self, m3u_module):\n        raw = [(\"http://epg.com\", 100, \"s1\")]\n        result = m3u_module.parse_epg_urls(raw)\n        assert result[0] == (\"http://epg.com\", 100, \"s1\")\n\n    def test_parse_empty(self, m3u_module):\n        assert m3u_module.parse_epg_urls([]) == []\n\n    def test_parse_skips_malformed(self, m3u_module):\n        raw = [[\"http://epg.com\", 90], \"plain_string\", [\"http://valid.com\", 60, \"src\"]]\n        result = m3u_module.parse_epg_urls(raw)\n        assert len(result) == 1\n        assert result[0] == (\"http://valid.com\", 60, \"src\")\n\n\nclass TestFetchLocks:\n    def test_get_fetch_lock(self, m3u_module):\n        lock = m3u_module.get_fetch_lock(\"live\")\n        assert lock is not None\n\n    def test_get_refresh_in_progress(self, m3u_module):\n        rip = m3u_module.get_refresh_in_progress()\n        assert isinstance(rip, set)\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "main.py",
    "content": "#!/usr/bin/env python3\n# /// script\n# requires-python = \">=3.11\"\n# dependencies = [\"fastapi\", \"uvicorn[standard]\", \"jinja2\", \"python-multipart\", \"cryptography\", \"defusedxml\"]\n# ///\n\"\"\"IPTV Web App.\n\nUsage:\n    ./main.py [--port PORT] [--https] [--cert FILE --key FILE]\n\nOptions:\n    --port PORT     Port to listen on (default: 8000)\n    --https         Enable HTTPS using Let's Encrypt certs (auto-detect domain)\n    --cert FILE     SSL certificate file (overrides --https)\n    --key FILE      SSL private key file (overrides --https)\n\nExamples:\n    ./main.py                              # HTTP on port 8000\n    ./main.py --https                      # HTTPS with auto-detected Let's Encrypt certs\n    ./main.py --cert c.pem --key k.pem     # HTTPS with custom certs\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import AsyncIterator\nfrom contextlib import asynccontextmanager\nfrom dataclasses import dataclass\nfrom datetime import UTC, datetime, timedelta\nfrom typing import Annotated, Any\nfrom xml.sax.saxutils import escape as xml_escape\n\nimport asyncio\nimport concurrent.futures\nimport contextlib\nimport json\nimport logging\nimport os\nimport pathlib\nimport re\nimport signal\nimport subprocess\nimport threading\nimport time\nimport urllib.error\nimport urllib.parse\n\nfrom fastapi import Depends, FastAPI, Form, HTTPException, Query, Request\nfrom fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom fastapi.templating import Jinja2Templates\nfrom starlette.responses import StreamingResponse\n\nfrom auth import create_token, verify_password, verify_token\nfrom cache import (\n    AVAILABLE_ENCODERS,\n    CACHE_DIR,\n    LOGO_BROWSER_TTL,\n    LOGO_MAX_SIZE,\n    Source,\n    clear_all_caches,\n    clear_all_file_caches,\n    get_cache,\n    get_cache_lock,\n    get_cached_info,\n    get_cached_logo,\n    get_sources,\n    get_watch_position,\n    load_file_cache,\n    load_server_settings,\n    load_user_settings,\n    refresh_encoders,\n    save_file_cache,\n    save_logo,\n    save_server_settings,\n    save_user_settings,\n    save_watch_position,\n    update_source_epg_url,\n)\nfrom epg import fetch_epg\nfrom m3u import (\n    fetch_m3u,\n    fetch_source_live_data,\n    fetch_source_vod_data,\n    get_first_xtream_client,\n    get_refresh_in_progress,\n    get_xtream_client_by_source,\n    load_all_live_data,\n    load_series_data,\n    load_vod_data,\n    parse_epg_urls,\n)\nfrom xtream import XtreamClient\n\nimport auth\nimport epg\nimport ffmpeg_command\nimport ffmpeg_session\n\n\nlog = logging.getLogger()\n\n# SSE subscribers for EPG ready notifications (limit to prevent DoS)\n_epg_subscribers: set[asyncio.Queue[str]] = set()\n_shutdown_event: asyncio.Event | None = None  # Set during shutdown to close SSE\n_MAX_SSE_SUBSCRIBERS = 100\n\n# Login rate limiting: track failed attempts per IP\n_login_attempts: dict[str, list[float]] = {}\n_LOGIN_WINDOW = 300  # 5 minutes\n_LOGIN_MAX_ATTEMPTS = 10\n\n# Category filter limits\n_MAX_FILTER_CATEGORIES = 10000\n\n\n# =============================================================================\n# App Setup\n# =============================================================================\n\nAPP_DIR = pathlib.Path(__file__).parent\nTEMPLATES = Jinja2Templates(directory=APP_DIR / \"templates\")\nTEMPLATES.env.auto_reload = True\n\n# Super-resolution engine directory (TensorRT engines for different resolutions)\nSR_ENGINE_DIR = pathlib.Path(\n    os.environ.get(\"SR_ENGINE_DIR\", pathlib.Path.home() / \"ffmpeg_build/models\")\n)\n\n\ndef get_sr_models() -> list[str]:\n    \"\"\"Get available AI Upscale models (unique model names from engine files).\"\"\"\n    if not SR_ENGINE_DIR.exists():\n        return []\n    # Engine files are named: {model}_{height}p_fp16.engine\n    # e.g., 4x-compact_1080p_fp16.engine, 2x-liveaction-span_720p_fp16.engine\n    models = set()\n    for engine in SR_ENGINE_DIR.glob(\"*_*p_fp16.engine\"):\n        # Extract model name by removing _{height}p_fp16.engine suffix\n        name = engine.stem  # e.g., \"2x-liveaction-span_1080p_fp16\"\n        # Remove _fp16 and _{height}p\n        parts = name.rsplit(\"_\", 2)  # [\"2x-liveaction-span\", \"1080p\", \"fp16\"]\n        if len(parts) >= 3:\n            models.add(parts[0])\n\n    # Sort with 4x-compact first (recommended), then alphabetically\n    def sort_key(m: str) -> tuple[int, str]:\n        if m == \"4x-compact\":\n            return (0, m)\n        return (1, m)\n\n    return sorted(models, key=sort_key)\n\n\ndef is_sr_available() -> bool:\n    \"\"\"Check if AI Upscale is available (at least one TensorRT engine exists).\"\"\"\n    return len(get_sr_models()) > 0\n\n\ndef _logo_url_filter(url: str) -> str:\n    \"\"\"Wrap external logo URLs through /api/logo proxy.\"\"\"\n    if not url or url.startswith(\"/\") or url.startswith(\"data:\"):\n        return url  # Already local or data URL\n    # Use hostname as source for organization\n    parsed = urllib.parse.urlparse(url)\n    source = parsed.netloc.split(\":\")[0] if parsed.netloc else \"external\"\n    return f\"/api/logo?source={urllib.parse.quote(source)}&url={urllib.parse.quote(url)}\"\n\n\nTEMPLATES.env.filters[\"logo_url\"] = _logo_url_filter\n\n\ndef _safe_float(value: float | str | None, default: float = 0.0) -> float:\n    \"\"\"Safely convert value to float, returning default on failure.\"\"\"\n    if value is None:\n        return default\n    try:\n        return float(value)\n    except (ValueError, TypeError):\n        return default\n\n\n# Thread locks for fetch operations\n_fetch_locks: dict[str, threading.Lock] = {\n    \"live\": threading.Lock(),\n    \"vod\": threading.Lock(),\n    \"series\": threading.Lock(),\n    \"epg\": threading.Lock(),\n}\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI) -> AsyncIterator[None]:\n    \"\"\"Clean up orphaned transcodes and preload data on startup.\"\"\"\n    # Initialize EPG database\n    epg.init(CACHE_DIR)\n\n    # Prune expired EPG data (keep 24h buffer for \"what was just on\")\n    cutoff = datetime.now(UTC) - timedelta(hours=24)\n    pruned = epg.prune_old_programs(cutoff)\n    if pruned:\n        log.info(\"Pruned %d expired EPG programs\", pruned)\n\n    # Initialize transcoding module with settings callback\n    ffmpeg_command.init(\n        load_server_settings,\n        sr_engine_dir=str(SR_ENGINE_DIR) if is_sr_available() else \"\",\n    )\n\n    # Kill orphaned ffmpeg processes\n    try:\n        result = subprocess.run(\n            [\"pgrep\", \"-f\", \"ffmpeg.*iptv_transcode\"],\n            check=False,\n            capture_output=True,\n            text=True,\n        )\n        for pid in result.stdout.strip().split(\"\\n\"):\n            if pid:\n                try:\n                    os.kill(int(pid), signal.SIGKILL)\n                    log.info(\"Killed orphaned ffmpeg pid %s\", pid)\n                except (ProcessLookupError, ValueError):\n                    pass\n    except Exception:\n        pass\n    # Clean up orphaned dirs and recover valid VOD sessions\n    ffmpeg_session.cleanup_and_recover_sessions()\n\n    # Preload all data in background threads (parallel)\n    def load_live():\n        get_refresh_in_progress().add(\"guide_load\")\n        try:\n            log.info(\"Preloading live data\")\n            cats, streams, epg_urls = load_all_live_data()\n            with get_cache_lock():\n                get_cache()[\"live_categories\"] = cats\n                get_cache()[\"live_streams\"] = streams\n                get_cache()[\"epg_urls\"] = epg_urls\n            log.info(\"Live data loaded\")\n        finally:\n            get_refresh_in_progress().discard(\"guide_load\")\n\n    def load_epg_data():\n        try:\n            epg_urls = get_cache().get(\"epg_urls\", [])\n            if epg_urls:\n                load_all_epg(epg_urls)\n                log.info(\"EPG data loaded: %d programs\", epg.get_program_count())\n                # Notify SSE subscribers\n                for q in list(_epg_subscribers):\n                    with contextlib.suppress(Exception):\n                        q.put_nowait(\"epg_ready\")\n        except Exception as e:\n            log.error(\"EPG load error: %s\", e)\n\n    def load_vod():\n        vod_cats, vod_streams = load_vod_data()\n        with get_cache_lock():\n            get_cache()[\"vod_categories\"] = vod_cats\n            get_cache()[\"vod_streams\"] = vod_streams\n        log.info(\"VOD data loaded\")\n\n    def load_series():\n        series_cats, series_list = load_series_data()\n        with get_cache_lock():\n            get_cache()[\"series_categories\"] = series_cats\n            get_cache()[\"series\"] = series_list\n        log.info(\"Series data loaded\")\n\n    # Start all preloads in parallel (EPG waits for live data internally)\n    def load_all():\n        load_live()\n        # EPG needs epg_urls from live data, so run after\n        load_epg_data()\n\n    threading.Thread(target=load_all, daemon=True).start()\n    threading.Thread(target=load_vod, daemon=True).start()\n    threading.Thread(target=load_series, daemon=True).start()\n    log.info(\"Preload started: live+EPG, VOD, series loading in parallel\")\n\n    # Periodic cleanup of expired sessions (VOD and live)\n    cleanup_stop = threading.Event()\n\n    def cleanup_loop():\n        while not cleanup_stop.wait(60):  # Check every minute\n            ffmpeg_session.cleanup_expired_sessions()\n\n    cleanup_thread = threading.Thread(target=cleanup_loop, daemon=True)\n    cleanup_thread.start()\n\n    # EPG scheduler\n    scheduler_stop = threading.Event()\n    _last_triggered: dict[str, str] = {}  # source_id -> last triggered time\n\n    def scheduler_loop():\n        while not scheduler_stop.wait(30):  # Check every 30 seconds\n            now = datetime.now()\n            current_time = now.strftime(\"%H:%M\")\n            for source in get_sources():\n                if current_time in source.epg_schedule:\n                    key = f\"{source.id}_epg\"\n                    # Only trigger once per scheduled time\n                    if (\n                        _last_triggered.get(source.id) != current_time\n                        and key not in get_refresh_in_progress()\n                    ):\n                        log.info(\"Scheduled EPG refresh for %s at %s\", source.name, current_time)\n                        _last_triggered[source.id] = current_time\n                        get_refresh_in_progress().add(key)\n\n                        def do_refresh(src: Source = source, k: str = key):\n                            try:\n                                epg_url = None\n                                if src.type == \"xtream\":\n                                    client = XtreamClient(src.url, src.username, src.password)\n                                    epg_url = client.epg_url\n                                elif src.type == \"m3u\":\n                                    _, _, epg_url = fetch_m3u(src.url, src.id)\n                                elif src.type == \"epg\":\n                                    epg_url = src.url\n                                if epg_url:\n                                    _fetch_all_epg([(epg_url, src.epg_timeout, src.id)])\n                                    log.info(\"Scheduled EPG refresh complete for %s\", src.name)\n                            except Exception as e:\n                                log.error(\"Scheduled EPG refresh failed for %s: %s\", src.name, e)\n                            finally:\n                                get_refresh_in_progress().discard(k)\n\n                        threading.Thread(target=do_refresh, daemon=True).start()\n\n    scheduler_thread = threading.Thread(target=scheduler_loop, daemon=True)\n    scheduler_thread.start()\n\n    yield\n\n    # Shutdown - signal SSE connections to close\n    global _shutdown_event\n    _shutdown_event = asyncio.Event()\n    _shutdown_event.set()\n    cleanup_stop.set()\n    scheduler_stop.set()\n    ffmpeg_session.shutdown()\n\n\napp = FastAPI(title=\"neTV\", lifespan=lifespan)\napp.mount(\"/static\", StaticFiles(directory=APP_DIR / \"static\"), name=\"static\")\n\n\nclass AuthRequired(Exception):\n    \"\"\"Raised when authentication is required.\"\"\"\n\n\n@app.exception_handler(AuthRequired)\nasync def auth_required_handler(request: Request, _exc: AuthRequired):\n    return RedirectResponse(\"/login\", status_code=303)\n\n\n@app.exception_handler(HTTPException)\nasync def http_exception_handler(request: Request, exc: HTTPException):\n    \"\"\"Show nice HTML error pages for HTTP errors.\"\"\"\n    # Only handle HTML requests, let API requests get JSON\n    accept = request.headers.get(\"accept\", \"\")\n    if \"text/html\" not in accept:\n        return JSONResponse({\"detail\": exc.detail}, status_code=exc.status_code)\n\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"error.html\",\n        {\"title\": f\"Error {exc.status_code}\", \"message\": exc.detail},\n        status_code=exc.status_code,\n    )\n\n\ndef get_current_user(request: Request) -> dict | None:\n    token = request.cookies.get(\"token\")\n    if not token:\n        return None\n    return verify_token(token)\n\n\ndef require_auth(request: Request) -> dict:\n    user = get_current_user(request)\n    if not user:\n        raise AuthRequired\n    return user\n\n\ndef require_admin(request: Request) -> dict:\n    user = require_auth(request)\n    username = user.get(\"sub\", \"\")\n    if not auth.is_admin(username):\n        raise HTTPException(403, \"Admin access required\")\n    return user\n\n\n# =============================================================================\n# Auth Routes\n# =============================================================================\n\n\n@app.get(\"/setup\", response_class=HTMLResponse)\nasync def setup_page(request: Request):\n    \"\"\"Initial setup page - create first admin user.\"\"\"\n    if not auth.is_setup_required():\n        return RedirectResponse(\"/login\", status_code=303)\n    return TEMPLATES.TemplateResponse(request, \"setup.html\", {\"error\": None})\n\n\n@app.post(\"/setup\")\nasync def setup_create_user(\n    request: Request,\n    username: Annotated[str, Form()],\n    password: Annotated[str, Form()],\n    confirm: Annotated[str, Form()],\n):\n    \"\"\"Create the initial admin user.\"\"\"\n    if not auth.is_setup_required():\n        return RedirectResponse(\"/login\", status_code=303)\n    # Validate\n    if len(username) < 3:\n        return TEMPLATES.TemplateResponse(\n            request, \"setup.html\", {\"error\": \"Username must be at least 3 characters\"}\n        )\n    if len(password) < 8:\n        return TEMPLATES.TemplateResponse(\n            request, \"setup.html\", {\"error\": \"Password must be at least 8 characters\"}\n        )\n    if password != confirm:\n        return TEMPLATES.TemplateResponse(\n            request, \"setup.html\", {\"error\": \"Passwords do not match\"}\n        )\n    auth.create_user(username, password)\n    return RedirectResponse(\"/login\", status_code=303)\n\n\n@app.get(\"/login\", response_class=HTMLResponse)\nasync def login_page(request: Request, error: str | None = None):\n    \"\"\"Login page - redirects to setup if no users exist.\"\"\"\n    if auth.is_setup_required():\n        return RedirectResponse(\"/setup\", status_code=303)\n    last_user = request.cookies.get(\"last_user\", \"\")\n    return TEMPLATES.TemplateResponse(\n        request, \"login.html\", {\"error\": error, \"last_user\": last_user}\n    )\n\n\ndef _check_rate_limit(ip: str) -> None:\n    \"\"\"Check login rate limit. Raises HTTPException if exceeded.\"\"\"\n    now = time.time()\n    attempts = _login_attempts.get(ip, [])\n    # Clean old attempts for this IP\n    attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]\n    if attempts:\n        _login_attempts[ip] = attempts\n    elif ip in _login_attempts:\n        del _login_attempts[ip]\n    # Periodically clean stale IPs (when dict is large)\n    if len(_login_attempts) > 1000:\n        stale = [k for k, v in _login_attempts.items() if not v or now - max(v) > _LOGIN_WINDOW]\n        for k in stale[:100]:\n            del _login_attempts[k]\n    if len(attempts) >= _LOGIN_MAX_ATTEMPTS:\n        raise HTTPException(429, \"Too many login attempts, try again later\")\n\n\n@app.post(\"/login\")\nasync def login(\n    request: Request,\n    username: Annotated[str, Form()],\n    password: Annotated[str, Form()],\n):\n    \"\"\"Authenticate user and create session.\"\"\"\n    ip = request.client.host if request.client else \"unknown\"\n    _check_rate_limit(ip)\n    if not verify_password(username, password):\n        _login_attempts.setdefault(ip, []).append(time.time())\n        return RedirectResponse(\"/login?error=invalid\", status_code=303)\n    token = create_token({\"sub\": username})\n    response = RedirectResponse(\"/\", status_code=303)\n    is_secure = request.url.scheme == \"https\" or \"https\" in request.headers.get(\"x-forwarded-proto\", \"\").lower() or \"https\" in request.headers.get(\"x-forwarded-scheme\", \"\").lower()\n    response.set_cookie(\n        \"token\", token, httponly=True, samesite=\"strict\", max_age=86400 * 7, secure=is_secure\n    )\n    response.set_cookie(\"last_user\", username, max_age=86400 * 365, secure=is_secure)\n    return response\n\n\n@app.get(\"/logout\")\nasync def logout():\n    response = RedirectResponse(\"/login\", status_code=303)\n    response.delete_cookie(\"token\")\n    return response\n\n\n# =============================================================================\n# Main Pages\n# =============================================================================\n\n\n@app.get(\"/favicon.ico\")\nasync def favicon():\n    return Response(status_code=204)\n\n\n@app.get(\"/\", response_class=HTMLResponse)\nasync def index(request: Request, _user: Annotated[dict, Depends(require_auth)]):\n    return RedirectResponse(\"/guide\", status_code=303)\n\n\ndef _fetch_all_epg(epg_urls: list[tuple[str, int, str]]) -> int:\n    \"\"\"Fetch EPG from all URLs into sqlite (in parallel). Returns total program count.\"\"\"\n    user_agent = ffmpeg_command.get_user_agent()\n\n    def fetch_one(url_timeout_source: tuple[str, int, str]) -> tuple[str, int]:\n        url, timeout, source_id = url_timeout_source\n        try:\n            log.info(\"Fetching EPG (timeout=%ds): %s\", timeout, url[:80])\n            count = fetch_epg(url, CACHE_DIR, timeout=timeout, source_id=source_id, user_agent=user_agent)\n            log.info(\"EPG done: %d programs from %s\", count, url[:50])\n            return url, count\n        except Exception as e:\n            log.error(\"EPG failed: %s - %s\", url[:50], e)\n            return url, 0\n\n    total = 0\n    max_workers = min(len(epg_urls) or 1, 8)  # Cap at 8 concurrent fetches\n    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex:\n        futures = [ex.submit(fetch_one, u) for u in epg_urls]\n        for future in concurrent.futures.as_completed(futures):\n            _, count = future.result()\n            total += count\n\n    # Prune expired programs (keep 24h buffer)\n    cutoff = datetime.now(UTC) - timedelta(hours=24)\n    pruned = epg.prune_old_programs(cutoff)\n    if pruned:\n        log.info(\"Pruned %d expired EPG programs\", pruned)\n\n    log.info(\"EPG fetch complete: %d programs total\", total)\n    return total\n\n\ndef load_all_epg(epg_urls: list[tuple[str, int, str]]) -> None:\n    \"\"\"Load EPG into sqlite database if empty.\n\n    Args:\n        epg_urls: List of (url, timeout, source_id) tuples\n    \"\"\"\n    if epg.has_programs():\n        log.info(\"EPG database has %d programs\", epg.get_program_count())\n        return\n\n    # No data - fetch synchronously\n    with _fetch_locks[\"epg\"]:\n        if epg.has_programs():\n            return\n        log.info(\"No EPG data, fetching\")\n        try:\n            _fetch_all_epg(epg_urls)\n        except Exception as e:\n            log.error(\"EPG fetch failed: %s\", e)\n            get_cache()[\"epg_error\"] = str(e)\n\n\ndef _start_guide_background_load() -> None:\n    \"\"\"Start background loading of guide data if not already in progress.\"\"\"\n    if \"guide_load\" in get_refresh_in_progress():\n        return\n    get_refresh_in_progress().add(\"guide_load\")\n\n    def load():\n        try:\n            log.info(\"Loading guide data in background\")\n            cats, streams, epg_urls = load_all_live_data()\n            with get_cache_lock():\n                get_cache()[\"live_categories\"] = cats\n                get_cache()[\"live_streams\"] = streams\n                get_cache()[\"epg_urls\"] = epg_urls\n            try:\n                _fetch_all_epg(epg_urls)\n            except Exception as e:\n                with get_cache_lock():\n                    get_cache()[\"epg_error\"] = str(e)\n            log.info(\"Guide data loaded\")\n        finally:\n            get_refresh_in_progress().discard(\"guide_load\")\n\n    threading.Thread(target=load, daemon=True).start()\n\n\n@app.get(\"/events/epg\")\nasync def epg_events(_user: Annotated[dict, Depends(require_auth)]):\n    \"\"\"SSE endpoint - notifies when EPG is ready.\"\"\"\n    if len(_epg_subscribers) >= _MAX_SSE_SUBSCRIBERS:\n        raise HTTPException(503, \"Too many subscribers\")\n    queue: asyncio.Queue[str] = asyncio.Queue()\n    _epg_subscribers.add(queue)\n\n    async def event_stream():\n        try:\n            # If EPG already loaded, send immediately\n            if epg.has_programs():\n                yield \"data: epg_ready\\n\\n\"\n                return\n            # Wait for EPG ready event or shutdown\n            while True:\n                if _shutdown_event and _shutdown_event.is_set():\n                    return\n                try:\n                    event = await asyncio.wait_for(queue.get(), timeout=1)\n                    yield f\"data: {event}\\n\\n\"\n                    return\n                except TimeoutError:\n                    continue\n        except TimeoutError:\n            yield \"data: timeout\\n\\n\"\n        finally:\n            _epg_subscribers.discard(queue)\n\n    return StreamingResponse(event_stream(), media_type=\"text/event-stream\")\n\n\n@app.get(\"/guide\", response_class=HTMLResponse)\nasync def guide_page(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n    offset: int = 0,  # hours offset from now\n    cats: str = \"\",  # comma-separated category IDs\n):\n    username = user.get(\"sub\", \"\")\n    # Check if cats was explicitly in URL (even if empty)\n    cats_in_url = \"cats\" in request.query_params\n\n    # If no channel data in memory, try file cache first (async to avoid blocking)\n    if \"live_categories\" not in get_cache() or \"live_streams\" not in get_cache():\n        cached = await asyncio.to_thread(load_file_cache, \"live_data\")\n        if cached:\n            data, _ = cached\n            with get_cache_lock():\n                get_cache()[\"live_categories\"] = data[\"cats\"]\n                get_cache()[\"live_streams\"] = data[\"streams\"]\n                get_cache()[\"epg_urls\"] = parse_epg_urls(data.get(\"epg_urls\", []))\n        else:\n            # No cache at all - start background load and show loading page\n            _start_guide_background_load()\n            return TEMPLATES.TemplateResponse(\n                request,\n                \"guide.html\",\n                {\n                    \"grid_data\": [],\n                    \"selected_cats\": [],\n                    \"cats_param\": cats,\n                    \"time_markers\": [],\n                    \"offset\": offset,\n                    \"window_start\": \"\",\n                    \"loading_message\": \"Loading channel data...\",\n                    \"channel_count\": 0,\n                    \"loading\": True,\n                },\n            )\n\n    categories = get_cache()[\"live_categories\"]\n    # EPG is optional - check sqlite db for data\n    epg_loading = not epg.has_programs()\n\n    # Get the full saved filter for dropdown (not just current URL filter)\n    user_settings = load_user_settings(username)\n    saved_filter_list = user_settings.get(\"guide_filter\", [])\n    saved_filter = set(saved_filter_list)  # For fast lookup\n    # Build ordered list of category objects matching user's saved order\n    cat_by_id = {str(c[\"category_id\"]): c for c in categories}\n    ordered_filter_cats = [cat_by_id[cid] for cid in saved_filter_list if cid in cat_by_id]\n\n    # Get saved VIEW selection (separate from Settings filter)\n    saved_view_cats = user_settings.get(\"guide_selected_cats\")  # None = show all\n\n    # Determine effective cats: URL param (if present) > saved view > all from filter\n    if cats_in_url:\n        # URL explicitly has cats param (could be empty for \"none\")\n        effective_cats = cats\n    elif saved_view_cats is not None:\n        # Use saved view selection (could be [] for \"none\")\n        effective_cats = \",\".join(saved_view_cats)\n    else:\n        # Default: show all from settings filter\n        effective_cats = \",\".join(saved_filter_list)\n\n    # Use helper to get filtered/sorted streams\n    streams, ordered_cats, selected_cats = _get_guide_streams(effective_cats, username)\n    total_count = len(streams)\n\n    # Time window: 3 hours starting from offset\n    now = datetime.now(UTC)\n    window_start = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=offset)\n    window_end = window_start + timedelta(hours=3)\n\n    # Virtual scrolling: only render first batch, JS fetches rest on scroll\n    # When disabled, render all rows server-side\n    virtual_scroll_enabled = user_settings.get(\"virtual_scroll\", True)\n    initial_batch_size = 500 if virtual_scroll_enabled else total_count\n    grid_data = _build_guide_rows(streams, 0, initial_batch_size, window_start, window_end)\n\n    # Time markers (every 30 min) - convert to local time for display\n    time_markers = []\n    for i in range(7):  # 0, 30, 60, 90, 120, 150, 180 minutes\n        t = window_start + timedelta(minutes=i * 30)\n        t_local = t.astimezone()  # Convert to local timezone\n        time_markers.append(\n            {\n                \"label\": t_local.strftime(\"%H:%M\"),\n                \"left_pct\": (i * 30 / 180) * 100,\n            }\n        )\n\n    # Mobile time markers (2 hour window instead of 3)\n    time_markers_mobile = []\n    for i in range(5):  # 0, 30, 60, 90, 120 minutes\n        t = window_start + timedelta(minutes=i * 30)\n        t_local = t.astimezone()\n        time_markers_mobile.append(\n            {\n                \"label\": t_local.strftime(\"%H:%M\"),\n                \"left_pct\": (i * 30 / 120) * 100,\n            }\n        )\n\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"guide.html\",\n        {\n            \"categories\": categories,\n            \"selected_cats\": selected_cats,\n            \"saved_filter\": saved_filter,  # Full saved filter for dropdown (set)\n            \"ordered_filter_cats\": ordered_filter_cats,  # Ordered list for dropdown\n            \"cats_param\": cats,\n            \"effective_cats\": effective_cats,  # What's actually being used\n            \"grid_data\": grid_data,\n            \"time_markers\": time_markers,\n            \"time_markers_mobile\": time_markers_mobile,\n            \"offset\": offset,\n            \"window_start\": window_start.strftime(\"%Y-%m-%d %H:%M\"),\n            \"epg_error\": get_cache().get(\"epg_error\"),\n            \"epg_loading\": epg_loading,\n            \"channel_count\": len(grid_data),\n            \"total_count\": total_count,  # For virtual scrolling\n            \"virtual_scroll\": virtual_scroll_enabled,\n            \"loading\": False,\n            \"content_access\": _get_content_access(username),\n        },\n    )\n\n\ndef _get_guide_streams(cats: str, username: str) -> tuple[list[dict], list[str], set[str]]:\n    \"\"\"Get filtered and sorted streams for guide.\n\n    Returns:\n        Tuple of (filtered_streams, ordered_cat_ids, selected_cat_set)\n    \"\"\"\n    all_streams = get_cache().get(\"live_streams\", [])\n\n    # Parse selected category IDs (ordered list)\n    ordered_cats: list[str] = []\n    if cats:\n        ordered_cats = [c.strip() for c in cats.split(\",\") if c.strip()]\n    selected_cats = set(ordered_cats)\n\n    if not selected_cats:\n        return [], ordered_cats, selected_cats\n\n    # Get user's unavailable groups for filtering\n    user_limits = auth.get_user_limits(username)\n    unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n\n    cat_order = {c: i for i, c in enumerate(ordered_cats)}\n\n    def stream_sort_key(s: dict) -> int:\n        for c in s.get(\"category_ids\") or []:\n            if str(c) in cat_order:\n                return cat_order[str(c)]\n        return len(ordered_cats)\n\n    def stream_allowed(s: dict) -> bool:\n        cat_ids = s.get(\"category_ids\") or []\n        return not any(f\"cat:{c}\" in unavailable_groups for c in cat_ids)\n\n    streams = [\n        s\n        for s in all_streams\n        if any(str(c) in selected_cats for c in (s.get(\"category_ids\") or [])) and stream_allowed(s)\n    ]\n    streams.sort(key=stream_sort_key)\n\n    return streams, ordered_cats, selected_cats\n\n\ndef _build_guide_rows(\n    streams: list[dict],\n    start_idx: int,\n    count: int,\n    window_start: datetime,\n    window_end: datetime,\n) -> list[dict]:\n    \"\"\"Build guide grid rows for a range of streams.\n\n    Returns:\n        List of row dicts with channel info and programs.\n    \"\"\"\n    end_idx = min(start_idx + count, len(streams))\n    slice_streams = streams[start_idx:end_idx]\n\n    if not slice_streams:\n        return []\n\n    # Collect EPG IDs for batch query\n    epg_ids = [s.get(\"epg_channel_id\") or \"\" for s in slice_streams]\n    epg_ids_set = [e for e in epg_ids if e]\n\n    # Batch fetch icons and programs\n    icons_map = epg.get_icons_batch(epg_ids_set) if epg_ids_set else {}\n\n    # Build preferred_sources for EPG matching\n    preferred_sources = {\n        epg_id: s.get(\"source_id\", \"\")\n        for s, epg_id in zip(slice_streams, epg_ids, strict=False)\n        if epg_id and s.get(\"source_id\")\n    }\n    programs_map = (\n        epg.get_programs_batch(epg_ids_set, window_start, window_end, preferred_sources)\n        if epg_ids_set\n        else {}\n    )\n\n    # Build rows\n    window_end_mobile = window_start + timedelta(hours=2)\n    grid_data = []\n\n    for idx, (s, epg_id) in enumerate(zip(slice_streams, epg_ids, strict=False), start=start_idx):\n        icon = s.get(\"stream_icon\", \"\") or icons_map.get(epg_id, \"\")\n        ch = {\n            \"stream_id\": s[\"stream_id\"],\n            \"name\": s[\"name\"],\n            \"icon\": icon,\n            \"epg_id\": epg_id,\n        }\n        row = {\"channel\": ch, \"programs\": [], \"programs_mobile\": [], \"index\": idx}\n\n        for p in programs_map.get(epg_id, []):\n            p_start = max(p.start, window_start)\n            p_end = min(p.stop, window_end)\n            start_mins = (p_start - window_start).total_seconds() / 60\n            duration_mins = (p_end - p_start).total_seconds() / 60\n            left_pct = (start_mins / 180) * 100\n            width_pct = (duration_mins / 180) * 100\n            row[\"programs\"].append(\n                {\n                    \"title\": p.title,\n                    \"desc\": p.desc,\n                    \"start\": p.start.strftime(\"%H:%M\"),\n                    \"end\": p.stop.strftime(\"%H:%M\"),\n                    \"left_pct\": left_pct,\n                    \"width_pct\": width_pct,\n                }\n            )\n            # Mobile: 2-hour window\n            if p.start < window_end_mobile:\n                p_end_m = min(p.stop, window_end_mobile)\n                duration_mins_m = (p_end_m - p_start).total_seconds() / 60\n                left_pct_m = (start_mins / 120) * 100\n                width_pct_m = (duration_mins_m / 120) * 100\n                row[\"programs_mobile\"].append(\n                    {\n                        \"title\": p.title,\n                        \"desc\": p.desc,\n                        \"start\": p.start.strftime(\"%H:%M\"),\n                        \"end\": p.stop.strftime(\"%H:%M\"),\n                        \"left_pct\": left_pct_m,\n                        \"width_pct\": width_pct_m,\n                    }\n                )\n\n        grid_data.append(row)\n\n    return grid_data\n\n\n@app.get(\"/api/guide/rows\")\nasync def guide_rows_api(\n    user: Annotated[dict, Depends(require_auth)],\n    start: int = Query(default=0, ge=0, description=\"Starting row index\"),\n    count: int = Query(default=130, ge=1, le=500, description=\"Number of rows to fetch\"),\n    offset: int = Query(default=0, ge=-168, le=168, description=\"Hours offset from now\"),\n    cats: str = \"\",\n):\n    \"\"\"API endpoint for virtual scrolling - returns guide rows as JSON.\"\"\"\n    username = user.get(\"sub\", \"\")\n\n    # Use saved filter if no cats provided\n    if not cats:\n        user_settings = load_user_settings(username)\n        saved = user_settings.get(\"guide_filter\", [])\n        if saved:\n            cats = \",\".join(saved)\n\n    # Ensure data is loaded\n    if \"live_streams\" not in get_cache():\n        cached = await asyncio.to_thread(load_file_cache, \"live_data\")\n        if cached:\n            data, _ = cached\n            with get_cache_lock():\n                get_cache()[\"live_categories\"] = data[\"cats\"]\n                get_cache()[\"live_streams\"] = data[\"streams\"]\n                get_cache()[\"epg_urls\"] = parse_epg_urls(data.get(\"epg_urls\", []))\n\n    streams, _, _ = _get_guide_streams(cats, username)\n    total_count = len(streams)\n\n    if total_count == 0:\n        return JSONResponse({\"rows\": [], \"total\": 0, \"start\": start})\n\n    # Time window\n    now = datetime.now(UTC)\n    window_start = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=offset)\n    window_end = window_start + timedelta(hours=3)\n\n    rows = _build_guide_rows(streams, start, count, window_start, window_end)\n\n    return JSONResponse(\n        {\"rows\": rows, \"total\": total_count, \"start\": start},\n        headers={\"Cache-Control\": \"no-store\"},\n    )\n\n\ndef _start_vod_background_load() -> None:\n    \"\"\"Start background loading of VOD data if not already in progress.\"\"\"\n    if \"vod_load\" in get_refresh_in_progress():\n        return\n    get_refresh_in_progress().add(\"vod_load\")\n\n    def load():\n        try:\n            log.info(\"Loading VOD data in background\")\n            vod_cats, vod_streams = load_vod_data()\n            with get_cache_lock():\n                get_cache()[\"vod_categories\"] = vod_cats\n                get_cache()[\"vod_streams\"] = vod_streams\n            log.info(\"VOD data loaded\")\n        finally:\n            get_refresh_in_progress().discard(\"vod_load\")\n\n    threading.Thread(target=load, daemon=True).start()\n\n\n@app.get(\"/vod\", response_class=HTMLResponse)\nasync def vod_page(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n    category: int | None = None,\n    sort: str | None = None,\n):\n    # Load from file cache if not in memory (async to avoid blocking)\n    if \"vod_categories\" not in get_cache() or \"vod_streams\" not in get_cache():\n        cached = await asyncio.to_thread(load_file_cache, \"vod_data\")\n        if cached:\n            data, _ = cached\n            get_cache()[\"vod_categories\"] = data[\"cats\"]\n            get_cache()[\"vod_streams\"] = data[\"streams\"]\n        else:\n            # No cache - start background load and show loading page\n            _start_vod_background_load()\n            username = user.get(\"sub\", \"\")\n            user_settings = load_user_settings(username)\n            return TEMPLATES.TemplateResponse(\n                request,\n                \"vod.html\",\n                {\n                    \"categories\": [],\n                    \"streams\": [],\n                    \"current_category\": category,\n                    \"current_sort\": sort,\n                    \"loading\": True,\n                    \"favorites\": user_settings.get(\"favorites\", {\"series\": {}, \"movies\": {}}),\n                },\n            )\n\n    username = user.get(\"sub\", \"\")\n    user_settings = load_user_settings(username)\n\n    # Check if user has access to any movies\n    content_access = _get_content_access(username)\n    if not content_access[\"movies\"]:\n        raise HTTPException(403, \"Access to movies is restricted\")\n\n    # Get user's unavailable groups for filtering\n    user_limits = auth.get_user_limits(username)\n    unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n\n    # Filter by group access (movies:{source_id})\n    def movie_allowed(s: dict) -> bool:\n        source_id = s.get(\"source_id\", \"\")\n        return f\"movies:{source_id}\" not in unavailable_groups\n\n    streams = [s for s in get_cache()[\"vod_streams\"] if movie_allowed(s)]\n    categories = [\n        c\n        for c in get_cache()[\"vod_categories\"]\n        if f\"movies:{c.get('source_id', '')}\" not in unavailable_groups\n    ]\n\n    # Apply user's VOD category filter (if set)\n    vod_filter = user_settings.get(\"vod_filter\", [])\n    if vod_filter:\n        vod_filter_set = set(str(c) for c in vod_filter)\n        categories = [c for c in categories if str(c.get(\"category_id\")) in vod_filter_set]\n        streams = [s for s in streams if str(s.get(\"category_id\")) in vod_filter_set]\n\n    # Filter by category if specified\n    if category:\n        streams = [s for s in streams if str(s.get(\"category_id\")) == str(category)]\n\n    # Sort\n    if sort == \"alpha\":\n        streams.sort(key=lambda s: (s.get(\"name\") or \"\").lower())\n    elif sort == \"rating\":\n        streams.sort(key=lambda s: _safe_float(s.get(\"rating\")), reverse=True)\n    elif sort == \"newest\":\n        streams.sort(key=lambda s: int(s.get(\"added\") or 0), reverse=True)\n\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"vod.html\",\n        {\n            \"categories\": categories,\n            \"streams\": streams,\n            \"current_category\": category,\n            \"current_sort\": sort,\n            \"favorites\": user_settings.get(\"favorites\", {\"series\": {}, \"movies\": {}}),\n            \"content_access\": _get_content_access(username),\n        },\n    )\n\n\ndef _start_series_background_load() -> None:\n    \"\"\"Start background loading of series data if not already in progress.\"\"\"\n    if \"series_load\" in get_refresh_in_progress():\n        return\n    get_refresh_in_progress().add(\"series_load\")\n\n    def load():\n        try:\n            log.info(\"Loading series data in background\")\n            series_cats, series_list = load_series_data()\n            with get_cache_lock():\n                get_cache()[\"series_categories\"] = series_cats\n                get_cache()[\"series\"] = series_list\n            log.info(\"Series data loaded\")\n        finally:\n            get_refresh_in_progress().discard(\"series_load\")\n\n    threading.Thread(target=load, daemon=True).start()\n\n\n@app.get(\"/series\", response_class=HTMLResponse)\nasync def series_page(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n    category: int | None = None,\n    sort: str | None = None,\n):\n    # Load from file cache if not in memory (async to avoid blocking)\n    if \"series_categories\" not in get_cache() or \"series\" not in get_cache():\n        cached = await asyncio.to_thread(load_file_cache, \"series_data\")\n        if cached:\n            data, _ = cached\n            get_cache()[\"series_categories\"] = data[\"cats\"]\n            get_cache()[\"series\"] = data[\"series\"]\n        else:\n            # No cache - start background load and show loading page\n            _start_series_background_load()\n            username = user.get(\"sub\", \"\")\n            user_settings = load_user_settings(username)\n            return TEMPLATES.TemplateResponse(\n                request,\n                \"series.html\",\n                {\n                    \"categories\": [],\n                    \"series\": [],\n                    \"current_category\": category,\n                    \"current_sort\": sort,\n                    \"loading\": True,\n                    \"favorites\": user_settings.get(\"favorites\", {\"series\": {}, \"movies\": {}}),\n                },\n            )\n\n    username = user.get(\"sub\", \"\")\n    user_settings = load_user_settings(username)\n\n    # Check if user has access to any series\n    content_access = _get_content_access(username)\n    if not content_access[\"series\"]:\n        raise HTTPException(403, \"Access to series is restricted\")\n\n    # Get user's unavailable groups for filtering\n    user_limits = auth.get_user_limits(username)\n    unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n\n    # Filter by group access (series:{source_id})\n    def series_allowed(s: dict) -> bool:\n        source_id = s.get(\"source_id\", \"\")\n        return f\"series:{source_id}\" not in unavailable_groups\n\n    series = [s for s in get_cache()[\"series\"] if series_allowed(s)]\n    categories = [\n        c\n        for c in get_cache()[\"series_categories\"]\n        if f\"series:{c.get('source_id', '')}\" not in unavailable_groups\n    ]\n\n    # Apply user's series category filter (if set)\n    series_filter = user_settings.get(\"series_filter\", [])\n    if series_filter:\n        series_filter_set = set(str(c) for c in series_filter)\n        categories = [c for c in categories if str(c.get(\"category_id\")) in series_filter_set]\n        series = [s for s in series if str(s.get(\"category_id\")) in series_filter_set]\n\n    # Filter by category if specified\n    if category:\n        series = [s for s in series if str(s.get(\"category_id\")) == str(category)]\n\n    # Sort\n    if sort == \"alpha\":\n        series.sort(key=lambda s: (s.get(\"name\") or \"\").lower())\n    elif sort == \"rating\":\n        series.sort(key=lambda s: _safe_float(s.get(\"rating\")), reverse=True)\n    elif sort == \"newest\":\n        series.sort(key=lambda s: int(s.get(\"last_modified\") or 0), reverse=True)\n\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"series.html\",\n        {\n            \"categories\": categories,\n            \"series\": series,\n            \"current_category\": category,\n            \"current_sort\": sort,\n            \"favorites\": user_settings.get(\"favorites\", {\"series\": {}, \"movies\": {}}),\n            \"content_access\": _get_content_access(username),\n        },\n    )\n\n\n@app.get(\"/series/{series_id}\", response_class=HTMLResponse)\nasync def series_detail_page(\n    request: Request,\n    series_id: int,\n    user: Annotated[dict, Depends(require_auth)],\n    refresh: bool = False,\n):\n    username = user.get(\"sub\", \"\")\n\n    # Check access for this specific series and get source_id\n    source_id = \"\"\n    if \"series\" in get_cache():\n        cached_series = next(\n            (s for s in get_cache()[\"series\"] if str(s.get(\"series_id\")) == str(series_id)),\n            None,\n        )\n        if cached_series:\n            source_id = cached_series.get(\"source_id\", \"\")\n            user_limits = auth.get_user_limits(username)\n            unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n            if f\"series:{source_id}\" in unavailable_groups:\n                raise HTTPException(403, \"Access to this series is restricted\")\n\n    # Use the series' source, fall back to first Xtream source\n    xtream = get_xtream_client_by_source(source_id) if source_id else None\n    if not xtream:\n        xtream = get_first_xtream_client()\n    if not xtream:\n        raise HTTPException(404, \"No Xtream source configured\")\n    cache_key = f\"series_info_{source_id}_{series_id}\" if source_id else f\"series_info_{series_id}\"\n    try:\n        series_data = await asyncio.to_thread(\n            get_cached_info, cache_key, lambda: xtream.get_series_info(series_id), refresh\n        )\n    except (urllib.error.URLError, TimeoutError) as e:\n        return TEMPLATES.TemplateResponse(\n            request,\n            \"error.html\",\n            {\n                \"title\": \"Provider Error\",\n                \"message\": f\"Failed to load series info: {e}\",\n            },\n            status_code=502,\n        )\n    if refresh:\n        log.info(\"Force refreshed series info %s\", series_id)\n    # Extract year from releaseDate if not present\n    if series_data.get(\"info\"):\n        info = series_data[\"info\"]\n        if not info.get(\"year\") and info.get(\"releaseDate\"):\n            info[\"year\"] = info[\"releaseDate\"][:4]\n    # Strip redundant series title and episode numbers from episode titles\n    if series_data.get(\"episodes\"):\n        for season_eps in series_data[\"episodes\"].values():\n            for ep in season_eps:\n                if ep.get(\"title\"):\n                    # Remove patterns like \"Series Name - S01E01 - Episode Title\"\n                    # Keep only the actual episode title\n                    title = ep[\"title\"]\n                    # Remove S##E## - pattern\n                    title = re.sub(r\"^S\\d+E\\d+\\s*-\\s*\", \"\", title)\n                    # Remove any leading \"SeriesName - \" pattern\n                    if \" - \" in title and len(title.split(\" - \")) > 1:\n                        parts = title.split(\" - \")\n                        # Take the last part which should be the actual episode title\n                        title = parts[-1]\n                    ep[\"title\"] = title.strip()\n\n                # Parse info field if it's JSON\n                if ep.get(\"info\"):\n                    if isinstance(ep[\"info\"], str):\n                        try:\n                            info_obj = json.loads(ep[\"info\"])\n                            # Extract plot/description from parsed JSON\n                            if isinstance(info_obj, dict):\n                                ep[\"description\"] = (\n                                    info_obj.get(\"plot\") or info_obj.get(\"description\") or \"\"\n                                )\n                        except (json.JSONDecodeError, TypeError):\n                            pass\n                    elif isinstance(ep[\"info\"], dict):\n                        # Already a dict\n                        ep[\"description\"] = (\n                            ep[\"info\"].get(\"plot\") or ep[\"info\"].get(\"description\") or \"\"\n                        )\n\n    username = user.get(\"sub\", \"\")\n    user_settings = load_user_settings(username)\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"series_detail.html\",\n        {\n            \"series\": series_data,\n            \"series_id\": series_id,\n            \"favorites\": user_settings.get(\"favorites\", {\"series\": {}, \"movies\": {}}),\n        },\n    )\n\n\n@app.get(\"/movie/{stream_id}\", response_class=HTMLResponse)\nasync def movie_detail_page(\n    request: Request,\n    stream_id: int,\n    user: Annotated[dict, Depends(require_auth)],\n):\n    username = user.get(\"sub\", \"\")\n\n    # Load from file cache if not in memory\n    if \"vod_streams\" not in get_cache():\n        vod_cats, vod_streams = load_vod_data()\n        get_cache()[\"vod_categories\"] = vod_cats\n        get_cache()[\"vod_streams\"] = vod_streams\n\n    vod_streams = get_cache().get(\"vod_streams\", [])\n    movie = next((m for m in vod_streams if m.get(\"stream_id\") == stream_id), None)\n\n    # Check access for this specific movie\n    if movie:\n        source_id = movie.get(\"source_id\", \"\")\n        user_limits = auth.get_user_limits(username)\n        unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n        if f\"movies:{source_id}\" in unavailable_groups:\n            raise HTTPException(403, \"Access to this movie is restricted\")\n\n    # Fetch detailed movie info\n    if movie:\n        source_id = movie.get(\"source_id\", \"\")\n        xtream = get_xtream_client_by_source(source_id) if source_id else None\n        if not xtream:\n            xtream = get_first_xtream_client()\n        if xtream:\n            cache_key = (\n                f\"vod_info_{source_id}_{stream_id}\" if source_id else f\"vod_info_{stream_id}\"\n            )\n            try:\n                vod_info = await asyncio.to_thread(\n                    get_cached_info, cache_key, lambda: xtream.get_vod_info(stream_id)\n                )\n            except (urllib.error.URLError, TimeoutError):\n                vod_info = {}\n            if vod_info and vod_info.get(\"info\"):\n                info = vod_info[\"info\"]\n                # Merge detailed info into movie object\n                movie = {**movie}  # Copy\n                movie[\"plot\"] = info.get(\"plot\") or info.get(\"description\", \"\")\n                movie[\"director\"] = info.get(\"director\", \"\")\n                movie[\"cast\"] = info.get(\"cast\") or info.get(\"actors\", \"\")\n                movie[\"genre\"] = info.get(\"genre\", \"\")\n                movie[\"rating\"] = info.get(\"rating\", \"\")\n                movie[\"year\"] = info.get(\"releasedate\", \"\")[:4] if info.get(\"releasedate\") else \"\"\n                movie[\"duration\"] = info.get(\"duration\", \"\")\n                movie[\"cover_big\"] = info.get(\"cover_big\") or info.get(\"movie_image\", \"\")\n                movie[\"youtube_trailer\"] = info.get(\"youtube_trailer\", \"\")\n\n    username = user.get(\"sub\", \"\")\n    user_settings = load_user_settings(username)\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"movie_detail.html\",\n        {\n            \"movie\": movie,\n            \"favorites\": user_settings.get(\"favorites\", {\"series\": {}, \"movies\": {}}),\n        },\n    )\n\n\n@dataclass(slots=True)\nclass PlayerInfo:\n    \"\"\"Info needed to render the player page.\"\"\"\n\n    url: str = \"\"\n    is_m3u: bool = False\n    channel_name: str = \"\"\n    program_title: str = \"\"\n    program_desc: str = \"\"\n    deinterlace_fallback: bool = True  # Used when probe is skipped\n    source_id: str = \"\"  # Source ID for stream limit tracking\n    category_ids: list[str] | None = None  # Category IDs for live streams (access check)\n\n\ndef _get_episode_desc(ep: dict) -> str:\n    \"\"\"Extract description from episode info (handles str or dict).\"\"\"\n    info = ep.get(\"info\")\n    if isinstance(info, str):\n        try:\n            info = json.loads(info)\n        except (json.JSONDecodeError, TypeError):\n            info = None\n    if isinstance(info, dict):\n        return info.get(\"plot\") or info.get(\"description\") or \"\"\n    return ep.get(\"description\") or ep.get(\"plot\") or \"\"\n\n\ndef _get_live_player_info(stream_id: str) -> PlayerInfo:\n    \"\"\"Get player info for live stream.\"\"\"\n    _ensure_live_cache()\n    stream = next(\n        (s for s in get_cache()[\"live_streams\"] if str(s.get(\"stream_id\")) == stream_id),\n        None,\n    )\n    if not stream:\n        return PlayerInfo()\n\n    info = PlayerInfo(channel_name=stream.get(\"name\", \"\"))\n\n    if stream.get(\"direct_url\"):\n        info.url = stream[\"direct_url\"]\n        info.is_m3u = True\n    elif stream.get(\"source_type\") == \"xtream\":\n        base, user, pwd = stream[\"source_url\"], stream[\"source_username\"], stream[\"source_password\"]\n        orig_id = stream_id.split(\"_\")[-1] if \"_\" in stream_id else stream_id\n        # URL-encode username/password to handle special chars like # in passwords\n        user = urllib.parse.quote(user, safe=\"\")\n        pwd = urllib.parse.quote(pwd, safe=\"\")\n        info.url = f\"{base}/live/{user}/{pwd}/{orig_id}.m3u8\"\n\n    # Look up source settings\n    source_id = stream.get(\"source_id\", \"\")\n    info.source_id = source_id\n    info.category_ids = stream.get(\"category_ids\")\n    if source_id:\n        sources = load_server_settings().get(\"sources\", [])\n        source = next((s for s in sources if s.get(\"id\") == source_id), None)\n        if source:\n            info.deinterlace_fallback = source.get(\"deinterlace_fallback\", True)\n\n    # Look up current program from EPG\n    epg_id = stream.get(\"epg_channel_id\") or \"\"\n    if epg_id:\n        now = datetime.now(UTC)\n        programs = epg.get_programs_in_range(epg_id, now, now + timedelta(minutes=1))\n        if programs:\n            info.program_title, info.program_desc = programs[0].title, programs[0].desc\n    return info\n\n\ndef _get_movie_player_info(stream_id: str, ext: str) -> PlayerInfo:\n    \"\"\"Get player info for movie.\"\"\"\n    # Find movie in cache to get its source_id\n    cached_movie = None\n    if \"vod_streams\" in get_cache():\n        cached_movie = next(\n            (m for m in get_cache()[\"vod_streams\"] if str(m.get(\"stream_id\")) == str(stream_id)),\n            None,\n        )\n\n    # Get client for the movie's source (fall back to first if not found)\n    source_id = cached_movie.get(\"source_id\", \"\") if cached_movie else \"\"\n    xtream = get_xtream_client_by_source(source_id) if source_id else None\n    if not xtream:\n        xtream = get_first_xtream_client()\n    if not xtream:\n        return PlayerInfo()\n\n    ext = ext or \"mkv\"\n    info = PlayerInfo(url=xtream.build_stream_url(\"movie\", int(stream_id), ext))\n    info.source_id = source_id\n\n    cache_key = f\"vod_info_{source_id}_{stream_id}\"\n    try:\n        movie = get_cached_info(cache_key, lambda: xtream.get_vod_info(int(stream_id)))\n    except (urllib.error.URLError, TimeoutError):\n        return info\n    if movie and movie.get(\"info\"):\n        m = movie[\"info\"]\n        name = m.get(\"name\", \"\")\n        year = str(m.get(\"year\") or m.get(\"releasedate\", \"\"))[:4]\n        info.channel_name = f\"{name} ({year})\" if year else name\n        info.program_desc = m.get(\"plot\") or m.get(\"description\") or \"\"\n    return info\n\n\ndef _get_series_player_info(\n    stream_id: str, series_id: int | None, ext: str\n) -> tuple[PlayerInfo, str | None]:\n    \"\"\"Get player info for series episode. Returns (info, next_episode_url).\"\"\"\n    # Find series in cache to get its source_id\n    cached_series = None\n    source_id = \"\"\n    if series_id and \"series\" in get_cache():\n        cached_series = next(\n            (s for s in get_cache()[\"series\"] if str(s.get(\"series_id\")) == str(series_id)),\n            None,\n        )\n        if cached_series:\n            source_id = cached_series.get(\"source_id\", \"\")\n\n    # Get client for the series' source (fall back to first if not found)\n    xtream = get_xtream_client_by_source(source_id) if source_id else None\n    if not xtream:\n        xtream = get_first_xtream_client()\n    if not xtream:\n        return PlayerInfo(), None\n\n    ext = ext or \"mkv\"\n    info = PlayerInfo(url=xtream.build_stream_url(\"series\", int(stream_id), ext))\n    info.source_id = source_id\n\n    if not series_id:\n        return info, None\n\n    cache_key = f\"series_info_{source_id}_{series_id}\"\n    try:\n        series = get_cached_info(cache_key, lambda: xtream.get_series_info(series_id))\n    except (urllib.error.URLError, TimeoutError) as e:\n        log.warning(\"Failed to fetch series info %s: %s\", series_id, e)\n        return info, None\n    if not series:\n        return info, None\n\n    if series.get(\"info\"):\n        name = series[\"info\"].get(\"name\", \"\")\n        year = series[\"info\"].get(\"year\", \"\")\n        info.channel_name = f\"{name} ({year})\" if year else name\n\n    # Build flat list of all episodes in order (season, episode)\n    all_episodes: list[tuple[int, dict]] = []\n    for season_num, eps in sorted((series.get(\"episodes\") or {}).items(), key=lambda x: int(x[0])):\n        for ep in sorted(eps, key=lambda e: int(e.get(\"episode_num\", 0))):\n            all_episodes.append((int(season_num), ep))\n\n    # Find current episode and next\n    next_episode_url = None\n    for i, (season_num, ep) in enumerate(all_episodes):\n        if str(ep.get(\"id\")) == str(stream_id):\n            title = re.sub(r\"^S\\d+E\\d+\\s*-\\s*\", \"\", ep.get(\"title\", \"\"))\n            if \" - \" in title:\n                title = title.split(\" - \")[-1]\n            info.program_title = (\n                f\"S{int(season_num):02d}E{int(ep.get('episode_num', 0)):02d} — {title.strip()}\"\n            )\n            info.program_desc = _get_episode_desc(ep)\n            # Get next episode URL\n            if i + 1 < len(all_episodes):\n                _, next_ep = all_episodes[i + 1]\n                next_ext = next_ep.get(\"container_extension\") or ext\n                next_episode_url = (\n                    f\"/play/series/{next_ep['id']}?series_id={series_id}&ext={next_ext}\"\n                )\n            break\n    return info, next_episode_url\n\n\ndef _ensure_live_cache() -> None:\n    \"\"\"Ensure live streams and EPG are loaded.\"\"\"\n    if \"live_streams\" not in get_cache():\n        cats, streams, epg_urls = load_all_live_data()\n        with get_cache_lock():\n            get_cache()[\"live_categories\"] = cats\n            get_cache()[\"live_streams\"] = streams\n            get_cache()[\"epg_urls\"] = epg_urls\n    if not epg.has_programs():\n        with contextlib.suppress(Exception):\n            load_all_epg(get_cache().get(\"epg_urls\", []))\n\n\n@app.get(\"/play/{stream_type}/{stream_id:path}\", response_class=HTMLResponse)\nasync def player_page(\n    request: Request,\n    stream_type: str,\n    stream_id: str,\n    user: Annotated[dict, Depends(require_auth)],\n    ext: str = \"\",\n    series_id: int | None = None,\n):\n    \"\"\"Render player page for live/movie/series stream.\"\"\"\n    username = user.get(\"sub\", \"\")\n    next_episode_url = None\n    if stream_type == \"live\":\n        info = await asyncio.to_thread(_get_live_player_info, stream_id)\n    elif stream_type == \"movie\":\n        info = await asyncio.to_thread(_get_movie_player_info, stream_id, ext)\n    elif stream_type == \"series\":\n        info, next_episode_url = await asyncio.to_thread(\n            _get_series_player_info, stream_id, series_id, ext\n        )\n    else:\n        raise HTTPException(404, \"Invalid stream type\")\n\n    if not info.url:\n        raise HTTPException(404, \"Stream not found\")\n\n    # Check user's group access\n    user_limits = auth.get_user_limits(username)\n    unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n    log.info(\n        \"Access check: user=%s type=%s source_id=%s unavailable=%s\",\n        username,\n        stream_type,\n        info.source_id,\n        unavailable_groups,\n    )\n    if unavailable_groups:\n        if stream_type == \"live\" and info.category_ids:\n            # Live streams: blocked if any category is unavailable\n            if any(f\"cat:{cat_id}\" in unavailable_groups for cat_id in info.category_ids):\n                raise HTTPException(403, \"Access to this channel is restricted\")\n        elif stream_type == \"movie\" and info.source_id:\n            if f\"movies:{info.source_id}\" in unavailable_groups:\n                raise HTTPException(403, \"Access to movies is restricted\")\n        elif (\n            stream_type == \"series\"\n            and info.source_id\n            and f\"series:{info.source_id}\" in unavailable_groups\n        ):\n            raise HTTPException(403, \"Access to series is restricted\")\n\n    log.info(\"Play %s/%s: %s\", stream_type, stream_id, info.url)\n\n    server_settings = load_server_settings()\n    user_settings = load_user_settings(username)\n    transcode_mode = server_settings.get(\"transcode_mode\", \"auto\")\n    is_https = request.url.scheme == \"https\" or \"https\" in request.headers.get(\"x-forwarded-proto\", \"\").lower() or \"https\" in request.headers.get(\"x-forwarded-scheme\", \"\").lower()\n    if transcode_mode == \"auto\":\n        needs_transcode = info.is_m3u or ext in (\"mkv\", \"mp4\", \"avi\", \"wmv\", \"flv\")\n        mixed_content = is_https and info.url.startswith(\"http://\")\n        if needs_transcode or mixed_content:\n            transcode_mode = \"always\"\n\n    # Get saved watch position for VOD (per-user)\n    resume_position = 0.0\n    if stream_type in (\"movie\", \"series\"):\n        watch_entry = get_watch_position(username, info.url)\n        if watch_entry:\n            resume_position = watch_entry.get(\"position\", 0.0)\n\n    # For series, stream_id is episode_id\n    episode_id = int(stream_id) if stream_type == \"series\" and stream_id.isdigit() else None\n    # Extract series name from channel_name (format: \"Series Name (Year)\" or just \"Series Name\")\n    series_name = \"\"\n    if stream_type == \"series\" and info.channel_name:\n        # Strip year suffix like \" (2020)\"\n        series_name = re.sub(r\"\\s*\\(\\d{4}\\)$\", \"\", info.channel_name)\n\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"player.html\",\n        {\n            \"raw_url\": info.url,\n            \"transcode_mode\": transcode_mode,\n            \"stream_type\": stream_type,\n            \"channel_name\": info.channel_name,\n            \"program_title\": info.program_title,\n            \"program_desc\": info.program_desc,\n            \"captions_enabled\": user_settings.get(\"captions_enabled\", False),\n            \"resume_position\": resume_position,\n            \"series_id\": series_id,\n            \"episode_id\": episode_id,\n            \"series_name\": series_name,\n            \"cc_lang\": user_settings.get(\"cc_lang\", \"\"),\n            \"cc_style\": user_settings.get(\"cc_style\", {}),\n            \"cast_host\": user_settings.get(\"cast_host\", \"\"),\n            \"next_episode_url\": next_episode_url,\n            \"deinterlace_fallback\": info.deinterlace_fallback,\n            \"source_id\": info.source_id,\n            \"content_access\": _get_content_access(username),\n        },\n    )\n\n\n@app.get(\"/search\", response_class=HTMLResponse)\nasync def search_page(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n    q: str = \"\",\n    regex: bool = False,\n    live: bool = False,\n    vod: bool = False,\n    series: bool = False,\n    limit: int = 100,\n):\n    results: dict[str, list] = {\"live\": [], \"vod\": [], \"series\": []}\n\n    # Default all on if none specified\n    if not live and not vod and not series:\n        live = vod = series = True\n\n    if q:\n        if regex:\n            # Limit regex length to prevent ReDoS\n            if len(q) > 100:\n                raise HTTPException(400, \"Regex pattern too long\")\n            try:\n                pattern = re.compile(q, re.IGNORECASE)\n\n                def match_fn(name: str) -> bool:\n                    try:\n                        # Timeout via match limit - search only first 1000 chars\n                        return pattern.search(name[:1000]) is not None\n                    except Exception:\n                        return False\n            except re.error:\n\n                def match_fn(name: str) -> bool:\n                    return False\n        else:\n            q_lower = q.lower()\n\n            def match_fn(name: str) -> bool:\n                return q_lower in name.lower()\n\n        # Load live data (run in thread to avoid blocking)\n        if live:\n            if \"live_streams\" not in get_cache():\n                cats, streams, epg_urls = await asyncio.to_thread(load_all_live_data)\n                with get_cache_lock():\n                    get_cache()[\"live_categories\"] = cats\n                    get_cache()[\"live_streams\"] = streams\n                    get_cache()[\"epg_urls\"] = epg_urls\n            matched = sorted(\n                [s for s in get_cache()[\"live_streams\"] if match_fn(s.get(\"name\") or \"\")],\n                key=lambda x: x.get(\"name\", \"\").lower(),\n            )\n            results[\"live\"] = matched[:limit] if limit else matched\n\n        # Load VOD data (run in thread to avoid blocking)\n        if vod:\n            if \"vod_streams\" not in get_cache():\n                vod_cats, vod_streams = await asyncio.to_thread(load_vod_data)\n                with get_cache_lock():\n                    get_cache()[\"vod_categories\"] = vod_cats\n                    get_cache()[\"vod_streams\"] = vod_streams\n            matched = sorted(\n                [s for s in get_cache()[\"vod_streams\"] if match_fn(s.get(\"name\") or \"\")],\n                key=lambda x: x.get(\"name\", \"\").lower(),\n            )\n            results[\"vod\"] = matched[:limit] if limit else matched\n\n        # Load series data (run in thread to avoid blocking)\n        if series:\n            if \"series\" not in get_cache():\n                series_cats, series_list = await asyncio.to_thread(load_series_data)\n                with get_cache_lock():\n                    get_cache()[\"series_categories\"] = series_cats\n                    get_cache()[\"series\"] = series_list\n            matched = sorted(\n                [s for s in get_cache()[\"series\"] if match_fn(s.get(\"name\") or \"\")],\n                key=lambda x: x.get(\"name\", \"\").lower(),\n            )\n            results[\"series\"] = matched[:limit] if limit else matched\n\n    username = user.get(\"sub\", \"\")\n    user_settings = load_user_settings(username)\n\n    # Filter results based on user access\n    user_limits = auth.get_user_limits(username)\n    unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n\n    # Filter live results by category access\n    results[\"live\"] = [\n        s\n        for s in results[\"live\"]\n        if not any(\n            f\"cat:{cat_id}\" in unavailable_groups for cat_id in (s.get(\"category_ids\") or [])\n        )\n    ]\n    # Filter movie results by source access\n    results[\"vod\"] = [\n        s for s in results[\"vod\"] if f\"movies:{s.get('source_id', '')}\" not in unavailable_groups\n    ]\n    # Filter series results by source access\n    results[\"series\"] = [\n        s for s in results[\"series\"] if f\"series:{s.get('source_id', '')}\" not in unavailable_groups\n    ]\n\n    # Apply user's category filters from settings\n    guide_filter = user_settings.get(\"guide_filter\", [])\n    if guide_filter:\n        guide_filter_set = set(str(c) for c in guide_filter)\n        results[\"live\"] = [\n            s\n            for s in results[\"live\"]\n            if any(str(cat_id) in guide_filter_set for cat_id in (s.get(\"category_ids\") or []))\n        ]\n\n    vod_filter = user_settings.get(\"vod_filter\", [])\n    if vod_filter:\n        vod_filter_set = set(str(c) for c in vod_filter)\n        results[\"vod\"] = [s for s in results[\"vod\"] if str(s.get(\"category_id\")) in vod_filter_set]\n\n    series_filter = user_settings.get(\"series_filter\", [])\n    if series_filter:\n        series_filter_set = set(str(c) for c in series_filter)\n        results[\"series\"] = [\n            s for s in results[\"series\"] if str(s.get(\"category_id\")) in series_filter_set\n        ]\n\n    content_access = _get_content_access(username)\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"search.html\",\n        {\n            \"query\": q,\n            \"results\": results,\n            \"regex\": regex,\n            \"search_live\": live,\n            \"search_vod\": vod and content_access[\"movies\"],\n            \"search_series\": series and content_access[\"series\"],\n            \"limit\": limit,\n            \"favorites\": user_settings.get(\"favorites\", {\"series\": {}, \"movies\": {}}),\n            \"content_access\": content_access,\n        },\n    )\n\n\n@app.get(\"/stream/{stream_type}/{stream_id}\")\nasync def stream_redirect(\n    stream_type: str,\n    stream_id: int,\n    _user: Annotated[dict, Depends(require_auth)],\n    ext: str = \"\",\n):\n    xtream = get_first_xtream_client()\n    if not xtream:\n        raise HTTPException(404, \"No Xtream source configured\")\n    url = xtream.build_stream_url(stream_type, stream_id, ext)\n    return RedirectResponse(url, status_code=302)\n\n\n@app.get(\"/playlist.xspf\")\nasync def playlist_xspf(\n    _user: Annotated[dict, Depends(require_auth)],\n    url: str,\n):\n    content = f\"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<playlist xmlns=\"http://xspf.org/ns/0/\" version=\"1\">\n  <trackList><track><location>{xml_escape(url)}</location></track></trackList>\n</playlist>\"\"\"\n    return Response(\n        content=content,\n        media_type=\"application/xspf+xml\",\n        headers={\"Content-Disposition\": \"attachment; filename=stream.xspf\"},\n    )\n\n\n# =============================================================================\n# Transcoding routes (logic in ffmpeg_session.py)\n# =============================================================================\n\n\n@app.get(\"/transcode/start\")\nasync def transcode_start(\n    user: Annotated[dict, Depends(require_auth)],\n    url: str,\n    content_type: str = \"live\",  # \"movie\", \"series\", or \"live\"\n    series_id: int | None = None,\n    episode_id: int | None = None,\n    series_name: str = \"\",\n    deinterlace_fallback: str = \"1\",  # \"1\" or \"0\"\n    source_id: str = \"\",\n):\n    \"\"\"Start a transcode session, return session ID.\"\"\"\n    deinterlace_fb = deinterlace_fallback == \"1\"\n    username = user.get(\"sub\", \"\")\n\n    # Get user limits for this source\n    user_limits = auth.get_user_limits(username)\n    max_streams_per_source = user_limits.get(\"max_streams_per_source\", {})\n    user_max_streams = max_streams_per_source.get(source_id, 0) if source_id else 0\n\n    # Get source max_streams (global limit for this source)\n    source_max_streams = 0\n    if source_id:\n        settings = load_server_settings()\n        sources = settings.get(\"sources\", [])\n        source = next((s for s in sources if s.get(\"id\") == source_id), None)\n        if source:\n            source_max_streams = source.get(\"max_streams\", 0)\n\n    return await ffmpeg_session.start_transcode(\n        url,\n        content_type,\n        series_id,\n        episode_id,\n        series_name,\n        deinterlace_fb,\n        username,\n        source_id,\n        user_max_streams,\n        source_max_streams,\n    )\n\n\n@app.get(\"/transcode/seek/{session_id}\")\nasync def transcode_seek(\n    session_id: str,\n    time: float,\n    _user: Annotated[dict, Depends(require_auth)],\n):\n    \"\"\"Seek VOD transcode to a new position.\"\"\"\n    return await ffmpeg_session.seek_transcode(session_id, time)\n\n\n@app.get(\"/transcode/progress/{session_id}\")\nasync def transcode_progress(\n    session_id: str,\n    _user: Annotated[dict, Depends(require_auth)],\n):\n    \"\"\"Get transcode progress (segment count, duration).\"\"\"\n    progress = ffmpeg_session.get_session_progress(session_id)\n    if not progress:\n        raise HTTPException(404, \"Session not found\")\n    return progress\n\n\n@app.get(\"/transcode/{session_id}/{filename}\")\nasync def transcode_file(\n    request: Request,\n    session_id: str,\n    filename: str,\n):\n    \"\"\"Serve HLS playlist or segments (no auth - session IDs are unguessable).\"\"\"\n    # Prevent path traversal\n    safe_filename = pathlib.Path(filename).name\n    if safe_filename != filename or \"..\" in filename:\n        raise HTTPException(400, \"Invalid filename\")\n\n    session = ffmpeg_session.get_session(session_id)\n    if not session:\n        log.debug(f\"[CAST] 404 session not found: {session_id}\")\n        raise HTTPException(404, \"Transcode session not found\")\n\n    file_path = pathlib.Path(session[\"dir\"]) / safe_filename\n    if not file_path.exists():\n        log.debug(f\"[CAST] 404 file not found: {file_path}\")\n        raise HTTPException(404, \"File not found\")\n\n    # Log Chromecast requests\n    ua = request.headers.get(\"user-agent\", \"\")\n    if \"CrKey\" in ua or \"Chromecast\" in ua.lower() or \"cast\" in ua.lower():\n        log.debug(f\"[CAST] Chromecast request: {filename} UA={ua[:80]}\")\n\n    cors = {\"Access-Control-Allow-Origin\": \"*\"}\n    if filename.endswith(\".m3u8\"):\n        content = file_path.read_text()\n        return Response(\n            content=content,\n            media_type=\"application/vnd.apple.mpegurl\",\n            headers={\"Cache-Control\": \"no-cache, no-store, must-revalidate\", **cors},\n        )\n    if filename.endswith(\".vtt\"):\n        content = file_path.read_text()\n        return Response(content=content, media_type=\"text/vtt\", headers=cors)\n    return FileResponse(file_path, media_type=\"video/mp2t\", headers=cors)\n\n\n@app.get(\"/subs/{session_id}/{filename}\")\nasync def subtitle_file(session_id: str, filename: str):\n    \"\"\"Serve VTT subtitle files (no auth - session IDs are unguessable).\"\"\"\n    # Prevent path traversal\n    safe_filename = pathlib.Path(filename).name\n    if safe_filename != filename or \"..\" in filename:\n        raise HTTPException(400, \"Invalid filename\")\n    if not safe_filename.endswith(\".vtt\"):\n        raise HTTPException(400, \"Only VTT files allowed\")\n    session = ffmpeg_session.get_session(session_id)\n    if not session:\n        raise HTTPException(404, \"Session not found\")\n    file_path = pathlib.Path(session[\"dir\"]) / safe_filename\n    # Wait briefly for file, return empty VTT if not ready (client will poll again)\n    for _ in range(15):  # 3 seconds\n        if file_path.exists() and file_path.stat().st_size > 20:\n            break\n        await asyncio.sleep(0.2)\n    try:\n        content = file_path.read_text() if file_path.exists() else \"WEBVTT\\n\\n\"\n    except (UnicodeDecodeError, OSError):\n        # File may be partially written or corrupted\n        content = \"WEBVTT\\n\\n\"\n    return Response(\n        content=content,\n        media_type=\"text/vtt\",\n        headers={\"Access-Control-Allow-Origin\": \"*\"},\n    )\n\n\n@app.delete(\"/transcode/{session_id}\")\nasync def transcode_stop(\n    session_id: str,\n    _user: Annotated[dict, Depends(require_auth)],\n):\n    \"\"\"Stop a transcode session (VOD sessions stay cached).\"\"\"\n    ffmpeg_session.stop_session(session_id, force=False)\n    return {\"status\": \"stopped\"}\n\n\n@app.post(\"/transcode/{session_id}/stop\")\nasync def transcode_stop_post(\n    session_id: str,\n    _user: Annotated[dict, Depends(require_auth)],\n):\n    \"\"\"Stop a transcode session (POST for sendBeacon, VOD cached).\"\"\"\n    ffmpeg_session.stop_session(session_id, force=False)\n    return {\"status\": \"stopped\"}\n\n\n@app.delete(\"/transcode-clear\")\nasync def transcode_clear(\n    url: str,\n    _user: Annotated[dict, Depends(require_auth)],\n):\n    \"\"\"Force-delete any cached transcode session for a URL.\"\"\"\n    session_id = ffmpeg_session.clear_url_session(url)\n    if session_id:\n        ffmpeg_session.stop_session(session_id, force=True)\n        log.info(\"Force-cleared transcode session %s for URL\", session_id)\n    return {\"status\": \"cleared\", \"session_id\": session_id}\n\n\ndef _build_all_groups() -> list[dict[str, str]]:\n    \"\"\"Build list of all available groups for user restrictions.\n\n    Groups are:\n    - Live TV categories: cat:{category_id} displayed as \"{source.name}: {category.name}\"\n    - Movies: movies:{source_id} displayed as \"{source.name}: Movies\"\n    - Series: series:{source_id} displayed as \"{source.name}: Series\"\n    \"\"\"\n    groups = []\n    sources_by_id = {s.id: s for s in get_sources()}\n\n    # Live TV categories\n    for cat in get_cache().get(\"live_categories\", []):\n        source_id = cat.get(\"source_id\", \"\")\n        source = sources_by_id.get(source_id)\n        if source:\n            groups.append(\n                {\n                    \"id\": f\"cat:{cat['category_id']}\",\n                    \"name\": f\"{source.name}: {cat['category_name']}\",\n                    \"type\": \"live\",\n                }\n            )\n\n    # Movies and Series for Xtream sources\n    for source in get_sources():\n        if source.type == \"xtream\":\n            groups.append(\n                {\n                    \"id\": f\"movies:{source.id}\",\n                    \"name\": f\"{source.name}: Movies\",\n                    \"type\": \"movies\",\n                }\n            )\n            groups.append(\n                {\n                    \"id\": f\"series:{source.id}\",\n                    \"name\": f\"{source.name}: Series\",\n                    \"type\": \"series\",\n                }\n            )\n\n    return groups\n\n\ndef _get_content_access(username: str) -> dict[str, bool]:\n    \"\"\"Check if user has access to movies/series from any source.\n\n    Returns dict with 'movies' and 'series' booleans.\n    If no xtream sources exist, access is granted (nothing to restrict).\n    \"\"\"\n    user_limits = auth.get_user_limits(username)\n    unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n\n    has_movies = False\n    has_series = False\n    has_xtream_source = False\n\n    for source in get_sources():\n        if source.type == \"xtream\":\n            has_xtream_source = True\n            if f\"movies:{source.id}\" not in unavailable_groups:\n                has_movies = True\n            if f\"series:{source.id}\" not in unavailable_groups:\n                has_series = True\n\n    # If no xtream sources, allow access (nothing to restrict)\n    if not has_xtream_source:\n        return {\"movies\": True, \"series\": True}\n\n    return {\"movies\": has_movies, \"series\": has_series}\n\n\n# Register template global for content access check\nTEMPLATES.env.globals[\"get_content_access\"] = _get_content_access\n\n\ndef _get_content_access_from_request(request: Request) -> dict[str, bool]:\n    \"\"\"Get content access from request (for use in base template).\"\"\"\n    token = request.cookies.get(\"token\")\n    if not token:\n        return {\"movies\": True, \"series\": True}  # Not logged in, show all\n    payload = verify_token(token)\n    if not payload:\n        return {\"movies\": True, \"series\": True}\n    username = payload.get(\"sub\", \"\")\n    if not username:\n        return {\"movies\": True, \"series\": True}\n    return _get_content_access(username)\n\n\nTEMPLATES.env.globals[\"get_content_access_from_request\"] = _get_content_access_from_request\n\n\n@app.get(\"/settings\", response_class=HTMLResponse)\nasync def settings_page(request: Request, user: Annotated[dict, Depends(require_auth)]):\n    username = user.get(\"sub\", \"\")\n    is_admin = auth.is_admin(username)\n    server_settings = load_server_settings()\n    user_settings = load_user_settings(username)\n    # Load categories (from file cache or trigger background load)\n    if \"live_categories\" not in get_cache():\n        cached = await asyncio.to_thread(load_file_cache, \"live_data\")\n        if cached:\n            data, _ = cached\n            with get_cache_lock():\n                get_cache()[\"live_categories\"] = data[\"cats\"]\n                get_cache()[\"live_streams\"] = data[\"streams\"]\n                get_cache()[\"epg_urls\"] = parse_epg_urls(data.get(\"epg_urls\", []))\n        else:\n            # No cache - start background load\n            _start_guide_background_load()\n\n    # Load VOD categories if not cached\n    if \"vod_categories\" not in get_cache():\n        cached = await asyncio.to_thread(load_file_cache, \"vod_data\")\n        if cached:\n            data, _ = cached\n            with get_cache_lock():\n                get_cache()[\"vod_categories\"] = data[\"cats\"]\n                get_cache()[\"vod_streams\"] = data[\"streams\"]\n\n    # Load series categories if not cached\n    if \"series_categories\" not in get_cache():\n        cached = await asyncio.to_thread(load_file_cache, \"series_data\")\n        if cached:\n            data, _ = cached\n            with get_cache_lock():\n                get_cache()[\"series_categories\"] = data[\"cats\"]\n                get_cache()[\"series\"] = data[\"series\"]\n\n    # Build source_id -> source_name mapping\n    source_names = {s[\"id\"]: s[\"name\"] for s in server_settings.get(\"sources\", [])}\n\n    # Filter categories based on user's unavailable groups\n    user_limits = auth.get_user_limits(username)\n    unavailable_groups = set(user_limits.get(\"unavailable_groups\", []))\n    all_live_cats = get_cache().get(\"live_categories\", [])\n    live_categories = [\n        cat for cat in all_live_cats if f\"cat:{cat['category_id']}\" not in unavailable_groups\n    ]\n    vod_categories = get_cache().get(\"vod_categories\", [])\n    series_categories = get_cache().get(\"series_categories\", [])\n\n    return TEMPLATES.TemplateResponse(\n        request,\n        \"settings.html\",\n        {\n            # Server settings\n            \"sources\": server_settings.get(\"sources\", []),\n            \"transcode_mode\": server_settings.get(\"transcode_mode\", \"auto\"),\n            \"transcode_hw\": server_settings.get(\"transcode_hw\", \"nvidia\"),\n            \"max_resolution\": server_settings.get(\"max_resolution\", \"1080p\"),\n            \"quality\": server_settings.get(\"quality\", \"high\"),\n            \"vod_transcode_cache_mins\": server_settings.get(\"vod_transcode_cache_mins\", 60),\n            \"live_transcode_cache_secs\": server_settings.get(\"live_transcode_cache_secs\", 60),\n            \"live_dvr_mins\": server_settings.get(\"live_dvr_mins\", 0),\n            \"transcode_dir\": server_settings.get(\"transcode_dir\", \"\"),\n            \"probe_live\": server_settings.get(\"probe_live\", True),\n            \"probe_movies\": server_settings.get(\"probe_movies\", True),\n            \"probe_series\": server_settings.get(\"probe_series\", False),\n            \"user_agent_preset\": server_settings.get(\"user_agent_preset\", \"default\"),\n            \"user_agent_custom\": server_settings.get(\"user_agent_custom\", \"\"),\n            \"available_encoders\": AVAILABLE_ENCODERS,\n            \"sr_available\": is_sr_available(),\n            \"sr_models\": get_sr_models(),\n            \"sr_model\": server_settings.get(\"sr_model\", \"\"),\n            \"all_users\": auth.get_users_with_admin(),\n            \"all_groups\": _build_all_groups(),\n            \"current_user\": username,\n            \"is_admin\": is_admin,\n            # User settings\n            \"captions_enabled\": user_settings.get(\"captions_enabled\", False),\n            \"virtual_scroll\": user_settings.get(\"virtual_scroll\", True),\n            \"live_categories\": live_categories,\n            \"vod_categories\": vod_categories,\n            \"series_categories\": series_categories,\n            \"source_names\": source_names,\n            \"selected_cats\": user_settings.get(\"guide_filter\", []),\n            \"selected_vod_cats\": user_settings.get(\"vod_filter\", []),\n            \"selected_series_cats\": user_settings.get(\"series_filter\", []),\n            \"cc_lang\": user_settings.get(\"cc_lang\", \"\"),\n            \"cc_style\": user_settings.get(\"cc_style\", {}),\n            \"cast_host\": user_settings.get(\"cast_host\", \"\"),\n            \"content_access\": _get_content_access(username),\n        },\n    )\n\n\n@app.post(\"/settings/guide-filter\")\nasync def settings_guide_filter(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n):\n    username = user.get(\"sub\", \"\")\n    data = await request.json()\n    cats = data.get(\"cats\", [])\n    if not isinstance(cats, list) or len(cats) > _MAX_FILTER_CATEGORIES:\n        raise HTTPException(400, \"Invalid filter list\")\n    user_settings = load_user_settings(username)\n    user_settings[\"guide_filter\"] = cats\n    save_user_settings(username, user_settings)\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/settings/vod-filter\")\nasync def settings_vod_filter(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n):\n    username = user.get(\"sub\", \"\")\n    data = await request.json()\n    cats = data.get(\"cats\", [])\n    if not isinstance(cats, list) or len(cats) > _MAX_FILTER_CATEGORIES:\n        raise HTTPException(400, \"Invalid filter list\")\n    user_settings = load_user_settings(username)\n    user_settings[\"vod_filter\"] = cats\n    save_user_settings(username, user_settings)\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/settings/series-filter\")\nasync def settings_series_filter(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n):\n    username = user.get(\"sub\", \"\")\n    data = await request.json()\n    cats = data.get(\"cats\", [])\n    if not isinstance(cats, list) or len(cats) > _MAX_FILTER_CATEGORIES:\n        raise HTTPException(400, \"Invalid filter list\")\n    user_settings = load_user_settings(username)\n    user_settings[\"series_filter\"] = cats\n    save_user_settings(username, user_settings)\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/settings/add\")\nasync def settings_add_source(\n    _user: Annotated[dict, Depends(require_admin)],\n    name: Annotated[str, Form()],\n    source_type: Annotated[str, Form()],\n    url: Annotated[str, Form()],\n    username: Annotated[str, Form()] = \"\",\n    password: Annotated[str, Form()] = \"\",\n    epg_timeout: Annotated[int, Form()] = 120,\n    epg_schedule: Annotated[str, Form()] = \"\",\n    epg_enabled: Annotated[str, Form()] = \"\",  # Checkbox: \"on\" if checked\n    deinterlace_fallback: Annotated[str, Form()] = \"\",  # Checkbox: \"on\" if checked\n    max_streams: Annotated[int, Form()] = 0,\n):\n    # Validate inputs\n    if not name or not name.strip():\n        raise HTTPException(400, \"Name is required\")\n    if source_type not in (\"xtream\", \"m3u\", \"epg\"):\n        raise HTTPException(400, \"Invalid source type\")\n    parsed_url = urllib.parse.urlparse(url)\n    if parsed_url.scheme not in (\"http\", \"https\"):\n        raise HTTPException(400, \"URL must use http or https\")\n    if len(name) > 200:\n        raise HTTPException(400, \"Name too long\")\n\n    # Parse schedule times\n    schedule_list = []\n    for t in epg_schedule.split(\",\"):\n        t = t.strip()\n        if t and re.match(r\"^\\d{1,2}:\\d{2}$\", t):\n            schedule_list.append(t.zfill(5))\n\n    settings = load_server_settings()\n    sources = settings.get(\"sources\", [])\n    source_id = f\"src_{int(time.time())}_{len(sources)}\"\n    sources.append(\n        {\n            \"id\": source_id,\n            \"name\": name,\n            \"type\": source_type,\n            \"url\": url.rstrip(\"/\"),\n            \"username\": username,\n            \"password\": password,\n            \"epg_timeout\": max(1, min(3600, epg_timeout)),\n            \"epg_schedule\": schedule_list,\n            \"epg_enabled\": epg_enabled == \"on\" or source_type == \"epg\",\n            \"deinterlace_fallback\": deinterlace_fallback == \"on\",\n            \"max_streams\": max(0, max_streams),\n        }\n    )\n    settings[\"sources\"] = sources\n    save_server_settings(settings)\n    clear_all_caches()\n    return RedirectResponse(\"/settings\", status_code=303)\n\n\n@app.post(\"/settings/edit/{source_id}\")\nasync def settings_edit_source(\n    source_id: str,\n    _user: Annotated[dict, Depends(require_admin)],\n    name: Annotated[str, Form()],\n    source_type: Annotated[str, Form()],\n    url: Annotated[str, Form()],\n    username: Annotated[str, Form()] = \"\",\n    password: Annotated[str, Form()] = \"\",\n    epg_timeout: Annotated[int, Form()] = 120,\n    epg_schedule: Annotated[str, Form()] = \"\",\n    epg_enabled: Annotated[str, Form()] = \"\",  # Checkbox: \"on\" if checked\n    epg_url: Annotated[str, Form()] = \"\",\n    deinterlace_fallback: Annotated[str, Form()] = \"\",  # Checkbox: \"on\" if checked\n    max_streams: Annotated[int, Form()] = 0,\n):\n    # Validate inputs\n    if not name or not name.strip():\n        raise HTTPException(400, \"Name is required\")\n    if source_type not in (\"xtream\", \"m3u\", \"epg\"):\n        raise HTTPException(400, \"Invalid source type\")\n    parsed_url = urllib.parse.urlparse(url)\n    if parsed_url.scheme not in (\"http\", \"https\"):\n        raise HTTPException(400, \"URL must use http or https\")\n    if len(name) > 200:\n        raise HTTPException(400, \"Name too long\")\n\n    # Parse schedule times (comma-separated HH:MM)\n    schedule_list = []\n    for t in epg_schedule.split(\",\"):\n        t = t.strip()\n        if t and re.match(r\"^\\d{1,2}:\\d{2}$\", t):\n            schedule_list.append(t.zfill(5))  # Normalize to HH:MM\n\n    settings = load_server_settings()\n    for s in settings.get(\"sources\", []):\n        if s[\"id\"] == source_id:\n            s[\"name\"] = name\n            s[\"type\"] = source_type\n            s[\"url\"] = url.rstrip(\"/\")\n            s[\"username\"] = username\n            s[\"password\"] = password\n            s[\"epg_timeout\"] = max(1, min(3600, epg_timeout))\n            s[\"epg_schedule\"] = schedule_list\n            s[\"epg_enabled\"] = epg_enabled == \"on\" or source_type == \"epg\"\n            s[\"epg_url\"] = epg_url.strip()\n            s[\"deinterlace_fallback\"] = deinterlace_fallback == \"on\"\n            s[\"max_streams\"] = max(0, max_streams)\n            break\n    save_server_settings(settings)\n    clear_all_caches()\n    return {\"ok\": True}\n\n\n@app.post(\"/settings/delete/{source_id}\")\nasync def settings_delete_source(\n    source_id: str,\n    _user: Annotated[dict, Depends(require_admin)],\n):\n    settings = load_server_settings()\n    settings[\"sources\"] = [s for s in settings.get(\"sources\", []) if s[\"id\"] != source_id]\n    save_server_settings(settings)\n    # Clear all caches including EPG data for this source\n    epg.clear_source(source_id)\n    clear_all_file_caches()\n    return RedirectResponse(\"/settings\", status_code=303)\n\n\n@app.get(\"/guide/refresh\")\nasync def guide_refresh(_user: Annotated[dict, Depends(require_auth)]):\n    \"\"\"Refresh guide data in background (stale-while-revalidate).\"\"\"\n\n    def refresh_live():\n        try:\n            log.info(\"Live refresh: fetching channels\")\n            cats, streams, epg_urls = load_all_live_data()\n            with get_cache_lock():\n                get_cache()[\"live_categories\"] = cats\n                get_cache()[\"live_streams\"] = streams\n                get_cache()[\"epg_urls\"] = epg_urls\n            save_file_cache(\"live_data\", {\"cats\": cats, \"streams\": streams, \"epg_urls\": epg_urls})\n            log.info(\"Live refresh: complete (%d categories, %d streams)\", len(cats), len(streams))\n        except Exception as e:\n            log.error(\"Live refresh failed: %s\", e)\n        finally:\n            get_refresh_in_progress().discard(\"live_refresh\")\n\n    def refresh_epg():\n        try:\n            epg_urls = get_cache().get(\"epg_urls\", [])\n            if epg_urls:\n                log.info(\"EPG refresh: fetching %d sources\", len(epg_urls))\n                epg.clear()\n                count = _fetch_all_epg(epg_urls)\n                with get_cache_lock():\n                    get_cache().pop(\"epg_error\", None)\n                log.info(\"EPG refresh: complete (%d programs)\", count)\n            else:\n                log.warning(\"EPG refresh: no EPG URLs available\")\n        except Exception as e:\n            log.error(\"EPG refresh failed: %s\", e)\n            with get_cache_lock():\n                get_cache()[\"epg_error\"] = str(e)\n        finally:\n            get_refresh_in_progress().discard(\"epg_refresh\")\n\n    # Set flags before starting threads to avoid race with status polling\n    if \"live_refresh\" not in get_refresh_in_progress():\n        get_refresh_in_progress().add(\"live_refresh\")\n        threading.Thread(target=refresh_live, daemon=True).start()\n    if \"epg_refresh\" not in get_refresh_in_progress():\n        get_refresh_in_progress().add(\"epg_refresh\")\n        threading.Thread(target=refresh_epg, daemon=True).start()\n    return RedirectResponse(\"/guide?refreshing=1\", status_code=303)\n\n\n@app.get(\"/guide/refresh-status\")\nasync def guide_refresh_status(_user: Annotated[dict, Depends(require_auth)]):\n    \"\"\"Return refresh status for polling.\"\"\"\n    return {\n        \"live\": \"live_refresh\" in get_refresh_in_progress(),\n        \"epg\": \"epg_refresh\" in get_refresh_in_progress(),\n    }\n\n\n@app.post(\"/settings/refresh/{source_id}/{refresh_type}\")\nasync def settings_refresh_source(\n    source_id: str,\n    refresh_type: str,\n    _user: Annotated[dict, Depends(require_admin)],\n):\n    \"\"\"Refresh a specific data type for a single source.\"\"\"\n    sources = get_sources()\n    source = next((s for s in sources if s.id == source_id), None)\n    if not source:\n        return {\"error\": \"Source not found\"}\n\n    key = f\"{source_id}_{refresh_type}\"\n    if key in get_refresh_in_progress():\n        return {\"status\": \"already_running\"}\n\n    get_refresh_in_progress().add(key)\n\n    def do_refresh():\n        try:\n            if refresh_type == \"live\":\n                log.info(\"Refreshing live data for source: %s\", source.name)\n                cats, streams, epg_url, timeout = fetch_source_live_data(source)\n                # Update cache by replacing this source's data\n                with get_cache_lock():\n                    existing_cats = [\n                        c\n                        for c in get_cache().get(\"live_categories\", [])\n                        if c.get(\"source_id\") != source_id\n                    ]\n                    existing_streams = [\n                        s\n                        for s in get_cache().get(\"live_streams\", [])\n                        if s.get(\"source_id\") != source_id\n                    ]\n                    existing_epg = [e for e in get_cache().get(\"epg_urls\", []) if e[2] != source_id]\n                    new_cats = existing_cats + cats\n                    new_streams = existing_streams + streams\n                    new_epg = existing_epg + ([(epg_url, timeout, source_id)] if epg_url else [])\n                    get_cache()[\"live_categories\"] = new_cats\n                    get_cache()[\"live_streams\"] = new_streams\n                    get_cache()[\"epg_urls\"] = new_epg\n                # Save to file cache\n                save_file_cache(\n                    \"live_data\",\n                    {\"cats\": new_cats, \"streams\": new_streams, \"epg_urls\": new_epg},\n                )\n                log.info(\n                    \"Live refresh complete for %s: %d cats, %d streams\",\n                    source.name,\n                    len(cats),\n                    len(streams),\n                )\n\n            elif refresh_type == \"epg\":\n                log.info(\n                    \"Refreshing EPG for source: %s (timeout=%ds)\", source.name, source.epg_timeout\n                )\n                epg_url = source.epg_url or (source.url if source.type == \"epg\" else \"\")\n                if epg_url:\n                    epg.clear_source(source_id)\n                    count = _fetch_all_epg([(epg_url, source.epg_timeout, source_id)])\n                    log.info(\"EPG refresh complete for %s: %d programs\", source.name, count)\n                else:\n                    log.warning(\"No EPG URL for source: %s\", source.name)\n\n            elif refresh_type == \"vod\" and source.type == \"xtream\":\n                log.info(\"Refreshing VOD for source: %s\", source.name)\n                new_cats, new_streams = fetch_source_vod_data(source)\n                # Merge with existing data from other sources\n                existing_cats, existing_streams = load_vod_data()\n                # Remove old data from this source, keep others\n                merged_cats = [c for c in existing_cats if c.get(\"source_id\") != source_id]\n                merged_streams = [s for s in existing_streams if s.get(\"source_id\") != source_id]\n                # Add new data from this source\n                merged_cats.extend(new_cats)\n                merged_streams.extend(new_streams)\n                with get_cache_lock():\n                    get_cache().pop(\"vod_categories\", None)\n                    get_cache().pop(\"vod_streams\", None)\n                for f in CACHE_DIR.glob(\"vod_data*.json\"):\n                    f.unlink(missing_ok=True)\n                save_file_cache(\"vod_data\", {\"cats\": merged_cats, \"streams\": merged_streams})\n                log.info(\n                    \"VOD refresh complete for %s: %d cats, %d streams (total: %d)\",\n                    source.name,\n                    len(new_cats),\n                    len(new_streams),\n                    len(merged_streams),\n                )\n\n            elif refresh_type == \"m3u\" and source.type == \"m3u\":\n                log.info(\"Refreshing M3U playlist for source: %s\", source.name)\n                cats, streams, detected_epg_url = fetch_m3u(source.url, source.id)\n                update_source_epg_url(source_id, detected_epg_url)\n                with get_cache_lock():\n                    existing_cats = [\n                        c\n                        for c in get_cache().get(\"live_categories\", [])\n                        if c.get(\"source_id\") != source_id\n                    ]\n                    existing_streams = [\n                        s\n                        for s in get_cache().get(\"live_streams\", [])\n                        if s.get(\"source_id\") != source_id\n                    ]\n                    new_cats = existing_cats + cats\n                    new_streams = existing_streams + streams\n                    get_cache()[\"live_categories\"] = new_cats\n                    get_cache()[\"live_streams\"] = new_streams\n                    epg_urls = get_cache().get(\"epg_urls\", [])\n                save_file_cache(\n                    \"live_data\",\n                    {\n                        \"cats\": new_cats,\n                        \"streams\": new_streams,\n                        \"epg_urls\": epg_urls,\n                    },\n                )\n                log.info(\n                    \"M3U refresh complete for %s: %d cats, %d streams\",\n                    source.name,\n                    len(cats),\n                    len(streams),\n                )\n\n        except Exception as e:\n            log.error(\"Source refresh failed (%s/%s): %s\", source.name, refresh_type, e)\n        finally:\n            get_refresh_in_progress().discard(key)\n\n    threading.Thread(target=do_refresh, daemon=True).start()\n    return {\"status\": \"started\", \"key\": key}\n\n\n@app.get(\"/settings/refresh-status\")\nasync def settings_refresh_status(_user: Annotated[dict, Depends(require_auth)]):\n    \"\"\"Return per-source refresh status.\"\"\"\n    statuses: dict[str, Any] = {}\n    for key in list(get_refresh_in_progress()):\n        if \"_\" in key:\n            # Format: source_id_type (e.g., \"ota_epg\" or \"src_123_epg\")\n            parts = key.rsplit(\"_\", 1)\n            if len(parts) == 2:\n                source_id, rtype = parts\n                statuses.setdefault(source_id, {})[rtype] = True\n    # Report global guide_load as affecting all sources\n    if \"guide_load\" in get_refresh_in_progress():\n        statuses[\"_global\"] = {\"live\": True, \"epg\": True}\n    return statuses\n\n\n@app.post(\"/settings/captions\")\nasync def settings_captions(\n    user: Annotated[dict, Depends(require_auth)],\n    enabled: Annotated[str, Form()] = \"\",\n):\n    username = user.get(\"sub\", \"\")\n    user_settings = load_user_settings(username)\n    user_settings[\"captions_enabled\"] = enabled == \"on\"\n    save_user_settings(username, user_settings)\n    return {\"ok\": True}\n\n\n@app.post(\"/api/cast-log\")\nasync def cast_log_endpoint(request: Request):\n    \"\"\"Log cast events from client (debug mode only).\"\"\"\n    if log.isEnabledFor(logging.DEBUG):\n        body = await request.body()\n        # Sanitize: limit length, single line, printable chars only\n        msg = body.decode(\"utf-8\", errors=\"replace\")[:2048]\n        msg = \"\".join(c if c.isprintable() and c != \"\\n\" else \"?\" for c in msg)\n        log.debug(f\"[CAST] {msg}\")\n    return {\"ok\": True}\n\n\n@app.get(\"/api/user-prefs\")\nasync def get_user_prefs(user: Annotated[dict, Depends(require_auth)]):\n    \"\"\"Get user preferences (favorites, cc_lang, cc_style, cast_host, virtual_scroll).\"\"\"\n    username = user.get(\"sub\", \"\")\n    settings = load_user_settings(username)\n    return {\n        \"favorites\": settings.get(\"favorites\", {}),\n        \"cc_lang\": settings.get(\"cc_lang\", \"\"),\n        \"cc_style\": settings.get(\"cc_style\", {}),\n        \"cast_host\": settings.get(\"cast_host\", \"\"),\n        \"virtual_scroll\": settings.get(\"virtual_scroll\", True),\n    }\n\n\n@app.post(\"/api/user-prefs\")\nasync def save_user_prefs(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n):\n    \"\"\"Save user preferences (partial update).\"\"\"\n    username = user.get(\"sub\", \"\")\n    body = await request.body()\n    if len(body) > 64 * 1024:  # 64KB limit\n        raise HTTPException(400, \"Request too large\")\n    data = json.loads(body)\n    settings = load_user_settings(username)\n    for key in (\n        \"favorites\",\n        \"cc_lang\",\n        \"cc_style\",\n        \"cast_host\",\n        \"virtual_scroll\",\n        \"guide_selected_cats\",\n    ):\n        if key in data:\n            settings[key] = data[key]\n    save_user_settings(username, settings)\n    return {\"ok\": True}\n\n\ndef _fetch_logo(url: str, timeout: int = 10) -> tuple[bytes, str]:\n    \"\"\"Fetch logo synchronously. Returns (data, content_type).\"\"\"\n    from util import safe_urlopen\n\n    with safe_urlopen(url, timeout=timeout) as resp:\n        content_type = resp.headers.get(\"Content-Type\", \"\")\n        if not content_type.startswith(\"image/\"):\n            raise ValueError(\"URL is not an image\")\n        data = resp.read(LOGO_MAX_SIZE)\n        if len(data) >= LOGO_MAX_SIZE:\n            raise ValueError(\"Image too large\")\n    return data, content_type\n\n\n@app.get(\"/api/logo\")\nasync def get_logo(\n    url: str,\n    _user: Annotated[dict, Depends(require_auth)],\n    source: str = \"default\",\n):\n    \"\"\"Proxy and cache external logos to avoid mixed-content issues.\"\"\"\n    if not url:\n        raise HTTPException(400, \"Missing url parameter\")\n    # Check cache first\n    cached = get_cached_logo(source, url)\n    if cached:\n        return FileResponse(cached, headers={\"Cache-Control\": f\"max-age={LOGO_BROWSER_TTL}\"})\n    # Validate URL scheme\n    parsed = urllib.parse.urlparse(url)\n    if parsed.scheme not in (\"http\", \"https\"):\n        raise HTTPException(400, \"Invalid URL scheme\")\n    # Fetch the logo (in thread to avoid blocking)\n    try:\n        data, content_type = await asyncio.to_thread(_fetch_logo, url)\n        path = save_logo(source, url, data, content_type)\n        return FileResponse(path, headers={\"Cache-Control\": f\"max-age={LOGO_BROWSER_TTL}\"})\n    except ValueError as e:\n        raise HTTPException(400, str(e)) from None\n    except urllib.error.URLError as e:\n        log.debug(\"Logo fetch failed for %s: %s\", url, e)\n        raise HTTPException(502, \"Failed to fetch logo\") from None\n    except Exception as e:\n        log.debug(\"Logo fetch error for %s: %s\", url, e)\n        raise HTTPException(500, \"Logo fetch error\") from None\n\n\n@app.post(\"/settings/transcode\")\nasync def settings_transcode(\n    _user: Annotated[dict, Depends(require_admin)],\n    transcode_mode: Annotated[str, Form()],\n    transcode_hw: Annotated[str, Form()],\n    max_resolution: Annotated[str, Form()] = \"1080p\",\n    quality: Annotated[str, Form()] = \"high\",\n    vod_transcode_cache_mins: Annotated[int, Form()] = 60,\n    live_transcode_cache_secs: Annotated[int, Form()] = 0,\n    live_dvr_mins: Annotated[int, Form()] = 0,\n    transcode_dir: Annotated[str, Form()] = \"\",\n    probe_live: Annotated[str | None, Form()] = None,\n    probe_movies: Annotated[str | None, Form()] = None,\n    probe_series: Annotated[str | None, Form()] = None,\n    sr_model: Annotated[str, Form()] = \"\",\n):\n    settings = load_server_settings()\n    settings[\"transcode_mode\"] = transcode_mode\n    # Validate sr_model against available models\n    available_models = get_sr_models()\n    settings[\"sr_model\"] = sr_model if sr_model in available_models else \"\"\n    settings[\"transcode_hw\"] = transcode_hw\n    settings[\"max_resolution\"] = max_resolution\n    settings[\"quality\"] = quality if quality in (\"high\", \"medium\", \"low\") else \"high\"\n    settings[\"vod_transcode_cache_mins\"] = max(0, vod_transcode_cache_mins)\n    settings[\"live_transcode_cache_secs\"] = max(0, live_transcode_cache_secs)\n    settings[\"live_dvr_mins\"] = max(0, live_dvr_mins)\n    if transcode_dir:\n        settings[\"transcode_dir\"] = transcode_dir\n    elif \"transcode_dir\" in settings:\n        del settings[\"transcode_dir\"]  # Use default\n    settings[\"probe_live\"] = probe_live == \"on\"\n    settings[\"probe_movies\"] = probe_movies == \"on\"\n    settings[\"probe_series\"] = probe_series == \"on\"\n    save_server_settings(settings)\n    return {\"ok\": True}\n\n\n@app.post(\"/settings/refresh-encoders\")\nasync def settings_refresh_encoders(\n    _user: Annotated[dict, Depends(require_admin)],\n):\n    \"\"\"Re-detect available hardware encoders.\"\"\"\n    encoders = refresh_encoders()\n    return {\"ok\": True, \"encoders\": encoders}\n\n\n@app.post(\"/settings/user-agent\")\nasync def settings_user_agent(\n    _user: Annotated[dict, Depends(require_admin)],\n    preset: Annotated[str, Form()],\n    custom: Annotated[str, Form()] = \"\",\n):\n    valid_presets = {\"default\", \"vlc\", \"chrome\", \"tivimate\", \"custom\"}\n    if preset not in valid_presets:\n        preset = \"default\"\n    settings = load_server_settings()\n    settings[\"user_agent_preset\"] = preset\n    settings[\"user_agent_custom\"] = custom\n    save_server_settings(settings)\n    return {\"ok\": True}\n\n\ndef _enrich_probe_cache_stats(stats: list[dict], xtream: Any) -> list[dict]:\n    \"\"\"Enrich probe cache stats with series/episode names (blocking).\"\"\"\n    for entry in stats:\n        series: dict | None = None\n        if not entry.get(\"name\") or entry.get(\"episodes\"):\n            cache_key = f\"series_info_{entry['series_id']}\"\n            with contextlib.suppress(Exception):\n                series = get_cached_info(\n                    cache_key, lambda sid=entry[\"series_id\"]: xtream.get_series_info(sid)\n                )\n        if series:\n            if not entry.get(\"name\") and series.get(\"info\"):\n                entry[\"name\"] = series[\"info\"].get(\"name\", \"\")\n            ep_map: dict[int, str] = {}\n            for season_num, eps in (series.get(\"episodes\") or {}).items():\n                for ep in eps:\n                    eid = ep.get(\"id\")\n                    if eid:\n                        ep_num = ep.get(\"episode_num\", 0)\n                        title = ep.get(\"title\", \"\")\n                        title = re.sub(r\"^S\\d+E\\d+\\s*-\\s*\", \"\", title)\n                        if \" - \" in title:\n                            title = title.split(\" - \")[-1]\n                        ep_map[int(eid)] = (\n                            f\"S{int(season_num):02d}E{int(ep_num):02d} {title.strip()}\"\n                        )\n            for ep in entry.get(\"episodes\", []):\n                ep_id = ep.get(\"episode_id\")\n                if ep_id in ep_map:\n                    ep[\"name\"] = ep_map[ep_id]\n    return stats\n\n\n@app.get(\"/settings/probe-cache\")\nasync def get_probe_cache(\n    _user: Annotated[dict, Depends(require_auth)],\n    response: Response,\n):\n    \"\"\"Get probe cache stats for settings UI.\"\"\"\n    response.headers[\"Cache-Control\"] = \"no-cache, no-store, must-revalidate\"\n    stats = ffmpeg_command.get_series_probe_cache_stats()\n    xtream = get_first_xtream_client()\n    if not xtream:\n        return {\"series\": stats}\n    stats = await asyncio.to_thread(_enrich_probe_cache_stats, stats, xtream)\n    return {\"series\": stats}\n\n\n@app.post(\"/settings/probe-cache/clear\")\nasync def clear_probe_cache(_user: Annotated[dict, Depends(require_admin)]):\n    \"\"\"Clear all probe caches.\"\"\"\n    count = ffmpeg_command.clear_all_probe_cache()\n    return {\"ok\": True, \"cleared\": count}\n\n\n@app.post(\"/settings/probe-cache/clear/{series_id}\")\nasync def clear_series_probe_cache(\n    series_id: int,\n    _user: Annotated[dict, Depends(require_admin)],\n    episode_id: int | None = None,\n):\n    \"\"\"Clear probe cache for a specific series or episode.\"\"\"\n    ffmpeg_command.invalidate_series_probe_cache(series_id, episode_id)\n    return {\"ok\": True}\n\n\n@app.post(\"/settings/probe-cache/clear-mru/{series_id}\")\nasync def clear_series_mru(\n    series_id: int,\n    _user: Annotated[dict, Depends(require_admin)],\n):\n    \"\"\"Clear only the MRU for a series, keeping episode cache intact.\"\"\"\n    ffmpeg_command.clear_series_mru(series_id)\n    return {\"ok\": True}\n\n\n@app.post(\"/settings/data-cache/clear\")\nasync def clear_data_cache(_user: Annotated[dict, Depends(require_admin)]):\n    \"\"\"Clear all data file caches (live, VOD, series) and memory cache.\"\"\"\n    count = clear_all_file_caches()\n    return {\"ok\": True, \"cleared\": count}\n\n\n@app.get(\"/api/settings\")\nasync def get_settings_api(_user: Annotated[dict, Depends(require_auth)]):\n    return load_server_settings()\n\n\n@app.post(\"/api/settings\")\nasync def update_settings_api(\n    request: Request,\n    _user: Annotated[dict, Depends(require_admin)],\n):\n    data = await request.json()\n    # Whitelist allowed keys - never allow users/secret_key to be overwritten\n    allowed_keys = {\n        \"transcode_mode\",\n        \"transcode_hw\",\n        \"vod_transcode_cache_mins\",\n        \"probe_live\",\n        \"probe_movies\",\n        \"probe_series\",\n        \"vod_order\",\n        \"series_order\",\n    }\n    settings = load_server_settings()\n    for key in allowed_keys:\n        if key in data:\n            settings[key] = data[key]\n    save_server_settings(settings)\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/api/watch-position\")\nasync def save_watch_position_api(\n    request: Request,\n    user: Annotated[dict, Depends(require_auth)],\n):\n    \"\"\"Save watch position for a stream (per-user).\"\"\"\n    username = user.get(\"sub\", \"\")\n    data = await request.json()\n    url = data.get(\"url\", \"\")\n    position = float(data.get(\"position\", 0))\n    duration = float(data.get(\"duration\", 0))\n    if url and position >= 0:\n        save_watch_position(username, url, position, duration)\n    return {\"status\": \"ok\"}\n\n\n@app.get(\"/api/watch-position\")\nasync def get_watch_position_api(\n    user: Annotated[dict, Depends(require_auth)],\n    url: str,\n):\n    \"\"\"Get watch position for a stream (per-user).\"\"\"\n    username = user.get(\"sub\", \"\")\n    entry = get_watch_position(username, url)\n    if entry:\n        return {\"position\": entry.get(\"position\", 0), \"duration\": entry.get(\"duration\", 0)}\n    return {\"position\": 0, \"duration\": 0}\n\n\n# User management endpoints\n@app.post(\"/settings/users/delete/{username}\")\nasync def settings_delete_user(\n    username: str,\n    user: Annotated[dict, Depends(require_auth)],\n    password: Annotated[str, Form()] = \"\",\n):\n    \"\"\"Delete a user. Self-deletion requires password. Other users require admin.\"\"\"\n    current_user = user.get(\"sub\", \"\")\n    if username == current_user:\n        if not password or not auth.verify_password(username, password):\n            raise HTTPException(400, \"Password required to delete your own account\")\n        auth.delete_user(username)\n        response = RedirectResponse(\"/login\", status_code=303)\n        response.delete_cookie(\"token\")\n        return response\n    # Deleting other users requires admin\n    if not auth.is_admin(current_user):\n        raise HTTPException(403, \"Admin access required\")\n    if not auth.delete_user(username):\n        raise HTTPException(404, \"User not found\")\n    return RedirectResponse(\"/settings\", status_code=303)\n\n\n@app.post(\"/settings/users/add\")\nasync def settings_add_user(\n    _user: Annotated[dict, Depends(require_admin)],\n    username: Annotated[str, Form()],\n    password: Annotated[str, Form()],\n    admin: Annotated[str, Form()] = \"\",\n    max_streams_per_source: Annotated[str | None, Form()] = None,\n    unavailable_groups: Annotated[str | None, Form()] = None,\n):\n    \"\"\"Add a new user.\"\"\"\n    username = username.strip()\n    if not username or len(username) < 2:\n        raise HTTPException(400, \"Username must be at least 2 characters\")\n    if len(password) < 8:\n        raise HTTPException(400, \"Password must be at least 8 characters\")\n    if username in auth.get_all_usernames():\n        raise HTTPException(400, \"User already exists\")\n    auth.create_user(username, password, admin=admin == \"on\")\n    # Apply limits if provided\n    parsed_max_streams = None\n    if max_streams_per_source:\n        with contextlib.suppress(json.JSONDecodeError):\n            parsed_max_streams = json.loads(max_streams_per_source)\n    parsed_unavailable = None\n    if unavailable_groups:\n        with contextlib.suppress(json.JSONDecodeError):\n            parsed_unavailable = json.loads(unavailable_groups)\n    if parsed_max_streams or parsed_unavailable:\n        auth.set_user_limits(username, parsed_max_streams, parsed_unavailable)\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/settings/users/password\")\nasync def settings_change_own_password(\n    user: Annotated[dict, Depends(require_auth)],\n    current_password: Annotated[str, Form()],\n    new_password: Annotated[str, Form()],\n):\n    \"\"\"Change own password. Requires current password verification.\"\"\"\n    username = user.get(\"sub\", \"\")\n    if not auth.verify_password(username, current_password):\n        raise HTTPException(400, \"Current password is incorrect\")\n    if len(new_password) < 8:\n        raise HTTPException(400, \"Password must be at least 8 characters\")\n    if not auth.change_password(username, new_password):\n        raise HTTPException(404, \"User not found\")\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/settings/users/password/{target_user}\")\nasync def settings_change_password(\n    target_user: str,\n    user: Annotated[dict, Depends(require_auth)],\n    new_password: Annotated[str, Form()],\n):\n    \"\"\"Change a user's password. Own password or admin required.\"\"\"\n    current_user = user.get(\"sub\", \"\")\n    if target_user != current_user and not auth.is_admin(current_user):\n        raise HTTPException(403, \"Admin access required\")\n    if len(new_password) < 8:\n        raise HTTPException(400, \"Password must be at least 8 characters\")\n    if not auth.change_password(target_user, new_password):\n        raise HTTPException(404, \"User not found\")\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/settings/users/admin/{target_user}\")\nasync def settings_set_admin(\n    target_user: str,\n    _user: Annotated[dict, Depends(require_admin)],\n    admin: Annotated[str, Form()] = \"\",\n):\n    \"\"\"Set admin status for a user.\"\"\"\n    if not auth.set_admin(target_user, admin == \"on\"):\n        raise HTTPException(404, \"User not found\")\n    return {\"status\": \"ok\"}\n\n\n@app.post(\"/settings/users/limits/{target_user}\")\nasync def settings_set_user_limits(\n    target_user: str,\n    _user: Annotated[dict, Depends(require_admin)],\n    max_streams_per_source: Annotated[str | None, Form()] = None,  # JSON object string\n    unavailable_groups: Annotated[str | None, Form()] = None,  # JSON array string\n):\n    \"\"\"Set stream limits and group restrictions for a user.\"\"\"\n    parsed_max_streams = None\n    if max_streams_per_source is not None:\n        try:\n            parsed_max_streams = json.loads(max_streams_per_source)\n            if not isinstance(parsed_max_streams, dict):\n                raise HTTPException(400, \"max_streams_per_source must be a JSON object\")\n        except json.JSONDecodeError as err:\n            raise HTTPException(400, \"Invalid JSON for max_streams_per_source\") from err\n\n    parsed_unavailable = None\n    if unavailable_groups is not None:\n        try:\n            parsed_unavailable = json.loads(unavailable_groups)\n            if not isinstance(parsed_unavailable, list):\n                raise HTTPException(400, \"unavailable_groups must be a JSON array\")\n        except json.JSONDecodeError as err:\n            raise HTTPException(400, \"Invalid JSON for unavailable_groups\") from err\n\n    if not auth.set_user_limits(target_user, parsed_max_streams, parsed_unavailable):\n        raise HTTPException(404, \"User not found\")\n    return {\"status\": \"ok\"}\n\n\nif __name__ == \"__main__\":\n    import argparse\n\n    import uvicorn  # pyright: ignore[reportMissingImports]\n\n    parser = argparse.ArgumentParser(description=\"IPTV Web App\")\n    parser.add_argument(\"--port\", type=int, default=8000, help=\"Port to listen on\")\n    parser.add_argument(\"--debug\", action=\"store_true\", help=\"Enable debug logging\")\n    parser.add_argument(\n        \"--https\",\n        nargs=\"?\",\n        const=\"\",\n        metavar=\"DOMAIN\",\n        help=\"Enable HTTPS (auto-detect domain, or specify one)\",\n    )\n    parser.add_argument(\"--cert\", help=\"SSL certificate file (e.g., fullchain.pem)\")\n    parser.add_argument(\"--key\", help=\"SSL private key file (e.g., privkey.pem)\")\n    args = parser.parse_args()\n\n    # LOG_LEVEL env var takes precedence, then --debug flag, then default INFO\n    log_level_env = os.environ.get(\"LOG_LEVEL\", \"\").upper()\n    if log_level_env in (\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"):\n        log_level = getattr(logging, log_level_env)\n    else:\n        log_level = logging.DEBUG if args.debug else logging.INFO\n    logging.basicConfig(\n        level=log_level,\n        format=\"%(asctime)s | %(levelname)s | %(message)s\",\n        datefmt=\"%H:%M:%S\",\n        force=True,\n    )\n\n    ssl_args = {}\n    if args.cert and args.key:\n        ssl_args = {\"ssl_certfile\": args.cert, \"ssl_keyfile\": args.key}\n    elif args.https is not None:\n        live_dir = pathlib.Path(\"/etc/letsencrypt/live\")\n        if args.https:\n            domain = args.https\n        else:\n            # Auto-detect first domain\n            domains = [\n                d.name for d in live_dir.iterdir() if d.is_dir() and (d / \"fullchain.pem\").exists()\n            ]\n            if not domains:\n                raise SystemExit(\"No Let's Encrypt certs found in /etc/letsencrypt/live/\")\n            domain = domains[0]\n        cert = live_dir / domain / \"fullchain.pem\"\n        key = live_dir / domain / \"privkey.pem\"\n        if not cert.exists():\n            raise SystemExit(f\"Cert not found: {cert}\")\n        log.info(\"Using Let's Encrypt certs for %s\", domain)\n        ssl_args = {\"ssl_certfile\": str(cert), \"ssl_keyfile\": str(key)}\n\n    uv_log = \"debug\" if args.debug else \"info\"\n    uvicorn.run(\n        app,\n        host=\"0.0.0.0\",\n        port=args.port,\n        access_log=args.debug,\n        log_level=uv_log,\n        log_config=None,  # preserve our basicConfig\n        timeout_graceful_shutdown=5,\n        proxy_headers=True,\n        forwarded_allow_ips=\"*\",\n        **ssl_args,  # pyright: ignore[reportArgumentType]\n    )\n"
  },
  {
    "path": "main_test.py",
    "content": "\"\"\"Tests for main.py - FastAPI routes.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport json\n\nimport pytest\n\nimport cache as cache_module\nimport m3u as m3u_module\n\n\n@pytest.fixture\ndef mock_deps():\n    \"\"\"Mock all external dependencies before importing main.\"\"\"\n    with (\n        patch.dict(\n            \"sys.modules\", {\"defusedxml\": MagicMock(), \"defusedxml.ElementTree\": MagicMock()}\n        ),\n        patch(\"cache.CACHE_DIR\", Path(\"/tmp/test_cache\")),\n        patch(\"cache.SERVER_SETTINGS_FILE\", Path(\"/tmp/test_cache/server_settings.json\")),\n        patch(\"cache.USERS_DIR\", Path(\"/tmp/test_cache/users\")),\n    ):\n        yield\n\n\n@pytest.fixture\ndef client(tmp_path: Path, mock_deps):\n    \"\"\"Create test client with mocked dependencies.\"\"\"\n    from fastapi.testclient import TestClient\n\n    # Patch paths before importing main\n    with (\n        patch(\"cache.CACHE_DIR\", tmp_path),\n        patch(\"cache.SERVER_SETTINGS_FILE\", tmp_path / \"server_settings.json\"),\n        patch(\"cache.USERS_DIR\", tmp_path / \"users\"),\n        patch(\"auth.CACHE_DIR\", tmp_path),\n        patch(\"auth.SERVER_SETTINGS_FILE\", tmp_path / \"server_settings.json\"),\n        patch(\"auth.USERS_DIR\", tmp_path / \"users\"),\n        patch(\"epg.init\"),\n        patch(\"ffmpeg_command.init\"),\n        patch(\"ffmpeg_session.cleanup_and_recover_sessions\"),\n    ):\n        (tmp_path / \"users\").mkdir(exist_ok=True)\n        import main\n\n        # Disable background loading\n        cache_module.get_cache().clear()\n        yield TestClient(main.app)\n\n\n@pytest.fixture\ndef auth_client(tmp_path: Path, mock_deps):\n    \"\"\"Create test client with a logged-in user.\"\"\"\n    from fastapi.testclient import TestClient\n\n    with (\n        patch(\"cache.CACHE_DIR\", tmp_path),\n        patch(\"cache.SERVER_SETTINGS_FILE\", tmp_path / \"server_settings.json\"),\n        patch(\"cache.USERS_DIR\", tmp_path / \"users\"),\n        patch(\"auth.CACHE_DIR\", tmp_path),\n        patch(\"auth.SERVER_SETTINGS_FILE\", tmp_path / \"server_settings.json\"),\n        patch(\"auth.USERS_DIR\", tmp_path / \"users\"),\n        patch(\"epg.init\"),\n        patch(\"ffmpeg_command.init\"),\n        patch(\"ffmpeg_session.cleanup_and_recover_sessions\"),\n    ):\n        (tmp_path / \"users\").mkdir(exist_ok=True)\n        import auth\n        import main\n\n        cache_module.get_cache().clear()\n        client = TestClient(main.app)\n\n        # Create user and get token\n        auth.create_user(\"testuser\", \"testpass123\")\n        token = auth.create_token({\"sub\": \"testuser\"})\n        client.cookies.set(\"token\", token)\n\n        yield client\n\n\nclass TestSetup:\n    \"\"\"Tests for initial setup flow.\"\"\"\n\n    def test_setup_page_shown_when_no_users(self, client):\n        resp = client.get(\"/setup\", follow_redirects=False)\n        assert resp.status_code == 200\n        assert b\"setup\" in resp.content.lower() or b\"Create\" in resp.content\n\n    def test_setup_redirects_when_users_exist(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.get(\"/setup\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/login\"\n\n    def test_setup_creates_user(self, client):\n        resp = client.post(\n            \"/setup\",\n            data={\"username\": \"admin\", \"password\": \"password123\", \"confirm\": \"password123\"},\n            follow_redirects=False,\n        )\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/login\"\n\n        import auth\n\n        assert auth.verify_password(\"admin\", \"password123\")\n\n    def test_setup_validates_username_length(self, client):\n        resp = client.post(\n            \"/setup\",\n            data={\"username\": \"ab\", \"password\": \"password123\", \"confirm\": \"password123\"},\n        )\n        assert resp.status_code == 200\n        assert b\"at least 3\" in resp.content\n\n    def test_setup_validates_password_length(self, client):\n        resp = client.post(\n            \"/setup\",\n            data={\"username\": \"admin\", \"password\": \"short\", \"confirm\": \"short\"},\n        )\n        assert resp.status_code == 200\n        assert b\"at least 8\" in resp.content\n\n    def test_setup_validates_password_match(self, client):\n        resp = client.post(\n            \"/setup\",\n            data={\"username\": \"admin\", \"password\": \"password123\", \"confirm\": \"different\"},\n        )\n        assert resp.status_code == 200\n        assert b\"do not match\" in resp.content\n\n\nclass TestLogin:\n    \"\"\"Tests for login flow.\"\"\"\n\n    def test_login_page_redirects_to_setup_when_no_users(self, client):\n        resp = client.get(\"/login\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/setup\"\n\n    def test_login_page_shown_when_users_exist(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.get(\"/login\")\n        assert resp.status_code == 200\n\n    def test_login_success_sets_cookie(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.post(\n            \"/login\",\n            data={\"username\": \"admin\", \"password\": \"password123\"},\n            follow_redirects=False,\n        )\n        assert resp.status_code == 303\n        assert \"token\" in resp.cookies\n\n    def test_login_failure_returns_401(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.post(\n            \"/login\",\n            data={\"username\": \"admin\", \"password\": \"wrongpassword\"},\n            follow_redirects=False,\n        )\n        assert resp.status_code == 303\n        assert \"error=invalid\" in resp.headers[\"location\"]\n\n\nclass TestLogout:\n    \"\"\"Tests for logout.\"\"\"\n\n    def test_logout_clears_cookie(self, auth_client):\n        resp = auth_client.get(\"/logout\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/login\"\n\n\nclass TestAuthRequired:\n    \"\"\"Tests for auth-protected routes.\"\"\"\n\n    def test_index_redirects_to_login(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.get(\"/\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/login\"\n\n    def test_guide_redirects_to_login(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.get(\"/guide\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/login\"\n\n    def test_vod_redirects_to_login(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.get(\"/vod\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/login\"\n\n    def test_series_redirects_to_login(self, client, tmp_path):\n        import auth\n\n        auth.create_user(\"admin\", \"password123\")\n\n        resp = client.get(\"/series\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/login\"\n\n\nclass TestIndex:\n    \"\"\"Tests for index route.\"\"\"\n\n    def test_index_redirects_to_guide(self, auth_client):\n        resp = auth_client.get(\"/\", follow_redirects=False)\n        assert resp.status_code == 303\n        assert resp.headers[\"location\"] == \"/guide\"\n\n\nclass TestFavicon:\n    \"\"\"Tests for favicon.\"\"\"\n\n    def test_favicon_returns_204(self, client):\n        resp = client.get(\"/favicon.ico\")\n        assert resp.status_code == 204\n\n\nclass TestGuide:\n    \"\"\"Tests for guide page.\"\"\"\n\n    def test_guide_shows_loading_when_no_cache(self, auth_client):\n        with patch(\"main.load_file_cache\", return_value=None):\n            resp = auth_client.get(\"/guide\")\n            assert resp.status_code == 200\n            # Should show loading state\n            assert b\"loading\" in resp.content.lower() or b\"Loading\" in resp.content\n\n    def test_guide_shows_channels_from_cache(self, auth_client):\n        cache_module.get_cache()[\"live_categories\"] = [\n            {\"category_id\": \"1\", \"category_name\": \"News\"}\n        ]\n        cache_module.get_cache()[\"live_streams\"] = [\n            {\"stream_id\": 1, \"name\": \"CNN\", \"category_ids\": [\"1\"], \"epg_channel_id\": \"\"}\n        ]\n\n        with patch(\"main.epg.has_programs\", return_value=True):\n            resp = auth_client.get(\"/guide?cats=1\")\n            assert resp.status_code == 200\n\n    def test_guide_uses_saved_filter(self, auth_client, tmp_path):\n        user_dir = tmp_path / \"users\" / \"testuser\"\n        user_dir.mkdir(parents=True, exist_ok=True)\n        (user_dir / \"settings.json\").write_text(json.dumps({\"guide_filter\": [\"1\", \"2\"]}))\n\n        cache_module.get_cache()[\"live_categories\"] = []\n        cache_module.get_cache()[\"live_streams\"] = []\n\n        # Guide now renders directly using saved filter (no redirect)\n        with patch(\"main.epg.has_programs\", return_value=True):\n            resp = auth_client.get(\"/guide\")\n            assert resp.status_code == 200\n\n\nclass TestVod:\n    \"\"\"Tests for VOD page.\"\"\"\n\n    def test_vod_shows_loading_when_no_cache(self, auth_client):\n        with patch(\"main.load_file_cache\", return_value=None):\n            resp = auth_client.get(\"/vod\")\n            assert resp.status_code == 200\n\n    def test_vod_shows_movies_from_cache(self, auth_client):\n        cache_module.get_cache()[\"vod_categories\"] = [\n            {\"category_id\": \"10\", \"category_name\": \"Movies\", \"source_id\": \"src1\"}\n        ]\n        cache_module.get_cache()[\"vod_streams\"] = [\n            {\"stream_id\": 100, \"name\": \"Movie 1\", \"category_id\": \"10\", \"source_id\": \"src1\"}\n        ]\n\n        resp = auth_client.get(\"/vod\")\n        assert resp.status_code == 200\n\n    def test_vod_filters_by_category(self, auth_client):\n        cache_module.get_cache()[\"vod_categories\"] = [\n            {\"category_id\": \"10\", \"category_name\": \"Action\", \"source_id\": \"src1\"},\n            {\"category_id\": \"20\", \"category_name\": \"Comedy\", \"source_id\": \"src1\"},\n        ]\n        cache_module.get_cache()[\"vod_streams\"] = [\n            {\"stream_id\": 100, \"name\": \"Action Movie\", \"category_id\": \"10\", \"source_id\": \"src1\"},\n            {\"stream_id\": 101, \"name\": \"Comedy Movie\", \"category_id\": \"20\", \"source_id\": \"src1\"},\n        ]\n\n        resp = auth_client.get(\"/vod?category=10\")\n        assert resp.status_code == 200\n\n    def test_vod_sorts_by_alpha(self, auth_client):\n        cache_module.get_cache()[\"vod_categories\"] = []\n        cache_module.get_cache()[\"vod_streams\"] = [\n            {\"stream_id\": 1, \"name\": \"Zebra\", \"source_id\": \"src1\"},\n            {\"stream_id\": 2, \"name\": \"Apple\", \"source_id\": \"src1\"},\n        ]\n\n        resp = auth_client.get(\"/vod?sort=alpha\")\n        assert resp.status_code == 200\n\n\nclass TestSeries:\n    \"\"\"Tests for series page.\"\"\"\n\n    def test_series_shows_loading_when_no_cache(self, auth_client):\n        with patch(\"main.load_file_cache\", return_value=None):\n            resp = auth_client.get(\"/series\")\n            assert resp.status_code == 200\n\n    def test_series_shows_list_from_cache(self, auth_client):\n        cache_module.get_cache()[\"series_categories\"] = [\n            {\"category_id\": \"30\", \"category_name\": \"Drama\", \"source_id\": \"src1\"}\n        ]\n        cache_module.get_cache()[\"series\"] = [\n            {\"series_id\": 200, \"name\": \"Show 1\", \"category_id\": \"30\", \"source_id\": \"src1\"}\n        ]\n\n        resp = auth_client.get(\"/series\")\n        assert resp.status_code == 200\n\n\nclass TestSearch:\n    \"\"\"Tests for search page.\"\"\"\n\n    def test_search_page_renders(self, auth_client):\n        cache_module.get_cache()[\"live_streams\"] = []\n        cache_module.get_cache()[\"vod_streams\"] = []\n        cache_module.get_cache()[\"series\"] = []\n\n        resp = auth_client.get(\"/search\")\n        assert resp.status_code == 200\n\n    def test_search_finds_live_streams(self, auth_client):\n        cache_module.get_cache()[\"live_streams\"] = [\n            {\"stream_id\": 1, \"name\": \"CNN News\"},\n            {\"stream_id\": 2, \"name\": \"BBC World\"},\n        ]\n        cache_module.get_cache()[\"live_categories\"] = []\n        cache_module.get_cache()[\"epg_urls\"] = []\n        cache_module.get_cache()[\"vod_streams\"] = []\n        cache_module.get_cache()[\"series\"] = []\n\n        resp = auth_client.get(\"/search?q=CNN&live=true\")\n        assert resp.status_code == 200\n\n    def test_search_regex_mode(self, auth_client):\n        cache_module.get_cache()[\"live_streams\"] = [\n            {\"stream_id\": 1, \"name\": \"CNN News\"},\n            {\"stream_id\": 2, \"name\": \"CNBC Finance\"},\n        ]\n        cache_module.get_cache()[\"live_categories\"] = []\n        cache_module.get_cache()[\"epg_urls\"] = []\n        cache_module.get_cache()[\"vod_streams\"] = []\n        cache_module.get_cache()[\"series\"] = []\n\n        resp = auth_client.get(\"/search?q=CN.*&regex=true&live=true\")\n        assert resp.status_code == 200\n\n    def test_search_rejects_long_regex(self, auth_client):\n        cache_module.get_cache()[\"live_streams\"] = []\n\n        resp = auth_client.get(f\"/search?q={'a' * 101}&regex=true&live=true\")\n        assert resp.status_code == 400\n\n\nclass TestSettings:\n    \"\"\"Tests for settings page.\"\"\"\n\n    def test_settings_page_renders(self, auth_client):\n        cache_module.get_cache()[\"live_categories\"] = []\n\n        with patch(\"main.load_file_cache\", return_value=None):\n            resp = auth_client.get(\"/settings\")\n            assert resp.status_code == 200\n\n    def test_settings_guide_filter(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/guide-filter\",\n            json={\"cats\": [\"1\", \"2\", \"3\"]},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] == \"ok\"\n\n    def test_settings_captions(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/captions\",\n            data={\"enabled\": \"on\"},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"ok\"] is True\n\n    def test_settings_transcode(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/transcode\",\n            data={\n                \"transcode_mode\": \"auto\",\n                \"transcode_hw\": \"nvidia\",\n                \"vod_transcode_cache_mins\": 60,\n            },\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"ok\"] is True\n\n\nclass TestAddSource:\n    \"\"\"Tests for adding sources.\"\"\"\n\n    def test_add_xtream_source(self, auth_client):\n        with patch(\"main.clear_all_caches\"):\n            resp = auth_client.post(\n                \"/settings/add\",\n                data={\n                    \"name\": \"Test Provider\",\n                    \"source_type\": \"xtream\",\n                    \"url\": \"http://example.com\",\n                    \"username\": \"user\",\n                    \"password\": \"pass\",\n                    \"epg_timeout\": 120,\n                },\n                follow_redirects=False,\n            )\n            assert resp.status_code == 303\n\n    def test_add_m3u_source(self, auth_client):\n        with patch(\"main.clear_all_caches\"):\n            resp = auth_client.post(\n                \"/settings/add\",\n                data={\n                    \"name\": \"M3U Playlist\",\n                    \"source_type\": \"m3u\",\n                    \"url\": \"http://example.com/playlist.m3u\",\n                    \"epg_timeout\": 120,\n                },\n                follow_redirects=False,\n            )\n            assert resp.status_code == 303\n\n    def test_add_source_validates_type(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/add\",\n            data={\n                \"name\": \"Bad Source\",\n                \"source_type\": \"invalid\",\n                \"url\": \"http://example.com\",\n            },\n        )\n        assert resp.status_code == 400\n\n    def test_add_source_validates_url_scheme(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/add\",\n            data={\n                \"name\": \"Bad Source\",\n                \"source_type\": \"xtream\",\n                \"url\": \"ftp://example.com\",\n            },\n        )\n        assert resp.status_code == 400\n\n    def test_add_source_validates_name_length(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/add\",\n            data={\n                \"name\": \"x\" * 201,\n                \"source_type\": \"xtream\",\n                \"url\": \"http://example.com\",\n            },\n        )\n        assert resp.status_code == 400\n\n\nclass TestDeleteSource:\n    \"\"\"Tests for deleting sources.\"\"\"\n\n    def test_delete_source(self, auth_client, tmp_path):\n        settings_file = tmp_path / \"server_settings.json\"\n        settings_file.write_text(\n            json.dumps(\n                {\n                    \"sources\": [\n                        {\n                            \"id\": \"src_123\",\n                            \"name\": \"Test\",\n                            \"type\": \"xtream\",\n                            \"url\": \"http://example.com\",\n                        }\n                    ]\n                }\n            )\n        )\n\n        with patch(\"main.clear_all_caches\"):\n            resp = auth_client.post(\"/settings/delete/src_123\", follow_redirects=False)\n            assert resp.status_code == 303\n\n\nclass TestUserPrefs:\n    \"\"\"Tests for user preferences API.\"\"\"\n\n    def test_get_user_prefs(self, auth_client):\n        resp = auth_client.get(\"/api/user-prefs\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"favorites\" in data\n        assert \"cc_lang\" in data\n\n    def test_save_user_prefs(self, auth_client):\n        resp = auth_client.post(\n            \"/api/user-prefs\",\n            json={\"cc_lang\": \"eng\", \"cast_host\": \"192.168.1.100\"},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"ok\"] is True\n\n\nclass TestWatchPosition:\n    \"\"\"Tests for watch position API.\"\"\"\n\n    def test_save_watch_position(self, auth_client):\n        resp = auth_client.post(\n            \"/api/watch-position\",\n            json={\"url\": \"http://example.com/movie.mkv\", \"position\": 1234.5, \"duration\": 7200},\n        )\n        assert resp.status_code == 200\n\n    def test_get_watch_position(self, auth_client):\n        # Save first\n        auth_client.post(\n            \"/api/watch-position\",\n            json={\"url\": \"http://example.com/movie.mkv\", \"position\": 1234.5, \"duration\": 7200},\n        )\n\n        resp = auth_client.get(\"/api/watch-position?url=http://example.com/movie.mkv\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"position\"] == 1234.5\n        assert data[\"duration\"] == 7200\n\n    def test_get_watch_position_not_found(self, auth_client):\n        resp = auth_client.get(\"/api/watch-position?url=http://example.com/unknown.mkv\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"position\"] == 0\n\n\nclass TestUserManagement:\n    \"\"\"Tests for user management endpoints.\"\"\"\n\n    def test_delete_user(self, auth_client, tmp_path):\n        import auth\n\n        auth.create_user(\"otheruser\", \"password123\")\n\n        resp = auth_client.post(\"/settings/users/delete/otheruser\", follow_redirects=False)\n        assert resp.status_code == 303\n\n    def test_cannot_delete_self(self, auth_client):\n        resp = auth_client.post(\"/settings/users/delete/testuser\")\n        assert resp.status_code == 400\n\n    def test_change_password(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/users/password\",\n            data={\"current_password\": \"testpass123\", \"new_password\": \"newpass456\"},\n        )\n        assert resp.status_code == 200\n\n    def test_change_password_wrong_current(self, auth_client):\n        resp = auth_client.post(\n            \"/settings/users/password\",\n            data={\"current_password\": \"wrongpass\", \"new_password\": \"newpass456\"},\n        )\n        assert resp.status_code == 400\n\n\nclass TestPlaylistXspf:\n    \"\"\"Tests for XSPF playlist generation.\"\"\"\n\n    def test_playlist_xspf(self, auth_client):\n        resp = auth_client.get(\"/playlist.xspf?url=http://example.com/stream.m3u8\")\n        assert resp.status_code == 200\n        assert b\"<?xml\" in resp.content\n        assert b\"http://example.com/stream.m3u8\" in resp.content\n        assert resp.headers[\"content-type\"] == \"application/xspf+xml\"\n\n\nclass TestApiSettings:\n    \"\"\"Tests for settings API.\"\"\"\n\n    def test_get_settings(self, auth_client):\n        resp = auth_client.get(\"/api/settings\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"transcode_mode\" in data\n\n    def test_update_settings(self, auth_client):\n        resp = auth_client.post(\n            \"/api/settings\",\n            json={\"transcode_mode\": \"always\"},\n        )\n        assert resp.status_code == 200\n\n\nclass TestTranscodeRoutes:\n    \"\"\"Tests for transcode routes (with mocked transcoding module).\"\"\"\n\n    def test_transcode_file_not_found(self, auth_client):\n        with patch(\"main.ffmpeg_session.get_session\", return_value=None):\n            resp = auth_client.get(\"/transcode/invalid-session/stream.m3u8\")\n            assert resp.status_code == 404\n\n    def test_transcode_stop(self, auth_client):\n        with patch(\"main.ffmpeg_session.stop_session\"):\n            resp = auth_client.delete(\"/transcode/test-session\")\n            assert resp.status_code == 200\n            assert resp.json()[\"status\"] == \"stopped\"\n\n    def test_transcode_stop_post(self, auth_client):\n        with patch(\"main.ffmpeg_session.stop_session\"):\n            resp = auth_client.post(\"/transcode/test-session/stop\")\n            assert resp.status_code == 200\n\n    def test_transcode_progress_not_found(self, auth_client):\n        with patch(\"main.ffmpeg_session.get_session_progress\", return_value=None):\n            resp = auth_client.get(\"/transcode/progress/invalid-session\")\n            assert resp.status_code == 404\n\n\nclass TestSubtitleRoutes:\n    \"\"\"Tests for subtitle routes.\"\"\"\n\n    def test_subtitle_invalid_filename(self, auth_client):\n        resp = auth_client.get(\"/subs/session/notavtt.txt\")\n        assert resp.status_code == 400\n\n    def test_subtitle_session_not_found(self, auth_client):\n        with patch(\"main.ffmpeg_session.get_session\", return_value=None):\n            resp = auth_client.get(\"/subs/invalid-session/sub0.vtt\")\n            assert resp.status_code == 404\n\n\nclass TestProbeCache:\n    \"\"\"Tests for probe cache endpoints.\"\"\"\n\n    def test_get_probe_cache(self, auth_client):\n        with patch(\"main.ffmpeg_command.get_series_probe_cache_stats\", return_value=[]):\n            resp = auth_client.get(\"/settings/probe-cache\")\n            assert resp.status_code == 200\n            assert \"series\" in resp.json()\n\n    def test_clear_probe_cache(self, auth_client):\n        with patch(\"main.ffmpeg_command.clear_all_probe_cache\", return_value=5):\n            resp = auth_client.post(\"/settings/probe-cache/clear\")\n            assert resp.status_code == 200\n            assert resp.json()[\"cleared\"] == 5\n\n    def test_clear_series_probe_cache(self, auth_client):\n        with patch(\"main.ffmpeg_command.invalidate_series_probe_cache\"):\n            resp = auth_client.post(\"/settings/probe-cache/clear/123\")\n            assert resp.status_code == 200\n\n\nclass TestRefreshStatus:\n    \"\"\"Tests for refresh status endpoints.\"\"\"\n\n    def test_guide_refresh_status(self, auth_client):\n        m3u_module.get_refresh_in_progress().clear()\n\n        resp = auth_client.get(\"/guide/refresh-status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"live\"] is False\n        assert data[\"epg\"] is False\n\n    def test_settings_refresh_status(self, auth_client):\n        m3u_module.get_refresh_in_progress().clear()\n\n        resp = auth_client.get(\"/settings/refresh-status\")\n        assert resp.status_code == 200\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"netv\"\nversion = \"0.1.0\"\ndescription = \"Minimal self-hosted IPTV web interface with EPG guide, VOD, and Chromecast\"\nreadme = \"README.md\"\nlicense = \"Apache-2.0\"\nrequires-python = \">=3.10\"\nauthors = [\n    { name = \"Joshua V. Dillon\" },\n]\nkeywords = [\"iptv\", \"streaming\", \"epg\", \"hls\", \"transcoding\", \"chromecast\"]\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Environment :: Web Environment\",\n    \"Framework :: FastAPI\",\n    \"Intended Audience :: End Users/Desktop\",\n    \"License :: OSI Approved :: Apache Software License\",\n    \"Operating System :: POSIX :: Linux\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Topic :: Multimedia :: Video :: Display\",\n]\ndependencies = [\n    \"fastapi>=0.115\",\n    \"uvicorn[standard]>=0.32\",\n    \"jinja2>=3.1\",\n    \"python-multipart>=0.0.9\",\n    \"cryptography>=43.0\",\n    \"defusedxml>=0.7\",\n]\n\n[dependency-groups]\ndev = [\n    \"basedpyright>=1.10\",\n    \"httpx>=0.27\",\n    \"pytest>=7.0\",\n    \"pytest-asyncio>=0.23\",\n    \"ruff>=0.8\",\n]\nai_upscale = [\n    \"torch>=2.0\",\n    \"onnx>=1.14\",\n    \"tensorrt>=10.0\",\n]\n\n[project.urls]\nRepository = \"https://github.com/jvdillon/netv\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\ninclude = [\"*.py\"]\n\n[tool.uv]\npackage = false\n\n[tool.ruff]\nfix = true\nshow-fixes = true\nline-length = 100\noutput-format = \"concise\"\ntarget-version = \"py311\"\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\", \"UP\", \"B\", \"SIM\"]\nignore = [\"E501\", \"SIM108\"]\n\n[tool.ruff.lint.isort]\ncase-sensitive = false\ncombine-as-imports = true\nforce-wrap-aliases = true\nsplit-on-trailing-comma = true\ndefault-section = \"third-party\"\ndetect-same-package = true\nforce-single-line = false\nforce-sort-within-sections = false\nfrom-first = true\nlines-between-types = 1\nlines-after-imports = 2\n# extra-standard-library = [\"typing\",\"typing_extensions\"]\nknown-first-party = [\"core\", \"ae\", \"gm\", \"data\", \"tools\"]\nno-lines-before = [\"future\"] #, \"standard-library\"]\norder-by-type = true\nrelative-imports-order = \"closest-to-furthest\"\nsection-order = [\n    \"future\",\n    \"standard-library\",\n    \"third-party\",\n    \"first-party\",\n    \"local-folder\",\n]\n\n[tool.basedpyright]\ntypeCheckingMode = \"standard\"\npythonVersion = \"3.11\"\nexclude = [\"tools/\", \".venv/\"]  # Optional scripts + virtual env\n\n[tool.pytest.ini_options]\ntestpaths = [\".\"]\npython_files = [\"*_test.py\"]\npythonpath = [\".\"]\nfilterwarnings = [\"ignore::pytest.PytestUnraisableExceptionWarning:coroutine.*was never awaited\"]\n"
  },
  {
    "path": "static/js/app.js",
    "content": "// Keyboard navigation for 10-foot UI\n(function() {\n  'use strict';\n\n  // Pages with custom arrow key handling\n  const customNavPages = ['/play/', '/guide'];\n  const hasCustomNav = customNavPages.some(p => location.pathname.startsWith(p));\n\n  // ============================================================\n  // Focus Management\n  // ============================================================\n\n  function getFocusables(container = document) {\n    return Array.from(container.querySelectorAll(\n      'a[href]:not([disabled]), button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex=\"0\"], .focusable'\n    )).filter(el => el.offsetParent !== null); // visible only\n  }\n\n  function getGridInfo(element) {\n    const grid = element.closest('.grid');\n    if (!grid) return null;\n\n    const items = Array.from(grid.querySelectorAll('[data-nav=\"grid\"]'));\n    const index = items.indexOf(element);\n    if (index === -1) return null;\n\n    // Detect columns by comparing Y positions\n    let cols = 1;\n    if (items.length > 1) {\n      const firstTop = items[0].getBoundingClientRect().top;\n      for (let i = 1; i < items.length; i++) {\n        if (items[i].getBoundingClientRect().top > firstTop + 5) {\n          cols = i;\n          break;\n        }\n      }\n      if (cols === 1) cols = items.length;\n    }\n\n    return { items, index, cols };\n  }\n\n  function moveFocus(direction) {\n    const current = document.activeElement;\n    const focusables = getFocusables();\n    const currentIndex = focusables.indexOf(current);\n\n    // Try grid navigation first\n    const gridInfo = getGridInfo(current);\n    if (gridInfo && gridInfo.cols > 1) {\n      const { items, index, cols } = gridInfo;\n      let nextIndex = -1;\n\n      switch (direction) {\n        case 'up': nextIndex = index - cols; break;\n        case 'down': nextIndex = index + cols; break;\n        case 'left': nextIndex = index - 1; break;\n        case 'right': nextIndex = index + 1; break;\n      }\n\n      if (nextIndex >= 0 && nextIndex < items.length) {\n        items[nextIndex].focus();\n        items[nextIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n        return true;\n      }\n      // At grid edge - don't wrap for up/down\n      if (direction === 'up' || direction === 'down') return false;\n    }\n\n    // Linear navigation fallback\n    let nextElement = null;\n    if (direction === 'up' || direction === 'left') {\n      nextElement = focusables[currentIndex - 1];\n    } else {\n      nextElement = focusables[currentIndex + 1];\n    }\n\n    if (nextElement) {\n      nextElement.focus();\n      nextElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n      return true;\n    }\n    return false;\n  }\n\n  // ============================================================\n  // Initial Focus\n  // ============================================================\n\n  function setInitialFocus() {\n    // Skip if something is already focused (other than body)\n    if (document.activeElement && document.activeElement !== document.body) return;\n\n    // Priority: [autofocus], first grid item, first focusable in main\n    const autofocus = document.querySelector('[autofocus]');\n    if (autofocus) { autofocus.focus(); return; }\n\n    const mainContent = document.querySelector('main');\n    if (!mainContent) return;\n\n    const gridItem = mainContent.querySelector('[data-nav=\"grid\"]');\n    if (gridItem) { gridItem.focus(); return; }\n\n    const firstFocusable = getFocusables(mainContent)[0];\n    if (firstFocusable) firstFocusable.focus();\n  }\n\n  // ============================================================\n  // Favorites Toggle\n  // ============================================================\n\n  function toggleFocusedFavorite() {\n    const el = document.activeElement;\n    if (!el) return false;\n\n    // Check for movie card\n    const movieCard = el.closest('.movie-card');\n    if (movieCard) {\n      const btn = movieCard.querySelector('.fav-btn, .fav-btn-movie');\n      if (btn) { btn.click(); return true; }\n    }\n\n    // Check for series card\n    const seriesCard = el.closest('.series-card');\n    if (seriesCard) {\n      const btn = seriesCard.querySelector('.fav-btn, .fav-btn-series');\n      if (btn) { btn.click(); return true; }\n    }\n\n    // Check for favorites tile (in favorites view)\n    const tile = el.closest('.vod-tile, .series-tile');\n    if (tile) {\n      const btn = tile.querySelector('button');\n      if (btn) { btn.click(); return true; }\n    }\n\n    // Check for detail page favorite button\n    const favBtn = document.getElementById('fav-btn');\n    if (favBtn) { favBtn.click(); return true; }\n\n    return false;\n  }\n\n  // ============================================================\n  // Keyboard Handler\n  // ============================================================\n\n  document.addEventListener('keydown', (e) => {\n    const isInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';\n    const isSelect = e.target.tagName === 'SELECT';\n\n    // Input field handling\n    if (isInput) {\n      if (e.key === 'Escape') {\n        e.target.blur();\n        return;\n      }\n      // Allow down arrow to escape search input\n      if (e.key === 'ArrowDown' && e.target.type === 'text') {\n        const mainContent = document.querySelector('main');\n        const firstResult = mainContent?.querySelector('[data-nav=\"grid\"]');\n        if (firstResult) {\n          e.preventDefault();\n          firstResult.focus();\n          return;\n        }\n      }\n      // Let other keys work normally in inputs\n      return;\n    }\n\n    // Select handling - let arrows work for options\n    if (isSelect && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {\n      return;\n    }\n\n    switch (e.key) {\n      case 'ArrowUp':\n      case 'ArrowDown':\n      case 'ArrowLeft':\n      case 'ArrowRight':\n        // Skip if page has custom navigation or Alt pressed (browser nav)\n        if (hasCustomNav || e.altKey) return;\n        e.preventDefault();\n        const dir = e.key.replace('Arrow', '').toLowerCase();\n        moveFocus(dir);\n        break;\n\n      case 'Enter': {\n        const el = document.activeElement;\n        if (el?.href) {\n          e.preventDefault();\n          if (e.ctrlKey || e.metaKey) {\n            window.open(el.href, '_blank');\n          } else {\n            window.location.href = el.href;\n          }\n        } else if (el?.click && el.tagName !== 'A' && el.tagName !== 'BUTTON') {\n          e.preventDefault();\n          el.click();\n        }\n        break;\n      }\n\n      case 'f':\n      case 'F':\n        if (toggleFocusedFavorite()) {\n          e.preventDefault();\n        }\n        break;\n\n      case 'Escape':\n        // Only handle if focus is on a known focusable element (not during browser find dialog, etc.)\n        if (!document.activeElement || document.activeElement === document.body) return;\n        e.preventDefault();\n        if (document.activeElement?.closest('nav')) {\n          // In nav - go to main content\n          const mainFocusable = document.querySelector('main [data-nav=\"grid\"], main .focusable, main a[href], main button');\n          if (mainFocusable) mainFocusable.focus();\n        } else {\n          // In content - go to nav\n          const navLink = document.querySelector('nav .nav-link');\n          if (navLink) navLink.focus();\n        }\n        break;\n\n      case 'Backspace':\n        // Go back unless on root pages or in input\n        const rootPages = ['/', '/guide', '/vod', '/series', '/search', '/settings'];\n        if (!rootPages.includes(location.pathname)) {\n          e.preventDefault();\n          history.back();\n        }\n        break;\n    }\n  });\n\n  // Set initial focus after page load\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', setInitialFocus);\n  } else {\n    setTimeout(setInitialFocus, 0);\n  }\n\n})();\n"
  },
  {
    "path": "static/js/favorites-grid.js",
    "content": "// Shared Favorites Grid Module for VOD/Series pages\n// Requires: window.FAVORITES_CONFIG = { type: 'movies'|'series', favorites, cardClass, tileClass, detailUrl, orderKey }\n\n(function() {\n  'use strict';\n\n  const cfg = window.FAVORITES_CONFIG;\n  if (!cfg) return;\n\n  function escapeHtml(s) {\n    if (!s) return '';\n    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n  }\n\n  function escapeAttr(s) {\n    if (!s) return '';\n    return String(s).replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n  }\n\n  window.favorites = cfg.favorites;\n\n  function getFavorites() {\n    return window.favorites[cfg.type] || {};\n  }\n\n  function saveFavorites() {\n    fetch('/api/user-prefs', {\n      method: 'POST',\n      headers: {'Content-Type': 'application/json'},\n      body: JSON.stringify({favorites: window.favorites})\n    });\n  }\n\n  window.toggleFavorite = function(id, name, cover, ext) {\n    const favs = window.favorites[cfg.type];\n    if (favs[id]) {\n      delete favs[id];\n    } else {\n      favs[id] = cfg.type === 'movies' ? { name, cover, ext } : { name, cover };\n    }\n    saveFavorites();\n    updateFavoriteButtons();\n    if (typeof window.renderFavorites === 'function') window.renderFavorites();\n  };\n\n  window.updateFavoriteButtons = function() {\n    const favs = getFavorites();\n    document.querySelectorAll('.fav-btn').forEach(btn => {\n      const card = btn.closest('.' + cfg.cardClass);\n      const id = card?.dataset[cfg.type === 'movies' ? 'movieId' : 'seriesId'];\n      btn.textContent = favs[id] ? '★' : '☆';\n      btn.classList.toggle('text-yellow-400', !!favs[id]);\n    });\n  };\n\n  // Browse view handlers\n  if (cfg.isBrowseView) {\n    window.updateBrowseUrl = function() {\n      const cat = document.getElementById('category-select').value;\n      const sort = document.getElementById('sort-select').value;\n      const params = new URLSearchParams();\n      if (cat) params.set('category', cat);\n      params.set('sort', sort);\n      window.location.href = cfg.baseUrl + '?' + params;\n    };\n\n    const catSel = document.getElementById('category-select');\n    const sortSel = document.getElementById('sort-select');\n    if (catSel) catSel.addEventListener('change', updateBrowseUrl);\n    if (sortSel) sortSel.addEventListener('change', updateBrowseUrl);\n    updateFavoriteButtons();\n  }\n\n  // Favorites view handlers\n  if (!cfg.isBrowseView) {\n    async function getOrder() {\n      try {\n        const resp = await fetch('/api/settings');\n        const settings = await resp.json();\n        return settings[cfg.orderKey] || [];\n      } catch (e) { console.error('Failed to get order:', e); return []; }\n    }\n\n    async function saveOrder(order) {\n      try {\n        const resp = await fetch('/api/settings');\n        const settings = await resp.json();\n        settings[cfg.orderKey] = order;\n        await fetch('/api/settings', {\n          method: 'POST',\n          headers: {'Content-Type': 'application/json'},\n          body: JSON.stringify(settings)\n        });\n      } catch (e) {\n        console.error('Failed to save order:', e);\n      }\n    }\n\n    window.renderFavorites = async function() {\n      const favs = getFavorites();\n      const grid = document.getElementById('favorites-grid');\n      const noFavs = document.getElementById('no-favorites');\n\n      let ids = Object.keys(favs);\n      if (ids.length === 0) {\n        grid.innerHTML = '';\n        noFavs.classList.remove('hidden');\n        return;\n      }\n\n      const order = await getOrder();\n      const orderedIds = order.filter(id => favs[id]);\n      const unorderedIds = ids.filter(id => !order.includes(id));\n      ids = [...orderedIds, ...unorderedIds];\n\n      noFavs.classList.add('hidden');\n      grid.innerHTML = ids.map(id => {\n        const f = favs[id];\n        const safeId = escapeAttr(id);\n        const safeCover = escapeAttr(f.cover);\n        const safeName = escapeHtml(f.name);\n        return `\n          <div class=\"${cfg.tileClass}\" data-id=\"${safeId}\">\n            <a href=\"${cfg.detailUrl}${encodeURIComponent(id)}\" class=\"block bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 focusable group relative\"\n               tabindex=\"0\" data-nav=\"grid\">\n              <div class=\"aspect-[2/3] bg-gray-700\">\n                ${f.cover ? `<img src=\"${safeCover}\" class=\"w-full h-full object-cover\" loading=\"lazy\">` : ''}\n              </div>\n              <button class=\"absolute top-2 right-2 w-8 h-8 rounded-full bg-black/50 text-xl text-yellow-400 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10 focusable\"\n                      tabindex=\"0\"\n                      onclick=\"event.preventDefault(); event.stopPropagation(); toggleFavorite('${safeId}', '', '', '');\">★</button>\n              <div class=\"p-2 text-sm line-clamp-2\">${safeName}</div>\n            </a>\n          </div>\n        `;\n      }).join('');\n\n      initDragDrop();\n    };\n\n    function initDragDrop() {\n      const grid = document.getElementById('favorites-grid');\n      let draggedEl = null;\n      let touchStartY = 0;\n      let touchStartX = 0;\n      let longPressTimer = null;\n      let isDragging = false;\n\n      grid.addEventListener('contextmenu', (e) => {\n        if (e.target.closest('.' + cfg.tileClass)) e.preventDefault();\n      });\n\n      grid.querySelectorAll('.' + cfg.tileClass).forEach(tile => {\n        tile.draggable = true;\n\n        tile.addEventListener('dragstart', () => {\n          draggedEl = tile;\n          tile.classList.add('opacity-50');\n        });\n\n        tile.addEventListener('dragend', () => {\n          tile.classList.remove('opacity-50');\n          draggedEl = null;\n          saveCurrentOrder();\n        });\n\n        tile.addEventListener('dragover', (e) => {\n          e.preventDefault();\n          if (draggedEl && draggedEl !== tile) {\n            const rect = tile.getBoundingClientRect();\n            const midpoint = rect.left + rect.width / 2;\n            grid.insertBefore(draggedEl, e.clientX < midpoint ? tile : tile.nextSibling);\n          }\n        });\n\n        tile.addEventListener('touchstart', (e) => {\n          touchStartX = e.touches[0].clientX;\n          touchStartY = e.touches[0].clientY;\n          longPressTimer = setTimeout(() => {\n            isDragging = true;\n            draggedEl = tile;\n            tile.classList.add('opacity-50', 'ring-2', 'ring-blue-500');\n            navigator.vibrate?.(50);\n          }, 400);\n        }, {passive: true});\n\n        tile.addEventListener('touchmove', (e) => {\n          if (longPressTimer && !isDragging) {\n            const dx = Math.abs(e.touches[0].clientX - touchStartX);\n            const dy = Math.abs(e.touches[0].clientY - touchStartY);\n            if (dx > 10 || dy > 10) { clearTimeout(longPressTimer); longPressTimer = null; }\n          }\n          if (!isDragging || !draggedEl) return;\n          e.preventDefault();\n          const touch = e.touches[0];\n          const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest('.' + cfg.tileClass);\n          if (target && target !== draggedEl) {\n            const rect = target.getBoundingClientRect();\n            const midpoint = rect.left + rect.width / 2;\n            grid.insertBefore(draggedEl, touch.clientX < midpoint ? target : target.nextSibling);\n          }\n        }, {passive: false});\n\n        tile.addEventListener('touchend', () => {\n          clearTimeout(longPressTimer);\n          longPressTimer = null;\n          if (isDragging && draggedEl) {\n            draggedEl.classList.remove('opacity-50', 'ring-2', 'ring-blue-500');\n            draggedEl = null;\n            isDragging = false;\n            saveCurrentOrder();\n          }\n        });\n      });\n    }\n\n    async function saveCurrentOrder() {\n      const grid = document.getElementById('favorites-grid');\n      const order = Array.from(grid.querySelectorAll('.' + cfg.tileClass)).map(tile => tile.dataset.id);\n      await saveOrder(order);\n    }\n\n    renderFavorites();\n  }\n})();\n"
  },
  {
    "path": "static/js/player.js",
    "content": "// IPTV Player Module\n// Requires: Hls.js, window.PLAYER_CONFIG\n\n(function() {\n  'use strict';\n\n  const cfg = window.PLAYER_CONFIG;\n  const video = document.getElementById('video');\n  const loading = document.getElementById('loading');\n  const error = document.getElementById('error');\n  const ccBtn = document.getElementById('toggle-cc');\n  const settingsMenu = document.getElementById('settings-menu');\n\n  // State\n  let transcodeSessionId = null;\n  let currentHls = null;\n  let isTranscoding = false;\n  let ccEnabled = cfg.captionsEnabled;\n  let subtitlePollTimerId = null;\n  let transcodedDuration = 0;\n  let totalDuration = 0;\n  let seekInProgress = false;\n  let seekOffset = 0;\n  let currentSubtitles = null;\n  let activeTrackStates = null;\n  let progressPollTimerId = null;\n  let lastSavedPosition = 0;\n  let savePositionTimeout = null;\n  let autoMutedByPolicy = false;\n\n  // ============================================================\n  // Utilities\n  // ============================================================\n\n  function formatTime(seconds) {\n    const h = Math.floor(seconds / 3600);\n    const m = Math.floor((seconds % 3600) / 60);\n    const s = Math.floor(seconds % 60);\n    if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;\n    return `${m}:${s.toString().padStart(2, '0')}`;\n  }\n\n  function parseTime(str) {\n    str = str.trim();\n    if (!str) return 0;\n    const parts = str.split(':').map(Number);\n    if (parts.some(isNaN)) return 0;\n    if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];\n    if (parts.length === 2) return parts[0] * 60 + parts[1];\n    const n = parts[0];\n    if (totalDuration >= 3600 && n * 3600 <= totalDuration) return n * 3600;\n    if (n * 60 <= totalDuration) return n * 60;\n    return n;\n  }\n\n  function parseVttTime(str) {\n    const parts = str.split(':');\n    if (parts.length === 3) return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2]);\n    if (parts.length === 2) return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);\n    return 0;\n  }\n\n  function parseVttCues(vttText) {\n    const cues = [];\n    const lines = vttText.split('\\n');\n    let i = 0;\n    while (i < lines.length && !lines[i].includes('-->')) i++;\n    while (i < lines.length) {\n      const line = lines[i];\n      if (line.includes('-->')) {\n        const [startStr, endStr] = line.split('-->').map(s => s.trim().split(' ')[0]);\n        const start = parseVttTime(startStr);\n        const end = parseVttTime(endStr);\n        i++;\n        const textLines = [];\n        while (i < lines.length && lines[i].trim() !== '') {\n          textLines.push(lines[i]);\n          i++;\n        }\n        if (textLines.length > 0) cues.push({start, end, text: textLines.join('\\n')});\n      }\n      i++;\n    }\n    return cues;\n  }\n\n  // ============================================================\n  // UI Helpers\n  // ============================================================\n\n  function hideLoading() {\n    loading.classList.add('hidden');\n  }\n\n  function showLoading() {\n    loading.classList.remove('hidden');\n  }\n\n  function showError() {\n    hideLoading();\n    error.classList.remove('hidden');\n  }\n\n  function updateTranscodeCheck() {\n    const check = document.getElementById('tc-check');\n    if (check) check.textContent = isTranscoding ? '✓' : '';\n  }\n\n  function updateCcButton() {\n    ccBtn.classList.toggle('active', ccEnabled);\n  }\n\n  function updatePlayIcon() {\n    const playIcon = document.getElementById('play-icon');\n    const pauseIcon = document.getElementById('pause-icon');\n    if (playIcon && pauseIcon) {\n      playIcon.classList.toggle('hidden', !video.paused);\n      pauseIcon.classList.toggle('hidden', video.paused);\n    }\n  }\n\n  function updateMuteIcon() {\n    const volIcon = document.getElementById('vol-icon');\n    const mutedIcon = document.getElementById('muted-icon');\n    if (volIcon && mutedIcon) {\n      volIcon.classList.toggle('hidden', video.muted);\n      mutedIcon.classList.toggle('hidden', !video.muted);\n    }\n  }\n\n  function updateFullscreenIcon() {\n    const fsEnter = document.getElementById('fs-enter');\n    const fsExit = document.getElementById('fs-exit');\n    const isFs = !!document.fullscreenElement;\n    if (fsEnter && fsExit) {\n      fsEnter.classList.toggle('hidden', isFs);\n      fsExit.classList.toggle('hidden', !isFs);\n    }\n  }\n\n  function disableCcButton() {\n    ccBtn.disabled = true;\n    ccBtn.classList.remove('active');\n  }\n\n  function enableCcButton() {\n    ccBtn.disabled = false;\n    updateCcButton();\n  }\n\n  // ============================================================\n  // HLS Configuration\n  // ============================================================\n\n  // Custom cueHandler for CEA-608 caption positioning\n  // See: https://github.com/video-dev/hls.js/issues/654\n  const customCueHandler = {\n    newCue(track, startTime, endTime, captionScreen) {\n      const lines = [];\n      for (let r = 0; r < 15; r++) {\n        const row = captionScreen.rows[r];\n        let text = '';\n        for (let c = 0; c < 32; c++) {\n          text += row.chars[c]?.uchar || ' ';\n        }\n        text = text.trim();\n        if (text) lines.push(text);\n      }\n      if (lines.length === 0) return [];\n      const cue = new VTTCue(startTime, endTime, lines.join('\\n'));\n      cue.line = -2;\n      cue.align = 'center';\n      track.addCue(cue);\n      return [cue];\n    }\n  };\n\n  function createHlsConfig(options = {}) {\n    const base = {\n      enableWorker: true,\n      lowLatencyMode: false,\n      enableCEA708Captions: true,\n      subtitleDisplay: ccEnabled,\n      cueHandler: customCueHandler,\n      manifestLoadingRetryDelay: 1000,\n      levelLoadingRetryDelay: 1000,\n      fragLoadingRetryDelay: 1000,\n    };\n    if (options.forSeek) {\n      return {\n        ...base,\n        liveSyncDurationCount: 0,\n        startPosition: 0,\n        manifestLoadingMaxRetry: 30,\n        levelLoadingMaxRetry: 30,\n        fragLoadingMaxRetry: 30,\n      };\n    }\n    return {\n      ...base,\n      liveSyncDurationCount: options.isVod ? 0 : 3,\n      startPosition: options.isVod ? 0 : -1,\n    };\n  }\n\n  // ============================================================\n  // Captions\n  // ============================================================\n\n  function applyCaptionStyles() {\n    const s = cfg.ccStyle || {};\n    const hexToRgba = (hex, opacity) => {\n      if (hex === 'transparent') return 'transparent';\n      const r = parseInt(hex.slice(1,3), 16);\n      const g = parseInt(hex.slice(3,5), 16);\n      const b = parseInt(hex.slice(5,7), 16);\n      return `rgba(${r},${g},${b},${opacity})`;\n    };\n    const color = hexToRgba(s.cc_color || '#ffffff', 1);\n    const shadow = s.cc_shadow || '0 0 4px black, 0 0 4px black';\n    const bg = hexToRgba(s.cc_bg || '#000000', s.cc_bg_opacity || 0.75);\n    const size = s.cc_size || '1em';\n    const font = s.cc_font || 'inherit';\n    let style = document.getElementById('cc-style');\n    if (!style) {\n      style = document.createElement('style');\n      style.id = 'cc-style';\n      document.head.appendChild(style);\n    }\n    const sizeMultiplier = parseFloat(size) || 1;\n    const infoSize = (2.5 * sizeMultiplier) + 'vh';\n    style.textContent = `\n      video::cue {\n        color: ${color} !important;\n        text-shadow: ${shadow} !important;\n        background-color: ${bg} !important;\n        font-size: ${size} !important;\n        font-family: ${font} !important;\n      }\n      #info-overlay { font-size: ${infoSize}; max-width: 50em; }\n    `;\n  }\n\n  function getPreferredSubtitleTrack(tracks) {\n    const prefLang = cfg.ccLang || '';\n    if (!prefLang || tracks.length === 0) return 0;\n    // Handle CC1-CC4 (CEA-608 channels)\n    if (/^cc[1-4]$/i.test(prefLang)) {\n      const ccNum = prefLang.toUpperCase();\n      const idx = tracks.findIndex(t => (t.name || t.label || '').toUpperCase().includes(ccNum));\n      if (idx >= 0) return idx;\n      // Fallback: CC1 is usually index 0, CC2 is index 1, etc.\n      const num = parseInt(prefLang.slice(2)) - 1;\n      return num < tracks.length ? num : 0;\n    }\n    const langNames = {en: 'english', es: 'spanish', fr: 'french', de: 'german', it: 'italian', pt: 'portuguese', ja: 'japanese', ko: 'korean', zh: 'chinese'};\n    let idx = tracks.findIndex(t => t.lang && t.lang.toLowerCase().startsWith(prefLang));\n    if (idx >= 0) return idx;\n    const prefName = langNames[prefLang];\n    if (prefName) {\n      idx = tracks.findIndex(t => (t.name || t.label) && (t.name || t.label).toLowerCase().includes(prefName));\n      if (idx >= 0) return idx;\n    }\n    return 0;\n  }\n\n  function applyCaptionsSetting() {\n    const tracks = Array.from(video.textTracks).filter(t =>\n      (t.kind === 'subtitles' || t.kind === 'captions') && t.mode !== 'disabled');\n    if (!ccEnabled) {\n      tracks.forEach(t => t.mode = 'hidden');\n      return;\n    }\n    if (tracks.length === 0) return;\n    const prefLang = cfg.ccLang || '';\n    const langNames = {en: 'english', es: 'spanish', fr: 'french', de: 'german', it: 'italian', pt: 'portuguese', ja: 'japanese', ko: 'korean', zh: 'chinese'};\n    let preferredIdx = 0;\n    if (prefLang) {\n      const idx = tracks.findIndex(t => t.language && t.language.toLowerCase().startsWith(prefLang));\n      if (idx >= 0) preferredIdx = idx;\n      else {\n        const prefName = langNames[prefLang];\n        if (prefName) {\n          const nameIdx = tracks.findIndex(t => t.label && t.label.toLowerCase().includes(prefName));\n          if (nameIdx >= 0) preferredIdx = nameIdx;\n        }\n      }\n    }\n    tracks.forEach((t, i) => t.mode = i === preferredIdx ? 'showing' : 'hidden');\n  }\n\n  function startSubtitlePolling(subtitles, prefIdx) {\n    if (subtitlePollTimerId) {\n      clearInterval(subtitlePollTimerId);\n      clearTimeout(subtitlePollTimerId);\n      subtitlePollTimerId = null;\n    }\n    if (activeTrackStates && activeTrackStates.length === subtitles.length) {\n      for (const ts of activeTrackStates) {\n        if (ts.track.mode === 'disabled') ts.track.mode = 'hidden';\n        const cues = ts.track.cues;\n        if (cues) while (cues.length > 0) ts.track.removeCue(cues[0]);\n        ts.addedCues.clear();\n        ts.retryCount = 0;\n      }\n    } else {\n      for (let i = 0; i < video.textTracks.length; i++) {\n        video.textTracks[i].mode = 'disabled';\n      }\n      activeTrackStates = subtitles.map((sub) => ({\n        url: sub.url,\n        track: video.addTextTrack('subtitles', sub.label, sub.lang),\n        addedCues: new Set(),\n        retryCount: 0,\n      }));\n    }\n    activeTrackStates.forEach((ts, i) => {\n      ts.track.mode = (ccEnabled && i === prefIdx) ? 'showing' : 'hidden';\n    });\n\n    const poll = async () => {\n      for (let i = 0; i < activeTrackStates.length; i++) {\n        const ts = activeTrackStates[i];\n        if (ts.retryCount > 120) continue;\n        try {\n          const resp = await fetch(ts.url + '?t=' + Date.now());\n          if (!resp.ok) { ts.retryCount++; continue; }\n          const vtt = await resp.text();\n          const cues = parseVttCues(vtt);\n          for (const cue of cues) {\n            const key = `${cue.start}-${cue.end}`;\n            if (!ts.addedCues.has(key)) {\n              try {\n                ts.track.addCue(new VTTCue(cue.start, cue.end, cue.text));\n                ts.addedCues.add(key);\n              } catch (e) {}\n            }\n          }\n          ts.retryCount = 0;\n        } catch (e) { ts.retryCount++; }\n      }\n    };\n\n    let pollCount = 0;\n    const doPoll = async () => {\n      await poll();\n      pollCount++;\n      subtitlePollTimerId = pollCount < 20 ? setTimeout(doPoll, 500) : setInterval(poll, 5000);\n    };\n    doPoll();\n  }\n\n  // ============================================================\n  // Position Tracking\n  // ============================================================\n\n  function savePosition() {\n    const actualTime = video.currentTime + seekOffset;\n    if (!cfg.isVod || actualTime < 5) return;\n    if (video.currentTime < 1 && seekOffset > 0) return;\n    if (Math.abs(actualTime - lastSavedPosition) < 5) return;\n    lastSavedPosition = actualTime;\n    const data = JSON.stringify({ url: cfg.rawUrl, position: actualTime, duration: totalDuration });\n    if (document.visibilityState === 'hidden') {\n      navigator.sendBeacon('/api/watch-position', new Blob([data], {type: 'application/json'}));\n    } else {\n      fetch('/api/watch-position', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: data });\n    }\n  }\n\n  function restorePosition() {\n    if (!cfg.isVod) return;\n    const savedTime = cfg.serverResumePosition;\n    if (!savedTime || savedTime <= 5) return;\n    if (transcodedDuration < 30) return;\n    const rangeStart = seekOffset;\n    const rangeEnd = seekOffset + Math.max(0, transcodedDuration - 10);\n    if (savedTime < rangeStart) return;\n    const targetTime = Math.min(savedTime, rangeEnd);\n    video.currentTime = targetTime - seekOffset;\n  }\n\n  function setupPositionTracking() {\n    if (!cfg.isVod) return;\n    video.addEventListener('timeupdate', () => {\n      if (savePositionTimeout) return;\n      savePositionTimeout = setTimeout(() => {\n        savePosition();\n        savePositionTimeout = null;\n      }, 10000);\n    });\n    video.addEventListener('pause', savePosition);\n    video.addEventListener('ended', savePosition);\n    video.addEventListener('ended', () => {\n      if (autoNextEnabled && cfg.nextEpisodeUrl) window.location.href = cfg.nextEpisodeUrl;\n    });\n    document.addEventListener('visibilitychange', () => {\n      if (document.visibilityState === 'hidden') savePosition();\n    });\n  }\n\n  // ============================================================\n  // Progress Polling\n  // ============================================================\n\n  function startProgressPolling() {\n    if (progressPollTimerId) clearInterval(progressPollTimerId);\n    const poll = async () => {\n      if (!transcodeSessionId) return;\n      try {\n        const resp = await fetch('/transcode/progress/' + transcodeSessionId);\n        if (resp.ok) {\n          const data = await resp.json();\n          transcodedDuration = data.duration || 0;\n        }\n      } catch (e) {}\n    };\n    poll();\n    progressPollTimerId = setInterval(poll, 2000);\n  }\n\n  function stopProgressPolling() {\n    if (progressPollTimerId) {\n      clearInterval(progressPollTimerId);\n      progressPollTimerId = null;\n    }\n  }\n\n  // ============================================================\n  // Transcode Management\n  // ============================================================\n\n  async function cleanupTranscode() {\n    if (document.pictureInPictureElement === video) return;\n    stopProgressPolling();\n    if (subtitlePollTimerId) {\n      clearInterval(subtitlePollTimerId);\n      subtitlePollTimerId = null;\n    }\n    if (currentHls) {\n      currentHls.destroy();\n      currentHls = null;\n    }\n    transcodedDuration = 0;\n    totalDuration = 0;\n    seekInProgress = false;\n    seekOffset = 0;\n    currentSubtitles = null;\n    activeTrackStates = null;\n    document.getElementById('menu-jump')?.classList.add('hidden');\n    document.getElementById('seek-container').classList.add('hidden');\n    if (transcodeSessionId) {\n      const sessionToStop = transcodeSessionId;\n      transcodeSessionId = null;\n      try {\n        await fetch('/transcode/' + sessionToStop, {method: 'DELETE'});\n      } catch (e) {\n        console.error('Cleanup error:', e);\n      }\n    }\n  }\n\n  function cleanupTranscodeSync() {\n    if (document.pictureInPictureElement === video) return;\n    stopProgressPolling();\n    if (subtitlePollTimerId) {\n      clearInterval(subtitlePollTimerId);\n      subtitlePollTimerId = null;\n    }\n    if (currentHls) {\n      currentHls.destroy();\n      currentHls = null;\n    }\n    if (transcodeSessionId) {\n      const blob = new Blob([], {type: 'application/json'});\n      navigator.sendBeacon('/transcode/' + transcodeSessionId + '/stop', blob);\n      transcodeSessionId = null;\n    }\n  }\n\n  async function handleSeekToPosition(targetTime) {\n    if (!transcodeSessionId || seekInProgress) return false;\n    seekInProgress = true;\n    showLoading();\n    error.classList.add('hidden');\n    video.pause();\n    video.src = '';\n    if (subtitlePollTimerId) {\n      clearInterval(subtitlePollTimerId);\n      clearTimeout(subtitlePollTimerId);\n      subtitlePollTimerId = null;\n    }\n    if (currentHls) {\n      currentHls.destroy();\n      currentHls = null;\n    }\n    try {\n      const resp = await fetch('/transcode/seek/' + transcodeSessionId + '?time=' + targetTime);\n      if (!resp.ok) throw new Error('Seek failed: ' + resp.status);\n      transcodedDuration = 0;\n      seekOffset = targetTime;\n      const hls = new Hls(createHlsConfig({forSeek: true}));\n      currentHls = hls;\n\n      hls.on(Hls.Events.MANIFEST_PARSED, () => {\n        hideLoading();\n        error.classList.add('hidden');\n        seekInProgress = false;\n        savePosition();\n        if (currentSubtitles && currentSubtitles.length > 0) {\n          startSubtitlePolling(currentSubtitles, getPreferredSubtitleTrack(currentSubtitles));\n        }\n        video.play().catch(() => {});\n      });\n\n      let recoveryAttempts = 0;\n      hls.on(Hls.Events.ERROR, (event, data) => {\n        if (data.fatal) {\n          recoveryAttempts++;\n          if (recoveryAttempts <= 3) {\n            if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();\n            else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();\n            else { console.error('[SEEK] HLS error:', data); showError(); }\n          } else {\n            console.error('[SEEK] HLS error after retries:', data);\n            showError();\n          }\n        }\n      });\n\n      hls.loadSource('/transcode/' + transcodeSessionId + '/stream.m3u8');\n      hls.attachMedia(video);\n      return true;\n    } catch (e) {\n      console.error('[SEEK] Error:', e);\n      seekInProgress = false;\n      showError();\n      return false;\n    }\n  }\n\n  async function startTranscode(onError) {\n    showLoading();\n    await cleanupTranscode();\n    try {\n      let url = '/transcode/start?url=' + encodeURIComponent(cfg.rawUrl) + '&content_type=' + cfg.streamType;\n      if (cfg.seriesId) url += '&series_id=' + cfg.seriesId;\n      if (cfg.episodeId) url += '&episode_id=' + cfg.episodeId;\n      if (cfg.seriesName) url += '&series_name=' + encodeURIComponent(cfg.seriesName);\n      if (cfg.deinterlaceFallback !== undefined) url += '&deinterlace_fallback=' + (cfg.deinterlaceFallback ? '1' : '0');\n      if (cfg.sourceId) url += '&source_id=' + encodeURIComponent(cfg.sourceId);\n      const resp = await fetch(url);\n      if (!resp.ok) throw new Error('Transcode start failed: ' + resp.status);\n      const data = await resp.json();\n      transcodeSessionId = data.session_id;\n      isTranscoding = true;\n      updateTranscodeCheck();\n      totalDuration = data.duration || 0;\n      seekOffset = data.seek_offset || 0;\n      transcodedDuration = data.transcoded_duration || 0;\n      currentSubtitles = data.subtitles || null;\n      if (cfg.isVod) {\n        document.getElementById('menu-jump')?.classList.remove('hidden');\n        document.getElementById('progress-container')?.classList.remove('hidden');\n        if (totalDuration > 0) {\n          document.getElementById('seek-duration').textContent = '/ ' + formatTime(totalDuration);\n          document.getElementById('time-duration').textContent = formatTime(totalDuration);\n        }\n        enableCcButton();\n      }\n      playWithUrl(data.playlist, onError, data.subtitles);\n    } catch (e) {\n      console.error('[TC] Error:', e);\n      if (onError) onError();\n      else showError();\n    }\n  }\n\n  // ============================================================\n  // Playback\n  // ============================================================\n\n  function playWithUrl(url, onError, subtitles) {\n    showLoading();\n    const useHls = Hls.isSupported() && (\n      url.includes('.m3u8') || url.includes('/live/') || url.includes('/transcode')\n    );\n    if (!useHls) {\n      video.src = url;\n      video.addEventListener('loadedmetadata', function() {\n        hideLoading();\n        error.classList.add('hidden');\n        if (video.textTracks.length === 0) disableCcButton();\n        applyCaptionsSetting();\n        restorePosition();\n        video.play().catch(() => { if (!video.muted) { autoMutedByPolicy = true; video.muted = true; } video.play(); });\n      }, { once: true });\n      video.addEventListener('error', function() {\n        if (onError) onError();\n        else showError();\n      }, { once: true });\n      return;\n    }\n\n    const isVodUrl = url.includes('/transcode');\n    const hls = new Hls(createHlsConfig({isVod: isVodUrl}));\n    currentHls = hls;\n    let recoveryAttempts = 0;\n    let hasLoaded = false;\n\n    hls.loadSource(url);\n    hls.attachMedia(video);\n\n    // Timeout for initial load (Auto mode only)\n    let loadTimeout = null;\n    if (onError) {\n      loadTimeout = setTimeout(() => {\n        if (!hasLoaded) {\n          console.log('[AUTO] Load timeout, triggering transcode');\n          hls.destroy();\n          currentHls = null;\n          onError();\n        }\n      }, 10000);\n    }\n\n    // Check for missing audio (Auto mode only)\n    if (onError) {\n      let audioChecked = false;\n      video.addEventListener('timeupdate', function checkAudio() {\n        if (audioChecked || video.currentTime < 1) return;\n        audioChecked = true;\n        video.removeEventListener('timeupdate', checkAudio);\n        let hasAudio = false;\n        if (typeof video.webkitAudioDecodedByteCount !== 'undefined') {\n          hasAudio = video.webkitAudioDecodedByteCount > 0;\n        } else if (typeof video.mozHasAudio !== 'undefined') {\n          hasAudio = video.mozHasAudio;\n        } else if (video.audioTracks && video.audioTracks.length > 0) {\n          hasAudio = true;\n        }\n        console.log('[AUTO] Audio check: hasAudio=' + hasAudio + ', webkitAudioDecodedByteCount=' + video.webkitAudioDecodedByteCount);\n        if (!hasAudio) {\n          console.log('[AUTO] No audio detected, triggering transcode');\n          hls.destroy();\n          currentHls = null;\n          onError();\n        }\n      });\n    }\n\n    hls.on(Hls.Events.MANIFEST_PARSED, () => {\n      if (loadTimeout) clearTimeout(loadTimeout);\n      hideLoading();\n      error.classList.add('hidden');\n      hasLoaded = true;\n      recoveryAttempts = 0;\n      if (subtitles && subtitles.length > 0) {\n        startSubtitlePolling(subtitles, getPreferredSubtitleTrack(subtitles));\n      }\n      if (cfg.captionsEnabled && hls.subtitleTracks.length > 0) {\n        hls.subtitleTrack = getPreferredSubtitleTrack(hls.subtitleTracks);\n      }\n      if (transcodeSessionId && isVodUrl) startProgressPolling();\n      restorePosition();\n      video.play().catch(() => { if (!video.muted) { autoMutedByPolicy = true; video.muted = true; } video.play(); });\n      setTimeout(() => {\n        if (hls.subtitleTracks.length === 0 && video.textTracks.length === 0) disableCcButton();\n      }, 1000);\n    });\n\n    hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => {\n      if (hls.subtitleTracks.length > 0 && ccBtn.disabled) {\n        enableCcButton();\n        if (cfg.captionsEnabled) hls.subtitleTrack = getPreferredSubtitleTrack(hls.subtitleTracks);\n      } else if (hls.subtitleTracks.length === 0 && !ccBtn.disabled) {\n        disableCcButton();\n      }\n    });\n\n    video.textTracks.addEventListener('addtrack', (e) => {\n      if (e.track.kind === 'captions' || e.track.kind === 'subtitles') {\n        applyCaptionsSetting();\n        if (ccBtn.disabled) enableCcButton();\n      }\n    });\n\n    hls.on(Hls.Events.ERROR, (event, data) => {\n      if (data.fatal) {\n        recoveryAttempts++;\n        if (recoveryAttempts <= 3) {\n          if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();\n          else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();\n          else {\n            hls.destroy();\n            currentHls = null;\n            if (!hasLoaded && onError) onError();\n            else showError();\n          }\n        } else {\n          hls.destroy();\n          currentHls = null;\n          if (!hasLoaded && onError) onError();\n          else showError();\n        }\n      }\n    });\n  }\n\n  // ============================================================\n  // Controls\n  // ============================================================\n\n  function setupKeyboardControls() {\n    document.addEventListener('keydown', (e) => {\n      // In seek input: allow player hotkeys, block other non-time chars\n      if (e.target.id === 'seek-input') {\n        const passthrough = ['j', 'm', 'f', ' ', 'k', 'c', 'i', 'Escape'];\n        if (!passthrough.includes(e.key) && !/^[0-9:]$/.test(e.key) &&\n            !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Tab', 'Enter'].includes(e.key)) {\n          e.preventDefault();\n          return;\n        }\n        // Let passthrough keys fall through to main handler below\n        if (!passthrough.includes(e.key)) return;\n      }\n      // Skip other input fields entirely\n      else if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {\n        if (e.key === 'Escape') e.target.blur();\n        return;\n      }\n      switch(e.key) {\n        case ' ':\n        case 'k':\n          if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') return;\n          e.preventDefault();\n          video.paused ? video.play() : video.pause();\n          break;\n        case 'ArrowLeft': e.preventDefault(); video.currentTime -= 10; break;\n        case 'ArrowRight': e.preventDefault(); video.currentTime += 10; break;\n        case 'ArrowUp': e.preventDefault(); video.volume = Math.min(1, video.volume + 0.1); break;\n        case 'ArrowDown': e.preventDefault(); video.volume = Math.max(0, video.volume - 0.1); break;\n        case 'f':\n          e.preventDefault();\n          suppressShowControls = true;\n          setTimeout(() => suppressShowControls = false, 150);\n          if (document.fullscreenElement) document.exitFullscreen();\n          else document.getElementById('player-container').requestFullscreen();\n          break;\n        case 'm': video.muted = !video.muted; updateMuteIcon(); break;\n        case 't': document.getElementById('menu-transcode')?.click(); break;\n        case 'c': ccBtn.click(); break;\n        case 'i': document.getElementById('info-btn')?.click(); break;\n        case 'a': document.getElementById('cast-btn')?.click(); break;\n        case 'x': document.getElementById('menu-restart')?.click(); break;\n        case 'j':\n          e.preventDefault();\n          if (cfg.isVod) document.getElementById('jump-btn')?.click();\n          break;\n        case 'n': document.getElementById('autonext-btn')?.click(); break;\n        case 'p': document.getElementById('pip-btn')?.click(); break;\n        case 'h':\n          const container = document.getElementById('player-container');\n          if (container.classList.contains('controls-visible')) {\n            container.classList.remove('controls-visible');\n            clearTimeout(activityTimeoutId);\n            activityTimeoutId = null;\n          } else {\n            container.classList.add('controls-visible');\n            clearTimeout(activityTimeoutId);\n            activityTimeoutId = setTimeout(() => {\n              if (!settingsMenu.classList.contains('open')) {\n                container.classList.remove('controls-visible');\n                activityTimeoutId = null;\n              }\n            }, 3000);\n          }\n          break;\n        case 'Escape': {\n          const seekContainer = document.getElementById('seek-container');\n          const infoOverlay = document.getElementById('info-overlay');\n          if (seekContainer && !seekContainer.classList.contains('hidden')) {\n            seekContainer.classList.add('hidden');\n          } else if (infoOverlay?.classList.contains('pinned')) {\n            infoOverlay.classList.remove('pinned');\n            document.getElementById('info-btn')?.classList.remove('active');\n            saveSettings({ infoPinned: false });\n          } else if (settingsMenu.classList.contains('open')) {\n            settingsMenu.classList.remove('open');\n          } else if (!document.fullscreenElement) {\n            if (history.length > 1) history.back();\n            else window.location.href = '/guide';\n          }\n          break;\n        }\n      }\n    });\n  }\n\n  let activityTimeoutId = null;\n  let autoNextEnabled = true;\n  let suppressShowControls = false;\n\n  // Persistent settings\n  const STORAGE_KEY = 'playerSettings';\n  function loadSettings() {\n    try {\n      return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};\n    } catch { return {}; }\n  }\n  function saveSettings(updates) {\n    const settings = { ...loadSettings(), ...updates };\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));\n  }\n\n  function setupActivityTracking() {\n    const container = document.getElementById('player-container');\n    const HIDE_DELAY = 3000;\n\n    function showControls() {\n      if (suppressShowControls) return;\n      container.classList.add('controls-visible');\n      clearTimeout(activityTimeoutId);\n      activityTimeoutId = setTimeout(hideControls, HIDE_DELAY);\n    }\n\n    function hideControls() {\n      if (settingsMenu.classList.contains('open')) return;\n      if (document.getElementById('seek-container')?.classList.contains('hidden') === false) return;\n      container.classList.remove('controls-visible');\n      activityTimeoutId = null;\n    }\n\n    container.addEventListener('mousemove', showControls);\n    container.addEventListener('mouseenter', showControls);\n    container.addEventListener('click', showControls);\n    container.addEventListener('mouseleave', () => {\n      clearTimeout(activityTimeoutId);\n      activityTimeoutId = null;\n      hideControls();\n    });\n    showControls();\n  }\n\n  function setupButtonHandlers() {\n    const seekContainer = document.getElementById('seek-container');\n    const seekInput = document.getElementById('seek-input');\n\n    // Play/Pause button\n    document.getElementById('play-btn')?.addEventListener('click', () => {\n      video.paused ? video.play() : video.pause();\n    });\n\n    // Click video to toggle play/pause and show controls\n    video.addEventListener('click', (e) => {\n      if (e.target !== video) return;\n      video.paused ? video.play() : video.pause();\n      const container = document.getElementById('player-container');\n      container.classList.add('controls-visible');\n      clearTimeout(activityTimeoutId);\n      activityTimeoutId = setTimeout(() => {\n        if (!settingsMenu.classList.contains('open') && seekContainer.classList.contains('hidden')) {\n          container.classList.remove('controls-visible');\n          activityTimeoutId = null;\n        }\n      }, 3000);\n    });\n\n    // Mute button\n    document.getElementById('mute-btn')?.addEventListener('click', () => {\n      video.muted = !video.muted;\n      updateMuteIcon();\n      saveSettings({ muted: video.muted });\n    });\n\n    // Volume slider\n    const volSlider = document.getElementById('volume-slider');\n    volSlider?.addEventListener('input', () => {\n      video.volume = parseFloat(volSlider.value);\n      video.muted = false;\n      updateMuteIcon();\n      saveSettings({ volume: video.volume, muted: false });\n    });\n    video.addEventListener('volumechange', () => {\n      if (volSlider) volSlider.value = video.muted ? 0 : video.volume;\n    });\n\n    // Jump button\n    document.getElementById('jump-btn')?.addEventListener('click', (e) => {\n      e.stopPropagation();\n      seekContainer.classList.toggle('hidden');\n      if (!seekContainer.classList.contains('hidden')) {\n        // Show controls and prevent auto-hide while jump input is active\n        document.getElementById('player-container').classList.add('controls-visible');\n        clearTimeout(activityTimeoutId);\n        activityTimeoutId = null;\n        seekInput.value = '';\n        seekInput.focus();\n      }\n    });\n\n    // Auto-next button\n    const autoNextBtn = document.getElementById('autonext-btn');\n    autoNextBtn?.addEventListener('click', (e) => {\n      e.stopPropagation();\n      autoNextEnabled = !autoNextEnabled;\n      autoNextBtn.classList.toggle('active', autoNextEnabled);\n    });\n\n    // Info button\n    const infoOverlay = document.getElementById('info-overlay');\n    const infoBtn = document.getElementById('info-btn');\n    infoBtn?.addEventListener('click', (e) => {\n      e.stopPropagation();\n      const wasPinned = infoOverlay.classList.contains('pinned');\n      infoOverlay.classList.toggle('pinned');\n      infoBtn.classList.toggle('active', !wasPinned);\n      saveSettings({ infoPinned: !wasPinned });\n      if (wasPinned && !activityTimeoutId) {\n        document.getElementById('player-container').classList.remove('controls-visible');\n      }\n    });\n\n    // CC button\n    ccBtn.addEventListener('click', function(e) {\n      e.stopPropagation();\n      ccEnabled = !ccEnabled;\n      if (activeTrackStates && activeTrackStates.length > 0) {\n        const prefIdx = getPreferredSubtitleTrack(activeTrackStates.map(ts => ({lang: ts.track.language, label: ts.track.label})));\n        activeTrackStates.forEach((ts, i) => ts.track.mode = (ccEnabled && i === prefIdx) ? 'showing' : 'hidden');\n      } else {\n        const tracks = Array.from(video.textTracks).filter(t => (t.kind === 'subtitles' || t.kind === 'captions') && t.mode !== 'disabled');\n        const prefIdx = getPreferredSubtitleTrack(tracks.map(t => ({lang: t.language, label: t.label})));\n        tracks.forEach((t, i) => t.mode = (ccEnabled && i === prefIdx) ? 'showing' : 'hidden');\n      }\n      if (currentHls) {\n        currentHls.subtitleDisplay = ccEnabled;\n        if (ccEnabled && currentHls.subtitleTracks?.length > 0) {\n          currentHls.subtitleTrack = getPreferredSubtitleTrack(currentHls.subtitleTracks);\n        }\n      }\n      updateCcButton();\n      saveSettings({ ccEnabled });\n    });\n\n    // PiP button\n    const pipBtn = document.getElementById('pip-btn');\n    if (pipBtn && document.pictureInPictureEnabled) {\n      pipBtn.addEventListener('click', async (e) => {\n        e.stopPropagation();\n        try {\n          if (document.pictureInPictureElement) {\n            await document.exitPictureInPicture();\n          } else {\n            await video.requestPictureInPicture();\n          }\n        } catch (err) {\n          console.error('[PiP] Error:', err);\n        }\n      });\n    } else if (pipBtn) {\n      pipBtn.style.display = 'none';\n    }\n\n    // Settings menu toggle\n    document.getElementById('settings-btn')?.addEventListener('click', () => {\n      settingsMenu.classList.toggle('open');\n    });\n\n    // Close settings menu when clicking outside\n    document.addEventListener('click', (e) => {\n      if (!e.target.closest('#settings-btn') && !e.target.closest('#settings-menu')) {\n        settingsMenu.classList.remove('open');\n      }\n    });\n\n    // CC Track selection\n    let selectedCcTrackIdx = 0;\n    const ccTracksMenuItem = document.getElementById('menu-cc-tracks');\n\n    function getCcTracks() {\n      const tracks = [];\n      if (activeTrackStates?.length > 0) {\n        activeTrackStates.forEach((ts, i) => tracks.push({ idx: i, label: ts.track.label || `Track ${i + 1}`, lang: ts.track.language }));\n      } else if (currentHls?.subtitleTracks?.length > 0) {\n        currentHls.subtitleTracks.forEach((t, i) => tracks.push({ idx: i, label: t.name || `Track ${i + 1}`, lang: t.lang }));\n      } else {\n        Array.from(video.textTracks).filter(t => t.kind === 'subtitles' || t.kind === 'captions')\n          .forEach((t, i) => tracks.push({ idx: i, label: t.label || `Track ${i + 1}`, lang: t.language }));\n      }\n      return tracks;\n    }\n\n    function selectCcTrack(idx) {\n      ccEnabled = true;\n      updateCcButton();\n      if (activeTrackStates?.length > 0) {\n        activeTrackStates.forEach((ts, i) => ts.track.mode = i === idx ? 'showing' : 'hidden');\n      } else if (currentHls?.subtitleTracks?.length > 0) {\n        currentHls.subtitleTrack = idx;\n        currentHls.subtitleDisplay = true;\n        // iOS: also set TextTrack.mode directly (HLS.js property change doesn't always trigger render)\n        const tracks = Array.from(video.textTracks).filter(\n          t => t.kind === 'subtitles' || t.kind === 'captions'\n        );\n        tracks.forEach((t, i) => t.mode = i === idx ? 'showing' : 'hidden');\n      } else {\n        const tracks = Array.from(video.textTracks).filter(t => t.kind === 'subtitles' || t.kind === 'captions');\n        tracks.forEach((t, i) => t.mode = i === idx ? 'showing' : 'hidden');\n      }\n    }\n\n    function updateCcTracksLabel() {\n      if (!ccTracksMenuItem) return;\n      const tracks = getCcTracks();\n      const label = tracks.length > 0 && tracks[selectedCcTrackIdx]\n        ? `CC: ${tracks[selectedCcTrackIdx].label}`\n        : 'CC Track';\n      ccTracksMenuItem.innerHTML = `<span class=\"settings-check\"></span>${label}`;\n    }\n\n    ccTracksMenuItem?.addEventListener('click', () => {\n      const tracks = getCcTracks();\n      if (tracks.length === 0) return;\n      selectedCcTrackIdx = (selectedCcTrackIdx + 1) % tracks.length;\n      selectCcTrack(selectedCcTrackIdx);\n      updateCcTracksLabel();\n    });\n\n    // Settings menu items\n    document.getElementById('menu-transcode')?.addEventListener('click', async () => {\n      settingsMenu.classList.remove('open');\n      video.pause();\n      video.src = '';\n      error.classList.add('hidden');\n      if (isTranscoding) {\n        await cleanupTranscode();\n        isTranscoding = false;\n        updateTranscodeCheck();\n        playWithUrl(cfg.rawUrl);\n      } else {\n        await startTranscode();\n      }\n    });\n\n    document.getElementById('menu-restart')?.addEventListener('click', async () => {\n      settingsMenu.classList.remove('open');\n      video.pause();\n      video.src = '';\n      error.classList.add('hidden');\n      try {\n        await fetch('/transcode-clear?url=' + encodeURIComponent(cfg.rawUrl), {method: 'DELETE'});\n        await cleanupTranscode();\n        isTranscoding = false;\n        await startTranscode();\n      } catch (e) {\n        console.error('[X] Error:', e);\n        showError();\n      }\n    });\n\n    document.getElementById('menu-jump')?.addEventListener('click', () => {\n      settingsMenu.classList.remove('open');\n      seekContainer.classList.toggle('hidden');\n      if (!seekContainer.classList.contains('hidden')) {\n        // Show controls and prevent auto-hide while jump input is active\n        document.getElementById('player-container').classList.add('controls-visible');\n        clearTimeout(activityTimeoutId);\n        activityTimeoutId = null;\n        seekInput.value = '';\n        seekInput.focus();\n      }\n    });\n\n    document.getElementById('menu-url')?.addEventListener('click', () => {\n      settingsMenu.classList.remove('open');\n      if (navigator.clipboard) {\n        navigator.clipboard.writeText(cfg.rawUrl).catch(fallback);\n      } else {\n        fallback();\n      }\n      function fallback() {\n        const ta = document.createElement('textarea');\n        ta.value = cfg.rawUrl;\n        ta.style.position = 'fixed';\n        ta.style.opacity = '0';\n        document.body.appendChild(ta);\n        ta.select();\n        document.execCommand('copy');\n        document.body.removeChild(ta);\n      }\n    });\n\n    document.getElementById('menu-external')?.addEventListener('click', () => {\n      settingsMenu.classList.remove('open');\n      video.pause();\n      video.src = '';\n      window.location.href = '/playlist.xspf?url=' + encodeURIComponent(cfg.rawUrl);\n    });\n\n    // Fullscreen button\n    document.getElementById('fullscreen-btn')?.addEventListener('click', () => {\n      if (document.fullscreenElement) document.exitFullscreen();\n      else document.getElementById('player-container').requestFullscreen();\n    });\n\n    // Fullscreen change listener\n    document.addEventListener('fullscreenchange', updateFullscreenIcon);\n\n    // Video state listeners\n    video.addEventListener('play', updatePlayIcon);\n    video.addEventListener('pause', updatePlayIcon);\n    video.addEventListener('volumechange', updateMuteIcon);\n\n    // Prevent video element's native spacebar handling\n    video.addEventListener('keydown', (e) => {\n      if (e.key === ' ') e.preventDefault();\n    });\n\n    // Mousewheel volume control\n    document.getElementById('player-container')?.addEventListener('wheel', (e) => {\n      e.preventDefault();\n      video.volume = Math.max(0, Math.min(1, video.volume + (e.deltaY < 0 ? 0.05 : -0.05)));\n      saveSettings({ volume: video.volume });\n    }, { passive: false });\n\n    // Progress bar\n    const progressBar = document.getElementById('progress-bar');\n    const progressPlayed = document.getElementById('progress-played');\n    const progressHandle = document.getElementById('progress-handle');\n    const progressBuffered = document.getElementById('progress-buffered');\n    const timeCurrent = document.getElementById('time-current');\n    const timeDuration = document.getElementById('time-duration');\n\n    function updateProgress() {\n      const duration = totalDuration || video.duration || 0;\n      if (!duration) return;\n      const currentTime = video.currentTime + seekOffset;\n      const pct = (currentTime / duration) * 100;\n      if (progressPlayed) progressPlayed.style.width = pct + '%';\n      if (progressHandle) progressHandle.style.left = pct + '%';\n      if (timeCurrent) timeCurrent.textContent = formatTime(currentTime);\n      if (timeDuration) timeDuration.textContent = formatTime(duration);\n    }\n\n    video.addEventListener('timeupdate', updateProgress);\n    video.addEventListener('loadedmetadata', () => {\n      if (cfg.isVod && !transcodeSessionId) {\n        document.getElementById('progress-container')?.classList.remove('hidden');\n        document.getElementById('menu-jump')?.classList.remove('hidden');\n      }\n      updateProgress();\n    });\n\n    progressBar?.addEventListener('click', async (e) => {\n      const rect = progressBar.getBoundingClientRect();\n      const pct = (e.clientX - rect.left) / rect.width;\n      const duration = totalDuration || video.duration || 0;\n      if (!duration) return;\n      const targetTime = pct * duration;\n      if (transcodeSessionId) {\n        const actualTranscodedEnd = seekOffset + transcodedDuration;\n        if (targetTime >= seekOffset && targetTime <= actualTranscodedEnd + 10) {\n          video.currentTime = targetTime - seekOffset;\n        } else {\n          await handleSeekToPosition(targetTime);\n        }\n      } else {\n        video.currentTime = targetTime;\n      }\n    });\n\n    // Seek input handler - filter chars here (must be at input level to block typing)\n    seekInput?.addEventListener('keydown', async function(e) {\n      // Only allow: digits, colon, navigation keys, Enter\n      // Hotkeys and other chars: preventDefault (hotkeys will still bubble to global handler)\n      const typeable = /^[0-9:]$/.test(e.key) ||\n        ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Tab'].includes(e.key);\n      if (!typeable) e.preventDefault();\n      if (e.key !== 'Enter') return;\n      e.preventDefault();\n      const targetTime = parseTime(seekInput.value);\n      if (targetTime < 0 || targetTime > totalDuration) {\n        seekInput.classList.add('ring-2', 'ring-red-500');\n        setTimeout(() => seekInput.classList.remove('ring-2', 'ring-red-500'), 500);\n        return;\n      }\n      seekContainer.classList.add('hidden');\n      const actualTranscodedEnd = seekOffset + transcodedDuration;\n      if (targetTime >= seekOffset && targetTime <= actualTranscodedEnd + 10) {\n        video.currentTime = targetTime - seekOffset;\n        return;\n      }\n      await handleSeekToPosition(targetTime);\n    });\n  }\n\n  // ============================================================\n  // Cast (Chromecast)\n  // ============================================================\n\n  function setupCast() {\n    if (!cfg.isHttps) return;\n    const castBtn = document.getElementById('cast-btn');\n    if (!castBtn) return;\n\n    function getCastUrl() {\n      const host = cfg.castHost || window.location.host;\n      const proto = window.location.protocol;\n      if (transcodeSessionId) {\n        return proto + '//' + host + '/transcode/' + transcodeSessionId + '/stream.m3u8';\n      }\n      if (cfg.rawUrl.includes('localhost') || cfg.rawUrl.includes('127.0.0.1')) {\n        return cfg.rawUrl.replace(/localhost|127\\.0\\.0\\.1/, host.split(':')[0]);\n      }\n      return cfg.rawUrl;\n    }\n\n    function castLog(msg) {\n      fetch('/api/cast-log', {method: 'POST', body: msg}).catch(() => {});\n    }\n\n    function initCast() {\n      cast.framework.CastContext.getInstance().setOptions({\n        receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,\n        autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,\n      });\n      castBtn.disabled = false;\n      cast.framework.CastContext.getInstance().addEventListener(\n        cast.framework.CastContextEventType.SESSION_STATE_CHANGED, (e) => {\n          const connected = e.sessionState === cast.framework.SessionState.SESSION_STARTED ||\n                            e.sessionState === cast.framework.SessionState.SESSION_RESUMED;\n          castBtn.classList.toggle('active', connected);\n        }\n      );\n    }\n\n    function loadMediaToCast() {\n      const session = cast.framework.CastContext.getInstance().getCurrentSession();\n      if (!session) {\n        castLog('No active session');\n        return;\n      }\n      const url = getCastUrl();\n      castLog('URL: ' + url + ' isVod=' + cfg.isVod + ' seek=' + (video.currentTime + seekOffset).toFixed(1));\n      const mediaInfo = new chrome.cast.media.MediaInfo(url, 'application/x-mpegurl');\n      mediaInfo.streamType = chrome.cast.media.StreamType.LIVE;\n      mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata();\n      mediaInfo.metadata.title = cfg.mediaTitle;\n      if (chrome.cast.media.HlsSegmentFormat) mediaInfo.hlsSegmentFormat = chrome.cast.media.HlsSegmentFormat.TS;\n      if (chrome.cast.media.HlsVideoSegmentFormat) mediaInfo.hlsVideoSegmentFormat = chrome.cast.media.HlsVideoSegmentFormat.MPEG2_TS;\n      const request = new chrome.cast.media.LoadRequest(mediaInfo);\n      request.autoplay = true;\n      request.currentTime = video.currentTime + seekOffset;\n      castLog('streamType=' + mediaInfo.streamType);\n      session.loadMedia(request).then(\n        () => {\n          castLog('Media loaded OK');\n          video.pause();\n          const media = session.getMediaSession();\n          if (media) {\n            media.addUpdateListener((isAlive) => {\n              if (!isAlive) { castLog('Session ended'); return; }\n              const ps = media.playerState;\n              const idle = media.idleReason;\n              castLog('State: ' + ps + (idle ? ' (' + idle + ')' : ''));\n            });\n          }\n        },\n        (e) => {\n          const code = e?.code || 'unknown';\n          const desc = e?.description || e?.message || String(e);\n          castLog('LOAD FAILED: code=' + code + ' desc=' + desc);\n        }\n      );\n    }\n\n    let castDialogClosedAt = 0;\n    castBtn.addEventListener('click', function(e) {\n      e.stopPropagation();\n      e.preventDefault();\n      this.blur();\n      settingsMenu.classList.remove('open');\n      if (!window.cast || !cast.framework) {\n        alert('Cast not available.\\n\\nTry accessing via your LAN IP instead of 0.0.0.0');\n        return;\n      }\n      if (Date.now() - castDialogClosedAt < 1000) return;\n      const ctx = cast.framework.CastContext.getInstance();\n      ctx.requestSession().then(\n        () => { castDialogClosedAt = Date.now(); loadMediaToCast(); },\n        () => { castDialogClosedAt = Date.now(); }\n      );\n    });\n\n    let pollCount = 0;\n    const castPoll = setInterval(() => {\n      if (window.cast && cast.framework) {\n        clearInterval(castPoll);\n        console.log('[CAST] SDK ready');\n        initCast();\n      } else if (++pollCount > 30) {\n        clearInterval(castPoll);\n        console.log('[CAST] SDK timeout');\n        castBtn.disabled = false;\n      }\n    }, 100);\n  }\n\n  // ============================================================\n  // Initialization\n  // ============================================================\n\n  function init() {\n    // Restore persistent settings\n    const settings = loadSettings();\n    if (settings.volume !== undefined) video.volume = settings.volume;\n    if (settings.muted !== undefined) video.muted = settings.muted;\n    if (settings.ccEnabled !== undefined) ccEnabled = settings.ccEnabled;\n\n    applyCaptionStyles();\n    setupPositionTracking();\n    setupKeyboardControls();\n    setupButtonHandlers();\n    setupActivityTracking();\n    setupCast();\n    updateTranscodeCheck();\n    updateCcButton();\n    updateMuteIcon();\n    document.getElementById('volume-slider').value = video.muted ? 0 : video.volume;\n\n    // Restore volume on first user interaction if auto-muted by browser policy\n    function restoreVolumeOnInteraction() {\n      if (autoMutedByPolicy) {\n        video.muted = false;\n        updateMuteIcon();\n        autoMutedByPolicy = false;\n      }\n    }\n    document.addEventListener('click', restoreVolumeOnInteraction, { once: true });\n    document.addEventListener('keydown', restoreVolumeOnInteraction, { once: true });\n\n    // Restore info pinned state\n    if (settings.infoPinned) {\n      document.getElementById('info-overlay')?.classList.add('pinned');\n      document.getElementById('info-btn')?.classList.add('active');\n    }\n\n\n    window.addEventListener('beforeunload', cleanupTranscodeSync);\n    window.addEventListener('pagehide', cleanupTranscodeSync);\n\n    // Start playback based on transcode mode\n    if (cfg.transcodeMode === 'always') {\n      startTranscode();\n    } else if (cfg.transcodeMode === 'never') {\n      playWithUrl(cfg.rawUrl);\n    } else {\n      playWithUrl(cfg.rawUrl, () => {\n        error.classList.add('hidden');\n        startTranscode();\n      });\n    }\n  }\n\n  init();\n})();\n"
  },
  {
    "path": "static/js/settings.js",
    "content": "// Settings Page Module\n(function() {\n  'use strict';\n\n  const cfg = window.SETTINGS_CONFIG || {};\n\n  // ============================================================\n  // Shared Helpers\n  // ============================================================\n\n  function escapeHtml(s) {\n    if (!s) return '';\n    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n  }\n\n  function showFeedback(el, success) {\n    if (!el) return;\n    const cls = success ? 'ring-green-500' : 'ring-red-500';\n    el.classList.add('ring-2', cls);\n    setTimeout(() => el.classList.remove('ring-2', cls), success ? 500 : 1000);\n  }\n\n  async function saveWithFeedback(url, options, feedbackEl) {\n    try {\n      const resp = await fetch(url, options);\n      showFeedback(feedbackEl, resp.ok);\n      return resp;\n    } catch (e) {\n      console.error('Save failed:', e);\n      showFeedback(feedbackEl, false);\n      return null;\n    }\n  }\n\n  function getFeedbackEl(el) {\n    if (!el) return null;\n    if (el.type === 'radio' || el.type === 'checkbox') return el.closest('label') || el;\n    return el;\n  }\n\n  // ============================================================\n  // Drag-Drop Helper\n  // ============================================================\n\n  function setupDragDrop(containerSelector, chipSelector, onDrop) {\n    let draggedChip = null;\n\n    document.querySelectorAll(chipSelector).forEach(chip => {\n      chip.addEventListener('dragstart', e => {\n        draggedChip = chip;\n        e.dataTransfer.effectAllowed = 'move';\n        chip.classList.add('opacity-50');\n      });\n      chip.addEventListener('dragend', () => {\n        chip.classList.remove('opacity-50');\n        draggedChip = null;\n      });\n      chip.addEventListener('dragover', e => {\n        e.preventDefault();\n        if (draggedChip && draggedChip !== chip) {\n          chip.classList.add('border-t-2', 'border-blue-500');\n        }\n      });\n      chip.addEventListener('dragleave', () => {\n        chip.classList.remove('border-t-2', 'border-blue-500');\n      });\n      chip.addEventListener('drop', e => {\n        e.preventDefault();\n        e.stopPropagation();\n        chip.classList.remove('border-t-2', 'border-blue-500');\n        if (draggedChip && draggedChip !== chip) {\n          chip.parentElement.insertBefore(draggedChip, chip);\n          onDrop?.(chip.parentElement, draggedChip);\n        }\n      });\n    });\n\n    document.querySelectorAll(containerSelector).forEach(container => {\n      container.addEventListener('dragover', e => {\n        e.preventDefault();\n        e.dataTransfer.dropEffect = 'move';\n        container.classList.add('border-blue-500');\n      });\n      container.addEventListener('dragleave', e => {\n        if (!container.contains(e.relatedTarget)) {\n          container.classList.remove('border-blue-500');\n        }\n      });\n      container.addEventListener('drop', e => {\n        e.preventDefault();\n        container.classList.remove('border-blue-500');\n        if (draggedChip && draggedChip.parentElement !== container) {\n          container.appendChild(draggedChip);\n          onDrop?.(container, draggedChip);\n        }\n      });\n    });\n  }\n\n  function setupSearch(inputId, clearBtnId, chipSelector) {\n    const input = document.getElementById(inputId);\n    const clearBtn = document.getElementById(clearBtnId);\n    if (!input) return;\n\n    function apply() {\n      const q = input.value.toLowerCase();\n      document.querySelectorAll(chipSelector).forEach(el => {\n        el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';\n      });\n      clearBtn?.classList.toggle('hidden', !input.value);\n    }\n\n    input.addEventListener('input', apply);\n    clearBtn?.addEventListener('click', () => { input.value = ''; apply(); });\n  }\n\n  // ============================================================\n  // Global Functions (used by inline handlers)\n  // ============================================================\n\n  window.togglePwdVis = function(btn) {\n    const input = btn.parentElement.querySelector('input[type=\"password\"], input[type=\"text\"]');\n    if (!input) return;\n    const isPassword = input.type === 'password';\n    input.type = isPassword ? 'text' : 'password';\n    btn.querySelector('.eye-off')?.classList.toggle('hidden', isPassword);\n    btn.querySelector('.eye-on')?.classList.toggle('hidden', !isPassword);\n  };\n\n  window.toggleSourceFields = function(select) {\n    const form = select.closest('form');\n    const isXtream = select.value === 'xtream';\n    const isEpg = select.value === 'epg';\n    form.querySelector('.xtream-fields')?.style.setProperty('display', isXtream ? 'grid' : 'none');\n    form.querySelector('.non-epg-only')?.style.setProperty('display', isEpg ? 'none' : 'block');\n    form.querySelector('.epg-url-field')?.style.setProperty('display', isEpg ? 'none' : 'block');\n  };\n\n  window.showDeleteSelfModal = function() {\n    document.getElementById('delete-self-modal')?.classList.remove('hidden');\n    const pwInput = document.getElementById('delete-self-password');\n    if (pwInput) { pwInput.value = ''; pwInput.focus(); }\n    const msg = document.getElementById('delete-self-msg');\n    if (msg) msg.textContent = '';\n  };\n\n  window.hideDeleteSelfModal = function() {\n    document.getElementById('delete-self-modal')?.classList.add('hidden');\n  };\n\n  window.submitDeleteSelf = async function(e) {\n    e.preventDefault();\n    const pw = document.getElementById('delete-self-password')?.value;\n    const msgEl = document.getElementById('delete-self-msg');\n    if (!pw) return;\n    const form = new FormData();\n    form.append('password', pw);\n    try {\n      const resp = await fetch('/settings/users/delete/' + cfg.currentUser, { method: 'POST', body: form });\n      if (resp.ok || resp.redirected) {\n        window.location.href = '/login';\n      } else {\n        const data = await resp.json();\n        if (msgEl) msgEl.textContent = data.detail || 'Failed';\n      }\n    } catch (e) {\n      console.error('Delete self failed:', e);\n      if (msgEl) msgEl.textContent = 'Request failed';\n    }\n  };\n\n  // ============================================================\n  // Add Source Type Select\n  // ============================================================\n\n  function setupSourceTypeSelect() {\n    const typeSelect = document.getElementById('source-type');\n    if (!typeSelect) return;\n\n    typeSelect.addEventListener('change', function() {\n      const isXtream = this.value === 'xtream';\n      const isM3u = this.value === 'm3u';\n      const isEpg = this.value === 'epg';\n\n      document.getElementById('xtream-fields')?.style.setProperty('display', isXtream ? 'grid' : 'none');\n      document.getElementById('epg-enabled-field')?.style.setProperty('display', isEpg ? 'none' : 'block');\n\n      const deinterlaceField = document.getElementById('deinterlace-field');\n      if (deinterlaceField) {\n        deinterlaceField.style.display = isEpg ? 'none' : 'block';\n        const cb = deinterlaceField.querySelector('input[name=\"deinterlace_fallback\"]');\n        if (cb) cb.checked = isM3u;\n      }\n\n      document.getElementById('max-streams-field')?.style.setProperty('display', isEpg ? 'none' : 'block');\n\n      const urlInput = document.querySelector('#add-source-form input[name=\"url\"]');\n      if (urlInput) {\n        const placeholders = { xtream: 'https://server.com', m3u: 'http://server.com/playlist.m3u', epg: 'http://server.com/epg.xml' };\n        urlInput.placeholder = placeholders[this.value] || placeholders.xtream;\n      }\n    });\n  }\n\n  // ============================================================\n  // Source Edit Auto-Save\n  // ============================================================\n\n  function setupSourceEditForms() {\n    document.querySelectorAll('.source-edit-form').forEach(form => {\n      const sourceId = form.dataset.sourceId;\n      if (!sourceId) return;\n\n      form.querySelectorAll('input, select').forEach(el => {\n        if (el.type === 'button' || el.type === 'submit') return;\n        el.addEventListener('change', async function() {\n          await saveWithFeedback(\n            `/settings/edit/${sourceId}`,\n            { method: 'POST', body: new FormData(form) },\n            getFeedbackEl(this)\n          );\n        });\n      });\n\n      form.addEventListener('submit', e => e.preventDefault());\n    });\n\n    // Delete source buttons\n    document.querySelectorAll('.delete-source-btn').forEach(btn => {\n      btn.addEventListener('click', async () => {\n        const sourceId = btn.dataset.sourceId;\n        if (!confirm('Delete this source?')) return;\n        btn.disabled = true;\n        btn.textContent = 'Deleting...';\n        try {\n          const resp = await fetch(`/settings/delete/${sourceId}`, { method: 'POST' });\n          if (resp.ok) location.reload();\n          else throw new Error('Delete failed');\n        } catch {\n          btn.disabled = false;\n          btn.textContent = 'Delete';\n        }\n      });\n    });\n  }\n\n  // ============================================================\n  // Live TV Category Filter\n  // ============================================================\n\n  function setupCategoryFilter() {\n    const availableContainer = document.getElementById('available-cats');\n    const unavailableContainer = document.getElementById('unavailable-cats');\n    if (!availableContainer || !unavailableContainer) return;\n\n    async function save(container) {\n      const cats = Array.from(availableContainer.querySelectorAll('.cat-chip')).map(el => el.dataset.id);\n      await saveWithFeedback(\n        '/settings/guide-filter',\n        { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({cats}) },\n        container\n      );\n    }\n\n    // Initialize order from config\n    const chipById = {};\n    unavailableContainer.querySelectorAll('.cat-chip').forEach(el => chipById[el.dataset.id] = el);\n    (cfg.selectedCats || []).forEach(catId => {\n      if (chipById[catId]) availableContainer.appendChild(chipById[catId]);\n    });\n\n    setupDragDrop('#available-cats, #unavailable-cats', '#filters .cat-chip', save);\n    setupSearch('cat-search', 'cat-search-clear', '#filters .cat-chip');\n\n    document.getElementById('cat-move-all-right')?.addEventListener('click', async () => {\n      availableContainer.querySelectorAll('.cat-chip:not([style*=\"display: none\"])').forEach(c => unavailableContainer.appendChild(c));\n      await save(unavailableContainer);\n    });\n\n    document.getElementById('cat-move-all-left')?.addEventListener('click', async () => {\n      unavailableContainer.querySelectorAll('.cat-chip:not([style*=\"display: none\"])').forEach(c => availableContainer.appendChild(c));\n      await save(availableContainer);\n    });\n  }\n\n  // ============================================================\n  // VOD Category Filter\n  // ============================================================\n\n  function setupVodCategoryFilter() {\n    const availableContainer = document.getElementById('available-vod-cats');\n    const unavailableContainer = document.getElementById('unavailable-vod-cats');\n    if (!availableContainer || !unavailableContainer) return;\n\n    async function save(container) {\n      const cats = Array.from(availableContainer.querySelectorAll('.vod-cat-chip')).map(el => el.dataset.id);\n      await saveWithFeedback(\n        '/settings/vod-filter',\n        { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({cats}) },\n        container\n      );\n    }\n\n    // Initialize order from config\n    const chipById = {};\n    unavailableContainer.querySelectorAll('.vod-cat-chip').forEach(el => chipById[el.dataset.id] = el);\n    (cfg.selectedVodCats || []).forEach(catId => {\n      if (chipById[catId]) availableContainer.appendChild(chipById[catId]);\n    });\n\n    setupDragDrop('#available-vod-cats, #unavailable-vod-cats', '#vod-filters .vod-cat-chip', save);\n    setupSearch('vod-cat-search', 'vod-cat-search-clear', '#vod-filters .vod-cat-chip');\n\n    document.getElementById('vod-cat-move-all-right')?.addEventListener('click', async () => {\n      availableContainer.querySelectorAll('.vod-cat-chip:not([style*=\"display: none\"])').forEach(c => unavailableContainer.appendChild(c));\n      await save(unavailableContainer);\n    });\n\n    document.getElementById('vod-cat-move-all-left')?.addEventListener('click', async () => {\n      unavailableContainer.querySelectorAll('.vod-cat-chip:not([style*=\"display: none\"])').forEach(c => availableContainer.appendChild(c));\n      await save(availableContainer);\n    });\n  }\n\n  // ============================================================\n  // Series Category Filter\n  // ============================================================\n\n  function setupSeriesCategoryFilter() {\n    const availableContainer = document.getElementById('available-series-cats');\n    const unavailableContainer = document.getElementById('unavailable-series-cats');\n    if (!availableContainer || !unavailableContainer) return;\n\n    async function save(container) {\n      const cats = Array.from(availableContainer.querySelectorAll('.series-cat-chip')).map(el => el.dataset.id);\n      await saveWithFeedback(\n        '/settings/series-filter',\n        { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({cats}) },\n        container\n      );\n    }\n\n    // Initialize order from config\n    const chipById = {};\n    unavailableContainer.querySelectorAll('.series-cat-chip').forEach(el => chipById[el.dataset.id] = el);\n    (cfg.selectedSeriesCats || []).forEach(catId => {\n      if (chipById[catId]) availableContainer.appendChild(chipById[catId]);\n    });\n\n    setupDragDrop('#available-series-cats, #unavailable-series-cats', '#series-filters .series-cat-chip', save);\n    setupSearch('series-cat-search', 'series-cat-search-clear', '#series-filters .series-cat-chip');\n\n    document.getElementById('series-cat-move-all-right')?.addEventListener('click', async () => {\n      availableContainer.querySelectorAll('.series-cat-chip:not([style*=\"display: none\"])').forEach(c => unavailableContainer.appendChild(c));\n      await save(unavailableContainer);\n    });\n\n    document.getElementById('series-cat-move-all-left')?.addEventListener('click', async () => {\n      unavailableContainer.querySelectorAll('.series-cat-chip:not([style*=\"display: none\"])').forEach(c => availableContainer.appendChild(c));\n      await save(availableContainer);\n    });\n  }\n\n  // ============================================================\n  // Chrome CC Link Copy\n  // ============================================================\n\n  function setupChromeCcLink() {\n    const el = document.getElementById('chrome-cc-link');\n    if (!el) return;\n    el.addEventListener('click', async () => {\n      const text = 'chrome://settings/captions';\n      const orig = el.textContent;\n      try {\n        await navigator.clipboard.writeText(text);\n        el.textContent = 'Copied!';\n      } catch (e) {\n        console.error('Copy failed:', e);\n        el.textContent = 'Failed';\n      }\n      setTimeout(() => el.textContent = orig, 1500);\n    });\n  }\n\n  // ============================================================\n  // Caption Settings\n  // ============================================================\n\n  function setupCaptionSettings() {\n    const preview = document.getElementById('cc-preview');\n    const selects = document.querySelectorAll('.cc-setting');\n    const langSelect = document.getElementById('cc-lang-pref');\n    const enabledCb = document.getElementById('captions-enabled');\n\n    let ccStyle = cfg.ccStyle || {};\n\n    function hexToRgba(hex, opacity) {\n      if (hex === 'transparent') return 'transparent';\n      const r = parseInt(hex.slice(1,3), 16);\n      const g = parseInt(hex.slice(3,5), 16);\n      const b = parseInt(hex.slice(5,7), 16);\n      return `rgba(${r},${g},${b},${opacity})`;\n    }\n\n    function updatePreview() {\n      if (!preview) return;\n      preview.style.color = hexToRgba(ccStyle.cc_color || '#ffffff', 1);\n      preview.style.textShadow = ccStyle.cc_shadow || '0 0 4px black, 0 0 4px black';\n      preview.style.backgroundColor = hexToRgba(ccStyle.cc_bg || '#000000', ccStyle.cc_bg_opacity ?? 0.5);\n      preview.style.fontSize = ccStyle.cc_size || '1em';\n      preview.style.fontFamily = ccStyle.cc_font || 'inherit';\n    }\n\n    selects.forEach(sel => {\n      if (ccStyle[sel.dataset.setting]) sel.value = ccStyle[sel.dataset.setting];\n      sel.addEventListener('change', async function() {\n        ccStyle[this.dataset.setting] = this.value;\n        updatePreview();\n        await saveWithFeedback(\n          '/api/user-prefs',\n          { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({cc_style: ccStyle}) },\n          this\n        );\n      });\n    });\n\n    updatePreview();\n\n    if (langSelect) {\n      if (cfg.ccLang) langSelect.value = cfg.ccLang;\n      langSelect.addEventListener('change', async function() {\n        await saveWithFeedback(\n          '/api/user-prefs',\n          { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({cc_lang: this.value}) },\n          this\n        );\n      });\n    }\n\n    if (enabledCb) {\n      enabledCb.addEventListener('change', async function() {\n        const form = new FormData();\n        if (this.checked) form.append('enabled', 'on');\n        await saveWithFeedback('/settings/captions', { method: 'POST', body: form }, getFeedbackEl(this));\n      });\n    }\n  }\n\n  // ============================================================\n  // Guide Settings\n  // ============================================================\n\n  function setupGuideSettings() {\n    const virtualScrollCb = document.getElementById('virtual-scroll');\n    if (virtualScrollCb) {\n      virtualScrollCb.addEventListener('change', async function() {\n        await saveWithFeedback(\n          '/api/user-prefs',\n          { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({virtual_scroll: this.checked}) },\n          getFeedbackEl(this)\n        );\n      });\n    }\n  }\n\n  // ============================================================\n  // Transcode & User-Agent Settings\n  // ============================================================\n\n  function setupTranscodeSettings() {\n    const container = document.getElementById('transcode-settings');\n    if (!container) return;\n\n    // Collect all transcode-related inputs (in container + probe checkboxes + transcode_dir)\n    const transcodeInputs = [\n      ...container.querySelectorAll('.setting-input'),\n      ...document.querySelectorAll('input[name=\"probe_live\"], input[name=\"probe_movies\"], input[name=\"probe_series\"]'),\n      document.querySelector('input[name=\"transcode_dir\"]')\n    ].filter(Boolean);\n\n    async function save(triggerEl) {\n      const form = new FormData();\n      // Auto-collect all transcode inputs by type\n      transcodeInputs.forEach(el => {\n        if (!el.name) return;\n        if (el.type === 'checkbox') {\n          if (el.checked) form.append(el.name, 'on');\n        } else if (el.type === 'radio') {\n          if (el.checked) form.append(el.name, el.value);\n        } else {\n          form.append(el.name, el.value);\n        }\n      });\n      await saveWithFeedback('/settings/transcode', { method: 'POST', body: form }, getFeedbackEl(triggerEl));\n    }\n\n    // Auto-enable \"Always\" transcode when AI Upscale is enabled\n    const srInputs = container.querySelectorAll('input[name=\"sr_model\"]');\n    const alwaysRadio = container.querySelector('input[name=\"transcode_mode\"][value=\"always\"]');\n    srInputs.forEach(sr => {\n      sr.addEventListener('change', function() {\n        if (this.value !== '' && alwaysRadio && !alwaysRadio.checked) {\n          alwaysRadio.checked = true;\n          showFeedback(getFeedbackEl(alwaysRadio), true);\n        }\n      });\n    });\n\n    transcodeInputs.forEach(el => {\n      el.addEventListener('change', function() { save(this); });\n    });\n\n    // Re-detect hardware button\n    const refreshBtn = document.getElementById('refresh-encoders-btn');\n    if (refreshBtn) {\n      refreshBtn.addEventListener('click', async () => {\n        refreshBtn.disabled = true;\n        refreshBtn.textContent = 'Detecting...';\n        try {\n          const resp = await fetch('/settings/refresh-encoders', { method: 'POST' });\n          if (resp.ok) {\n            const { encoders = {} } = await resp.json();\n            // Map encoder detection to radio button enable/disable states\n            const radioStates = {\n              'nvenc+vaapi': encoders.nvenc && encoders.vaapi,\n              'nvenc+software': encoders.nvenc,\n              'amf+vaapi': encoders.amf && encoders.vaapi,\n              'amf+software': encoders.amf,\n              'qsv': encoders.qsv,\n              'vaapi': encoders.vaapi,\n              'software': true,  // Always available\n            };\n            Object.entries(radioStates).forEach(([value, enabled]) => {\n              const radio = container.querySelector(`input[name=\"transcode_hw\"][value=\"${value}\"]`);\n              const label = radio?.closest('label');\n              if (radio && label) {\n                radio.disabled = !enabled;\n                label.classList.toggle('opacity-40', !enabled);\n              }\n            });\n            refreshBtn.textContent = 'Done!';\n          } else {\n            refreshBtn.textContent = 'Failed';\n          }\n        } catch (e) {\n          console.error('Refresh encoders failed:', e);\n          refreshBtn.textContent = 'Failed';\n        }\n        setTimeout(() => { refreshBtn.textContent = 'Re-detect Hardware'; refreshBtn.disabled = false; }, 1500);\n      });\n    }\n  }\n\n  function setupUserAgentSettings() {\n    const container = document.getElementById('user-agent-settings');\n    if (!container) return;\n\n    const customContainer = document.getElementById('custom-user-agent-container');\n    const customInput = container.querySelector('input[name=\"user_agent_custom\"]');\n\n    async function save(triggerEl) {\n      const form = new FormData();\n      form.append('preset', container.querySelector('input[name=\"user_agent_preset\"]:checked')?.value || 'default');\n      form.append('custom', customInput?.value || '');\n      await saveWithFeedback('/settings/user-agent', { method: 'POST', body: form }, getFeedbackEl(triggerEl));\n    }\n\n    container.querySelectorAll('input[name=\"user_agent_preset\"]').forEach(radio => {\n      radio.addEventListener('change', function() {\n        customContainer?.classList.toggle('hidden', this.value !== 'custom');\n        save(this);\n      });\n    });\n\n    customInput?.addEventListener('change', function() { save(this); });\n  }\n\n  // ============================================================\n  // Data & Probe Cache\n  // ============================================================\n\n  function setupDataCache() {\n    const clearBtn = document.getElementById('clear-data-cache');\n    if (!clearBtn) return;\n\n    clearBtn.addEventListener('click', async () => {\n      clearBtn.disabled = true;\n      clearBtn.textContent = 'Deleting...';\n      const resp = await saveWithFeedback('/settings/data-cache/clear', { method: 'POST' }, clearBtn);\n      clearBtn.textContent = resp?.ok ? 'Deleted!' : 'Failed';\n      setTimeout(() => { clearBtn.textContent = 'Delete'; clearBtn.disabled = false; }, 2000);\n    });\n  }\n\n  function setupProbeCache() {\n    const listEl = document.getElementById('probe-cache-list');\n    const clearAllBtn = document.getElementById('clear-all-probe-cache');\n    if (!listEl) return;\n\n    function formatDuration(secs) {\n      if (!secs || secs <= 0) return '';\n      const h = Math.floor(secs / 3600);\n      const m = Math.floor((secs % 3600) / 60);\n      return h > 0 ? `${h}h${m}m` : `${m}m`;\n    }\n\n    function loadCache() {\n      fetch('/settings/probe-cache')\n        .then(r => r.json())\n        .then(data => {\n          const series = data.series || [];\n          if (series.length === 0) {\n            listEl.innerHTML = '<div class=\"text-gray-500 text-sm\">No cached probes</div>';\n            return;\n          }\n          listEl.innerHTML = series.map(s => {\n            const name = escapeHtml(s.name) || `Series ${s.series_id}`;\n            const episodes = s.episodes || [];\n            const mruEp = s.mru != null ? episodes.find(ep => ep.episode_id === s.mru) : null;\n            const mruName = mruEp ? escapeHtml(mruEp.name) || `Episode ${s.mru}` : (s.mru != null ? `Episode ${s.mru}` : null);\n            return `\n              <details class=\"bg-gray-700 rounded group\">\n                <summary class=\"flex items-center justify-between p-2 cursor-pointer hover:bg-gray-600 rounded text-sm\">\n                  <div class=\"flex-1 min-w-0\">\n                    <span class=\"font-medium truncate\">${name}</span>\n                    <span class=\"text-gray-400 ml-2\">${s.episode_count} ep${s.episode_count > 1 ? 's' : ''}</span>\n                    <span class=\"text-gray-500 ml-2\">${escapeHtml(s.video_codec || '')}/${escapeHtml(s.audio_codec || '')}</span>\n                    ${s.subtitle_count > 0 ? `<span class=\"text-gray-500 ml-1\">+${s.subtitle_count} subs</span>` : ''}\n                  </div>\n                  <button class=\"clear-series px-2 py-1 text-xs bg-gray-600 hover:bg-red-600 rounded ml-2\" data-series=\"${s.series_id}\">Clear</button>\n                </summary>\n                <div class=\"p-2 pt-0 border-t border-gray-600 max-h-48 overflow-y-auto\">\n                  ${mruName ? `\n                    <div class=\"flex items-center justify-between py-1 text-xs text-blue-400 border-b border-gray-600 mb-1 pb-1\">\n                      <span class=\"truncate mr-2\">MRU: ${mruName}</span>\n                      <button class=\"clear-mru flex-shrink-0 px-1.5 py-0.5 bg-gray-600 hover:bg-red-600 rounded\" data-series=\"${s.series_id}\">×</button>\n                    </div>\n                  ` : ''}\n                  ${episodes.map(ep => `\n                    <div class=\"flex items-center justify-between py-1 text-xs text-gray-400\">\n                      <span class=\"truncate mr-2\">${escapeHtml(ep.name) || 'Episode ' + ep.episode_id}${ep.duration ? ` (${formatDuration(ep.duration)})` : ''}${ep.subtitle_count ? ` +${ep.subtitle_count} subs` : ''}</span>\n                      <button class=\"clear-episode flex-shrink-0 px-1.5 py-0.5 bg-gray-600 hover:bg-red-600 rounded\" data-series=\"${s.series_id}\" data-episode=\"${ep.episode_id}\">×</button>\n                    </div>\n                  `).join('')}\n                </div>\n              </details>\n            `;\n          }).join('');\n        })\n        .catch(() => {\n          listEl.innerHTML = '<div class=\"text-red-400 text-sm\">Failed to load</div>';\n        });\n    }\n\n    clearAllBtn?.addEventListener('click', () => {\n      fetch('/settings/probe-cache/clear', { method: 'POST' }).then(() => loadCache());\n    });\n\n    // Event delegation for dynamically created buttons\n    listEl.addEventListener('click', (e) => {\n      const btn = e.target.closest('button');\n      if (!btn) return;\n      e.stopPropagation();\n\n      if (btn.classList.contains('clear-series')) {\n        fetch(`/settings/probe-cache/clear/${btn.dataset.series}`, { method: 'POST' }).then(() => loadCache());\n      } else if (btn.classList.contains('clear-mru')) {\n        fetch(`/settings/probe-cache/clear-mru/${btn.dataset.series}`, { method: 'POST' }).then(() => loadCache());\n      } else if (btn.classList.contains('clear-episode')) {\n        fetch(`/settings/probe-cache/clear/${btn.dataset.series}?episode_id=${btn.dataset.episode}`, { method: 'POST' }).then(() => loadCache());\n      }\n    });\n\n    loadCache();\n  }\n\n  // ============================================================\n  // Source Refresh Buttons\n  // ============================================================\n\n  function setupRefreshButtons() {\n    const activeRefreshes = new Set();\n    let pollInterval = null;\n\n    function updateButtonStates(statuses) {\n      const globalStatus = statuses._global || {};\n      document.querySelectorAll('[data-source-id]').forEach(container => {\n        const sourceId = container.dataset.sourceId;\n        const sourceStatuses = statuses[sourceId] || {};\n        container.querySelectorAll('.refresh-btn').forEach(btn => {\n          const refreshType = btn.dataset.refresh;\n          const isActive = !!sourceStatuses[refreshType] || !!globalStatus[refreshType];\n          btn.classList.toggle('active', isActive);\n          if (isActive) activeRefreshes.add(`${sourceId}_${refreshType}`);\n          else activeRefreshes.delete(`${sourceId}_${refreshType}`);\n        });\n      });\n      if (activeRefreshes.size === 0 && pollInterval) {\n        clearInterval(pollInterval);\n        pollInterval = null;\n      }\n    }\n\n    function pollStatus() {\n      fetch('/settings/refresh-status').then(r => r.json()).then(updateButtonStates).catch(() => {});\n    }\n\n    function startPolling() {\n      if (!pollInterval) {\n        pollInterval = setInterval(pollStatus, 1000);\n        pollStatus();\n      }\n    }\n\n    document.querySelectorAll('[data-source-id] .refresh-btn').forEach(btn => {\n      btn.addEventListener('click', () => {\n        const container = btn.closest('[data-source-id]');\n        const sourceId = container.dataset.sourceId;\n        btn.classList.add('active');\n        activeRefreshes.add(`${sourceId}_${btn.dataset.refresh}`);\n        fetch(`/settings/refresh/${sourceId}/${btn.dataset.refresh}`, { method: 'POST' })\n          .then(() => startPolling())\n          .catch(() => btn.classList.remove('active'));\n      });\n    });\n\n    fetch('/settings/refresh-status').then(r => r.json()).then(statuses => {\n      if (Object.keys(statuses).length > 0) {\n        updateButtonStates(statuses);\n        startPolling();\n      }\n    }).catch(() => {});\n  }\n\n  // ============================================================\n  // User Management\n  // ============================================================\n\n  function setupUserForms() {\n    // Add User form\n    const addUserForm = document.getElementById('add-user-form');\n    if (addUserForm) {\n      setupDragDrop(\n        '#add-user-available-groups, #add-user-unavailable-groups',\n        '.add-user-group-chip',\n        null\n      );\n\n      setupSearch('add-user-group-search', 'add-user-group-search-clear', '.add-user-group-chip');\n\n      document.getElementById('add-user-block-all')?.addEventListener('click', () => {\n        const avail = document.getElementById('add-user-available-groups');\n        const unavail = document.getElementById('add-user-unavailable-groups');\n        avail?.querySelectorAll('.add-user-group-chip:not([style*=\"display: none\"])').forEach(c => unavail?.appendChild(c));\n      });\n\n      document.getElementById('add-user-allow-all')?.addEventListener('click', () => {\n        const avail = document.getElementById('add-user-available-groups');\n        const unavail = document.getElementById('add-user-unavailable-groups');\n        unavail?.querySelectorAll('.add-user-group-chip:not([style*=\"display: none\"])').forEach(c => avail?.appendChild(c));\n      });\n\n      addUserForm.addEventListener('submit', async function(e) {\n        e.preventDefault();\n        const form = new FormData(this);\n\n        const maxStreamsPerSource = {};\n        document.querySelectorAll('.add-user-source-max-streams').forEach(inp => {\n          const val = parseInt(inp.value) || 0;\n          if (val > 0) maxStreamsPerSource[inp.dataset.sourceId] = val;\n        });\n        form.append('max_streams_per_source', JSON.stringify(maxStreamsPerSource));\n\n        const unavailableGroups = Array.from(\n          document.querySelectorAll('#add-user-unavailable-groups .add-user-group-chip')\n        ).map(c => c.dataset.groupId);\n        form.append('unavailable_groups', JSON.stringify(unavailableGroups));\n\n        const msgEl = document.getElementById('add-user-msg');\n        try {\n          const resp = await fetch('/settings/users/add', { method: 'POST', body: form });\n          if (resp.ok) {\n            if (msgEl) { msgEl.textContent = 'Added'; msgEl.className = 'text-sm text-green-400'; }\n            this.reset();\n            setTimeout(() => location.reload(), 500);\n          } else {\n            const data = await resp.json();\n            if (msgEl) { msgEl.textContent = data.detail || 'Failed'; msgEl.className = 'text-sm text-red-400'; }\n          }\n        } catch (e) {\n          console.error('Add user failed:', e);\n          if (msgEl) { msgEl.textContent = 'Request failed'; msgEl.className = 'text-sm text-red-400'; }\n        }\n        msgEl?.classList.remove('hidden');\n        setTimeout(() => { if (msgEl) msgEl.className = 'text-sm hidden'; }, 3000);\n      });\n    }\n\n    // Password inputs\n    document.querySelectorAll('.password-input').forEach(input => {\n      input.addEventListener('change', async function() {\n        const username = this.closest('[data-username]')?.dataset.username;\n        if (!username || this.value.length < 8) {\n          showFeedback(this, false);\n          return;\n        }\n        const form = new FormData();\n        form.append('new_password', this.value);\n        const resp = await saveWithFeedback(`/settings/users/password/${username}`, { method: 'POST', body: form }, this);\n        if (resp?.ok) this.value = '';\n      });\n    });\n\n    // Admin toggles\n    document.querySelectorAll('.admin-toggle').forEach(checkbox => {\n      checkbox.addEventListener('change', async function() {\n        const username = this.closest('[data-username]')?.dataset.username;\n        if (!username) return;\n        const form = new FormData();\n        if (this.checked) form.append('admin', 'on');\n        try {\n          const resp = await fetch(`/settings/users/admin/${username}`, { method: 'POST', body: form });\n          if (resp.ok) location.reload();\n          else this.checked = !this.checked;\n        } catch (e) {\n          console.error('Admin toggle failed:', e);\n          this.checked = !this.checked;\n        }\n      });\n    });\n\n    // Max streams per source\n    document.querySelectorAll('.user-source-max-streams').forEach(input => {\n      input.addEventListener('change', async function() {\n        const container = this.closest('.user-max-streams-container');\n        const username = container?.dataset.username;\n        if (!username) return;\n\n        const maxStreamsPerSource = {};\n        container.querySelectorAll('.user-source-max-streams').forEach(inp => {\n          const val = parseInt(inp.value) || 0;\n          if (val > 0) maxStreamsPerSource[inp.dataset.sourceId] = val;\n        });\n\n        const form = new FormData();\n        form.append('max_streams_per_source', JSON.stringify(maxStreamsPerSource));\n        await saveWithFeedback(`/settings/users/limits/${username}`, { method: 'POST', body: form }, this);\n      });\n    });\n\n    // Group restrictions\n    setupUserGroupDragDrop();\n  }\n\n  function setupUserGroupDragDrop() {\n    async function saveGroups(username, feedbackContainer) {\n      const unavailableContainer = document.querySelector(`.user-unavailable-groups[data-username=\"${username}\"]`);\n      const unavailableGroups = Array.from(unavailableContainer?.querySelectorAll('.group-chip') || [])\n        .map(c => c.dataset.groupId);\n\n      const form = new FormData();\n      form.append('unavailable_groups', JSON.stringify(unavailableGroups));\n      await saveWithFeedback(`/settings/users/limits/${username}`, { method: 'POST', body: form }, feedbackContainer);\n    }\n\n    setupDragDrop('.user-available-groups, .user-unavailable-groups', '.group-chip', (container) => {\n      const username = container.dataset.username;\n      if (username) saveGroups(username, container);\n    });\n\n    // Search per user\n    document.querySelectorAll('.user-group-search').forEach(input => {\n      const username = input.dataset.username;\n      const clearBtn = document.querySelector(`.user-group-search-clear[data-username=\"${username}\"]`);\n\n      function apply() {\n        const q = input.value.toLowerCase();\n        [`.user-available-groups[data-username=\"${username}\"]`, `.user-unavailable-groups[data-username=\"${username}\"]`].forEach(sel => {\n          document.querySelectorAll(`${sel} .group-chip`).forEach(chip => {\n            chip.style.display = chip.textContent.toLowerCase().includes(q) ? '' : 'none';\n          });\n        });\n        clearBtn?.classList.toggle('hidden', !input.value);\n      }\n\n      input.addEventListener('input', apply);\n      clearBtn?.addEventListener('click', () => { input.value = ''; apply(); });\n    });\n\n    // Move all buttons\n    document.querySelectorAll('.group-move-all-unavailable').forEach(btn => {\n      btn.addEventListener('click', async () => {\n        const username = btn.dataset.username;\n        if (!username) return;\n        const avail = document.querySelector(`.user-available-groups[data-username=\"${username}\"]`);\n        const unavail = document.querySelector(`.user-unavailable-groups[data-username=\"${username}\"]`);\n        avail?.querySelectorAll('.group-chip:not([style*=\"display: none\"])').forEach(c => unavail?.appendChild(c));\n        await saveGroups(username, unavail);\n      });\n    });\n\n    document.querySelectorAll('.group-move-all-available').forEach(btn => {\n      btn.addEventListener('click', async () => {\n        const username = btn.dataset.username;\n        if (!username) return;\n        const avail = document.querySelector(`.user-available-groups[data-username=\"${username}\"]`);\n        const unavail = document.querySelector(`.user-unavailable-groups[data-username=\"${username}\"]`);\n        unavail?.querySelectorAll('.group-chip:not([style*=\"display: none\"])').forEach(c => avail?.appendChild(c));\n        await saveGroups(username, avail);\n      });\n    });\n  }\n\n  // ============================================================\n  // Init\n  // ============================================================\n\n  function init() {\n    setupSourceTypeSelect();\n    setupSourceEditForms();\n    setupCategoryFilter();\n    setupVodCategoryFilter();\n    setupSeriesCategoryFilter();\n    setupChromeCcLink();\n    setupCaptionSettings();\n    setupGuideSettings();\n    setupTranscodeSettings();\n    setupUserAgentSettings();\n    setupDataCache();\n    setupProbeCache();\n    setupRefreshButtons();\n    setupUserForms();\n  }\n\n  init();\n})();\n"
  },
  {
    "path": "static/js/virtual-guide.js",
    "content": "/**\n * Virtual scrolling for the TV guide.\n * Only renders rows that are visible (plus buffer), fetches more as needed.\n */\n\n// Configuration constants\nconst VIRTUAL_GUIDE_DEFAULTS = {\n  ROW_HEIGHT_DESKTOP: 64,       // 4rem in pixels\n  ROW_HEIGHT_MOBILE: 40,        // 2.5rem in pixels\n  BUFFER_SIZE: 50,              // Rows to load above/below viewport\n  MAX_CACHE_SIZE: 500,          // Evict cache beyond this\n  MAX_RETRIES: 3,               // Retry failed fetches this many times\n  MOBILE_BREAKPOINT: 512,       // Width below which is considered mobile\n  SCROLL_DIRECTION_THRESHOLD: 5, // Min scroll delta to register direction\n  RENDER_DEBOUNCE_MS: 16,       // ~60fps for smooth visual update\n  FETCH_DEBOUNCE_MS: 150,       // Wait for scroll to settle before fetching\n  RESIZE_DEBOUNCE_MS: 100,      // Debounce window resize handler\n};\n\nclass VirtualGuide {\n  constructor(options) {\n    const D = VIRTUAL_GUIDE_DEFAULTS;\n\n    this.container = options.container;\n    this.rowHeight = options.rowHeight || D.ROW_HEIGHT_DESKTOP;\n    this.rowHeightMobile = options.rowHeightMobile || D.ROW_HEIGHT_MOBILE;\n    this.totalRows = options.totalRows;\n    this.bufferSize = options.bufferSize || D.BUFFER_SIZE;\n    this.maxCacheSize = options.maxCacheSize || D.MAX_CACHE_SIZE;\n    this.initialRows = options.initialRows || [];\n    this.offset = options.offset || 0;\n    this.cats = options.cats || '';\n    this.logoUrlFilter = options.logoUrlFilter || (url => url);\n\n    // State\n    this.cache = new Map(); // row index -> row data\n    this.failedRanges = new Map(); // range key -> retry count\n    this.maxRetries = D.MAX_RETRIES;\n    this.needsRecheck = false;\n    this.renderedRange = { start: 0, end: 0 };\n    this.pendingFetch = null;\n    this.pendingFetchRange = null;\n    this.scrollDebounce = null;\n    this.fetchDebounce = null;\n    this.renderDebounce = null;\n    this.recheckTimeout = null;\n    this.isMobile = window.innerWidth < D.MOBILE_BREAKPOINT;\n    this.lastScrollTop = 0;\n    this.scrollDirection = 'down';\n\n    // DOM elements\n    this.viewport = null;\n    this.content = null;\n    this.spacer = null;\n\n    this.init();\n  }\n\n  get currentRowHeight() {\n    return this.isMobile ? this.rowHeightMobile : this.rowHeight;\n  }\n\n  get visibleCount() {\n    if (!this.viewport) return 30;\n    return Math.ceil(this.viewport.clientHeight / this.currentRowHeight) + 1;\n  }\n\n  init() {\n    // Cache initial SSR rows\n    for (const row of this.initialRows) {\n      this.cache.set(row.index, row);\n    }\n\n    // Set up virtual scroll container\n    this.setupDOM();\n    this.bindEvents();\n\n    // Handle scroll position restoration\n    // Check if there's a saved scroll position that's beyond initial rows\n    const scrollKey = 'guide_scroll';\n    const savedScroll = sessionStorage.getItem(scrollKey);\n\n    if (savedScroll && this.viewport) {\n      const scrollTop = parseInt(savedScroll);\n      const firstVisible = Math.floor(scrollTop / this.currentRowHeight);\n\n      // If saved position is beyond initial batch, fetch first then scroll\n      if (firstVisible >= this.initialRows.length) {\n        // Fetch data for the saved position, then restore scroll\n        const start = Math.max(0, firstVisible - this.bufferSize);\n        const end = Math.min(this.totalRows, firstVisible + this.visibleCount + this.bufferSize);\n\n        this.fetchMissingRanges([{ start, end }]).then(() => {\n          this.viewport.scrollTop = scrollTop;\n          this.renderedRange = { start, end };\n          this.render();\n        });\n        return; // Don't do normal init flow\n      }\n    }\n\n    // If we have more rows than initial batch, enable virtual scrolling\n    if (this.totalRows > this.initialRows.length) {\n      this.updateVisibleRange();\n    }\n  }\n\n  setupDOM() {\n    // Find the scroll container (the overflow-y-auto div)\n    this.viewport = this.container.querySelector('.overflow-y-auto');\n    if (!this.viewport) return;\n\n    // Create spacer for full height scrollbar\n    this.spacer = document.createElement('div');\n    this.spacer.className = 'virtual-spacer';\n    this.spacer.style.height = `${this.totalRows * this.currentRowHeight}px`;\n    this.spacer.style.position = 'absolute';\n    this.spacer.style.top = '0';\n    this.spacer.style.left = '0';\n    this.spacer.style.right = '0';\n    this.spacer.style.pointerEvents = 'none';\n\n    // Create content container\n    this.content = document.createElement('div');\n    this.content.className = 'virtual-content';\n    this.content.style.position = 'relative';\n    this.content.style.zIndex = '1';\n\n    // Move existing rows into content container\n    const existingRows = this.viewport.querySelectorAll('.guide-row');\n    existingRows.forEach(row => this.content.appendChild(row));\n\n    // Set viewport to relative positioning\n    this.viewport.style.position = 'relative';\n\n    // Add spacer and content to viewport\n    this.viewport.appendChild(this.spacer);\n    this.viewport.insertBefore(this.content, this.spacer);\n\n    // Set initial rendered range based on SSR content\n    this.renderedRange = { start: 0, end: this.initialRows.length };\n  }\n\n  bindEvents() {\n    if (!this.viewport) return;\n\n    // Scroll handler with RAF for smooth updates\n    let ticking = false;\n    this.viewport.addEventListener('scroll', () => {\n      if (!ticking) {\n        requestAnimationFrame(() => {\n          this.onScroll();\n          ticking = false;\n        });\n        ticking = true;\n      }\n    }, { passive: true });\n\n    // Handle resize\n    const D = VIRTUAL_GUIDE_DEFAULTS;\n    let resizeTimer;\n    window.addEventListener('resize', () => {\n      clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        const wasMobile = this.isMobile;\n        this.isMobile = window.innerWidth < D.MOBILE_BREAKPOINT;\n        if (wasMobile !== this.isMobile) {\n          // Row height changed, update spacer\n          this.spacer.style.height = `${this.totalRows * this.currentRowHeight}px`;\n          this.updateVisibleRange();\n        }\n      }, D.RESIZE_DEBOUNCE_MS);\n    });\n  }\n\n  onScroll() {\n    const D = VIRTUAL_GUIDE_DEFAULTS;\n\n    // Clear any pending debounce\n    clearTimeout(this.fetchDebounce);\n    clearTimeout(this.renderDebounce);\n\n    const scrollTop = this.viewport.scrollTop;\n    const firstVisible = Math.floor(scrollTop / this.currentRowHeight);\n    const lastVisible = firstVisible + this.visibleCount;\n\n    // Track scroll direction\n    const scrollDelta = scrollTop - (this.lastScrollTop || 0);\n    this.lastScrollTop = scrollTop;\n    if (Math.abs(scrollDelta) > D.SCROLL_DIRECTION_THRESHOLD) {\n      this.scrollDirection = scrollDelta > 0 ? 'down' : 'up';\n    }\n\n    // Calculate desired range with buffer\n    const desiredStart = Math.max(0, firstVisible - this.bufferSize);\n    const desiredEnd = Math.min(this.totalRows, lastVisible + this.bufferSize);\n\n    // Check if we need to update rendered range\n    const needsRender = desiredStart < this.renderedRange.start ||\n                        desiredEnd > this.renderedRange.end;\n\n    if (needsRender) {\n      // Render immediately with whatever we have (placeholders for missing)\n      this.renderDebounce = setTimeout(() => {\n        this.renderedRange = { start: desiredStart, end: desiredEnd };\n        this.render();\n      }, D.RENDER_DEBOUNCE_MS);\n\n      // Debounce fetching - wait for scroll to settle before fetching\n      this.fetchDebounce = setTimeout(() => {\n        this.updateVisibleRange();\n      }, D.FETCH_DEBOUNCE_MS);\n    }\n  }\n\n  async updateVisibleRange() {\n    const scrollTop = this.viewport.scrollTop;\n    const firstVisible = Math.floor(scrollTop / this.currentRowHeight);\n    const lastVisible = firstVisible + this.visibleCount;\n\n    // Calculate ranges: visible, forward buffer, backward buffer\n    const visibleStart = Math.max(0, firstVisible);\n    const visibleEnd = Math.min(this.totalRows, lastVisible + 1);\n\n    const bufferStart = Math.max(0, firstVisible - this.bufferSize);\n    const bufferEnd = Math.min(this.totalRows, lastVisible + this.bufferSize);\n\n    // Priority fetch order based on scroll direction\n    const fetchOrder = [];\n\n    // 1. Always fetch visible rows first\n    const visibleMissing = this.findMissingRanges(visibleStart, visibleEnd);\n    if (visibleMissing.length > 0) {\n      fetchOrder.push({ ranges: visibleMissing, priority: 'visible' });\n    }\n\n    // 2. Fetch buffer in scroll direction\n    // 3. Fetch buffer in opposite direction\n    if (this.scrollDirection === 'down') {\n      const forwardMissing = this.findMissingRanges(visibleEnd, bufferEnd);\n      const backwardMissing = this.findMissingRanges(bufferStart, visibleStart);\n      if (forwardMissing.length > 0) fetchOrder.push({ ranges: forwardMissing, priority: 'forward' });\n      if (backwardMissing.length > 0) fetchOrder.push({ ranges: backwardMissing, priority: 'backward' });\n    } else {\n      const backwardMissing = this.findMissingRanges(bufferStart, visibleStart);\n      const forwardMissing = this.findMissingRanges(visibleEnd, bufferEnd);\n      if (backwardMissing.length > 0) fetchOrder.push({ ranges: backwardMissing, priority: 'backward' });\n      if (forwardMissing.length > 0) fetchOrder.push({ ranges: forwardMissing, priority: 'forward' });\n    }\n\n    // Fetch in priority order, re-rendering after each batch\n    for (const batch of fetchOrder) {\n      const success = await this.fetchMissingRanges(batch.ranges);\n      // Re-render after each batch so visible content appears first\n      this.renderedRange = { start: bufferStart, end: bufferEnd };\n      this.render();\n\n      if (!success) {\n        // A pending fetch exists or we just aborted one - don't fetch lower-priority buffers\n        // The recheck timeout will re-call updateVisibleRange with correct priorities\n        break;\n      }\n    }\n\n    // Always do a final render to ensure current position is shown\n    // This handles the case where fetchOrder is empty (all rows cached)\n    this.renderedRange = { start: bufferStart, end: bufferEnd };\n    this.render();\n  }\n\n  findMissingRanges(start, end) {\n    const ranges = [];\n    let rangeStart = null;\n\n    for (let i = start; i < end; i++) {\n      if (!this.cache.has(i)) {\n        if (rangeStart === null) rangeStart = i;\n      } else if (rangeStart !== null) {\n        ranges.push({ start: rangeStart, end: i });\n        rangeStart = null;\n      }\n    }\n\n    if (rangeStart !== null) {\n      ranges.push({ start: rangeStart, end });\n    }\n\n    return ranges;\n  }\n\n  /**\n   * Fetch missing rows for the given ranges.\n   * @returns {Promise<boolean>} true if fetch completed (or nothing to fetch),\n   *          false if a pending fetch blocked us or we aborted one\n   */\n  async fetchMissingRanges(ranges) {\n    // Early return if no ranges to fetch\n    if (!ranges || ranges.length === 0) {\n      return true;\n    }\n\n    // Merge into a single request for simplicity\n    const overallStart = Math.min(...ranges.map(r => r.start));\n    const overallEnd = Math.max(...ranges.map(r => r.end));\n\n    // If there's a pending fetch, check if it's for a relevant range\n    if (this.pendingFetch) {\n      if (this.pendingFetchRange) {\n        const p = this.pendingFetchRange;\n        const overlaps = !(overallEnd < p.start || overallStart > p.end);\n\n        if (overlaps) {\n          // Pending fetch will give us some useful data, let it finish\n          // The recheck timeout will catch any remaining gaps\n          this.needsRecheck = true;\n          return false;\n        }\n      }\n      // Non-overlapping or orphaned pending fetch - abort it\n      // Return false so caller doesn't continue to lower-priority fetches\n      this.pendingFetch.abort();\n      this.pendingFetch = null;\n      this.pendingFetchRange = null;\n\n      // CRITICAL: Schedule recheck ourselves since the aborted fetch's finally\n      // block won't do it (we already set pendingFetch = null)\n      clearTimeout(this.recheckTimeout);\n      this.recheckTimeout = setTimeout(() => {\n        this.updateVisibleRange();\n      }, 50);\n      return false;\n    }\n\n    const controller = new AbortController();\n    this.pendingFetch = controller;\n    this.pendingFetchRange = { start: overallStart, end: overallEnd };\n    // Clear needsRecheck since we're now fetching what we need\n    this.needsRecheck = false;\n\n    try {\n      const params = new URLSearchParams({\n        start: overallStart,\n        count: overallEnd - overallStart,\n        offset: this.offset\n      });\n      // Pass cats if set (for temporary dropdown filters)\n      if (this.cats) {\n        params.set('cats', this.cats);\n      }\n\n      const resp = await fetch(`/api/guide/rows?${params}`, {\n        signal: controller.signal\n      });\n\n      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n\n      const data = await resp.json();\n\n      // Cache the fetched rows\n      for (const row of data.rows) {\n        this.cache.set(row.index, row);\n      }\n\n      // Clear any failure tracking for this range\n      const rangeKey = `${overallStart}-${overallEnd}`;\n      this.failedRanges.delete(rangeKey);\n\n      // Evict old cache entries to prevent memory growth\n      this.pruneCache();\n    } catch (e) {\n      if (e.name !== 'AbortError') {\n        console.error('Failed to fetch guide rows:', e);\n\n        // Track failed range for retry\n        const rangeKey = `${overallStart}-${overallEnd}`;\n        const retryCount = (this.failedRanges.get(rangeKey) || 0) + 1;\n\n        if (retryCount < this.maxRetries) {\n          this.failedRanges.set(rangeKey, retryCount);\n          // Schedule retry after delay\n          setTimeout(() => {\n            this.failedRanges.delete(rangeKey);\n            this.updateVisibleRange();\n          }, 1000 * retryCount); // Exponential backoff: 1s, 2s, 3s\n        } else {\n          // Max retries reached - clear tracking\n          this.failedRanges.delete(rangeKey);\n          console.error(`Failed to fetch rows ${overallStart}-${overallEnd} after ${this.maxRetries} retries`);\n        }\n      }\n    } finally {\n      if (this.pendingFetch === controller) {\n        this.pendingFetch = null;\n        this.pendingFetchRange = null;\n\n        // Always recheck after fetch completes to catch any gaps\n        // Use a small delay to batch multiple rapid rechecks\n        clearTimeout(this.recheckTimeout);\n        this.recheckTimeout = setTimeout(() => {\n          this.updateVisibleRange();\n        }, 50);\n      }\n    }\n\n    return true;\n  }\n\n  render() {\n    if (!this.content) return;\n\n    const html = [];\n\n    for (let i = this.renderedRange.start; i < this.renderedRange.end; i++) {\n      const row = this.cache.get(i);\n      if (row) {\n        html.push(this.renderRow(row, i));\n      } else {\n        html.push(this.renderPlaceholder(i));\n      }\n    }\n\n    // Position content at the right scroll offset\n    this.content.style.transform = `translateY(${this.renderedRange.start * this.currentRowHeight}px)`;\n    this.content.innerHTML = html.join('');\n  }\n\n  /**\n   * Evict cached rows far from current view to prevent unbounded memory growth.\n   * Keeps rows within 2x buffer distance from current rendered range.\n   */\n  pruneCache() {\n    if (this.cache.size <= this.maxCacheSize) {\n      return;\n    }\n\n    const center = Math.floor((this.renderedRange.start + this.renderedRange.end) / 2);\n    const keepDistance = this.bufferSize * 2;\n\n    // Collect indices to remove (those far from current view)\n    const toRemove = [];\n    for (const index of this.cache.keys()) {\n      const distance = Math.abs(index - center);\n      if (distance > keepDistance) {\n        toRemove.push(index);\n      }\n    }\n\n    // Remove furthest first until under max size\n    toRemove.sort((a, b) => Math.abs(b - center) - Math.abs(a - center));\n    const removeCount = Math.min(toRemove.length, this.cache.size - this.maxCacheSize);\n    for (let i = 0; i < removeCount; i++) {\n      this.cache.delete(toRemove[i]);\n    }\n  }\n\n  renderPlaceholder(index) {\n    const height = this.currentRowHeight;\n    const isMobile = this.isMobile;\n\n    if (isMobile) {\n      return `\n        <div class=\"guide-row flex border-b border-gray-700 animate-pulse\" data-row=\"${index}\" style=\"height: ${height}px;\">\n          <div class=\"compact-only w-32 flex-shrink-0 p-1 flex items-center bg-gray-800 sticky left-0 z-10 border-r border-gray-700\">\n            <div class=\"h-3 bg-gray-600 rounded w-20\"></div>\n          </div>\n          <div class=\"flex-1 relative ml-1\">\n            <div class=\"absolute inset-0.5 bg-gray-700 rounded\"></div>\n          </div>\n        </div>\n      `;\n    }\n\n    return `\n      <div class=\"guide-row flex border-b border-gray-700 animate-pulse\" data-row=\"${index}\" style=\"height: ${height}px;\">\n        <div class=\"desktop-only w-36 lg:w-48 flex-shrink-0 p-1 flex items-center gap-2 bg-gray-800 sticky left-0 z-10 border-r border-gray-700\">\n          <div class=\"w-10 h-10 bg-gray-600 rounded\"></div>\n          <div class=\"h-4 bg-gray-600 rounded w-24\"></div>\n        </div>\n        <div class=\"flex-1 relative ml-1\">\n          <div class=\"absolute top-1 bottom-1 left-0 right-1/3 bg-gray-700 rounded\"></div>\n        </div>\n      </div>\n    `;\n  }\n\n  renderRow(row, index) {\n    const ch = row.channel;\n    const iconUrl = ch.icon ? this.logoUrlFilter(ch.icon) : '';\n    const height = this.currentRowHeight;\n\n    // Escape HTML in text content\n    const escapeHtml = (str) => {\n      if (!str) return '';\n      return str.replace(/&/g, '&amp;')\n                .replace(/</g, '&lt;')\n                .replace(/>/g, '&gt;')\n                .replace(/\"/g, '&quot;')\n                .replace(/'/g, '&#39;');\n    };\n\n    // Desktop programs\n    let programsDesktop = '';\n    if (row.programs && row.programs.length > 0) {\n      programsDesktop = row.programs.map((prog, pIdx) => `\n        <a href=\"/play/live/${ch.stream_id}\"\n           class=\"absolute top-1 bottom-1 bg-gray-700 hover:bg-gray-600 rounded px-2 py-1 overflow-hidden\n                  focusable border-2 border-transparent focus:border-blue-500 focus:bg-blue-900/50\"\n           style=\"left: ${prog.left_pct}%; width: calc(${prog.width_pct}% - 4px);\"\n           tabindex=\"0\" data-nav=\"epg\" data-row=\"${index}\" data-col=\"${pIdx}\"\n           title=\"${escapeHtml(prog.title)}&#10;${prog.start} - ${prog.end}&#10;${escapeHtml(prog.desc)}\">\n          <div class=\"text-sm font-medium truncate\">${escapeHtml(prog.title)}</div>\n          <div class=\"text-xs text-gray-400 truncate\">${escapeHtml(prog.desc)}</div>\n        </a>\n      `).join('');\n    } else {\n      programsDesktop = `\n        <div class=\"absolute inset-1 flex items-center px-2 text-gray-500 text-sm\">\n          No program info\n        </div>\n      `;\n    }\n\n    // Mobile programs\n    let programsMobile = '';\n    if (row.programs_mobile && row.programs_mobile.length > 0) {\n      programsMobile = row.programs_mobile.map((prog, pIdx) => `\n        <a href=\"/play/live/${ch.stream_id}\"\n           class=\"absolute top-0.5 bottom-0.5 bg-gray-700 hover:bg-gray-600 rounded px-1 overflow-hidden\n                  focusable border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 focus:ring-offset-gray-800\"\n           style=\"left: ${prog.left_pct}%; width: calc(${prog.width_pct}% - 4px);\"\n           tabindex=\"0\" data-nav=\"epg\" data-row=\"${index}\" data-col=\"${pIdx}\"\n           title=\"${escapeHtml(prog.title)}&#10;${prog.start} - ${prog.end}&#10;${escapeHtml(prog.desc)}\">\n          <div class=\"text-[10px] font-medium truncate\">${escapeHtml(prog.title)}</div>\n        </a>\n      `).join('');\n    } else {\n      programsMobile = `\n        <div class=\"absolute inset-0.5 flex items-center px-1 text-gray-500 text-[10px]\">\n          No info\n        </div>\n      `;\n    }\n\n    return `\n      <div class=\"guide-row flex border-b border-gray-700 hover:bg-gray-750\" data-row=\"${index}\">\n        <!-- Mobile Channel Info -->\n        <div class=\"compact-only w-32 flex-shrink-0 p-1 items-center bg-gray-800 sticky left-0 z-10 border-r border-gray-700\">\n          <a href=\"/play/live/${ch.stream_id}\"\n             class=\"text-xs line-clamp-2 hover:text-blue-400 focus:text-blue-400 focus:outline focus:outline-2 focus:outline-blue-500 focusable\"\n             tabindex=\"0\" data-nav=\"epg\" data-row=\"${index}\" data-col=\"-1\"\n             title=\"${escapeHtml(ch.name)}\">\n            ${escapeHtml(ch.name)}\n          </a>\n        </div>\n\n        <!-- Desktop Channel Info -->\n        <div class=\"desktop-only w-36 lg:w-48 flex-shrink-0 p-1 items-center gap-1 bg-gray-800 sticky left-0 z-10 border-r border-gray-700\">\n          ${iconUrl ? `<img src=\"${iconUrl}\" alt=\"\" class=\"w-10 h-10 object-contain\" onerror=\"this.style.display='none'\">` : ''}\n          <a href=\"/play/live/${ch.stream_id}\"\n             class=\"text-sm line-clamp-3 hover:text-blue-400 focus:text-blue-400 focus:outline focus:outline-2 focus:outline-blue-500 focusable\"\n             tabindex=\"0\" data-nav=\"epg\" data-row=\"${index}\" data-col=\"-1\"\n             title=\"${escapeHtml(ch.name)}\">\n            ${escapeHtml(ch.name)}\n          </a>\n        </div>\n\n        <!-- Mobile Programs (2-hour window) -->\n        <div class=\"compact-only-block flex-1 relative h-10 ml-1\">\n          ${programsMobile}\n        </div>\n\n        <!-- Desktop Programs -->\n        <div class=\"desktop-only-block flex-1 relative h-16 ml-1\">\n          ${programsDesktop}\n        </div>\n      </div>\n    `;\n  }\n\n  /**\n   * Clean up resources when the virtual guide is no longer needed.\n   * Call this before removing/reinitializing to prevent memory leaks.\n   */\n  destroy() {\n    // Clear all timers\n    clearTimeout(this.fetchDebounce);\n    clearTimeout(this.renderDebounce);\n    clearTimeout(this.scrollDebounce);\n    clearTimeout(this.recheckTimeout);\n\n    // Abort any pending fetch\n    if (this.pendingFetch) {\n      this.pendingFetch.abort();\n      this.pendingFetch = null;\n      this.pendingFetchRange = null;\n    }\n\n    // Clear caches\n    this.cache.clear();\n    this.failedRanges.clear();\n\n    // Clear DOM references\n    if (this.content) {\n      this.content.innerHTML = '';\n    }\n    this.viewport = null;\n    this.content = null;\n    this.spacer = null;\n    this.container = null;\n\n    // Note: Event listeners on window (resize) are not removed\n    // as they use anonymous functions. For full cleanup, would need\n    // to store references to bound handlers in constructor.\n  }\n}\n\n// Export for use\nwindow.VirtualGuide = VirtualGuide;\n"
  },
  {
    "path": "templates/base.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"h-full\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>{% block title %}neTV{% endblock %}</title>\n  <script src=\"https://cdn.tailwindcss.com\"></script>\n  <script src=\"https://unpkg.com/htmx.org@2.0.4\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/hls.js@1\"></script>\n  <script>\n    window.__onGCastApiAvailable = function(isAvailable) {\n      console.log('[CAST] onGCastApiAvailable:', isAvailable);\n    };\n  </script>\n  <script src=\"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1\"\n          onload=\"console.log('[CAST] Script loaded')\"\n          onerror=\"console.log('[CAST] Script failed to load')\"></script>\n  <style>\n    /* Focus styles */\n    a:focus, button:focus, .focusable:focus { outline: none; }\n    .nav-link:focus, .nav-link.active {\n      background-color: rgb(59 130 246);\n    }\n    /* Scroll containers need padding for ring visibility */\n    .scroll-ring-safe { padding: 2px; margin: -2px; }\n  </style>\n  {% block head_extra %}{% endblock %}\n</head>\n<body class=\"h-full bg-gray-900 text-gray-100 overflow-hidden\">\n  <div class=\"flex h-full min-w-0\">\n    <!-- Sidebar -->\n    <nav class=\"bg-gray-800 flex flex-col py-4 items-center gap-4 w-10 flex-shrink-0\">\n      <a href=\"/guide\" class=\"nav-link p-1.5 rounded hover:bg-gray-700 {% if request.url.path == '/guide' %}active{% endif %}\" tabindex=\"0\" title=\"Live TV\">\n        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"white\" viewBox=\"0 0 24 24\" stroke-linecap=\"round\">\n          <!-- Tower legs -->\n          <line x1=\"12\" y1=\"4\" x2=\"8\" y2=\"21\" stroke-width=\"1.2\"/>\n          <line x1=\"12\" y1=\"4\" x2=\"16\" y2=\"21\" stroke-width=\"1.2\"/>\n          <!-- X-pattern trusses -->\n          <line x1=\"10.6\" y1=\"10\" x2=\"14.6\" y2=\"15\" stroke-width=\"0.7\"/>\n          <line x1=\"13.4\" y1=\"10\" x2=\"9.4\" y2=\"15\" stroke-width=\"0.7\"/>\n          <line x1=\"9.2\" y1=\"16\" x2=\"15.9\" y2=\"20.5\" stroke-width=\"0.7\"/>\n          <line x1=\"14.8\" y1=\"16\" x2=\"8.1\" y2=\"20.5\" stroke-width=\"0.7\"/>\n          <!-- Broadcast waves (more spacing) -->\n          <path d=\"M10.5 2.5 A 2 2 0 0 0 10.5 5.5\" stroke-width=\"1\"/>\n          <path d=\"M13.5 2.5 A 2 2 0 0 1 13.5 5.5\" stroke-width=\"1\"/>\n          <path d=\"M8.5 1.5 A 4 4 0 0 0 8.5 6.5\" stroke-width=\"1\"/>\n          <path d=\"M15.5 1.5 A 4 4 0 0 1 15.5 6.5\" stroke-width=\"1\"/>\n          <path d=\"M6.5 0.5 A 6 6 0 0 0 6.5 7.5\" stroke-width=\"1\"/>\n          <path d=\"M17.5 0.5 A 6 6 0 0 1 17.5 7.5\" stroke-width=\"1\"/>\n        </svg>\n      </a>\n      {% set access = content_access if content_access is defined else get_content_access_from_request(request) %}\n      {% if access.movies %}\n      <a href=\"/vod\" class=\"nav-link p-1.5 rounded hover:bg-gray-700 {% if request.url.path.startswith('/vod') %}active{% endif %}\" tabindex=\"0\" title=\"Movies\">\n        <svg class=\"w-6 h-6\" fill=\"white\" viewBox=\"0 0 24 24\">\n          <!-- Board -->\n          <rect x=\"4\" y=\"10\" width=\"16\" height=\"10\" rx=\"1\"/>\n          <!-- Clapper top angled at 15deg -->\n          <g transform=\"rotate(-15 4 9)\">\n            <rect x=\"4\" y=\"5\" width=\"16\" height=\"4\" fill=\"white\"/>\n            <rect x=\"6\" y=\"5\" width=\"2.5\" height=\"4\" fill=\"black\"/>\n            <rect x=\"10\" y=\"5\" width=\"2.5\" height=\"4\" fill=\"black\"/>\n            <rect x=\"14\" y=\"5\" width=\"2.5\" height=\"4\" fill=\"black\"/>\n          </g>\n        </svg>\n      </a>\n      {% endif %}\n      {% if access.series %}\n      <a href=\"/series\" class=\"nav-link p-1.5 rounded hover:bg-gray-700 {% if request.url.path.startswith('/series') %}active{% endif %}\" tabindex=\"0\" title=\"Series\">\n        <svg class=\"w-6 h-6\" fill=\"white\" viewBox=\"0 0 24 24\"><path d=\"M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z\"/></svg>\n      </a>\n      {% endif %}\n      <a href=\"/search\" class=\"nav-link p-1.5 rounded hover:bg-gray-700 {% if request.url.path == '/search' %}active{% endif %}\" tabindex=\"0\" title=\"Search\">\n        <svg class=\"w-6 h-6\" fill=\"white\" viewBox=\"0 0 24 24\"><path d=\"M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z\"/></svg>\n      </a>\n      <a href=\"/settings\" class=\"nav-link p-1.5 rounded hover:bg-gray-700 {% if request.url.path == '/settings' %}active{% endif %}\" tabindex=\"0\" title=\"Settings\">\n        <svg class=\"w-6 h-6\" fill=\"white\" viewBox=\"0 0 24 24\"><path d=\"M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z\"/></svg>\n      </a>\n      <div class=\"mt-auto\">\n        <a href=\"/logout\" class=\"text-gray-400 hover:text-white text-xs p-1.5 block\" title=\"Logout\">\n          <svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z\"/></svg>\n        </a>\n      </div>\n    </nav>\n\n    <!-- Main content -->\n    <main class=\"flex-1 overflow-x-hidden overflow-y-auto p-2 min-w-0\">\n      {% block content %}{% endblock %}\n    </main>\n  </div>\n\n  <script src=\"/static/js/app.js\"></script>\n  {% block scripts %}{% endblock %}\n</body>\n</html>\n"
  },
  {
    "path": "templates/error.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}Error{% endblock %}\n\n{% block content %}\n<div class=\"flex flex-col items-center justify-center h-full gap-4\">\n  <div class=\"text-6xl\">⚠️</div>\n  <h1 class=\"text-2xl font-bold text-red-400\">{{ title }}</h1>\n  <p class=\"text-gray-400 text-center max-w-md\">{{ message }}</p>\n  <div class=\"flex gap-2 mt-4\">\n    <button onclick=\"location.reload()\" class=\"px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded focusable\" tabindex=\"0\">Retry</button>\n    <button onclick=\"history.back()\" class=\"px-4 py-2 bg-gray-600 hover:bg-gray-500 rounded focusable\" tabindex=\"0\">Go Back</button>\n  </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "templates/guide.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}Live TV - neTV{% endblock %}\n\n{% block head_extra %}\n{% if loading and not request.query_params.get('refreshing') %}\n<meta http-equiv=\"refresh\" content=\"2\">\n{% endif %}\n<style>\n/* Desktop (default): show desktop-only, hide compact-only */\n.compact-only { display: none; }\n.compact-only-block { display: none; }\n.desktop-only { display: flex; }\n.desktop-only-block { display: block; }\n\n/* Performance: contain layout recalculations to each row */\n.guide-row {\n  contain: layout style paint;\n  content-visibility: auto;\n  contain-intrinsic-size: auto 4rem; /* approximate row height */\n}\n\n/* Mobile (width < 512px): show compact-only, hide desktop-only */\n@media (max-width: 511px) {\n  .guide-container .compact-only { display: flex; }\n  .guide-container .compact-only-block { display: block; }\n  .guide-container .desktop-only { display: none; }\n  .guide-container .desktop-only-block { display: none; }\n}\n\n/* Landscape mobile: hide header, compact time row */\n@media (max-height: 500px) and (max-width: 900px) and (orientation: landscape) {\n  .guide-container > .desktop-only { display: none !important; }\n  .guide-container > .mobile-header { display: none !important; }\n  .guide-container .desktop-only.sticky .relative { height: 1.25rem; }\n  .guide-container .desktop-only.sticky .relative div { font-size: 0.625rem; }\n}\n\n/* EPG cell focus */\n.desktop-only-block a[data-nav=\"epg\"]:focus {\n  outline: 3px solid #3b82f6;\n  outline-offset: -1px;\n  background: rgba(59, 130, 246, 0.3);\n}\n</style>\n{% endblock %}\n\n{% block content %}\n<div class=\"guide-container flex flex-col h-full min-w-0\">\n  <!-- Desktop header (fixed, non-scrolling) -->\n  <div class=\"desktop-only flex items-center justify-between gap-2 mb-4 h-10\">\n    <div class=\"min-w-0 flex items-center gap-2 flex-shrink-0\">\n      <h2 class=\"text-xl sm:text-3xl font-bold\">Live TV</h2>\n      {% if saved_filter or selected_cats %}\n      <!-- Category filter multi-select dropdown -->\n      <div class=\"inline-block\" id=\"category-dropdown\">\n        <button type=\"button\" id=\"category-dropdown-btn\"\n                class=\"bg-gray-700 text-sm rounded px-2 py-1 border border-gray-600 hover:border-gray-500 focus:border-blue-500 focus:outline-none inline-flex items-center gap-1\">\n          <span>{% if effective_cats.split(',') | length < ordered_filter_cats | length %}{{ effective_cats.split(',') | length }} selected{% else %}All categories{% endif %}</span>\n          <svg class=\"w-3 h-3 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"/></svg>\n        </button>\n      </div>\n      <!-- Dropdown menu (portal, positioned via JS) -->\n      <div id=\"category-dropdown-menu\" class=\"hidden fixed top-0 left-0 bg-gray-800 border border-gray-600 rounded shadow-lg z-[9999] min-w-[200px] max-h-[300px] overflow-y-auto\">\n        <div class=\"p-2 border-b border-gray-700 flex gap-2\">\n          <button type=\"button\" onclick=\"document.querySelectorAll('#category-dropdown-menu .cat-checkbox').forEach(c=>c.checked=true)\" class=\"text-xs text-blue-400 hover:text-blue-300\">All</button>\n          <button type=\"button\" onclick=\"document.querySelectorAll('#category-dropdown-menu .cat-checkbox').forEach(c=>c.checked=false)\" class=\"text-xs text-blue-400 hover:text-blue-300\">None</button>\n        </div>\n        {% for cat in ordered_filter_cats %}\n        <label class=\"flex items-center gap-2 px-3 py-1.5 hover:bg-gray-700 cursor-pointer text-sm\">\n          <input type=\"checkbox\" class=\"cat-checkbox rounded bg-gray-600 border-gray-500 text-blue-500 focus:ring-blue-500 focus:ring-offset-0\"\n                 value=\"{{ cat.category_id }}\"\n                 {% if cat.category_id|string in effective_cats.split(',') %}checked{% endif %}>\n          <span class=\"truncate\">{{ cat.category_name }}</span>\n        </label>\n        {% endfor %}\n      </div>\n      <script>\n      function applyDesktopFilter() {\n        var menu = document.getElementById('category-dropdown-menu');\n        var checks = menu ? menu.querySelectorAll('.cat-checkbox') : [];\n        var selected = [];\n        for (var i = 0; i < checks.length; i++) {\n          if (checks[i].checked) selected.push(checks[i].value);\n        }\n        var offset = {{ offset }};\n        // Determine URL first - explicit check for \"all selected\" vs \"some/none\"\n        var allSelected = checks.length > 0 && selected.length === checks.length;\n        var url = allSelected ? '/guide?offset=' + offset : '/guide?offset=' + offset + '&cats=' + selected.join(',');\n        // Save view preference (fire and forget, don't block navigation)\n        try {\n          fetch('/api/user-prefs', {\n            method: 'POST',\n            headers: {'Content-Type': 'application/json'},\n            body: JSON.stringify({guide_selected_cats: allSelected ? null : selected})\n          });\n        } catch (e) {}\n        // Navigate\n        window.location.href = url;\n      }\n      </script>\n      <span class=\"text-xs text-gray-500\">({{ total_count | default(channel_count) }} ch)</span>\n      <a href=\"/settings#filters\" class=\"text-xs text-gray-500 hover:text-blue-400\">[edit]</a>\n      {% endif %}\n    </div>\n    <div class=\"flex gap-1\">\n      <a href=\"/guide?offset={{ offset - 3 }}{% if cats_param %}&cats={{ cats_param }}{% endif %}\"\n         class=\"px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded focusable\" tabindex=\"0\">\n        &larr; Earlier\n      </a>\n      <a href=\"/guide?offset=0{% if cats_param %}&cats={{ cats_param }}{% endif %}\"\n         class=\"px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 rounded focusable\" tabindex=\"0\">\n        Now\n      </a>\n      <a href=\"/guide?offset={{ offset + 3 }}{% if cats_param %}&cats={{ cats_param }}{% endif %}\"\n         class=\"px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded focusable\" tabindex=\"0\">\n        Later &rarr;\n      </a>\n    </div>\n  </div>\n\n  {% if loading_message %}\n  <div class=\"bg-blue-900/50 border border-blue-500 rounded p-2 sm:p-4 mb-2 sm:mb-4\">\n    <p class=\"text-blue-300 text-sm sm:text-base\">{{ loading_message }}</p>\n  </div>\n  {% elif epg_error %}\n  <div class=\"bg-red-900/50 border border-red-500 rounded p-2 sm:p-4 mb-2 sm:mb-4\">\n    <p class=\"text-red-300 text-sm sm:text-base\">EPG Error: {{ epg_error }}</p>\n    <p class=\"text-xs sm:text-sm text-gray-400 mt-1\">Showing channels without program info</p>\n  </div>\n  {% endif %}\n\n  <!-- Mobile header (outside scroll container, stays visible in portrait, hidden in landscape) -->\n  <div class=\"compact-only mobile-header items-center justify-between gap-1 p-2 bg-gray-900 border-b border-gray-700 rounded-t-lg\">\n    <div class=\"flex items-center gap-1\">\n      <span class=\"text-sm font-bold\">Live TV</span>\n      {% if saved_filter or selected_cats %}\n      <!-- Mobile category filter multi-select -->\n      <div class=\"inline-block\" id=\"category-dropdown-mobile\">\n        <button type=\"button\" id=\"category-dropdown-btn-mobile\"\n                class=\"bg-gray-700 text-[10px] rounded px-1 py-0.5 border border-gray-600 inline-flex items-center gap-0.5 max-w-[100px]\">\n          <span class=\"truncate\">{% if effective_cats.split(',') | length < ordered_filter_cats | length %}{{ effective_cats.split(',') | length }} sel{% else %}All{% endif %}</span>\n          <svg class=\"w-2 h-2 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"/></svg>\n        </button>\n      </div>\n      <!-- Mobile dropdown menu (portal) -->\n      <div id=\"category-dropdown-menu-mobile\" class=\"hidden fixed top-0 left-0 bg-gray-800 border border-gray-600 rounded shadow-lg z-[9999] min-w-[160px] max-h-[250px] overflow-y-auto\">\n        <div class=\"p-1.5 border-b border-gray-700 flex gap-2\">\n          <button type=\"button\" onclick=\"document.querySelectorAll('#category-dropdown-menu-mobile .cat-checkbox-mobile').forEach(c=>c.checked=true)\" class=\"text-[10px] text-blue-400\">All</button>\n          <button type=\"button\" onclick=\"document.querySelectorAll('#category-dropdown-menu-mobile .cat-checkbox-mobile').forEach(c=>c.checked=false)\" class=\"text-[10px] text-blue-400\">None</button>\n        </div>\n        {% for cat in ordered_filter_cats %}\n        <label class=\"flex items-center gap-1.5 px-2 py-1 hover:bg-gray-700 cursor-pointer text-[11px]\">\n          <input type=\"checkbox\" class=\"cat-checkbox-mobile rounded bg-gray-600 border-gray-500 text-blue-500 w-3 h-3\"\n                 value=\"{{ cat.category_id }}\"\n                 {% if cat.category_id|string in effective_cats.split(',') %}checked{% endif %}>\n          <span class=\"truncate\">{{ cat.category_name }}</span>\n        </label>\n        {% endfor %}\n      </div>\n      <script>\n      function applyMobileFilter() {\n        var menu = document.getElementById('category-dropdown-menu-mobile');\n        var checks = menu ? menu.querySelectorAll('.cat-checkbox-mobile') : [];\n        var selected = [];\n        for (var i = 0; i < checks.length; i++) {\n          if (checks[i].checked) selected.push(checks[i].value);\n        }\n        var offset = {{ offset }};\n        // Determine URL first - explicit check for \"all selected\" vs \"some/none\"\n        var allSelected = checks.length > 0 && selected.length === checks.length;\n        var url = allSelected ? '/guide?offset=' + offset : '/guide?offset=' + offset + '&cats=' + selected.join(',');\n        // Save view preference (fire and forget, don't block navigation)\n        try {\n          fetch('/api/user-prefs', {\n            method: 'POST',\n            headers: {'Content-Type': 'application/json'},\n            body: JSON.stringify({guide_selected_cats: allSelected ? null : selected})\n          });\n        } catch (e) {}\n        // Navigate\n        window.location.href = url;\n      }\n      </script>\n      {% endif %}\n    </div>\n    <div class=\"flex gap-1\">\n      <a href=\"/guide?offset={{ offset - 2 }}{% if cats_param %}&cats={{ cats_param }}{% endif %}\"\n         class=\"px-1 py-0.5 text-[10px] bg-gray-700 rounded focusable\" tabindex=\"0\">◀</a>\n      <a href=\"/guide?offset=0{% if cats_param %}&cats={{ cats_param }}{% endif %}\"\n         class=\"px-1 py-0.5 text-[10px] bg-blue-600 rounded focusable\" tabindex=\"0\">Now</a>\n      <a href=\"/guide?offset={{ offset + 2 }}{% if cats_param %}&cats={{ cats_param }}{% endif %}\"\n         class=\"px-1 py-0.5 text-[10px] bg-gray-700 rounded focusable\" tabindex=\"0\">▶</a>\n    </div>\n  </div>\n\n  {% if grid_data %}\n  <!-- EPG Grid (scrollable) -->\n  <div class=\"flex-1 overflow-y-auto overflow-x-hidden bg-gray-800 rounded-lg\">\n    <!-- Mobile Time Header -->\n    <div class=\"compact-only sticky top-0 z-20 bg-gray-900 border-b border-gray-700\">\n      <div class=\"w-32 flex-shrink-0 p-1 bg-gray-900\"></div>\n      <div class=\"flex-1 relative h-5\">\n        {% for marker in time_markers_mobile %}\n        <div class=\"absolute top-0 h-full border-l border-gray-700 text-[10px] text-gray-400 pl-0.5\"\n             style=\"left: {{ marker.left_pct }}%\">\n          {{ marker.label }}\n        </div>\n        {% endfor %}\n      </div>\n    </div>\n\n    <!-- Desktop Time Header -->\n    <div class=\"desktop-only sticky top-0 z-20 bg-gray-900 border-b border-gray-700\">\n      <div class=\"w-36 lg:w-48 flex-shrink-0 p-2 bg-gray-900\"></div>\n      <div class=\"flex-1 relative h-10\">\n        {% for marker in time_markers %}\n        <div class=\"absolute top-0 h-full border-l border-gray-700 text-sm text-gray-400 pl-1\"\n             style=\"left: {{ marker.left_pct }}%\">\n          {{ marker.label }}\n        </div>\n        {% endfor %}\n      </div>\n    </div>\n\n    <!-- Channel Rows -->\n    {% for row in grid_data %}\n    <div class=\"guide-row flex border-b border-gray-700 hover:bg-gray-750\" data-row=\"{{ loop.index0 }}\">\n      <!-- Mobile Channel Info -->\n      <div class=\"compact-only w-32 flex-shrink-0 p-1 items-center bg-gray-800 sticky left-0 z-10 border-r border-gray-700\">\n        <a href=\"/play/live/{{ row.channel.stream_id }}\"\n           class=\"text-xs line-clamp-2 hover:text-blue-400 focus:text-blue-400 focus:outline focus:outline-2 focus:outline-blue-500 focusable\"\n           tabindex=\"0\" data-nav=\"epg\" data-row=\"{{ loop.index0 }}\" data-col=\"-1\"\n           title=\"{{ row.channel.name }}\">\n          {{ row.channel.name }}\n        </a>\n      </div>\n\n      <!-- Desktop Channel Info -->\n      <div class=\"desktop-only w-36 lg:w-48 flex-shrink-0 p-1 items-center gap-1 bg-gray-800 sticky left-0 z-10 border-r border-gray-700\">\n        {% if row.channel.icon %}\n        <img src=\"{{ row.channel.icon | logo_url }}\" alt=\"\" class=\"w-10 h-10 object-contain\"\n             onerror=\"this.style.display='none'\">\n        {% endif %}\n        <a href=\"/play/live/{{ row.channel.stream_id }}\"\n           class=\"text-sm line-clamp-3 hover:text-blue-400 focus:text-blue-400 focus:outline focus:outline-2 focus:outline-blue-500 focusable\"\n           tabindex=\"0\" data-nav=\"epg\" data-row=\"{{ loop.index0 }}\" data-col=\"-1\"\n           title=\"{{ row.channel.name }}\">\n          {{ row.channel.name }}\n        </a>\n      </div>\n\n      <!-- Mobile Programs (2-hour window) -->\n      {% set row_idx = loop.index0 %}\n      <div class=\"compact-only-block flex-1 relative h-10 ml-1\">\n        {% if row.programs_mobile %}\n        {% for prog in row.programs_mobile %}\n        <a href=\"/play/live/{{ row.channel.stream_id }}\"\n           class=\"absolute top-0.5 bottom-0.5 bg-gray-700 hover:bg-gray-600 rounded px-1 overflow-hidden\n                  focusable border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 focus:ring-offset-gray-800\"\n           style=\"left: {{ prog.left_pct }}%; width: calc({{ prog.width_pct }}% - 4px);\"\n           tabindex=\"0\" data-nav=\"epg\" data-row=\"{{ row_idx }}\" data-col=\"{{ loop.index0 }}\"\n           title=\"{{ prog.title }}&#10;{{ prog.start }} - {{ prog.end }}&#10;{{ prog.desc }}\">\n          <div class=\"text-[10px] font-medium truncate\">{{ prog.title }}</div>\n        </a>\n        {% endfor %}\n        {% else %}\n        <div class=\"absolute inset-0.5 flex items-center px-1 text-gray-500 text-[10px]\">\n          No info\n        </div>\n        {% endif %}\n      </div>\n\n      <!-- Desktop Programs -->\n      <div class=\"desktop-only-block flex-1 relative h-16 ml-1\">\n        {% if row.programs %}\n        {% for prog in row.programs %}\n        <a href=\"/play/live/{{ row.channel.stream_id }}\"\n           class=\"absolute top-1 bottom-1 bg-gray-700 hover:bg-gray-600 rounded px-2 py-1 overflow-hidden\n                  focusable border-2 border-transparent focus:border-blue-500 focus:bg-blue-900/50\"\n           style=\"left: {{ prog.left_pct }}%; width: calc({{ prog.width_pct }}% - 4px);\"\n           tabindex=\"0\" data-nav=\"epg\" data-row=\"{{ row_idx }}\" data-col=\"{{ loop.index0 }}\"\n           title=\"{{ prog.title }}&#10;{{ prog.start }} - {{ prog.end }}&#10;{{ prog.desc }}\">\n          <div class=\"text-sm font-medium truncate\">{{ prog.title }}</div>\n          <div class=\"text-xs text-gray-400 truncate\">{{ prog.desc }}</div>\n        </a>\n        {% endfor %}\n        {% else %}\n        <div class=\"absolute inset-1 flex items-center px-2 text-gray-500 text-sm\">\n          No program info\n        </div>\n        {% endif %}\n      </div>\n    </div>\n    {% endfor %}\n  </div>\n  {% else %}\n  <div class=\"flex-1 flex items-center justify-center bg-gray-800 rounded-lg\">\n    <div class=\"text-center text-gray-400\">\n      <p class=\"text-2xl mb-4\">No channels selected</p>\n      <p>Go to <a href=\"/settings#filters\" class=\"text-blue-400 hover:underline\">Settings</a> to configure Live TV category filter</p>\n    </div>\n  </div>\n  {% endif %}\n</div>\n{% endblock %}\n\n{% block scripts %}\n<script src=\"/static/js/virtual-guide.js\"></script>\n<script>\n// Virtual scroll configuration\nconst GUIDE_CONFIG = {\n  totalRows: {{ total_count | default(0) }},\n  offset: {{ offset }},\n  cats: {{ cats_param | tojson }},\n  initialRows: {{ grid_data | tojson | safe }},\n  // Logo URL proxy (matches _logo_url_filter in Python)\n  logoUrlFilter: function(url) {\n    if (!url || url.startsWith('/') || url.startsWith('data:')) {\n      return url;\n    }\n    try {\n      const parsed = new URL(url);\n      const source = parsed.hostname.split(':')[0] || 'external';\n      return '/api/logo?source=' + encodeURIComponent(source) + '&url=' + encodeURIComponent(url);\n    } catch (e) {\n      return url;\n    }\n  }\n};\n\n// Category multi-select dropdown toggle\n(function() {\n  function setupDropdown(btnId, menuId, applyFn) {\n    const btn = document.getElementById(btnId);\n    const menu = document.getElementById(menuId);\n    if (!btn || !menu) return;\n\n    // Move menu to body to avoid container clipping\n    document.body.appendChild(menu);\n\n    btn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      if (menu.classList.contains('hidden')) {\n        const rect = btn.getBoundingClientRect();\n        menu.style.top = rect.bottom + 4 + 'px';\n        menu.style.left = rect.left + 'px';\n        menu.classList.remove('hidden');\n      } else {\n        // Closing via button click - apply\n        menu.classList.add('hidden');\n        applyFn();\n      }\n    });\n\n    document.addEventListener('click', (e) => {\n      if (!menu.contains(e.target) && !btn.contains(e.target) && !menu.classList.contains('hidden')) {\n        menu.classList.add('hidden');\n        applyFn();\n      }\n    });\n  }\n\n  setupDropdown('category-dropdown-btn', 'category-dropdown-menu', applyDesktopFilter);\n  setupDropdown('category-dropdown-btn-mobile', 'category-dropdown-menu-mobile', applyMobileFilter);\n})();\n\n// Auto-refresh when EPG finishes loading\n{% if epg_loading %}\n(function() {\n  const es = new EventSource('/events/epg');\n  es.onmessage = function(e) {\n    if (e.data === 'epg_ready') {\n      es.close();\n      window.location.reload();\n    }\n  };\n  es.onerror = function() { es.close(); };\n})();\n{% endif %}\n\n// Auto-advance when half-hour boundary passes (only when viewing \"now\")\n{% if offset == 0 %}\n(function() {\n  const now = new Date();\n  const min = now.getMinutes();\n  const minToNext = min < 30 ? (30 - min) : (60 - min);\n  const msToNext = (minToNext * 60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 500;\n  setTimeout(() => window.location.reload(), msToNext);\n})();\n{% endif %}\n\n// Initialize virtual scrolling if enabled and we have more than initial batch\n(function() {\n  const virtualScrollEnabled = {{ virtual_scroll | default(true) | tojson }};\n  if (virtualScrollEnabled && GUIDE_CONFIG.totalRows > GUIDE_CONFIG.initialRows.length && GUIDE_CONFIG.totalRows > 0) {\n    const container = document.querySelector('.guide-container');\n    if (container) {\n      window.virtualGuide = new VirtualGuide({\n        container: container,\n        totalRows: GUIDE_CONFIG.totalRows,\n        initialRows: GUIDE_CONFIG.initialRows,\n        offset: GUIDE_CONFIG.offset,\n        cats: GUIDE_CONFIG.cats,\n        rowHeight: 64,  // 4rem desktop\n        rowHeightMobile: 40,  // 2.5rem mobile\n        bufferSize: 50,\n        logoUrlFilter: GUIDE_CONFIG.logoUrlFilter\n      });\n    }\n  }\n})();\n\n// EPG-specific keyboard navigation\n(function() {\n  function isVisible(el) {\n    return el.offsetParent !== null;\n  }\n\n  // Use total count for keyboard navigation range\n  const maxRow = () => {\n    return Math.max(0, GUIDE_CONFIG.totalRows - 1);\n  };\n\n  function getVisiblePrograms(row) {\n    // Select ONLY from desktop-only-block containers\n    const containers = document.querySelectorAll('.desktop-only-block');\n    let programs = [];\n    containers.forEach(c => {\n      const rowDiv = c.closest('[data-row]');\n      if (rowDiv && rowDiv.dataset.row === String(row)) {\n        programs.push(...c.querySelectorAll('a[data-nav=\"epg\"]'));\n      }\n    });\n    // Filter to unique visual positions (first element at each left position)\n    const seen = new Set();\n    const unique = programs.filter(p => {\n      const left = Math.round(p.getBoundingClientRect().left);\n      if (seen.has(left)) return false;\n      seen.add(left);\n      return true;\n    });\n    return unique;\n  }\n\n  function getCurrentPos() {\n    const el = document.activeElement;\n    if (!el || !el.dataset || el.dataset.nav !== 'epg') {\n      return { row: 0, col: -1 };\n    }\n    const row = parseInt(el.dataset.row) || 0;\n    const dataCol = parseInt(el.dataset.col);\n    if (dataCol === -1) {\n      return { row, col: -1 };\n    }\n    // Find visual index in sorted list\n    const programs = getVisiblePrograms(row);\n    const visualCol = programs.indexOf(el);\n    return { row, col: visualCol >= 0 ? visualCol : 0 };\n  }\n\n  function focusCell(row, col) {\n    // If virtual scrolling is active, ensure row is in view first\n    if (window.virtualGuide) {\n      const vg = window.virtualGuide;\n      const rowHeight = vg.currentRowHeight;\n      const viewport = vg.viewport;\n\n      // Check if row is outside rendered range\n      if (row < vg.renderedRange.start || row >= vg.renderedRange.end) {\n        // Scroll to bring row into view, then focus after render\n        const targetScroll = row * rowHeight - (viewport.clientHeight / 2) + (rowHeight / 2);\n        viewport.scrollTop = Math.max(0, targetScroll);\n\n        // Wait for render and then focus\n        setTimeout(() => doFocus(row, col), 100);\n        return;\n      }\n    }\n\n    doFocus(row, col);\n  }\n\n  function doFocus(row, col) {\n    let cell;\n    if (col === -1) {\n      const candidates = document.querySelectorAll(`[data-nav=\"epg\"][data-row=\"${row}\"][data-col=\"-1\"]`);\n      cell = Array.from(candidates).find(isVisible);\n    } else {\n      const cells = getVisiblePrograms(row);\n      cell = cells[col] || cells[cells.length - 1];\n      if (!cell) {\n        const fallback = document.querySelectorAll(`[data-nav=\"epg\"][data-row=\"${row}\"][data-col=\"-1\"]`);\n        cell = Array.from(fallback).find(isVisible);\n      }\n    }\n    if (cell) {\n      cell.focus();\n      cell.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });\n    }\n  }\n\n  document.addEventListener('keydown', (e) => {\n    if (e.target.type === 'checkbox' || e.target.tagName === 'INPUT') return;\n    if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'PageUp', 'PageDown', 'Home', 'End'].includes(e.key)) return;\n    if (e.altKey) return; // Allow browser back/forward\n\n    const { row, col } = getCurrentPos();\n    const catsParam = '{{ cats_param }}';\n    const catsSuffix = catsParam ? '&cats=' + catsParam : '';\n\n    switch(e.key) {\n      case 'ArrowUp':\n        e.preventDefault();\n        focusCell(Math.max(0, row - 1), col);\n        break;\n      case 'ArrowDown':\n        e.preventDefault();\n        focusCell(Math.min(maxRow(), row + 1), col);\n        break;\n      case 'ArrowLeft':\n        e.preventDefault();\n        if (col > -1) {\n          focusCell(row, col - 1);\n        } else {\n          window.location.href = `/guide?offset={{ offset - 3 }}${catsSuffix}`;\n        }\n        break;\n      case 'ArrowRight':\n        e.preventDefault();\n        const progs = getVisiblePrograms(row);\n        if (col < progs.length - 1) {\n          focusCell(row, col + 1);\n        }\n        break;\n      case 'Enter':\n        if (document.activeElement?.href) {\n          e.preventDefault();\n          if (e.ctrlKey || e.metaKey) {\n            window.open(document.activeElement.href, '_blank');\n          } else {\n            window.location.href = document.activeElement.href;\n          }\n        }\n        break;\n      case 'PageUp':\n      case 'PageDown':\n        e.preventDefault();\n        const container = document.querySelector('.overflow-y-auto');\n        const rowEl = document.querySelector('[data-row=\"0\"]');\n        const pageSize = (container && rowEl) ? Math.floor(container.clientHeight / rowEl.offsetHeight) : 10;\n        if (e.key === 'PageUp') {\n          focusCell(Math.max(0, row - pageSize), col);\n        } else {\n          focusCell(Math.min(maxRow(), row + pageSize), col);\n        }\n        break;\n      case 'Home':\n        e.preventDefault();\n        focusCell(0, col);\n        break;\n      case 'End':\n        e.preventDefault();\n        focusCell(maxRow(), col);\n        break;\n    }\n  });\n\n  // Save/restore scroll position (persists across reloads and navigation)\n  // Note: VirtualGuide handles scroll restore for virtual scrolling mode\n  const scrollKey = 'guide_scroll';\n  const gridContainer = document.querySelector('.overflow-y-auto');\n  if (gridContainer) {\n    // Only restore if not using virtual scrolling (VirtualGuide handles it)\n    if (!window.virtualGuide) {\n      const saved = sessionStorage.getItem(scrollKey);\n      if (saved) gridContainer.scrollTop = parseInt(saved);\n    }\n    // Save on scroll (debounced)\n    let scrollTimer;\n    gridContainer.addEventListener('scroll', () => {\n      clearTimeout(scrollTimer);\n      scrollTimer = setTimeout(() => sessionStorage.setItem(scrollKey, gridContainer.scrollTop), 100);\n    });\n    // Save immediately when clicking any link (before navigation)\n    gridContainer.addEventListener('click', (e) => {\n      if (e.target.closest('a')) sessionStorage.setItem(scrollKey, gridContainer.scrollTop);\n    });\n    // Save before page unload (catches all navigation/reloads)\n    window.addEventListener('beforeunload', () => sessionStorage.setItem(scrollKey, gridContainer.scrollTop));\n    // Restore on bfcache navigation (pageshow fires when page is restored from cache)\n    window.addEventListener('pageshow', (e) => {\n      if (e.persisted) {\n        const saved = sessionStorage.getItem(scrollKey);\n        if (saved) gridContainer.scrollTop = parseInt(saved);\n      }\n    });\n  }\n\n  // Initial focus\n  setTimeout(() => {\n    const first = document.querySelector('[data-nav=\"epg\"][data-row=\"0\"][data-col=\"-1\"]');\n    if (first && document.activeElement === document.body) first.focus();\n  }, 0);\n})();\n\n</script>\n{% endblock %}\n"
  },
  {
    "path": "templates/login.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"h-full\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Login - neTV</title>\n  <script src=\"https://cdn.tailwindcss.com\"></script>\n</head>\n<body class=\"h-full bg-gray-900 text-gray-100 flex items-center justify-center\">\n  <div class=\"bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-md\">\n    <h1 class=\"text-3xl font-bold text-center mb-8 text-blue-400\">neTV</h1>\n    {% if error %}\n    <div class=\"mb-6 p-3 bg-red-900/50 border border-red-500 rounded text-red-200 text-center\">\n      Invalid username or password\n    </div>\n    {% endif %}\n    <form method=\"POST\" action=\"/login\" class=\"space-y-6\">\n      <div>\n        <label for=\"username\" class=\"block text-lg mb-2\">Username</label>\n        <input type=\"text\" id=\"username\" name=\"username\" value=\"{{ last_user }}\" required {% if not last_user %}autofocus{% endif %}\n               class=\"w-full px-4 py-3 bg-gray-700 rounded text-xl focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n      </div>\n      <div>\n        <label for=\"password\" class=\"block text-lg mb-2\">Password</label>\n        <input type=\"password\" id=\"password\" name=\"password\" required {% if last_user %}autofocus{% endif %}\n               class=\"w-full px-4 py-3 bg-gray-700 rounded text-xl focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n      </div>\n      <button type=\"submit\"\n              class=\"w-full py-4 bg-blue-600 hover:bg-blue-700 rounded text-xl font-semibold transition-colors\">\n        Login\n      </button>\n    </form>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/movie_detail.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}{{ movie.name if movie else 'Movie' }} - neTV{% endblock %}\n\n{% block content %}\n<div class=\"flex flex-col md:h-full min-h-0 min-w-0\">\n  {% if movie %}\n  <div class=\"flex gap-2 sm:gap-6 mb-6\">\n    {% if movie.cover_big or movie.stream_icon %}\n    <img id=\"cover\" src=\"{{ (movie.cover_big or movie.stream_icon) | logo_url }}\" alt=\"\" class=\"w-24 sm:w-36 lg:w-48 h-auto max-h-72 object-cover rounded-lg flex-shrink-0\">\n    {% endif %}\n    <div class=\"min-w-0 flex-1\">\n      <div class=\"flex flex-wrap items-center justify-between gap-2 mb-2\">\n        <h2 class=\"text-xl sm:text-3xl font-bold min-w-0\">{{ movie.name }}</h2>\n        <button id=\"fav-btn\" class=\"px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base rounded bg-gray-700 hover:bg-gray-600 focusable flex-shrink-0\" tabindex=\"0\">\n          ☆ Add to Favorites\n        </button>\n      </div>\n      <div class=\"flex gap-4 text-gray-400 mb-4\">\n        {% if movie.year %}<span>{{ movie.year }}</span>{% endif %}\n        {% if movie.rating %}<span class=\"text-yellow-400\">★ {{ movie.rating }}/10</span>{% endif %}\n        {% if movie.duration %}<span>{{ movie.duration }}</span>{% endif %}\n      </div>\n\n      {% if movie.genre %}\n      <div class=\"text-sm text-gray-400 mb-4\">{{ movie.genre }}</div>\n      {% endif %}\n\n      {% if movie.plot %}\n      <p class=\"text-gray-300 mb-6 max-w-3xl\">{{ movie.plot }}</p>\n      {% endif %}\n\n      {% if movie.director %}\n      <p class=\"text-sm text-gray-400 mb-1\"><span class=\"text-gray-500\">Director:</span> {{ movie.director }}</p>\n      {% endif %}\n      {% if movie.cast %}\n      <p class=\"text-sm text-gray-400 mb-4\"><span class=\"text-gray-500\">Cast:</span> {{ movie.cast }}</p>\n      {% endif %}\n\n      <div class=\"flex flex-wrap gap-2 sm:gap-4\">\n        <a href=\"/play/movie/{{ movie.stream_id }}?ext={{ movie.container_extension or 'mkv' }}\"\n           class=\"inline-block px-4 sm:px-8 py-2 sm:py-4 bg-blue-600 hover:bg-blue-700 rounded-lg text-base sm:text-xl font-semibold focusable\"\n           tabindex=\"0\" autofocus>\n          ▶ Play\n        </a>\n        {% if movie.youtube_trailer %}\n        <a href=\"https://www.youtube.com/watch?v={{ movie.youtube_trailer }}\" target=\"_blank\"\n           class=\"inline-block px-4 sm:px-8 py-2 sm:py-4 bg-red-600 hover:bg-red-700 rounded-lg text-base sm:text-xl font-semibold focusable\"\n           tabindex=\"0\">\n          Trailer\n        </a>\n        {% endif %}\n      </div>\n    </div>\n  </div>\n  {% else %}\n  <p class=\"text-gray-400\">Movie not found</p>\n  {% endif %}\n</div>\n{% endblock %}\n\n{% block scripts %}\n<script>\nlet favorites = {{ favorites | tojson }};\nconst MOVIE_ID = '{{ movie.stream_id if movie else \"\" }}';\nconst MOVIE_NAME = '{{ movie.name | e if movie else \"\" }}';\nconst MOVIE_COVER = document.getElementById('cover')?.getAttribute('src') ?? \"\";\nconst MOVIE_EXT = '{{ movie.container_extension or \"mkv\" if movie else \"mkv\" }}';\n\nfunction saveFavorites() {\n  fetch('/api/user-prefs', {\n    method: 'POST',\n    headers: {'Content-Type': 'application/json'},\n    body: JSON.stringify({favorites})\n  });\n}\n\nfunction toggleFavorite() {\n  if (!MOVIE_ID) return;\n  if (favorites.movies[MOVIE_ID]) {\n    delete favorites.movies[MOVIE_ID];\n  } else {\n    favorites.movies[MOVIE_ID] = { name: MOVIE_NAME, cover: MOVIE_COVER, ext: MOVIE_EXT };\n  }\n  saveFavorites();\n  updateButton();\n}\n\nfunction updateButton() {\n  const btn = document.getElementById('fav-btn');\n  const isFav = !!favorites.movies[MOVIE_ID];\n  btn.innerHTML = isFav ? '★ In Favorites' : '☆ Add to Favorites';\n  btn.classList.toggle('bg-yellow-600', isFav);\n  btn.classList.toggle('hover:bg-yellow-700', isFav);\n  btn.classList.toggle('bg-gray-700', !isFav);\n  btn.classList.toggle('hover:bg-gray-600', !isFav);\n}\n\ndocument.getElementById('fav-btn').addEventListener('click', toggleFavorite);\nupdateButton();\n</script>\n{% endblock %}\n"
  },
  {
    "path": "templates/player.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}{% if channel_name %}{{ channel_name }}{% if program_title %} — {{ program_title }}{% endif %}{% else %}Now Playing{% endif %}{% endblock %}\n\n{% block head_extra %}\n<style>\n/* Control bar - show on activity */\n.player-controls {\n  opacity: 0;\n  transition: opacity 0.2s;\n  pointer-events: none;\n}\n#player-container.controls-visible .player-controls {\n  opacity: 1;\n  pointer-events: auto;\n}\n#info-overlay.pinned {\n  opacity: 1;\n  pointer-events: auto;\n}\n#player-container:has(#loading:not(.hidden)) #info-overlay {\n  opacity: 1;\n  pointer-events: auto;\n}\n#player-container { cursor: none; }\n#player-container.controls-visible { cursor: auto; }\n/* Settings menu */\n.settings-menu {\n  display: none;\n  position: absolute;\n  bottom: 100%;\n  right: 0;\n  margin-bottom: 0.5rem;\n  min-width: 10rem;\n}\n.settings-menu.open { display: block; }\n.settings-item {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.5rem 0.75rem;\n  cursor: pointer;\n  white-space: nowrap;\n}\n.settings-item:hover { background: #374151; }\n.settings-check { width: 1rem; text-align: center; }\n/* Icon buttons */\n.ctrl-btn {\n  padding: 0.5rem;\n  border-radius: 0.25rem;\n  color: white;\n  background: transparent;\n}\n.ctrl-btn:hover { background: rgba(255,255,255,0.1); }\n.ctrl-btn:disabled { opacity: 0.4; cursor: not-allowed; }\n.ctrl-btn svg { width: 1.25rem; height: 1.25rem; fill: white; }\n#toggle-cc svg, #info-btn svg, #autonext-btn svg { opacity: 0.25; }\n#toggle-cc.active svg, #info-btn.active svg, #autonext-btn.active svg { opacity: 1; }\n/* Seek input */\n#seek-container input {\n  background: rgba(0,0,0,0.7);\n  border: 1px solid #4b5563;\n}\n</style>\n{% endblock %}\n\n{% block content %}\n  <!-- Video container with overlay controls - full frame -->\n  <div class=\"h-full bg-black overflow-hidden relative\" id=\"player-container\">\n    <video id=\"video\" class=\"w-full h-full bg-black\" crossorigin=\"anonymous\"></video>\n\n    <!-- Title overlay -->\n    <div id=\"info-overlay\" class=\"player-controls absolute top-0 left-0 z-30 bg-black/50 rounded-br-lg pb-2 pt-2 px-3\">\n      <div>{% if channel_name %}{{ channel_name }}{% if program_title %} &mdash; {{ program_title }}{% endif %}{% else %}Now Playing{% endif %}</div>\n      {% if program_desc %}<div class=\"text-gray-400 mt-0.5\" style=\"font-size:0.85em\">{{ program_desc }}</div>{% endif %}\n    </div>\n\n    <!-- Loading indicator -->\n    <div id=\"loading\" class=\"absolute inset-0 flex items-center justify-center bg-black/50 z-20 pointer-events-none\">\n      <div class=\"animate-spin w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full\"></div>\n    </div>\n\n    <!-- Error state -->\n    <div id=\"error\" class=\"absolute inset-0 flex items-center justify-center bg-black/80 hidden z-30\">\n      <div class=\"text-center\">\n        <p class=\"text-2xl text-red-400 mb-4\">Failed to load stream</p>\n        <button onclick=\"location.reload()\" class=\"px-6 py-3 bg-blue-600 rounded-lg focusable\" tabindex=\"0\">Retry</button>\n      </div>\n    </div>\n\n    <!-- Control bar overlay -->\n    <div class=\"player-controls absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 to-transparent pt-8 pb-2 px-2\">\n      <!-- Progress bar for VOD -->\n      <div id=\"progress-container\" class=\"px-1 mb-2\">\n        <div class=\"relative h-1 bg-gray-600 rounded cursor-pointer group\" id=\"progress-bar\">\n          <div id=\"progress-buffered\" class=\"absolute h-full bg-gray-500 rounded\"></div>\n          <div id=\"progress-played\" class=\"absolute h-full bg-blue-500 rounded\"></div>\n          <div id=\"progress-handle\" class=\"absolute w-3 h-3 bg-white rounded-full -top-1 -ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity\"></div>\n        </div>\n        <div class=\"flex justify-between text-xs text-gray-300 mt-1\">\n          <span id=\"time-current\">0:00</span>\n          <span id=\"time-duration\">0:00</span>\n        </div>\n      </div>\n      <!-- Jump input (hidden, shown from menu) -->\n      <div id=\"seek-container\" class=\"hidden flex items-center gap-2 mb-2 px-1\">\n        <input type=\"text\" id=\"seek-input\" placeholder=\"0:00:00\" class=\"w-20 px-2 py-1 text-xs rounded focus:outline-none focus:ring-1 focus:ring-blue-500\" title=\"Enter time (H:MM:SS)\">\n        <span id=\"seek-duration\" class=\"text-xs text-gray-300\"></span>\n      </div>\n\n      <div class=\"flex items-center justify-between\">\n        <!-- Left controls -->\n        <div class=\"flex items-center gap-1\">\n          <button id=\"play-btn\" class=\"ctrl-btn\" title=\"Play/Pause (Space)\">\n            <svg id=\"play-icon\" viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\"/></svg>\n            <svg id=\"pause-icon\" class=\"hidden\" viewBox=\"0 0 24 24\"><path d=\"M6 19h4V5H6v14zm8-14v14h4V5h-4z\"/></svg>\n          </button>\n          <button id=\"mute-btn\" class=\"ctrl-btn\" title=\"Mute (m)\">\n            <svg id=\"vol-icon\" viewBox=\"0 0 24 24\"><path d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z\"/></svg>\n            <svg id=\"muted-icon\" class=\"hidden\" viewBox=\"0 0 24 24\"><path d=\"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z\"/></svg>\n          </button>\n          <input type=\"range\" id=\"volume-slider\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\" class=\"w-16 h-1 accent-white cursor-pointer\">\n        </div>\n\n        <!-- Right controls -->\n        <div class=\"flex items-center gap-1\">\n          {% if stream_type in ['movie', 'series'] %}\n          <button id=\"jump-btn\" class=\"ctrl-btn\" title=\"Jump (j)\">\n            <svg viewBox=\"0 0 24 24\"><path d=\"M18 4l-4 4h3v7c0 1.1-.9 2-2 2s-2-.9-2-2V9c0-2.21-1.79-4-4-4S5 6.79 5 9v7H2l4 4 4-4H7V9c0-1.1.9-2 2-2s2 .9 2 2v6c0 2.21 1.79 4 4 4s4-1.79 4-4V8h3l-4-4z\"/></svg>\n          </button>\n          {% endif %}\n          {% if stream_type == 'series' and next_episode_url %}\n          <button id=\"autonext-btn\" class=\"ctrl-btn active\" title=\"Auto-play next (n)\">\n            <svg viewBox=\"0 0 24 24\"><path d=\"M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z\"/></svg>\n          </button>\n          {% endif %}\n          <button id=\"info-btn\" class=\"ctrl-btn\" title=\"Info (i)\">\n            <svg viewBox=\"0 0 24 24\"><path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z\"/></svg>\n          </button>\n          <button id=\"toggle-cc\" class=\"ctrl-btn\" title=\"Captions (c)\">\n            <svg viewBox=\"0 0 24 24\"><path d=\"M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z\"/></svg>\n          </button>\n          <button id=\"pip-btn\" class=\"ctrl-btn\" title=\"Picture-in-Picture (p)\">\n            <svg viewBox=\"0 0 24 24\"><path d=\"M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z\"/></svg>\n          </button>\n\n          <!-- Settings dropdown -->\n          <div class=\"relative\">\n            <button id=\"settings-btn\" class=\"ctrl-btn\" title=\"Settings\">\n              <svg viewBox=\"0 0 24 24\"><path d=\"M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z\"/></svg>\n            </button>\n            <div id=\"settings-menu\" class=\"settings-menu bg-gray-800 rounded shadow-lg text-sm\">\n              <div id=\"menu-transcode\" class=\"settings-item\"><span class=\"settings-check\" id=\"tc-check\"></span>Transcode</div>\n              <div id=\"menu-restart\" class=\"settings-item\"><span class=\"settings-check\"></span>Restart transcode</div>\n              <div id=\"menu-jump\" class=\"settings-item hidden\"><span class=\"settings-check\"></span>Jump to time...</div>\n              <div id=\"menu-cc-tracks\" class=\"settings-item\"><span class=\"settings-check\"></span>CC Track</div>\n              <div class=\"border-t border-gray-700 my-1\"></div>\n              <div id=\"menu-url\" class=\"settings-item\"><span class=\"settings-check\"></span>Copy URL</div>\n              <div id=\"menu-external\" class=\"settings-item\"><span class=\"settings-check\"></span>Open external</div>\n            </div>\n          </div>\n\n          {% if request.url.scheme == 'https' %}\n          <button id=\"cast-btn\" class=\"ctrl-btn\" title=\"Cast\" disabled>\n            <svg viewBox=\"0 0 24 24\"><path d=\"M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11z\"/></svg>\n          </button>\n          {% endif %}\n\n          <button id=\"fullscreen-btn\" class=\"ctrl-btn\" title=\"Fullscreen (f)\">\n            <svg id=\"fs-enter\" viewBox=\"0 0 24 24\"><path d=\"M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z\"/></svg>\n            <svg id=\"fs-exit\" class=\"hidden\" viewBox=\"0 0 24 24\"><path d=\"M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z\"/></svg>\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n{% endblock %}\n\n{% block scripts %}\n<script>\nwindow.PLAYER_CONFIG = {\n  rawUrl: {{ raw_url | tojson }},\n  transcodeMode: {{ transcode_mode | tojson }},\n  captionsEnabled: {{ captions_enabled | tojson }},\n  streamType: {{ stream_type | tojson }},\n  isVod: {{ stream_type | tojson }} === 'movie' || {{ stream_type | tojson }} === 'series',\n  serverResumePosition: {{ resume_position | tojson }},\n  seriesId: {{ series_id | tojson }},\n  episodeId: {{ episode_id | tojson }},\n  seriesName: {{ series_name | tojson }},\n  mediaTitle: {{ (channel_name ~ (' — ' ~ program_title if program_title else '')) | default('Now Playing', true) | tojson }},\n  nextEpisodeUrl: {{ next_episode_url | tojson }},\n  ccStyle: {{ cc_style | tojson }},\n  ccLang: {{ cc_lang | tojson }},\n  castHost: {{ cast_host | tojson }},\n  isHttps: {{ (request.url.scheme == 'https') | tojson }},\n  deinterlaceFallback: {{ deinterlace_fallback | tojson }},\n  sourceId: {{ source_id | tojson }}\n};\n</script>\n<script src=\"/static/js/player.js\"></script>\n{% endblock %}\n"
  },
  {
    "path": "templates/search.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}Search - neTV{% endblock %}\n\n{% block content %}\n<div class=\"flex flex-col h-full min-h-0 min-w-0\">\n  <h2 class=\"text-xl sm:text-3xl font-bold mb-6\">Search</h2>\n\n  <form action=\"/search\" method=\"GET\" class=\"mb-8\">\n    <div class=\"flex flex-wrap items-center gap-2 sm:gap-4 max-w-xl mb-3\">\n      <input type=\"text\" name=\"q\" value=\"{{ query }}\" placeholder=\"Search...\"\n             class=\"flex-1 min-w-0 px-3 sm:px-6 py-2 sm:py-4 bg-gray-800 rounded-lg text-base sm:text-xl focus:ring-2 focus:ring-blue-500 focus:outline-none\"\n             autofocus>\n      <label class=\"flex items-center gap-2 text-sm text-gray-400 cursor-pointer\">\n        <input type=\"checkbox\" name=\"regex\" value=\"true\" {% if regex %}checked{% endif %}\n               class=\"w-4 h-4 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n        Regex\n      </label>\n    </div>\n    <div class=\"flex items-center gap-4 text-sm\">\n      <label class=\"flex items-center gap-2 text-gray-400 cursor-pointer\">\n        <input type=\"checkbox\" name=\"live\" value=\"true\" {% if search_live %}checked{% endif %}\n               class=\"w-4 h-4 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n        Live TV\n      </label>\n      <label class=\"flex items-center gap-2 text-gray-400 cursor-pointer\">\n        <input type=\"checkbox\" name=\"vod\" value=\"true\" {% if search_vod %}checked{% endif %}\n               class=\"w-4 h-4 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n        Movies\n      </label>\n      <label class=\"flex items-center gap-2 text-gray-400 cursor-pointer\">\n        <input type=\"checkbox\" name=\"series\" value=\"true\" {% if search_series %}checked{% endif %}\n               class=\"w-4 h-4 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n        Series\n      </label>\n      <label class=\"flex items-center gap-2 text-gray-400\">\n        <span>Limit:</span>\n        <select name=\"limit\" class=\"bg-gray-700 border-gray-600 rounded px-2 py-1 text-sm\" onchange=\"this.form.submit()\">\n          <option value=\"25\" {% if limit == 25 %}selected{% endif %}>25</option>\n          <option value=\"50\" {% if limit == 50 %}selected{% endif %}>50</option>\n          <option value=\"100\" {% if limit == 100 %}selected{% endif %}>100</option>\n          <option value=\"250\" {% if limit == 250 %}selected{% endif %}>250</option>\n          <option value=\"0\" {% if limit == 0 %}selected{% endif %}>All</option>\n        </select>\n      </label>\n    </div>\n  </form>\n\n  <div class=\"flex-1 min-h-0 overflow-auto scroll-ring-safe\">\n  {% if query %}\n  {% if results.live or results.vod or results.series %}\n  <div class=\"flex gap-4 mb-4 text-sm\">\n    {% if results.live %}<a href=\"#live-results\" class=\"text-blue-400 hover:underline\">Live ({{ results.live|length }}{% if limit and results.live|length == limit %}+{% endif %})</a>{% endif %}\n    {% if results.vod %}<a href=\"#vod-results\" class=\"text-blue-400 hover:underline\">Movies ({{ results.vod|length }}{% if limit and results.vod|length == limit %}+{% endif %})</a>{% endif %}\n    {% if results.series %}<a href=\"#series-results\" class=\"text-blue-400 hover:underline\">Series ({{ results.series|length }}{% if limit and results.series|length == limit %}+{% endif %})</a>{% endif %}\n  </div>\n  {% endif %}\n  <!-- Live results -->\n  {% if results.live %}\n  <div id=\"live-results\" class=\"mb-8\">\n    <h3 class=\"text-2xl font-semibold mb-4\">Live Channels ({{ results.live|length }}{% if limit and results.live|length == limit %}+{% endif %})</h3>\n    <div class=\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4\">\n      {% for stream in results.live %}\n      <a href=\"/play/live/{{ stream.stream_id }}\"\n         class=\"block bg-gray-800 rounded-lg p-4 hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 focusable transition-all flex flex-col items-center\"\n         tabindex=\"0\" data-nav=\"grid\">\n        {% if stream.stream_icon %}\n        <img data-src=\"{{ stream.stream_icon | logo_url }}\" alt=\"\" class=\"w-16 h-16 object-contain mb-2 lazy-img\"\n             onerror=\"this.style.display='none'\">\n        {% endif %}\n        <span class=\"text-center line-clamp-2\">{{ stream.name }}</span>\n      </a>\n      {% endfor %}\n    </div>\n  </div>\n  {% endif %}\n\n  <!-- VOD results -->\n  {% if results.vod %}\n  <div id=\"vod-results\" class=\"mb-8\">\n    <h3 class=\"text-2xl font-semibold mb-4\">Movies ({{ results.vod|length }}{% if limit and results.vod|length == limit %}+{% endif %})</h3>\n    <div class=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3\">\n      {% for movie in results.vod %}\n      <div class=\"movie-card relative group\" data-movie-id=\"{{ movie.stream_id }}\" data-movie-name=\"{{ movie.name }}\"\n           data-movie-cover=\"{{ (movie.stream_icon or '') | logo_url }}\" data-movie-ext=\"{{ movie.container_extension or 'mkv' }}\">\n        <a href=\"/movie/{{ movie.stream_id }}\"\n           class=\"block bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 focusable transition-all\"\n           tabindex=\"0\" data-nav=\"grid\">\n          <div class=\"aspect-[2/3] bg-gray-700\">\n            {% if movie.stream_icon %}\n            <img data-src=\"{{ movie.stream_icon | logo_url }}\" alt=\"\" class=\"w-full h-full object-cover lazy-img\"\n                 onerror=\"this.style.display='none'\">\n            {% endif %}\n          </div>\n          <div class=\"p-2\">\n            <span class=\"text-sm line-clamp-2\">{{ movie.name }}</span>\n          </div>\n        </a>\n        <button type=\"button\" class=\"fav-btn-movie absolute top-2 right-2 w-8 h-8 rounded-full bg-black/50 text-xl opacity-0 group-hover:opacity-100 transition-opacity z-10\"\n                onclick='toggleMovieFavorite({{ movie.stream_id | tojson }}, {{ movie.name | tojson }}, {{ (movie.stream_icon or \"\") | logo_url | tojson }}, {{ (movie.container_extension or \"mkv\") | tojson }})'>\n          ☆\n        </button>\n      </div>\n      {% endfor %}\n    </div>\n  </div>\n  {% endif %}\n\n  <!-- Series results -->\n  {% if results.series %}\n  <div id=\"series-results\" class=\"mb-8\">\n    <h3 class=\"text-2xl font-semibold mb-4\">Series ({{ results.series|length }}{% if limit and results.series|length == limit %}+{% endif %})</h3>\n    <div class=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3\">\n      {% for s in results.series %}\n      <div class=\"series-card relative group\" data-series-id=\"{{ s.series_id }}\" data-series-name=\"{{ s.name }}\" data-series-cover=\"{{ (s.cover or '') | logo_url }}\">\n        <a href=\"/series/{{ s.series_id }}\"\n           class=\"block bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 focusable transition-all\"\n           tabindex=\"0\" data-nav=\"grid\">\n          <div class=\"aspect-[2/3] bg-gray-700\">\n            {% if s.cover %}\n            <img data-src=\"{{ s.cover | logo_url }}\" alt=\"\" class=\"w-full h-full object-cover lazy-img\"\n                 onerror=\"this.style.display='none'\">\n            {% endif %}\n          </div>\n          <div class=\"p-2\">\n            <span class=\"text-sm line-clamp-2\">{{ s.name }}</span>\n          </div>\n        </a>\n        <button type=\"button\" class=\"fav-btn-series absolute top-2 right-2 w-8 h-8 rounded-full bg-black/50 text-xl opacity-0 group-hover:opacity-100 transition-opacity z-10\"\n                onclick='toggleSeriesFavorite({{ s.series_id | tojson }}, {{ s.name | tojson }}, {{ (s.cover or \"\") | logo_url | tojson }})'>\n          ☆\n        </button>\n      </div>\n      {% endfor %}\n    </div>\n  </div>\n  {% endif %}\n\n  {% if not results.live and not results.vod and not results.series %}\n  <p class=\"text-gray-400 text-xl\">No results found for \"{{ query }}\"</p>\n  {% endif %}\n  {% endif %}\n  </div>\n</div>\n{% endblock %}\n\n{% block scripts %}\n<script>\nlet favorites = {{ favorites | tojson }};\n\nfunction saveFavorites() {\n  fetch('/api/user-prefs', {\n    method: 'POST',\n    headers: {'Content-Type': 'application/json'},\n    body: JSON.stringify({favorites})\n  });\n}\n\nfunction toggleSeriesFavorite(id, name, cover) {\n  id = String(id);\n  if (favorites.series[id]) delete favorites.series[id];\n  else favorites.series[id] = { name, cover };\n  saveFavorites();\n  updateButtons();\n}\n\nfunction toggleMovieFavorite(id, name, cover, ext) {\n  id = String(id);\n  if (favorites.movies[id]) delete favorites.movies[id];\n  else favorites.movies[id] = { name, cover, ext };\n  saveFavorites();\n  updateButtons();\n}\n\nfunction updateButtons() {\n  document.querySelectorAll('.fav-btn-series').forEach(btn => {\n    const card = btn.closest('.series-card');\n    const id = card?.dataset.seriesId;\n    btn.textContent = favorites.series[id] ? '★' : '☆';\n    btn.classList.toggle('text-yellow-400', !!favorites.series[id]);\n  });\n\n  document.querySelectorAll('.fav-btn-movie').forEach(btn => {\n    const card = btn.closest('.movie-card');\n    const id = card?.dataset.movieId;\n    btn.textContent = favorites.movies[id] ? '★' : '☆';\n    btn.classList.toggle('text-yellow-400', !!favorites.movies[id]);\n  });\n}\n\nupdateButtons();\n\n// Lazy load images with Intersection Observer\nconst scrollContainer = document.querySelector('.overflow-auto');\nconst lazyObserver = new IntersectionObserver((entries) => {\n  entries.forEach(entry => {\n    if (entry.isIntersecting) {\n      const img = entry.target;\n      img.src = img.dataset.src;\n      img.classList.remove('lazy-img');\n      lazyObserver.unobserve(img);\n    }\n  });\n}, { root: scrollContainer, rootMargin: '200px' });\n\ndocument.querySelectorAll('.lazy-img').forEach(img => lazyObserver.observe(img));\n</script>\n{% endblock %}\n"
  },
  {
    "path": "templates/series.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}Series - neTV{% endblock %}\n\n{% block head_extra %}\n{% if loading %}\n<meta http-equiv=\"refresh\" content=\"2\">\n{% endif %}\n{% endblock %}\n\n{% block content %}\n{% if loading %}\n<div class=\"flex flex-col h-full items-center justify-center\">\n  <p class=\"text-xl text-gray-400\">Loading series...</p>\n</div>\n{% else %}\n<div class=\"flex flex-col h-full min-h-0 min-w-0\">\n  <div class=\"flex flex-wrap items-center justify-between gap-2 mb-4\">\n    <h2 class=\"text-xl sm:text-3xl font-bold\">Series</h2>\n    <div class=\"flex gap-2\">\n      <a href=\"/search?series=true\" class=\"px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\">\n        Search\n      </a>\n      {% if current_sort %}\n      <a href=\"/series\" class=\"px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\">\n        Favorites\n      </a>\n      {% else %}\n      <a href=\"/series?sort=rating\" class=\"px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\">\n        Browse All\n      </a>\n      {% endif %}\n    </div>\n  </div>\n\n  {% if current_sort %}\n  <!-- Browse view -->\n  <div class=\"flex-1 min-h-0 overflow-auto scroll-ring-safe\">\n    <div class=\"mb-4 flex gap-2\">\n      <select id=\"category-select\" class=\"bg-gray-700 text-white px-4 py-2 rounded\">\n        <option value=\"\">All Categories</option>\n        {% for cat in categories %}\n        <option value=\"{{ cat.category_id }}\" {% if current_category|string == cat.category_id|string %}selected{% endif %}>\n          {{ cat.category_name }}\n        </option>\n        {% endfor %}\n      </select>\n      <select id=\"sort-select\" class=\"bg-gray-700 text-white px-4 py-2 rounded\">\n        <option value=\"alpha\" {% if current_sort == 'alpha' %}selected{% endif %}>A-Z</option>\n        <option value=\"rating\" {% if current_sort == 'rating' %}selected{% endif %}>Rating</option>\n        <option value=\"newest\" {% if current_sort == 'newest' %}selected{% endif %}>Newest</option>\n      </select>\n    </div>\n    <div class=\"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-2 sm:gap-3 pb-4\">\n      {% for s in series %}\n      <div class=\"series-card relative group\" data-series-id=\"{{ s.series_id }}\" data-series-name=\"{{ s.name }}\" data-series-cover=\"{{ (s.cover or '') | logo_url }}\">\n        <a href=\"/series/{{ s.series_id }}\"\n           class=\"block bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 focusable transition-all\"\n           tabindex=\"0\" data-nav=\"grid\" title=\"{{ s.name }}{% if s.rating %}&#10;★ {{ s.rating }}{% endif %}\">\n          <div class=\"aspect-[2/3] bg-gray-700\">\n            {% if s.cover %}\n            <img src=\"{{ s.cover | logo_url }}\" alt=\"\" class=\"w-full h-full object-cover\" loading=\"lazy\"\n                 onerror=\"this.style.display='none'\">\n            {% endif %}\n          </div>\n          <div class=\"p-2\">\n            <div class=\"text-sm line-clamp-2 leading-tight\">{{ s.name }}</div>\n            {% if s.rating %}\n            <div class=\"text-xs text-yellow-400 mt-1\">★ {{ s.rating }}</div>\n            {% endif %}\n          </div>\n        </a>\n        <button type=\"button\" class=\"fav-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-black/50 text-xl opacity-0 group-hover:opacity-100 transition-opacity z-10\"\n                onclick='toggleFavorite({{ s.series_id | tojson }}, {{ s.name | tojson }}, {{ (s.cover or \"\") | logo_url | tojson }})'>\n          ☆\n        </button>\n      </div>\n      {% endfor %}\n    </div>\n  </div>\n  {% else %}\n  <!-- Favorites view -->\n  <div id=\"favorites-section\" class=\"flex-1 min-h-0 overflow-auto scroll-ring-safe\">\n    <h3 class=\"text-xl font-semibold mb-4\">★ My Favorites</h3>\n    <div id=\"favorites-grid\" class=\"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-2 sm:gap-3 pb-4\">\n    </div>\n    <div id=\"no-favorites\" class=\"hidden text-center text-gray-400 py-12\">\n      <p class=\"text-xl mb-2\">No favorites yet</p>\n      <p>Use Search or Browse All to find series, then click ☆ to add</p>\n    </div>\n  </div>\n  {% endif %}\n</div>\n{% endif %}\n{% endblock %}\n\n{% block scripts %}\n<script>\nwindow.FAVORITES_CONFIG = {\n  type: 'series',\n  favorites: {{ favorites | tojson }},\n  cardClass: 'series-card',\n  tileClass: 'series-tile',\n  detailUrl: '/series/',\n  baseUrl: '/series',\n  orderKey: 'series_order',\n  isBrowseView: {{ (current_sort is not none) | tojson }}\n};\n</script>\n<script src=\"/static/js/favorites-grid.js\"></script>\n{% endblock %}\n"
  },
  {
    "path": "templates/series_detail.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}{{ series.info.name if series.info else 'Series' }} - neTV{% endblock %}\n\n{% block content %}\n<div class=\"flex flex-col md:h-full min-h-0 min-w-0\">\n  {% if series.info %}\n  <div class=\"flex gap-2 sm:gap-6 mb-6\">\n    {% if series.info.cover %}\n    <img id=\"cover\" src=\"{{ series.info.cover | logo_url }}\" alt=\"\" class=\"w-24 sm:w-36 lg:w-48 h-auto max-h-72 object-cover rounded-lg flex-shrink-0\">\n    {% endif %}\n    <div class=\"min-w-0 flex-1\">\n      <div class=\"flex flex-wrap items-center justify-between gap-2 mb-2\">\n        <h2 class=\"text-xl sm:text-3xl font-bold min-w-0\">{{ series.info.name }}</h2>\n        <div class=\"flex gap-2 flex-shrink-0\">\n          <a href=\"?refresh=1\" class=\"px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\" title=\"Check for new episodes\">↻</a>\n          <button id=\"fav-btn\" class=\"px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\">\n            ☆ Add to Favorites\n          </button>\n        </div>\n      </div>\n      <div class=\"flex gap-4 text-gray-400 mb-4\">\n        {% if series.info.year %}<span>{{ series.info.year }}</span>{% endif %}\n        {% if series.info.rating %}<span class=\"text-yellow-400\">★ {{ series.info.rating }}/10</span>{% endif %}\n        {% if series.info.episode_run_time %}<span>{{ series.info.episode_run_time }} min/ep</span>{% endif %}\n      </div>\n\n      {% if series.info.genre %}\n      <div class=\"text-sm text-gray-400 mb-4\">{{ series.info.genre }}</div>\n      {% endif %}\n\n      {% if series.info.plot %}\n      <p class=\"text-gray-300 mb-6 max-w-3xl\">{{ series.info.plot }}</p>\n      {% endif %}\n\n      {% if series.info.director %}\n      <p class=\"text-sm text-gray-400 mb-1\"><span class=\"text-gray-500\">Director:</span> {{ series.info.director }}</p>\n      {% endif %}\n      {% if series.info.cast %}\n      <p class=\"text-sm text-gray-400 mb-4\"><span class=\"text-gray-500\">Cast:</span> {{ series.info.cast }}</p>\n      {% endif %}\n    </div>\n  </div>\n  {% endif %}\n\n  <!-- Episodes by season -->\n  <div class=\"flex-1 min-h-0 md:overflow-auto scroll-ring-safe\">\n  {% if series.episodes %}\n  {% set ns = namespace(first_ep=true) %}\n  {% for season_num, episodes in series.episodes.items() %}\n  <div class=\"mb-6\">\n    <h3 class=\"text-xl font-semibold mb-3\">Season {{ season_num }}</h3>\n    <div class=\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 pr-1\">\n      {% for ep in episodes %}\n      <a href=\"/play/series/{{ ep.id }}?series_id={{ series_id }}&ext={{ ep.container_extension or 'mkv' }}\"\n         class=\"bg-gray-800 rounded-lg p-3 hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 focusable transition-all\"\n         tabindex=\"0\" data-nav=\"grid\" {% if ns.first_ep %}autofocus{% set ns.first_ep = false %}{% endif %}\n         title=\"{{ series.info.name if series.info else '' }} — S{{ '%02d' | format(season_num | int) }}E{{ '%02d' | format(ep.episode_num | int) }} — {{ ep.title }}{% if ep.description %}&#10;{{ ep.description }}{% elif ep.plot %}&#10;{{ ep.plot }}{% endif %}\">\n        <div class=\"font-medium\">E{{ ep.episode_num }}</div>\n        <div class=\"text-sm text-gray-400 line-clamp-1\">{{ ep.title }}</div>\n      </a>\n      {% endfor %}\n    </div>\n  </div>\n  {% endfor %}\n  {% else %}\n  <p class=\"text-gray-400\">No episodes available</p>\n  {% endif %}\n  </div>\n</div>\n{% endblock %}\n\n{% block scripts %}\n<script>\nlet favorites = {{ favorites | tojson }};\nconst SERIES_ID = '{{ series_id }}';\nconst SERIES_NAME = '{{ series.info.name | e if series.info else \"\" }}';\nconst SERIES_COVER = document.getElementById('cover')?.getAttribute('src') ?? \"\";\n\nfunction saveFavorites() {\n  fetch('/api/user-prefs', {\n    method: 'POST',\n    headers: {'Content-Type': 'application/json'},\n    body: JSON.stringify({favorites})\n  });\n}\n\nfunction toggleFavorite() {\n  if (!SERIES_ID) return;\n  if (favorites.series[SERIES_ID]) {\n    delete favorites.series[SERIES_ID];\n  } else {\n    favorites.series[SERIES_ID] = { name: SERIES_NAME, cover: SERIES_COVER };\n  }\n  saveFavorites();\n  updateButton();\n}\n\nfunction updateButton() {\n  const btn = document.getElementById('fav-btn');\n  const isFav = !!favorites.series[SERIES_ID];\n  btn.innerHTML = isFav ? '★ In Favorites' : '☆ Add to Favorites';\n  btn.classList.toggle('bg-yellow-600', isFav);\n  btn.classList.toggle('hover:bg-yellow-700', isFav);\n  btn.classList.toggle('bg-gray-700', !isFav);\n  btn.classList.toggle('hover:bg-gray-600', !isFav);\n}\n\ndocument.getElementById('fav-btn').addEventListener('click', toggleFavorite);\nupdateButton();\n</script>\n{% endblock %}\n"
  },
  {
    "path": "templates/settings.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}Settings - neTV{% endblock %}\n\n{% block head_extra %}\n<style>\n@keyframes spin { to { transform: rotate(360deg); } }\n.refresh-btn.active { background-color: rgb(37, 99, 235); pointer-events: none; }\n.refresh-btn .spinner { display: none; width: 0.75em; height: 0.75em; border: 2px solid white; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 0.25em; }\n.refresh-btn.active .spinner { display: inline-block; }\n</style>\n{% endblock %}\n\n{% block content %}\n<div class=\"max-w-2xl\">\n  <h2 class=\"text-3xl font-bold mb-6\">Settings</h2>\n\n  <h3 class=\"text-lg text-gray-400 mb-3\">Client Settings</h3>\n\n  <!-- Live TV Category Filter -->\n  <div id=\"filters\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Live TV Filter</h3>\n    <p class=\"text-sm text-gray-400 mb-3\">Select and order which channel categories appear in the Live TV guide</p>\n    <div class=\"grid grid-cols-2 gap-2 mb-2\">\n      <div class=\"relative\">\n        <input type=\"text\" id=\"cat-search\" placeholder=\"Filter categories...\"\n               class=\"w-full px-3 py-1 pr-7 bg-gray-700 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500\">\n        <button type=\"button\" id=\"cat-search-clear\" class=\"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white text-sm px-1 hidden\">✕</button>\n      </div>\n      <div class=\"flex gap-2\">\n        <button type=\"button\" id=\"cat-move-all-right\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Block All</button>\n        <button type=\"button\" id=\"cat-move-all-left\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Allow All</button>\n      </div>\n    </div>\n    <div class=\"grid grid-cols-2 gap-2\">\n      <div>\n        <div class=\"text-xs text-gray-400 mb-1\">Available</div>\n        <div id=\"available-cats\" class=\"h-96 overflow-auto space-y-1 bg-gray-700 rounded p-2 border-2 border-dashed border-gray-600\">\n        </div>\n      </div>\n      <div>\n        <div class=\"text-xs text-gray-400 mb-1\">Unavailable</div>\n        <div id=\"unavailable-cats\" class=\"h-96 overflow-auto space-y-1 bg-gray-700 rounded p-2 border-2 border-dashed border-gray-600\">\n          {% for cat in live_categories %}\n          {% set src_name = source_names.get(cat.source_id, '') %}\n          <div class=\"cat-chip px-2 py-1 bg-gray-600 rounded text-xs cursor-move truncate hover:bg-gray-500\"\n               draggable=\"true\" data-id=\"{{ cat.category_id }}\" title=\"{% if src_name %}[{{ src_name }}] {% endif %}{{ cat.category_name }}\">\n            {% if src_name %}<span class=\"text-gray-400\">[{{ src_name }}]</span> {% endif %}{{ cat.category_name }}\n          </div>\n          {% endfor %}\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- Movies Category Filter -->\n  <div id=\"vod-filters\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Movies Filter</h3>\n    <p class=\"text-sm text-gray-400 mb-3\">Select which movie categories appear on the Movies page</p>\n    <div class=\"grid grid-cols-2 gap-2 mb-2\">\n      <div class=\"relative\">\n        <input type=\"text\" id=\"vod-cat-search\" placeholder=\"Filter categories...\"\n               class=\"w-full px-3 py-1 pr-7 bg-gray-700 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500\">\n        <button type=\"button\" id=\"vod-cat-search-clear\" class=\"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white text-sm px-1 hidden\">✕</button>\n      </div>\n      <div class=\"flex gap-2\">\n        <button type=\"button\" id=\"vod-cat-move-all-right\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Block All</button>\n        <button type=\"button\" id=\"vod-cat-move-all-left\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Allow All</button>\n      </div>\n    </div>\n    <div class=\"grid grid-cols-2 gap-2\">\n      <div>\n        <div class=\"text-xs text-gray-400 mb-1\">Available</div>\n        <div id=\"available-vod-cats\" class=\"h-96 overflow-auto space-y-1 bg-gray-700 rounded p-2 border-2 border-dashed border-gray-600\">\n        </div>\n      </div>\n      <div>\n        <div class=\"text-xs text-gray-400 mb-1\">Unavailable</div>\n        <div id=\"unavailable-vod-cats\" class=\"h-96 overflow-auto space-y-1 bg-gray-700 rounded p-2 border-2 border-dashed border-gray-600\">\n          {% for cat in vod_categories %}\n          {% set src_name = source_names.get(cat.source_id, '') %}\n          <div class=\"vod-cat-chip px-2 py-1 bg-gray-600 rounded text-xs cursor-move truncate hover:bg-gray-500\"\n               draggable=\"true\" data-id=\"{{ cat.category_id }}\" title=\"{% if src_name %}[{{ src_name }}] {% endif %}{{ cat.category_name }}\">\n            {% if src_name %}<span class=\"text-gray-400\">[{{ src_name }}]</span> {% endif %}{{ cat.category_name }}\n          </div>\n          {% endfor %}\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- Series Category Filter -->\n  <div id=\"series-filters\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Series Filter</h3>\n    <p class=\"text-sm text-gray-400 mb-3\">Select which series categories appear on the Series page</p>\n    <div class=\"grid grid-cols-2 gap-2 mb-2\">\n      <div class=\"relative\">\n        <input type=\"text\" id=\"series-cat-search\" placeholder=\"Filter categories...\"\n               class=\"w-full px-3 py-1 pr-7 bg-gray-700 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500\">\n        <button type=\"button\" id=\"series-cat-search-clear\" class=\"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white text-sm px-1 hidden\">✕</button>\n      </div>\n      <div class=\"flex gap-2\">\n        <button type=\"button\" id=\"series-cat-move-all-right\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Block All</button>\n        <button type=\"button\" id=\"series-cat-move-all-left\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Allow All</button>\n      </div>\n    </div>\n    <div class=\"grid grid-cols-2 gap-2\">\n      <div>\n        <div class=\"text-xs text-gray-400 mb-1\">Available</div>\n        <div id=\"available-series-cats\" class=\"h-96 overflow-auto space-y-1 bg-gray-700 rounded p-2 border-2 border-dashed border-gray-600\">\n        </div>\n      </div>\n      <div>\n        <div class=\"text-xs text-gray-400 mb-1\">Unavailable</div>\n        <div id=\"unavailable-series-cats\" class=\"h-96 overflow-auto space-y-1 bg-gray-700 rounded p-2 border-2 border-dashed border-gray-600\">\n          {% for cat in series_categories %}\n          {% set src_name = source_names.get(cat.source_id, '') %}\n          <div class=\"series-cat-chip px-2 py-1 bg-gray-600 rounded text-xs cursor-move truncate hover:bg-gray-500\"\n               draggable=\"true\" data-id=\"{{ cat.category_id }}\" title=\"{% if src_name %}[{{ src_name }}] {% endif %}{{ cat.category_name }}\">\n            {% if src_name %}<span class=\"text-gray-400\">[{{ src_name }}]</span> {% endif %}{{ cat.category_name }}\n          </div>\n          {% endfor %}\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- Guide settings -->\n  <div id=\"guide-settings\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Guide</h3>\n    <div class=\"mb-4\">\n      <label class=\"flex items-center gap-3 cursor-pointer\">\n        <input type=\"checkbox\" name=\"virtual_scroll\" id=\"virtual-scroll\" {% if virtual_scroll %}checked{% endif %}\n               class=\"w-5 h-5 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n        <span>Enable virtual scrolling for large channel lists</span>\n      </label>\n      <p class=\"text-xs text-gray-400 mt-1 ml-8\">When enabled, only visible rows are loaded as you scroll. Disable for smoother scrolling with fewer channels.</p>\n    </div>\n  </div>\n\n  <!-- Closed Captions settings -->\n  <div id=\"captions\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Closed Captions</h3>\n    <div class=\"mb-4\">\n      <label class=\"flex items-center gap-3 cursor-pointer\">\n        <input type=\"checkbox\" name=\"captions_enabled\" id=\"captions-enabled\" {% if captions_enabled %}checked{% endif %}\n               class=\"w-5 h-5 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n        <span>Enable closed captions by default</span>\n      </label>\n    </div>\n\n    <div class=\"mb-4\">\n      <label class=\"block text-sm mb-2\">Preferred Caption Language</label>\n      <select id=\"cc-lang-pref\" class=\"px-3 py-1.5 bg-gray-700 rounded text-sm\">\n        <option value=\"\">Any (first available)</option>\n        <option value=\"en\">English</option>\n        <option value=\"es\">Spanish</option>\n        <option value=\"fr\">French</option>\n        <option value=\"de\">German</option>\n        <option value=\"it\">Italian</option>\n        <option value=\"pt\">Portuguese</option>\n        <option value=\"ja\">Japanese</option>\n        <option value=\"ko\">Korean</option>\n        <option value=\"zh\">Chinese</option>\n      </select>\n    </div>\n\n    <div class=\"grid grid-cols-2 gap-3 mb-4\">\n      <div>\n        <label class=\"block text-sm mb-1\">Font Size</label>\n        <select class=\"cc-setting w-full px-2 py-1 bg-gray-700 rounded text-sm\" data-setting=\"cc_size\">\n          <option value=\"0.7em\">Very Small</option>\n          <option value=\"0.85em\">Small</option>\n          <option value=\"1em\">Medium</option>\n          <option value=\"1.25em\">Large</option>\n          <option value=\"1.5em\">Very Large</option>\n        </select>\n      </div>\n      <div>\n        <label class=\"block text-sm mb-1\">Font</label>\n        <select class=\"cc-setting w-full px-2 py-1 bg-gray-700 rounded text-sm\" data-setting=\"cc_font\">\n          <option value=\"inherit\">Default</option>\n          <option value=\"monospace\">Monospace</option>\n          <option value=\"serif\">Serif</option>\n          <option value=\"sans-serif\">Sans-Serif</option>\n        </select>\n      </div>\n      <div>\n        <label class=\"block text-sm mb-1\">Text Color</label>\n        <select class=\"cc-setting w-full px-2 py-1 bg-gray-700 rounded text-sm\" data-setting=\"cc_color\">\n          <option value=\"#ffffff\">White</option>\n          <option value=\"#ffff00\">Yellow</option>\n          <option value=\"#00ff00\">Green</option>\n          <option value=\"#00ffff\">Cyan</option>\n          <option value=\"#ff00ff\">Magenta</option>\n          <option value=\"#ff0000\">Red</option>\n        </select>\n      </div>\n      <div>\n        <label class=\"block text-sm mb-1\">Text Shadow</label>\n        <select class=\"cc-setting w-full px-2 py-1 bg-gray-700 rounded text-sm\" data-setting=\"cc_shadow\">\n          <option value=\"0 0 4px black, 0 0 4px black\">Drop Shadow</option>\n          <option value=\"2px 2px 0 black\">Raised</option>\n          <option value=\"-1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black\">Outline</option>\n          <option value=\"none\">None</option>\n        </select>\n      </div>\n      <div>\n        <label class=\"block text-sm mb-1\">Background Color</label>\n        <select class=\"cc-setting w-full px-2 py-1 bg-gray-700 rounded text-sm\" data-setting=\"cc_bg\">\n          <option value=\"#000000\">Black</option>\n          <option value=\"#333333\">Dark Gray</option>\n          <option value=\"transparent\">Transparent</option>\n        </select>\n      </div>\n      <div>\n        <label class=\"block text-sm mb-1\">Background Opacity</label>\n        <select class=\"cc-setting w-full px-2 py-1 bg-gray-700 rounded text-sm\" data-setting=\"cc_bg_opacity\">\n          <option value=\"1\">100%</option>\n          <option value=\"0.75\">75%</option>\n          <option value=\"0.5\">50%</option>\n          <option value=\"0.25\">25%</option>\n          <option value=\"0\">0%</option>\n        </select>\n      </div>\n    </div>\n\n    <div class=\"p-4 rounded text-center\" style=\"background-image: linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%); background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0;\">\n      <span class=\"py-0.5 px-1 inline-block\" id=\"cc-preview\">Sample Caption Text</span>\n    </div>\n    <p class=\"text-xs text-gray-500 mt-2\">\n      Also: <code id=\"chrome-cc-link\" class=\"bg-gray-700 px-1 rounded cursor-pointer hover:bg-gray-600\" title=\"Click to copy\">chrome://settings/captions</code>\n    </p>\n  </div>\n\n  {% if is_admin %}\n  <h3 class=\"text-lg text-gray-400 mb-3 mt-8\">Server Settings</h3>\n  {% endif %}\n\n  <!-- Users management -->\n  <div id=\"users\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">{% if is_admin %}Users{% else %}Account{% endif %}</h3>\n    <div class=\"space-y-2\">\n      {% for u in all_users %}\n      {% if is_admin or u.username == current_user %}\n      <details class=\"bg-gray-700 rounded group\" data-username=\"{{ u.username }}\">\n        <summary class=\"flex items-center justify-between p-2 cursor-pointer hover:bg-gray-600 rounded\">\n          <div class=\"flex items-center gap-2\">\n            <span class=\"font-medium\">{{ u.username }}</span>\n            {% if u.username == current_user %}<span class=\"text-xs text-gray-400\">(you)</span>{% endif %}\n            {% if u.admin %}<span class=\"text-xs px-1.5 py-0.5 bg-gray-600 rounded\">admin</span>{% endif %}\n          </div>\n          <span class=\"text-gray-400 text-xs group-open:hidden\">Expand</span>\n        </summary>\n        <div class=\"p-3 pt-1 border-t border-gray-600 space-y-3\">\n          {% if is_admin and all_users|length > 1 %}\n          <label class=\"flex items-center gap-2 cursor-pointer\">\n            <input type=\"checkbox\" class=\"admin-toggle w-4 h-4 rounded bg-gray-600 border-gray-500 text-blue-500\" {% if u.admin %}checked{% endif %}>\n            <span class=\"text-sm\">Admin privileges</span>\n          </label>\n          {% endif %}\n          <div>\n            <label class=\"block text-xs text-gray-400 mb-1\">New Password</label>\n            <div class=\"relative inline-block\">\n              <input type=\"password\" class=\"password-input w-36 px-2 py-1 pr-8 text-sm bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n              <button type=\"button\" onclick=\"togglePwdVis(this)\" class=\"absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white\">\n                <svg class=\"w-4 h-4 eye-off\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\"/></svg>\n                <svg class=\"w-4 h-4 eye-on hidden\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"/><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"/></svg>\n              </button>\n            </div>\n            <span class=\"text-xs text-gray-400 ml-2\">Min 8 characters</span>\n          </div>\n          {% if is_admin %}\n          <!-- Stream Limits (admin only) -->\n          <div class=\"pt-2 border-t border-gray-600\">\n            <label class=\"block text-sm mb-2\">Max Streams per Source: <span class=\"text-xs text-gray-400\">(0 = unlimited, requires Always Transcode)</span></label>\n            <div class=\"user-max-streams-container space-y-1 mb-3\" data-username=\"{{ u.username }}\">\n              {% for src in sources %}\n              {% if src.type != 'epg' %}\n              <div class=\"flex items-center gap-2\">\n                <input type=\"number\" class=\"user-source-max-streams w-16 px-2 py-0.5 text-xs bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\"\n                       data-source-id=\"{{ src.id }}\" value=\"{{ (u.max_streams_per_source or {}).get(src.id, 0) }}\" min=\"0\">\n                <span class=\"text-xs text-gray-300\">{{ src.name }}</span>\n              </div>\n              {% endif %}\n              {% endfor %}\n            </div>\n            <!-- Group Restrictions -->\n            <div class=\"mt-3\">\n              <label class=\"block text-sm mb-2\">Group Access:</label>\n              <div class=\"grid grid-cols-2 gap-2 mb-2\">\n                <div class=\"relative\">\n                  <input type=\"text\" class=\"user-group-search w-full px-3 py-1 pr-7 bg-gray-600 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500\"\n                         placeholder=\"Filter groups...\" data-username=\"{{ u.username }}\">\n                  <button type=\"button\" class=\"user-group-search-clear absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white text-sm px-1 hidden\" data-username=\"{{ u.username }}\">✕</button>\n                </div>\n                <div class=\"flex gap-2\">\n                  <button type=\"button\" class=\"group-move-all-unavailable px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-username=\"{{ u.username }}\">Block All</button>\n                  <button type=\"button\" class=\"group-move-all-available px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-username=\"{{ u.username }}\">Allow All</button>\n                </div>\n              </div>\n              <div class=\"grid grid-cols-2 gap-2\">\n                <div>\n                  <div class=\"text-xs text-gray-400 mb-1\">Available</div>\n                  <div class=\"user-available-groups h-96 overflow-auto p-2 bg-gray-700 rounded border-2 border-dashed border-gray-600 space-y-1\"\n                       data-username=\"{{ u.username }}\">\n                    {% for grp in all_groups %}\n                    {% if grp.id not in (u.unavailable_groups or []) %}\n                    <div class=\"group-chip px-2 py-1 bg-gray-600 rounded text-xs cursor-move truncate hover:bg-gray-500\" draggable=\"true\" data-group-id=\"{{ grp.id }}\" title=\"{{ grp.name }}\">{{ grp.name }}</div>\n                    {% endif %}\n                    {% endfor %}\n                  </div>\n                </div>\n                <div>\n                  <div class=\"text-xs text-gray-400 mb-1\">Unavailable</div>\n                  <div class=\"user-unavailable-groups h-96 overflow-auto p-2 bg-gray-700 rounded border-2 border-dashed border-gray-600 space-y-1\"\n                       data-username=\"{{ u.username }}\">\n                    {% for grp in all_groups %}\n                    {% if grp.id in (u.unavailable_groups or []) %}\n                    <div class=\"group-chip px-2 py-1 bg-gray-600 rounded text-xs cursor-move truncate hover:bg-gray-500\" draggable=\"true\" data-group-id=\"{{ grp.id }}\" title=\"{{ grp.name }}\">{{ grp.name }}</div>\n                    {% endif %}\n                    {% endfor %}\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n          {% endif %}\n          <div class=\"flex justify-end\">\n            {% if u.username == current_user %}\n            <button type=\"button\" class=\"px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 rounded\" onclick=\"showDeleteSelfModal()\">Delete Account</button>\n            {% elif is_admin %}\n            <form action=\"/settings/users/delete/{{ u.username }}\" method=\"POST\">\n              <button type=\"submit\" class=\"px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 rounded\" onclick=\"return confirm('Delete user {{ u.username }}?')\">Delete</button>\n            </form>\n            {% endif %}\n          </div>\n        </div>\n      </details>\n      {% endif %}\n      {% endfor %}\n      {% if is_admin %}\n      <details class=\"bg-gray-700 rounded group\">\n        <summary class=\"flex items-center justify-between p-2 cursor-pointer hover:bg-gray-600 rounded\">\n          <div class=\"flex items-center gap-2\">\n            <span class=\"font-medium\">Add User</span>\n          </div>\n          <span class=\"text-gray-400 text-xs group-open:hidden\">Expand</span>\n        </summary>\n      <div class=\"p-3 pt-1 border-t border-gray-600 space-y-3\">\n        <form id=\"add-user-form\" class=\"space-y-3\">\n          <div class=\"flex gap-4 items-end flex-wrap\">\n            <div>\n              <label class=\"block text-xs text-gray-400 mb-1\">Username <span class=\"text-red-400\">*</span></label>\n              <input type=\"text\" name=\"username\" required class=\"w-36 px-2 py-1 text-sm bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            </div>\n            <div>\n              <label class=\"block text-xs text-gray-400 mb-1\">Password <span class=\"text-red-400\">*</span></label>\n              <div class=\"relative\">\n                <input type=\"password\" name=\"password\" required class=\"w-36 px-2 py-1 pr-8 text-sm bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n                <button type=\"button\" onclick=\"togglePwdVis(this)\" class=\"absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white\">\n                  <svg class=\"w-4 h-4 eye-off\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\"/></svg>\n                  <svg class=\"w-4 h-4 eye-on hidden\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"/><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"/></svg>\n                </button>\n              </div>\n            </div>\n            <label class=\"flex items-center gap-2 cursor-pointer\">\n              <input type=\"checkbox\" name=\"admin\" class=\"w-4 h-4 rounded bg-gray-600 border-gray-500 text-blue-500\">\n              <span class=\"text-sm\">Admin privileges</span>\n            </label>\n          </div>\n          <!-- Max Streams per Source -->\n          <div class=\"pt-2 border-t border-gray-600\">\n            <label class=\"block text-sm mb-2\">Max Streams per Source: <span class=\"text-xs text-gray-400\">(0 = unlimited)</span></label>\n            <div id=\"add-user-max-streams\" class=\"space-y-1 mb-3\">\n              {% for src in sources %}\n              {% if src.type != 'epg' %}\n              <div class=\"flex items-center gap-2\">\n                <input type=\"number\" class=\"add-user-source-max-streams w-16 px-2 py-0.5 text-xs bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\"\n                       data-source-id=\"{{ src.id }}\" value=\"0\" min=\"0\">\n                <span class=\"text-xs text-gray-300\">{{ src.name }}</span>\n              </div>\n              {% endif %}\n              {% endfor %}\n            </div>\n            <!-- Group Access -->\n            <div class=\"mt-3\">\n              <label class=\"block text-sm mb-2\">Group Access:</label>\n              <div class=\"grid grid-cols-2 gap-2 mb-2\">\n                <div class=\"relative\">\n                  <input type=\"text\" id=\"add-user-group-search\" class=\"w-full px-3 py-1 pr-7 bg-gray-600 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500\"\n                         placeholder=\"Filter groups...\">\n                  <button type=\"button\" id=\"add-user-group-search-clear\" class=\"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white text-sm px-1 hidden\">✕</button>\n                </div>\n                <div class=\"flex gap-2\">\n                  <button type=\"button\" id=\"add-user-block-all\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Block All</button>\n                  <button type=\"button\" id=\"add-user-allow-all\" class=\"px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded\">Allow All</button>\n                </div>\n              </div>\n              <div class=\"grid grid-cols-2 gap-2\">\n                <div>\n                  <div class=\"text-xs text-gray-400 mb-1\">Available</div>\n                  <div id=\"add-user-available-groups\" class=\"h-96 overflow-auto p-2 bg-gray-700 rounded border-2 border-dashed border-gray-600 space-y-1\">\n                    {% for grp in all_groups %}\n                    <div class=\"add-user-group-chip px-2 py-1 bg-gray-600 rounded text-xs cursor-move truncate hover:bg-gray-500\" draggable=\"true\" data-group-id=\"{{ grp.id }}\" title=\"{{ grp.name }}\">{{ grp.name }}</div>\n                    {% endfor %}\n                  </div>\n                </div>\n                <div>\n                  <div class=\"text-xs text-gray-400 mb-1\">Unavailable</div>\n                  <div id=\"add-user-unavailable-groups\" class=\"h-96 overflow-auto p-2 bg-gray-700 rounded border-2 border-dashed border-gray-600 space-y-1\">\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n          <div class=\"flex justify-end items-center gap-3\">\n            <span id=\"add-user-msg\" class=\"text-sm hidden\"></span>\n            <button type=\"submit\" class=\"px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 rounded\">Add User</button>\n          </div>\n        </form>\n      </div>\n      </details>\n      {% endif %}\n    </div>\n  </div>\n\n  {% if is_admin %}\n  <!-- Transcoding settings -->\n  <div id=\"transcoding\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Transcoding</h3>\n    <div class=\"space-y-4\" id=\"transcode-settings\">\n      <div>\n        <label class=\"block text-sm font-medium mb-2\">Mode</label>\n        <div class=\"flex gap-4\">\n          <label class=\"flex items-center gap-2 cursor-pointer text-sm\">\n            <input type=\"radio\" name=\"transcode_mode\" value=\"auto\" {% if transcode_mode == 'auto' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>Auto</span>\n          </label>\n          <label class=\"flex items-center gap-2 cursor-pointer text-sm\">\n            <input type=\"radio\" name=\"transcode_mode\" value=\"always\" {% if transcode_mode == 'always' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>Always</span>\n          </label>\n          <label class=\"flex items-center gap-2 cursor-pointer text-sm\">\n            <input type=\"radio\" name=\"transcode_mode\" value=\"never\" {% if transcode_mode == 'never' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>Never</span>\n          </label>\n        </div>\n        <p class=\"text-xs text-gray-400 mt-1\">Auto transcodes when browser can't play the format natively</p>\n      </div>\n      <div>\n        <label class=\"block text-sm font-medium mb-2\">Hardware Acceleration</label>\n        <div class=\"flex flex-col gap-2\">\n          <!-- NVENC options -->\n          <div class=\"flex gap-4\">\n            <label class=\"flex items-center gap-2 text-sm {% if not available_encoders.nvenc or not available_encoders.vaapi %}opacity-40{% endif %}\">\n              <input type=\"radio\" name=\"transcode_hw\" value=\"nvenc+vaapi\" {% if transcode_hw == 'nvenc+vaapi' %}checked{% endif %}\n                     {% if not available_encoders.nvenc or not available_encoders.vaapi %}disabled{% endif %}\n                     class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n              <span>NVENC + VAAPI</span>\n            </label>\n            <label class=\"flex items-center gap-2 text-sm {% if not available_encoders.nvenc %}opacity-40{% endif %}\">\n              <input type=\"radio\" name=\"transcode_hw\" value=\"nvenc+software\" {% if transcode_hw == 'nvenc+software' %}checked{% endif %}\n                     {% if not available_encoders.nvenc %}disabled{% endif %}\n                     class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n              <span>NVENC + Software</span>\n            </label>\n          </div>\n          <!-- AMF options -->\n          <div class=\"flex gap-4\">\n            <label class=\"flex items-center gap-2 text-sm {% if not available_encoders.amf or not available_encoders.vaapi %}opacity-40{% endif %}\">\n              <input type=\"radio\" name=\"transcode_hw\" value=\"amf+vaapi\" {% if transcode_hw == 'amf+vaapi' %}checked{% endif %}\n                     {% if not available_encoders.amf or not available_encoders.vaapi %}disabled{% endif %}\n                     class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n              <span>AMF + VAAPI</span>\n            </label>\n            <label class=\"flex items-center gap-2 text-sm {% if not available_encoders.amf %}opacity-40{% endif %}\">\n              <input type=\"radio\" name=\"transcode_hw\" value=\"amf+software\" {% if transcode_hw == 'amf+software' %}checked{% endif %}\n                     {% if not available_encoders.amf %}disabled{% endif %}\n                     class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n              <span>AMF + Software</span>\n            </label>\n          </div>\n          <!-- Standalone options -->\n          <div class=\"flex gap-4\">\n            <label class=\"flex items-center gap-2 text-sm {% if not available_encoders.qsv %}opacity-40{% endif %}\">\n              <input type=\"radio\" name=\"transcode_hw\" value=\"qsv\" {% if transcode_hw == 'qsv' %}checked{% endif %}\n                     {% if not available_encoders.qsv %}disabled{% endif %}\n                     class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n              <span>QSV (Intel)</span>\n            </label>\n            <label class=\"flex items-center gap-2 text-sm {% if not available_encoders.vaapi %}opacity-40{% endif %}\">\n              <input type=\"radio\" name=\"transcode_hw\" value=\"vaapi\" {% if transcode_hw == 'vaapi' %}checked{% endif %}\n                     {% if not available_encoders.vaapi %}disabled{% endif %}\n                     class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n              <span>VAAPI</span>\n            </label>\n            <label class=\"flex items-center gap-2 text-sm\">\n              <input type=\"radio\" name=\"transcode_hw\" value=\"software\" {% if transcode_hw == 'software' %}checked{% endif %}\n                     class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n              <span>Software</span>\n            </label>\n          </div>\n        </div>\n        <p class=\"text-xs text-gray-400 mt-1\">\n        NVENC: discrete Nvidia GPU encoder;\n        AMF: discrete AMD GPU encoder;\n        VAAPI: CPU-integrated-GPU decoder (and/or encoder);\n        Software: CPU decoder (and/or encoder)\n        </p>\n        <button type=\"button\" id=\"refresh-encoders-btn\"\n                class=\"mt-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 rounded\">\n          Re-detect Hardware\n        </button>\n      </div>\n      <div>\n        <label class=\"block text-sm font-medium mb-2\">Max Transcode Resolution</label>\n        <div class=\"flex gap-4 flex-wrap\">\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"max_resolution\" value=\"4k\" {% if max_resolution == '4k' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>4K</span>\n          </label>\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"max_resolution\" value=\"1080p\" {% if max_resolution in ('1080p', 'source') or not max_resolution %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>1080p</span>\n          </label>\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"max_resolution\" value=\"720p\" {% if max_resolution == '720p' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>720p</span>\n          </label>\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"max_resolution\" value=\"480p\" {% if max_resolution == '480p' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>480p</span>\n          </label>\n        </div>\n        <p class=\"text-xs text-gray-400 mt-1\">Limit output resolution (lower = faster, more compatible)</p>\n      </div>\n      <div>\n        <label class=\"block text-sm font-medium mb-2\">Max Quality</label>\n        <div class=\"flex gap-4 flex-wrap\">\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"quality\" value=\"high\" {% if quality == 'high' or not quality %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>High</span>\n          </label>\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"quality\" value=\"medium\" {% if quality == 'medium' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>Medium</span>\n          </label>\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"quality\" value=\"low\" {% if quality == 'low' %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>Low</span>\n          </label>\n        </div>\n        <p class=\"text-xs text-gray-400 mt-1\">Maximum quality ceiling (won't use more bits than source provides)</p>\n      </div>\n      <div id=\"sr-settings\">\n        <label class=\"block text-sm font-medium mb-2\">AI Upscale</label>\n        <div class=\"flex flex-wrap gap-4\">\n          <label class=\"flex items-center gap-2 text-sm\">\n            <input type=\"radio\" name=\"sr_model\" value=\"\" {% if not sr_model %}checked{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>None</span>\n          </label>\n          <label class=\"flex items-center gap-2 text-sm {% if '4x-compact' not in sr_models %}opacity-40{% endif %}\">\n            <input type=\"radio\" name=\"sr_model\" value=\"4x-compact\" {% if sr_model == '4x-compact' %}checked{% endif %}\n                   {% if '4x-compact' not in sr_models %}disabled{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>4x-compact (quality)</span>\n          </label>\n          <label class=\"flex items-center gap-2 text-sm {% if '2x-liveaction-span' not in sr_models %}opacity-40{% endif %}\">\n            <input type=\"radio\" name=\"sr_model\" value=\"2x-liveaction-span\" {% if sr_model == '2x-liveaction-span' %}checked{% endif %}\n                   {% if '2x-liveaction-span' not in sr_models %}disabled{% endif %}\n                   class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n            <span>2x-liveaction-span (fast)</span>\n          </label>\n        </div>\n        <p class=\"text-xs text-gray-400 mt-1\">{% if sr_available %}AI upscaling via TensorRT (requires NVIDIA GPU). Upscales to max resolution then scales down.{% else %}Not available - run <code class=\"bg-gray-700 px-1 rounded\">tools/install-ai_upscale.sh</code> to install{% endif %}</p>\n      </div>\n      <div>\n        <label class=\"block text-sm font-medium mb-2\">VOD Transcode Cache (minutes)</label>\n        <input type=\"number\" name=\"vod_transcode_cache_mins\" value=\"{{ vod_transcode_cache_mins }}\"\n               min=\"0\" max=\"1440\" step=\"5\"\n               class=\"setting-input w-32 px-3 py-2 bg-gray-700 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm\">\n        <p class=\"text-xs text-gray-400 mt-1\">Keep transcoded VOD for reuse (0 = disabled)</p>\n      </div>\n      <div>\n        <label class=\"block text-sm font-medium mb-2\">Live Session Timeout (seconds)</label>\n        <input type=\"number\" name=\"live_transcode_cache_secs\" value=\"{{ live_transcode_cache_secs }}\"\n               min=\"0\" max=\"300\" step=\"5\"\n               class=\"setting-input w-32 px-3 py-2 bg-gray-700 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm\">\n        <p class=\"text-xs text-gray-400 mt-1\">Keep dead session for reconnect (0 = immediate cleanup)</p>\n      </div>\n      <div>\n        <label class=\"block text-sm font-medium mb-2\">Live DVR Buffer (minutes)</label>\n        <input type=\"number\" name=\"live_dvr_mins\" value=\"{{ live_dvr_mins }}\"\n               min=\"0\" max=\"120\" step=\"5\"\n               class=\"setting-input w-32 px-3 py-2 bg-gray-700 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm\">\n        <p class=\"text-xs text-gray-400 mt-1\">Allow seeking back in live streams (0 = disabled, ~30s buffer)</p>\n      </div>\n    </div>\n  </div>\n\n  <!-- User-Agent -->\n  <div id=\"user-agent\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">User-Agent</h3>\n    <p class=\"text-sm text-gray-400 mb-4\">HTTP User-Agent header sent when fetching streams. Only affects transcoding; passthrough uses the client's native user-agent.</p>\n    <div class=\"space-y-4\" id=\"user-agent-settings\">\n      <div class=\"space-y-2\">\n        <label class=\"flex items-center gap-3 cursor-pointer text-sm\">\n          <input type=\"radio\" name=\"user_agent_preset\" value=\"tivimate\" {% if user_agent_preset == 'tivimate' %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n          <span>TiviMate</span>\n          <span class=\"text-xs text-gray-500 font-mono\">TiviMate/4.7.0</span>\n        </label>\n        <label class=\"flex items-center gap-3 cursor-pointer text-sm\">\n          <input type=\"radio\" name=\"user_agent_preset\" value=\"chrome\" {% if user_agent_preset == 'chrome' %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n          <span>Chrome</span>\n          <span class=\"text-xs text-gray-500 font-mono truncate max-w-xs\">Mozilla/5.0 ... Chrome/...</span>\n        </label>\n        <label class=\"flex items-center gap-3 cursor-pointer text-sm\">\n          <input type=\"radio\" name=\"user_agent_preset\" value=\"default\" {% if user_agent_preset == 'default' %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n          <span>FFmpeg</span>\n          <span class=\"text-xs text-gray-500 font-mono\">Lavf/...</span>\n        </label>\n        <label class=\"flex items-center gap-3 cursor-pointer text-sm\">\n          <input type=\"radio\" name=\"user_agent_preset\" value=\"vlc\" {% if user_agent_preset == 'vlc' %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n          <span>VLC</span>\n          <span class=\"text-xs text-gray-500 font-mono\">VLC/3.0.20 LibVLC/3.0.20</span>\n        </label>\n        <label class=\"flex items-center gap-3 cursor-pointer text-sm\">\n          <input type=\"radio\" name=\"user_agent_preset\" value=\"custom\" {% if user_agent_preset == 'custom' %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500\">\n          <span>Custom</span>\n        </label>\n      </div>\n      <div id=\"custom-user-agent-container\" class=\"{% if user_agent_preset != 'custom' %}hidden{% endif %}\">\n        <input type=\"text\" name=\"user_agent_custom\" value=\"{{ user_agent_custom }}\"\n               placeholder=\"Enter custom user-agent string\"\n               class=\"setting-input w-full p-2 bg-gray-700 border border-gray-600 rounded focus:border-blue-500 focus:outline-none font-mono text-sm\">\n      </div>\n    </div>\n  </div>\n\n  <!-- Data Cache -->\n  <div id=\"data-cache\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Data Cache</h3>\n    <div class=\"mb-4\">\n      <label class=\"block text-sm font-medium mb-2\">Transcode Directory</label>\n      <input type=\"text\" name=\"transcode_dir\" value=\"{{ transcode_dir }}\"\n             placeholder=\"(system temp directory)\"\n             class=\"setting-input w-full p-2 bg-gray-700 border border-gray-600 rounded focus:border-blue-500 focus:outline-none font-mono text-sm\">\n      <p class=\"text-xs text-gray-400 mt-1\">Directory for HLS segments. Empty = system temp. Use a fast SSD for best performance.</p>\n    </div>\n    <p class=\"text-sm text-gray-400 mb-4\">Clear cached channel lists, movies, and series data. Use this after changing source settings or if content access isn't working correctly.</p>\n    <div class=\"flex justify-end\">\n      <button id=\"clear-data-cache\" class=\"px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 rounded\">Delete</button>\n    </div>\n  </div>\n\n  <!-- Probe Cache -->\n  <div id=\"probe-cache\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Probe Cache</h3>\n    <div class=\"mb-4\">\n      <label class=\"block text-sm font-medium mb-2\">Probe Media (detect codecs/subs)</label>\n      <div class=\"flex gap-4\">\n        <label class=\"flex items-center gap-2\">\n          <input type=\"checkbox\" name=\"probe_live\" {% if probe_live %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500 rounded\">\n          <span>Live TV</span>\n        </label>\n        <label class=\"flex items-center gap-2\">\n          <input type=\"checkbox\" name=\"probe_movies\" {% if probe_movies %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500 rounded\">\n          <span>Movies</span>\n        </label>\n        <label class=\"flex items-center gap-2\">\n          <input type=\"checkbox\" name=\"probe_series\" {% if probe_series %}checked{% endif %}\n                 class=\"setting-input w-4 h-4 bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500 rounded\">\n          <span>Series</span>\n        </label>\n      </div>\n      <p class=\"text-xs text-gray-400 mt-1\">Probing adds a few seconds delay on first play but enables hardware decode pipeline. Results are cached.</p>\n    </div>\n    <p class=\"text-sm text-gray-400 mb-4\">Cached probe results avoid re-probing episodes in the same series.</p>\n    <div id=\"probe-cache-list\" class=\"space-y-2 max-h-96 overflow-y-auto\">\n      <div class=\"text-gray-500 text-sm\">Loading...</div>\n    </div>\n    <div class=\"flex justify-end mt-4\">\n      <button id=\"clear-all-probe-cache\" class=\"px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 rounded\">Delete</button>\n    </div>\n  </div>\n\n  <!-- Sources -->\n  <div id=\"sources\" class=\"mb-8 p-4 bg-gray-800 rounded\">\n    <h3 class=\"text-xl font-semibold mb-4\">Sources</h3>\n    <div class=\"space-y-2\">\n      {% for source in sources %}\n      <details class=\"bg-gray-700 rounded group\">\n        <summary class=\"flex items-center justify-between p-2 cursor-pointer hover:bg-gray-600 rounded\">\n          <div>\n            <span class=\"font-medium\">{{ source.name }}</span>\n            <span class=\"ml-2 px-2 py-0.5 text-xs bg-gray-600 rounded\">{{ source.type }}</span>\n            <div class=\"text-sm text-gray-400 truncate max-w-md\">{{ source.url }}</div>\n          </div>\n          <span class=\"text-gray-400 text-xs group-open:hidden\">Expand</span>\n        </summary>\n        <form class=\"source-edit-form p-3 pt-0 space-y-3 border-t border-gray-600\" data-source-id=\"{{ source.id }}\">\n          <div class=\"grid grid-cols-2 gap-3\">\n            <div>\n              <label class=\"block text-sm font-medium mb-1\">Name</label>\n              <input type=\"text\" name=\"name\" value=\"{{ source.name }}\" required\n                     class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            </div>\n            <div>\n              <label class=\"block text-sm font-medium mb-1\">Type</label>\n              <select name=\"source_type\" onchange=\"toggleSourceFields(this)\"\n                      class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n                <option value=\"xtream\" {% if source.type == 'xtream' %}selected{% endif %}>Xtream API</option>\n                <option value=\"m3u\" {% if source.type == 'm3u' %}selected{% endif %}>M3U Playlist</option>\n                <option value=\"epg\" {% if source.type == 'epg' %}selected{% endif %}>EPG Only</option>\n              </select>\n            </div>\n          </div>\n          <div>\n            <label class=\"block text-sm font-medium mb-1\">URL</label>\n            <input type=\"url\" name=\"url\" value=\"{{ source.url }}\" required\n                   class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n          </div>\n          <div class=\"non-epg-only\" style=\"{% if source.type == 'epg' %}display:none{% endif %}\">\n            <label class=\"flex items-center gap-2 cursor-pointer\">\n              <input type=\"checkbox\" name=\"epg_enabled\" {% if source.epg_enabled is not defined or source.epg_enabled %}checked{% endif %}\n                     class=\"w-4 h-4 rounded bg-gray-600 border-gray-500 text-blue-500 focus:ring-blue-500\">\n              <span class=\"text-sm\">Fetch EPG from this source</span>\n            </label>\n          </div>\n          <div class=\"grid grid-cols-2 gap-3 xtream-fields\" style=\"{% if source.type != 'xtream' %}display:none{% endif %}\">\n            <div>\n              <label class=\"block text-sm font-medium mb-1\">Username</label>\n              <input type=\"text\" name=\"username\" value=\"{{ source.username }}\"\n                     class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            </div>\n            <div>\n              <label class=\"block text-sm font-medium mb-1\">Password</label>\n              <div class=\"relative\">\n                <input type=\"password\" name=\"password\" value=\"{{ source.password }}\"\n                       class=\"w-full px-3 py-1.5 pr-9 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n                <button type=\"button\" onclick=\"togglePwdVis(this)\" class=\"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white\">\n                  <svg class=\"w-5 h-5 eye-off\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\"/></svg>\n                  <svg class=\"w-5 h-5 eye-on hidden\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"/><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"/></svg>\n                </button>\n              </div>\n            </div>\n          </div>\n          <div>\n            <label class=\"block text-sm font-medium mb-1\">EPG Timeout (seconds)</label>\n            <input type=\"number\" name=\"epg_timeout\" value=\"{{ source.epg_timeout|default(120) }}\" min=\"1\" max=\"3600\"\n                   class=\"w-32 px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            <span class=\"text-xs text-gray-400 ml-2\">1-3600s (default: 120)</span>\n          </div>\n          <div>\n            <label class=\"block text-sm font-medium mb-1\">EPG Schedule</label>\n            <input type=\"text\" name=\"epg_schedule\" value=\"{{ source.epg_schedule|default([])|join(', ') }}\"\n                   placeholder=\"03:00, 15:00\"\n                   class=\"w-48 px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            <span class=\"text-xs text-gray-400 ml-2\">Times to auto-refresh (comma-separated HH:MM)</span>\n          </div>\n          <div class=\"epg-url-field\" style=\"{% if source.type == 'epg' %}display:none{% endif %}\">\n            <label class=\"block text-sm font-medium mb-1\">EPG URL</label>\n            <input type=\"text\" name=\"epg_url\" value=\"{{ source.epg_url|default('') }}\"\n                   placeholder=\"http://example.com/epg.xml\"\n                   class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm\">\n            <span class=\"text-xs text-gray-400\">Auto-detected on first refresh, or set manually</span>\n          </div>\n          <div class=\"non-epg-only\" style=\"{% if source.type == 'epg' %}display:none{% endif %}\">\n            <label class=\"flex items-center gap-2 cursor-pointer\">\n              <input type=\"checkbox\" name=\"deinterlace_fallback\" {% if source.deinterlace_fallback is not defined or source.deinterlace_fallback %}checked{% endif %}\n                     class=\"w-4 h-4 rounded bg-gray-600 border-gray-500 text-blue-500 focus:ring-blue-500\">\n              <span class=\"text-sm\">Deinterlace when probe skipped</span>\n            </label>\n            <span class=\"text-xs text-gray-400 ml-6\">Enable for OTA/HDHomeRun, disable for IPTV</span>\n          </div>\n          <div class=\"non-epg-only\" style=\"{% if source.type == 'epg' %}display:none{% endif %}\">\n            <label class=\"block text-sm font-medium mb-1\">Max Streams</label>\n            <input type=\"number\" name=\"max_streams\" value=\"{{ source.max_streams | default(0) }}\" min=\"0\"\n                   class=\"w-24 px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            <span class=\"text-xs text-gray-400 ml-2\">0 = unlimited</span>\n          </div>\n          <div>\n            <label class=\"block text-sm font-medium mb-1\">Refresh</label>\n            <div class=\"flex gap-2\" data-source-id=\"{{ source.id }}\" data-source-type=\"{{ source.type }}\">\n              {% if source.type == 'xtream' %}\n              <button type=\"button\" class=\"refresh-btn px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-refresh=\"epg\"><span class=\"spinner\"></span><span class=\"label\">EPG</span></button>\n              <button type=\"button\" class=\"refresh-btn px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-refresh=\"live\"><span class=\"spinner\"></span><span class=\"label\">Live</span></button>\n              <button type=\"button\" class=\"refresh-btn px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-refresh=\"vod\"><span class=\"spinner\"></span><span class=\"label\">VOD</span></button>\n              {% elif source.type == 'm3u' %}\n              <button type=\"button\" class=\"refresh-btn px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-refresh=\"epg\"><span class=\"spinner\"></span><span class=\"label\">EPG</span></button>\n              <button type=\"button\" class=\"refresh-btn px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-refresh=\"m3u\"><span class=\"spinner\"></span><span class=\"label\">M3U</span></button>\n              {% elif source.type == 'epg' %}\n              <button type=\"button\" class=\"refresh-btn px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded\" data-refresh=\"epg\"><span class=\"spinner\"></span><span class=\"label\">EPG</span></button>\n              {% endif %}\n            </div>\n          </div>\n          <div class=\"flex justify-end\">\n            <button type=\"button\" class=\"delete-source-btn px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm\"\n                    data-source-id=\"{{ source.id }}\">Delete</button>\n          </div>\n        </form>\n      </details>\n      {% endfor %}\n      <details class=\"bg-gray-700 rounded group\">\n        <summary class=\"flex items-center justify-between p-2 cursor-pointer hover:bg-gray-600 rounded\">\n          <div>\n            <span class=\"font-medium\">Add Source</span>\n          </div>\n          <span class=\"text-gray-400 text-xs group-open:hidden\">Expand</span>\n        </summary>\n      <div class=\"p-3 pt-1 border-t border-gray-600\">\n    <form action=\"/settings/add\" method=\"POST\" class=\"space-y-3\" id=\"add-source-form\">\n      <div class=\"grid grid-cols-2 gap-3\">\n        <div>\n          <label class=\"block text-sm font-medium mb-1\">Name <span class=\"text-red-400\">*</span></label>\n          <input type=\"text\" name=\"name\" required placeholder=\"My IPTV\"\n                 class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n        </div>\n        <div>\n          <label class=\"block text-sm font-medium mb-1\">Type</label>\n          <select name=\"source_type\" id=\"source-type\"\n                  class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            <option value=\"xtream\">Xtream API</option>\n            <option value=\"m3u\">M3U Playlist</option>\n            <option value=\"epg\">EPG Only</option>\n          </select>\n        </div>\n      </div>\n      <div>\n        <label class=\"block text-sm font-medium mb-1\">URL <span class=\"text-red-400\">*</span></label>\n        <input type=\"url\" name=\"url\" required placeholder=\"https://server.com\"\n               class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n      </div>\n      <div id=\"epg-enabled-field\">\n        <label class=\"flex items-center gap-2 cursor-pointer\">\n          <input type=\"checkbox\" name=\"epg_enabled\" checked\n                 class=\"w-4 h-4 rounded bg-gray-600 border-gray-500 text-blue-500 focus:ring-blue-500\">\n          <span class=\"text-sm\">Fetch EPG from this source</span>\n        </label>\n      </div>\n      <div id=\"deinterlace-field\">\n        <label class=\"flex items-center gap-2 cursor-pointer\">\n          <input type=\"checkbox\" name=\"deinterlace_fallback\"\n                 class=\"w-4 h-4 rounded bg-gray-600 border-gray-500 text-blue-500 focus:ring-blue-500\">\n          <span class=\"text-sm\">Deinterlace when probe skipped</span>\n        </label>\n        <span class=\"text-xs text-gray-400 ml-6\">Enable for OTA/HDHomeRun, disable for IPTV</span>\n      </div>\n      <div id=\"max-streams-field\">\n        <label class=\"block text-sm font-medium mb-1\">Max Streams</label>\n        <input type=\"number\" name=\"max_streams\" value=\"0\" min=\"0\"\n               class=\"w-24 px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n        <span class=\"text-xs text-gray-400 ml-2\">0 = unlimited</span>\n      </div>\n      <div id=\"xtream-fields\" class=\"grid grid-cols-2 gap-3\">\n        <div>\n          <label class=\"block text-sm font-medium mb-1\">Username</label>\n          <input type=\"text\" name=\"username\" placeholder=\"username\"\n                 class=\"w-full px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n        </div>\n        <div>\n          <label class=\"block text-sm font-medium mb-1\">Password</label>\n          <div class=\"relative\">\n            <input type=\"password\" name=\"password\" placeholder=\"password\"\n                   class=\"w-full px-3 py-1.5 pr-9 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n            <button type=\"button\" onclick=\"togglePwdVis(this)\" class=\"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white\">\n              <svg class=\"w-5 h-5 eye-off\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\"/></svg>\n              <svg class=\"w-5 h-5 eye-on hidden\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"/><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"/></svg>\n            </button>\n          </div>\n        </div>\n      </div>\n      <div class=\"grid grid-cols-2 gap-4\">\n        <div>\n          <label class=\"block text-sm font-medium mb-1\">EPG Timeout (seconds)</label>\n          <input type=\"number\" name=\"epg_timeout\" value=\"120\" min=\"1\" max=\"3600\"\n                 class=\"w-32 px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n          <span class=\"text-xs text-gray-400 ml-2\">1-3600s</span>\n        </div>\n        <div>\n          <label class=\"block text-sm font-medium mb-1\">EPG Schedule</label>\n          <input type=\"text\" name=\"epg_schedule\" placeholder=\"03:00, 15:00\"\n                 class=\"w-48 px-3 py-1.5 bg-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\">\n          <span class=\"text-xs text-gray-400 ml-2\">HH:MM times</span>\n        </div>\n      </div>\n      <div class=\"flex justify-end\">\n        <button type=\"submit\" class=\"px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 rounded\">Add Source</button>\n      </div>\n    </form>\n      </div>\n      </details>\n    </div>\n  </div>\n  {% endif %}\n</div>\n\n<!-- Delete Self Modal -->\n<div id=\"delete-self-modal\" class=\"hidden fixed inset-0 bg-black/70 flex items-center justify-center z-50\">\n  <div class=\"bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4\">\n    <h3 class=\"text-lg font-semibold mb-4\">Delete Your Account</h3>\n    <p class=\"text-sm text-gray-400 mb-4\">Enter your password to confirm account deletion. This cannot be undone.</p>\n    <form id=\"delete-self-form\" onsubmit=\"submitDeleteSelf(event); return false;\">\n      <input type=\"password\" id=\"delete-self-password\" placeholder=\"Password\" required\n             class=\"w-full px-3 py-2 bg-gray-700 rounded mb-2 focus:outline-none focus:ring-1 focus:ring-red-500\">\n      <div id=\"delete-self-msg\" class=\"text-red-400 text-sm mb-2\"></div>\n      <div class=\"flex gap-3 justify-end\">\n        <button type=\"button\" onclick=\"hideDeleteSelfModal()\" class=\"px-4 py-2 bg-gray-600 hover:bg-gray-500 rounded\">Cancel</button>\n        <button type=\"submit\" class=\"px-4 py-2 bg-red-600 hover:bg-red-700 rounded\">Delete</button>\n      </div>\n    </form>\n  </div>\n</div>\n{% endblock %}\n\n{% block scripts %}\n<script>\nwindow.SETTINGS_CONFIG = {\n  currentUser: {{ current_user | tojson | safe }},\n  selectedCats: {{ selected_cats | tojson | safe }},\n  selectedVodCats: {{ selected_vod_cats | tojson | safe }},\n  selectedSeriesCats: {{ selected_series_cats | tojson | safe }},\n  catNames: {\n    {% for cat in live_categories %}{{ cat.category_id | tojson | safe }}: {{ cat.category_name | tojson | safe }}{% if not loop.last %},{% endif %}\n    {% endfor %}\n  },\n  vodCatNames: {\n    {% for cat in vod_categories %}{{ cat.category_id | tojson | safe }}: {{ cat.category_name | tojson | safe }}{% if not loop.last %},{% endif %}\n    {% endfor %}\n  },\n  seriesCatNames: {\n    {% for cat in series_categories %}{{ cat.category_id | tojson | safe }}: {{ cat.category_name | tojson | safe }}{% if not loop.last %},{% endif %}\n    {% endfor %}\n  },\n  ccStyle: {{ cc_style | tojson | safe }},\n  ccLang: {{ cc_lang | tojson | safe }}\n};\n</script>\n<script src=\"/static/js/settings.js\"></script>\n{% endblock %}\n"
  },
  {
    "path": "templates/setup.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"h-full\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Setup - neTV</title>\n  <script src=\"https://cdn.tailwindcss.com\"></script>\n</head>\n<body class=\"h-full bg-gray-900 text-gray-100 flex items-center justify-center\">\n  <div class=\"bg-gray-800 p-8 rounded-lg w-full max-w-md\">\n    <h1 class=\"text-3xl font-bold text-center mb-2 text-blue-400\">neTV</h1>\n    <p class=\"text-center text-gray-400 mb-8\">Create your admin account</p>\n\n    {% if error %}\n    <div class=\"bg-red-900/50 border border-red-500 rounded p-3 mb-4 text-red-300 text-sm\">\n      {{ error }}\n    </div>\n    {% endif %}\n\n    <form method=\"POST\" action=\"/setup\" class=\"space-y-4\">\n      <div>\n        <label class=\"block text-sm mb-1\">Username</label>\n        <input type=\"text\" name=\"username\" required minlength=\"3\"\n               class=\"w-full px-4 py-2 bg-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\"\n               placeholder=\"admin\">\n      </div>\n      <div>\n        <label class=\"block text-sm mb-1\">Password</label>\n        <input type=\"password\" name=\"password\" required minlength=\"6\"\n               class=\"w-full px-4 py-2 bg-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\"\n               placeholder=\"Choose a strong password\">\n      </div>\n      <div>\n        <label class=\"block text-sm mb-1\">Confirm Password</label>\n        <input type=\"password\" name=\"confirm\" required minlength=\"6\"\n               class=\"w-full px-4 py-2 bg-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none\"\n               placeholder=\"Confirm password\">\n      </div>\n      <button type=\"submit\"\n              class=\"w-full py-3 bg-blue-600 hover:bg-blue-700 rounded font-semibold transition-colors\">\n        Create Account\n      </button>\n    </form>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/vod.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}Movies - neTV{% endblock %}\n\n{% block head_extra %}\n{% if loading %}\n<meta http-equiv=\"refresh\" content=\"2\">\n{% endif %}\n{% endblock %}\n\n{% block content %}\n{% if loading %}\n<div class=\"flex flex-col h-full items-center justify-center\">\n  <p class=\"text-xl text-gray-400\">Loading movies...</p>\n</div>\n{% else %}\n<div class=\"flex flex-col h-full min-h-0 min-w-0\">\n  <div class=\"flex flex-wrap items-center justify-between gap-2 mb-4\">\n    <h2 class=\"text-xl sm:text-3xl font-bold\">Movies</h2>\n    <div class=\"flex gap-2\">\n      <a href=\"/search?vod=true\" class=\"px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\">\n        Search\n      </a>\n      {% if current_sort %}\n      <a href=\"/vod\" class=\"px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\">\n        Favorites\n      </a>\n      {% else %}\n      <a href=\"/vod?sort=rating\" class=\"px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 focusable\" tabindex=\"0\">\n        Browse All\n      </a>\n      {% endif %}\n    </div>\n  </div>\n\n  {% if current_sort %}\n  <!-- Browse view -->\n  <div class=\"flex-1 min-h-0 overflow-auto scroll-ring-safe\">\n    <div class=\"mb-4 flex gap-2\">\n      <select id=\"category-select\" class=\"bg-gray-700 text-white px-4 py-2 rounded\">\n        <option value=\"\">All Categories</option>\n        {% for cat in categories %}\n        <option value=\"{{ cat.category_id }}\" {% if current_category|string == cat.category_id|string %}selected{% endif %}>\n          {{ cat.category_name }}\n        </option>\n        {% endfor %}\n      </select>\n      <select id=\"sort-select\" class=\"bg-gray-700 text-white px-4 py-2 rounded\">\n        <option value=\"alpha\" {% if current_sort == 'alpha' %}selected{% endif %}>A-Z</option>\n        <option value=\"rating\" {% if current_sort == 'rating' %}selected{% endif %}>Rating</option>\n        <option value=\"newest\" {% if current_sort == 'newest' %}selected{% endif %}>Newest</option>\n      </select>\n    </div>\n    <div class=\"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-2 sm:gap-3 pb-4\">\n      {% for movie in streams %}\n      <div class=\"movie-card relative group\" data-movie-id=\"{{ movie.stream_id }}\" data-movie-name=\"{{ movie.name }}\"\n           data-movie-cover=\"{{ (movie.stream_icon or '') | logo_url }}\" data-movie-ext=\"{{ movie.container_extension or 'mkv' }}\">\n        <a href=\"/movie/{{ movie.stream_id }}\"\n           class=\"block bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 focus:ring-2 focus:ring-blue-500 focusable transition-all\"\n           tabindex=\"0\" data-nav=\"grid\" title=\"{{ movie.name }}{% if movie.rating %}&#10;★ {{ movie.rating }}{% endif %}\">\n          <div class=\"aspect-[2/3] bg-gray-700\">\n            {% if movie.stream_icon %}\n            <img src=\"{{ movie.stream_icon | logo_url }}\" alt=\"\" class=\"w-full h-full object-cover\" loading=\"lazy\"\n                 onerror=\"this.style.display='none'\">\n            {% endif %}\n          </div>\n          <div class=\"p-2\">\n            <div class=\"text-sm line-clamp-2 leading-tight\">{{ movie.name }}</div>\n            {% if movie.rating %}\n            <div class=\"text-xs text-yellow-400 mt-1\">★ {{ movie.rating }}</div>\n            {% endif %}\n          </div>\n        </a>\n        <button type=\"button\" class=\"fav-btn absolute top-2 right-2 w-8 h-8 rounded-full bg-black/50 text-xl opacity-0 group-hover:opacity-100 transition-opacity z-10\"\n                onclick='toggleFavorite({{ movie.stream_id | tojson }}, {{ movie.name | tojson }}, {{ (movie.stream_icon or \"\") | logo_url | tojson }}, {{ (movie.container_extension or \"mkv\") | tojson }})'>\n          ☆\n        </button>\n      </div>\n      {% endfor %}\n    </div>\n  </div>\n  {% else %}\n  <!-- Favorites view -->\n  <div id=\"favorites-section\" class=\"flex-1 min-h-0 overflow-auto scroll-ring-safe\">\n    <h3 class=\"text-xl font-semibold mb-4\">★ My Favorites</h3>\n    <div id=\"favorites-grid\" class=\"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-2 sm:gap-3 pb-4\">\n    </div>\n    <div id=\"no-favorites\" class=\"hidden text-center text-gray-400 py-12\">\n      <p class=\"text-xl mb-2\">No favorites yet</p>\n      <p>Use Search or Browse All to find movies, then click ☆ to add</p>\n    </div>\n  </div>\n  {% endif %}\n</div>\n{% endif %}\n{% endblock %}\n\n{% block scripts %}\n<script>\nwindow.FAVORITES_CONFIG = {\n  type: 'movies',\n  favorites: {{ favorites | tojson }},\n  cardClass: 'movie-card',\n  tileClass: 'vod-tile',\n  detailUrl: '/movie/',\n  baseUrl: '/vod',\n  orderKey: 'vod_order',\n  isBrowseView: {{ (current_sort is not none) | tojson }}\n};\n</script>\n<script src=\"/static/js/favorites-grid.js\"></script>\n{% endblock %}\n"
  },
  {
    "path": "testing.py",
    "content": "\"\"\"Test utilities.\"\"\"\n\nimport sys\nimport warnings\n\n\n# Suppress unawaited coroutine warnings from AsyncMock in tests.\nwarnings.filterwarnings(\"ignore\", message=\"coroutine.*was never awaited\")\n\n\ndef run_tests(test_file: str) -> None:\n    \"\"\"Run pytest on a test file with standard flags.\n\n    Usage:\n        if __name__ == \"__main__\":\n            from testing import run_tests\n            run_tests(__file__)\n    \"\"\"\n    import pytest\n\n    sys.exit(\n        pytest.main(\n            [\n                test_file,\n                \"-v\",\n                \"-s\",\n                \"-W\",\n                \"ignore::pytest.PytestAssertRewriteWarning\",\n                *sys.argv[1:],\n            ]\n        )\n    )\n"
  },
  {
    "path": "tools/alignm3u.py",
    "content": "#!/usr/bin/env python3\n# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false\n\"\"\"alignm3u.py -- Align HDHomeRun M3U with XMLTV guide data.\n\nTakes an M3U playlist from HDHomeRun and aligns channel IDs with an XMLTV\nguide file (e.g., from zap2xml.py). Outputs a new M3U with tvg-id attributes\nset for EPG matching.\n\nUsage:\n    # First, fetch your HDHomeRun lineup and generate XMLTV:\n    wget http://YOUR_HDHR_IP/lineup.m3u -O lineup.m3u\n    ./zap2xml.py --zip YOUR_ZIP\n\n    # Then align them:\n    ./alignm3u.py --input lineup.m3u --xmltv xmltv.xml --output ota.m3u\n\n    # Optionally specify the URL where xmltv.xml will be served:\n    ./alignm3u.py --input lineup.m3u --xmltv xmltv.xml --output ota.m3u \\\\\n        --xmltv-url http://your-server/xmltv.xml\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport collections\nimport pathlib\nimport re\nimport xml.etree.ElementTree as ET\n\n\n# https://en.wikipedia.org/wiki/Call_signs_in_the_United_States#Suffixes\n_CALLSIGN_REGEX = re.compile(r\"^([A-Z]+?)(LD|DT|CD|CA|LP|TV|FM|D)(\\d*)$\")\n\n\ndef parse_callsign(coded_callsign: str) -> tuple[str, str, int]:\n    \"\"\"Parse FCC callsign into (call, suffix, number).\"\"\"\n    result = _CALLSIGN_REGEX.search(coded_callsign.upper())\n    if not result:\n        return coded_callsign, \"\", 1\n    call, suffix, num = result.groups()\n    if call == \"KQS\" and suffix == \"LD\":\n        call, suffix = \"KQSL\", \"LD\"  # Known bug in some data\n    return call, suffix, int(num) if num else 1\n\n\ndef parse_m3u(path: pathlib.Path) -> list[list]:\n    \"\"\"Parse M3U file into list of [title, attrs, url].\"\"\"\n    with open(path) as f:\n        first_line = f.readline().strip()\n        if not first_line.startswith(\"#EXTM3U\"):\n            raise ValueError(f\"Invalid M3U file: {path}\")\n        entries = []\n        for line in f:\n            line = line.strip()\n            if not line:\n                continue\n            if not line.startswith(\"#EXTINF:\"):\n                if entries:\n                    entries[-1].append(line)\n                continue\n            attrs_str, title = line.split(\"#EXTINF:\")[1].split(\",\", 1)\n            attrs_str = attrs_str.split(\"-1 \", 1)[1] if \"-1 \" in attrs_str else attrs_str\n            attrs_list = re.findall(r'(?:[^\\s,\"]|\"(?:\\\\.|[^\"])*\")+', attrs_str)\n            attrs = dict(s.replace('\"', \"\").split(\"=\", 1) for s in attrs_list if \"=\" in s)\n            entries.append([title.strip(), attrs])\n    return entries\n\n\ndef parse_xmltv_channels(path: pathlib.Path) -> dict[str, tuple[str, ...]]:\n    \"\"\"Parse XMLTV file and return {channel_id: (display_names...)}.\"\"\"\n    channels = {}\n    for elem in ET.parse(path).getroot():\n        if elem.tag == \"channel\":\n            channel_id = elem.get(\"id\")\n            names = tuple(v.text for v in elem if v.tag == \"display-name\" and v.text)\n            if channel_id:\n                channels[channel_id] = names\n    return channels\n\n\ndef build_lookup(xmltv_channels: dict[str, tuple[str, ...]]) -> dict[str, set[str]]:\n    \"\"\"Build lookup from channel number/name to channel IDs.\"\"\"\n    lookup: dict[str, set[str]] = collections.defaultdict(set)\n    for channel_id, names in xmltv_channels.items():\n        for name in names:\n            lookup[name].add(channel_id)\n    return lookup\n\n\ndef align_channels(\n    m3u: list[list],\n    lookup: dict[str, set[str]],\n) -> tuple[list[list], list[list]]:\n    \"\"\"Align M3U entries with XMLTV channel IDs. Returns (aligned, missing).\"\"\"\n    missing = []\n    for entry in m3u:\n        if len(entry) < 3:\n            continue\n        title, attrs, _ = entry\n        chan_num = attrs.get(\"channel-number\", \"\")\n        chan_name = attrs.get(\"tvg-name\", title)\n\n        # Normalize channel number (some have leading digit for ATSC3)\n        if chan_num and float(chan_num) > 100:\n            chan_num = str(float(chan_num[1:]))\n\n        candidates_num = tuple(lookup.get(chan_num, ()))\n        candidates_name = tuple(lookup.get(chan_name, ()))\n\n        # Priority: exact match on number > exact match on name > any match\n        if len(candidates_num) == 1:\n            attrs[\"tvg-id\"] = candidates_num[0]\n        elif len(candidates_name) == 1:\n            attrs[\"tvg-id\"] = candidates_name[0]\n        elif candidates_num:\n            attrs[\"tvg-id\"] = candidates_num[0]\n        elif candidates_name:\n            attrs[\"tvg-id\"] = candidates_name[0]\n        else:\n            missing.append(entry)\n\n    return m3u, missing\n\n\ndef write_m3u(\n    m3u: list[list],\n    output: pathlib.Path,\n    xmltv_url: str,\n    group_prefix: str = \"OTA\",\n) -> None:\n    \"\"\"Write aligned M3U file.\"\"\"\n    with open(output, \"w\") as f:\n        if xmltv_url:\n            print(f'#EXTM3U url-tvg=\"{xmltv_url}\" x-tvg-url=\"{xmltv_url}\"', file=f)\n        else:\n            print(\"#EXTM3U\", file=f)\n        for entry in m3u:\n            if len(entry) < 3:\n                continue\n            title, attrs, url = entry\n            # Use tvg-name as title if available\n            title = attrs.get(\"tvg-name\", title)\n            # Build group-title\n            groups = [group_prefix]\n            if \"group-title\" in attrs:\n                groups.append(attrs[\"group-title\"])\n            # Mark ATSC3 channels\n            if \"channel-id\" in attrs and float(attrs[\"channel-id\"]) >= 100:\n                groups.append(\"ATSC3\")\n            attrs[\"group-title\"] = \" | \".join(groups)\n            # Format attributes\n            attrs_str = \" \".join(f'{k}=\"{v}\"' for k, v in attrs.items())\n            print(f\"#EXTINF:-1 {attrs_str},{title}\", file=f)\n            print(url, file=f)\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        description=\"Align HDHomeRun M3U with XMLTV guide data.\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\nExample:\n    wget http://192.168.1.100/lineup.m3u -O lineup.m3u\n    ./zap2xml.py --zip 90210\n    ./alignm3u.py --input lineup.m3u --xmltv xmltv.xml --output ota.m3u\n        \"\"\",\n    )\n    parser.add_argument(\n        \"--input\",\n        \"-i\",\n        type=pathlib.Path,\n        required=True,\n        help=\"Input M3U file from HDHomeRun\",\n    )\n    parser.add_argument(\n        \"--xmltv\",\n        \"-x\",\n        type=pathlib.Path,\n        required=True,\n        help=\"XMLTV guide file (e.g., from zap2xml.py)\",\n    )\n    parser.add_argument(\n        \"--output\",\n        \"-o\",\n        type=pathlib.Path,\n        required=True,\n        help=\"Output M3U file with aligned tvg-id\",\n    )\n    parser.add_argument(\n        \"--xmltv-url\",\n        type=str,\n        default=\"\",\n        help=\"URL where XMLTV file will be served (for M3U header)\",\n    )\n    parser.add_argument(\n        \"--group\",\n        type=str,\n        default=\"OTA\",\n        help=\"Group prefix for channels (default: OTA)\",\n    )\n    args = parser.parse_args()\n\n    # Parse inputs\n    print(f\"Reading M3U: {args.input}\")\n    m3u = parse_m3u(args.input)\n    print(f\"  Found {len(m3u)} channels\")\n\n    print(f\"Reading XMLTV: {args.xmltv}\")\n    xmltv_channels = parse_xmltv_channels(args.xmltv)\n    print(f\"  Found {len(xmltv_channels)} channels\")\n\n    # Build lookup and align\n    lookup = build_lookup(xmltv_channels)\n    m3u, missing = align_channels(m3u, lookup)\n\n    if missing:\n        print(f\"\\nUnable to align {len(missing)} channels:\")\n        for entry in missing:\n            title, attrs = entry[0], entry[1]\n            num = attrs.get(\"channel-number\", \"?\")\n            print(f\"  {num}: {title}\")\n\n    # Write output\n    print(f\"\\nWriting: {args.output}\")\n    write_m3u(m3u, args.output, args.xmltv_url, args.group)\n    aligned = len(m3u) - len(missing)\n    print(f\"  Aligned {aligned}/{len(m3u)} channels\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tools/export-tensorrt.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Export upscaling models to TensorRT engines for FFmpeg dnn_processing filter.\n\nThis script converts AI upscaling models to TensorRT engines (.engine files)\nthat can be loaded by FFmpeg's TensorRT DNN backend.\n\nAvailable models (use --list to see all):\n  2x models (1080p → 4K):\n    - 2x-liveaction-span    Best for live action TV/film\n\n  4x models (720p → 4K, 480p → 1080p):\n    - 4x-compact            Fast, good quality (SRVGGNetCompact)\n\nUsage:\n    # List available models\n    python export-tensorrt.py --list\n\n    # Export 2x model for live action\n    python export-tensorrt.py --model 2x-liveaction-span -o model.engine\n\n    # Export with custom height range\n    python export-tensorrt.py --model 2x-liveaction-span --min-height 720 --max-height 1080\n\nRequirements:\n    pip install torch onnx tensorrt\n\nExample FFmpeg usage after export:\n    ffmpeg -i input.mp4 -vf \"dnn_processing=dnn_backend=tensorrt:model=model.engine\" output.mp4\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, NotRequired, TypedDict\n\n\nif TYPE_CHECKING:\n    import tensorrt as trt\n\nimport argparse\nimport sys\nimport tempfile\nimport urllib.request\n\nimport tensorrt as trt\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\n\n\nclass SRVGGNetCompact(nn.Module):\n    \"\"\"Compact SR network - fast inference, good quality.\"\"\"\n\n    upscale: int\n    body: nn.ModuleList\n    upsampler: nn.PixelShuffle\n\n    def __init__(\n        self,\n        num_in_ch: int = 3,\n        num_out_ch: int = 3,\n        num_feat: int = 64,\n        num_conv: int = 32,\n        upscale: int = 4,\n    ):\n        super().__init__()\n        self.upscale = upscale\n        self.body = nn.ModuleList()\n        self.body.append(nn.Conv2d(num_in_ch, num_feat, 3, 1, 1))\n        self.body.append(nn.PReLU(num_parameters=num_feat))\n        for _ in range(num_conv - 2):\n            self.body.append(nn.Conv2d(num_feat, num_feat, 3, 1, 1))\n            self.body.append(nn.PReLU(num_parameters=num_feat))\n        self.body.append(nn.Conv2d(num_feat, num_out_ch * upscale * upscale, 3, 1, 1))\n        self.upsampler = nn.PixelShuffle(upscale)\n\n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        out = x\n        for layer in self.body[:-1]:\n            out = layer(out)\n        out = self.body[-1](out)\n        out = self.upsampler(out)\n        return out + F.interpolate(x, scale_factor=self.upscale, mode=\"nearest\")\n\n\nclass ResidualDenseBlock(nn.Module):\n    \"\"\"Residual Dense Block for RRDBNet.\"\"\"\n\n    conv1: nn.Conv2d\n    conv2: nn.Conv2d\n    conv3: nn.Conv2d\n    conv4: nn.Conv2d\n    conv5: nn.Conv2d\n    lrelu: nn.LeakyReLU\n\n    def __init__(self, nf: int = 64, gc: int = 32):\n        super().__init__()\n        self.conv1 = nn.Conv2d(nf, gc, 3, 1, 1)\n        self.conv2 = nn.Conv2d(nf + gc, gc, 3, 1, 1)\n        self.conv3 = nn.Conv2d(nf + 2 * gc, gc, 3, 1, 1)\n        self.conv4 = nn.Conv2d(nf + 3 * gc, gc, 3, 1, 1)\n        self.conv5 = nn.Conv2d(nf + 4 * gc, nf, 3, 1, 1)\n        self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)\n\n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        x1 = self.lrelu(self.conv1(x))\n        x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))\n        x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))\n        x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))\n        x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))\n        return x5 * 0.2 + x\n\n\nclass RRDB(nn.Module):\n    \"\"\"Residual in Residual Dense Block.\"\"\"\n\n    rdb1: ResidualDenseBlock\n    rdb2: ResidualDenseBlock\n    rdb3: ResidualDenseBlock\n\n    def __init__(self, nf: int, gc: int = 32):\n        super().__init__()\n        self.rdb1 = ResidualDenseBlock(nf, gc)\n        self.rdb2 = ResidualDenseBlock(nf, gc)\n        self.rdb3 = ResidualDenseBlock(nf, gc)\n\n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        out = self.rdb1(x)\n        out = self.rdb2(out)\n        out = self.rdb3(out)\n        return out * 0.2 + x\n\n\nclass RRDBNet(nn.Module):\n    \"\"\"RRDBNet architecture for Real-ESRGAN - highest quality, slower.\"\"\"\n\n    scale: int\n    conv_first: nn.Conv2d\n    body: nn.Sequential\n    conv_body: nn.Conv2d\n    conv_up1: nn.Conv2d\n    conv_up2: nn.Conv2d\n    conv_hr: nn.Conv2d\n    conv_last: nn.Conv2d\n    lrelu: nn.LeakyReLU\n\n    def __init__(\n        self,\n        num_in_ch: int = 3,\n        num_out_ch: int = 3,\n        num_feat: int = 64,\n        num_block: int = 23,\n        num_grow_ch: int = 32,\n        scale: int = 4,\n    ):\n        super().__init__()\n        self.scale = scale\n        self.conv_first = nn.Conv2d(num_in_ch, num_feat, 3, 1, 1)\n        self.body = nn.Sequential(*[RRDB(num_feat, num_grow_ch) for _ in range(num_block)])\n        self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1)\n        self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)\n        self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)\n        self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1)\n        self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1)\n        self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)\n\n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        feat = self.conv_first(x)\n        body_feat = self.conv_body(self.body(feat))\n        feat = feat + body_feat\n        feat = self.lrelu(\n            self.conv_up1(\n                F.interpolate(\n                    feat,\n                    scale_factor=2,\n                    mode=\"nearest\",\n                )\n            )\n        )\n        feat = self.lrelu(\n            self.conv_up2(\n                F.interpolate(\n                    feat,\n                    scale_factor=2,\n                    mode=\"nearest\",\n                )\n            )\n        )\n        out = self.conv_last(self.lrelu(self.conv_hr(feat)))\n        return out\n\n\nclass ModelInfo(TypedDict):\n    \"\"\"Type definition for model registry entries.\"\"\"\n\n    description: str\n    filename: str\n    scale: int\n    arch: str\n    url: NotRequired[str]\n    onnx_url: NotRequired[str]\n\n\nMODELS: dict[str, ModelInfo] = {\n    # 2x models - high quality, 1080p → 4K\n    \"2x-liveaction-span\": {\n        \"description\": \"Live action TV/film - handles compression, preserves grain\",\n        \"onnx_url\": \"https://github.com/jcj83429/upscaling/raw/f73a3a02874360ec6ced18f8bdd8e43b5d7bba57/2xLiveActionV1_SPAN/2xLiveActionV1_SPAN_490000.onnx\",\n        \"filename\": \"2xLiveActionV1_SPAN.onnx\",\n        \"scale\": 2,\n        \"arch\": \"span\",\n    },\n    # 4x models - 720p → 4K or 480p → 1080p\n    \"4x-compact\": {\n        \"description\": \"Fast 4x upscale - SRVGGNetCompact\",\n        \"url\": \"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth\",\n        \"filename\": \"realesr-general-x4v3.pth\",\n        \"scale\": 4,\n        \"arch\": \"compact\",\n    },\n    # 4x-realesrgan - not recommended (overly smooths faces)\n    \"4x-realesrgan\": {\n        \"description\": \"RealESRGAN 4x - smooths faces (not recommended)\",\n        \"url\": \"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth\",\n        \"filename\": \"RealESRGAN_x4plus.pth\",\n        \"scale\": 4,\n        \"arch\": \"rrdbnet\",\n    },\n    # NOTE: 4x-rrdbnet was removed because:\n    # - 1080p engine build fails with OOM even on 32GB VRAM (RTX 5090)\n    # - 720p engine causes \"Invalid frame dimensions 0x0\" errors during playback\n    # - Same weights as 4x-realesrgan but different name\n}\n\n\ndef resolve_model(model_name: str) -> tuple[str, ModelInfo]:\n    \"\"\"Resolve model name.\"\"\"\n    info = MODELS.get(model_name)\n    if info is None:\n        raise ValueError(f\"Unknown model: {model_name}\")\n    return model_name, info\n\n\ndef download_model(model_name: str, cache_dir: Path) -> Path:\n    \"\"\"Download model weights (ONNX or PTH).\"\"\"\n    model_name, info = resolve_model(model_name)\n    # Use .name to prevent path traversal\n    path = cache_dir / Path(info[\"filename\"]).name\n    if path.exists():\n        print(f\"Using cached model: {path}\")\n        return path\n\n    url = info.get(\"onnx_url\") or info.get(\"url\")\n    if url is None:\n        raise ValueError(f\"No URL for model: {model_name}\")\n    if not url.startswith(\"https://\"):\n        raise ValueError(f\"URL must use HTTPS: {url}\")\n    print(f\"Downloading {info['filename']}...\")\n\n    # Download to a temp file first, then rename to avoid partial downloads\n    temp_path = path.with_suffix(path.suffix + \".tmp\")\n    try:\n        with (\n            urllib.request.urlopen(url, timeout=300) as response,\n            open(temp_path, \"wb\") as f,\n        ):\n            f.write(response.read())\n        # Verify the download succeeded and file is not empty\n        file_size = temp_path.stat().st_size\n        if file_size == 0:\n            raise RuntimeError(f\"Downloaded file is empty: {temp_path}\")\n        temp_path.rename(path)\n        print(f\"Downloaded to {path} ({file_size / 1024 / 1024:.1f} MB)\")\n    except Exception as e:\n        # Clean up partial download\n        if temp_path.exists():\n            temp_path.unlink()\n        raise RuntimeError(f\"Failed to download model from {url}: {e}\") from e\n\n    return path\n\n\ndef list_models() -> None:\n    \"\"\"Print available models.\"\"\"\n    print(\"\\nAvailable models:\\n\")\n    print(\"  2x models (1080p → 4K):\")\n    for name, info in MODELS.items():\n        if name.startswith(\"2x-\"):\n            rec = \" (recommended)\" if name == \"2x-liveaction-span\" else \"\"\n            print(f\"    {name:24s} {info['description']}{rec}\")\n    print(\"\\n  4x models (720p → 4K):\")\n    for name, info in MODELS.items():\n        if name.startswith(\"4x-\"):\n            print(f\"    {name:24s} {info['description']}\")\n    print()\n\n\ndef get_model_and_onnx(\n    model_name: str,\n    cache_dir: Path | None = None,\n) -> tuple[\n    nn.Module | None,\n    Path | None,\n    int,\n]:\n    \"\"\"Load model and return (model_or_none, onnx_path_or_none, scale).\n\n    For ONNX-based models, returns (None, onnx_path, scale).\n    For PTH-based models, returns (model, None, scale).\n    \"\"\"\n    if cache_dir is None:\n        cache_dir = Path.home() / \".cache\" / \"ai_upscale\"\n    cache_dir.mkdir(parents=True, exist_ok=True)\n\n    model_name, info = resolve_model(model_name)\n    scale = info[\"scale\"]\n    arch = info[\"arch\"]\n\n    model_path = download_model(model_name, cache_dir)\n\n    # ONNX-based models - no PyTorch loading needed\n    if \"onnx_url\" in info:\n        print(f\"Using ONNX model directly: {model_path}\")\n        print(f\"  Architecture: {arch}, Scale: {scale}x\")\n        return None, model_path, scale\n\n    # PTH-based models - load PyTorch\n    print(f\"Loading PyTorch model from {model_path}\")\n    state_dict = torch.load(model_path, map_location=\"cpu\", weights_only=True)\n    if \"params_ema\" in state_dict:\n        state_dict = state_dict[\"params_ema\"]\n    elif \"params\" in state_dict:\n        state_dict = state_dict[\"params\"]\n\n    # Instantiate model based on explicit architecture\n    if arch == \"rrdbnet\":\n        model: nn.Module = RRDBNet(\n            num_in_ch=3,\n            num_out_ch=3,\n            num_feat=64,\n            num_block=23,\n            num_grow_ch=32,\n            scale=scale,\n        )\n        arch_name = \"RRDBNet\"\n    elif arch == \"compact\":\n        # Count conv layers to determine num_conv for SRVGGNetCompact\n        num_conv_layers = sum(\n            1 for k, v in state_dict.items() if \"weight\" in k and len(v.shape) == 4\n        )\n        model = SRVGGNetCompact(\n            num_in_ch=3,\n            num_out_ch=3,\n            num_feat=64,\n            num_conv=num_conv_layers,\n            upscale=scale,\n        )\n        arch_name = \"SRVGGNetCompact\"\n    else:\n        raise ValueError(f\"Unknown architecture: {arch}\")\n\n    model.load_state_dict(state_dict)\n    model.eval()\n    params = sum(p.numel() for p in model.parameters()) / 1e6\n    print(f\"  Loaded {arch_name} ({params:.2f}M params), Scale: {scale}x\")\n    return model, None, scale\n\n\ndef export_onnx(model: nn.Module, opt_shape: tuple[int, int], onnx_path: Path | str) -> None:\n    \"\"\"Export model to ONNX format with dynamic axes.\"\"\"\n    opt_w, opt_h = opt_shape\n    print(f\"Exporting to ONNX: {onnx_path}\")\n    print(f\"  Optimal shape: 1x3x{opt_h}x{opt_w}\")\n\n    dummy_input = torch.randn(1, 3, opt_h, opt_w, device=\"cpu\")\n\n    dynamic_axes = {\n        \"input\": {\n            2: \"height\",\n            3: \"width\",\n        },\n        \"output\": {\n            2: \"out_height\",\n            3: \"out_width\",\n        },\n    }\n\n    torch.onnx.export(\n        model,\n        (dummy_input,),\n        onnx_path,\n        input_names=[\"input\"],\n        output_names=[\"output\"],\n        opset_version=17,\n        do_constant_folding=True,\n        dynamic_axes=dynamic_axes,\n        dynamo=False,\n    )\n    print(\"  ONNX export complete (dynamic H/W)\")\n\n\ndef _get_trt_dtype_map() -> dict[str, trt.DataType]:\n    \"\"\"Get mapping from precision string to TensorRT DataType.\"\"\"\n    dtype_map: dict[str, trt.DataType] = {\n        \"fp32\": trt.float32,\n        \"fp16\": trt.float16,\n    }\n    if hasattr(trt, \"bfloat16\"):\n        dtype_map[\"bf16\"] = trt.bfloat16\n    return dtype_map\n\n\ndef _trt_dtype_str(dtype: trt.DataType) -> str:\n    \"\"\"Convert TensorRT DataType to human-readable string.\"\"\"\n    for name, dt in _get_trt_dtype_map().items():\n        if dtype == dt:\n            return name.upper()\n    return str(dtype)\n\n\ndef build_engine(\n    onnx_path: Path | str,\n    engine_path: Path | str,\n    min_shape: tuple[int, int],\n    opt_shape: tuple[int, int],\n    max_shape: tuple[int, int],\n    precision: str = \"fp16\",\n    workspace_gb: int = 4,\n    opt_level: int = 3,\n) -> None:\n    \"\"\"Build TensorRT engine from ONNX model with dynamic shapes.\"\"\"\n    min_w, min_h = min_shape\n    opt_w, opt_h = opt_shape\n    max_w, max_h = max_shape\n\n    print(f\"Building TensorRT engine: {engine_path}\")\n    print(\"  Dynamic shapes:\")\n    print(f\"    min: {min_w}x{min_h}\")\n    print(f\"    opt: {opt_w}x{opt_h}\")\n    print(f\"    max: {max_w}x{max_h}\")\n    print(f\"  Precision: {precision}\")\n    print(f\"  Workspace: {workspace_gb} GB\")\n\n    logger = trt.Logger(trt.Logger.INFO)\n    builder = trt.Builder(logger)\n    network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))\n    parser = trt.OnnxParser(network, logger)\n\n    with open(onnx_path, \"rb\") as f:\n        if not parser.parse(f.read()):\n            for i in range(parser.num_errors):\n                print(f\"  ONNX parse error: {parser.get_error(i)}\")\n            raise RuntimeError(\"Failed to parse ONNX model\")\n\n    config = builder.create_builder_config()\n    config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, workspace_gb * (1 << 30))\n\n    # Optimization level (0-5, default is 3)\n    # Higher levels enable more aggressive kernel selection/fusion but use more memory\n    config.builder_optimization_level = opt_level\n    print(f\"  Optimization level: {opt_level}\")\n\n    profile = builder.create_optimization_profile()\n    input_name = network.get_input(0).name\n    profile.set_shape(\n        input_name,\n        min=(1, 3, min_h, min_w),\n        opt=(1, 3, opt_h, opt_w),\n        max=(1, 3, max_h, max_w),\n    )\n    config.add_optimization_profile(profile)\n\n    # Set compute precision\n    if precision in (\"fp16\", \"bf16\"):\n        if builder.platform_has_fast_fp16:\n            config.set_flag(trt.BuilderFlag.FP16)\n        else:\n            print(\"  Warning: FP16/BF16 not supported on this platform, using FP32\")\n            precision = \"fp32\"\n    if precision == \"bf16\":\n        if hasattr(trt.BuilderFlag, \"BF16\"):\n            config.set_flag(trt.BuilderFlag.BF16)\n        else:\n            print(\"  Warning: BF16 not supported by TensorRT, using FP16\")\n            precision = \"fp16\"\n\n    # Set I/O tensor precision (matches compute precision)\n    dtype_map = _get_trt_dtype_map()\n    if precision not in dtype_map:\n        raise ValueError(f\"Unknown precision: {precision}\")\n    io_dtype = dtype_map[precision]\n\n    if io_dtype != trt.float32:\n        for i in range(network.num_inputs):\n            network.get_input(i).dtype = io_dtype\n        for i in range(network.num_outputs):\n            network.get_output(i).dtype = io_dtype\n\n    print(\"  Building engine (this may take several minutes)...\")\n    serialized_engine = builder.build_serialized_network(network, config)\n    if serialized_engine is None:\n        raise RuntimeError(\"Failed to build TensorRT engine\")\n\n    with open(engine_path, \"wb\") as f:\n        f.write(serialized_engine)\n\n    print(\n        f\"  Engine saved: {engine_path} ({Path(engine_path).stat().st_size / 1024 / 1024:.1f} MB)\"\n    )\n\n    # Verify the built engine has correct I/O types\n    runtime = trt.Runtime(logger)\n    engine = runtime.deserialize_cuda_engine(serialized_engine)\n    print(\"  Verifying engine I/O:\")\n    for i in range(engine.num_io_tensors):\n        name = engine.get_tensor_name(i)\n        dtype = engine.get_tensor_dtype(name)\n        mode = engine.get_tensor_mode(name)\n        dtype_str = _trt_dtype_str(dtype)\n        print(f\"    {name}: {dtype_str} ({mode})\")\n        if dtype != io_dtype:\n            print(f\"  WARNING: {name} is {dtype_str} but {_trt_dtype_str(io_dtype)} was requested!\")\n\n\ndef height_to_shape(h: int, aspect: float = 16 / 9) -> tuple[int, int]:\n    \"\"\"Convert height to (width, height) assuming aspect ratio.\n\n    Both width and height are aligned to 8 pixels, as required by many\n    neural network architectures with pooling/striding layers.\n    \"\"\"\n    # Align height to 8 first\n    h = (h + 7) // 8 * 8\n    # Calculate width from aligned height\n    w = int(h * aspect)\n    # Align width to 8\n    w = (w + 7) // 8 * 8\n    return (w, h)\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        description=\"Export AI upscaling models to TensorRT engines\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n    )\n    parser.add_argument(\n        \"--model\",\n        \"-m\",\n        type=str,\n        default=\"4x-compact\",\n        help=\"Model name (use --list to see available models)\",\n    )\n    parser.add_argument(\"--list\", \"-l\", action=\"store_true\", help=\"List available models\")\n    parser.add_argument(\n        \"--min-height\",\n        type=int,\n        default=None,\n        help=\"Minimum input height (default: auto)\",\n    )\n    parser.add_argument(\n        \"--opt-height\",\n        type=int,\n        default=None,\n        help=\"Optimal input height (default: auto)\",\n    )\n    parser.add_argument(\n        \"--max-height\",\n        type=int,\n        default=None,\n        help=\"Maximum input height (default: auto)\",\n    )\n    parser.add_argument(\n        \"--output\",\n        \"-o\",\n        type=str,\n        default=None,\n        help=\"Output engine path\",\n    )\n    parser.add_argument(\n        \"--precision\",\n        \"-p\",\n        type=str,\n        default=\"fp16\",\n        choices=[\"fp16\", \"bf16\", \"fp32\"],\n        help=\"Model precision for compute and I/O tensors (default: fp16)\",\n    )\n    parser.add_argument(\n        \"--workspace\",\n        type=int,\n        default=8,\n        help=\"TensorRT workspace size in GB (default: 8)\",\n    )\n    parser.add_argument(\n        \"--opt-level\",\n        type=int,\n        default=3,\n        choices=[0, 1, 2, 3, 4, 5],\n        help=\"TensorRT builder optimization level 0-5 (default: 3). Higher = more memory, potentially faster.\",\n    )\n    parser.add_argument(\n        \"--onnx-only\",\n        action=\"store_true\",\n        help=\"Only export ONNX, skip TensorRT engine build\",\n    )\n    args = parser.parse_args()\n\n    if args.list:\n        list_models()\n        return\n\n    # Get model info for defaults\n    try:\n        model_name, info = resolve_model(args.model)\n    except ValueError as e:\n        print(f\"Error: {e}\", file=sys.stderr)\n        list_models()\n        sys.exit(1)\n\n    scale = info[\"scale\"]\n\n    # Set height defaults based on scale factor\n    if scale == 2:\n        # 2x: input 1080p -> output 4K\n        default_min, default_opt, default_max = 720, 1080, 1080\n    elif scale == 4:\n        # 4x: input 720p -> output 4K, or 480p -> 1080p\n        default_min, default_opt, default_max = 480, 720, 1080\n    else:\n        raise ValueError(f\"Unsupported scale factor: {scale}\")\n\n    min_h = args.min_height or default_min\n    opt_h = args.opt_height or default_opt\n    max_h = args.max_height or default_max\n\n    # Validate height constraints\n    if min_h > max_h:\n        raise ValueError(f\"--min-height ({min_h}) cannot be greater than --max-height ({max_h})\")\n    if opt_h < min_h or opt_h > max_h:\n        raise ValueError(\n            f\"--opt-height ({opt_h}) must be between --min-height ({min_h}) and --max-height ({max_h})\"\n        )\n\n    min_shape = height_to_shape(min_h)\n    opt_shape = height_to_shape(opt_h)\n    max_shape = height_to_shape(max_h)\n\n    if args.output is None:\n        args.output = f\"{model_name}_{opt_h}p_{args.precision}.engine\"\n\n    print(\"=\" * 60)\n    print(\"AI Upscale: TensorRT Engine Export\")\n    print(\"=\" * 60)\n    print(f\"Model: {model_name}\")\n    print(f\"  {info['description']}\")\n    print()\n\n    model, existing_onnx, _ = get_model_and_onnx(args.model)\n\n    # Determine ONNX path\n    if existing_onnx:\n        # Model already has ONNX - use it directly\n        onnx_path = existing_onnx\n        cleanup_onnx = False\n    elif args.onnx_only:\n        # Save ONNX to current directory with sensible name\n        onnx_path = Path(f\"{model_name}_{opt_h}p.onnx\")\n        cleanup_onnx = False\n    else:\n        # Temp file for intermediate ONNX\n        with tempfile.NamedTemporaryFile(suffix=\".onnx\", delete=False) as tmp:\n            onnx_path = Path(tmp.name)\n        cleanup_onnx = True\n\n    try:\n        # Export to ONNX if needed (PTH-based models only)\n        if model is not None:\n            export_onnx(model, opt_shape, onnx_path)\n\n        if args.onnx_only:\n            print(f\"\\nONNX saved to: {onnx_path}\")\n            print(\"Skipping TensorRT build (--onnx-only). Build later with:\")\n            print(f\"  trtexec --onnx={onnx_path} --saveEngine={args.output} --fp16\")\n            return\n\n        build_engine(\n            onnx_path,\n            args.output,\n            min_shape=min_shape,\n            opt_shape=opt_shape,\n            max_shape=max_shape,\n            precision=args.precision,\n            workspace_gb=args.workspace,\n            opt_level=args.opt_level,\n        )\n    finally:\n        if cleanup_onnx and (onnx_file := Path(onnx_path)).exists():\n            onnx_file.unlink()\n\n    print()\n    print(\"=\" * 60)\n    print(\"Export complete!\")\n    print(\"=\" * 60)\n    print()\n    print(f\"Model: {model_name} ({scale}x upscale)\")\n    print(f\"Engine accepts input heights from {min_h} to {max_h} (16:9)\")\n    print()\n    print(\"Usage with FFmpeg:\")\n    print(\n        f'  ffmpeg -i input.mp4 -vf \"dnn_processing=dnn_backend=tensorrt:model={args.output}\" output.mp4'\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tools/install-ai_upscale.sh",
    "content": "#!/bin/bash\n# Build TensorRT engines for AI Upscale\n#\n# Prerequisites: uv sync --group ai_upscale\n#   Or: pip install torch onnx tensorrt\n#\n# Models sourced from https://openmodeldb.info/\n#\nset -e\n\n# Capture script directory (with error handling)\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\" || {\n    echo \"ERROR: Failed to determine script directory\" >&2\n    exit 1\n}\nPROJECT_DIR=\"$(dirname \"$SCRIPT_DIR\")\"\nMODEL_DIR=\"${MODEL_DIR:-$HOME/ffmpeg_build/models}\"\nMODEL=\"${MODEL:-recommended}\"\nPRECISION=\"${PRECISION:-fp16}\"\n\n# Recursion guard to prevent fork bombs when calling ourselves\nMAX_RECURSION_DEPTH=10\nRECURSION_DEPTH=${RECURSION_DEPTH:-0}\nif [ \"$RECURSION_DEPTH\" -ge \"$MAX_RECURSION_DEPTH\" ]; then\n    echo \"ERROR: Maximum recursion depth ($MAX_RECURSION_DEPTH) exceeded\" >&2\n    exit 1\nfi\n\n# Use uv run if in a uv project, otherwise plain python3\n# Note: PYTHON_CMD is an array to handle paths with spaces correctly\nif [ -f \"$PROJECT_DIR/pyproject.toml\" ] && command -v uv >/dev/null 2>&1; then\n    PYTHON_CMD=(\"uv\" \"run\" \"--project\" \"$PROJECT_DIR\" \"python3\")\nelse\n    PYTHON_CMD=(\"python3\")\nfi\n\n# Helper: run python command\nrun_python() {\n    \"${PYTHON_CMD[@]}\" \"$@\"\n}\n\n# Validate export-tensorrt.py exists\nEXPORT_SCRIPT=\"$SCRIPT_DIR/export-tensorrt.py\"\nif [ ! -f \"$EXPORT_SCRIPT\" ]; then\n    echo \"ERROR: export-tensorrt.py not found at $EXPORT_SCRIPT\" >&2\n    exit 1\nfi\n\n# Show help\nif [ \"$1\" = \"-h\" ] || [ \"$1\" = \"--help\" ]; then\n    echo \"Usage: $0 [MODEL]\"\n    echo \"\"\n    echo \"Build TensorRT engines for AI Upscale.\"\n    echo \"\"\n    echo \"Arguments:\"\n    echo \"  MODEL    Model to build (default: $MODEL)\"\n    echo \"           'recommended' - 4x-compact, 2x-liveaction-span\"\n    echo \"           'all'         - all models including 4x-realesrgan\"\n    echo \"\"\n    echo \"Environment:\"\n    echo \"  MODEL_DIR   Output directory (default: \\$HOME/ffmpeg_build/models)\"\n    echo \"  MODEL       Model name (can also be passed as argument)\"\n    echo \"  PRECISION   Model precision: fp16, bf16, fp32 (default: fp16)\"\n    echo \"\"\n    echo \"Available models:\"\n    run_python \"$EXPORT_SCRIPT\" --list\n    exit 0\nfi\n\n# Allow model to be passed as argument\nif [ -n \"$1\" ]; then\n    MODEL=\"$1\"\nfi\n\n# Handle \"recommended\" option - build recommended models\nif [ \"$MODEL\" = \"recommended\" ]; then\n    echo \"========================================\"\n    echo \"AI Upscale: Building recommended models\"\n    echo \"========================================\"\n    echo \"\"\n    for m in 4x-compact 2x-liveaction-span; do\n        echo \">>> Building $m...\"\n        # Increment recursion depth when calling ourselves\n        RECURSION_DEPTH=$((RECURSION_DEPTH + 1)) MODEL=\"$m\" \"$0\"\n        echo \"\"\n    done\n    echo \"Done! Recommended models built.\"\n    exit 0\nfi\n\n# Handle \"all\" option - build all available models\nif [ \"$MODEL\" = \"all\" ]; then\n    echo \"========================================\"\n    echo \"AI Upscale: Building ALL models\"\n    echo \"========================================\"\n    echo \"\"\n    for m in 4x-compact 2x-liveaction-span 4x-realesrgan; do\n        echo \">>> Building $m...\"\n        # Increment recursion depth when calling ourselves\n        RECURSION_DEPTH=$((RECURSION_DEPTH + 1)) MODEL=\"$m\" \"$0\"\n        echo \"\"\n    done\n    echo \"Done! All models built.\"\n    exit 0\nfi\n\necho \"========================================\"\necho \"AI Upscale: TensorRT Engine Builder\"\necho \"========================================\"\necho \"Model: $MODEL\"\necho \"Output: $MODEL_DIR/\"\necho \"\"\n\n# Check dependencies\nif ! run_python -c \"import torch, onnx, tensorrt\" 2>/dev/null; then\n    echo \"ERROR: Missing dependencies. Install with:\"\n    echo \"  uv sync --group ai_upscale\"\n    echo \"Or:\"\n    echo \"  pip install torch onnx tensorrt\"\n    exit 1\nfi\n\n# Create output directory with validation\nmkdir -p \"$MODEL_DIR\" || {\n    echo \"ERROR: Cannot create directory: $MODEL_DIR\" >&2\n    exit 1\n}\nif [ ! -w \"$MODEL_DIR\" ]; then\n    echo \"ERROR: No write permission for: $MODEL_DIR\" >&2\n    exit 1\nfi\n\n# Check disk space (engines are ~100-500MB each, need at least 2GB free)\nREQUIRED_SPACE_KB=$((2 * 1024 * 1024))  # 2GB in KB\nAVAILABLE_KB=$(df \"$MODEL_DIR\" 2>/dev/null | tail -1 | awk '{print $4}')\nif [ -n \"$AVAILABLE_KB\" ] && [ \"$AVAILABLE_KB\" -lt \"$REQUIRED_SPACE_KB\" ] 2>/dev/null; then\n    echo \"WARNING: Low disk space in $MODEL_DIR ($(( AVAILABLE_KB / 1024 ))MB available, recommend 2GB+)\" >&2\nfi\n\n# Input resolutions to build engines for (output can be downscaled as needed)\nRESOLUTIONS=\"480 720 1080\"\n\n# Sanitize model name for safe filename (remove any path separators)\n# Done once before the loop since MODEL doesn't change during iteration\nSAFE_MODEL=\"${MODEL//\\//_}\"\nSAFE_MODEL=\"${SAFE_MODEL//\\\\/_}\"\n\n# Build engines for common resolutions (FFmpeg TensorRT backend needs fixed shapes)\necho \"Building TensorRT engines for resolutions: $RESOLUTIONS\"\necho \"\"\n\n# Use word splitting intentionally here (RESOLUTIONS is space-separated)\n# shellcheck disable=SC2086\nfor res in $RESOLUTIONS; do\n    engine=\"$MODEL_DIR/${SAFE_MODEL}_${res}p_${PRECISION}.engine\"\n\n    if [ -f \"$engine\" ]; then\n        echo \"  ${res}p: already exists, skipping\"\n    else\n        echo \"  ${res}p: building...\"\n        # Capture output to show errors if build fails\n        if ! OUTPUT=$(run_python \"$EXPORT_SCRIPT\" \\\n            --model \"$MODEL\" \\\n            --precision \"$PRECISION\" \\\n            --min-height \"$res\" --opt-height \"$res\" --max-height \"$res\" \\\n            -o \"$engine\" 2>&1); then\n            echo \"ERROR building ${res}p engine:\" >&2\n            echo \"$OUTPUT\" >&2\n            exit 1\n        fi\n        # Show filtered progress on success\n        echo \"$OUTPUT\" | grep -E \"^(Downloading|Using cached|Loading|Using ONNX|Engine saved|  )\" || true\n        # Verify engine was created\n        if [ ! -f \"$engine\" ]; then\n            echo \"ERROR: Engine file not created: $engine\" >&2\n            echo \"Build output:\" >&2\n            echo \"$OUTPUT\" >&2\n            exit 1\n        fi\n    fi\ndone\n\necho \"\"\necho \"========================================\"\necho \"Installation complete!\"\necho \"========================================\"\necho \"\"\necho \"Engines built:\"\n# Safe listing of engine files (handles filenames with special chars)\nfind \"$MODEL_DIR\" -maxdepth 1 -name \"${SAFE_MODEL}_*.engine\" -type f -exec ls -lh {} \\; 2>/dev/null | \\\n    while IFS= read -r line; do\n        size=$(echo \"$line\" | awk '{print $5}')\n        file=$(echo \"$line\" | awk '{print $NF}')\n        echo \"  $(basename \"$file\") ($size)\"\n    done\necho \"\"\necho \"To use a different model, run:\"\necho \"  MODEL=2x-liveaction-span $0\"\necho \"  MODEL=4x-compact $0\"\necho \"\"\necho \"Test with:\"\necho \"  ffmpeg -init_hw_device cuda=cu -filter_hw_device cu \\\\\"\necho \"    -f lavfi -i testsrc=duration=3:size=1920x1080:rate=30 \\\\\"\necho \"    -vf \\\"format=rgb24,hwupload,dnn_processing=dnn_backend=8:model=$MODEL_DIR/${SAFE_MODEL}_1080p_${PRECISION}.engine\\\" \\\\\"\necho \"    -c:v hevc_nvenc test.mp4\"\n"
  },
  {
    "path": "tools/install-ffmpeg.sh",
    "content": "#!/bin/bash\n# Build ffmpeg from source with hardware acceleration support\n# Supports: NVIDIA NVENC, AMD AMF, Intel QSV/VAAPI, LibTorch DNN, TensorRT DNN\n# https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu\n#\n# Usage Examples:\n#\n#   # Default build (TensorRT enabled, requires compute_70+ GPU)\n#   ./install-ffmpeg.sh\n#\n#   # Maxwell GPU (compute_52, e.g. GTX 900/TITAN X) - use LibTorch 2.5 instead of TensorRT\n#   ENABLE_LIBTORCH=1 ENABLE_TENSORRT=0 LIBTORCH_VERSION=2.5.0 LIBTORCH_VARIANT=cu121 ./install-ffmpeg.sh\n#\n#   # CPU-only DNN inference (no GPU required)\n#   ENABLE_LIBTORCH=1 ENABLE_TENSORRT=0 LIBTORCH_VARIANT=cpu ./install-ffmpeg.sh\n#\n#   # Specific CUDA version\n#   CUDA_VERSION=12.4 ./install-ffmpeg.sh\n#\n#   # Skip dependency installation (already installed)\n#   SKIP_DEPS=1 ./install-ffmpeg.sh\n#\n#   # Both LibTorch and TensorRT (for testing/comparison)\n#   ENABLE_LIBTORCH=1 ENABLE_TENSORRT=1 ./install-ffmpeg.sh\n#\nset -e\n\n# =============================================================================\n# Potentially Viable Pre-built FFmpeg alternatives\n#\n# DOCKER IMAGES\n#   LinuxServer docker-ffmpeg    https://github.com/linuxserver/docker-ffmpeg\n#     - Full hardware accel (NVENC, VAAPI, QSV)\n#     - Comprehensive codec support, builds libva 2.23+ from source\n#     - Used by Dispatcharr, basis for comparison with this script\n#\n# STATIC BINARIES (Linux)\n#   BtbN/FFmpeg-Builds           https://github.com/BtbN/FFmpeg-Builds\n#     - Daily automated builds from git master and release branches\n#     - GPL/LGPL/nonfree variants, static and shared options\n#     - Targets glibc 2.28+ (RHEL 8 / Ubuntu 20.04+)\n#     - CUDA support: sm_52+ (Maxwell and newer)\n#\n#   John Van Sickle              https://johnvansickle.com/ffmpeg/\n#     - Static builds for amd64, i686, armhf, arm64\n#     - GPL v3 licensed, targets kernel 3.2.0+\n#     - Note: static glibc = no DNS resolution (install nscd to fix)\n#\n# STATIC BINARIES (Windows)\n#   gyan.dev                     https://www.gyan.dev/ffmpeg/builds/\n#     - Essentials build: common codecs (Win 7+)\n#     - Full build: all codecs including bluray, opencl (Win 10+)\n#     - Official FFmpeg download page recommendation\n#\n# SPECIALIZED BUILDS\n#   Jellyfin-ffmpeg              https://github.com/jellyfin/jellyfin-ffmpeg\n#     - Modified FFmpeg with Jellyfin-specific patches\n#     - Optimized for media server transcoding\n#     - Ships with Jellyfin packages and Docker images\n#     - Recommended only for Jellyfin; other apps should use standard builds\n#\n# =============================================================================\n\n# =============================================================================\n# FFmpeg library reference (checked 2026-01)\n#\n# Priority: high    = essential for most workflows\n#           med     = useful for specific workflows\n#           low     = niche use cases\n#           subsumed= functionality covered by another library we use\n#           legacy  = outdated, superseded by newer codecs\n#\n# Enable: src = built from source, apt = use apt package, - = not enabled\n#\n#   Library          | Build  | Pri      | Apt Ver | Latest  | Description\n#   -----------------|--------|----------|---------|---------|---------------------------\n#   VIDEO CODECS\n#   libx264          | src    | high     | 0.164   | 0.165   | H.264/AVC encoder (8/10-bit)\n#   libx265          | src    | high     | 3.5     | 4.1     | H.265/HEVC encoder (8/10/12-bit)\n#   libsvtav1        | src    | high     | 1.7.0   | 3.0.2   | AV1 encoder (fast, scalable)\n#   libaom           | src    | high     | 3.8.2   | 3.13.1  | AV1 reference encoder/decoder\n#   libdav1d         | src    | high     | 1.4.1   | 1.5.3   | AV1 decoder (fastest)\n#   libvpx           | apt    | high     | 1.14.0  | 1.14.1  | VP8/VP9 encoder/decoder\n#   libvvenc         | -      | low      | -       | 1.13.1  | H.266/VVC encoder (too early)\n#   librav1e         | -      | subsumed | 0.7.1   | 0.8.1   | AV1 encoder - svtav1 faster\n#   libkvazaar       | -      | subsumed | 2.3.1   | 2.3.2   | HEVC encoder - x265 better\n#   libopenh264      | -      | subsumed | 2.6.0   | 2.6.0   | H.264 (Cisco) - x264 better\n#   libxvid          | -      | legacy   | 1.3.7   | 1.3.7   | MPEG-4 Part 2 (obsolete)\n#   libtheora        | -      | legacy   | 1.2.0a1 | 1.2.0   | Theora codec (obsolete)\n#\n#   IMAGE CODECS\n#   libwebp          | src    | high     | 1.3.2   | 1.6.0   | WebP image codec\n#   libjxl           | src    | high     | 0.7.0   | 0.11.1  | JPEG XL (next-gen, HDR)\n#   libopenjpeg      | -      | low      | 2.5.0   | 2.5.4   | JPEG 2000 (cinema/medical)\n#   librsvg          | -      | low      | 2.58.0  | 2.61.3  | SVG rasterization\n#   libsnappy        | -      | low      | 1.1.10  | 1.2.2   | Snappy compression (HAP codec)\n#\n#   AUDIO CODECS\n#   libfdk-aac       | apt    | high     | 2.0.2   | 2.0.3   | AAC encoder (best quality)\n#   libmp3lame       | apt    | high     | 3.100   | 3.100   | MP3 encoder\n#   libopus          | apt    | high     | 1.5.2   | 1.6     | Opus encoder/decoder\n#   libvorbis        | apt    | high     | 1.3.7   | 1.3.7   | Vorbis encoder/decoder\n#   librubberband    | apt    | med      | 3.3.0   | 4.0.0   | Audio time-stretch/pitch-shift\n#   liblc3           | -      | low      | 1.1.3   | 1.1.3   | LC3 Bluetooth audio codec\n#   libopencore-amr  | -      | legacy   | 0.1.6   | 0.1.6   | AMR-NB/WB (old mobile audio)\n#\n#   SUBTITLE/TEXT\n#   libass           | apt    | high     | 0.17.3  | 0.17.4  | ASS/SSA subtitle renderer\n#   libfreetype      | apt    | high     | 2.13.3  | 2.14.1  | Font rendering\n#   libfontconfig    | apt    | high     | 2.15.0  | 2.17.0  | Font configuration\n#   libfribidi       | apt    | med      | 1.0.16  | 1.0.16  | BiDi text (RTL languages)\n#   libharfbuzz      | apt    | med      | 10.2.0  | 12.3.0  | Complex text shaping\n#\n#   FILTERS/PROCESSING\n#   libzimg          | apt    | high     | 3.0.5   | 3.0.6   | High-quality image scaling\n#   libsoxr          | apt    | high     | 0.1.3   | 0.1.3   | High-quality audio resampling\n#   libvmaf          | src    | med      | 2.3.1   | 3.0.0   | Video quality metrics\n#   libplacebo       | src    | med      | 7.349.0 | 7.351.0 | GPU HDR tone mapping\n#   libshaderc       | src*   | med      | -       | -       | GLSL->SPIRV compiler (*via Vulkan SDK)\n#   libvidstab       | apt    | med      | 1.1.0   | 1.1.1   | Video stabilization\n#   libmysofa        | -      | low      | 1.3.3   | 1.3.3   | HRTF spatial audio (sofalizer)\n#   libtesseract     | -      | low      | 5.5.0   | 5.5.1   | OCR text extraction\n#   opencl           | apt    | low      | 2.3.3   | -       | GPU compute filters\n#\n#   HARDWARE ACCEL\n#   libva            | src    | high     | 2.20.0  | 2.23.0  | VA-API (Intel/AMD) - Xe support\n#   libvpl           | src    | high     | 2023.3  | 2.16.0  | Intel QuickSync Video\n#   cuda-nvcc        | src    | high     | -       | -       | NVIDIA CUDA compiler\n#   nvenc            | src    | high     | -       | -       | NVIDIA hardware encoder\n#   cuvid            | src    | high     | -       | -       | NVIDIA hardware decoder\n#   vaapi            | src    | high     | -       | -       | VA-API hwaccel\n#   nvdec            | src    | med      | -       | -       | NVIDIA hwaccel decode API\n#   vulkan           | src    | med      | -       | -       | Vulkan GPU compute\n#   cuda-llvm        | -      | subsumed | -       | -       | CUDA via clang - we use nvcc\n#   vdpau            | -      | legacy   | 1.5     | 1.5     | NVIDIA VDPAU (use nvdec)\n#\n#   PROTOCOLS/NETWORK\n#   openssl          | apt    | high     | 3.0.13  | 3.0.15  | TLS/HTTPS support\n#   libsrt           | apt    | high     | 1.5.3   | 1.5.4   | SRT streaming protocol\n#   libssh           | -      | low      | 0.10.6  | 0.11.1  | SFTP protocol\n#   librist          | -      | low      | 0.2.11  | 0.2.11  | RIST broadcast protocol\n#   libzmq           | -      | low      | 4.3.5   | 4.3.5   | ZeroMQ IPC messaging\n#   libxml2          | -      | low      | 2.9.14  | 2.13.5  | XML/DASH manifest parsing\n#\n#   INPUT/OUTPUT\n#   libbluray        | apt    | med      | 1.3.4   | 1.4.0   | Blu-ray disc reading\n#   libv4l2          | -      | low      | 1.28.1  | 1.28.1  | V4L2 webcam/capture\n#   alsa             | -      | low      | 1.2.14  | 1.2.14  | Linux ALSA audio input\n#\n#   META FLAGS\n#   gpl              | yes    | high     | -       | -       | Enable GPL-licensed code\n#   version3         | yes    | high     | -       | -       | Enable (L)GPL v3 code\n#   nonfree          | yes    | high     | -       | -       | Enable non-free code (fdk-aac)\n#\n# =============================================================================\n\n# Hardware acceleration (set to 1 to enable)\nENABLE_NVIDIA_CUDA=${ENABLE_NVIDIA_CUDA:-1}  # NVENC/NVDEC hardware encoding/decoding\nENABLE_AMD_AMF=${ENABLE_AMD_AMF:-1}          # AMD AMF hardware encoding (requires AMD GPU)\nENABLE_LIBTORCH=${ENABLE_LIBTORCH:-0}        # LibTorch DNN backend for AI filters (default off, prefer TensorRT)\nENABLE_TENSORRT=${ENABLE_TENSORRT:-1}        # TensorRT DNN backend for AI filters (fastest)\n\n# LibTorch CUDA variant (only used if ENABLE_LIBTORCH=1)\n# LIBTORCH_VARIANT options:\n#   \"cu126\"   - (default) CUDA 12.6 - compatible with LibTorch 2.7+\n#               Note: cu126 binaries work on CUDA 12.6+ runtimes (forward compatible)\n#   \"auto\"    - auto-detect from CUDA_VERSION, rounding minor to nearest even\n#               (PyTorch only releases cu126, cu128, cu130 - even minor versions)\n#               Examples: CUDA 12.9 -> cu128, CUDA 12.7 -> cu126, CUDA 13.x -> cu130\n#   \"cpu\"     - CPU-only (no GPU acceleration for DNN filters)\n#   \"cu124\"   - CUDA 12.4 (for older LibTorch 2.5.x)\n#   \"cu126\"   - force CUDA 12.6\n#   \"cu128\"   - force CUDA 12.8\n#   \"cu130\"   - force CUDA 13.0\n#   \"rocm6.4\" - AMD ROCm 6.4 (requires ROCm installed on host)\nLIBTORCH_VARIANT=${LIBTORCH_VARIANT:-cu126}\n\n# Optional build components (set to 0 to use apt package instead)\nBUILD_LIBPLACEBO=${BUILD_LIBPLACEBO:-1}  # GPU HDR tone mapping (requires Vulkan SDK)\nBUILD_LIBX265=${BUILD_LIBX265:-1}        # H.265/HEVC encoder (apt: 3.5, latest: 4.1)\nBUILD_LIBAOM=${BUILD_LIBAOM:-1}          # AV1 reference codec (apt: 3.8, latest: 3.13)\nBUILD_LIBWEBP=${BUILD_LIBWEBP:-1}        # WebP image codec (apt: 1.3, latest: 1.6)\nBUILD_LIBVPL=${BUILD_LIBVPL:-1}          # Intel QuickSync (apt: 2023.3, latest: 2.16)\nBUILD_LIBDAV1D=${BUILD_LIBDAV1D:-1}      # AV1 decoder (apt: 1.4.1, latest: 1.5.0)\nBUILD_LIBSVTAV1=${BUILD_LIBSVTAV1:-1}    # AV1 encoder (apt: 1.7.0, latest: 3.0.0)\nBUILD_LIBVMAF=${BUILD_LIBVMAF:-1}        # Video quality metrics (apt: 2.3.1, latest: 3.0.0)\nBUILD_LIBVA=${BUILD_LIBVA:-1}            # VA-API (apt: 2.20.0, latest: 2.23.0 - Xe support)\nBUILD_LIBJXL=${BUILD_LIBJXL:-1}          # JPEG XL (apt: 0.7.0, latest: 0.11.1)\nBUILD_LIBX264=${BUILD_LIBX264:-1}        # H.264 encoder (apt: 8-bit only, src: 8/10-bit)\nSVTAV1_GIT_REF=${SVTAV1_GIT_REF:-}\n\n# FFmpeg version: \"snapshot\" for latest git, or specific version like \"7.1\"\nFFMPEG_VERSION=${FFMPEG_VERSION:-snapshot}\n\n# Skip apt dependency installation (use if deps already installed, avoids sudo)\nSKIP_DEPS=${SKIP_DEPS:-0}\nPHASE=${PHASE:-all}\n\n# Noninteractive apt installs (prevents prompts)\nexport DEBIAN_FRONTEND=\"${DEBIAN_FRONTEND:-noninteractive}\"\n\n# Capture script directory before any cd commands (with error handling)\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\" || {\n    echo \"ERROR: Failed to determine script directory\" >&2\n    exit 1\n}\n\n# Validate SCRIPT_DIR is not empty and exists\nif [ -z \"$SCRIPT_DIR\" ] || [ ! -d \"$SCRIPT_DIR\" ]; then\n    echo \"ERROR: Invalid script directory: $SCRIPT_DIR\" >&2\n    exit 1\nfi\n\n# =============================================================================\n# Helper Functions\n# =============================================================================\n\n# Log error message to stderr\nlog_error() {\n    echo \"ERROR: $*\" >&2\n}\n\n# Log warning message to stderr\nlog_warn() {\n    echo \"WARNING: $*\" >&2\n}\n\n# Log info message\nlog_info() {\n    echo \"INFO: $*\"\n}\n\n# Verify a patch was applied by checking for expected content\nverify_patch() {\n    local file=\"$1\"\n    local pattern=\"$2\"\n    local description=\"$3\"\n    if ! grep -q \"$pattern\" \"$file\"; then\n        log_error \"Patch verification failed: $description\"\n        log_error \"Expected pattern '$pattern' not found in $file\"\n        return 1\n    fi\n    return 0\n}\n\n# Clone a git repo with validation\ngit_clone_validated() {\n    local url=\"$1\"\n    local dir=\"$2\"\n    local depth=\"${3:-1}\"\n    local ref=\"${4:-}\"\n\n    if [ -n \"$ref\" ]; then\n        git clone --depth \"$depth\" --branch \"$ref\" \"$url\" \"$dir\"\n    else\n        git clone --depth \"$depth\" \"$url\" \"$dir\"\n    fi\n\n    # Validate clone succeeded\n    if [ ! -d \"$dir/.git\" ]; then\n        log_error \"Git clone failed: $url -> $dir\"\n        return 1\n    fi\n    return 0\n}\n\n# Update or clone a git repo\ngit_update_or_clone() {\n    local url=\"$1\"\n    local dir=\"$2\"\n    local depth=\"${3:-1}\"\n    local ref=\"${4:-}\"\n\n    if [ -d \"$dir/.git\" ]; then\n        # Check for conflicts or errors during pull\n        local pull_output\n        if ! pull_output=$(git -C \"$dir\" pull 2>&1); then\n            log_warn \"Git pull failed for $dir: $pull_output\"\n            log_warn \"Re-cloning...\"\n            safe_rm_rf \"$dir\" 2 || rm -rf \"$dir\"\n            git_clone_validated \"$url\" \"$dir\" \"$depth\" \"$ref\"\n        elif echo \"$pull_output\" | grep -qE \"CONFLICT|fatal\"; then\n            log_warn \"Git pull had conflicts for $dir, re-cloning...\"\n            safe_rm_rf \"$dir\" 2 || rm -rf \"$dir\"\n            git_clone_validated \"$url\" \"$dir\" \"$depth\" \"$ref\"\n        fi\n    else\n        # Remove if exists but not a git repo\n        if [ -e \"$dir\" ]; then\n            safe_rm_rf \"$dir\" 2 || rm -rf \"$dir\"\n        fi\n        git_clone_validated \"$url\" \"$dir\" \"$depth\" \"$ref\"\n    fi\n}\n\n# Detect Ubuntu version with validation\nget_ubuntu_version() {\n    local version_id\n    if [ ! -f /etc/os-release ]; then\n        log_error \"/etc/os-release not found - cannot detect Ubuntu version\"\n        return 1\n    fi\n    version_id=$(grep \"^VERSION_ID=\" /etc/os-release | cut -d'\"' -f2)\n    if [ -z \"$version_id\" ]; then\n        log_error \"Could not parse VERSION_ID from /etc/os-release\"\n        return 1\n    fi\n    # Remove dots: \"24.04\" -> \"2404\"\n    echo \"$version_id\" | tr -d '.'\n}\n\n# Safe rm -rf: validates path before deletion to prevent catastrophic mistakes\nsafe_rm_rf() {\n    local path=\"$1\"\n    local min_depth=\"${2:-2}\"  # Minimum path depth (default: 2 components)\n\n    # Never delete empty paths\n    if [ -z \"$path\" ]; then\n        log_error \"safe_rm_rf: empty path\"\n        return 1\n    fi\n\n    # Never delete root or single-level paths\n    local depth\n    depth=$(echo \"$path\" | tr -cd '/' | wc -c)\n    if [ \"$depth\" -lt \"$min_depth\" ]; then\n        log_error \"safe_rm_rf: path '$path' too shallow (depth $depth < $min_depth)\"\n        return 1\n    fi\n\n    # Never delete common dangerous paths\n    case \"$path\" in\n        /|/bin|/boot|/dev|/etc|/home|/lib|/lib64|/media|/mnt|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)\n            log_error \"safe_rm_rf: refusing to delete system path: $path\"\n            return 1\n            ;;\n    esac\n\n    # Path must exist to delete\n    if [ ! -e \"$path\" ]; then\n        return 0  # Nothing to delete\n    fi\n\n    rm -rf \"$path\"\n}\n\n# Validate CUDA version format (e.g., \"12.4\", \"13.0\")\nvalidate_cuda_version() {\n    local version=\"$1\"\n    if ! [[ \"$version\" =~ ^[0-9]+\\.[0-9]+$ ]]; then\n        log_error \"Invalid CUDA version format: '$version' (expected X.Y)\"\n        return 1\n    fi\n    return 0\n}\n\n# Validate torch variant format (e.g., \"cu124\", \"cu130\", \"cpu\")\nvalidate_torch_variant() {\n    local variant=\"$1\"\n    if ! [[ \"$variant\" =~ ^(cu[0-9]+|rocm[0-9]+\\.[0-9]+|cpu)$ ]]; then\n        log_error \"Invalid TORCH_VARIANT: '$variant' (expected cuXXX, rocmX.Y, or cpu)\"\n        return 1\n    fi\n    return 0\n}\n\n# Verify SHA256 checksum of a file\nverify_sha256() {\n    local file=\"$1\"\n    local expected=\"$2\"\n\n    if [ ! -f \"$file\" ]; then\n        log_error \"File not found for checksum verification: $file\"\n        return 1\n    fi\n\n    local actual\n    actual=$(sha256sum \"$file\" | awk '{print $1}')\n\n    if [ \"$actual\" != \"$expected\" ]; then\n        log_error \"SHA256 checksum mismatch for $file\"\n        log_error \"  Expected: $expected\"\n        log_error \"  Actual:   $actual\"\n        return 1\n    fi\n\n    log_info \"SHA256 verified: $file\"\n    return 0\n}\n\n# Download file with optional checksum verification\ndownload_file() {\n    local url=\"$1\"\n    local output=\"$2\"\n    local sha256=\"${3:-}\"  # Optional checksum\n\n    log_info \"Downloading: $url\"\n    if ! wget -q -O \"$output\" \"$url\"; then\n        log_error \"Failed to download: $url\"\n        return 1\n    fi\n\n    if [ -n \"$sha256\" ]; then\n        if ! verify_sha256 \"$output\" \"$sha256\"; then\n            rm -f \"$output\"\n            return 1\n        fi\n    fi\n\n    return 0\n}\n\nversion_ge() {\n    [ \"$(printf '%s\\n' \"$2\" \"$1\" | sort -V | head -n1)\" = \"$2\" ]\n}\n\nensure_meson_min_version() {\n    local min_version=\"$1\"\n    local current_version\n\n    current_version=$(meson --version 2>/dev/null || echo 0)\n    if ! version_ge \"$current_version\" \"$min_version\"; then\n        echo \"Meson $current_version < $min_version, upgrading via pip...\"\n        sudo apt-get update\n        sudo apt-get install -y python3-pip\n        if pip3 install --help 2>/dev/null | grep -q -- '--break-system-packages'; then\n            sudo -E pip3 install --upgrade --break-system-packages meson\n        else\n            sudo -E pip3 install --upgrade meson\n        fi\n        export PATH=\"/usr/local/bin:$PATH\"\n    fi\n}\n\n# NVIDIA CUDA setup (only used if ENABLE_NVIDIA_CUDA=1)\n# CUDA_VERSION options:\n#   \"auto\"    - (default) use installed CUDA if available, else install latest\n#   \"12.8\"    - explicit version (e.g., 12.4, 12.6, 13.0) - dots converted to dashes internally\nCUDA_VERSION=${CUDA_VERSION:-auto}\n# Validate CUDA_VERSION format before normalization\nif [ \"$CUDA_VERSION\" != \"auto\" ]; then\n    if ! validate_cuda_version \"$CUDA_VERSION\"; then\n        log_error \"Set CUDA_VERSION to 'auto' or a valid version like '12.8' or '13.0'\"\n        exit 1\n    fi\nfi\n# Normalize: convert \"12.4\" to \"12-4\" for apt package names\nCUDA_VERSION=\"${CUDA_VERSION//./-}\"\n# NVCC_GENCODE options:\n#   \"native\"  - (default) compile for build machine's GPU via nvidia-smi\n#   \"minimum\" - lowest arch for CUDA version (sm_52 for <13, sm_75 for 13+)\n#   \"75\"      - explicit single arch (e.g., 75, 86, 89)\nNVCC_GENCODE=${NVCC_GENCODE:-native}\n\n# Build paths\nSRC_DIR=\"${SRC_DIR:-$HOME/ffmpeg_sources}\"      # Source code cache (can be deleted after build)\nBUILD_DIR=\"${BUILD_DIR:-$HOME/ffmpeg_build}\"    # Build artifacts cache (can be deleted after build)\nBIN_DIR=\"${BIN_DIR:-$HOME/.local/bin}\"          # Final binary install location\nLIB_DIR=\"${LIB_DIR:-$HOME/.local/lib}\"          # Final shared library install location (for libva)\n\n# Get number of processors with fallback and validation\nNPROC=$(nproc 2>/dev/null || echo 4)\nif ! [[ \"$NPROC\" =~ ^[0-9]+$ ]] || [ \"$NPROC\" -lt 1 ] || [ \"$NPROC\" -gt 256 ]; then\n    log_warn \"Invalid NPROC value '$NPROC', using 4\"\n    NPROC=4\nfi\n\n# Ensure HOME is set (for cron/systemd contexts)\nif [ -z \"$HOME\" ]; then\n    HOME=$(getent passwd \"$(id -un)\" 2>/dev/null | cut -d: -f6) || true\n    if [ -z \"$HOME\" ] || [ ! -d \"$HOME\" ]; then\n        log_error \"HOME environment variable not set and could not be detected\"\n        exit 1\n    fi\n    export HOME\nfi\n\n# Create build directories (with validation)\nmkdir -p \"$SRC_DIR\" \"$BUILD_DIR\" \"$BIN_DIR\" \"$LIB_DIR\" || {\n    log_error \"Failed to create build directories\"\n    exit 1\n}\n\n# Note: libplacebo pin was previously needed for jammy because older versions\n# lacked dependencies. Now using latest which is compatible with FFmpeg snapshot API.\n\n# Note: SVT-AV1 pin was previously needed for FFmpeg 7.0 API compatibility on jammy.\n# With FFmpeg snapshot, we use latest SVT-AV1 (no pin needed).\n\n# Base packages (installed first, includes wget needed for CUDA repo setup)\nAPT_PACKAGES=(\n    autoconf\n    automake\n    build-essential\n    cmake\n    doxygen\n    git\n    meson\n    nasm\n    ninja-build\n    pkg-config\n    texinfo\n    unzip\n    wget\n    xxd\n    yasm\n    libass-dev\n    libbluray-dev\n    libfdk-aac-dev\n    libfontconfig1-dev\n    libfreetype6-dev\n    libfribidi-dev\n    libharfbuzz-dev\n    libsoxr-dev\n    libsrt-openssl-dev\n    libssl-dev\n    libzstd-dev\n    libzimg-dev\n    liblzma-dev\n    liblzo2-dev\n    libmp3lame-dev\n    libnuma-dev\n    ocl-icd-opencl-dev\n    libopus-dev\n    librubberband-dev\n    libsdl2-dev\n    libtool\n    python3-jinja2\n    libunistring-dev\n    libvdpau-dev\n    libvidstab-dev\n    libdrm-dev\n    libx11-dev\n    libvorbis-dev\n    libvpx-dev\n    libxcb-shm0-dev\n    libxcb-xfixes0-dev\n    libxcb1-dev\n    zlib1g-dev\n    # Intel oneVPL/QSV runtime (needed for Intel GPU hardware encoding)\n    libmfx-gen1.2\n)\n# Add apt packages for libraries we're not building from source\n[ \"$BUILD_LIBX265\" != \"1\" ] && APT_PACKAGES+=(libx265-dev)\n[ \"$BUILD_LIBAOM\" != \"1\" ] && APT_PACKAGES+=(libaom-dev)\n[ \"$BUILD_LIBWEBP\" != \"1\" ] && APT_PACKAGES+=(libwebp-dev)\n[ \"$BUILD_LIBVPL\" != \"1\" ] && APT_PACKAGES+=(libvpl-dev)\n[ \"$BUILD_LIBDAV1D\" != \"1\" ] && APT_PACKAGES+=(libdav1d-dev)\n[ \"$BUILD_LIBSVTAV1\" != \"1\" ] && APT_PACKAGES+=(libsvtav1enc-dev)\n[ \"$BUILD_LIBVMAF\" != \"1\" ] && APT_PACKAGES+=(libvmaf-dev)\n[ \"$BUILD_LIBVA\" != \"1\" ] && APT_PACKAGES+=(libva-dev)\n[ \"$BUILD_LIBJXL\" != \"1\" ] && APT_PACKAGES+=(libjxl-dev)\n[ \"$BUILD_LIBX264\" != \"1\" ] && APT_PACKAGES+=(libx264-dev)\n# Note: TensorRT headers (libnvinfer-headers-dev) installed later after CUDA repo is set up\nif [ \"$SKIP_DEPS\" != \"1\" ]; then\n    sudo apt-get update\n    sudo apt-get install -y \"${APT_PACKAGES[@]}\"\n    ensure_meson_min_version 0.63\nfi\n\n\nCUDA_FLAGS=()\nNVCC_ARCH=\"\"\n\nif [ \"$ENABLE_NVIDIA_CUDA\" = \"1\" ]; then\n    # Check if CUDA is already installed\n    if [ \"$CUDA_VERSION\" = \"auto\" ]; then\n        if command -v nvcc &> /dev/null; then\n            # Extract version from nvcc (e.g., \"12.9\" -> \"12-9\")\n            NVCC_VERSION=$(nvcc --version | grep -oP 'release \\K[0-9]+\\.[0-9]+')\n            CUDA_VERSION=$(echo \"$NVCC_VERSION\" | tr '.' '-')\n            echo \"Detected installed CUDA $NVCC_VERSION (using version $CUDA_VERSION)\"\n        else\n            echo \"No CUDA installed, will install latest from NVIDIA repo\"\n        fi\n    fi\n\n    # Add CUDA repo if not present or if we need to install\n    if [ \"$SKIP_DEPS\" != \"1\" ]; then\n        if [ \"$CUDA_VERSION\" = \"auto\" ] || ! command -v nvcc &> /dev/null; then\n            if ! dpkg -l cuda-keyring 2>/dev/null | grep -q ^ii; then\n                # Detect Ubuntu version for correct CUDA repo (24.04 -> ubuntu2404, 25.04 -> ubuntu2504)\n                UBUNTU_VERSION=$(get_ubuntu_version) || exit 1\n                CUDA_REPO_URL=\"https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/x86_64/cuda-keyring_1.1-1_all.deb\"\n                if ! wget --progress=dot:giga \"$CUDA_REPO_URL\" -O cuda-keyring.deb; then\n                    log_error \"Failed to download CUDA keyring from $CUDA_REPO_URL\"\n                    exit 1\n                fi\n                sudo dpkg -i cuda-keyring.deb\n                rm cuda-keyring.deb\n                sudo apt-get update\n            fi\n\n            # Get latest CUDA version if still auto\n            if [ \"$CUDA_VERSION\" = \"auto\" ]; then\n                CUDA_VERSION=$(apt-cache search '^cuda-nvcc-[0-9]' | sed 's/cuda-nvcc-//' | cut -d' ' -f1 | sort -V | tail -1)\n                if [ -z \"$CUDA_VERSION\" ]; then\n                    echo \"Error: No CUDA packages found. Install CUDA repo first or set CUDA_VERSION manually.\" >&2\n                    exit 1\n                fi\n                echo \"Will install latest CUDA version: $CUDA_VERSION\"\n            fi\n        fi\n\n        # Install CUDA packages\n        sudo apt-get install -y libffmpeg-nvenc-dev cuda-nvcc-$CUDA_VERSION cuda-cudart-dev-$CUDA_VERSION\n\n        # Install TensorRT headers only (requires NVIDIA repo set up above)\n        # We only need headers for compilation - libnvinfer is loaded via dlopen at runtime\n        if [ \"$ENABLE_TENSORRT\" = \"1\" ]; then\n            sudo apt-get install -y libnvinfer-headers-dev\n        fi\n    fi\n    echo \"Using CUDA version: $CUDA_VERSION\"\n\n    # Detect CUDA installation path\n    CUDA_VERSION_DOT=$(echo \"$CUDA_VERSION\" | tr '-' '.')\n    if [ -d \"/usr/local/cuda\" ]; then\n        CUDA_PATH=\"/usr/local/cuda\"\n    elif [ -d \"/usr/local/cuda-${CUDA_VERSION_DOT}\" ]; then\n        CUDA_PATH=\"/usr/local/cuda-${CUDA_VERSION_DOT}\"\n    else\n        echo \"Warning: CUDA path not found, using /usr/local/cuda (headers may be missing)\" >&2\n        CUDA_PATH=\"/usr/local/cuda\"\n    fi\n    echo \"Using CUDA path: $CUDA_PATH\"\n\n    # Patch CUDA headers for glibc 2.42+ compatibility (Ubuntu 25.04+)\n    # glibc 2.42 added rsqrt/rsqrtf to mathcalls.h which conflicts with CUDA's definitions\n    # This causes \"exception specification is incompatible\" errors during nvcc compilation\n    if [ \"$SKIP_DEPS\" != \"1\" ]; then\n        CUDA_MATH_HEADER=\"$CUDA_PATH/targets/x86_64-linux/include/crt/math_functions.h\"\n        if [ -f \"$CUDA_MATH_HEADER\" ]; then\n            GLIBC_VERSION=$(ldd --version | head -1 | grep -oP '\\d+\\.\\d+$')\n            GLIBC_MAJOR=$(echo \"$GLIBC_VERSION\" | cut -d. -f1)\n            GLIBC_MINOR=$(echo \"$GLIBC_VERSION\" | cut -d. -f2)\n\n            # Only patch if glibc >= 2.42 and patch not already applied\n            if [ \"$GLIBC_MAJOR\" -gt 2 ] || ([ \"$GLIBC_MAJOR\" -eq 2 ] && [ \"$GLIBC_MINOR\" -ge 42 ]); then\n                # Check for our patch OR NVIDIA's fix (they use __NV_GLIBC_PROVIDES_IEC_60559_FUNCS for similar issues)\n                if grep -q \"rsqrt\" \"$CUDA_MATH_HEADER\" && \\\n                   ! grep -B2 \"double[[:space:]]*rsqrt(double\" \"$CUDA_MATH_HEADER\" | grep -q \"GLIBC\"; then\n                    echo \"Patching CUDA headers for glibc $GLIBC_VERSION compatibility...\"\n                    # Backup original if no backup exists\n                    [ ! -f \"${CUDA_MATH_HEADER}.bak\" ] && sudo cp \"$CUDA_MATH_HEADER\" \"${CUDA_MATH_HEADER}.bak\"\n                    # Add guards around rsqrt declaration (prevent conflict with glibc's rsqrt)\n                    sudo sed -i '/extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ double[[:space:]]*rsqrt(double/c\\\n#if !(defined(__GLIBC__) \\&\\& __GLIBC_USE_IEC_60559_FUNCS_EXT_C23)\\\nextern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ double                 rsqrt(double x);\\\n#endif' \"$CUDA_MATH_HEADER\"\n                    # Add guards around rsqrtf declaration\n                    sudo sed -i '/extern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ float[[:space:]]*rsqrtf(float/c\\\n#if !(defined(__GLIBC__) \\&\\& __GLIBC_USE_IEC_60559_FUNCS_EXT_C23)\\\nextern __DEVICE_FUNCTIONS_DECL__ __device_builtin__ float                  rsqrtf(float x);\\\n#endif' \"$CUDA_MATH_HEADER\"\n                    # Verify patch was applied\n                    if grep -B2 \"double[[:space:]]*rsqrt(double\" \"$CUDA_MATH_HEADER\" | grep -q \"GLIBC\"; then\n                        echo \"CUDA header patched successfully\"\n                    else\n                        echo \"Warning: CUDA header patch may have failed - rsqrt declaration not found\" >&2\n                        echo \"CUDA version may have different header format. Check $CUDA_MATH_HEADER\" >&2\n                    fi\n                else\n                    echo \"CUDA headers already patched for glibc compatibility\"\n                fi\n            fi\n        fi\n    fi\n\n    CUDA_FLAGS=(--enable-cuda-nvcc --enable-nvenc --enable-cuvid --enable-nvdec)\n\n    CUDA_MAJOR=\"${CUDA_VERSION%%-*}\"\n\n    if [ \"$NVCC_GENCODE\" = \"native\" ]; then\n        # Detect GPU compute capability\n        if ! command -v nvidia-smi &> /dev/null; then\n            log_warn \"nvidia-smi not found, falling back to minimum arch\"\n            NVCC_GENCODE=\"minimum\"\n        elif ! COMPUTE_CAP=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1); then\n            log_warn \"nvidia-smi failed to query GPU, falling back to minimum arch\"\n            NVCC_GENCODE=\"minimum\"\n        elif [ -z \"$COMPUTE_CAP\" ]; then\n            log_warn \"nvidia-smi found but no GPU detected, falling back to minimum\"\n            NVCC_GENCODE=\"minimum\"\n        else\n            COMPUTE_CAP_NUM=$(echo \"$COMPUTE_CAP\" | tr -d '.')\n            NVCC_ARCH=\"-arch=sm_${COMPUTE_CAP_NUM}\"\n            log_info \"CUDA $CUDA_VERSION NVCC_GENCODE=native -> $NVCC_ARCH (detected via nvidia-smi)\"\n        fi\n    fi\n\n    if [ \"$NVCC_GENCODE\" = \"minimum\" ]; then\n        if [ \"$CUDA_MAJOR\" -ge 13 ]; then\n            NVCC_ARCH=\"-arch=sm_75\"\n        else\n            NVCC_ARCH=\"-arch=sm_52\"\n        fi\n        echo \"CUDA $CUDA_VERSION NVCC_GENCODE=minimum -> $NVCC_ARCH\"\n    elif [ \"$NVCC_GENCODE\" != \"native\" ]; then\n        # Explicit arch number\n        NVCC_ARCH=\"-arch=sm_$NVCC_GENCODE\"\n        echo \"CUDA $CUDA_VERSION NVCC_GENCODE=$NVCC_GENCODE -> $NVCC_ARCH\"\n    fi\n\n    # Pin nv-codec-headers for specific builds:\n    # - netv-ffmpeg:cuda12.4 OR CUDA_VERSION=12.4 -> use NVENC API 12.2 headers (sdk/12.2)\n    # - otherwise                                 -> use upstream master\n    NV_CODEC_REF=\"master\"\n    if [ \"${FFMPEG_IMAGE:-}\" = \"netv-ffmpeg:cuda12.4\" ] || [ \"${CUDA_VERSION:-}\" = \"12-4\" ]; then\n        NV_CODEC_REF=\"sdk/12.2\"\n    fi\n    echo \"nv-codec-headers: FFMPEG_IMAGE=${FFMPEG_IMAGE:-unset}, CUDA_VERSION=${CUDA_VERSION:-unset}, ref=$NV_CODEC_REF\"\n\n    cd \"$SRC_DIR\"\n    if [ -d nv-codec-headers/.git ]; then\n        git -C nv-codec-headers fetch --depth 1 origin \"$NV_CODEC_REF\"\n        git -C nv-codec-headers checkout -f \"$NV_CODEC_REF\"\n    else\n        git clone --depth 1 --branch \"$NV_CODEC_REF\" https://git.videolan.org/git/ffmpeg/nv-codec-headers.git\n    fi\n    cd nv-codec-headers\n    make\n    make PREFIX=\"$BUILD_DIR\" install\nfi\n\n\n# AMD AMF setup (hardware encoding for AMD GPUs)\n# AMF is header-only at build time; runtime driver comes from host's AMD GPU driver\nAMF_FLAGS=()\nif [ \"$ENABLE_AMD_AMF\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://github.com/GPUOpen-LibrariesAndSDKs/AMF.git\" \"AMF\" 1\n    if [ ! -d \"AMF/amf/public/include\" ]; then\n        log_error \"AMF clone succeeded but include directory not found\"\n        exit 1\n    fi\n    mkdir -p \"$BUILD_DIR/include/AMF\"\n    # Use /. to copy directory contents without glob expansion issues\n    cp -r \"AMF/amf/public/include/.\" \"$BUILD_DIR/include/AMF/\"\n    AMF_FLAGS=(--enable-amf)\n    log_info \"AMF headers installed for AMD GPU encoding\"\nfi\n\nif [ \"$PHASE\" = \"deps\" ]; then\n    echo \"PHASE=deps set; skipping source builds.\"\n    exit 0\nfi\n\n\n# libx264 (H.264/AVC encoder)\n# Build with --bit-depth=all for 8-bit and 10-bit support\nif [ \"$BUILD_LIBX264\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://code.videolan.org/videolan/x264.git\" \"x264\" 1\n    cd x264\n    PATH=\"$BIN_DIR:$PATH\" ./configure --prefix=\"$BUILD_DIR\" --enable-static --enable-pic --disable-cli --bit-depth=all\n    PATH=\"$BIN_DIR:$PATH\" make -j \"$NPROC\"\n    make install\nfi\n\n\n# libx265 (H.265/HEVC encoder)\n# Multilib build: 8-bit + 10-bit + 12-bit support (required for HDR)\n# Build order: 12-bit → 10-bit → 8-bit (main links the others)\nif [ \"$BUILD_LIBX265\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://bitbucket.org/multicoreware/x265_git.git\" \"x265_git\" 1\n    cd x265_git/build/linux\n\n    # Clean previous builds\n    rm -rf 8bit 10bit 12bit\n    mkdir -p 8bit 10bit 12bit\n\n    # Build 12-bit\n    cd 12bit\n    PATH=\"$BIN_DIR:$PATH\" cmake -G \"Unix Makefiles\" \\\n        -DCMAKE_INSTALL_PREFIX=\"$BUILD_DIR\" \\\n        -DHIGH_BIT_DEPTH=ON \\\n        -DEXPORT_C_API=OFF \\\n        -DENABLE_SHARED=OFF \\\n        -DENABLE_CLI=OFF \\\n        -DMAIN12=ON \\\n        ../../../source\n    PATH=\"$BIN_DIR:$PATH\" make -j \"$NPROC\"\n\n    # Build 10-bit\n    cd ../10bit\n    PATH=\"$BIN_DIR:$PATH\" cmake -G \"Unix Makefiles\" \\\n        -DCMAKE_INSTALL_PREFIX=\"$BUILD_DIR\" \\\n        -DHIGH_BIT_DEPTH=ON \\\n        -DEXPORT_C_API=OFF \\\n        -DENABLE_SHARED=OFF \\\n        -DENABLE_CLI=OFF \\\n        ../../../source\n    PATH=\"$BIN_DIR:$PATH\" make -j \"$NPROC\"\n\n    # Build 8-bit (main) and link in 10-bit and 12-bit\n    cd ../8bit\n    ln -sf ../10bit/libx265.a libx265_main10.a\n    ln -sf ../12bit/libx265.a libx265_main12.a\n    PATH=\"$BIN_DIR:$PATH\" cmake -G \"Unix Makefiles\" \\\n        -DCMAKE_INSTALL_PREFIX=\"$BUILD_DIR\" \\\n        -DLIB_INSTALL_DIR=\"$BUILD_DIR/lib\" \\\n        -DENABLE_SHARED=OFF \\\n        -DENABLE_CLI=OFF \\\n        -DEXTRA_LIB=\"x265_main10.a;x265_main12.a\" \\\n        -DEXTRA_LINK_FLAGS=\"-L.\" \\\n        -DLINKED_10BIT=ON \\\n        -DLINKED_12BIT=ON \\\n        ../../../source\n    PATH=\"$BIN_DIR:$PATH\" make -j \"$NPROC\"\n    # Merge 8-bit, 10-bit, and 12-bit libraries into one (cmake doesn't do this automatically)\n    mv libx265.a libx265_main.a\n    mkdir -p merged/8bit merged/10bit merged/12bit\n    (cd merged/8bit && ar x ../../libx265_main.a)\n    (cd merged/10bit && ar x ../../libx265_main10.a)\n    (cd merged/12bit && ar x ../../libx265_main12.a)\n    ar crs libx265.a merged/*/*.o\n    rm -rf merged libx265_main.a\n    make install\n\n    # x265's cmake doesn't reliably install x265.pc, so we create it manually\n    # Extract version from x265.h (format: #define X265_BUILD 215)\n    X265_VERSION=$(grep '#define X265_BUILD' \"$BUILD_DIR/include/x265.h\" | awk '{print $3}')\n    mkdir -p \"$BUILD_DIR/lib/pkgconfig\"\n    cat > \"$BUILD_DIR/lib/pkgconfig/x265.pc\" << PCEOF\nprefix=$BUILD_DIR\nexec_prefix=\\${prefix}\nlibdir=\\${exec_prefix}/lib\nincludedir=\\${prefix}/include\n\nName: x265\nDescription: H.265/HEVC video encoder (8-bit + 10-bit + 12-bit)\nVersion: $X265_VERSION\nLibs: -L\\${libdir} -lx265\nLibs.private: -lstdc++ -lm -lrt -ldl -lnuma -lpthread\nCflags: -I\\${includedir}\nPCEOF\nfi\n\n# libaom (AV1 reference codec)\nif [ \"$BUILD_LIBAOM\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://aomedia.googlesource.com/aom\" \"aom\" 1\n    mkdir -p aom_build\n    cd aom_build\n    PATH=\"$BIN_DIR:$PATH\" cmake -G \"Unix Makefiles\" -DCMAKE_INSTALL_PREFIX=\"$BUILD_DIR\" -DENABLE_TESTS=OFF -DENABLE_NASM=on -DBUILD_SHARED_LIBS=OFF -DCONFIG_AV1_HIGHBITDEPTH=1 ../aom\n    PATH=\"$BIN_DIR:$PATH\" make -j \"$NPROC\"\n    make install\nfi\n\n# libwebp (WebP image codec)\nif [ \"$BUILD_LIBWEBP\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://chromium.googlesource.com/webm/libwebp\" \"libwebp\" 1\n    cd libwebp\n    ./autogen.sh\n    ./configure --prefix=\"$BUILD_DIR\" --disable-shared --enable-static\n    make -j \"$NPROC\"\n    make install\nfi\n\n# libjxl (JPEG XL image codec)\n# Ubuntu 24.04 ships 0.7.0 which is quite old; latest is 0.11.1 with HDR improvements\nif [ \"$BUILD_LIBJXL\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    # libjxl needs --recursive for submodules\n    if [ -d libjxl/.git ]; then\n        git -C libjxl pull\n        git -C libjxl submodule update --init --recursive\n    else\n        rm -rf libjxl\n        git clone --depth 1 --recursive https://github.com/libjxl/libjxl.git\n        if [ ! -d libjxl/.git ]; then\n            log_error \"libjxl clone failed\"\n            exit 1\n        fi\n    fi\n    cd libjxl\n    mkdir -p build\n    cd build\n    cmake -G \"Unix Makefiles\" -DCMAKE_INSTALL_PREFIX=\"$BUILD_DIR\" -DCMAKE_BUILD_TYPE=Release \\\n        -DBUILD_SHARED_LIBS=OFF -DJPEGXL_ENABLE_BENCHMARK=OFF -DJPEGXL_ENABLE_EXAMPLES=OFF \\\n        -DJPEGXL_ENABLE_MANPAGES=OFF -DJPEGXL_ENABLE_PLUGINS=OFF -DJPEGXL_ENABLE_VIEWERS=OFF \\\n        -DJPEGXL_ENABLE_TOOLS=OFF -DJPEGXL_ENABLE_DOXYGEN=OFF -DJPEGXL_ENABLE_JPEGLI=OFF ..\n    make -j \"$NPROC\"\n    make install\nfi\n\n# libvpl (Intel Video Processing Library / QuickSync)\nif [ \"$BUILD_LIBVPL\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://github.com/intel/libvpl.git\" \"libvpl\" 1\n    mkdir -p libvpl/build\n    cd libvpl/build\n    cmake -G \"Unix Makefiles\" -DCMAKE_INSTALL_PREFIX=\"$BUILD_DIR\" -DBUILD_SHARED_LIBS=OFF ..\n    make -j \"$NPROC\"\n    make install\nfi\n\n# libdav1d (AV1 decoder)\nif [ \"$BUILD_LIBDAV1D\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://code.videolan.org/videolan/dav1d.git\" \"dav1d\" 1\n    cd dav1d\n    if [ -f build/build.ninja ]; then\n        meson setup --reconfigure build --buildtype=release --default-library=static --prefix=\"$BUILD_DIR\" --libdir=\"$BUILD_DIR/lib\" || \\\n        meson setup --wipe build --buildtype=release --default-library=static --prefix=\"$BUILD_DIR\" --libdir=\"$BUILD_DIR/lib\"\n    else\n        meson setup build --buildtype=release --default-library=static --prefix=\"$BUILD_DIR\" --libdir=\"$BUILD_DIR/lib\"\n    fi\n    ninja -C build\n    ninja -C build install\nfi\n\n# libsvtav1 (AV1 encoder)\nif [ \"$BUILD_LIBSVTAV1\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://gitlab.com/AOMediaCodec/SVT-AV1.git\" \"SVT-AV1\" 1\n    mkdir -p SVT-AV1/build\n    cd SVT-AV1/build\n    if [ -n \"$SVTAV1_GIT_REF\" ]; then\n        git -C .. fetch --depth 1 origin \"$SVTAV1_GIT_REF\"\n        git -C .. checkout -q FETCH_HEAD\n    fi\n    PATH=\"$BIN_DIR:$PATH\" cmake -G \"Unix Makefiles\" -DCMAKE_INSTALL_PREFIX=\"$BUILD_DIR\" -DCMAKE_BUILD_TYPE=Release -DBUILD_DEC=OFF -DBUILD_SHARED_LIBS=OFF ..\n    PATH=\"$BIN_DIR:$PATH\" make -j \"$NPROC\"\n    make install\nfi\n\n# libvmaf (video quality metrics)\nif [ \"$BUILD_LIBVMAF\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://github.com/Netflix/vmaf\" \"vmaf\" 1\n    mkdir -p vmaf/libvmaf/build\n    cd vmaf/libvmaf/build\n    if [ -f build.ninja ]; then\n        meson setup --reconfigure -Denable_tests=false -Denable_docs=false --buildtype=release --default-library=static '../' --prefix \"$BUILD_DIR\" --bindir=\"$BIN_DIR\" --libdir=\"$BUILD_DIR/lib\" || \\\n        meson setup --wipe -Denable_tests=false -Denable_docs=false --buildtype=release --default-library=static '../' --prefix \"$BUILD_DIR\" --bindir=\"$BIN_DIR\" --libdir=\"$BUILD_DIR/lib\"\n    else\n        meson setup -Denable_tests=false -Denable_docs=false --buildtype=release --default-library=static '../' --prefix \"$BUILD_DIR\" --bindir=\"$BIN_DIR\" --libdir=\"$BUILD_DIR/lib\"\n    fi\n    ninja\n    ninja install\nfi\n\n# libva (VA-API)\n# Ubuntu 24.04 ships 2.20.0 which lacks Intel Xe kernel driver support (added in 2.21)\n# Build from source to get Xe support for newer Intel GPUs\nif [ \"$BUILD_LIBVA\" = \"1\" ]; then\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://github.com/intel/libva.git\" \"libva\" 1\n    cd libva\n    if [ -f build/build.ninja ]; then\n        meson setup --reconfigure build --buildtype=release --default-library=shared --prefix=\"$BUILD_DIR\" --libdir=\"$BUILD_DIR/lib\" || \\\n        meson setup --wipe build --buildtype=release --default-library=shared --prefix=\"$BUILD_DIR\" --libdir=\"$BUILD_DIR/lib\"\n    else\n        meson setup build --buildtype=release --default-library=shared --prefix=\"$BUILD_DIR\" --libdir=\"$BUILD_DIR/lib\"\n    fi\n    ninja -C build\n    ninja -C build install\n    # Copy shared libs to permanent location (LIB_DIR) for runtime\n    cp -a \"$BUILD_DIR/lib\"/libva*.so* \"$LIB_DIR/\"\nfi\n\nmeson_supports_prefer_static() {\n    meson setup --help 2>/dev/null | grep -q -- '--prefer-static'\n}\n\nbuild_libplacebo() {\n    local meson_args=(\n        --buildtype=release\n        --default-library=static\n        -Dvulkan=enabled\n        -Dvulkan-registry=\"$VULKAN_SDK/share/vulkan/registry/vk.xml\"\n        -Dopengl=disabled\n        -Dd3d11=disabled\n        -Ddemos=false\n        --prefix \"$BUILD_DIR\"\n        --libdir \"$BUILD_DIR/lib\"\n    )\n\n    if meson_supports_prefer_static; then\n        meson_args+=(--prefer-static)\n    fi\n\n    if [ -f build/build.ninja ]; then\n        meson setup --reconfigure \"${meson_args[@]}\" build || \\\n        meson setup --wipe \"${meson_args[@]}\" build\n    else\n        meson setup \"${meson_args[@]}\" build\n    fi\n}\n\n# libplacebo (for GPU tone mapping)\nif [ \"$BUILD_LIBPLACEBO\" = \"1\" ]; then\n    # Download Vulkan SDK tarball (apt packages deprecated May 2025)\n    VULKAN_SDK_VERSION=${VULKAN_SDK_VERSION:-1.4.335.0}\n    VULKAN_SDK_DIR=\"${SRC_DIR}/vulkan-sdk-${VULKAN_SDK_VERSION}\"\n    if [ ! -d \"$VULKAN_SDK_DIR\" ]; then\n        echo \"Downloading Vulkan SDK $VULKAN_SDK_VERSION...\"\n        cd \"$SRC_DIR\"\n        rm -f vulkansdk.tar.xz    # Clean up any partial download\n        wget --progress=dot:giga -O vulkansdk.tar.xz \"https://sdk.lunarg.com/sdk/download/${VULKAN_SDK_VERSION}/linux/vulkansdk-linux-x86_64-${VULKAN_SDK_VERSION}.tar.xz\"\n        tar xf vulkansdk.tar.xz\n        mv \"${VULKAN_SDK_VERSION}\" \"vulkan-sdk-${VULKAN_SDK_VERSION}\"\n        rm -f vulkansdk.tar.xz\n    fi\n    export VULKAN_SDK=\"$VULKAN_SDK_DIR/x86_64\"\n    export PATH=\"$VULKAN_SDK/bin:$PATH\"\n    export PKG_CONFIG_PATH=\"$VULKAN_SDK/lib/pkgconfig:$PKG_CONFIG_PATH\"\n    echo \"Using Vulkan SDK: $VULKAN_SDK\"\n\n    # Use static shaderc (avoid runtime .so dependency)\n    if [ ! -f \"$VULKAN_SDK/lib/pkgconfig/shaderc.pc.bak\" ]; then\n        cp \"$VULKAN_SDK/lib/pkgconfig/shaderc.pc\" \"$VULKAN_SDK/lib/pkgconfig/shaderc.pc.bak\"\n    fi\n    cp \"$VULKAN_SDK/lib/pkgconfig/shaderc_combined.pc\" \"$VULKAN_SDK/lib/pkgconfig/shaderc.pc\"\n\n    cd \"$SRC_DIR\"\n    git_update_or_clone \"https://code.videolan.org/videolan/libplacebo.git\" \"libplacebo\" 1\n    cd libplacebo\n    if [ -n \"$LIBPLACEBO_GIT_REF\" ]; then\n        git fetch --depth 1 origin \"$LIBPLACEBO_GIT_REF\"\n        git checkout -q FETCH_HEAD\n    fi\n    build_libplacebo\n    ninja -C build\n    ninja -C build install\nfi\n\n# LibTorch (PyTorch C++ library for DNN backend)\n# Enables AI-based video filters like dnn_processing for upscaling, denoising, etc.\n# NOTE: LibTorch 2.6.0+ renamed initXPU() to init(). We patch ffmpeg to handle both.\n#       Use 2.7.0+ for RTX 50-series (Blackwell/SM 12.0) support.\nLIBTORCH_FLAGS=()\nif [ \"$ENABLE_LIBTORCH\" = \"1\" ]; then\n    LIBTORCH_VERSION=${LIBTORCH_VERSION:-2.7.0}\n    LIBTORCH_DIR=\"$SRC_DIR/libtorch\"\n\n    # Determine LibTorch CUDA variant\n    # LIBTORCH_VARIANT: cu124 (default), auto, cpu, cu126, cu128, cu130, rocm6.4\n    # PyTorch only releases for even-numbered CUDA versions\n    if [ \"$LIBTORCH_VARIANT\" != \"auto\" ]; then\n        # Validate user-provided variant\n        if ! validate_torch_variant \"$LIBTORCH_VARIANT\"; then\n            log_error \"Valid variants: cu124, cu126, cu128, cu130, rocm6.4, cpu\"\n            exit 1\n        fi\n        TORCH_VARIANT=\"$LIBTORCH_VARIANT\"\n        echo \"LibTorch: using $TORCH_VARIANT (explicit)\"\n    elif [ \"$ENABLE_NVIDIA_CUDA\" = \"1\" ]; then\n        CUDA_MAJOR=\"${CUDA_VERSION%%-*}\"\n        CUDA_MINOR=\"${CUDA_VERSION#*-}\"\n        if [ \"$CUDA_MAJOR\" -ge 13 ]; then\n            TORCH_VARIANT=\"cu130\"\n        else\n            # Round down to nearest even, clamp to [6, 8]\n            EVEN_MINOR=$(( (CUDA_MINOR / 2) * 2 ))\n            [ \"$EVEN_MINOR\" -gt 8 ] && EVEN_MINOR=8\n            [ \"$EVEN_MINOR\" -lt 6 ] && EVEN_MINOR=6\n            TORCH_VARIANT=\"cu12${EVEN_MINOR}\"\n        fi\n        echo \"LibTorch: using $TORCH_VARIANT (from CUDA $CUDA_VERSION)\"\n    else\n        TORCH_VARIANT=\"cpu\"\n        echo \"LibTorch: using CPU-only variant\"\n    fi\n\n    # Download LibTorch if not present or wrong variant\n    LIBTORCH_MARKER=\"$LIBTORCH_DIR/.variant-${TORCH_VARIANT}\"\n    if [ ! -f \"$LIBTORCH_MARKER\" ]; then\n        echo \"Downloading LibTorch $LIBTORCH_VERSION ($TORCH_VARIANT)...\"\n        cd \"$SRC_DIR\"\n        # Clean up any existing libtorch directory (safe: inside $SRC_DIR)\n        safe_rm_rf \"$SRC_DIR/libtorch\" 3\n        rm -f libtorch.zip\n\n        # Download from pytorch.org\n        # cu124 and earlier use cxx11-abi prefix, cu130+ dropped it\n        if [[ \"$TORCH_VARIANT\" == cu13* ]] || [[ \"$TORCH_VARIANT\" == cu14* ]]; then\n            LIBTORCH_URL=\"https://download.pytorch.org/libtorch/${TORCH_VARIANT}/libtorch-shared-with-deps-${LIBTORCH_VERSION}%2B${TORCH_VARIANT}.zip\"\n        else\n            LIBTORCH_URL=\"https://download.pytorch.org/libtorch/${TORCH_VARIANT}/libtorch-cxx11-abi-shared-with-deps-${LIBTORCH_VERSION}%2B${TORCH_VARIANT}.zip\"\n        fi\n        if ! wget --progress=dot:giga -O libtorch.zip \"$LIBTORCH_URL\"; then\n            log_error \"Failed to download LibTorch from $LIBTORCH_URL\"\n            exit 1\n        fi\n        unzip -q libtorch.zip\n        rm -f libtorch.zip\n        # Verify extraction succeeded before marking complete\n        if [ ! -f \"$LIBTORCH_DIR/lib/libtorch.so\" ]; then\n            log_error \"LibTorch extraction failed - libtorch.so not found\"\n            exit 1\n        fi\n        touch \"$LIBTORCH_MARKER\"\n    fi\n\n    export LIBTORCH_PATH=\"$LIBTORCH_DIR\"\n    LIBTORCH_FLAGS=(--enable-libtorch)\n    echo \"Using LibTorch: $LIBTORCH_PATH\"\n\n    # Copy libtorch shared libs to permanent location (LIB_DIR)\n    echo \"Installing libtorch libs to $LIB_DIR...\"\n    if ! cp -a \"$LIBTORCH_DIR/lib\"/*.so* \"$LIB_DIR/\" 2>/dev/null; then\n        log_warn \"Some libtorch libs may not have been copied - check $LIB_DIR\"\n    fi\n    # Verify at least libtorch.so was copied\n    if [ ! -f \"$LIB_DIR/libtorch.so\" ]; then\n        log_error \"libtorch.so not found in $LIB_DIR after copy\"\n        exit 1\n    fi\n\n    # Create pkg-config file for libtorch (FFmpeg configure uses pkg-config for detection)\n    mkdir -p \"$BUILD_DIR/lib/pkgconfig\"\n    # Include CUDA libs if using CUDA variant\n    if [[ \"$TORCH_VARIANT\" == cu* ]]; then\n        TORCH_LIBS=\"-ltorch -lc10 -ltorch_cpu -ltorch_cuda -lc10_cuda\"\n        # Needed for ffmpeg extra-libs to ensure libtorch_cuda is linked (not just dlopen'd)\n        TORCH_EXTRA_LIBS=\"-lc10_cuda -ltorch_cuda\"\n    else\n        TORCH_LIBS=\"-ltorch -lc10 -ltorch_cpu\"\n        TORCH_EXTRA_LIBS=\"\"\n    fi\n    cat > \"$BUILD_DIR/lib/pkgconfig/libtorch.pc\" << PCEOF\nprefix=$LIBTORCH_DIR\nexec_prefix=\\${prefix}\nlibdir=$LIB_DIR\nincludedir=\\${prefix}/include\n\nName: libtorch\nDescription: PyTorch C++ library\nVersion: $LIBTORCH_VERSION\nLibs: -L\\${libdir} $TORCH_LIBS\nCflags: -I\\${includedir} -I\\${includedir}/torch/csrc/api/include -std=c++17\nPCEOF\n    echo \"Created libtorch.pc for pkg-config detection (variant: $TORCH_VARIANT)\"\nfi\n\n# ffmpeg\nFFMPEG_DIR=\"ffmpeg-${FFMPEG_VERSION}\"\ncd \"$SRC_DIR\"\nif [ ! -d \"$FFMPEG_DIR\" ]; then\n    if [ \"$FFMPEG_VERSION\" = \"snapshot\" ]; then\n        rm -f ffmpeg-snapshot.tar.bz2    # Clean up any partial download\n        wget --progress=dot:giga -O ffmpeg-snapshot.tar.bz2 https://ffmpeg.org/releases/ffmpeg-snapshot.tar.bz2\n        tar xjf ffmpeg-snapshot.tar.bz2\n        mv ffmpeg \"$FFMPEG_DIR\"\n        rm -f ffmpeg-snapshot.tar.bz2\n    else\n        rm -f \"ffmpeg-${FFMPEG_VERSION}.tar.xz\"    # Clean up any partial download\n        wget --progress=dot:giga -O \"ffmpeg-${FFMPEG_VERSION}.tar.xz\" \"https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz\"\n        tar xJf \"ffmpeg-${FFMPEG_VERSION}.tar.xz\"\n        rm -f \"ffmpeg-${FFMPEG_VERSION}.tar.xz\"\n    fi\nfi\n\n# Patch ffmpeg for SVT-AV1 v4.0.0 API compatibility\n# v4.0.0 renamed enable_adaptive_quantization -> aq_mode\nSVTAV1_CODEC=\"$FFMPEG_DIR/libavcodec/libsvtav1.c\"\nSVTAV1_HEADER=\"$BUILD_DIR/include/svt-av1/EbSvtAv1Enc.h\"\nif [ -f \"$SVTAV1_CODEC\" ] && grep -q \"enable_adaptive_quantization\" \"$SVTAV1_CODEC\" && \\\n   [ -f \"$SVTAV1_HEADER\" ] && grep -q \"uint8_t aq_mode;\" \"$SVTAV1_HEADER\"; then\n    echo \"Patching ffmpeg for SVT-AV1 v4.0.0 (adding compat alias)...\"\n    sed -i '/#include <EbSvtAv1ErrorCodes.h>/i\\\n/* SVT-AV1 v4.0.0 compat: renamed enable_adaptive_quantization -> aq_mode */\\\n#define enable_adaptive_quantization aq_mode' \"$SVTAV1_CODEC\"\nfi\n\n# Patch ffmpeg's torch backend\nif [ \"$ENABLE_LIBTORCH\" = \"1\" ]; then\n    TORCH_BACKEND=\"$FFMPEG_DIR/libavfilter/dnn/dnn_backend_torch.cpp\"\n\n    # Patch 1: Fix initXPU() -> init() for libtorch 2.6+ compatibility\n    if [ -f \"$TORCH_BACKEND\" ] && grep -q \"initXPU()\" \"$TORCH_BACKEND\"; then\n        TORCH_MAJOR=$(echo \"$LIBTORCH_VERSION\" | cut -d. -f1)\n        TORCH_MINOR=$(echo \"$LIBTORCH_VERSION\" | cut -d. -f2)\n        if [ \"$TORCH_MAJOR\" -gt 2 ] || { [ \"$TORCH_MAJOR\" -eq 2 ] && [ \"$TORCH_MINOR\" -ge 6 ]; }; then\n            echo \"Patching ffmpeg for libtorch 2.6+ (initXPU -> init)...\"\n            sed -i 's/initXPU()/init()/g' \"$TORCH_BACKEND\"\n        fi\n    fi\n\n    # Patch 2: Add CUDA device support (upstream only supports CPU/XPU)\n    if [ -f \"$TORCH_BACKEND\" ] && ! grep -q \"device.is_cuda()\" \"$TORCH_BACKEND\"; then\n        echo \"Patching ffmpeg torch backend for CUDA support...\"\n        # Add CUDA device support between XPU and the catch-all error\n        # Also adds dlopen for libtorch_cuda.so to load CUDA kernels at runtime\n        sed -i '/at::detail::getXPUHooks().init/a\\\n    } else if (device.is_cuda()) {\\\n        if (!at::cuda::is_available()) {\\\n            av_log(ctx, AV_LOG_ERROR, \"No CUDA device found\\\\n\");\\\n            goto fail;\\\n        }\\\n        // Load CUDA kernels - required for libtorch CUDA ops\\\n        static bool cuda_lib_loaded = false;\\\n        if (!cuda_lib_loaded) {\\\n            cuda_lib_loaded = true;\\\n            void *cuda_handle = dlopen(\"libtorch_cuda.so\", RTLD_NOW | RTLD_GLOBAL);\\\n            if (cuda_handle) {\\\n                av_log(ctx, AV_LOG_DEBUG, \"libtorch_cuda.so loaded\\\\n\");\\\n            } else {\\\n                av_log(ctx, AV_LOG_WARNING, \"Failed to load libtorch_cuda.so: %s\\\\n\", dlerror());\\\n            }\\\n        }' \"$TORCH_BACKEND\"\n        # Add required CUDA header\n        if ! grep -q \"#include <ATen/cuda/CUDAContext.h>\" \"$TORCH_BACKEND\"; then\n            sed -i '/#include <torch\\/torch.h>/a #include <ATen/cuda/CUDAContext.h>' \"$TORCH_BACKEND\"\n        fi\n        echo \"Torch CUDA patch applied\"\n    fi\n\n    # Patch 3: Add TensorRT support (load runtime + handle tuple outputs)\n    if [ -f \"$TORCH_BACKEND\" ] && ! grep -q \"isTuple\" \"$TORCH_BACKEND\"; then\n        echo \"Patching ffmpeg torch backend for TensorRT support...\"\n\n        # Add dlfcn.h header for dlopen\n        if ! grep -q \"#include <dlfcn.h>\" \"$TORCH_BACKEND\"; then\n            sed -i '/#include <torch\\/script.h>/a #include <dlfcn.h>' \"$TORCH_BACKEND\"\n        fi\n\n        # Add TensorRT runtime loading in model init (before torch::jit::load)\n        if ! grep -q \"libtorchtrt_runtime\" \"$TORCH_BACKEND\"; then\n            sed -i '/torch::jit::load(ctx->model_filename)/i\\\n    // Load TensorRT runtime if available (enables TRT-compiled models)\\\n    static bool trt_init_attempted = false;\\\n    if (!trt_init_attempted) {\\\n        trt_init_attempted = true;\\\n        void *trt_handle = dlopen(\"libtorchtrt_runtime.so\", RTLD_NOW | RTLD_GLOBAL);\\\n        if (trt_handle) {\\\n            av_log(ctx, AV_LOG_INFO, \"TensorRT runtime loaded\\\\n\");\\\n        }\\\n    }' \"$TORCH_BACKEND\"\n        fi\n\n        # Change forward().toTensor() to handle TRT tuple outputs\n        sed -i 's/\\*infer_request->output = th_model->jit_model->forward(inputs)\\.toTensor();/auto _fwd_out = th_model->jit_model->forward(inputs);\\\n    if (_fwd_out.isTuple()) {\\\n        *infer_request->output = _fwd_out.toTuple()->elements()[0].toTensor();\\\n    } else {\\\n        *infer_request->output = _fwd_out.toTensor();\\\n    }/' \"$TORCH_BACKEND\"\n\n        # Fix device detection for TRT models (they may not have parameters)\n        sed -i 's/c10::Device device = (\\*th_model->jit_model->parameters()\\.begin())\\.device();/c10::Device device = torch::kCUDA;\\\n    auto params = th_model->jit_model->parameters();\\\n    if (params.begin() != params.end()) {\\\n        device = (*params.begin()).device();\\\n    }/' \"$TORCH_BACKEND\"\n\n        echo \"Torch TensorRT patch applied\"\n    fi\nfi\n\n# Patch ffmpeg's libplacebo filter to include libavformat version header\n# (suppresses LIBAVFORMAT_VERSION_INT -Wundef warnings).\nLIBPLACEBO_FILTER=\"$FFMPEG_DIR/libavfilter/vf_libplacebo.c\"\nif [ -f \"$LIBPLACEBO_FILTER\" ] && ! grep -q \"libavformat/version.h\" \"$LIBPLACEBO_FILTER\"; then\n    sed -i '/#include \"libavfilter\\/avfilter.h\"/a #include \"libavformat/version.h\"' \"$LIBPLACEBO_FILTER\"\nfi\n\n# TensorRT native backend (no libtorch dependency)\n# Loads pre-compiled .engine files directly for maximum performance\nTENSORRT_FLAGS=()\nif [ \"$ENABLE_TENSORRT\" = \"1\" ]; then\n    echo \"Patching FFmpeg for native TensorRT DNN backend...\"\n    PATCH_DIR=\"$SCRIPT_DIR/patches\"\n\n    # Copy TensorRT backend source file\n    if [ -f \"$PATCH_DIR/dnn_backend_tensorrt.cpp\" ]; then\n        cp \"$PATCH_DIR/dnn_backend_tensorrt.cpp\" \"$FFMPEG_DIR/libavfilter/dnn/\"\n        echo \"Copied dnn_backend_tensorrt.cpp\"\n    else\n        echo \"WARNING: dnn_backend_tensorrt.cpp not found in $PATCH_DIR\"\n    fi\n\n    # Copy CUDA kernels for GPU-resident format conversion (zero-copy)\n    # Rename to .cuda to prevent FFmpeg from auto-compiling it as .cu -> .o\n    # Our custom PTX rules in the Makefile will compile it properly\n    if [ -f \"$PATCH_DIR/dnn_cuda_kernels.cu\" ]; then\n        cp \"$PATCH_DIR/dnn_cuda_kernels.cu\" \"$FFMPEG_DIR/libavfilter/dnn/dnn_cuda_kernels.cuda\"\n        cp \"$PATCH_DIR/dnn_cuda_kernels.h\" \"$FFMPEG_DIR/libavfilter/dnn/\"\n        echo \"Copied CUDA format conversion kernels (renamed to .cuda to avoid auto-build)\"\n    else\n        echo \"WARNING: dnn_cuda_kernels.cu not found in $PATCH_DIR\"\n    fi\n\n    # Copy patched vf_dnn_processing.c for CUDA frame support\n    if [ -f \"$PATCH_DIR/vf_dnn_processing.c\" ]; then\n        cp \"$PATCH_DIR/vf_dnn_processing.c\" \"$FFMPEG_DIR/libavfilter/\"\n        echo \"Copied vf_dnn_processing.c with CUDA frame support\"\n    fi\n\n    # Patch dnn_interface.h to add DNN_TRT enum and TRTOptions\n    DNN_INTERFACE_H=\"$FFMPEG_DIR/libavfilter/dnn_interface.h\"\n    if [ -f \"$DNN_INTERFACE_H\" ] && ! grep -q \"DNN_TRT\" \"$DNN_INTERFACE_H\"; then\n        # FFmpeg 7.0 has single-line enum: {DNN_TF = 1, DNN_OV, DNN_TH}\n        # FFmpeg 7.1+/snapshot has multi-line: DNN_TH = 1 << 2\n        if grep -q \"DNN_TH = 1 << 2\" \"$DNN_INTERFACE_H\"; then\n            # Multi-line format (7.1+/snapshot)\n            sed -i '/DNN_TH = 1 << 2/s/$/,/' \"$DNN_INTERFACE_H\"\n            sed -i '/DNN_TH = 1 << 2,$/a\\    DNN_TRT = 1 << 3' \"$DNN_INTERFACE_H\"\n        elif grep -q \"DNN_TF = 1, DNN_OV, DNN_TH}\" \"$DNN_INTERFACE_H\"; then\n            # Single-line format (7.0)\n            sed -i 's/DNN_TF = 1, DNN_OV, DNN_TH}/DNN_TF = 1, DNN_OV, DNN_TH, DNN_TRT}/' \"$DNN_INTERFACE_H\"\n        else\n            echo \"ERROR: Unknown DNNBackendType enum format in dnn_interface.h\" >&2\n            grep -A5 \"DNNBackendType\" \"$DNN_INTERFACE_H\" >&2\n            exit 1\n        fi\n        # Add TRTOptions struct after THOptions\n        sed -i '/^} THOptions;$/a\\\n\\\ntypedef struct TRTOptions {\\\n    const AVClass *clazz;\\\n    int device_id;\\\n} TRTOptions;' \"$DNN_INTERFACE_H\"\n        # Add trt_option to DnnContext (after torch_option)\n        sed -i '/#if CONFIG_LIBTORCH/,/#endif/{\n            /#endif/a\\\n#if CONFIG_LIBTENSORRT\\\n    TRTOptions trt_option;\\\n#endif\n        }' \"$DNN_INTERFACE_H\"\n        # Verify patch was applied\n        if grep -q \"DNN_TRT\" \"$DNN_INTERFACE_H\"; then\n            echo \"Patched dnn_interface.h\"\n        else\n            echo \"ERROR: Failed to patch dnn_interface.h - DNN_TRT not found after patching\" >&2\n            exit 1\n        fi\n    fi\n\n    # Patch dnn_interface.c to register TensorRT backend\n    DNN_INTERFACE_C=\"$FFMPEG_DIR/libavfilter/dnn/dnn_interface.c\"\n    if [ -f \"$DNN_INTERFACE_C\" ] && ! grep -q \"ff_dnn_backend_tensorrt\" \"$DNN_INTERFACE_C\"; then\n        # Add extern declaration\n        sed -i '/extern const DNNModule ff_dnn_backend_torch;/a extern const DNNModule ff_dnn_backend_tensorrt;' \"$DNN_INTERFACE_C\"\n        # Add to backend list\n        sed -i '/#if CONFIG_LIBTORCH/,/#endif/{\n            /#endif/a\\\n#if CONFIG_LIBTENSORRT\\\n        {offsetof(DnnContext, trt_option), .module = \\&ff_dnn_backend_tensorrt},\\\n#endif\n        }' \"$DNN_INTERFACE_C\"\n        # Verify patch was applied\n        if ! verify_patch \"$DNN_INTERFACE_C\" \"ff_dnn_backend_tensorrt\" \"dnn_interface.c TensorRT registration\"; then\n            exit 1\n        fi\n        log_info \"Patched dnn_interface.c\"\n    fi\n\n    # Patch dnn/Makefile to add TensorRT objects and CUDA kernel PTX compilation\n    DNN_MAKEFILE=\"$FFMPEG_DIR/libavfilter/dnn/Makefile\"\n    if [ -f \"$DNN_MAKEFILE\" ] && ! grep -q \"CONFIG_LIBTENSORRT\" \"$DNN_MAKEFILE\"; then\n        # Add TensorRT backend object\n        sed -i '/CONFIG_LIBTORCH.*dnn_backend_torch/a DNN-OBJS-$(CONFIG_LIBTENSORRT)             += dnn/dnn_backend_tensorrt.o' \"$DNN_MAKEFILE\"\n        # Add embedded PTX object (compiled PTX as C byte array)\n        sed -i '/dnn_backend_tensorrt.o/a DNN-OBJS-$(CONFIG_LIBTENSORRT)               += dnn/dnn_cuda_kernels_ptx.o' \"$DNN_MAKEFILE\"\n        # Add PTX compilation and embedding rules\n        # 1. Compile .cu to .ptx with nvcc\n        # 2. Embed .ptx as C byte array using xxd (bin2c alternative)\n        # 3. Compile embedded C to object\n        cat >> \"$DNN_MAKEFILE\" << 'PTXRULES'\n\n# CUDA kernel PTX compilation and embedding (no cudart dependency)\n# Step 1: Compile CUDA kernels to PTX (intermediate representation)\n# Source is .cuda (not .cu) to prevent FFmpeg from auto-compiling to .o\nlibavfilter/dnn/dnn_cuda_kernels.ptx: libavfilter/dnn/dnn_cuda_kernels.cuda\n\t$(NVCC) --ptx -o $@ -x cu $< -m64\n\n# Step 2: Embed PTX as C byte array (using xxd, like bin2c)\nlibavfilter/dnn/dnn_cuda_kernels_ptx.c: libavfilter/dnn/dnn_cuda_kernels.ptx\n\t@echo \"Embedding PTX as C byte array...\"\n\t@echo \"/* Auto-generated - do not edit */\" > $@\n\t@echo \"#include <stddef.h>\" >> $@\n\t@echo \"\" >> $@\n\t@printf \"const unsigned char ff_dnn_cuda_kernels_ptx[] = {\\n\" >> $@\n\t@xxd -i < $< >> $@\n\t@echo \"};\" >> $@\n\t@echo \"\" >> $@\n\t@printf \"const unsigned int ff_dnn_cuda_kernels_ptx_len = sizeof(ff_dnn_cuda_kernels_ptx);\\n\" >> $@\n\n# Step 3: Compile embedded PTX C file to object\nlibavfilter/dnn/dnn_cuda_kernels_ptx.o: libavfilter/dnn/dnn_cuda_kernels_ptx.c\n\t$(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $<\nPTXRULES\n        # Verify Makefile patch\n        if ! verify_patch \"$DNN_MAKEFILE\" \"dnn_cuda_kernels_ptx.o\" \"dnn/Makefile PTX rules\"; then\n            exit 1\n        fi\n        log_info \"Patched dnn/Makefile with TensorRT and CUDA PTX kernel support\"\n    fi\n\n    # Patch configure to add --enable-libtensorrt option\n    CONFIGURE=\"$FFMPEG_DIR/configure\"\n    if [ -f \"$CONFIGURE\" ] && ! grep -q \"enable-libtensorrt\" \"$CONFIGURE\"; then\n        # Add help text\n        sed -i '/--enable-libtorch.*enable Torch/a\\  --enable-libtensorrt     enable TensorRT as one DNN backend [no]' \"$CONFIGURE\"\n        # Add to library list\n        sed -i '/^    libtorch$/a\\    libtensorrt' \"$CONFIGURE\"\n        # Add to dnn_deps_any\n        sed -i 's/dnn_deps_any=\"libtensorflow libopenvino libtorch\"/dnn_deps_any=\"libtensorflow libopenvino libtorch libtensorrt\"/' \"$CONFIGURE\"\n        # Add header check (after libtorch check)\n        # TensorRT (libnvinfer) and CUDA (libcuda) are loaded via dlopen at runtime.\n        # CUDA kernels are compiled to PTX and loaded via Driver API - no cudart dependency.\n        sed -i '/enabled libtorch.*require_cxx libtorch/a\\\nenabled libtensorrt       && check_cxxflags -std=c++17 && check_headers NvInfer.h' \"$CONFIGURE\"\n        echo \"Patched configure (TensorRT via dlopen, CUDA linked for kernels)\"\n    fi\n\n    TENSORRT_FLAGS=(--enable-libtensorrt)\n    echo \"TensorRT DNN backend patches applied\"\nfi\n\ncd \"$FFMPEG_DIR\"\n# Build configure flags\n# MARCH=native for CPU-specific optimizations (opt-in, not portable)\nEXTRA_CFLAGS=\"-I$BUILD_DIR/include -O3${MARCH:+ -march=$MARCH -mtune=$MARCH}\"\nEXTRA_CXXFLAGS=\"\"\n# -rpath embeds library search path in binary so it finds our built libs at runtime\nEXTRA_LDFLAGS=\"-L$BUILD_DIR/lib -s -Wl,-rpath,$LIB_DIR\"\nif [ \"$ENABLE_NVIDIA_CUDA\" = \"1\" ]; then\n    EXTRA_CFLAGS=\"$EXTRA_CFLAGS -I$CUDA_PATH/include\"\n    EXTRA_LDFLAGS=\"$EXTRA_LDFLAGS -L$CUDA_PATH/lib64\"\nfi\nif [ \"$BUILD_LIBPLACEBO\" = \"1\" ]; then\n    EXTRA_CFLAGS=\"$EXTRA_CFLAGS -I$VULKAN_SDK/include\"\n    EXTRA_LDFLAGS=\"$EXTRA_LDFLAGS -L$VULKAN_SDK/lib\"\nfi\nif [ \"$ENABLE_LIBTORCH\" = \"1\" ]; then\n    # LibTorch needs C++ flags (FFmpeg uses require_cxx for libtorch detection)\n    # Include CUDA path for CUDA torch support\n    EXTRA_CXXFLAGS=\"-I$LIBTORCH_PATH/include -I$LIBTORCH_PATH/include/torch/csrc/api/include\"\n    if [ \"$ENABLE_NVIDIA_CUDA\" = \"1\" ]; then\n        EXTRA_CXXFLAGS=\"$EXTRA_CXXFLAGS -I$CUDA_PATH/include\"\n    fi\n    EXTRA_LDFLAGS=\"$EXTRA_LDFLAGS -L$LIB_DIR -Wl,-rpath,$LIB_DIR\"\nfi\nif [ \"$ENABLE_TENSORRT\" = \"1\" ]; then\n    # TensorRT needs C++ flags with CUDA headers (uses require_cxx for detection)\n    # Note: We use CUDA Driver API (libcuda.so) loaded via dlopen at runtime,\n    # NOT CUDA Runtime API (libcudart.so). This avoids load-time dependency.\n    if [ \"$ENABLE_NVIDIA_CUDA\" = \"1\" ]; then\n        EXTRA_CXXFLAGS=\"$EXTRA_CXXFLAGS -I$CUDA_PATH/include\"\n    fi\nfi\nCONFIGURE_CMD=(\n    ./configure\n    --prefix=\"$BUILD_DIR\"\n    --pkg-config-flags=\"--static\"\n    --extra-cflags=\"$EXTRA_CFLAGS\"\n    --extra-cxxflags=\"$EXTRA_CXXFLAGS\"\n    --extra-ldflags=\"$EXTRA_LDFLAGS\"\n    --extra-libs=\"-lpthread -lm -ldl${TORCH_EXTRA_LIBS:+ $TORCH_EXTRA_LIBS}\"\n    --ld=\"g++\"\n    --bindir=\"$BIN_DIR\"\n    --disable-debug\n    --enable-gpl\n    --enable-version3\n    --enable-openssl\n    --enable-libaom\n    --enable-libass\n    --enable-libbluray\n    --enable-libfdk-aac\n    --enable-libfontconfig\n    --enable-libfreetype\n    --enable-libfribidi\n    --enable-libharfbuzz\n    --enable-libjxl\n    --enable-libmp3lame\n    --enable-libopus\n    --enable-libsvtav1\n    --enable-libdav1d\n    --enable-libvmaf\n    --enable-libvorbis\n    --enable-libvpx\n    --enable-libwebp\n    --enable-libx264\n    --enable-libx265\n    --enable-librubberband\n    --enable-libsoxr\n    --enable-libsrt\n    --enable-libvidstab\n    --enable-libvpl\n    --enable-libzimg\n    --enable-opencl\n    --enable-vaapi\n    --enable-nonfree\n    \"${CUDA_FLAGS[@]}\"\n    \"${AMF_FLAGS[@]}\"\n    \"${LIBTORCH_FLAGS[@]}\"\n    \"${TENSORRT_FLAGS[@]}\"\n)\n\nif [ \"$BUILD_LIBPLACEBO\" = \"1\" ]; then\n    CONFIGURE_CMD+=(--enable-vulkan --enable-libplacebo)\nfi\n\nif [ -n \"$NVCC_ARCH\" ]; then\n    CONFIGURE_CMD+=(--nvccflags=\"$NVCC_ARCH\")\nfi\n\n# Build PATH: include CUDA bin if NVIDIA enabled\nBUILD_PATH=\"$BIN_DIR:$PATH\"\n[ \"$ENABLE_NVIDIA_CUDA\" = \"1\" ] && BUILD_PATH=\"$CUDA_PATH/bin:$BUILD_PATH\"\n\nPATH=\"$BUILD_PATH\" PKG_CONFIG_PATH=\"$BUILD_DIR/lib/pkgconfig:$PKG_CONFIG_PATH\" \"${CONFIGURE_CMD[@]}\"\nPATH=\"$BUILD_PATH\" make -j \"$NPROC\"\nmake install\nhash -r\n\ngrep -q \"$BUILD_DIR/share/man\" \"$HOME/.manpath\" 2>/dev/null || echo \"MANPATH_MAP $BIN_DIR $BUILD_DIR/share/man\" >> \"$HOME/.manpath\"\n\nlog_info \"FFmpeg build completed successfully\"\n"
  },
  {
    "path": "tools/install-letsencrypt.sh",
    "content": "#!/bin/bash\n# Install and configure Let's Encrypt certificates\nset -e\n\nDOMAIN=\"${1:-}\"\n\nif [ -z \"$DOMAIN\" ]; then\n    echo \"Usage: $0 <domain>\"\n    echo \"Example: $0 yourdomain.com\"\n    exit 1\nfi\n\necho \"=== Installing certbot ===\"\nsudo apt update\nsudo apt install -y certbot\n\n# Detect web server and choose authenticator\nif systemctl is-active --quiet apache2; then\n    echo \"=== Apache detected, using apache authenticator ===\"\n    sudo apt install -y python3-certbot-apache\n    CERTBOT_MODE=\"--apache\"\nelif systemctl is-active --quiet nginx; then\n    echo \"=== Nginx detected, using nginx authenticator ===\"\n    sudo apt install -y python3-certbot-nginx\n    CERTBOT_MODE=\"--nginx\"\nelse\n    echo \"=== No web server detected, using standalone mode ===\"\n    echo \"Note: Port 80 must be free for domain verification\"\n    CERTBOT_MODE=\"--standalone\"\nfi\n\necho \"=== Obtaining certificate for $DOMAIN ===\"\nsudo certbot $CERTBOT_MODE -d \"$DOMAIN\"\n\necho \"=== Setting up ssl-cert group permissions ===\"\nsudo chgrp -R ssl-cert /etc/letsencrypt/archive/\nsudo chmod -R g+r /etc/letsencrypt/archive/\n\necho \"=== Installing deploy hook ===\"\ncat <<'EOF' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/ssl-cert-perms\n#!/bin/bash\n# Fix cert permissions after renewal for ssl-cert group\n\nchgrp -R ssl-cert /etc/letsencrypt/archive/\nchmod -R g+r /etc/letsencrypt/archive/\nEOF\n\nsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/ssl-cert-perms\n\necho \"\"\necho \"=== Done ===\"\necho \"\"\necho \"Certificate installed for $DOMAIN\"\necho \"Certbot timer will auto-renew (check: systemctl list-timers | grep certbot)\"\necho \"\"\necho \"To give a user access to certs:\"\necho \"  sudo usermod -aG ssl-cert <username>\"\n"
  },
  {
    "path": "tools/install-netv.sh",
    "content": "#!/bin/bash\n# Install netv systemd service\n# Prerequisites: uv (install time only), install-letsencrypt.sh\n#\n# Usage: sudo ./install-netv.sh [--port PORT]\n#   --port PORT  Port to listen on (default: 8000)\nset -e\n\nIPTV_DIR=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nUSER=\"${SUDO_USER:-$USER}\"\nPORT=8000\n\n# Parse arguments\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        --port)\n            PORT=\"$2\"\n            shift 2\n            ;;\n        *)\n            echo \"Unknown option: $1\"\n            echo \"Usage: sudo $0 [--port PORT]\"\n            exit 1\n            ;;\n    esac\ndone\n\n# Validate\nif [ \"$USER\" = \"root\" ]; then\n    echo \"Error: Run with sudo, not as root directly\"\n    echo \"Usage: sudo $0 [--port PORT]\"\n    exit 1\nfi\n\n# Find uv in user's environment (only needed at install time)\nUV_PATH=$(su - \"$USER\" -c \"which uv\" 2>/dev/null)\nif [ -z \"$UV_PATH\" ]; then\n    echo \"Error: uv not found for user $USER. Install with:\"\n    echo \"  curl -LsSf https://astral.sh/uv/install.sh | sh\"\n    echo \"See: https://docs.astral.sh/uv/\"\n    exit 1\nfi\n\necho \"=== Syncing dependencies ===\"\nsu - \"$USER\" -c \"cd '$IPTV_DIR' && '$UV_PATH' sync\"\n\nif [ ! -d /etc/letsencrypt/live ]; then\n    echo \"Warning: Let's Encrypt not configured. Run install-letsencrypt.sh first for HTTPS.\"\n    echo \"Continuing with HTTP-only setup...\"\n    HTTPS_FLAG=\"\"\nelse\n    HTTPS_FLAG=\"--https\"\nfi\n\necho \"=== Installing netv for user: $USER (port $PORT) ===\"\n\necho \"=== Adding $USER to ssl-cert group ===\"\nsudo usermod -aG ssl-cert \"$USER\"\n\necho \"=== Installing netv systemd service ===\"\n\n# Build PATH - prefer custom ffmpeg in ~/.local/bin if it exists\nUSER_LOCAL_BIN=\"/home/$USER/.local/bin\"\nif [ -x \"$USER_LOCAL_BIN/ffmpeg\" ]; then\n    echo \"  Found custom ffmpeg in $USER_LOCAL_BIN\"\n    ENV_PATH=\"$USER_LOCAL_BIN:/usr/local/bin:/usr/bin:/bin\"\nelse\n    ENV_PATH=\"/usr/local/bin:/usr/bin:/bin\"\nfi\n\n# Build LIBVA env vars if custom libva exists (for VAAPI on hybrid GPU systems)\nUSER_LOCAL_LIB=\"/home/$USER/.local/lib\"\nLIBVA_ENVS=\"\"\nif [ -f \"$USER_LOCAL_LIB/libva.so\" ]; then\n    echo \"  Found custom libva in $USER_LOCAL_LIB\"\n\n    # Auto-detect LIBVA driver based on GPU vendor\n    LIBVA_DRIVER=\"\"\n    if lspci -nn 2>/dev/null | grep -qE \"VGA.*\\[8086:\"; then\n        LIBVA_DRIVER=\"i965\"  # Intel\n    elif lspci -nn 2>/dev/null | grep -qE \"VGA.*\\[1002:\"; then\n        LIBVA_DRIVER=\"radeonsi\"  # AMD\n    fi\n\n    # Auto-detect DRI path\n    DRI_PATH=\"\"\n    for p in /usr/lib/x86_64-linux-gnu/dri /usr/lib64/dri /usr/lib/dri; do\n        if [ -d \"$p\" ]; then\n            DRI_PATH=\"$p\"\n            break\n        fi\n    done\n\n    if [ -n \"$LIBVA_DRIVER\" ] && [ -n \"$DRI_PATH\" ]; then\n        echo \"  Detected VAAPI driver: $LIBVA_DRIVER, path: $DRI_PATH\"\n        LIBVA_ENVS=\"Environment=\\\"LIBVA_DRIVER_NAME=$LIBVA_DRIVER\\\"\nEnvironment=\\\"LIBVA_DRIVERS_PATH=$DRI_PATH\\\"\"\n    fi\nfi\n\ncat <<EOF | sudo tee /etc/systemd/system/netv.service\n[Unit]\nDescription=NetV IPTV Server\nAfter=network.target\n\n[Service]\nType=simple\nUser=$USER\nGroup=ssl-cert\nWorkingDirectory=$IPTV_DIR\nEnvironment=\"PATH=$ENV_PATH\"\n$LIBVA_ENVS\nExecStart=$IPTV_DIR/.venv/bin/python ./main.py --port $PORT $HTTPS_FLAG\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\nEOF\n\nsudo systemctl daemon-reload\nsudo systemctl enable netv\nsudo systemctl start netv\n\nif [ -n \"$HTTPS_FLAG\" ]; then\n    echo \"=== Installing certbot deploy hook (restart netv on renewal) ===\"\n    cat <<'EOF' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/netv\n#!/bin/bash\n# Restart netv after cert renewal\nsystemctl restart netv\nEOF\n    sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/netv\nfi\n\necho \"\"\necho \"=== Done ===\"\necho \"\"\necho \"Commands:\"\necho \"  sudo systemctl status netv     # Check status\"\necho \"  sudo systemctl restart netv    # Restart after code changes\"\necho \"  journalctl -u netv -f          # View logs\"\n"
  },
  {
    "path": "tools/install-prereqs.sh",
    "content": "#!/bin/bash\n# Install prerequisites for netv\nset -e\n\necho \"=== Checking prerequisites ===\"\nfor cmd in git curl; do\n    if ! command -v $cmd &> /dev/null; then\n        echo \"Error: $cmd not found. Install it with your package manager.\"\n        exit 1\n    fi\ndone\n\necho \"=== Installing uv ===\"\nif command -v uv &> /dev/null; then\n    echo \"uv already installed: $(uv --version)\"\nelse\n    curl -LsSf https://astral.sh/uv/install.sh | sh\n    export PATH=\"$HOME/.local/bin:$PATH\"\nfi\n\necho \"=== Installing Python 3.11 via uv ===\"\nuv python install 3.11\n\necho \"\"\necho \"=== Done ===\"\necho \"\"\necho \"Next steps:\"\necho \"  1. Run: ./tools/install-letsencrypt.sh <your-domain>\"\necho \"  2. Run: ./tools/install-ffmpeg.sh  (optional, for transcoding)\"\necho \"  3. Run: sudo ./tools/install-netv.sh\"\n"
  },
  {
    "path": "tools/patches/dnn_backend_tensorrt.cpp",
    "content": "/*\n * Copyright 2026 Joshua V. Dillon\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @file\n * DNN TensorRT backend implementation.\n *\n * This backend loads pre-compiled TensorRT engine files (.engine) for\n * high-performance GPU inference. Use tools/export-tensorrt.py to convert\n * PyTorch models to TensorRT engines.\n *\n * All libraries are loaded at runtime via dlopen - no CUDA or TensorRT\n * dependency at ffmpeg load time. Errors only occur when the TRT backend\n * is actually used.\n *\n * Usage:\n *   ffmpeg -i input.mp4 -vf \"dnn_processing=dnn_backend=tensorrt:model=model.engine\" output.mp4\n */\n\n#include <NvInfer.h>\n#include <dlfcn.h>\n#include <fstream>\n#include <vector>\n#include <memory>\n#include <cstring>\n#include <atomic>\n#include <mutex>\n#include <unordered_map>\n#include <string>\n\n// ============================================================================\n// Engine cache - avoid reloading same engine file multiple times\n// ============================================================================\nstruct CachedEngine {\n    nvinfer1::ICudaEngine *engine;\n    nvinfer1::IRuntime *runtime;\n    std::atomic<int> refcount;\n\n    CachedEngine(nvinfer1::ICudaEngine *e, nvinfer1::IRuntime *r)\n        : engine(e), runtime(r), refcount(1) {}\n};\n\nstatic std::mutex g_engine_cache_mutex;\nstatic std::unordered_map<std::string, CachedEngine*> g_engine_cache;\n\n// ============================================================================\n// CUDA Driver API types (from cuda.h - we dlopen libcuda.so instead of linking)\n// ============================================================================\ntypedef int CUresult;\ntypedef int CUdevice;\ntypedef void* CUcontext;\ntypedef void* CUmodule;\ntypedef void* CUfunction;\ntypedef void* CUstream;\ntypedef unsigned long long CUdeviceptr;\n\n#define CUDA_SUCCESS 0\n\n// CUDA Driver API function pointer types\ntypedef CUresult (*fn_cuInit)(unsigned int);\ntypedef CUresult (*fn_cuDeviceGet)(CUdevice*, int);\ntypedef CUresult (*fn_cuDevicePrimaryCtxRetain)(CUcontext*, CUdevice);\ntypedef CUresult (*fn_cuCtxGetCurrent)(CUcontext*);\ntypedef CUresult (*fn_cuCtxSetCurrent)(CUcontext);\ntypedef CUresult (*fn_cuCtxPushCurrent)(CUcontext);\ntypedef CUresult (*fn_cuCtxPopCurrent)(CUcontext*);\ntypedef CUresult (*fn_cuMemAlloc)(CUdeviceptr*, size_t);\ntypedef CUresult (*fn_cuMemFree)(CUdeviceptr);\n\n// CUDA Runtime API function pointers (for compatibility with TensorRT which uses Runtime API)\n// Note: cudaError_t is already defined via NvInfer.h -> cuda_runtime_api.h\ntypedef cudaError_t (*fn_cudaMalloc)(void**, size_t);\ntypedef cudaError_t (*fn_cudaFree)(void*);\ntypedef cudaError_t (*fn_cudaSetDevice)(int);\ntypedef cudaError_t (*fn_cudaMemcpy)(void*, const void*, size_t, int);\ntypedef cudaError_t (*fn_cudaMemcpyAsync)(void*, const void*, size_t, int, cudaStream_t);\ntypedef cudaError_t (*fn_cudaStreamSynchronize)(cudaStream_t);\ntypedef cudaError_t (*fn_cudaStreamCreate)(cudaStream_t*, unsigned int);\ntypedef cudaError_t (*fn_cudaStreamDestroy)(cudaStream_t);\ntypedef cudaError_t (*fn_cudaMemGetInfo)(size_t*, size_t*);\n#define cudaMemcpyHostToDevice 1\n#define cudaMemcpyDeviceToHost 2\ntypedef CUresult (*fn_cuMemcpyHtoD)(CUdeviceptr, const void*, size_t);\ntypedef CUresult (*fn_cuMemcpyDtoH)(void*, CUdeviceptr, size_t);\ntypedef CUresult (*fn_cuMemcpyHtoDAsync)(CUdeviceptr, const void*, size_t, CUstream);\ntypedef CUresult (*fn_cuMemcpyDtoHAsync)(void*, CUdeviceptr, size_t, CUstream);\ntypedef CUresult (*fn_cuStreamCreate)(CUstream*, unsigned int);\ntypedef CUresult (*fn_cuStreamDestroy)(CUstream);\ntypedef CUresult (*fn_cuStreamSynchronize)(CUstream);\ntypedef CUresult (*fn_cuModuleLoadData)(CUmodule*, const void*);\ntypedef CUresult (*fn_cuModuleUnload)(CUmodule);\ntypedef CUresult (*fn_cuModuleGetFunction)(CUfunction*, CUmodule, const char*);\ntypedef CUresult (*fn_cuLaunchKernel)(CUfunction, unsigned int, unsigned int, unsigned int,\n                                       unsigned int, unsigned int, unsigned int,\n                                       unsigned int, CUstream, void**, void**);\ntypedef CUresult (*fn_cuGetErrorString)(CUresult, const char**);\n\n// CUDA Graph API function pointer types\ntypedef void* CUgraph;\ntypedef void* CUgraphExec;\ntypedef CUresult (*fn_cuStreamBeginCapture)(CUstream, int);\ntypedef CUresult (*fn_cuStreamEndCapture)(CUstream, CUgraph*);\ntypedef CUresult (*fn_cuGraphInstantiate)(CUgraphExec*, CUgraph, unsigned long long);\ntypedef CUresult (*fn_cuGraphLaunch)(CUgraphExec, CUstream);\ntypedef CUresult (*fn_cuGraphDestroy)(CUgraph);\ntypedef CUresult (*fn_cuGraphExecDestroy)(CUgraphExec);\n\n#define CU_STREAM_CAPTURE_MODE_GLOBAL 0\n\n// ============================================================================\n// Dynamic library loading for CUDA and TensorRT\n// NOTE: These handles are intentionally never dlclose'd. CUDA/TensorRT libraries\n// have complex cleanup requirements and calling dlclose can cause crashes.\n// The OS reclaims resources on process exit.\n// ============================================================================\nstatic void *libcuda_handle = NULL;\nstatic void *libnvinfer_handle = NULL;\nstatic int cuda_loaded = 0;\nstatic int tensorrt_loaded = 0;\nstatic std::atomic<int> libs_load_attempted(0);\nstatic std::mutex libs_load_mutex;\n\n// CUDA Driver API function pointers\nstatic fn_cuInit p_cuInit = NULL;\nstatic fn_cuDeviceGet p_cuDeviceGet = NULL;\nstatic fn_cuDevicePrimaryCtxRetain p_cuDevicePrimaryCtxRetain = NULL;\nstatic fn_cuCtxGetCurrent p_cuCtxGetCurrent = NULL;\nstatic fn_cuCtxSetCurrent p_cuCtxSetCurrent = NULL;\nstatic fn_cuCtxPushCurrent p_cuCtxPushCurrent = NULL;\nstatic fn_cuCtxPopCurrent p_cuCtxPopCurrent = NULL;\nstatic fn_cuMemAlloc p_cuMemAlloc = NULL;\nstatic fn_cuMemFree p_cuMemFree = NULL;\nstatic fn_cudaMalloc p_cudaMalloc = NULL;\nstatic fn_cudaFree p_cudaFree = NULL;\nstatic fn_cudaSetDevice p_cudaSetDevice = NULL;\nstatic fn_cudaMemcpy p_cudaMemcpy = NULL;\nstatic fn_cudaMemcpyAsync p_cudaMemcpyAsync = NULL;\nstatic fn_cudaStreamSynchronize p_cudaStreamSynchronize_rt = NULL;  // Runtime API stream sync\nstatic fn_cudaStreamCreate p_cudaStreamCreate_rt = NULL;  // Runtime API stream create\nstatic fn_cudaMemGetInfo p_cudaMemGetInfo = NULL;  // For memory diagnostics\nstatic void *cuda_rt_handle = NULL;  // libcudart.so handle\nstatic fn_cuMemcpyHtoD p_cuMemcpyHtoD = NULL;\nstatic fn_cuMemcpyDtoH p_cuMemcpyDtoH = NULL;\nstatic fn_cuMemcpyHtoDAsync p_cuMemcpyHtoDAsync = NULL;\nstatic fn_cuMemcpyDtoHAsync p_cuMemcpyDtoHAsync = NULL;\nstatic fn_cuStreamCreate p_cuStreamCreate = NULL;\nstatic fn_cuStreamDestroy p_cuStreamDestroy = NULL;\nstatic fn_cuStreamSynchronize p_cuStreamSynchronize = NULL;\nstatic fn_cuModuleLoadData p_cuModuleLoadData = NULL;\nstatic fn_cuModuleUnload p_cuModuleUnload = NULL;\nstatic fn_cuModuleGetFunction p_cuModuleGetFunction = NULL;\nstatic fn_cuLaunchKernel p_cuLaunchKernel = NULL;\nstatic fn_cuGetErrorString p_cuGetErrorString = NULL;\n\n// CUDA Graph API function pointers (optional - graceful fallback if unavailable)\nstatic fn_cuStreamBeginCapture p_cuStreamBeginCapture = NULL;\nstatic fn_cuStreamEndCapture p_cuStreamEndCapture = NULL;\nstatic fn_cuGraphInstantiate p_cuGraphInstantiate = NULL;\nstatic fn_cuGraphLaunch p_cuGraphLaunch = NULL;\nstatic fn_cuGraphDestroy p_cuGraphDestroy = NULL;\nstatic fn_cuGraphExecDestroy p_cuGraphExecDestroy = NULL;\nstatic int cuda_graphs_available = 0;\n\n// TensorRT factory function pointer\ntypedef nvinfer1::IRuntime* (*fn_createInferRuntime)(nvinfer1::ILogger&);\nstatic fn_createInferRuntime p_createInferRuntime = NULL;\n\n// Forward declaration\nstatic int load_libs(void *log_ctx);\n\nextern \"C\" {\n#include \"dnn_io_proc.h\"\n#include \"dnn_backend_common.h\"\n#include \"libavutil/opt.h\"\n#include \"libavutil/mem.h\"\n#include \"libavutil/avassert.h\"\n#include \"libavutil/internal.h\"\n#include \"libavutil/hwcontext.h\"\n#include \"libavutil/pixfmt.h\"\n#include \"libavutil/pixdesc.h\"\n#include \"queue.h\"\n#include \"safe_queue.h\"\n#include \"dnn_cuda_kernels.h\"\n}\n\n// Get CUDA error string\nstatic const char* cuda_error_string(CUresult err) {\n    const char *str = NULL;\n    if (p_cuGetErrorString && p_cuGetErrorString(err, &str) == CUDA_SUCCESS && str)\n        return str;\n    return \"unknown CUDA error\";\n}\n\n// Load CUDA Driver API and TensorRT via dlopen\nstatic int load_libs(void *log_ctx) {\n    // Double-checked locking for thread safety\n    if (libs_load_attempted.load(std::memory_order_acquire))\n        return (cuda_loaded && tensorrt_loaded) ? 0 : AVERROR(ENOSYS);\n\n    std::lock_guard<std::mutex> lock(libs_load_mutex);\n    // Check again after acquiring lock\n    if (libs_load_attempted.load(std::memory_order_relaxed))\n        return (cuda_loaded && tensorrt_loaded) ? 0 : AVERROR(ENOSYS);\n\n    // Set at end of function, not here, to ensure proper initialization\n    // before other threads see libs_load_attempted == 1\n\n    // Load CUDA Driver API (libcuda.so - NOT libcudart.so!)\n    const char *cuda_names[] = {\"libcuda.so.1\", \"libcuda.so\", NULL};\n    for (int i = 0; cuda_names[i] && !libcuda_handle; i++) {\n        libcuda_handle = dlopen(cuda_names[i], RTLD_NOW);\n    }\n    if (!libcuda_handle) {\n        av_log(log_ctx, AV_LOG_ERROR,\n               \"CUDA driver not available: %s\\n\"\n               \"Install NVIDIA driver or run with --gpus all to use nvidia-container-toolkit\\n\",\n               dlerror());\n        libs_load_attempted.store(1, std::memory_order_release);\n        return AVERROR(ENOSYS);\n    }\n\n    // Load all required CUDA functions\n    #define LOAD_CUDA_FUNC(name) \\\n        p_##name = (fn_##name)dlsym(libcuda_handle, #name); \\\n        if (!p_##name) { \\\n            av_log(log_ctx, AV_LOG_ERROR, \"Failed to load CUDA function: %s\\n\", #name); \\\n            dlclose(libcuda_handle); libcuda_handle = NULL; \\\n            libs_load_attempted.store(1, std::memory_order_release); \\\n            return AVERROR(ENOSYS); \\\n        }\n\n    LOAD_CUDA_FUNC(cuInit);\n    LOAD_CUDA_FUNC(cuDeviceGet);\n    LOAD_CUDA_FUNC(cuDevicePrimaryCtxRetain);\n    LOAD_CUDA_FUNC(cuCtxGetCurrent);\n    LOAD_CUDA_FUNC(cuCtxSetCurrent);\n    LOAD_CUDA_FUNC(cuCtxPushCurrent);\n    LOAD_CUDA_FUNC(cuCtxPopCurrent);\n    LOAD_CUDA_FUNC(cuMemAlloc);\n    LOAD_CUDA_FUNC(cuMemFree);\n    LOAD_CUDA_FUNC(cuMemcpyHtoD);\n    LOAD_CUDA_FUNC(cuMemcpyDtoH);\n    LOAD_CUDA_FUNC(cuMemcpyHtoDAsync);\n    LOAD_CUDA_FUNC(cuMemcpyDtoHAsync);\n    LOAD_CUDA_FUNC(cuStreamCreate);\n    LOAD_CUDA_FUNC(cuStreamDestroy);\n    LOAD_CUDA_FUNC(cuStreamSynchronize);\n    LOAD_CUDA_FUNC(cuModuleLoadData);\n    LOAD_CUDA_FUNC(cuModuleUnload);\n    LOAD_CUDA_FUNC(cuModuleGetFunction);\n    LOAD_CUDA_FUNC(cuLaunchKernel);\n    // cuGetErrorString is optional (for better error messages)\n    p_cuGetErrorString = (fn_cuGetErrorString)dlsym(libcuda_handle, \"cuGetErrorString\");\n\n    // CUDA Graph API (optional - CUDA 10.0+, graceful fallback if unavailable)\n    p_cuStreamBeginCapture = (fn_cuStreamBeginCapture)dlsym(libcuda_handle, \"cuStreamBeginCapture\");\n    p_cuStreamEndCapture = (fn_cuStreamEndCapture)dlsym(libcuda_handle, \"cuStreamEndCapture\");\n    p_cuGraphInstantiate = (fn_cuGraphInstantiate)dlsym(libcuda_handle, \"cuGraphInstantiateWithFlags\");\n    p_cuGraphLaunch = (fn_cuGraphLaunch)dlsym(libcuda_handle, \"cuGraphLaunch\");\n    p_cuGraphDestroy = (fn_cuGraphDestroy)dlsym(libcuda_handle, \"cuGraphDestroy\");\n    p_cuGraphExecDestroy = (fn_cuGraphExecDestroy)dlsym(libcuda_handle, \"cuGraphExecDestroy\");\n\n    // CUDA Graphs disabled - adds ~1GB memory overhead with no fps improvement\n    // (TensorRT is compute-bound in convolutions, not kernel-launch-bound)\n    // Keep the function pointers loaded in case we want to re-enable later\n    (void)p_cuStreamBeginCapture;\n    (void)p_cuStreamEndCapture;\n    (void)p_cuGraphInstantiate;\n    (void)p_cuGraphLaunch;\n    (void)p_cuGraphDestroy;\n    (void)p_cuGraphExecDestroy;\n    cuda_graphs_available = 0;\n\n    #undef LOAD_CUDA_FUNC\n\n    // Initialize CUDA (needed before any other CUDA calls)\n    CUresult err = p_cuInit(0);\n    if (err != CUDA_SUCCESS) {\n        av_log(log_ctx, AV_LOG_ERROR, \"cuInit failed: %s\\n\", cuda_error_string(err));\n        dlclose(libcuda_handle);\n        libcuda_handle = NULL;\n        libs_load_attempted.store(1, std::memory_order_release);\n        return AVERROR(ENOSYS);\n    }\n\n    cuda_loaded = 1;\n    av_log(log_ctx, AV_LOG_INFO, \"CUDA driver API loaded via dlopen\\n\");\n\n    // Load CUDA Runtime API (required for TensorRT compatibility)\n    const char *cudart_names[] = {\n        \"libcudart.so.12\", \"libcudart.so.11\", \"libcudart.so\", NULL\n    };\n    for (int i = 0; cudart_names[i] && !cuda_rt_handle; i++) {\n        cuda_rt_handle = dlopen(cudart_names[i], RTLD_NOW);\n    }\n    if (!cuda_rt_handle) {\n        av_log(log_ctx, AV_LOG_ERROR, \"Failed to load CUDA runtime library (libcudart.so)\\n\");\n        dlclose(libcuda_handle);\n        libcuda_handle = NULL;\n        cuda_loaded = 0;\n        libs_load_attempted.store(1, std::memory_order_release);\n        return AVERROR(ENOSYS);\n    }\n    p_cudaMalloc = (fn_cudaMalloc)dlsym(cuda_rt_handle, \"cudaMalloc\");\n    p_cudaFree = (fn_cudaFree)dlsym(cuda_rt_handle, \"cudaFree\");\n    p_cudaSetDevice = (fn_cudaSetDevice)dlsym(cuda_rt_handle, \"cudaSetDevice\");\n    p_cudaMemcpy = (fn_cudaMemcpy)dlsym(cuda_rt_handle, \"cudaMemcpy\");\n    p_cudaMemcpyAsync = (fn_cudaMemcpyAsync)dlsym(cuda_rt_handle, \"cudaMemcpyAsync\");\n    p_cudaStreamSynchronize_rt = (fn_cudaStreamSynchronize)dlsym(cuda_rt_handle, \"cudaStreamSynchronize\");\n    p_cudaStreamCreate_rt = (fn_cudaStreamCreate)dlsym(cuda_rt_handle, \"cudaStreamCreate\");\n    p_cudaMemGetInfo = (fn_cudaMemGetInfo)dlsym(cuda_rt_handle, \"cudaMemGetInfo\");  // Optional, for diagnostics\n    if (!p_cudaMalloc || !p_cudaFree || !p_cudaSetDevice || !p_cudaMemcpy) {\n        av_log(log_ctx, AV_LOG_ERROR, \"Failed to load CUDA runtime API functions\\n\");\n        dlclose(cuda_rt_handle);\n        cuda_rt_handle = NULL;\n        dlclose(libcuda_handle);\n        libcuda_handle = NULL;\n        cuda_loaded = 0;\n        libs_load_attempted.store(1, std::memory_order_release);\n        return AVERROR(ENOSYS);\n    }\n\n    // Load TensorRT\n    const char *nvinfer_names[] = {\n        \"libnvinfer.so.10\", \"libnvinfer.so.9\", \"libnvinfer.so.8\", \"libnvinfer.so\", NULL\n    };\n    for (int i = 0; nvinfer_names[i] && !libnvinfer_handle; i++) {\n        libnvinfer_handle = dlopen(nvinfer_names[i], RTLD_NOW);\n    }\n    if (!libnvinfer_handle) {\n        av_log(log_ctx, AV_LOG_ERROR,\n               \"TensorRT not available: %s\\n\"\n               \"Install TensorRT or run with --gpus all to use nvidia-container-toolkit\\n\",\n               dlerror());\n        dlclose(cuda_rt_handle);\n        cuda_rt_handle = NULL;\n        dlclose(libcuda_handle);\n        libcuda_handle = NULL;\n        cuda_loaded = 0;\n        libs_load_attempted.store(1, std::memory_order_release);\n        return AVERROR(ENOSYS);\n    }\n\n    // Get TensorRT factory function\n    const char *create_runtime_names[] = {\n        \"createInferRuntime_INTERNAL\",  // TensorRT 10+\n        \"_ZN8nvinfer118createInferRuntimeERNS_7ILoggerE\",  // GCC mangling (TRT 8-9)\n        \"createInferRuntime\",  // Some builds export unmangled\n        NULL\n    };\n    for (int i = 0; create_runtime_names[i] && !p_createInferRuntime; i++) {\n        p_createInferRuntime = (fn_createInferRuntime)dlsym(libnvinfer_handle, create_runtime_names[i]);\n    }\n    if (!p_createInferRuntime) {\n        av_log(log_ctx, AV_LOG_ERROR, \"Failed to find createInferRuntime in TensorRT library\\n\");\n        dlclose(libnvinfer_handle);\n        libnvinfer_handle = NULL;\n        dlclose(cuda_rt_handle);\n        cuda_rt_handle = NULL;\n        dlclose(libcuda_handle);\n        libcuda_handle = NULL;\n        cuda_loaded = 0;\n        libs_load_attempted.store(1, std::memory_order_release);\n        return AVERROR(ENOSYS);\n    }\n\n    tensorrt_loaded = 1;\n    av_log(log_ctx, AV_LOG_INFO, \"TensorRT library loaded via dlopen\\n\");\n\n    // Mark as attempted AFTER successful initialization (release semantics)\n    libs_load_attempted.store(1, std::memory_order_release);\n    return 0;\n}\n\n// Log GPU memory usage for diagnostics\nstatic void log_gpu_memory(void *log_ctx, const char *label) {\n    if (!p_cudaMemGetInfo) return;\n    size_t free_mem = 0, total_mem = 0;\n    if (p_cudaMemGetInfo(&free_mem, &total_mem) == 0) {\n        size_t used_mb = (total_mem - free_mem) / (1024 * 1024);\n        av_log(log_ctx, AV_LOG_WARNING, \"GPU_MEM [%s]: %zu MiB used\\n\", label, used_mb);\n    }\n}\n\n// TensorRT logger - forward to FFmpeg's logging\nclass TRTLogger : public nvinfer1::ILogger {\npublic:\n    void *log_ctx;\n    TRTLogger(void *ctx = nullptr) : log_ctx(ctx) {}\n\n    void log(Severity severity, const char *msg) noexcept override {\n        int level;\n        switch (severity) {\n            case Severity::kINTERNAL_ERROR:\n            case Severity::kERROR:\n                level = AV_LOG_ERROR;\n                break;\n            case Severity::kWARNING:\n                level = AV_LOG_WARNING;\n                break;\n            case Severity::kINFO:\n                level = AV_LOG_INFO;\n                break;\n            default:\n                level = AV_LOG_DEBUG;\n                break;\n        }\n        av_log(log_ctx, level, \"TensorRT: %s\\n\", msg);\n    }\n};\n\n// Supported tensor data types\ntypedef enum TRTDataType {\n    TRT_DT_FLOAT32 = 0,  // 4 bytes\n    TRT_DT_FLOAT16 = 1,  // 2 bytes\n    TRT_DT_BFLOAT16 = 2, // 2 bytes\n    TRT_DT_INT8 = 3,     // 1 byte\n    TRT_DT_UINT8 = 4,    // 1 byte\n    TRT_DT_UNKNOWN = -1\n} TRTDataType;\n\nstatic const char *trt_dtype_name(TRTDataType dt) {\n    switch (dt) {\n        case TRT_DT_FLOAT32: return \"FP32\";\n        case TRT_DT_FLOAT16: return \"FP16\";\n        case TRT_DT_BFLOAT16: return \"BF16\";\n        case TRT_DT_INT8: return \"INT8\";\n        case TRT_DT_UINT8: return \"UINT8\";\n        default: return \"UNKNOWN\";\n    }\n}\n\nstatic size_t trt_dtype_size(TRTDataType dt) {\n    switch (dt) {\n        case TRT_DT_FLOAT32: return 4;\n        case TRT_DT_FLOAT16: return 2;\n        case TRT_DT_BFLOAT16: return 2;\n        case TRT_DT_INT8: return 1;\n        case TRT_DT_UINT8: return 1;\n        default: return 0;\n    }\n}\n\nstatic TRTDataType nvinfer_to_trt_dtype(nvinfer1::DataType dt) {\n    switch (dt) {\n        case nvinfer1::DataType::kFLOAT: return TRT_DT_FLOAT32;\n        case nvinfer1::DataType::kHALF: return TRT_DT_FLOAT16;\n        case nvinfer1::DataType::kBF16: return TRT_DT_BFLOAT16;\n        case nvinfer1::DataType::kINT8: return TRT_DT_INT8;\n        case nvinfer1::DataType::kUINT8: return TRT_DT_UINT8;\n        default: return TRT_DT_UNKNOWN;\n    }\n}\n\ntypedef struct TRTModel {\n    DNNModel model;\n    DnnContext *ctx;\n    nvinfer1::IRuntime *runtime;\n    nvinfer1::ICudaEngine *engine;\n    nvinfer1::IExecutionContext *context;  // Lazily created on first inference\n    TRTLogger *logger;\n    CUstream stream;\n\n    // CUDA Graphs for reduced kernel launch overhead\n    CUgraph cuda_graph;\n    CUgraphExec cuda_graph_exec;\n    int cuda_graph_captured;  // 1 if graph has been captured and is ready\n    int cuda_graph_failed;    // 1 if capture failed, don't retry\n\n    // Engine cache entry (for refcounting shared engines)\n    CachedEngine *cached_engine;\n    char *engine_path;  // Key for cache lookup (av_strdup'd)\n\n    // CUDA kernel module (loaded from PTX)\n    CUmodule cuda_module;\n    // Kernels for each dtype: [0]=FP32, [1]=FP16, [2]=BF16\n    CUfunction kernel_hwc_to_nchw[3];\n    CUfunction kernel_nchw_to_hwc[3];\n    CUfunction kernel_hwc4_to_nchw[3];\n    CUfunction kernel_nchw_to_hwc4[3];\n\n    // I/O tensor info (TensorRT 10.x API)\n    char *input_name;\n    char *output_name;\n    nvinfer1::Dims input_dims;\n    nvinfer1::Dims output_dims;\n    TRTDataType input_dtype;\n    TRTDataType output_dtype;\n\n    // CUDA buffers (using CUdeviceptr for Driver API)\n    CUdeviceptr input_buffer;\n    CUdeviceptr output_buffer;\n    size_t input_size;\n    size_t output_size;\n\n    // Task management (reuse FFmpeg's queue infrastructure)\n    SafeQueue *request_queue;\n    Queue *task_queue;\n    Queue *lltask_queue;\n} TRTModel;\n\ntypedef struct TRTInferRequest {\n    float *output_data;  // CPU output buffer\n} TRTInferRequest;\n\ntypedef struct TRTRequestItem {\n    TRTInferRequest *infer_request;\n    LastLevelTaskItem *lltask;\n    DNNAsyncExecModule exec_module;\n} TRTRequestItem;\n\n#define OFFSET(x) offsetof(TRTOptions, x)\n#define FLAGS AV_OPT_FLAG_FILTERING_PARAM\n\nstatic const AVOption dnn_trt_options[] = {\n    { \"device_id\", \"CUDA device ID\", OFFSET(device_id), AV_OPT_TYPE_INT, { .i64 = 0 }, 0, 16, FLAGS },\n    { NULL }\n};\n\n// Check CUDA error and log\n#define CUDA_CHECK(call, ctx, ret) do { \\\n    CUresult cuda_err = (call); \\\n    if (cuda_err != CUDA_SUCCESS) { \\\n        av_log(ctx, AV_LOG_ERROR, \"CUDA error: %s\\n\", cuda_error_string(cuda_err)); \\\n        return ret; \\\n    } \\\n} while(0)\n\n// Load CUDA kernels from embedded PTX\nstatic int load_cuda_kernels(TRTModel *trt_model, void *log_ctx) {\n    CUresult err;\n\n    // Load PTX module\n    err = p_cuModuleLoadData(&trt_model->cuda_module, ff_dnn_cuda_kernels_ptx);\n    if (err != CUDA_SUCCESS) {\n        av_log(log_ctx, AV_LOG_ERROR, \"Failed to load CUDA kernel module: %s\\n\",\n               cuda_error_string(err));\n        return AVERROR(ENOSYS);\n    }\n\n    // Kernel names for each dtype: [0]=FP32, [1]=FP16, [2]=BF16\n    const char *hwc_to_nchw_names[] = {\n        DNN_CUDA_KERNEL_HWC_UINT8_TO_NCHW_FLOAT32,\n        DNN_CUDA_KERNEL_HWC_UINT8_TO_NCHW_FLOAT16,\n        DNN_CUDA_KERNEL_HWC_UINT8_TO_NCHW_BFLOAT16\n    };\n    const char *nchw_to_hwc_names[] = {\n        DNN_CUDA_KERNEL_NCHW_FLOAT32_TO_HWC_UINT8,\n        DNN_CUDA_KERNEL_NCHW_FLOAT16_TO_HWC_UINT8,\n        DNN_CUDA_KERNEL_NCHW_BFLOAT16_TO_HWC_UINT8\n    };\n    const char *hwc4_to_nchw_names[] = {\n        DNN_CUDA_KERNEL_HWC4_UINT8_TO_NCHW_FLOAT32,\n        DNN_CUDA_KERNEL_HWC4_UINT8_TO_NCHW_FLOAT16,\n        DNN_CUDA_KERNEL_HWC4_UINT8_TO_NCHW_BFLOAT16\n    };\n    const char *nchw_to_hwc4_names[] = {\n        DNN_CUDA_KERNEL_NCHW_FLOAT32_TO_HWC4_UINT8,\n        DNN_CUDA_KERNEL_NCHW_FLOAT16_TO_HWC4_UINT8,\n        DNN_CUDA_KERNEL_NCHW_BFLOAT16_TO_HWC4_UINT8\n    };\n\n    // Load all kernel variants\n    for (int i = 0; i < 3; i++) {\n        err = p_cuModuleGetFunction(&trt_model->kernel_hwc_to_nchw[i], trt_model->cuda_module, hwc_to_nchw_names[i]);\n        if (err != CUDA_SUCCESS) {\n            av_log(log_ctx, AV_LOG_ERROR, \"Failed to get kernel %s: %s\\n\", hwc_to_nchw_names[i], cuda_error_string(err));\n            p_cuModuleUnload(trt_model->cuda_module);\n            trt_model->cuda_module = NULL;\n            return AVERROR(ENOSYS);\n        }\n\n        err = p_cuModuleGetFunction(&trt_model->kernel_nchw_to_hwc[i], trt_model->cuda_module, nchw_to_hwc_names[i]);\n        if (err != CUDA_SUCCESS) {\n            av_log(log_ctx, AV_LOG_ERROR, \"Failed to get kernel %s: %s\\n\", nchw_to_hwc_names[i], cuda_error_string(err));\n            p_cuModuleUnload(trt_model->cuda_module);\n            trt_model->cuda_module = NULL;\n            return AVERROR(ENOSYS);\n        }\n\n        err = p_cuModuleGetFunction(&trt_model->kernel_hwc4_to_nchw[i], trt_model->cuda_module, hwc4_to_nchw_names[i]);\n        if (err != CUDA_SUCCESS) {\n            av_log(log_ctx, AV_LOG_ERROR, \"Failed to get kernel %s: %s\\n\", hwc4_to_nchw_names[i], cuda_error_string(err));\n            p_cuModuleUnload(trt_model->cuda_module);\n            trt_model->cuda_module = NULL;\n            return AVERROR(ENOSYS);\n        }\n\n        err = p_cuModuleGetFunction(&trt_model->kernel_nchw_to_hwc4[i], trt_model->cuda_module, nchw_to_hwc4_names[i]);\n        if (err != CUDA_SUCCESS) {\n            av_log(log_ctx, AV_LOG_ERROR, \"Failed to get kernel %s: %s\\n\", nchw_to_hwc4_names[i], cuda_error_string(err));\n            p_cuModuleUnload(trt_model->cuda_module);\n            trt_model->cuda_module = NULL;\n            return AVERROR(ENOSYS);\n        }\n    }\n\n    av_log(log_ctx, AV_LOG_INFO, \"CUDA format conversion kernels loaded (FP32/FP16/BF16)\\n\");\n    return 0;\n}\n\n// Launch kernel with Driver API\nstatic int launch_kernel(CUfunction func, CUstream stream,\n                         int width, int height, void **args, void *log_ctx) {\n    // 32x8 thread blocks (better warp utilization for row-major image access)\n    unsigned int block_x = 32, block_y = 8;\n    unsigned int grid_x = (width + block_x - 1) / block_x;\n    unsigned int grid_y = (height + block_y - 1) / block_y;\n\n    CUresult err = p_cuLaunchKernel(func,\n                                     grid_x, grid_y, 1,      // grid dimensions\n                                     block_x, block_y, 1,    // block dimensions\n                                     0,                       // shared memory\n                                     stream,                  // stream\n                                     args,                    // kernel arguments\n                                     NULL);                   // extra\n    if (err != CUDA_SUCCESS) {\n        av_log(log_ctx, AV_LOG_ERROR, \"Kernel launch failed: %s\\n\", cuda_error_string(err));\n        return AVERROR(EIO);\n    }\n    return 0;\n}\n\n// Lazily create execution context on first inference\n// Returns 0 on success, negative AVERROR on failure\nstatic int ensure_execution_context(TRTModel *trt_model, void *log_ctx)\n{\n    if (trt_model->context)\n        return 0;  // Already created\n\n    av_log(log_ctx, AV_LOG_INFO, \"Creating TensorRT execution context (lazy init)\\n\");\n\n    trt_model->context = trt_model->engine->createExecutionContext();\n    if (!trt_model->context) {\n        av_log(log_ctx, AV_LOG_ERROR, \"Failed to create execution context\\n\");\n        return AVERROR(ENOMEM);\n    }\n    log_gpu_memory(log_ctx, \"after createExecutionContext\");\n\n    // Allocate GPU buffers now that we have context\n    if (!trt_model->input_buffer) {\n        void *input_ptr = NULL, *output_ptr = NULL;\n        cudaError_t cuda_err = p_cudaMalloc(&input_ptr, trt_model->input_size);\n        if (cuda_err != cudaSuccess) {\n            av_log(log_ctx, AV_LOG_ERROR, \"cudaMalloc failed for input buffer: %d\\n\", cuda_err);\n            delete trt_model->context;\n            trt_model->context = nullptr;\n            return AVERROR(ENOMEM);\n        }\n        trt_model->input_buffer = (CUdeviceptr)input_ptr;\n\n        cuda_err = p_cudaMalloc(&output_ptr, trt_model->output_size);\n        if (cuda_err != cudaSuccess) {\n            av_log(log_ctx, AV_LOG_ERROR, \"cudaMalloc failed for output buffer: %d\\n\", cuda_err);\n            p_cudaFree((void*)trt_model->input_buffer);\n            trt_model->input_buffer = 0;\n            delete trt_model->context;\n            trt_model->context = nullptr;\n            return AVERROR(ENOMEM);\n        }\n        trt_model->output_buffer = (CUdeviceptr)output_ptr;\n\n        // Set tensor addresses\n        if (!trt_model->context->setTensorAddress(trt_model->input_name, (void*)trt_model->input_buffer)) {\n            av_log(log_ctx, AV_LOG_ERROR, \"Failed to set input tensor address\\n\");\n            p_cudaFree((void*)trt_model->input_buffer);\n            p_cudaFree((void*)trt_model->output_buffer);\n            trt_model->input_buffer = 0;\n            trt_model->output_buffer = 0;\n            delete trt_model->context;\n            trt_model->context = nullptr;\n            return AVERROR(EINVAL);\n        }\n        if (!trt_model->context->setTensorAddress(trt_model->output_name, (void*)trt_model->output_buffer)) {\n            av_log(log_ctx, AV_LOG_ERROR, \"Failed to set output tensor address\\n\");\n            p_cudaFree((void*)trt_model->input_buffer);\n            p_cudaFree((void*)trt_model->output_buffer);\n            trt_model->input_buffer = 0;\n            trt_model->output_buffer = 0;\n            delete trt_model->context;\n            trt_model->context = nullptr;\n            return AVERROR(EINVAL);\n        }\n\n        av_log(log_ctx, AV_LOG_INFO, \"  Allocated GPU buffers: input=%zuMB output=%zuMB\\n\",\n               trt_model->input_size / (1024 * 1024), trt_model->output_size / (1024 * 1024));\n        log_gpu_memory(log_ctx, \"after buffer allocation\");\n    }\n\n    return 0;\n}\n\nstatic int extract_lltask_from_task(TaskItem *task, Queue *lltask_queue)\n{\n    TRTModel *trt_model = (TRTModel *)task->model;\n    DnnContext *ctx = trt_model->ctx;\n    LastLevelTaskItem *lltask = (LastLevelTaskItem *)av_malloc(sizeof(*lltask));\n    if (!lltask) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to allocate memory for LastLevelTaskItem\\n\");\n        return AVERROR(ENOMEM);\n    }\n    task->inference_todo = 1;\n    task->inference_done = 0;\n    lltask->task = task;\n    if (ff_queue_push_back(lltask_queue, lltask) < 0) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to push back lltask_queue.\\n\");\n        av_freep(&lltask);\n        return AVERROR(ENOMEM);\n    }\n    return 0;\n}\n\nstatic void trt_free_request(TRTInferRequest *request)\n{\n    if (!request)\n        return;\n    if (request->output_data) {\n        av_freep(&request->output_data);\n    }\n}\n\nstatic inline void destroy_request_item(TRTRequestItem **arg)\n{\n    TRTRequestItem *item;\n    if (!arg || !*arg)\n        return;\n    item = *arg;\n    trt_free_request(item->infer_request);\n    av_freep(&item->infer_request);\n    av_freep(&item->lltask);\n    ff_dnn_async_module_cleanup(&item->exec_module);\n    av_freep(arg);\n}\n\nstatic void dnn_free_model_trt(DNNModel **model)\n{\n    TRTModel *trt_model;\n    if (!model || !*model)\n        return;\n\n    trt_model = (TRTModel *)(*model);\n\n    // Synchronize stream before cleanup to ensure all GPU operations complete\n    if (trt_model->stream && p_cuStreamSynchronize) {\n        p_cuStreamSynchronize(trt_model->stream);\n    }\n\n    // Free CUDA Graph resources\n    if (trt_model->cuda_graph_exec && p_cuGraphExecDestroy) {\n        p_cuGraphExecDestroy(trt_model->cuda_graph_exec);\n        trt_model->cuda_graph_exec = NULL;\n    }\n    if (trt_model->cuda_graph && p_cuGraphDestroy) {\n        p_cuGraphDestroy(trt_model->cuda_graph);\n        trt_model->cuda_graph = NULL;\n    }\n\n    // Free CUDA resources (using Runtime API - must match cudaMalloc allocation)\n    if (trt_model->input_buffer && p_cudaFree) {\n        p_cudaFree((void*)trt_model->input_buffer);\n        trt_model->input_buffer = 0;\n    }\n    if (trt_model->output_buffer && p_cudaFree) {\n        p_cudaFree((void*)trt_model->output_buffer);\n        trt_model->output_buffer = 0;\n    }\n    if (trt_model->cuda_module && p_cuModuleUnload) {\n        p_cuModuleUnload(trt_model->cuda_module);\n        trt_model->cuda_module = NULL;\n    }\n    if (trt_model->stream && p_cuStreamDestroy) {\n        p_cuStreamDestroy(trt_model->stream);\n        trt_model->stream = NULL;\n    }\n\n    // Free tensor names (engine_path freed after cache cleanup)\n    av_freep(&trt_model->input_name);\n    av_freep(&trt_model->output_name);\n\n    // Free TensorRT resources\n    if (trt_model->context) {\n        delete trt_model->context;\n        trt_model->context = nullptr;\n    }\n\n    // Handle cached engine - decrement refcount and only free when last reference\n    if (trt_model->cached_engine) {\n        std::lock_guard<std::mutex> lock(g_engine_cache_mutex);\n        // Use atomic fetch_sub to avoid race condition - returns OLD value\n        int old_refcount = trt_model->cached_engine->refcount.fetch_sub(1, std::memory_order_acq_rel);\n        int remaining = old_refcount - 1;\n        av_log(trt_model->ctx, AV_LOG_DEBUG, \"Engine refcount: %d -> %d (path=%s)\\n\",\n               old_refcount, remaining, trt_model->engine_path ? trt_model->engine_path : \"null\");\n        if (remaining == 0) {\n            // Last reference - remove from cache and delete\n            if (trt_model->engine_path) {\n                std::string path_key(trt_model->engine_path);\n                size_t erased = g_engine_cache.erase(path_key);\n                av_log(trt_model->ctx, AV_LOG_DEBUG, \"Erased %zu entries from cache\\n\", erased);\n            }\n            if (trt_model->cached_engine->engine) {\n                delete trt_model->cached_engine->engine;\n            }\n            if (trt_model->cached_engine->runtime) {\n                delete trt_model->cached_engine->runtime;\n            }\n            delete trt_model->cached_engine;\n            av_log(trt_model->ctx, AV_LOG_DEBUG, \"Released last reference to cached engine\\n\");\n        } else if (remaining < 0) {\n            av_log(trt_model->ctx, AV_LOG_ERROR, \"BUG: Engine refcount went negative (%d)!\\n\", remaining);\n        } else {\n            av_log(trt_model->ctx, AV_LOG_DEBUG, \"Released engine reference (remaining=%d)\\n\", remaining);\n        }\n        trt_model->cached_engine = nullptr;\n        trt_model->engine = nullptr;\n        trt_model->runtime = nullptr;\n    } else {\n        // Not cached (shouldn't happen normally, but handle gracefully)\n        if (trt_model->engine) {\n            delete trt_model->engine;\n            trt_model->engine = nullptr;\n        }\n        if (trt_model->runtime) {\n            delete trt_model->runtime;\n            trt_model->runtime = nullptr;\n        }\n    }\n\n    if (trt_model->logger) {\n        delete trt_model->logger;\n        trt_model->logger = nullptr;\n    }\n\n    // Free engine path (after cache cleanup which uses it)\n    av_freep(&trt_model->engine_path);\n\n    // Free queues\n    if (trt_model->request_queue) {\n        while (ff_safe_queue_size(trt_model->request_queue) != 0) {\n            TRTRequestItem *item = (TRTRequestItem *)ff_safe_queue_pop_front(trt_model->request_queue);\n            destroy_request_item(&item);\n        }\n        ff_safe_queue_destroy(trt_model->request_queue);\n    }\n    if (trt_model->lltask_queue) {\n        while (ff_queue_size(trt_model->lltask_queue) != 0) {\n            LastLevelTaskItem *item = (LastLevelTaskItem *)ff_queue_pop_front(trt_model->lltask_queue);\n            av_freep(&item);\n        }\n        ff_queue_destroy(trt_model->lltask_queue);\n    }\n    if (trt_model->task_queue) {\n        while (ff_queue_size(trt_model->task_queue) != 0) {\n            TaskItem *item = (TaskItem *)ff_queue_pop_front(trt_model->task_queue);\n            av_frame_free(&item->in_frame);\n            av_frame_free(&item->out_frame);\n            av_freep(&item);\n        }\n        ff_queue_destroy(trt_model->task_queue);\n    }\n\n    av_freep(&trt_model);\n    *model = NULL;\n}\n\nstatic int get_input_trt(DNNModel *model, DNNData *input, const char *input_name)\n{\n    TRTModel *trt_model = (TRTModel *)model;\n\n    // Validate tensor has expected dimensions (NCHW = 4)\n    if (trt_model->input_dims.nbDims != 4) {\n        av_log(trt_model->ctx, AV_LOG_ERROR,\n               \"Expected 4D input tensor (NCHW), got %d dimensions\\n\",\n               trt_model->input_dims.nbDims);\n        return AVERROR(EINVAL);\n    }\n\n    input->dt = DNN_FLOAT;\n    input->order = DCO_RGB;\n    input->layout = DL_NCHW;\n\n    // Get dimensions from engine\n    input->dims[0] = trt_model->input_dims.d[0];  // N (batch)\n    input->dims[1] = trt_model->input_dims.d[1];  // C (channels)\n    input->dims[2] = trt_model->input_dims.d[2];  // H (height)\n    input->dims[3] = trt_model->input_dims.d[3];  // W (width)\n\n    return 0;\n}\n\nstatic int fill_model_input_trt(TRTModel *trt_model, TRTRequestItem *request)\n{\n    LastLevelTaskItem *lltask = NULL;\n    TaskItem *task = NULL;\n    DNNData input = { 0 };\n    DnnContext *ctx = trt_model->ctx;\n    int ret;\n\n    // Ensure execution context and buffers are created (lazy initialization)\n    ret = ensure_execution_context(trt_model, ctx);\n    if (ret < 0) {\n        return ret;\n    }\n\n    lltask = (LastLevelTaskItem *)ff_queue_pop_front(trt_model->lltask_queue);\n    if (!lltask) {\n        return AVERROR(EINVAL);\n    }\n    request->lltask = lltask;\n    task = lltask->task;\n\n    ret = get_input_trt(&trt_model->model, &input, NULL);\n    if (ret != 0) {\n        return ret;\n    }\n\n    int height_idx = dnn_get_height_idx_by_layout(input.layout);\n    int width_idx = dnn_get_width_idx_by_layout(input.layout);\n\n    // Check input dimensions match engine\n    if (task->in_frame->height != input.dims[height_idx] ||\n        task->in_frame->width != input.dims[width_idx]) {\n        av_log(ctx, AV_LOG_ERROR, \"Input size %dx%d doesn't match engine's expected %dx%d\\n\",\n               task->in_frame->width, task->in_frame->height,\n               input.dims[width_idx], input.dims[height_idx]);\n        return AVERROR(EINVAL);\n    }\n\n    int width = task->in_frame->width;\n    int height = task->in_frame->height;\n\n    // Check for CUDA hardware frames (zero-copy input path)\n    if (task->in_frame->format == AV_PIX_FMT_CUDA && task->in_frame->hw_frames_ctx) {\n        AVHWFramesContext *hw_frames = (AVHWFramesContext *)task->in_frame->hw_frames_ctx->data;\n        int linesize = task->in_frame->linesize[0];\n        CUdeviceptr cuda_data = (CUdeviceptr)task->in_frame->data[0];\n        int dtype_idx = (int)trt_model->input_dtype;  // Kernel array index: 0=FP32, 1=FP16, 2=BF16\n\n        // For RGB24/BGR24: convert uint8 HWC to NCHW on GPU (zero-copy)\n        if (hw_frames->sw_format == AV_PIX_FMT_RGB24 || hw_frames->sw_format == AV_PIX_FMT_BGR24) {\n            void *args[] = {&cuda_data, &trt_model->input_buffer, &height, &width, &linesize};\n            ret = launch_kernel(trt_model->kernel_hwc_to_nchw[dtype_idx], trt_model->stream,\n                               width, height, args, ctx);\n            if (ret != 0) return ret;\n            return 0;\n        }\n\n        // For 4-channel formats (RGB0, RGBA, BGR0, BGRA)\n        if (hw_frames->sw_format == AV_PIX_FMT_RGB0 || hw_frames->sw_format == AV_PIX_FMT_BGR0 ||\n            hw_frames->sw_format == AV_PIX_FMT_RGBA || hw_frames->sw_format == AV_PIX_FMT_BGRA) {\n            int r_off = 0, g_off = 1, b_off = 2;\n            if (hw_frames->sw_format == AV_PIX_FMT_BGR0 || hw_frames->sw_format == AV_PIX_FMT_BGRA) {\n                r_off = 2; b_off = 0;\n            }\n            void *args[] = {&cuda_data, &trt_model->input_buffer, &height, &width, &linesize,\n                           &r_off, &g_off, &b_off};\n            ret = launch_kernel(trt_model->kernel_hwc4_to_nchw[dtype_idx], trt_model->stream,\n                               width, height, args, ctx);\n            if (ret != 0) return ret;\n            return 0;\n        }\n\n        // For 0RGB/ARGB formats (alpha first)\n        if (hw_frames->sw_format == AV_PIX_FMT_0RGB || hw_frames->sw_format == AV_PIX_FMT_0BGR ||\n            hw_frames->sw_format == AV_PIX_FMT_ARGB || hw_frames->sw_format == AV_PIX_FMT_ABGR) {\n            int r_off = 1, g_off = 2, b_off = 3;\n            if (hw_frames->sw_format == AV_PIX_FMT_0BGR || hw_frames->sw_format == AV_PIX_FMT_ABGR) {\n                r_off = 3; b_off = 1;\n            }\n            void *args[] = {&cuda_data, &trt_model->input_buffer, &height, &width, &linesize,\n                           &r_off, &g_off, &b_off};\n            ret = launch_kernel(trt_model->kernel_hwc4_to_nchw[dtype_idx], trt_model->stream,\n                               width, height, args, ctx);\n            if (ret != 0) return ret;\n            return 0;\n        }\n\n        av_log(ctx, AV_LOG_WARNING, \"CUDA sw_format %s not supported for zero-copy, using CPU path\\n\",\n               av_get_pix_fmt_name(hw_frames->sw_format));\n    }\n\n    // Standard CPU path - only supports FP32 engines\n    // For FP16/BF16, use CUDA hw frames for zero-copy path\n    if (trt_model->input_dtype != TRT_DT_FLOAT32) {\n        av_log(ctx, AV_LOG_ERROR, \"CPU input path only supports FP32 engines, got %s. \"\n               \"Use hwupload to provide CUDA frames for FP16/BF16 zero-copy.\\n\",\n               trt_dtype_name(trt_model->input_dtype));\n        return AVERROR(ENOSYS);\n    }\n\n    size_t input_elements = input.dims[0] * input.dims[1] * input.dims[2] * input.dims[3];\n    float *input_data = (float *)av_malloc(input_elements * sizeof(float));\n    if (!input_data) {\n        return AVERROR(ENOMEM);\n    }\n\n    input.data = input_data;\n    input.scale = 255;\n\n    switch (trt_model->model.func_type) {\n    case DFT_PROCESS_FRAME:\n        if (task->do_ioproc) {\n            if (trt_model->model.frame_pre_proc != NULL) {\n                trt_model->model.frame_pre_proc(task->in_frame, &input, trt_model->model.filter_ctx);\n            } else {\n                ff_proc_from_frame_to_dnn(task->in_frame, &input, ctx);\n            }\n        }\n        break;\n    default:\n        av_log(ctx, AV_LOG_ERROR, \"Unsupported model function type %d\\n\", trt_model->model.func_type);\n        av_freep(&input_data);\n        return AVERROR(EINVAL);\n    }\n\n    // Copy input to GPU using CUDA Runtime API (compatible with TensorRT's Runtime API context)\n    cudaError_t cuda_err = p_cudaMemcpy((void*)trt_model->input_buffer, input_data,\n                                         trt_model->input_size, cudaMemcpyHostToDevice);\n    if (cuda_err != cudaSuccess) {\n        av_log(ctx, AV_LOG_ERROR, \"cudaMemcpy failed for input: %d\\n\", cuda_err);\n        av_freep(&input_data);\n        return AVERROR(EIO);\n    }\n\n    av_freep(&input_data);\n    return 0;\n}\n\n// Capture TensorRT inference into a CUDA Graph for reduced kernel launch overhead\n// Returns 0 on success, negative on failure (non-fatal, falls back to regular enqueue)\nstatic int trt_capture_cuda_graph(TRTModel *trt_model, void *log_ctx)\n{\n    CUresult err;\n\n    if (!cuda_graphs_available || trt_model->cuda_graph_failed) {\n        return -1;\n    }\n\n    av_log(log_ctx, AV_LOG_INFO, \"Capturing TensorRT inference into CUDA Graph...\\n\");\n\n    // Synchronize stream before capture to ensure any pending work (e.g., input kernel) completes\n    // This prevents undefined behavior from capturing a stream with pending async operations\n    err = p_cuStreamSynchronize(trt_model->stream);\n    if (err != CUDA_SUCCESS) {\n        av_log(log_ctx, AV_LOG_WARNING, \"Stream sync before graph capture failed: %s\\n\", cuda_error_string(err));\n        trt_model->cuda_graph_failed = 1;\n        return -1;\n    }\n\n    // Begin stream capture\n    err = p_cuStreamBeginCapture(trt_model->stream, CU_STREAM_CAPTURE_MODE_GLOBAL);\n    if (err != CUDA_SUCCESS) {\n        av_log(log_ctx, AV_LOG_WARNING, \"CUDA Graph capture begin failed: %s\\n\", cuda_error_string(err));\n        trt_model->cuda_graph_failed = 1;\n        return -1;\n    }\n\n    // Execute TensorRT inference (this gets captured into the graph)\n    bool success = trt_model->context->enqueueV3((cudaStream_t)trt_model->stream);\n    if (!success) {\n        // End capture to clean up, ignore the graph\n        CUgraph temp_graph = NULL;\n        p_cuStreamEndCapture(trt_model->stream, &temp_graph);\n        if (temp_graph) p_cuGraphDestroy(temp_graph);\n        av_log(log_ctx, AV_LOG_WARNING, \"TensorRT inference failed during graph capture\\n\");\n        trt_model->cuda_graph_failed = 1;\n        return -1;\n    }\n\n    // End stream capture\n    err = p_cuStreamEndCapture(trt_model->stream, &trt_model->cuda_graph);\n    if (err != CUDA_SUCCESS || !trt_model->cuda_graph) {\n        av_log(log_ctx, AV_LOG_WARNING, \"CUDA Graph capture end failed: %s\\n\", cuda_error_string(err));\n        trt_model->cuda_graph_failed = 1;\n        return -1;\n    }\n\n    // Instantiate the graph for execution\n    err = p_cuGraphInstantiate(&trt_model->cuda_graph_exec, trt_model->cuda_graph, 0);\n    if (err != CUDA_SUCCESS || !trt_model->cuda_graph_exec) {\n        av_log(log_ctx, AV_LOG_WARNING, \"CUDA Graph instantiate failed: %s\\n\", cuda_error_string(err));\n        p_cuGraphDestroy(trt_model->cuda_graph);\n        trt_model->cuda_graph = NULL;\n        trt_model->cuda_graph_failed = 1;\n        return -1;\n    }\n\n    trt_model->cuda_graph_captured = 1;\n    av_log(log_ctx, AV_LOG_INFO, \"CUDA Graph captured successfully (reduced kernel launch overhead)\\n\");\n    return 0;\n}\n\nstatic int trt_start_inference(void *args)\n{\n    TRTRequestItem *request = (TRTRequestItem *)args;\n    LastLevelTaskItem *lltask;\n    TaskItem *task;\n    TRTModel *trt_model;\n    DnnContext *ctx;\n\n    if (!request || !request->lltask) {\n        av_log(NULL, AV_LOG_ERROR, \"TRTRequestItem or lltask is NULL\\n\");\n        return AVERROR(EINVAL);\n    }\n    lltask = request->lltask;\n    task = lltask->task;\n    trt_model = (TRTModel *)task->model;\n    ctx = trt_model->ctx;\n\n    // Validate required resources exist\n    if (!trt_model->context || !trt_model->stream) {\n        av_log(ctx, AV_LOG_ERROR, \"TensorRT context or CUDA stream not initialized\\n\");\n        return DNN_GENERIC_ERROR;\n    }\n\n    // NOTE: Tensor addresses are set once during model load (not per-frame)\n    // since input/output buffers are persistent\n\n    // Try to use CUDA Graph for reduced kernel launch overhead\n    // First frame: capture the graph; subsequent frames: launch the captured graph\n    if (cuda_graphs_available && !trt_model->cuda_graph_captured && !trt_model->cuda_graph_failed) {\n        // Capture on first inference\n        if (trt_capture_cuda_graph(trt_model, ctx) == 0) {\n            // Graph captured - we already ran inference during capture, so return\n            return 0;\n        }\n        // Capture failed - fall through to regular execution\n    }\n\n    if (trt_model->cuda_graph_captured && trt_model->cuda_graph_exec) {\n        // Execute the captured graph (much lower overhead than enqueueV3)\n        CUresult err = p_cuGraphLaunch(trt_model->cuda_graph_exec, trt_model->stream);\n        if (err != CUDA_SUCCESS) {\n            av_log(ctx, AV_LOG_ERROR, \"CUDA Graph launch failed: %s\\n\", cuda_error_string(err));\n            return DNN_GENERIC_ERROR;\n        }\n    } else {\n        // Regular execution path (fallback if graphs not available or capture failed)\n        bool success = trt_model->context->enqueueV3((cudaStream_t)trt_model->stream);\n        if (!success) {\n            av_log(ctx, AV_LOG_ERROR, \"TensorRT inference failed\\n\");\n            return DNN_GENERIC_ERROR;\n        }\n    }\n\n    // NOTE: No sync here - for zero-copy paths, we sync once after the output kernel\n    // For CPU paths, we sync before cudaMemcpy DtoH in infer_completion_callback\n\n    return 0;\n}\n\nstatic void infer_completion_callback(void *args)\n{\n    TRTRequestItem *request = (TRTRequestItem *)args;\n    LastLevelTaskItem *lltask = request->lltask;\n    TaskItem *task = lltask->task;\n    TRTModel *trt_model = (TRTModel *)task->model;\n    DnnContext *ctx = trt_model->ctx;\n    DNNData outputs = { 0 };\n    float *output_data = NULL;\n    size_t output_elements;\n    int ret;\n\n    // Output dimensions are validated during model loading, safe to access\n    outputs.order = DCO_RGB;\n    outputs.layout = DL_NCHW;\n    outputs.dt = DNN_FLOAT;\n    outputs.dims[0] = trt_model->output_dims.d[0];  // N\n    outputs.dims[1] = trt_model->output_dims.d[1];  // C\n    outputs.dims[2] = trt_model->output_dims.d[2];  // H\n    outputs.dims[3] = trt_model->output_dims.d[3];  // W\n\n    int out_height = outputs.dims[2];\n    int out_width = outputs.dims[3];\n\n    // Validate stream exists (should always be true if model loaded successfully)\n    if (!trt_model->stream) {\n        av_log(ctx, AV_LOG_ERROR, \"CUDA stream is NULL\\n\");\n        goto err;\n    }\n\n    // Check for CUDA output frames (zero-copy output path)\n    if (task->out_frame->format == AV_PIX_FMT_CUDA && task->out_frame->hw_frames_ctx) {\n        AVHWFramesContext *hw_frames = (AVHWFramesContext *)task->out_frame->hw_frames_ctx->data;\n        int out_linesize = task->out_frame->linesize[0];\n        CUdeviceptr cuda_out = (CUdeviceptr)task->out_frame->data[0];\n        int dtype_idx = (int)trt_model->output_dtype;  // Kernel array index: 0=FP32, 1=FP16, 2=BF16\n\n        // For RGB24/BGR24: convert NCHW to uint8 HWC on GPU (zero-copy)\n        if (hw_frames->sw_format == AV_PIX_FMT_RGB24 || hw_frames->sw_format == AV_PIX_FMT_BGR24) {\n            void *args[] = {&trt_model->output_buffer, &cuda_out, &out_height, &out_width, &out_linesize};\n            ret = launch_kernel(trt_model->kernel_nchw_to_hwc[dtype_idx], trt_model->stream,\n                               out_width, out_height, args, ctx);\n            if (ret != 0) goto err;\n\n            if (p_cuStreamSynchronize(trt_model->stream) != CUDA_SUCCESS) {\n                av_log(ctx, AV_LOG_ERROR, \"CUDA stream sync failed\\n\");\n                goto err;\n            }\n\n            task->out_frame->width = out_width;\n            task->out_frame->height = out_height;\n            task->inference_done++;\n            goto done;\n        }\n\n        // For 4-channel formats (RGB0, RGBA, BGR0, BGRA)\n        if (hw_frames->sw_format == AV_PIX_FMT_RGB0 || hw_frames->sw_format == AV_PIX_FMT_BGR0 ||\n            hw_frames->sw_format == AV_PIX_FMT_RGBA || hw_frames->sw_format == AV_PIX_FMT_BGRA) {\n            int r_off = 0, g_off = 1, b_off = 2, a_off = 3;\n            if (hw_frames->sw_format == AV_PIX_FMT_BGR0 || hw_frames->sw_format == AV_PIX_FMT_BGRA) {\n                r_off = 2; b_off = 0;\n            }\n            void *args[] = {&trt_model->output_buffer, &cuda_out, &out_height, &out_width, &out_linesize,\n                           &r_off, &g_off, &b_off, &a_off};\n            ret = launch_kernel(trt_model->kernel_nchw_to_hwc4[dtype_idx], trt_model->stream,\n                               out_width, out_height, args, ctx);\n            if (ret != 0) goto err;\n\n            if (p_cuStreamSynchronize(trt_model->stream) != CUDA_SUCCESS) {\n                av_log(ctx, AV_LOG_ERROR, \"CUDA stream sync failed\\n\");\n                goto err;\n            }\n\n            task->out_frame->width = out_width;\n            task->out_frame->height = out_height;\n            task->inference_done++;\n            goto done;\n        }\n\n        // For 0RGB/ARGB formats (alpha first)\n        if (hw_frames->sw_format == AV_PIX_FMT_0RGB || hw_frames->sw_format == AV_PIX_FMT_0BGR ||\n            hw_frames->sw_format == AV_PIX_FMT_ARGB || hw_frames->sw_format == AV_PIX_FMT_ABGR) {\n            int r_off = 1, g_off = 2, b_off = 3, a_off = 0;\n            if (hw_frames->sw_format == AV_PIX_FMT_0BGR || hw_frames->sw_format == AV_PIX_FMT_ABGR) {\n                r_off = 3; b_off = 1;\n            }\n            void *args[] = {&trt_model->output_buffer, &cuda_out, &out_height, &out_width, &out_linesize,\n                           &r_off, &g_off, &b_off, &a_off};\n            ret = launch_kernel(trt_model->kernel_nchw_to_hwc4[dtype_idx], trt_model->stream,\n                               out_width, out_height, args, ctx);\n            if (ret != 0) goto err;\n\n            if (p_cuStreamSynchronize(trt_model->stream) != CUDA_SUCCESS) {\n                av_log(ctx, AV_LOG_ERROR, \"CUDA stream sync failed\\n\");\n                goto err;\n            }\n\n            task->out_frame->width = out_width;\n            task->out_frame->height = out_height;\n            task->inference_done++;\n            goto done;\n        }\n\n        av_log(ctx, AV_LOG_WARNING, \"CUDA output sw_format %s not supported for zero-copy, using CPU path\\n\",\n               av_get_pix_fmt_name(hw_frames->sw_format));\n    }\n\n    // Standard CPU path - only supports FP32 engines\n    // For FP16/BF16, use CUDA hw frames for zero-copy path\n    if (trt_model->output_dtype != TRT_DT_FLOAT32) {\n        av_log(ctx, AV_LOG_ERROR, \"CPU output path only supports FP32 engines, got %s. \"\n               \"Use hwupload to provide CUDA frames for FP16/BF16 zero-copy.\\n\",\n               trt_dtype_name(trt_model->output_dtype));\n        goto err;\n    }\n\n    output_elements = outputs.dims[0] * outputs.dims[1] * outputs.dims[2] * outputs.dims[3];\n    output_data = (float *)av_malloc(output_elements * sizeof(float));\n    if (!output_data) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to allocate output buffer\\n\");\n        goto err;\n    }\n\n    // Sync stream before copying (inference runs async on stream)\n    if (p_cudaStreamSynchronize_rt) {\n        cudaError_t sync_err = p_cudaStreamSynchronize_rt((cudaStream_t)trt_model->stream);\n        if (sync_err != cudaSuccess) {\n            av_log(ctx, AV_LOG_ERROR, \"Stream sync failed before output copy: %d\\n\", sync_err);\n            av_freep(&output_data);\n            goto err;\n        }\n    } else {\n        // Fallback to Driver API sync\n        CUresult err = p_cuStreamSynchronize(trt_model->stream);\n        if (err != CUDA_SUCCESS) {\n            av_log(ctx, AV_LOG_ERROR, \"Stream sync failed: %s\\n\", cuda_error_string(err));\n            av_freep(&output_data);\n            goto err;\n        }\n    }\n\n    // Copy output from GPU using CUDA Runtime API\n    {\n        cudaError_t cuda_err = p_cudaMemcpy(output_data, (void*)trt_model->output_buffer,\n                                             trt_model->output_size, cudaMemcpyDeviceToHost);\n        if (cuda_err != cudaSuccess) {\n            av_log(ctx, AV_LOG_ERROR, \"cudaMemcpy failed for output: %d\\n\", cuda_err);\n            av_freep(&output_data);\n            goto err;\n        }\n    }\n\n    switch (trt_model->model.func_type) {\n    case DFT_PROCESS_FRAME:\n        if (task->do_ioproc) {\n            outputs.scale = 255;\n            outputs.data = output_data;\n            if (trt_model->model.frame_post_proc != NULL) {\n                trt_model->model.frame_post_proc(task->out_frame, &outputs, trt_model->model.filter_ctx);\n            } else {\n                ff_proc_from_dnn_to_frame(task->out_frame, &outputs, ctx);\n            }\n        } else {\n            task->out_frame->width = out_width;\n            task->out_frame->height = out_height;\n        }\n        break;\n    default:\n        av_log(ctx, AV_LOG_ERROR, \"Unsupported model function type %d\\n\", trt_model->model.func_type);\n        av_freep(&output_data);\n        goto err;\n    }\n\n    av_freep(&output_data);\n    task->inference_done++;\n    goto done;\n\nerr:\n    // Increment inference_done even on error so task completion tracking works\n    // The caller can detect failure through other means (e.g., frame validation)\n    task->inference_done++;\n\ndone:\n    av_freep(&request->lltask);\n    if (ff_safe_queue_push_back(trt_model->request_queue, request) < 0) {\n        destroy_request_item(&request);\n        av_log(ctx, AV_LOG_ERROR, \"Unable to push back request_queue.\\n\");\n    }\n}\n\nstatic int execute_model_trt(TRTRequestItem *request, Queue *lltask_queue)\n{\n    TRTModel *trt_model = NULL;\n    LastLevelTaskItem *lltask;\n    TaskItem *task = NULL;\n    int ret = 0;\n\n    if (ff_queue_size(lltask_queue) == 0) {\n        destroy_request_item(&request);\n        return 0;\n    }\n\n    lltask = (LastLevelTaskItem *)ff_queue_peek_front(lltask_queue);\n    if (lltask == NULL) {\n        av_log(NULL, AV_LOG_ERROR, \"Failed to get LastLevelTaskItem\\n\");\n        ret = AVERROR(EINVAL);\n        goto err;\n    }\n    task = lltask->task;\n    trt_model = (TRTModel *)task->model;\n\n    ret = fill_model_input_trt(trt_model, request);\n    if (ret != 0) {\n        goto err;\n    }\n\n    // Synchronous execution (TensorRT is fast, async adds complexity)\n    ret = trt_start_inference((void *)request);\n    if (ret != 0) {\n        goto err;\n    }\n    infer_completion_callback(request);\n    return (task->inference_done == task->inference_todo) ? 0 : DNN_GENERIC_ERROR;\n\nerr:\n    trt_free_request(request->infer_request);\n    av_freep(&request->lltask);  // Free lltask that was popped from queue\n    // Clean up the task that was left in task_queue to prevent memory leak\n    // The task is at the back since we just pushed it in dnn_execute_model_trt\n    if (trt_model && task) {\n        // Remove task from queue - it should be the last one we added\n        // Iterate to find and remove it (safer than assuming position)\n        Queue *tq = trt_model->task_queue;\n        size_t queue_size = ff_queue_size(tq);\n        for (size_t i = 0; i < queue_size; i++) {\n            TaskItem *queued_task = (TaskItem *)ff_queue_peek_front(tq);\n            if (queued_task == task) {\n                ff_queue_pop_front(tq);\n                av_frame_free(&task->in_frame);\n                av_frame_free(&task->out_frame);\n                av_freep(&task);\n                break;\n            }\n            // Move to next by popping and re-pushing (rotate queue)\n            ff_queue_pop_front(tq);\n            ff_queue_push_back(tq, queued_task);\n        }\n    }\n    if (!trt_model || ff_safe_queue_push_back(trt_model->request_queue, request) < 0) {\n        destroy_request_item(&request);\n    }\n    return ret;\n}\n\nstatic int get_output_trt(DNNModel *model, const char *input_name, int input_width, int input_height,\n                          const char *output_name, int *output_width, int *output_height)\n{\n    TRTModel *trt_model = (TRTModel *)model;\n\n    // Get from engine's output dimensions\n    *output_width = trt_model->output_dims.d[3];\n    *output_height = trt_model->output_dims.d[2];\n\n    return 0;\n}\n\nstatic TRTInferRequest *trt_create_inference_request(void)\n{\n    TRTInferRequest *request = (TRTInferRequest *)av_mallocz(sizeof(TRTInferRequest));\n    return request;\n}\n\nstatic DNNModel *dnn_load_model_trt(DnnContext *ctx, DNNFunctionType func_type, AVFilterContext *filter_ctx)\n{\n    TRTModel *trt_model = NULL;\n    TRTRequestItem *item = NULL;\n    CUresult err;\n\n    trt_model = (TRTModel *)av_mallocz(sizeof(TRTModel));\n    if (!trt_model)\n        return NULL;\n\n    trt_model->ctx = ctx;\n\n    // Load CUDA and TensorRT libraries via dlopen\n    if (load_libs(ctx) < 0) {\n        goto fail;\n    }\n\n    log_gpu_memory(ctx, \"after load_libs\");\n\n    // Set CUDA device using Runtime API for TensorRT compatibility\n    if (p_cudaSetDevice) {\n        int device_id = ctx->trt_option.device_id;\n        cudaError_t cuda_err = p_cudaSetDevice(device_id);\n        if (cuda_err != cudaSuccess) {\n            av_log(ctx, AV_LOG_ERROR, \"cudaSetDevice(%d) failed: %d\\n\", device_id, cuda_err);\n            goto fail;\n        }\n        av_log(ctx, AV_LOG_DEBUG, \"Set CUDA device %d for TensorRT\\n\", device_id);\n    }\n\n    // Create TensorRT logger (cleaned up by dnn_free_model_trt on any failure path)\n    trt_model->logger = new TRTLogger(ctx);\n\n    // Check engine cache first (avoid reloading same engine file)\n    trt_model->engine_path = av_strdup(ctx->model_filename);\n    if (!trt_model->engine_path) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to allocate engine path\\n\");\n        goto fail;\n    }\n    {\n        std::lock_guard<std::mutex> lock(g_engine_cache_mutex);\n        std::string path_key(trt_model->engine_path);\n        av_log(ctx, AV_LOG_DEBUG, \"Checking engine cache for: %s (cache size=%zu)\\n\",\n               trt_model->engine_path, g_engine_cache.size());\n        auto it = g_engine_cache.find(path_key);\n        if (it != g_engine_cache.end()) {\n            // Found in cache - reuse existing engine\n            trt_model->cached_engine = it->second;\n            if (!trt_model->cached_engine->engine || !trt_model->cached_engine->runtime) {\n                av_log(ctx, AV_LOG_ERROR, \"BUG: Cached engine has NULL pointers! Removing stale entry.\\n\");\n                g_engine_cache.erase(it);\n                trt_model->cached_engine = nullptr;\n            } else {\n                // Use atomic fetch_add to avoid race condition\n                int new_refcount = trt_model->cached_engine->refcount.fetch_add(1, std::memory_order_acq_rel) + 1;\n                trt_model->engine = trt_model->cached_engine->engine;\n                trt_model->runtime = trt_model->cached_engine->runtime;\n                av_log(ctx, AV_LOG_INFO, \"Reusing cached TensorRT engine (refcount=%d, engine=%p)\\n\",\n                       new_refcount, (void*)trt_model->engine);\n            }\n        }\n        if (!trt_model->cached_engine) {\n            av_log(ctx, AV_LOG_DEBUG, \"Engine not in cache, will load from file\\n\");\n        }\n    }\n\n    // If not in cache, load engine from file\n    if (!trt_model->engine) {\n        // Create runtime using dynamically loaded function\n        trt_model->runtime = p_createInferRuntime(*trt_model->logger);\n        if (!trt_model->runtime) {\n            av_log(ctx, AV_LOG_ERROR, \"Failed to create TensorRT runtime\\n\");\n            goto fail;\n        }\n\n        // Load engine from file\n        {\n            std::ifstream file(ctx->model_filename, std::ios::binary | std::ios::ate);\n            if (!file.is_open()) {\n                av_log(ctx, AV_LOG_ERROR, \"Failed to open engine file: %s\\n\", ctx->model_filename);\n                goto fail;\n            }\n\n            std::streampos pos = file.tellg();\n            if (pos == std::streampos(-1) || pos <= 0) {\n                av_log(ctx, AV_LOG_ERROR, \"Engine file is empty or unreadable: %s\\n\", ctx->model_filename);\n                goto fail;\n            }\n            size_t size = static_cast<size_t>(pos);\n            file.seekg(0, std::ios::beg);\n\n            std::vector<char> buffer(size);\n            if (!file.read(buffer.data(), size)) {\n                av_log(ctx, AV_LOG_ERROR, \"Failed to read engine file\\n\");\n                goto fail;\n            }\n\n            trt_model->engine = trt_model->runtime->deserializeCudaEngine(buffer.data(), size);\n            if (!trt_model->engine) {\n                av_log(ctx, AV_LOG_ERROR, \"Failed to deserialize CUDA engine\\n\");\n                goto fail;\n            }\n            log_gpu_memory(ctx, \"after engine deserialize\");\n        }\n\n        // Add to cache\n        {\n            std::lock_guard<std::mutex> lock(g_engine_cache_mutex);\n            std::string path_key(trt_model->engine_path);\n            // Double-check another thread didn't add it while we were loading\n            auto it = g_engine_cache.find(path_key);\n            if (it == g_engine_cache.end()) {\n                trt_model->cached_engine = new CachedEngine(trt_model->engine, trt_model->runtime);\n                g_engine_cache[path_key] = trt_model->cached_engine;\n                av_log(ctx, AV_LOG_INFO, \"Added TensorRT engine to cache\\n\");\n            } else {\n                // Another thread added it - use theirs, discard ours\n                delete trt_model->engine;\n                delete trt_model->runtime;\n                trt_model->cached_engine = it->second;\n                // Use atomic fetch_add to avoid race condition\n                int new_refcount = trt_model->cached_engine->refcount.fetch_add(1, std::memory_order_acq_rel) + 1;\n                trt_model->engine = trt_model->cached_engine->engine;\n                trt_model->runtime = trt_model->cached_engine->runtime;\n                av_log(ctx, AV_LOG_INFO, \"Using engine added by another thread (refcount=%d)\\n\",\n                       new_refcount);\n            }\n        }\n    }\n\n    // NOTE: Execution context is created lazily on first inference (saves ~720MB for probe instances)\n    // FFmpeg creates two filter instances: one for probing (never runs inference) and one for execution\n    trt_model->context = nullptr;\n\n    // CUDA Graph state (captured lazily on first inference)\n    trt_model->cuda_graph = NULL;\n    trt_model->cuda_graph_exec = NULL;\n    trt_model->cuda_graph_captured = 0;\n    trt_model->cuda_graph_failed = 0;\n\n    // Create CUDA stream for TensorRT operations\n    err = p_cuStreamCreate(&trt_model->stream, 0);\n    if (err != CUDA_SUCCESS) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to create CUDA stream: %s\\n\", cuda_error_string(err));\n        goto fail;\n    }\n\n    // Load CUDA kernels from embedded PTX\n    if (load_cuda_kernels(trt_model, ctx) < 0) {\n        goto fail;\n    }\n    log_gpu_memory(ctx, \"after load_cuda_kernels\");\n\n    // Get I/O tensor info (TensorRT 10.x API)\n    {\n        int nb_io_tensors = trt_model->engine->getNbIOTensors();\n        if (nb_io_tensors < 2) {\n            av_log(ctx, AV_LOG_ERROR, \"Engine must have at least 2 tensors (input and output), got %d\\n\", nb_io_tensors);\n            goto fail;\n        }\n\n        // Find input and output tensors\n        for (int i = 0; i < nb_io_tensors; i++) {\n            const char *name = trt_model->engine->getIOTensorName(i);\n            nvinfer1::TensorIOMode mode = trt_model->engine->getTensorIOMode(name);\n\n            if (mode == nvinfer1::TensorIOMode::kINPUT && !trt_model->input_name) {\n                trt_model->input_name = av_strdup(name);\n                trt_model->input_dims = trt_model->engine->getTensorShape(name);\n                trt_model->input_dtype = nvinfer_to_trt_dtype(trt_model->engine->getTensorDataType(name));\n            } else if (mode == nvinfer1::TensorIOMode::kOUTPUT && !trt_model->output_name) {\n                trt_model->output_name = av_strdup(name);\n                trt_model->output_dims = trt_model->engine->getTensorShape(name);\n                trt_model->output_dtype = nvinfer_to_trt_dtype(trt_model->engine->getTensorDataType(name));\n            }\n        }\n\n        if (!trt_model->input_name || !trt_model->output_name) {\n            av_log(ctx, AV_LOG_ERROR, \"Could not find input/output tensors\\n\");\n            goto fail;\n        }\n\n        // Validate dtypes are supported\n        if (trt_model->input_dtype == TRT_DT_UNKNOWN) {\n            av_log(ctx, AV_LOG_ERROR, \"Unsupported input tensor data type\\n\");\n            goto fail;\n        }\n        if (trt_model->output_dtype == TRT_DT_UNKNOWN) {\n            av_log(ctx, AV_LOG_ERROR, \"Unsupported output tensor data type\\n\");\n            goto fail;\n        }\n        // For now, we only support FP32/FP16/BF16 for zero-copy kernels\n        if (trt_model->input_dtype > TRT_DT_BFLOAT16 || trt_model->output_dtype > TRT_DT_BFLOAT16) {\n            av_log(ctx, AV_LOG_ERROR, \"Only FP32/FP16/BF16 tensors supported for zero-copy, got input=%s output=%s\\n\",\n                   trt_dtype_name(trt_model->input_dtype), trt_dtype_name(trt_model->output_dtype));\n            goto fail;\n        }\n\n        // Validate tensor dimensions (must be 4D for NCHW format)\n        if (trt_model->input_dims.nbDims != 4) {\n            av_log(ctx, AV_LOG_ERROR, \"Input tensor must be 4D (NCHW), got %d dimensions\\n\",\n                   trt_model->input_dims.nbDims);\n            goto fail;\n        }\n        if (trt_model->output_dims.nbDims != 4) {\n            av_log(ctx, AV_LOG_ERROR, \"Output tensor must be 4D (NCHW), got %d dimensions\\n\",\n                   trt_model->output_dims.nbDims);\n            goto fail;\n        }\n\n        // Validate all dimensions are positive\n        for (int i = 0; i < 4; i++) {\n            if (trt_model->input_dims.d[i] <= 0) {\n                av_log(ctx, AV_LOG_ERROR, \"Invalid input dimension[%d] = %ld\\n\",\n                       i, (long)trt_model->input_dims.d[i]);\n                goto fail;\n            }\n            if (trt_model->output_dims.d[i] <= 0) {\n                av_log(ctx, AV_LOG_ERROR, \"Invalid output dimension[%d] = %ld\\n\",\n                       i, (long)trt_model->output_dims.d[i]);\n                goto fail;\n            }\n        }\n\n        // Log dimensions and dtypes\n        av_log(ctx, AV_LOG_INFO, \"TensorRT engine loaded:\\n\");\n        av_log(ctx, AV_LOG_INFO, \"  Input '%s': %ldx%ldx%ldx%ld (%s)\\n\",\n               trt_model->input_name,\n               (long)trt_model->input_dims.d[0], (long)trt_model->input_dims.d[1],\n               (long)trt_model->input_dims.d[2], (long)trt_model->input_dims.d[3],\n               trt_dtype_name(trt_model->input_dtype));\n        av_log(ctx, AV_LOG_INFO, \"  Output '%s': %ldx%ldx%ldx%ld (%s)\\n\",\n               trt_model->output_name,\n               (long)trt_model->output_dims.d[0], (long)trt_model->output_dims.d[1],\n               (long)trt_model->output_dims.d[2], (long)trt_model->output_dims.d[3],\n               trt_dtype_name(trt_model->output_dtype));\n\n        // Calculate buffer sizes (allocation deferred to first inference via ensure_execution_context)\n        {\n            // Cast each factor to int64_t to prevent overflow during multiplication\n            int64_t in_elems = (int64_t)trt_model->input_dims.d[0] * (int64_t)trt_model->input_dims.d[1] *\n                               (int64_t)trt_model->input_dims.d[2] * (int64_t)trt_model->input_dims.d[3];\n            int64_t out_elems = (int64_t)trt_model->output_dims.d[0] * (int64_t)trt_model->output_dims.d[1] *\n                                (int64_t)trt_model->output_dims.d[2] * (int64_t)trt_model->output_dims.d[3];\n\n            size_t in_elem_size = trt_dtype_size(trt_model->input_dtype);\n            size_t out_elem_size = trt_dtype_size(trt_model->output_dtype);\n\n            // Check for overflow (max reasonable GPU buffer ~16GB)\n            const int64_t max_bytes = (int64_t)16 * 1024 * 1024 * 1024;\n            if (in_elems * (int64_t)in_elem_size > max_bytes || out_elems * (int64_t)out_elem_size > max_bytes) {\n                av_log(ctx, AV_LOG_ERROR, \"Tensor size exceeds maximum supported (16GB)\\n\");\n                goto fail;\n            }\n\n            trt_model->input_size = (size_t)in_elems * in_elem_size;\n            trt_model->output_size = (size_t)out_elems * out_elem_size;\n\n            av_log(ctx, AV_LOG_INFO, \"  Buffer sizes (deferred): input=%zuMB output=%zuMB\\n\",\n                   trt_model->input_size / (1024 * 1024), trt_model->output_size / (1024 * 1024));\n        }\n\n        // NOTE: GPU buffers and execution context are allocated lazily on first inference\n        // This saves ~720MB+ for FFmpeg's probe filter instance that never runs inference\n    }\n\n    // Initialize queues\n    trt_model->request_queue = ff_safe_queue_create();\n    if (!trt_model->request_queue)\n        goto fail;\n\n    item = (TRTRequestItem *)av_mallocz(sizeof(TRTRequestItem));\n    if (!item)\n        goto fail;\n\n    item->infer_request = trt_create_inference_request();\n    if (!item->infer_request)\n        goto fail;\n\n    item->exec_module.start_inference = &trt_start_inference;\n    item->exec_module.callback = &infer_completion_callback;\n    item->exec_module.args = item;\n\n    if (ff_safe_queue_push_back(trt_model->request_queue, item) < 0)\n        goto fail;\n    item = NULL;\n\n    trt_model->task_queue = ff_queue_create();\n    if (!trt_model->task_queue)\n        goto fail;\n\n    trt_model->lltask_queue = ff_queue_create();\n    if (!trt_model->lltask_queue)\n        goto fail;\n\n    // Set up model interface\n    trt_model->model.get_input = &get_input_trt;\n    trt_model->model.get_output = &get_output_trt;\n    trt_model->model.filter_ctx = filter_ctx;\n    trt_model->model.func_type = func_type;\n\n    return &trt_model->model;\n\nfail:\n    if (item) {\n        destroy_request_item(&item);\n    }\n    dnn_free_model_trt((DNNModel **)&trt_model);\n    return NULL;\n}\n\nstatic int dnn_execute_model_trt(const DNNModel *model, DNNExecBaseParams *exec_params)\n{\n    TRTModel *trt_model = (TRTModel *)model;\n    DnnContext *ctx = trt_model->ctx;\n    TaskItem *task;\n    TRTRequestItem *request;\n    int ret = 0;\n\n    ret = ff_check_exec_params(ctx, DNN_TRT, model->func_type, exec_params);\n    if (ret != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"exec parameter checking fail.\\n\");\n        return ret;\n    }\n\n    task = (TaskItem *)av_malloc(sizeof(TaskItem));\n    if (!task) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to alloc memory for task item.\\n\");\n        return AVERROR(ENOMEM);\n    }\n\n    ret = ff_dnn_fill_task(task, exec_params, trt_model, 0, 1);\n    if (ret != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to fill task.\\n\");\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return ret;\n    }\n\n    ret = ff_queue_push_back(trt_model->task_queue, task);\n    if (ret < 0) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to push back task_queue.\\n\");\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return ret;\n    }\n\n    ret = extract_lltask_from_task(task, trt_model->lltask_queue);\n    if (ret != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to extract last level task from task.\\n\");\n        // Remove task from queue since extraction failed\n        ff_queue_pop_back(trt_model->task_queue);\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return ret;\n    }\n\n    request = (TRTRequestItem *)ff_safe_queue_pop_front(trt_model->request_queue);\n    if (!request) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to get infer request.\\n\");\n        // Clean up: remove lltask and task we just added\n        LastLevelTaskItem *lltask = (LastLevelTaskItem *)ff_queue_pop_back(trt_model->lltask_queue);\n        av_freep(&lltask);\n        ff_queue_pop_back(trt_model->task_queue);\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return AVERROR(EINVAL);\n    }\n\n    return execute_model_trt(request, trt_model->lltask_queue);\n}\n\nstatic DNNAsyncStatusType dnn_get_result_trt(const DNNModel *model, AVFrame **in, AVFrame **out)\n{\n    TRTModel *trt_model = (TRTModel *)model;\n    return ff_dnn_get_result_common(trt_model->task_queue, in, out);\n}\n\nstatic int dnn_flush_trt(const DNNModel *model)\n{\n    TRTModel *trt_model = (TRTModel *)model;\n    TRTRequestItem *request;\n\n    if (ff_queue_size(trt_model->lltask_queue) == 0)\n        return 0;\n\n    request = (TRTRequestItem *)ff_safe_queue_pop_front(trt_model->request_queue);\n    if (!request) {\n        av_log(trt_model->ctx, AV_LOG_ERROR, \"unable to get infer request.\\n\");\n        return AVERROR(EINVAL);\n    }\n\n    return execute_model_trt(request, trt_model->lltask_queue);\n}\n\nextern const DNNModule ff_dnn_backend_tensorrt = {\n    .clazz          = DNN_DEFINE_CLASS(dnn_trt),\n    .type           = DNN_TRT,\n    .load_model     = dnn_load_model_trt,\n    .execute_model  = dnn_execute_model_trt,\n    .get_result     = dnn_get_result_trt,\n    .flush          = dnn_flush_trt,\n    .free_model     = dnn_free_model_trt,\n};\n"
  },
  {
    "path": "tools/patches/dnn_backend_torch.cpp",
    "content": "/*\n * Copyright 2026 Joshua V. Dillon\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n * Based on FFmpeg's dnn_backend_torch.cpp with extensive modifications\n * for CUDA zero-copy support and hardware frame integration.\n */\n\n/**\n * @file\n * DNN Torch backend implementation.\n */\n\n#include <torch/torch.h>\n#include <ATen/cuda/CUDAContext.h>\n#include <torch/script.h>\n#include <dlfcn.h>\n#include <thread>\n#include <mutex>\n#include <condition_variable>\n#include <atomic>\n\nextern \"C\" {\n#include \"dnn_io_proc.h\"\n#include \"dnn_backend_common.h\"\n#include \"libavutil/opt.h\"\n#include \"libavutil/mem.h\"\n#include \"libavutil/hwcontext.h\"\n#include \"libavutil/hwcontext_cuda.h\"\n#include \"libavutil/pixfmt.h\"\n#include \"libavutil/pixdesc.h\"\n#include \"queue.h\"\n#include \"safe_queue.h\"\n}\n\n#include <cuda_runtime.h>\n\ntypedef struct THModel {\n    DNNModel model;\n    DnnContext *ctx;\n    torch::jit::Module *jit_model;\n    SafeQueue *request_queue;\n    Queue *task_queue;\n    Queue *lltask_queue;\n    SafeQueue *pending_queue;       ///< requests waiting for inference\n    std::thread *worker_thread;     ///< background worker thread\n    std::mutex *mutex;              ///< mutex for the condition variable\n    std::condition_variable *cond;  ///< condition variable for worker wakeup\n    std::atomic<bool> worker_stop;  ///< signal for thread exit\n} THModel;\n\ntypedef struct THInferRequest {\n    torch::Tensor *output;\n    torch::Tensor *input_tensor;\n} THInferRequest;\n\ntypedef struct THRequestItem {\n    THInferRequest *infer_request;\n    LastLevelTaskItem *lltask;\n    DNNAsyncExecModule exec_module;\n} THRequestItem;\n\n\n#define OFFSET(x) offsetof(THOptions, x)\n#define FLAGS AV_OPT_FLAG_FILTERING_PARAM\nstatic const AVOption dnn_th_options[] = {\n    { \"optimize\", \"turn on graph executor optimization\", OFFSET(optimize), AV_OPT_TYPE_INT, { .i64 = 0 }, 0, 1, FLAGS},\n    { NULL }\n};\n\nstatic int extract_lltask_from_task(TaskItem *task, Queue *lltask_queue)\n{\n    THModel *th_model = (THModel *)task->model;\n    DnnContext *ctx = th_model->ctx;\n    LastLevelTaskItem *lltask = (LastLevelTaskItem *)av_malloc(sizeof(*lltask));\n    if (!lltask) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to allocate memory for LastLevelTaskItem\\n\");\n        return AVERROR(ENOMEM);\n    }\n    task->inference_todo = 1;\n    task->inference_done = 0;\n    lltask->task = task;\n    if (ff_queue_push_back(lltask_queue, lltask) < 0) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to push back lltask_queue.\\n\");\n        av_freep(&lltask);\n        return AVERROR(ENOMEM);\n    }\n    return 0;\n}\n\nstatic void th_free_request(THInferRequest *request)\n{\n    if (!request)\n        return;\n    if (request->output) {\n        delete(request->output);\n        request->output = NULL;\n    }\n    if (request->input_tensor) {\n        delete(request->input_tensor);\n        request->input_tensor = NULL;\n    }\n    return;\n}\n\nstatic inline void destroy_request_item(THRequestItem **arg)\n{\n    THRequestItem *item;\n    if (!arg || !*arg) {\n        return;\n    }\n    item = *arg;\n    th_free_request(item->infer_request);\n    av_freep(&item->infer_request);\n    av_freep(&item->lltask);\n    ff_dnn_async_module_cleanup(&item->exec_module);\n    av_freep(arg);\n}\n\nstatic void dnn_free_model_th(DNNModel **model)\n{\n    THModel *th_model;\n    if (!model || !*model)\n        return;\n\n    th_model = (THModel *)(*model);\n\n    /* 1. Stop and join the worker thread if it exists */\n    if (th_model->worker_thread) {\n        {\n            std::lock_guard<std::mutex> lock(*th_model->mutex);\n            th_model->worker_stop = true;\n        }\n        th_model->cond->notify_all();\n        th_model->worker_thread->join();\n        delete th_model->worker_thread;\n        th_model->worker_thread = NULL;\n    }\n\n    /* 2. Safely delete C++ synchronization objects */\n    if (th_model->mutex) {\n        delete th_model->mutex;\n        th_model->mutex = NULL;\n    }\n    if (th_model->cond) {\n        delete th_model->cond;\n        th_model->cond = NULL;\n    }\n\n    /* 3. Clean up the pending queue */\n    if (th_model->pending_queue) {\n        while (ff_safe_queue_size(th_model->pending_queue) > 0) {\n            THRequestItem *item = (THRequestItem *)ff_safe_queue_pop_front(th_model->pending_queue);\n            destroy_request_item(&item);\n        }\n        ff_safe_queue_destroy(th_model->pending_queue);\n    }\n\n    /* 4. Clean up standard backend queues */\n    if (th_model->request_queue) {\n        while (ff_safe_queue_size(th_model->request_queue) != 0) {\n            THRequestItem *item = (THRequestItem *)ff_safe_queue_pop_front(th_model->request_queue);\n            destroy_request_item(&item);\n        }\n        ff_safe_queue_destroy(th_model->request_queue);\n    }\n\n    if (th_model->lltask_queue) {\n        while (ff_queue_size(th_model->lltask_queue) != 0) {\n            LastLevelTaskItem *item = (LastLevelTaskItem *)ff_queue_pop_front(th_model->lltask_queue);\n            av_freep(&item);\n        }\n        ff_queue_destroy(th_model->lltask_queue);\n    }\n\n    if (th_model->task_queue) {\n        while (ff_queue_size(th_model->task_queue) != 0) {\n            TaskItem *item = (TaskItem *)ff_queue_pop_front(th_model->task_queue);\n            av_frame_free(&item->in_frame);\n            av_frame_free(&item->out_frame);\n            av_freep(&item);\n        }\n        ff_queue_destroy(th_model->task_queue);\n    }\n\n    /* 5. Final model cleanup */\n    if (th_model->jit_model)\n        delete th_model->jit_model;\n\n    av_freep(&th_model);\n    *model = NULL;\n}\n\nstatic int get_input_th(DNNModel *model, DNNData *input, const char *input_name)\n{\n    input->dt = DNN_FLOAT;\n    input->order = DCO_RGB;\n    input->layout = DL_NCHW;\n    input->dims[0] = 1;\n    input->dims[1] = 3;\n    input->dims[2] = -1;\n    input->dims[3] = -1;\n    return 0;\n}\n\nstatic void deleter(void *arg)\n{\n    av_freep(&arg);\n}\n\nstatic int fill_model_input_th(THModel *th_model, THRequestItem *request)\n{\n    LastLevelTaskItem *lltask = NULL;\n    TaskItem *task = NULL;\n    THInferRequest *infer_request = NULL;\n    DNNData input = { 0 };\n    DnnContext *ctx = th_model->ctx;\n    int ret, width_idx, height_idx, channel_idx;\n\n    lltask = (LastLevelTaskItem *)ff_queue_pop_front(th_model->lltask_queue);\n    if (!lltask) {\n        ret = AVERROR(EINVAL);\n        goto err;\n    }\n    request->lltask = lltask;\n    task = lltask->task;\n    infer_request = request->infer_request;\n\n    ret = get_input_th(&th_model->model, &input, NULL);\n    if (ret != 0) {\n        goto err;\n    }\n    width_idx = dnn_get_width_idx_by_layout(input.layout);\n    height_idx = dnn_get_height_idx_by_layout(input.layout);\n    channel_idx = dnn_get_channel_idx_by_layout(input.layout);\n    input.dims[height_idx] = task->in_frame->height;\n    input.dims[width_idx] = task->in_frame->width;\n\n    // Allocate tensors. Note: th_create_inference_request() NULL-initializes both pointers,\n    // so th_free_request() in the err path safely handles partial allocation if second new throws.\n    try {\n        infer_request->input_tensor = new torch::Tensor();\n        infer_request->output = new torch::Tensor();\n    } catch (const std::exception& e) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to allocate torch tensors: %s\\n\", e.what());\n        ret = AVERROR(ENOMEM);\n        goto err;\n    }\n\n    // Check for CUDA hardware frames (zero-copy input path)\n    if (task->in_frame->format == AV_PIX_FMT_CUDA && task->in_frame->hw_frames_ctx) {\n        AVHWFramesContext *hw_frames = (AVHWFramesContext *)task->in_frame->hw_frames_ctx->data;\n        int width = task->in_frame->width;\n        int height = task->in_frame->height;\n        int linesize = task->in_frame->linesize[0];\n        uint8_t *cuda_data = task->in_frame->data[0];\n\n        av_log(ctx, AV_LOG_DEBUG, \"CUDA frame input: %dx%d, sw_format=%s, linesize=%d\\n\",\n               width, height, av_get_pix_fmt_name(hw_frames->sw_format), linesize);\n\n        try {\n            // Handle RGB24/BGR24 sw_format - zero-copy path (3 bytes per pixel)\n            if (hw_frames->sw_format == AV_PIX_FMT_RGB24 || hw_frames->sw_format == AV_PIX_FMT_BGR24) {\n                auto options = torch::TensorOptions().dtype(torch::kUInt8).device(torch::kCUDA);\n\n                // Create tensor from CUDA memory (HWC format, uint8)\n                torch::Tensor input_hwc = torch::from_blob(\n                    cuda_data,\n                    {height, width, 3},\n                    {linesize, 3, 1},  // strides for row-major with padding\n                    options\n                );\n\n                // Convert: HWC uint8 [0,255] -> NCHW float32 [0,1]\n                *infer_request->input_tensor = input_hwc.permute({2, 0, 1})  // HWC -> CHW\n                                                        .unsqueeze(0)        // CHW -> NCHW\n                                                        .to(torch::kFloat32)\n                                                        .div(255.0f)\n                                                        .contiguous();\n\n                av_log(ctx, AV_LOG_DEBUG, \"Zero-copy CUDA input created (RGB24/BGR24)\\n\");\n                return 0;\n            }\n\n            // Handle RGB0/BGR0/0RGB/0BGR sw_format - zero-copy path (4 bytes per pixel, ignore alpha)\n            if (hw_frames->sw_format == AV_PIX_FMT_RGB0 || hw_frames->sw_format == AV_PIX_FMT_BGR0 ||\n                hw_frames->sw_format == AV_PIX_FMT_0RGB || hw_frames->sw_format == AV_PIX_FMT_0BGR ||\n                hw_frames->sw_format == AV_PIX_FMT_RGBA || hw_frames->sw_format == AV_PIX_FMT_BGRA ||\n                hw_frames->sw_format == AV_PIX_FMT_ARGB || hw_frames->sw_format == AV_PIX_FMT_ABGR) {\n                auto options = torch::TensorOptions().dtype(torch::kUInt8).device(torch::kCUDA);\n\n                // Create tensor from CUDA memory (4 channels, uint8)\n                torch::Tensor input_hwc4 = torch::from_blob(\n                    cuda_data,\n                    {height, width, 4},\n                    {linesize, 4, 1},  // strides for row-major with padding\n                    options\n                );\n\n                // Extract RGB channels based on format\n                torch::Tensor input_hwc;\n                if (hw_frames->sw_format == AV_PIX_FMT_RGB0 || hw_frames->sw_format == AV_PIX_FMT_BGR0 ||\n                    hw_frames->sw_format == AV_PIX_FMT_RGBA || hw_frames->sw_format == AV_PIX_FMT_BGRA) {\n                    // RGB(A) format: first 3 channels are R, G, B\n                    input_hwc = input_hwc4.slice(2, 0, 3);  // slice along channel dim\n                } else {\n                    // (A)RGB format: last 3 channels are R, G, B\n                    input_hwc = input_hwc4.slice(2, 1, 4);  // slice along channel dim\n                }\n\n                // Convert: HWC uint8 [0,255] -> NCHW float32 [0,1]\n                *infer_request->input_tensor = input_hwc.permute({2, 0, 1})  // HWC -> CHW\n                                                        .unsqueeze(0)        // CHW -> NCHW\n                                                        .to(torch::kFloat32)\n                                                        .div(255.0f)\n                                                        .contiguous();\n\n                av_log(ctx, AV_LOG_DEBUG, \"Zero-copy CUDA input created (4-channel format)\\n\");\n                return 0;\n            }\n        } catch (const std::exception& e) {\n            av_log(ctx, AV_LOG_ERROR, \"Torch exception in zero-copy input: %s\\n\", e.what());\n            ret = AVERROR(ENOSYS);\n            goto err;\n        }\n\n        av_log(ctx, AV_LOG_WARNING, \"CUDA sw_format %s not supported for zero-copy, falling back to CPU\\n\",\n               av_get_pix_fmt_name(hw_frames->sw_format));\n    }\n\n    // Standard CPU path - allocate memory for input data\n    input.data = av_malloc(input.dims[height_idx] * input.dims[width_idx] *\n                           input.dims[channel_idx] * sizeof(float));\n    if (!input.data) {\n        ret = AVERROR(ENOMEM);\n        goto err;\n    }\n\n    switch (th_model->model.func_type) {\n    case DFT_PROCESS_FRAME:\n        input.scale = 255;\n        if (task->do_ioproc) {\n            if (th_model->model.frame_pre_proc != NULL) {\n                th_model->model.frame_pre_proc(task->in_frame, &input, th_model->model.filter_ctx);\n            } else {\n                ff_proc_from_frame_to_dnn(task->in_frame, &input, ctx);\n            }\n        }\n        break;\n    default:\n        avpriv_report_missing_feature(NULL, \"model function type %d\", th_model->model.func_type);\n        av_freep(&input.data);\n        ret = AVERROR(ENOSYS);\n        goto err;\n    }\n    *infer_request->input_tensor = torch::from_blob(input.data,\n        {1, input.dims[channel_idx], input.dims[height_idx], input.dims[width_idx]},\n        deleter, torch::kFloat32);\n    return 0;\n\nerr:\n    th_free_request(infer_request);\n    return ret;\n}\n\nstatic int th_start_inference(void *args)\n{\n    THRequestItem *request = (THRequestItem *)args;\n    THInferRequest *infer_request = NULL;\n    LastLevelTaskItem *lltask = NULL;\n    TaskItem *task = NULL;\n    THModel *th_model = NULL;\n    DnnContext *ctx = NULL;\n    std::vector<torch::jit::IValue> inputs;\n    torch::NoGradGuard no_grad;\n\n    if (!request) {\n        av_log(NULL, AV_LOG_ERROR, \"THRequestItem is NULL\\n\");\n        return AVERROR(EINVAL);\n    }\n    infer_request = request->infer_request;\n    lltask = request->lltask;\n    if (!lltask) {\n        av_log(NULL, AV_LOG_ERROR, \"THRequestItem lltask is NULL\\n\");\n        return AVERROR(EINVAL);\n    }\n    task = lltask->task;\n    th_model = (THModel *)task->model;\n    ctx = th_model->ctx;\n\n    if (!infer_request->input_tensor || !infer_request->output) {\n        av_log(ctx, AV_LOG_ERROR, \"input or output tensor is NULL\\n\");\n        return DNN_GENERIC_ERROR;\n    }\n\n    try {\n        // Transfer tensor to the same device as model\n        c10::Device device = torch::kCUDA;\n        auto params = th_model->jit_model->parameters();\n        if (params.begin() != params.end()) {\n            device = (*params.begin()).device();\n        }\n        if (infer_request->input_tensor->device() != device)\n            *infer_request->input_tensor = infer_request->input_tensor->to(device);\n        inputs.push_back(*infer_request->input_tensor);\n\n        auto _fwd_out = th_model->jit_model->forward(inputs);\n        if (_fwd_out.isTuple()) {\n            *infer_request->output = _fwd_out.toTuple()->elements()[0].toTensor();\n        } else {\n            *infer_request->output = _fwd_out.toTensor();\n        }\n    } catch (const std::exception& e) {\n        av_log(ctx, AV_LOG_ERROR, \"Torch inference failed: %s\\n\", e.what());\n        return DNN_GENERIC_ERROR;\n    }\n\n    return 0;\n}\n\nstatic void infer_completion_callback(void *args) {\n    THRequestItem *request = (THRequestItem*)args;\n    LastLevelTaskItem *lltask = request->lltask;\n    TaskItem *task = lltask->task;\n    DNNData outputs = { 0 };\n    THInferRequest *infer_request = request->infer_request;\n    THModel *th_model = (THModel *)task->model;\n    torch::Tensor *output = infer_request->output;\n    c10::IntArrayRef sizes;\n\n    try {\n        sizes = output->sizes();\n    outputs.order = DCO_RGB;\n    outputs.layout = DL_NCHW;\n    outputs.dt = DNN_FLOAT;\n    if (sizes.size() == 4) {\n        // 4 dimensions: [batch_size, channel, height, width]\n        // this format of data is normally used for video frame SR\n        outputs.dims[0] = sizes.at(0); // N\n        outputs.dims[1] = sizes.at(1); // C\n        outputs.dims[2] = sizes.at(2); // H\n        outputs.dims[3] = sizes.at(3); // W\n    } else {\n        avpriv_report_missing_feature(th_model->ctx, \"Support of this kind of model\");\n        goto err;\n    }\n\n    switch (th_model->model.func_type) {\n    case DFT_PROCESS_FRAME:\n        // Check for CUDA output frames (zero-copy output path)\n        if (task->out_frame->format == AV_PIX_FMT_CUDA && task->out_frame->hw_frames_ctx) {\n            AVHWFramesContext *hw_frames = (AVHWFramesContext *)task->out_frame->hw_frames_ctx->data;\n            int out_height = outputs.dims[dnn_get_height_idx_by_layout(outputs.layout)];\n            int out_width = outputs.dims[dnn_get_width_idx_by_layout(outputs.layout)];\n            int out_linesize = task->out_frame->linesize[0];\n            uint8_t *cuda_out = task->out_frame->data[0];\n\n            av_log(th_model->ctx, AV_LOG_DEBUG, \"CUDA frame output: %dx%d, sw_format=%s\\n\",\n                   out_width, out_height, av_get_pix_fmt_name(hw_frames->sw_format));\n\n            if (hw_frames->sw_format == AV_PIX_FMT_RGB24 || hw_frames->sw_format == AV_PIX_FMT_BGR24) {\n                // Ensure output is on CUDA\n                if (!output->is_cuda()) {\n                    *output = output->to(torch::kCUDA);\n                }\n\n                // Convert: NCHW float32 [0,1] -> HWC uint8 [0,255]\n                torch::Tensor output_hwc = output->squeeze(0)           // NCHW -> CHW\n                                                  .permute({1, 2, 0})   // CHW -> HWC\n                                                  .mul(255.0f)\n                                                  .clamp(0.0f, 255.0f)\n                                                  .to(torch::kUInt8)\n                                                  .contiguous();\n\n                // Copy to output CUDA frame\n                cudaError_t cuda_err;\n                if (out_linesize == out_width * 3) {\n                    // Contiguous - single copy\n                    cuda_err = cudaMemcpy(cuda_out, output_hwc.data_ptr(),\n                                          out_height * out_width * 3, cudaMemcpyDeviceToDevice);\n                    if (cuda_err != cudaSuccess) {\n                        av_log(th_model->ctx, AV_LOG_ERROR, \"cudaMemcpy failed: %s\\n\",\n                               cudaGetErrorString(cuda_err));\n                        goto err;\n                    }\n                } else {\n                    // Padded rows - copy row by row\n                    for (int y = 0; y < out_height; y++) {\n                        cuda_err = cudaMemcpy(cuda_out + y * out_linesize,\n                                              (uint8_t*)output_hwc.data_ptr() + y * out_width * 3,\n                                              out_width * 3, cudaMemcpyDeviceToDevice);\n                        if (cuda_err != cudaSuccess) {\n                            av_log(th_model->ctx, AV_LOG_ERROR, \"cudaMemcpy row %d failed: %s\\n\",\n                                   y, cudaGetErrorString(cuda_err));\n                            goto err;\n                        }\n                    }\n                }\n\n                task->out_frame->width = out_width;\n                task->out_frame->height = out_height;\n\n                av_log(th_model->ctx, AV_LOG_DEBUG, \"Zero-copy CUDA output done (RGB24/BGR24)\\n\");\n                break;\n            }\n\n            // Handle 4-channel output formats (RGB0, BGR0, RGBA, etc.)\n            if (hw_frames->sw_format == AV_PIX_FMT_RGB0 || hw_frames->sw_format == AV_PIX_FMT_BGR0 ||\n                hw_frames->sw_format == AV_PIX_FMT_0RGB || hw_frames->sw_format == AV_PIX_FMT_0BGR ||\n                hw_frames->sw_format == AV_PIX_FMT_RGBA || hw_frames->sw_format == AV_PIX_FMT_BGRA ||\n                hw_frames->sw_format == AV_PIX_FMT_ARGB || hw_frames->sw_format == AV_PIX_FMT_ABGR) {\n                // Ensure output is on CUDA\n                if (!output->is_cuda()) {\n                    *output = output->to(torch::kCUDA);\n                }\n\n                // Convert: NCHW float32 [0,1] -> HWC uint8 [0,255]\n                torch::Tensor output_hwc = output->squeeze(0)           // NCHW -> CHW\n                                                  .permute({1, 2, 0})   // CHW -> HWC\n                                                  .mul(255.0f)\n                                                  .clamp(0.0f, 255.0f)\n                                                  .to(torch::kUInt8)\n                                                  .contiguous();\n\n                // Create 4-channel output with alpha=255\n                auto options = torch::TensorOptions().dtype(torch::kUInt8).device(torch::kCUDA);\n                torch::Tensor alpha = torch::full({out_height, out_width, 1}, 255, options);\n                torch::Tensor output_hwc4;\n\n                if (hw_frames->sw_format == AV_PIX_FMT_RGB0 || hw_frames->sw_format == AV_PIX_FMT_BGR0 ||\n                    hw_frames->sw_format == AV_PIX_FMT_RGBA || hw_frames->sw_format == AV_PIX_FMT_BGRA) {\n                    // RGB(A) format: R, G, B, A\n                    output_hwc4 = torch::cat({output_hwc, alpha}, 2).contiguous();\n                } else {\n                    // (A)RGB format: A, R, G, B\n                    output_hwc4 = torch::cat({alpha, output_hwc}, 2).contiguous();\n                }\n\n                // Copy to output CUDA frame\n                cudaError_t cuda_err4;\n                if (out_linesize == out_width * 4) {\n                    // Contiguous - single copy\n                    cuda_err4 = cudaMemcpy(cuda_out, output_hwc4.data_ptr(),\n                                           out_height * out_width * 4, cudaMemcpyDeviceToDevice);\n                    if (cuda_err4 != cudaSuccess) {\n                        av_log(th_model->ctx, AV_LOG_ERROR, \"cudaMemcpy failed: %s\\n\",\n                               cudaGetErrorString(cuda_err4));\n                        goto err;\n                    }\n                } else {\n                    // Padded rows - copy row by row\n                    for (int y = 0; y < out_height; y++) {\n                        cuda_err4 = cudaMemcpy(cuda_out + y * out_linesize,\n                                               (uint8_t*)output_hwc4.data_ptr() + y * out_width * 4,\n                                               out_width * 4, cudaMemcpyDeviceToDevice);\n                        if (cuda_err4 != cudaSuccess) {\n                            av_log(th_model->ctx, AV_LOG_ERROR, \"cudaMemcpy row %d failed: %s\\n\",\n                                   y, cudaGetErrorString(cuda_err4));\n                            goto err;\n                        }\n                    }\n                }\n\n                task->out_frame->width = out_width;\n                task->out_frame->height = out_height;\n\n                av_log(th_model->ctx, AV_LOG_DEBUG, \"Zero-copy CUDA output done (4-channel format)\\n\");\n                break;\n            }\n\n            av_log(th_model->ctx, AV_LOG_WARNING, \"CUDA output sw_format %s not supported, falling back to CPU\\n\",\n                   av_get_pix_fmt_name(hw_frames->sw_format));\n        }\n\n        // Standard CPU output path\n        if (task->do_ioproc) {\n            // Post process can only deal with CPU memory.\n            if (output->device() != torch::kCPU)\n                *output = output->to(torch::kCPU);  // Expensive GPU->CPU copy!\n            outputs.scale = 255;\n            outputs.data = output->data_ptr();\n            if (th_model->model.frame_post_proc != NULL) {\n                th_model->model.frame_post_proc(task->out_frame, &outputs, th_model->model.filter_ctx);\n            } else {\n                ff_proc_from_dnn_to_frame(task->out_frame, &outputs, th_model->ctx);\n            }\n        } else {\n            task->out_frame->width = outputs.dims[dnn_get_width_idx_by_layout(outputs.layout)];\n            task->out_frame->height = outputs.dims[dnn_get_height_idx_by_layout(outputs.layout)];\n        }\n        break;\n    default:\n        avpriv_report_missing_feature(th_model->ctx, \"model function type %d\", th_model->model.func_type);\n        goto err;\n    }\n    task->inference_done++;\n    goto done;\n    } catch (const std::exception& e) {\n        av_log(th_model->ctx, AV_LOG_ERROR, \"Torch exception in completion callback: %s\\n\", e.what());\n    }\nerr:\n    // Increment inference_done even on error so task completion tracking works\n    task->inference_done++;\ndone:\n    // Free lltask - it was popped from the queue in fill_model_input_th\n    av_freep(&request->lltask);\n\n    // Free the inference request data (tensors)\n    th_free_request(infer_request);\n\n    // Don't free infer_request struct here - it's reused when pushed back to request_queue\n    // The struct will be freed when the model is destroyed\n\n    if (ff_safe_queue_push_back(th_model->request_queue, request) < 0) {\n        // Only destroy if we can't push back - this will free the struct\n        av_freep(&request->infer_request);\n        av_freep(&request);\n        av_log(th_model->ctx, AV_LOG_ERROR, \"Unable to push back request_queue when failed to start inference.\\n\");\n    }\n}\n\nstatic void th_worker_thread(THModel *th_model) {\n    while (true) {\n        THRequestItem *request = NULL;\n        {\n            std::unique_lock<std::mutex> lock(*th_model->mutex);\n            th_model->cond->wait(lock, [&]{\n                return th_model->worker_stop || ff_safe_queue_size(th_model->pending_queue) > 0;\n            });\n\n            if (th_model->worker_stop && ff_safe_queue_size(th_model->pending_queue) == 0)\n                break;\n\n            request = (THRequestItem *)ff_safe_queue_pop_front(th_model->pending_queue);\n        }\n\n        if (request) {\n            int ret = th_start_inference(request);\n            if (ret < 0) {\n                av_log(NULL, AV_LOG_ERROR, \"Async inference failed: %d\\n\", ret);\n            }\n            infer_completion_callback(request);\n        }\n    }\n}\n\nstatic int execute_model_th(THRequestItem *request, Queue *lltask_queue)\n{\n    THModel *th_model = NULL;\n    LastLevelTaskItem *lltask;\n    TaskItem *task = NULL;\n    int ret = 0;\n\n    if (ff_queue_size(lltask_queue) == 0) {\n        destroy_request_item(&request);\n        return 0;\n    }\n\n    lltask = (LastLevelTaskItem *)ff_queue_peek_front(lltask_queue);\n    if (lltask == NULL) {\n        av_log(NULL, AV_LOG_ERROR, \"Failed to get LastLevelTaskItem\\n\");\n        ret = AVERROR(EINVAL);\n        goto err;\n    }\n    task = lltask->task;\n    th_model = (THModel *)task->model;\n\n    ret = fill_model_input_th(th_model, request);\n    if ( ret != 0) {\n        goto err;\n    }\n    if (task->async) {\n        std::lock_guard<std::mutex> lock(*th_model->mutex);\n        if (ff_safe_queue_push_back(th_model->pending_queue, request) < 0) {\n            th_free_request(request->infer_request);\n            av_freep(&request->lltask);\n            if (ff_safe_queue_push_back(th_model->request_queue, request) < 0) {\n                destroy_request_item(&request);\n            }\n            return AVERROR(ENOMEM);\n        }\n        th_model->cond->notify_one();\n        return 0;\n    } else {\n        // Synchronous execution path\n        ret = th_start_inference((void *)request);\n        if (ret != 0) {\n            goto err;\n        }\n        infer_completion_callback(request);\n        return (task->inference_done == task->inference_todo) ? 0 : DNN_GENERIC_ERROR;\n    }\n\nerr:\n    th_free_request(request->infer_request);\n    av_freep(&request->lltask);  // Free lltask to avoid leak and dangling pointer\n    if (!th_model || ff_safe_queue_push_back(th_model->request_queue, request) < 0) {\n        destroy_request_item(&request);\n    }\n    return ret;\n}\n\nstatic int get_output_th(DNNModel *model, const char *input_name, int input_width, int input_height,\n                                   const char *output_name, int *output_width, int *output_height)\n{\n    int ret = 0;\n    THModel *th_model = (THModel*) model;\n    DnnContext *ctx = th_model->ctx;\n    TaskItem task = { 0 };\n    THRequestItem *request = NULL;\n    DNNExecBaseParams exec_params = {\n        .input_name     = input_name,\n        .output_names   = &output_name,\n        .nb_output      = 1,\n        .in_frame       = NULL,\n        .out_frame      = NULL,\n    };\n    ret = ff_dnn_fill_gettingoutput_task(&task, &exec_params, th_model, input_height, input_width, ctx);\n    if ( ret != 0) {\n        goto err;\n    }\n\n    ret = extract_lltask_from_task(&task, th_model->lltask_queue);\n    if ( ret != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to extract last level task from task.\\n\");\n        goto err;\n    }\n\n    request = (THRequestItem*) ff_safe_queue_pop_front(th_model->request_queue);\n    if (!request) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to get infer request.\\n\");\n        ret = AVERROR(EINVAL);\n        // Clean up lltask that was pushed by extract_lltask_from_task\n        LastLevelTaskItem *lltask = (LastLevelTaskItem *)ff_queue_pop_back(th_model->lltask_queue);\n        av_freep(&lltask);\n        goto err;\n    }\n\n    ret = execute_model_th(request, th_model->lltask_queue);\n    *output_width = task.out_frame->width;\n    *output_height = task.out_frame->height;\n\nerr:\n    av_frame_free(&task.out_frame);\n    av_frame_free(&task.in_frame);\n    return ret;\n}\n\nstatic THInferRequest *th_create_inference_request(void)\n{\n    THInferRequest *request = (THInferRequest *)av_malloc(sizeof(THInferRequest));\n    if (!request) {\n        return NULL;\n    }\n    request->input_tensor = NULL;\n    request->output = NULL;\n    return request;\n}\n\nstatic DNNModel *dnn_load_model_th(DnnContext *ctx, DNNFunctionType func_type, AVFilterContext *filter_ctx)\n{\n    DNNModel *model = NULL;\n    THModel *th_model = NULL;\n    THRequestItem *item = NULL;\n    const char *device_name = ctx->device ? ctx->device : \"cpu\";\n\n    th_model = (THModel *)av_mallocz(sizeof(THModel));\n    if (!th_model)\n        return NULL;\n    model = &th_model->model;\n    th_model->ctx = ctx;\n\n    c10::Device device = c10::Device(device_name);\n    if (device.is_xpu()) {\n        if (!at::hasXPU()) {\n            av_log(ctx, AV_LOG_ERROR, \"No XPU device found\\n\");\n            goto fail;\n        }\n        at::detail::getXPUHooks().init();\n    } else if (device.is_cuda()) {\n        if (!at::cuda::is_available()) {\n            av_log(ctx, AV_LOG_ERROR, \"No CUDA device found\\n\");\n            goto fail;\n        }\n        // Load CUDA kernels - required for libtorch CUDA ops\n        // Thread-safe initialization using call_once\n        // NOTE: These handles are intentionally never dlclose'd. CUDA/TensorRT libraries\n        // have complex cleanup requirements and calling dlclose can cause crashes.\n        // The OS reclaims resources on process exit.\n        static std::once_flag cuda_lib_once;\n        static void *cuda_lib_handle = NULL;\n        std::call_once(cuda_lib_once, [ctx]() {\n            cuda_lib_handle = dlopen(\"libtorch_cuda.so\", RTLD_NOW | RTLD_GLOBAL);\n            if (cuda_lib_handle) {\n                av_log(ctx, AV_LOG_DEBUG, \"libtorch_cuda.so loaded\\n\");\n            } else {\n                av_log(ctx, AV_LOG_WARNING, \"Failed to load libtorch_cuda.so: %s\\n\", dlerror());\n            }\n        });\n    } else if (!device.is_cpu()) {\n        av_log(ctx, AV_LOG_ERROR, \"Not supported device:\\\"%s\\\"\\n\", device_name);\n        goto fail;\n    }\n\n    try {\n        th_model->jit_model = new torch::jit::Module;\n        // Load TensorRT runtime if available (enables TRT-compiled models)\n        // Thread-safe initialization using call_once\n        static std::once_flag trt_once;\n        static void *trt_lib_handle = NULL;\n        std::call_once(trt_once, [ctx]() {\n            trt_lib_handle = dlopen(\"libtorchtrt_runtime.so\", RTLD_NOW | RTLD_GLOBAL);\n            if (trt_lib_handle) {\n                av_log(ctx, AV_LOG_INFO, \"TensorRT runtime loaded\\n\");\n            }\n        });\n        (*th_model->jit_model) = torch::jit::load(ctx->model_filename);\n        th_model->jit_model->to(device);\n        // Set JIT optimization once at model load time (thread-safe)\n        torch::jit::setGraphExecutorOptimize(ctx->torch_option.optimize ? true : false);\n        av_log(ctx, AV_LOG_INFO, \"Model loaded to device: %s (JIT optimize=%d)\\n\",\n               device_name, ctx->torch_option.optimize);\n        if (device.is_cuda()) {\n            av_log(ctx, AV_LOG_INFO, \"CUDA available: %s, device count: %d\\n\",\n                   at::cuda::is_available() ? \"yes\" : \"no\",\n                   at::cuda::device_count());\n        }\n    } catch (const c10::Error& e) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to load torch model: %s\\n\", e.what());\n        goto fail;\n    } catch (const std::exception& e) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to load torch model: %s\\n\", e.what());\n        goto fail;\n    }\n\n    th_model->request_queue = ff_safe_queue_create();\n    if (!th_model->request_queue) {\n        goto fail;\n    }\n\n    item = (THRequestItem *)av_mallocz(sizeof(THRequestItem));\n    if (!item) {\n        goto fail;\n    }\n    item->lltask = NULL;\n    item->infer_request = th_create_inference_request();\n    if (!item->infer_request) {\n        av_log(NULL, AV_LOG_ERROR, \"Failed to allocate memory for Torch inference request\\n\");\n        goto fail;\n    }\n    item->exec_module.start_inference = &th_start_inference;\n    item->exec_module.callback = &infer_completion_callback;\n    item->exec_module.args = item;\n\n    if (ff_safe_queue_push_back(th_model->request_queue, item) < 0) {\n        goto fail;\n    }\n    item = NULL;\n\n    th_model->task_queue = ff_queue_create();\n    if (!th_model->task_queue) {\n        goto fail;\n    }\n\n    th_model->lltask_queue = ff_queue_create();\n    if (!th_model->lltask_queue) {\n        goto fail;\n    }\n\n    th_model->pending_queue = ff_safe_queue_create();\n    if (!th_model->pending_queue) {\n        goto fail;\n    }\n\n    try {\n        th_model->mutex = new std::mutex();\n        th_model->cond = new std::condition_variable();\n        th_model->worker_stop = false;\n        th_model->worker_thread = new std::thread(th_worker_thread, th_model);\n    } catch (const std::exception& e) {\n        av_log(ctx, AV_LOG_ERROR, \"Failed to create worker thread: %s\\n\", e.what());\n        goto fail;\n    }\n\n    model->get_input = &get_input_th;\n    model->get_output = &get_output_th;\n    model->filter_ctx = filter_ctx;\n    model->func_type = func_type;\n    return model;\n\nfail:\n    if (item) {\n        destroy_request_item(&item);\n        // Note: destroy_request_item already calls av_freep(arg), so item is now NULL\n    }\n    dnn_free_model_th(&model);\n    return NULL;\n}\n\nstatic int dnn_execute_model_th(const DNNModel *model, DNNExecBaseParams *exec_params)\n{\n    THModel *th_model = (THModel *)model;\n    DnnContext *ctx = th_model->ctx;\n    TaskItem *task;\n    THRequestItem *request;\n    int ret = 0;\n\n    ret = ff_check_exec_params(ctx, DNN_TH, model->func_type, exec_params);\n    if (ret != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"exec parameter checking fail.\\n\");\n        return ret;\n    }\n\n    task = (TaskItem *)av_malloc(sizeof(TaskItem));\n    if (!task) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to alloc memory for task item.\\n\");\n        return AVERROR(ENOMEM);\n    }\n\n    ret = ff_dnn_fill_task(task, exec_params, th_model, 0, 1);\n    if (ret != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to fill task.\\n\");\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return ret;\n    }\n\n    ret = ff_queue_push_back(th_model->task_queue, task);\n    if (ret < 0) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to push back task_queue.\\n\");\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return ret;\n    }\n\n    ret = extract_lltask_from_task(task, th_model->lltask_queue);\n    if (ret != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to extract last level task from task.\\n\");\n        ff_queue_pop_back(th_model->task_queue);\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return ret;\n    }\n\n    request = (THRequestItem *)ff_safe_queue_pop_front(th_model->request_queue);\n    if (!request) {\n        av_log(ctx, AV_LOG_ERROR, \"unable to get infer request.\\n\");\n        LastLevelTaskItem *lltask = (LastLevelTaskItem *)ff_queue_pop_back(th_model->lltask_queue);\n        av_freep(&lltask);\n        ff_queue_pop_back(th_model->task_queue);\n        av_frame_free(&task->in_frame);\n        av_frame_free(&task->out_frame);\n        av_freep(&task);\n        return AVERROR(EINVAL);\n    }\n\n    return execute_model_th(request, th_model->lltask_queue);\n}\n\nstatic DNNAsyncStatusType dnn_get_result_th(const DNNModel *model, AVFrame **in, AVFrame **out)\n{\n    THModel *th_model = (THModel *)model;\n    return ff_dnn_get_result_common(th_model->task_queue, in, out);\n}\n\nstatic int dnn_flush_th(const DNNModel *model)\n{\n    THModel *th_model = (THModel *)model;\n    THRequestItem *request;\n\n    if (ff_queue_size(th_model->lltask_queue) == 0)\n        // no pending task need to flush\n        return 0;\n\n    request = (THRequestItem *)ff_safe_queue_pop_front(th_model->request_queue);\n    if (!request) {\n        av_log(th_model->ctx, AV_LOG_ERROR, \"unable to get infer request.\\n\");\n        return AVERROR(EINVAL);\n    }\n\n    return execute_model_th(request, th_model->lltask_queue);\n}\n\nextern const DNNModule ff_dnn_backend_torch = {\n    .clazz          = DNN_DEFINE_CLASS(dnn_th),\n    .type           = DNN_TH,\n    .load_model     = dnn_load_model_th,\n    .execute_model  = dnn_execute_model_th,\n    .get_result     = dnn_get_result_th,\n    .flush          = dnn_flush_th,\n    .free_model     = dnn_free_model_th,\n};\n"
  },
  {
    "path": "tools/patches/dnn_cuda_kernels.cu",
    "content": "/*\n * Copyright 2026 Joshua V. Dillon\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n * CUDA kernels for DNN backend format conversion.\n * Compiled to PTX at build time, loaded dynamically at runtime.\n * No cudart dependency - uses CUDA Driver API via FFmpeg's dynlink.\n *\n * WHY CUSTOM CUDA KERNELS FOR SUCH A PRIMITIVE OPERATION?\n * ========================================================\n * These kernels convert FFmpeg frames (HWC uint8) to neural network input (NCHW float).\n * This seems like it should be a one-liner, but no standard library handles our needs:\n *\n * What we need (all in one pass, zero-copy from FFmpeg CUDA frames):\n *   1. Handle FFmpeg's linesize padding (row stride != width * channels)\n *   2. Convert uint8 [0,255] to float [0,1] (type conversion + scaling)\n *   3. Transpose HWC to NCHW (layout conversion)\n *   4. Support multiple pixel formats (RGB24, BGR24, RGBA, BGRA, ARGB, etc.)\n *   5. Support multiple tensor types (FP32, FP16, BF16)\n *\n * Why existing libraries don't work:\n *   - cuDNN cudnnTransformTensor: float-to-float layout changes only, no uint8\n *   - NPP (nppiConvert_8u32f): type conversion but no transpose, separate calls\n *   - TensorRT IReformatLayer: layout changes for float, not uint8 ingestion\n *   - Chaining these: multiple kernel launches + intermediate allocations\n *\n * Alternatives considered:\n *   - Preprocessing in ONNX model: Can't handle variable linesize padding\n *   - TensorRT custom plugin: Adds export complexity, less flexible\n *   - NPP + cuBLAS transpose: 2 passes, intermediate buffer, slower\n *\n * The fused kernel approach: one read, one write, no intermediate buffers.\n * It's unfortunate that we need 400 lines of CUDA for \"pixels to floats\",\n * but this is the reality of bridging FFmpeg's frame formats to ML frameworks.\n *\n * MEMORY ACCESS PATTERN NOTES:\n * ============================\n * The HWC->NCHW conversion writes R,G,B to memory locations separated by H*W elements.\n * This looks like poor cache behavior, but:\n *   - Writes within each channel plane ARE coalesced (adjacent threads write adjacent addresses)\n *   - Modern GPU L2 caches (4-6MB) can hold multiple planes for typical frame sizes\n *   - The strided HWC reads (3 bytes apart) are actually the slower part\n *   - Shared memory staging for better write coalescing was tested but didn't help\n *\n * An elementwise approach (3 separate kernels, one per channel) would:\n *   - Triple kernel launch overhead\n *   - Read the input 3x instead of 1x\n *   - Not improve coalescing (still stride-3 reads)\n */\n\n#include <cuda_fp16.h>\n#include <cuda_bf16.h>\n\nextern \"C\" {\n\n// Precomputed reciprocal for [0,255] -> [0,1] conversion\n// Using multiplication is faster than division\n__device__ __constant__ float kScale255Inv = 1.0f / 255.0f;\n\n// Kernel: HWC uint8 [0,255] -> NCHW float32 [0,1]\n// Input: uint8 buffer in HWC format (height, width, 3) with possible row padding\n// Output: float32 buffer in NCHW format (1, 3, height, width)\n__global__ void hwc_uint8_to_nchw_float32_kernel(\n    const unsigned char* __restrict__ input,\n    float* __restrict__ output,\n    int height, int width, int input_linesize)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    // Input: HWC with potential row padding\n    const unsigned char* row = input + y * input_linesize;\n    unsigned char r = row[x * 3 + 0];\n    unsigned char g = row[x * 3 + 1];\n    unsigned char b = row[x * 3 + 2];\n\n    // Output: NCHW (batch=1), scale to [0,1] using multiplication (faster than division)\n    int hw = height * width;\n    int offset = y * width + x;\n    output[0 * hw + offset] = r * kScale255Inv;  // R channel\n    output[1 * hw + offset] = g * kScale255Inv;  // G channel\n    output[2 * hw + offset] = b * kScale255Inv;  // B channel\n}\n\n// Helper: Clamp float to [0,255] with NaN handling and proper rounding\n__device__ __forceinline__ unsigned char float_to_uint8_safe(float val) {\n    // Handle NaN and Inf: NaN comparisons return false, so we check explicitly\n    // isfinite() returns false for NaN and Inf\n    if (!isfinite(val)) {\n        return 0;  // Default to black for corrupted values\n    }\n    // Scale, clamp, and round to nearest integer\n    val = val * 255.0f + 0.5f;  // Add 0.5 for proper rounding\n    val = fminf(fmaxf(val, 0.0f), 255.0f);\n    return (unsigned char)val;\n}\n\n// Kernel: NCHW float32 [0,1] -> HWC uint8 [0,255]\n// Input: float32 buffer in NCHW format (1, 3, height, width)\n// Output: uint8 buffer in HWC format (height, width, 3) with possible row padding\n__global__ void nchw_float32_to_hwc_uint8_kernel(\n    const float* __restrict__ input,\n    unsigned char* __restrict__ output,\n    int height, int width, int output_linesize)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    int hw = height * width;\n    int offset = y * width + x;\n\n    // Input: NCHW (batch=1), values in [0,1]\n    float r = input[0 * hw + offset];\n    float g = input[1 * hw + offset];\n    float b = input[2 * hw + offset];\n\n    // Output: HWC with potential row padding\n    // Using safe conversion with NaN handling and proper rounding\n    unsigned char* row = output + y * output_linesize;\n    row[x * 3 + 0] = float_to_uint8_safe(r);\n    row[x * 3 + 1] = float_to_uint8_safe(g);\n    row[x * 3 + 2] = float_to_uint8_safe(b);\n}\n\n// Kernel: 4-channel HWC uint8 -> NCHW float32 (extract RGB, ignore alpha)\n// NOTE: r_offset, g_offset, b_offset must be validated by host before launch (range [0,3]).\n// Device-side bounds checking would add branching overhead to every pixel - not worth it.\n__global__ void hwc4_uint8_to_nchw_float32_kernel(\n    const unsigned char* __restrict__ input,\n    float* __restrict__ output,\n    int height, int width, int input_linesize,\n    int r_offset, int g_offset, int b_offset)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    // Host is responsible for validating offsets are in [0,3]\n    const unsigned char* row = input + y * input_linesize;\n    unsigned char r = row[x * 4 + r_offset];\n    unsigned char g = row[x * 4 + g_offset];\n    unsigned char b = row[x * 4 + b_offset];\n\n    int hw = height * width;\n    int offset = y * width + x;\n    output[0 * hw + offset] = r * kScale255Inv;\n    output[1 * hw + offset] = g * kScale255Inv;\n    output[2 * hw + offset] = b * kScale255Inv;\n}\n\n// Kernel: NCHW float32 -> 4-channel HWC uint8 (add alpha=255)\n// NOTE: r_offset, g_offset, b_offset, a_offset must be validated by host before launch (range [0,3]).\n// Device-side bounds checking would add branching overhead to every pixel - not worth it.\n__global__ void nchw_float32_to_hwc4_uint8_kernel(\n    const float* __restrict__ input,\n    unsigned char* __restrict__ output,\n    int height, int width, int output_linesize,\n    int r_offset, int g_offset, int b_offset, int a_offset)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    // Host is responsible for validating offsets are in [0,3]\n    int hw = height * width;\n    int offset = y * width + x;\n    float r = input[0 * hw + offset];\n    float g = input[1 * hw + offset];\n    float b = input[2 * hw + offset];\n\n    // Using safe conversion with NaN handling and proper rounding\n    unsigned char* row = output + y * output_linesize;\n    row[x * 4 + r_offset] = float_to_uint8_safe(r);\n    row[x * 4 + g_offset] = float_to_uint8_safe(g);\n    row[x * 4 + b_offset] = float_to_uint8_safe(b);\n    row[x * 4 + a_offset] = 255;  // Alpha = opaque\n}\n\n// ============================================================================\n// FP16 (half precision) variants\n// ============================================================================\n\n// Kernel: HWC uint8 [0,255] -> NCHW float16 [0,1]\n__global__ void hwc_uint8_to_nchw_float16_kernel(\n    const unsigned char* __restrict__ input,\n    __half* __restrict__ output,\n    int height, int width, int input_linesize)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    const unsigned char* row = input + y * input_linesize;\n    unsigned char r = row[x * 3 + 0];\n    unsigned char g = row[x * 3 + 1];\n    unsigned char b = row[x * 3 + 2];\n\n    int hw = height * width;\n    int offset = y * width + x;\n    output[0 * hw + offset] = __float2half(r * kScale255Inv);\n    output[1 * hw + offset] = __float2half(g * kScale255Inv);\n    output[2 * hw + offset] = __float2half(b * kScale255Inv);\n}\n\n// Helper: Convert half to uint8 safely\n__device__ __forceinline__ unsigned char half_to_uint8_safe(__half val) {\n    float f = __half2float(val);\n    if (!isfinite(f)) return 0;\n    f = f * 255.0f + 0.5f;\n    f = fminf(fmaxf(f, 0.0f), 255.0f);\n    return (unsigned char)f;\n}\n\n// Kernel: NCHW float16 [0,1] -> HWC uint8 [0,255]\n__global__ void nchw_float16_to_hwc_uint8_kernel(\n    const __half* __restrict__ input,\n    unsigned char* __restrict__ output,\n    int height, int width, int output_linesize)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    int hw = height * width;\n    int offset = y * width + x;\n    __half r = input[0 * hw + offset];\n    __half g = input[1 * hw + offset];\n    __half b = input[2 * hw + offset];\n\n    unsigned char* row = output + y * output_linesize;\n    row[x * 3 + 0] = half_to_uint8_safe(r);\n    row[x * 3 + 1] = half_to_uint8_safe(g);\n    row[x * 3 + 2] = half_to_uint8_safe(b);\n}\n\n// Kernel: 4-channel HWC uint8 -> NCHW float16 (extract RGB, ignore alpha)\n// NOTE: r_offset, g_offset, b_offset must be validated by host before launch (range [0,3]).\n// Device-side bounds checking would add branching overhead to every pixel - not worth it.\n__global__ void hwc4_uint8_to_nchw_float16_kernel(\n    const unsigned char* __restrict__ input,\n    __half* __restrict__ output,\n    int height, int width, int input_linesize,\n    int r_offset, int g_offset, int b_offset)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    // Host is responsible for validating offsets are in [0,3]\n    const unsigned char* row = input + y * input_linesize;\n    unsigned char r = row[x * 4 + r_offset];\n    unsigned char g = row[x * 4 + g_offset];\n    unsigned char b = row[x * 4 + b_offset];\n\n    int hw = height * width;\n    int offset = y * width + x;\n    output[0 * hw + offset] = __float2half(r * kScale255Inv);\n    output[1 * hw + offset] = __float2half(g * kScale255Inv);\n    output[2 * hw + offset] = __float2half(b * kScale255Inv);\n}\n\n// Kernel: NCHW float16 -> 4-channel HWC uint8 (set alpha to 255)\n// NOTE: r_offset, g_offset, b_offset, a_offset must be validated by host before launch (range [0,3]).\n// Device-side bounds checking would add branching overhead to every pixel - not worth it.\n__global__ void nchw_float16_to_hwc4_uint8_kernel(\n    const __half* __restrict__ input,\n    unsigned char* __restrict__ output,\n    int height, int width, int output_linesize,\n    int r_offset, int g_offset, int b_offset, int a_offset)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    // Host is responsible for validating offsets are in [0,3]\n    int hw = height * width;\n    int offset = y * width + x;\n    __half r = input[0 * hw + offset];\n    __half g = input[1 * hw + offset];\n    __half b = input[2 * hw + offset];\n\n    unsigned char* row = output + y * output_linesize;\n    row[x * 4 + r_offset] = half_to_uint8_safe(r);\n    row[x * 4 + g_offset] = half_to_uint8_safe(g);\n    row[x * 4 + b_offset] = half_to_uint8_safe(b);\n    row[x * 4 + a_offset] = 255;\n}\n\n// ============================================================================\n// BF16 (bfloat16) variants\n// ============================================================================\n\n// Kernel: HWC uint8 [0,255] -> NCHW bfloat16 [0,1]\n__global__ void hwc_uint8_to_nchw_bfloat16_kernel(\n    const unsigned char* __restrict__ input,\n    __nv_bfloat16* __restrict__ output,\n    int height, int width, int input_linesize)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    const unsigned char* row = input + y * input_linesize;\n    unsigned char r = row[x * 3 + 0];\n    unsigned char g = row[x * 3 + 1];\n    unsigned char b = row[x * 3 + 2];\n\n    int hw = height * width;\n    int offset = y * width + x;\n    output[0 * hw + offset] = __float2bfloat16(r * kScale255Inv);\n    output[1 * hw + offset] = __float2bfloat16(g * kScale255Inv);\n    output[2 * hw + offset] = __float2bfloat16(b * kScale255Inv);\n}\n\n// Helper: Convert bfloat16 to uint8 safely\n__device__ __forceinline__ unsigned char bfloat16_to_uint8_safe(__nv_bfloat16 val) {\n    float f = __bfloat162float(val);\n    if (!isfinite(f)) return 0;\n    f = f * 255.0f + 0.5f;\n    f = fminf(fmaxf(f, 0.0f), 255.0f);\n    return (unsigned char)f;\n}\n\n// Kernel: NCHW bfloat16 [0,1] -> HWC uint8 [0,255]\n__global__ void nchw_bfloat16_to_hwc_uint8_kernel(\n    const __nv_bfloat16* __restrict__ input,\n    unsigned char* __restrict__ output,\n    int height, int width, int output_linesize)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    int hw = height * width;\n    int offset = y * width + x;\n    __nv_bfloat16 r = input[0 * hw + offset];\n    __nv_bfloat16 g = input[1 * hw + offset];\n    __nv_bfloat16 b = input[2 * hw + offset];\n\n    unsigned char* row = output + y * output_linesize;\n    row[x * 3 + 0] = bfloat16_to_uint8_safe(r);\n    row[x * 3 + 1] = bfloat16_to_uint8_safe(g);\n    row[x * 3 + 2] = bfloat16_to_uint8_safe(b);\n}\n\n// Kernel: 4-channel HWC uint8 -> NCHW bfloat16 (extract RGB, ignore alpha)\n// NOTE: r_offset, g_offset, b_offset must be validated by host before launch (range [0,3]).\n// Device-side bounds checking would add branching overhead to every pixel - not worth it.\n__global__ void hwc4_uint8_to_nchw_bfloat16_kernel(\n    const unsigned char* __restrict__ input,\n    __nv_bfloat16* __restrict__ output,\n    int height, int width, int input_linesize,\n    int r_offset, int g_offset, int b_offset)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    // Host is responsible for validating offsets are in [0,3]\n    const unsigned char* row = input + y * input_linesize;\n    unsigned char r = row[x * 4 + r_offset];\n    unsigned char g = row[x * 4 + g_offset];\n    unsigned char b = row[x * 4 + b_offset];\n\n    int hw = height * width;\n    int offset = y * width + x;\n    output[0 * hw + offset] = __float2bfloat16(r * kScale255Inv);\n    output[1 * hw + offset] = __float2bfloat16(g * kScale255Inv);\n    output[2 * hw + offset] = __float2bfloat16(b * kScale255Inv);\n}\n\n// Kernel: NCHW bfloat16 -> 4-channel HWC uint8 (set alpha to 255)\n// NOTE: r_offset, g_offset, b_offset, a_offset must be validated by host before launch (range [0,3]).\n// Device-side bounds checking would add branching overhead to every pixel - not worth it.\n__global__ void nchw_bfloat16_to_hwc4_uint8_kernel(\n    const __nv_bfloat16* __restrict__ input,\n    unsigned char* __restrict__ output,\n    int height, int width, int output_linesize,\n    int r_offset, int g_offset, int b_offset, int a_offset)\n{\n    int x = blockIdx.x * blockDim.x + threadIdx.x;\n    int y = blockIdx.y * blockDim.y + threadIdx.y;\n\n    if (x >= width || y >= height) return;\n\n    // Host is responsible for validating offsets are in [0,3]\n    int hw = height * width;\n    int offset = y * width + x;\n    __nv_bfloat16 r = input[0 * hw + offset];\n    __nv_bfloat16 g = input[1 * hw + offset];\n    __nv_bfloat16 b = input[2 * hw + offset];\n\n    unsigned char* row = output + y * output_linesize;\n    row[x * 4 + r_offset] = bfloat16_to_uint8_safe(r);\n    row[x * 4 + g_offset] = bfloat16_to_uint8_safe(g);\n    row[x * 4 + b_offset] = bfloat16_to_uint8_safe(b);\n    row[x * 4 + a_offset] = 255;\n}\n\n}  // extern \"C\"\n"
  },
  {
    "path": "tools/patches/dnn_cuda_kernels.h",
    "content": "/*\n * Copyright 2026 Joshua V. Dillon\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n * CUDA kernel PTX declarations for DNN backend format conversion.\n * Kernels are compiled to PTX at build time and loaded via Driver API at runtime.\n * This avoids any CUDA runtime (cudart) dependency.\n */\n\n#ifndef AVFILTER_DNN_CUDA_KERNELS_H\n#define AVFILTER_DNN_CUDA_KERNELS_H\n\n#include <stddef.h>\n\n/* PTX bytecode embedded at compile time via bin2c */\nextern const unsigned char ff_dnn_cuda_kernels_ptx[];\nextern const unsigned int ff_dnn_cuda_kernels_ptx_len;\n\n/* Kernel names within the PTX module */\n/* FP32 variants */\n#define DNN_CUDA_KERNEL_HWC_UINT8_TO_NCHW_FLOAT32     \"hwc_uint8_to_nchw_float32_kernel\"\n#define DNN_CUDA_KERNEL_NCHW_FLOAT32_TO_HWC_UINT8     \"nchw_float32_to_hwc_uint8_kernel\"\n#define DNN_CUDA_KERNEL_HWC4_UINT8_TO_NCHW_FLOAT32    \"hwc4_uint8_to_nchw_float32_kernel\"\n#define DNN_CUDA_KERNEL_NCHW_FLOAT32_TO_HWC4_UINT8    \"nchw_float32_to_hwc4_uint8_kernel\"\n\n/* FP16 variants */\n#define DNN_CUDA_KERNEL_HWC_UINT8_TO_NCHW_FLOAT16     \"hwc_uint8_to_nchw_float16_kernel\"\n#define DNN_CUDA_KERNEL_NCHW_FLOAT16_TO_HWC_UINT8     \"nchw_float16_to_hwc_uint8_kernel\"\n#define DNN_CUDA_KERNEL_HWC4_UINT8_TO_NCHW_FLOAT16    \"hwc4_uint8_to_nchw_float16_kernel\"\n#define DNN_CUDA_KERNEL_NCHW_FLOAT16_TO_HWC4_UINT8    \"nchw_float16_to_hwc4_uint8_kernel\"\n\n/* BF16 variants */\n#define DNN_CUDA_KERNEL_HWC_UINT8_TO_NCHW_BFLOAT16    \"hwc_uint8_to_nchw_bfloat16_kernel\"\n#define DNN_CUDA_KERNEL_NCHW_BFLOAT16_TO_HWC_UINT8    \"nchw_bfloat16_to_hwc_uint8_kernel\"\n#define DNN_CUDA_KERNEL_HWC4_UINT8_TO_NCHW_BFLOAT16   \"hwc4_uint8_to_nchw_bfloat16_kernel\"\n#define DNN_CUDA_KERNEL_NCHW_BFLOAT16_TO_HWC4_UINT8   \"nchw_bfloat16_to_hwc4_uint8_kernel\"\n\n#endif  /* AVFILTER_DNN_CUDA_KERNELS_H */\n"
  },
  {
    "path": "tools/patches/vf_dnn_processing.c",
    "content": "/*\n * Copyright (c) 2019 Guo Yejun\n * Copyright (c) 2026 Joshua V. Dillon (TensorRT/Torch backend integration, CUDA hw frame support)\n *\n * This file is part of FFmpeg.\n *\n * FFmpeg is free software; you can redistribute it and/or\n * modify it under the terms of the GNU Lesser General Public\n * License as published by the Free Software Foundation; either\n * version 2.1 of the License, or (at your option) any later version.\n *\n * FFmpeg is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU\n * Lesser General Public License for more details.\n *\n * You should have received a copy of the GNU Lesser General Public\n * License along with FFmpeg; if not, write to the Free Software\n * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n */\n\n/**\n * @file\n * implementing a generic image processing filter using deep learning networks.\n */\n\n#include \"libavutil/opt.h\"\n#include \"libavutil/pixdesc.h\"\n#include \"libavutil/avassert.h\"\n#include \"libavutil/imgutils.h\"\n#include \"libavutil/hwcontext.h\"\n#include \"libavutil/hwcontext_cuda.h\"\n#include \"filters.h\"\n#include \"dnn_filter_common.h\"\n#include \"video.h\"\n#include \"libswscale/swscale.h\"\n#include \"libavutil/time.h\"\n\ntypedef struct DnnProcessingContext {\n    const AVClass *class;\n    DnnContext dnnctx;\n    struct SwsContext *sws_uv_scale;\n    int sws_uv_height;\n    AVBufferRef *hw_frames_ctx;  // For CUDA output frames\n} DnnProcessingContext;\n\n#define OFFSET(x) offsetof(DnnProcessingContext, dnnctx.x)\n#define FLAGS AV_OPT_FLAG_FILTERING_PARAM | AV_OPT_FLAG_VIDEO_PARAM\nstatic const AVOption dnn_processing_options[] = {\n    { \"dnn_backend\", \"DNN backend\",                OFFSET(backend_type),     AV_OPT_TYPE_INT,       { .i64 = DNN_TF },    INT_MIN, INT_MAX, FLAGS, .unit = \"backend\" },\n#if (CONFIG_LIBTENSORFLOW == 1)\n    { \"tensorflow\",  \"tensorflow backend flag\",    0,                        AV_OPT_TYPE_CONST,     { .i64 = DNN_TF },    0, 0, FLAGS, .unit = \"backend\" },\n#endif\n#if (CONFIG_LIBOPENVINO == 1)\n    { \"openvino\",    \"openvino backend flag\",      0,                        AV_OPT_TYPE_CONST,     { .i64 = DNN_OV },    0, 0, FLAGS, .unit = \"backend\" },\n#endif\n#if (CONFIG_LIBTORCH == 1)\n    { \"torch\",       \"torch backend flag\",         0,                        AV_OPT_TYPE_CONST,     { .i64 = DNN_TH },    0, 0, FLAGS, .unit = \"backend\" },\n#endif\n#if (CONFIG_LIBTENSORRT == 1)\n    { \"tensorrt\",    \"tensorrt backend flag\",      0,                        AV_OPT_TYPE_CONST,     { .i64 = DNN_TRT },   0, 0, FLAGS, .unit = \"backend\" },\n#endif\n    { NULL }\n};\n\nAVFILTER_DNN_DEFINE_CLASS(dnn_processing, DNN_TF | DNN_OV | DNN_TH | DNN_TRT);\n\nstatic av_cold int init(AVFilterContext *context)\n{\n    DnnProcessingContext *ctx = context->priv;\n    return ff_dnn_init(&ctx->dnnctx, DFT_PROCESS_FRAME, context);\n}\n\nstatic const enum AVPixelFormat pix_fmts[] = {\n    AV_PIX_FMT_RGB24, AV_PIX_FMT_BGR24,\n    AV_PIX_FMT_GRAY8, AV_PIX_FMT_GRAYF32,\n    AV_PIX_FMT_YUV420P, AV_PIX_FMT_YUV422P,\n    AV_PIX_FMT_YUV444P, AV_PIX_FMT_YUV410P, AV_PIX_FMT_YUV411P,\n    AV_PIX_FMT_NV12,\n    AV_PIX_FMT_CUDA,  // CUDA hardware frames for zero-copy GPU inference\n    AV_PIX_FMT_NONE\n};\n\n#define LOG_FORMAT_CHANNEL_MISMATCH()                       \\\n    av_log(ctx, AV_LOG_ERROR,                               \\\n           \"the frame's format %s does not match \"          \\\n           \"the model input channel %d\\n\",                  \\\n           av_get_pix_fmt_name(fmt),                        \\\n           model_input->dims[dnn_get_channel_idx_by_layout(model_input->layout)]);\n\nstatic int check_modelinput_inlink(const DNNData *model_input, const AVFilterLink *inlink)\n{\n    AVFilterContext *ctx   = inlink->dst;\n    enum AVPixelFormat fmt = inlink->format;\n    int width_idx, height_idx;\n\n    width_idx = dnn_get_width_idx_by_layout(model_input->layout);\n    height_idx = dnn_get_height_idx_by_layout(model_input->layout);\n    // the design is to add explicit scale filter before this filter\n    if (model_input->dims[height_idx] != -1 &&\n        model_input->dims[height_idx] != inlink->h) {\n        av_log(ctx, AV_LOG_ERROR, \"the model requires frame height %d but got %d\\n\",\n                                   model_input->dims[height_idx],\n                                   inlink->h);\n        return AVERROR(EIO);\n    }\n    if (model_input->dims[width_idx] != -1 &&\n        model_input->dims[width_idx] != inlink->w) {\n        av_log(ctx, AV_LOG_ERROR, \"the model requires frame width %d but got %d\\n\",\n                                   model_input->dims[width_idx],\n                                   inlink->w);\n        return AVERROR(EIO);\n    }\n    if (model_input->dt != DNN_FLOAT) {\n        avpriv_report_missing_feature(ctx, \"data type rather than DNN_FLOAT\");\n        return AVERROR(EIO);\n    }\n\n    switch (fmt) {\n    case AV_PIX_FMT_RGB24:\n    case AV_PIX_FMT_BGR24:\n        if (model_input->dims[dnn_get_channel_idx_by_layout(model_input->layout)] != 3) {\n            LOG_FORMAT_CHANNEL_MISMATCH();\n            return AVERROR(EIO);\n        }\n        return 0;\n    case AV_PIX_FMT_GRAY8:\n    case AV_PIX_FMT_GRAYF32:\n    case AV_PIX_FMT_YUV420P:\n    case AV_PIX_FMT_YUV422P:\n    case AV_PIX_FMT_YUV444P:\n    case AV_PIX_FMT_YUV410P:\n    case AV_PIX_FMT_YUV411P:\n    case AV_PIX_FMT_NV12:\n        if (model_input->dims[dnn_get_channel_idx_by_layout(model_input->layout)] != 1) {\n            LOG_FORMAT_CHANNEL_MISMATCH();\n            return AVERROR(EIO);\n        }\n        return 0;\n    case AV_PIX_FMT_CUDA:\n        // CUDA frames: torch backend handles conversion internally\n        // Model expects 3 channels (RGB)\n        if (model_input->dims[dnn_get_channel_idx_by_layout(model_input->layout)] != 3) {\n            LOG_FORMAT_CHANNEL_MISMATCH();\n            return AVERROR(EIO);\n        }\n        return 0;\n    default:\n        avpriv_report_missing_feature(ctx, \"%s\", av_get_pix_fmt_name(fmt));\n        return AVERROR(EIO);\n    }\n}\n\nstatic int config_input(AVFilterLink *inlink)\n{\n    AVFilterContext *context     = inlink->dst;\n    DnnProcessingContext *ctx = context->priv;\n    int result;\n    DNNData model_input;\n    int check;\n\n    result = ff_dnn_get_input(&ctx->dnnctx, &model_input);\n    if (result != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"could not get input from the model\\n\");\n        return result;\n    }\n\n    check = check_modelinput_inlink(&model_input, inlink);\n    if (check != 0) {\n        return check;\n    }\n\n    return 0;\n}\n\nstatic av_always_inline int isPlanarYUV(enum AVPixelFormat pix_fmt)\n{\n    const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(pix_fmt);\n    if (!desc)\n        return 0;\n    return !(desc->flags & AV_PIX_FMT_FLAG_RGB) && desc->nb_components == 3;\n}\n\nstatic int prepare_uv_scale(AVFilterLink *outlink)\n{\n    AVFilterContext *context = outlink->src;\n    DnnProcessingContext *ctx = context->priv;\n    AVFilterLink *inlink = context->inputs[0];\n    enum AVPixelFormat fmt = inlink->format;\n\n    if (isPlanarYUV(fmt)) {\n        if (inlink->w != outlink->w || inlink->h != outlink->h) {\n            if (fmt == AV_PIX_FMT_NV12) {\n                ctx->sws_uv_scale = sws_getContext(inlink->w >> 1, inlink->h >> 1, AV_PIX_FMT_YA8,\n                                                   outlink->w >> 1, outlink->h >> 1, AV_PIX_FMT_YA8,\n                                                   SWS_BICUBIC, NULL, NULL, NULL);\n                if (!ctx->sws_uv_scale) {\n                    av_log(context, AV_LOG_ERROR, \"Failed to create UV scale context for NV12\\n\");\n                    return AVERROR(ENOMEM);\n                }\n                ctx->sws_uv_height = inlink->h >> 1;\n            } else {\n                const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(fmt);\n                int sws_src_h, sws_src_w, sws_dst_h, sws_dst_w;\n                if (!desc) {\n                    av_log(context, AV_LOG_ERROR, \"Unknown pixel format %d\\n\", fmt);\n                    return AVERROR(EINVAL);\n                }\n                sws_src_h = AV_CEIL_RSHIFT(inlink->h, desc->log2_chroma_h);\n                sws_src_w = AV_CEIL_RSHIFT(inlink->w, desc->log2_chroma_w);\n                sws_dst_h = AV_CEIL_RSHIFT(outlink->h, desc->log2_chroma_h);\n                sws_dst_w = AV_CEIL_RSHIFT(outlink->w, desc->log2_chroma_w);\n                ctx->sws_uv_scale = sws_getContext(sws_src_w, sws_src_h, AV_PIX_FMT_GRAY8,\n                                                   sws_dst_w, sws_dst_h, AV_PIX_FMT_GRAY8,\n                                                   SWS_BICUBIC, NULL, NULL, NULL);\n                if (!ctx->sws_uv_scale) {\n                    av_log(context, AV_LOG_ERROR, \"Failed to create UV scale context\\n\");\n                    return AVERROR(ENOMEM);\n                }\n                ctx->sws_uv_height = sws_src_h;\n            }\n        }\n    }\n\n    return 0;\n}\n\nstatic int config_output(AVFilterLink *outlink)\n{\n    AVFilterContext *context = outlink->src;\n    DnnProcessingContext *ctx = context->priv;\n    FilterLink *ol = ff_filter_link(outlink);\n    FilterLink *il = ff_filter_link(context->inputs[0]);\n    int result;\n    AVFilterLink *inlink = context->inputs[0];\n\n    // have a try run in case that the dnn model resize the frame\n    result = ff_dnn_get_output(&ctx->dnnctx, inlink->w, inlink->h, &outlink->w, &outlink->h);\n    if (result != 0) {\n        av_log(ctx, AV_LOG_ERROR, \"could not get output from the model\\n\");\n        return result;\n    }\n\n    // Handle CUDA frames - set up output hw_frames_ctx\n    if (inlink->format == AV_PIX_FMT_CUDA && il->hw_frames_ctx) {\n        AVHWFramesContext *in_frames_ctx = (AVHWFramesContext *)il->hw_frames_ctx->data;\n        AVHWFramesContext *out_frames_ctx;\n\n        ctx->hw_frames_ctx = av_hwframe_ctx_alloc(in_frames_ctx->device_ref);\n        if (!ctx->hw_frames_ctx)\n            return AVERROR(ENOMEM);\n\n        out_frames_ctx = (AVHWFramesContext *)ctx->hw_frames_ctx->data;\n        out_frames_ctx->format = AV_PIX_FMT_CUDA;\n        out_frames_ctx->sw_format = in_frames_ctx->sw_format;\n        out_frames_ctx->width = outlink->w;\n        out_frames_ctx->height = outlink->h;\n\n        result = av_hwframe_ctx_init(ctx->hw_frames_ctx);\n        if (result < 0) {\n            av_buffer_unref(&ctx->hw_frames_ctx);\n            return result;\n        }\n\n        ol->hw_frames_ctx = av_buffer_ref(ctx->hw_frames_ctx);\n        if (!ol->hw_frames_ctx) {\n            av_buffer_unref(&ctx->hw_frames_ctx);\n            return AVERROR(ENOMEM);\n        }\n\n        av_log(context, AV_LOG_INFO, \"CUDA output frames: %dx%d\\n\", outlink->w, outlink->h);\n        return 0;\n    }\n\n    result = prepare_uv_scale(outlink);\n    if (result < 0)\n        return result;\n\n    return 0;\n}\n\nstatic int copy_uv_planes(DnnProcessingContext *ctx, AVFrame *out, const AVFrame *in)\n{\n    const AVPixFmtDescriptor *desc;\n    int uv_height;\n\n    if (!ctx->sws_uv_scale) {\n        av_assert0(in->height == out->height && in->width == out->width);\n        desc = av_pix_fmt_desc_get(in->format);\n        if (!desc)\n            return AVERROR(EINVAL);\n        uv_height = AV_CEIL_RSHIFT(in->height, desc->log2_chroma_h);\n        for (int i = 1; i < 3; ++i) {\n            int bytewidth = av_image_get_linesize(in->format, in->width, i);\n            if (bytewidth < 0) {\n                return AVERROR(EINVAL);\n            }\n            av_image_copy_plane(out->data[i], out->linesize[i],\n                                in->data[i], in->linesize[i],\n                                bytewidth, uv_height);\n        }\n    } else if (in->format == AV_PIX_FMT_NV12) {\n        int ret = sws_scale(ctx->sws_uv_scale, (const uint8_t **)(in->data + 1), in->linesize + 1,\n                            0, ctx->sws_uv_height, out->data + 1, out->linesize + 1);\n        if (ret < 0)\n            return ret;\n    } else {\n        int ret = sws_scale(ctx->sws_uv_scale, (const uint8_t **)(in->data + 1), in->linesize + 1,\n                            0, ctx->sws_uv_height, out->data + 1, out->linesize + 1);\n        if (ret < 0)\n            return ret;\n        ret = sws_scale(ctx->sws_uv_scale, (const uint8_t **)(in->data + 2), in->linesize + 2,\n                        0, ctx->sws_uv_height, out->data + 2, out->linesize + 2);\n        if (ret < 0)\n            return ret;\n    }\n\n    return 0;\n}\n\nstatic int flush_frame(AVFilterLink *outlink, int64_t pts, int64_t *out_pts)\n{\n    DnnProcessingContext *ctx = outlink->src->priv;\n    int ret;\n    DNNAsyncStatusType async_state;\n\n    ret = ff_dnn_flush(&ctx->dnnctx);\n    if (ret != 0) {\n        return ret;\n    }\n\n    do {\n        AVFrame *in_frame = NULL;\n        AVFrame *out_frame = NULL;\n        async_state = ff_dnn_get_result(&ctx->dnnctx, &in_frame, &out_frame);\n        if (out_frame) {\n            int64_t frame_pts = out_frame->pts;  // Save before ff_filter_frame may free\n            if (in_frame && isPlanarYUV(in_frame->format)) {\n                ret = copy_uv_planes(ctx, out_frame, in_frame);\n                if (ret < 0) {\n                    av_frame_free(&in_frame);\n                    av_frame_free(&out_frame);\n                    return ret;\n                }\n            }\n            av_frame_free(&in_frame);\n            ret = ff_filter_frame(outlink, out_frame);\n            if (ret < 0)\n                return ret;\n            if (out_pts)\n                *out_pts = frame_pts + pts;\n        }\n        av_usleep(5000);\n    } while (async_state >= DAST_NOT_READY);\n\n    return 0;\n}\n\nstatic int activate(AVFilterContext *filter_ctx)\n{\n    AVFilterLink *inlink = filter_ctx->inputs[0];\n    AVFilterLink *outlink = filter_ctx->outputs[0];\n    DnnProcessingContext *ctx = filter_ctx->priv;\n    AVFrame *in = NULL, *out = NULL;\n    int64_t pts;\n    int ret, status;\n    int got_frame = 0;\n    int async_state;\n\n    FF_FILTER_FORWARD_STATUS_BACK(outlink, inlink);\n\n    do {\n        // drain all input frames\n        ret = ff_inlink_consume_frame(inlink, &in);\n        if (ret < 0)\n            return ret;\n        if (ret > 0) {\n            // Allocate CUDA frames for CUDA input, CPU frames otherwise\n            if (in->format == AV_PIX_FMT_CUDA && ctx->hw_frames_ctx) {\n                out = av_frame_alloc();\n                if (!out) {\n                    av_frame_free(&in);\n                    return AVERROR(ENOMEM);\n                }\n                ret = av_hwframe_get_buffer(ctx->hw_frames_ctx, out, 0);\n                if (ret < 0) {\n                    av_frame_free(&out);\n                    av_frame_free(&in);\n                    return ret;\n                }\n            } else {\n                out = ff_get_video_buffer(outlink, outlink->w, outlink->h);\n                if (!out) {\n                    av_frame_free(&in);\n                    return AVERROR(ENOMEM);\n                }\n            }\n            ret = av_frame_copy_props(out, in);\n            if (ret < 0) {\n                av_frame_free(&in);\n                av_frame_free(&out);\n                return ret;\n            }\n            if (ff_dnn_execute_model(&ctx->dnnctx, in, out) != 0) {\n                av_log(ctx, AV_LOG_ERROR, \"DNN model execution failed\\n\");\n                av_frame_free(&in);\n                av_frame_free(&out);\n                return AVERROR(EIO);\n            }\n        }\n    } while (ret > 0);\n\n    // drain all processed frames\n    do {\n        AVFrame *in_frame = NULL;\n        AVFrame *out_frame = NULL;\n        async_state = ff_dnn_get_result(&ctx->dnnctx, &in_frame, &out_frame);\n        if (out_frame) {\n            if (in_frame && isPlanarYUV(in_frame->format)) {\n                ret = copy_uv_planes(ctx, out_frame, in_frame);\n                if (ret < 0) {\n                    av_frame_free(&in_frame);\n                    av_frame_free(&out_frame);\n                    return ret;\n                }\n            }\n            av_frame_free(&in_frame);\n            ret = ff_filter_frame(outlink, out_frame);\n            if (ret < 0)\n                return ret;\n            got_frame = 1;\n        }\n    } while (async_state == DAST_SUCCESS);\n\n    // if frame got, schedule to next filter\n    if (got_frame)\n        return 0;\n\n    if (ff_inlink_acknowledge_status(inlink, &status, &pts)) {\n        if (status == AVERROR_EOF) {\n            int64_t out_pts = pts;\n            ret = flush_frame(outlink, pts, &out_pts);\n            ff_outlink_set_status(outlink, status, out_pts);\n            return ret;\n        }\n    }\n\n    FF_FILTER_FORWARD_WANTED(outlink, inlink);\n\n    return 0;\n}\n\nstatic av_cold void uninit(AVFilterContext *ctx)\n{\n    DnnProcessingContext *context = ctx->priv;\n\n    sws_freeContext(context->sws_uv_scale);\n    av_buffer_unref(&context->hw_frames_ctx);\n    ff_dnn_uninit(&context->dnnctx);\n}\n\nstatic const AVFilterPad dnn_processing_inputs[] = {\n    {\n        .name         = \"default\",\n        .type         = AVMEDIA_TYPE_VIDEO,\n        .config_props = config_input,\n    },\n};\n\nstatic const AVFilterPad dnn_processing_outputs[] = {\n    {\n        .name = \"default\",\n        .type = AVMEDIA_TYPE_VIDEO,\n        .config_props  = config_output,\n    },\n};\n\nconst FFFilter ff_vf_dnn_processing = {\n    .p.name        = \"dnn_processing\",\n    .p.description = NULL_IF_CONFIG_SMALL(\"Apply DNN processing filter to the input.\"),\n    .p.priv_class  = &dnn_processing_class,\n    .priv_size     = sizeof(DnnProcessingContext),\n    .preinit       = ff_dnn_filter_init_child_class,\n    .init          = init,\n    .uninit        = uninit,\n    FILTER_INPUTS(dnn_processing_inputs),\n    FILTER_OUTPUTS(dnn_processing_outputs),\n    FILTER_PIXFMTS_ARRAY(pix_fmts),\n    .activate      = activate,\n    .flags_internal = FF_FILTER_FLAG_HWFRAME_AWARE,\n};\n"
  },
  {
    "path": "tools/uninstall-netv.sh",
    "content": "#!/bin/bash\n# Uninstall netv systemd service\n#\n# Usage: sudo ./uninstall-netv.sh\nset -e\n\nif [ \"$EUID\" -ne 0 ]; then\n    echo \"Error: Run with sudo\"\n    echo \"Usage: sudo $0\"\n    exit 1\nfi\n\necho \"=== Uninstalling netv ===\"\n\nif systemctl is-active --quiet netv 2>/dev/null; then\n    echo \"Stopping netv service...\"\n    systemctl stop netv\nfi\n\nif systemctl is-enabled --quiet netv 2>/dev/null; then\n    echo \"Disabling netv service...\"\n    systemctl disable netv\nfi\n\nif [ -f /etc/systemd/system/netv.service ]; then\n    echo \"Removing service file...\"\n    rm /etc/systemd/system/netv.service\n    systemctl daemon-reload\nfi\n\nif [ -f /etc/letsencrypt/renewal-hooks/deploy/netv ]; then\n    echo \"Removing certbot hook...\"\n    rm /etc/letsencrypt/renewal-hooks/deploy/netv\nfi\n\necho \"\"\necho \"=== Done ===\"\necho \"\"\necho \"The netv service has been removed.\"\necho \"Project files and cache remain in place - delete manually if desired.\"\n"
  },
  {
    "path": "tools/xtream2m3u.py",
    "content": "#!/usr/bin/env python3\n# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportImplicitStringConcatenation=false, reportUnknownParameterType=false\n\n# M3U Details:\n#   https://github.com/HamzaBhf00/m3u-tags-iptv\n# Xtream Codes:\n#   https://github.com/engenex/xtream-codes-api-v2/blob/main/%5BHow-To%5D%20Player%20API%20v2%20-%20Tutorials%20-%20Xtream%20Codes.pdf\nfrom __future__ import annotations\n\nfrom typing import Any, Protocol\n\nimport collections\nimport concurrent.futures\nimport functools\nimport gzip  # lzma(80%), bz2(78%), gzip(75%) but gzip was fastest.\nimport json\nimport math\nimport pathlib\nimport pickle\nimport shutil\nimport threading\nimport time\nimport urllib\nimport urllib.error\nimport urllib.parse\nimport urllib.request\n\n\nclass RetryableError(Exception):\n    pass\n\n\nTOOLS_DIR = pathlib.Path(__file__).parent.resolve()\nCONFIG_FILE = TOOLS_DIR / \"xtream.json\"\nTEMPDIR = TOOLS_DIR\nDESTDIR = TOOLS_DIR\n\n\ndef _load_config() -> dict:\n    \"\"\"Load config from xtream.json, creating template if missing.\"\"\"\n    if not CONFIG_FILE.exists():\n        template = {\n            \"url\": \"https://your-provider.com\",\n            \"username\": \"your_username\",\n            \"password\": \"your_password\",\n            \"live_filter\": {},\n            \"locals_group\": \"\",\n            \"locals_filter\": [],\n        }\n        CONFIG_FILE.write_text(json.dumps(template, indent=2))\n        raise SystemExit(f\"Created {CONFIG_FILE} - edit with your credentials and re-run\")\n    return json.loads(CONFIG_FILE.read_text())\n\n\ndef _get_urls() -> tuple[str, str, str]:\n    \"\"\"Return (api_url, get_url, epg_url) from config.\"\"\"\n    cfg = _load_config()\n    base = cfg[\"url\"].rstrip(\"/\")\n    user, passwd = cfg[\"username\"], cfg[\"password\"]\n    api = f\"{base}/player_api.php?username={user}&password={passwd}\"\n    get = f\"{base}/get.php?username={user}&password={passwd}\"\n    epg = f\"{base}/xmltv.php?username={user}&password={passwd}\"\n    return api, get, epg\n\n\ndef _get_filters() -> tuple[dict[int, str], str, set[str]]:\n    \"\"\"Return (live_filter, locals_group, locals_filter) from config.\"\"\"\n    cfg = _load_config()\n    live_filter = {int(k): v for k, v in cfg.get(\"live_filter\", {}).items()}\n    locals_group = cfg.get(\"locals_group\", \"\")\n    locals_filter = set(cfg.get(\"locals_filter\", []))\n    return live_filter, locals_group, locals_filter\n\n\ndef main(cached_only: bool = False) -> None:\n    api_url, _, epg_url = _get_urls()\n    live_filter, locals_group, locals_filter = _get_filters()\n\n    if not cached_only:\n        fetch_all_data(api_url)\n\n    auth = load_dict(\"authentication.json\")\n    iptv_url = process_iptv_url(auth)\n\n    live, live_categories = process(\n        load_list(\"get_live_stream.json\"),\n        load_list(\"get_live_categories.json\"),\n        iptv_url,\n    )\n    del live_categories\n    live = filter_live(live, live_filter, locals_group, locals_filter)\n    write_m3u_live(live, auth, epg_url)\n\n    vod_url = list(iptv_url)\n    vod_url.insert(2, \"movie\")\n    vod, vod_categories = process(\n        load_list(\"get_vod_streams.json\"),\n        load_list(\"get_vod_categories.json\"),\n        vod_url,\n    )\n    del vod_categories\n    write_m3u_vod(vod, auth)\n\n    series_url = list(iptv_url)\n    series_url.insert(2, \"series\")\n    series, series_categories = process(\n        load_list(\"get_series.json\"),\n        load_list(\"get_series_categories.json\"),\n        series_url,\n    )\n    del series_categories\n    series_info = fetch_series_info(series, api_url, cached_only=cached_only)\n    write_m3u_series(series, series_info, auth, series_url)\n\n\n###############################################################################\n#  ____            __                       _                                 #\n# |  _ \\    ___   / _|  _ __    ___   ___  | |__                              #\n# | |_) |  / _ \\ | |_  | '__|  / _ \\ / __| | '_ \\                             #\n# |  _ <  |  __/ |  _| | |    |  __/ \\__ \\ | | | |                            #\n# |_| \\_\\  \\___| |_|   |_|     \\___| |___/ |_| |_|                            #\n#                                                                             #\n###############################################################################\n\n\ndef fetch_all_data(api_url: str) -> None:\n    if False:  # Intentionally disabled debug code\n        r = fetch_text(api_url + \"&type=m3u_plus\").encode(\"utf-8\")  # pyright: ignore[reportUnreachable]\n        with gzip.open(TEMPDIR / \"xtream.m3u.gz\", \"wb\") as f:\n            f.write(r)\n\n    print(\"Fetching authentication...\", end=\" \", flush=True)\n    t0 = time.perf_counter()\n    r = fetch_text(api_url)\n    with open(TEMPDIR / \"authentication.json\", \"w\") as f:\n        f.write(r)\n    print(f\"({time.perf_counter() - t0:.1f}s)\")\n\n    print(\"Fetching live streams...\", end=\" \", flush=True)\n    t0 = time.perf_counter()\n    r = fetch_text(api_url + \"&action=get_live_streams\", timeout=120)\n    with open(TEMPDIR / \"get_live_stream.json\", \"w\") as f:\n        f.write(r)\n    print(f\"({time.perf_counter() - t0:.1f}s)\")\n\n    print(\"Fetching live categories...\", end=\" \", flush=True)\n    t0 = time.perf_counter()\n    r = fetch_text(api_url + \"&action=get_live_categories\")\n    with open(TEMPDIR / \"get_live_categories.json\", \"w\") as f:\n        f.write(r)\n    print(f\"({time.perf_counter() - t0:.1f}s)\")\n\n    print(\"Fetching series...\", end=\" \", flush=True)\n    t0 = time.perf_counter()\n    r = fetch_text(api_url + \"&action=get_series\", timeout=120)\n    with open(TEMPDIR / \"get_series.json\", \"w\") as f:\n        f.write(r)\n    print(f\"({time.perf_counter() - t0:.1f}s)\")\n\n    print(\"Fetching series categories...\", end=\" \", flush=True)\n    t0 = time.perf_counter()\n    r = fetch_text(api_url + \"&action=get_series_categories\")\n    with open(TEMPDIR / \"get_series_categories.json\", \"w\") as f:\n        f.write(r)\n    print(f\"({time.perf_counter() - t0:.1f}s)\")\n\n    print(\"Fetching VOD streams...\", end=\" \", flush=True)\n    t0 = time.perf_counter()\n    r = fetch_text(api_url + \"&action=get_vod_streams\", timeout=120)\n    with open(TEMPDIR / \"get_vod_streams.json\", \"w\") as f:\n        f.write(r)\n    print(f\"({time.perf_counter() - t0:.1f}s)\")\n\n    print(\"Fetching VOD categories...\", end=\" \", flush=True)\n    t0 = time.perf_counter()\n    r = fetch_text(api_url + \"&action=get_vod_categories\")\n    with open(TEMPDIR / \"get_vod_categories.json\", \"w\") as f:\n        f.write(r)\n    print(f\"({time.perf_counter() - t0:.1f}s)\")\n\n\ndef fetch_text(url: str, timeout: int = 5) -> str:\n    parsed = urllib.parse.urlparse(url)\n    if parsed.scheme not in (\"http\", \"https\"):\n        raise ValueError(f\"Unsupported URL scheme: {parsed.scheme}\")\n\n    try:\n        with urllib.request.urlopen(url, timeout=timeout) as response:\n            return response.read().decode(\"utf-8\")\n    except urllib.error.HTTPError as e:\n        if e.code == 429:\n            raise RetryableError(f\"Unable to get {url}; http error {e.code}.\") from e\n        raise ValueError(f\"Unable to get {url}; http error {e.code}.\") from e\n    except (urllib.error.URLError, TimeoutError) as e:\n        reason = e.reason if isinstance(e, urllib.error.URLError) else str(e)\n        raise RetryableError(f\"Unable to get {url}; timeout {reason}.\") from e\n\n\ndef fetch_series_info(\n    series: dict[int, dict[str, Any]],\n    api_url: str,\n    cached_only: bool = False,\n) -> dict[int, Any]:\n    series_info: dict[int, None | Any] = {}\n    try:\n        with gzip.open(TEMPDIR / \"series_info.pickle.gz\", \"rb\") as f:\n            series_info = pickle.load(f)\n    except Exception as e:\n        print(f\"Cache miss: {e}\")\n        series_info = dict.fromkeys(series.keys())\n\n    if cached_only:\n        return series_info\n\n    changed = False\n    refetch_count = 0\n\n    for k in series:\n        series_info.setdefault(k, None)\n        try:\n            t = int(series_info[k][\"info\"][\"last_modified\"])  # pyright: ignore[reportOptionalSubscript]\n        except (KeyError, TypeError, ValueError):\n            t = -1\n        if series[k][\"last_modified\"] > t:\n            refetch_count += 1\n            series_info[k] = None\n\n    print(f\"Marked {refetch_count}/{len(series)} series for re/fetch.\")\n\n    for k in tuple(series_info.keys()):\n        if k in series:\n            continue\n        changed = True\n        del series_info[k]\n\n    progress_lock = threading.Lock()\n    progress_count = sum(v is not None for v in series_info.values())\n    limiter = SlidingRateLimiter(max_calls=4, per_seconds=1)\n    task_ = functools.partial(\n        _task,\n        limiter=limiter,\n        series_info=series_info,\n        api_url=api_url,\n        progress_lock=progress_lock,\n        progress_count_ref=[progress_count],\n    )\n\n    retries = -1\n    max_retries = 3\n    max_workers = math.ceil(1.5 * limiter.max_calls)\n\n    while (retries := retries + 1) < max_retries and (\n        ids := [k for k, v in series_info.items() if v is None]\n    ):\n        changed = True\n        executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers)\n        try:\n            list(executor.map(task_, ids))\n            executor.shutdown(wait=True)\n        except KeyboardInterrupt:\n            print(\"\\nCancelling...\")\n            executor.shutdown(wait=False, cancel_futures=True)\n            raise\n\n    if changed:\n        pickle_filename = TEMPDIR / \"series_info.pickle.gz\"\n        with gzip.open(pickle_filename.with_suffix(\".tmp\"), \"wb\") as f:\n            pickle.dump(series_info, f)\n        shutil.move(pickle_filename.with_suffix(\".tmp\"), pickle_filename)\n\n    return series_info\n\n\nclass RateLimiter(Protocol):\n    def __init__(self, max_calls: int, per_seconds: float = 1): ...\n\n    def acquire(self) -> None: ...\n\n\ndef _task(\n    id_: int,\n    limiter: RateLimiter,\n    series_info: dict[int, Any],\n    api_url: str,\n    progress_lock: threading.Lock,\n    progress_count_ref: list[int],\n) -> None:\n    try:\n        limiter.acquire()\n        result = json.loads(\n            fetch_text(\n                url=f\"{api_url}&action=get_series_info&series_id={id_}\",\n                timeout=60,\n            )\n        )\n        series_info[id_] = result\n        with progress_lock:\n            progress_count_ref[0] += 1\n            print_progress_bar(\n                iteration=progress_count_ref[0],\n                total=len(series_info),\n            )\n    except RetryableError as e:\n        print(e)\n    except (json.JSONDecodeError, ValueError, KeyError) as e:\n        print(e)\n        series_info[id_] = {}\n\n\ndef filter_live(\n    live: dict[int, dict[str, Any]],\n    live_filter: dict[int, str],\n    locals_group: str,\n    locals_filter: set[str],\n) -> dict[int, dict[str, Any]]:\n    if live_filter:\n        live_ = collections.defaultdict(dict)\n        for k, v in live.items():\n            if not any(c in live_filter for c in v[\"category_ids\"]):\n                continue\n            if len(v[\"group-title\"]) != 1:\n                raise ValueError(f\"Expected single group-title, got {v['group-title']}\")\n            live_[v[\"group-title\"][0]][k] = v\n        live = {}\n        for v in live_filter.values():\n            live.update(live_[v])\n\n    if locals_group and locals_filter:\n        live = {\n            k: v\n            for k, v in live.items()\n            if (\n                locals_group not in v[\"group-title\"]\n                or any(c in v[\"tvg-name\"] for c in locals_filter)\n            )\n        }\n\n    return live\n\n\n###############################################################################\n#  ____                                                                       #\n# |  _ \\    __ _   _ __   ___    ___                                          #\n# | |_) |  / _` | | '__| / __|  / _ \\                                         #\n# |  __/  | (_| | | |    \\__ \\ |  __/                                         #\n# |_|      \\__,_| |_|    |___/  \\___|                                         #\n#                                                                             #\n###############################################################################\n\n\ndef process(\n    elements: list[dict[str, Any]],\n    categories: list[dict[str, Any]],\n    iptv_url: list[str],\n) -> tuple[dict[int, dict[str, Any]], dict[int, Any]]:\n    categories_dict: dict[int, str] = {\n        int(c[\"category_id\"]): c[\"category_name\"] for c in categories\n    }\n    elements_dict: dict[int, dict[str, None | int | str | list[str]]] = {}\n    for s in elements:\n        stream_type = s.get(\"stream_type\")\n        if stream_type in (\"live\", \"radio_streams\"):\n            id_ = int(s[\"stream_id\"])\n            attr = {\n                \"tvg-name\": s[\"name\"] or s[\"title\"],\n                \"tvg-logo\": s[\"stream_icon\"],\n                \"group-title\": [categories_dict[c] for c in s[\"category_ids\"]],\n                \"tvg-id\": \"\" if s[\"epg_channel_id\"] is None else s[\"epg_channel_id\"],\n                \"url\": \"/\".join([*iptv_url, str(id_)]),\n                \"category_ids\": s[\"category_ids\"],\n                \"year\": None,\n                \"rating\": None,\n                \"num\": s[\"num\"],\n                \"last_modified\": None,\n                # 'timeshift': None, ???\n            }\n            if s[\"tv_archive\"] not in (0, 1):\n                raise ValueError(f\"Invalid tv_archive value: {s}\")\n            # assert not s[\"direct_source\"], s\n        elif stream_type == \"series\" or s.get(\"series_id\") is not None:\n            id_ = int(s[\"series_id\"])\n            attr = {\n                \"tvg-name\": s[\"name\"] or s[\"title\"],\n                \"tvg-logo\": s[\"cover\"],\n                \"group-title\": [categories_dict[c] for c in s[\"category_ids\"]],\n                \"tvg-id\": None,\n                \"url\": None,\n                \"category_ids\": s[\"category_ids\"],\n                \"year\": toint(s.get(\"year\")),\n                \"rating\": tofloat(s.get(\"rating\")),\n                \"num\": s[\"num\"],\n                \"last_modified\": int(s[\"last_modified\"]),\n            }\n        elif stream_type == \"movie\":\n            id_ = int(s[\"stream_id\"])\n            attr = {\n                \"tvg-name\": s[\"name\"] or s[\"title\"],\n                \"tvg-logo\": s[\"stream_icon\"],\n                \"group-title\": [categories_dict[c] for c in s[\"category_ids\"]],\n                \"tvg-id\": None,\n                \"url\": \"/\".join([*iptv_url, f\"{id_}.{s['container_extension']}\"]),\n                \"category_ids\": s[\"category_ids\"],\n                \"year\": toint(s.get(\"year\")),\n                \"rating\": tofloat(s.get(\"rating\")),\n                \"num\": s[\"num\"],\n                \"last_modified\": None,\n            }\n        else:\n            print(f\"Unrecognized {stream_type=}: {s}\")\n            continue\n        if id_ in elements_dict:\n            raise ValueError(f\"Duplicate id {id_}: {attr}\")\n        elements_dict[id_] = attr\n\n    return elements_dict, categories_dict\n\n\ndef process_iptv_url(auth: dict[str, dict[str, Any]]) -> list[str]:\n    if (status := auth[\"user_info\"][\"status\"]) != \"Active\":\n        raise ValueError(f\"Unsupported {status=}.\")\n    if (max_connections := int(auth[\"user_info\"][\"max_connections\"])) < 1:\n        raise ValueError(f\"Insufficient {max_connections=}.\")\n    if (server_protocol := auth[\"server_info\"][\"server_protocol\"]) not in (\n        \"http\",\n        \"https\",\n    ):\n        raise ValueError(f\"Unrecognized {server_protocol=}.\")\n    # We used to respect server protocol but now we just force HTTPS.\n    server_protocol = \"https\"\n    port_key = server_protocol + \"_port\"\n    port = auth[\"server_info\"].get(port_key, auth[\"server_info\"][port_key])\n    return [\n        f\"{server_protocol}:/\",  # We'll join everything with slashes later.\n        f\"{auth['server_info']['url']}:{port}\",\n        auth[\"user_info\"][\"username\"],\n        auth[\"user_info\"][\"password\"],\n    ]\n\n\ndef toint(x: str | None) -> int | None:\n    return int(x) if x else None\n\n\ndef tofloat(x: str | None) -> float | None:\n    return float(x) if x else None\n\n\ndef load(filename: str) -> Any:\n    with open(TEMPDIR / filename) as f:\n        return json.load(f)\n\n\ndef load_dict(filename: str) -> dict[str, Any]:\n    result = load(filename)\n    if not isinstance(result, dict):\n        raise TypeError(f\"Expected dict from {filename}, got {type(result)}\")\n    return result\n\n\ndef load_list(filename: str) -> list[dict[str, Any]]:\n    result = load(filename)\n    if not isinstance(result, list):\n        raise TypeError(f\"Expected list from {filename}, got {type(result)}\")\n    return result\n\n\nclass SlidingRateLimiter:\n    def __init__(self, max_calls: int, per_seconds: float = 1):\n        self.max_calls = max_calls\n        self.per_seconds = per_seconds\n        self.lock = threading.Lock()\n        self.requests = collections.deque()\n\n    def acquire(self) -> None:\n        while True:\n            with self.lock:\n                cutoff = time.perf_counter() - self.per_seconds\n                while self.requests and self.requests[0] <= cutoff:\n                    self.requests.popleft()\n                if len(self.requests) < self.max_calls:\n                    self.requests.append(time.perf_counter())\n                    return\n                sleep_time = max(0, self.requests[0] - cutoff)\n            time.sleep(sleep_time)\n\n\nclass ChunkingRateLimiter:\n    def __init__(self, max_calls: int, per_seconds: float = 1):\n        self.max_calls = max_calls\n        self.per_seconds = per_seconds\n        self.condition = threading.Condition()\n        self.calls = 0\n        self.last_reset = time.perf_counter()\n\n    def acquire(self) -> None:\n        with self.condition:\n            if self.calls >= self.max_calls:\n                now = time.perf_counter()\n                elapsed = now - self.last_reset\n                if elapsed < self.per_seconds:\n                    sleep_time = self.per_seconds - elapsed\n                    self.condition.wait(timeout=sleep_time)\n                    # Basically just,\n                    # self.lock.release()\n                    # time.sleep(sleep_time)\n                    # self.lock.acquire()\n                self.calls = 0\n                self.last_reset = time.perf_counter()\n            self.calls += 1\n\n\ndef print_progress_bar(\n    iteration: int,\n    total: int,\n    prefix: str = \"\",\n    suffix: str = \"\",\n    decimals: int = 1,\n    length: int = 50,\n    fill: str = \"█\",\n    printEnd: str = \"\\r\",\n) -> None:\n    r\"\"\"Call in a loop to create terminal progress bar\n    @params:\n        iteration   - Required  : current iteration (Int)\n        total       - Required  : total iterations (Int)\n        prefix      - Optional  : prefix string (Str)\n        suffix      - Optional  : suffix string (Str)\n        decimals    - Optional  : positive number of decimals in percent complete (Int)\n        length      - Optional  : character length of bar (Int)\n        fill        - Optional  : bar fill character (Str)\n        printEnd    - Optional  : end character (e.g. \"\\r\", \"\\r\\n\") (Str)\n    \"\"\"\n    if total == 0:\n        return\n    percent = (\"{0:.\" + str(decimals) + \"f}\").format(100 * (iteration / float(total)))\n    filledLength = int(length * iteration // total)\n    bar = fill * filledLength + \"-\" * (length - filledLength)\n    print(f\"\\r{prefix} |{bar}| {percent}% {suffix}\", end=printEnd)\n    # Print New Line on Complete\n    if iteration == total:\n        print()\n\n\ndef write_m3u_live(\n    live: dict[int, dict[str, Any]],\n    auth: dict[str, dict[str, Any]],\n    epg_url: str,\n) -> None:\n    with open(DESTDIR / \"live.m3u\", \"w\") as f:\n        print(f'#EXTM3U url-tvg=\"{epg_url}\" x-tvg-url=\"{epg_url}\"', file=f)\n        if auth[\"server_info\"].get(\"xui\") is not None:\n            version = auth[\"server_info\"][\"version\"]\n            print(f'#EXT-X-SESSION-DATA:DATA-ID=\"com.xui.{version}\"', file=f)\n        for v in live.values():\n            tvg_name = v[\"tvg-name\"]\n            tvg_logo = v[\"tvg-logo\"]\n            group_title = \"TV | \" + v[\"group-title\"][0]\n            url = v[\"url\"]\n            tvg_id = v[\"tvg-id\"]\n            print(\n                f'#EXTINF:-1 tvg-id=\"{tvg_id}\" tvg-name=\"{tvg_name}\" '\n                f'tvg-logo=\"{tvg_logo}\" group-title=\"{group_title}\",{tvg_name}',\n                file=f,\n            )\n            print(url, file=f)\n\n\ndef write_m3u_vod(\n    vod: dict[int, dict[str, Any]],\n    auth: dict[str, dict[str, Any]],\n) -> None:\n    with open(DESTDIR / \"vod.m3u\", \"w\") as f:\n        print(\"#EXTM3U\", file=f)\n        if auth[\"server_info\"].get(\"xui\") is not None:\n            version = auth[\"server_info\"][\"version\"]\n            print(f'#EXT-X-SESSION-DATA:DATA-ID=\"com.xui.{version}\"', file=f)\n        for v in vod.values():\n            tvg_name = v[\"tvg-name\"]\n            tvg_logo = v[\"tvg-logo\"]\n            group_title = \"VOD | \" + v[\"group-title\"][0]\n            url = v[\"url\"]\n            print(\n                f'#EXTINF:-1 tvg-name=\"{tvg_name}\" tvg-logo=\"{tvg_logo}\" '\n                f'group-title=\"{group_title}\",{tvg_name}',\n                file=f,\n            )\n            print(url, file=f)\n\n\ndef write_m3u_series(\n    series: dict[int, dict[str, Any]],\n    series_info: dict[int, None | Any],\n    auth: dict[str, dict[str, Any]],\n    series_url: list[str],\n) -> None:\n    series_episodes = {}\n    for k in series:\n        info = series_info.get(k)\n        if not info or \"episodes\" not in info:\n            continue\n        try:\n            series_episodes[k] = list(_descend(info[\"episodes\"]))\n        except Exception as e:\n            print(f\"Series {k}: {e}\")\n\n    with open(DESTDIR / \"series.m3u\", \"w\") as f:\n        print(\"#EXTM3U\", file=f)\n        if auth[\"server_info\"].get(\"xui\") is not None:\n            version = auth[\"server_info\"][\"version\"]\n            print(f'#EXT-X-SESSION-DATA:DATA-ID=\"com.xui.{version}\"', file=f)\n        for k, vv in series_episodes.items():\n            v = series[k]\n            tvg_logo = v[\"tvg-logo\"]\n            group_title = \"Series | \" + v[\"group-title\"][0]\n            for e in vv:\n                tvg_name = e[\"title\"]\n                url = \"/\".join([*series_url, f\"{e['id']}.{e['container_extension']}\"])\n                print(\n                    f'#EXTINF:-1 tvg-name=\"{tvg_name}\" tvg-logo=\"{tvg_logo}\" '\n                    f'group-title=\"{group_title}\",{tvg_name}',\n                    file=f,\n                )\n                print(url, file=f)\n\n\ndef _descend(x: Any):\n    if isinstance(x, dict):\n        if \"id\" in x:\n            yield x\n        else:\n            for x_ in x.values():\n                yield from _descend(x_)\n    elif isinstance(x, list):\n        for x_ in x:\n            yield from _descend(x_)\n\n\nif __name__ == \"__main__\":\n    main(cached_only=False)\n"
  },
  {
    "path": "tools/zap2xml.py",
    "content": "#!/usr/bin/env python3\n# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownLambdaType=false\n\"\"\"zap2xml.py -- Fetch TV guide data from zap2it/gracenote in XMLTV format.\n\nScrapes the internal JSON feed from zap2it/gracenote to generate XMLTV guide\ndata. The site occasionally returns 400 errors for certain time windows; this\ntool ignores those and continues fetching available data.\n\nWritten with only standard library dependencies.\n\nUsage:\n    ./zap2xml.py --zip 90210 --days 7\n\nCron example:\n    0 0 * * * cd /path/to/tools && ./zap2xml.py --zip 90210\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, ClassVar\n\nimport argparse\nimport datetime\nimport gzip  # lzma(80%), bz2(78%), gzip(75%) but gzip was fastest.\nimport json\nimport math\nimport pathlib\nimport re\nimport sys\nimport time\nimport urllib.error\nimport urllib.parse\nimport urllib.request\nimport xml.etree.ElementTree as xml\n\n\nSECONDS_PER_HOUR = 3_600\nSECONDS_PER_DAY = 86_400\n\n# https://en.wikipedia.org/wiki/Call_signs_in_the_United_States#Suffixes\n# Note: Doesn't correctly handly boosters.\n_CALLSIGN_REGEX = re.compile(r\"^([A-Z]+?)(LD|DT|CD|CA|LP|TV|FM|D)(\\d*)$\")\n\n\nclass Namespace(dict):  # pyright: ignore[reportMissingTypeArgument]\n    \"\"\"Allows a dictionary to be accessed as `x.item` vs. `x['item']`.\"\"\"\n\n    __slots__: ClassVar[tuple[str, ...]] = ()\n    __getattr__ = dict.__getitem__\n    __setattr__ = dict.__setitem__\n    __delattr__ = dict.__delitem__\n\n\ndef main() -> None:\n    args = parse_args()\n    working_dir = pathlib.Path(args.path)\n\n    cache_dir = working_dir / \".zap2xml\"\n    if not cache_dir.is_dir():\n        cache_dir.mkdir()\n\n    url_flags = {k[len(\"zap_\") :]: v for k, v in vars(args).items() if k.startswith(\"zap_\")}\n    url_flags[\"lineupId\"] = f\"{args.zap_country}-{args.zap_headendId}-DEFAULT\"\n\n    # Start time parameter is now rounded down to nearest `zap_timespan`, in s.\n    zap_time = int(datetime.datetime.now().timestamp())\n    print(f\"Local time:     {zap_time}  {strf_time_int(zap_time)}\")\n\n    zap_time_window = args.zap_timespan * SECONDS_PER_HOUR\n    zap_time = (zap_time // zap_time_window) * zap_time_window\n    print(f\"First zap time: {zap_time}  {strf_time_int(zap_time)}\")\n\n    remove_stale_cache(cache_dir, zap_time)\n\n    # https://wiki.xmltv.org/index.php/XMLTVFormat\n    # https://github.com/XMLTV/xmltv/blob/master/xmltv.dtd#L529\n\n    out = add_xml_child(\n        parent=None,\n        tag=\"tv\",\n        attrib={\n            \"source-info-url\": f\"https://{args.base_url}/grid-affiliates.html?aid=gapzap\",\n            \"source-info-name\": \"zap2it\",\n            \"generator-info-name\": \"zap2xml.py\",\n            \"generator-info-url\": \"https://github.com/jvdillon/netv\",\n        },\n    )\n\n    channel_map = {}  # Only used for debugging.\n    done_channels = False\n\n    # Fetch data in `zap_timespan` chunks.\n    if args.days > 15:\n        raise ValueError(f\"Can only collect at most 15 days; {args.days} too large.\")\n    num_fetch = math.ceil(args.days * 24 / args.zap_timespan)\n    for i in range(num_fetch):\n        i_time = zap_time + (i * zap_time_window)\n        print(f\"Getting data:   {i_time}  {strf_time_int(i_time)}\")\n\n        url = f\"https://{args.base_url}/api/grid?\"\n        url += urllib.parse.urlencode({**url_flags, \"time\": i_time})\n\n        result = get_cached(cache_dir, i_time, args.delay, url)\n        json_result = json.loads(result)\n\n        if not done_channels:\n            done_channels = True\n            for c_in in json_result[\"channels\"]:\n                # {'affiliateCallSign': 'null',\n                #  'affiliateName': 'AMERICAN BROADCASTING COMPANY',\n                #  'callSign': 'KXTVDT',\n                #  'channelId': '20775',\n                #  'channelNo': '10.1',\n                #  'id': '2077555',\n                #  'stationFilters': ['filter-sports'],\n                #  'stationGenres': [False],\n                #  'thumbnail': '//zap2it.tmsimg.com/h3/NowShowing/20775/s28708_ll_h15_ac.png?w=55'}\n                channel_key = get_channel_key(c_in)\n                channel_display_name = \" - \".join(\n                    [\n                        c_in[\"affiliateName\"].title(),  # Eg, \"CATCHY COMEDY\"\n                        parse_callsign(c_in[\"callSign\"]),  # Eg, \"KOVR-DT-5\"\n                        c_in[\"channelNo\"],  # Eg., \"13.5\"\n                    ]\n                )\n                channel_map[channel_key] = channel_display_name\n                c_out = add_xml_child(\n                    parent=out,\n                    tag=\"channel\",\n                    id=channel_key,\n                )\n                _ = add_xml_child(\n                    parent=c_out,\n                    tag=\"display-name\",\n                    text=channel_display_name,\n                )\n                _ = add_xml_child(\n                    parent=c_out,\n                    tag=\"icon\",\n                    src=f\"https:{c_in['thumbnail'].split('?')[0]}\",\n                )\n            channel_map = dict(sorted(channel_map.items(), key=lambda kv: kv[0]))\n\n        f = add_programme_tvimate if args.tvimate else add_programme\n        for c_in in json_result[\"channels\"]:\n            channel_key = get_channel_key(c_in)\n            for event in c_in[\"events\"]:\n                f(out, event, channel_key)\n\n    # https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.indent\n    # Note: xml.indent must be done last.\n    xml.indent(out, space=\"\\t\", level=0)\n    with pathlib.Path.open((working_dir / \"xmltv.xml\").resolve(), \"wb\") as f:\n        f.write(b'<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n')\n        f.write(xml.tostring(out, encoding=\"UTF-8\"))\n\n    sys.exit(0)\n\n\ndef get_cached(\n    cache_dir: pathlib.Path,\n    timestamp: int,\n    delay: int,\n    url: str,\n) -> bytes:\n    cache_path = (cache_dir / str(timestamp)).with_suffix(\".json.gz\")\n    if cache_path.is_file():\n        print(f\"Cached: {url}\")\n        with gzip.open(cache_path, \"rb\") as f:\n            return f.read()\n\n    print(f\"Fetching: '{url}'.\")\n    if not url.startswith((\"http:\", \"https:\")):\n        raise ValueError(f\"URL '{url}' must start with 'http:' or 'https:'\") from None\n    request = urllib.request.Request(\n        url,\n        headers={\n            \"User-Agent\": (\n                \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \"\n                \"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36\"\n            ),\n            \"Accept\": \"*/*\",\n            \"Sec-Ch-Ua\": ('\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"'),\n            \"Sec-Ch-Ua-Mobile\": \"?0\",\n            \"Sec-Ch-Ua-Platform\": '\"Linux\"',\n            # \"Accept-Encoding\": \"br, gzip, deflate, zstd, identity\",\n            \"Accept-Language\": \"en-US,en;q=0.9\",\n            \"Content-Type\": \"text/plain;charset=UTF-8\",\n            \"Priority\": \"u=1, i\",\n        },\n    )\n    try:\n        response = urllib.request.urlopen(request)\n        result = response.read()\n    except urllib.error.HTTPError as e:\n        if e.code != 400:\n            e.add_note(f'Url is \"{url}\".')\n            raise\n        print(\"Got a 400 error! Ignoring it.\")\n        result = b'{\"note\": \"Got a 400 error at this time, skipping.\",\"channels\": []}'\n    with gzip.open(cache_path, \"wb\") as f:\n        f.write(result)\n    time.sleep(delay)\n    return result\n\n\ndef remove_stale_cache(cache_dir: pathlib.Path, zap_time: int) -> None:\n    for p in sorted(cache_dir.glob(\"*\")):\n        x = Namespace()\n        x.name = p.name\n        x.zap_time = zap_time\n        x.data_time = int(str(p.name).removesuffix(\"\".join(p.suffixes)))\n        x.file_time = int(p.stat().st_mtime)\n\n        x.is_irrelevant = x.data_time < zap_time\n        x.is_1day_expired = _expired(3, 1, x.data_time, x.file_time, zap_time)\n        x.is_7day_expired = _expired(7, 7, x.data_time, x.file_time, zap_time)\n\n        if any(v for k, v in x.items() if k.startswith(\"is_\")):\n            x.file_time_str = strf_time_int(x.file_time)\n            x.data_time_str = strf_time_int(x.data_time)\n            s = \" \".join(f\"{k}={v}\" for k, v in x.items())\n            print(f\"Removing stale cache file: {s}\")\n            p.unlink()\n\n\ndef _expired(\n    data_days: float,\n    file_days: float,\n    data_time: int,\n    file_time: int,\n    zap_time: int,\n) -> bool:\n    data_time_within_limit = data_time < zap_time + data_days * SECONDS_PER_DAY\n    file_time_within_limit = zap_time < file_time + file_days * SECONDS_PER_DAY\n    return data_time_within_limit and not file_time_within_limit\n\n\ndef add_programme(\n    out: xml.Element,\n    event: Mapping[str, Any],\n    channel_key: str,\n) -> None:\n    # {'callSign': 'KCRADT2', 'duration': '30', 'startTime': '2025-04-20T18:00:00Z', 'endTime': '2025-04-20T18:30:00Z',\n    # 'thumbnail': 'p1119901_e_v9_ab', 'channelNo': '3.2', 'filter': [], 'seriesId': 'SH00001996', 'rating': 'TV-G', 'flag': [], 'tags': ['CC'],\n    # 'program': {\n    #     'title': 'Happy Days',\n    #     'id': 'EP000019960180',\n    #     'tmsId': 'EP000019960180',\n    #     'shortDesc': 'Richie is selected to become a contestant on a popular game show with a chance to win $3,200.',\n    #     'season': '2', 'releaseYear': None, 'episode': '9', 'episodeTitle': 'Big Money', 'seriesId': 'SH00001996', 'isGeneric': '0'}\n    # }\n    # https://tvlistings.gracenote.com/overview-affiliates.html?programSeriesId=SH00001996&tmsId=EP000019960180&aid=lat\n\n    prog_out = add_xml_child(\n        parent=out,\n        tag=\"programme\",\n        start=strf_time_str(event[\"startTime\"]),\n        stop=strf_time_str(event[\"endTime\"]),\n        channel=channel_key,\n    )\n\n    prog_in = event[\"program\"]\n\n    if prog_in[\"title\"]:\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"title\",\n            # lang=\"en\",\n            text=prog_in[\"title\"],\n        )\n\n    year = toint(prog_in[\"releaseYear\"])\n\n    if prog_in[\"episodeTitle\"]:\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"sub-title\",\n            # lang=\"en\",\n            text=prog_in[\"episodeTitle\"],\n        )\n    elif \"filter-movie\" in event[\"filter\"]:\n        if prog_in[\"title\"] == \"Movie\":\n            text = \"TBD\"\n        elif year:\n            text = f\"Movie ({year})\"\n        else:\n            text = \"Movie\"\n\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"sub-title\",\n            # lang=\"en\",\n            text=text,\n        )\n\n    if prog_in[\"shortDesc\"]:\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"desc\",\n            # lang=\"en\",\n            text=prog_in[\"shortDesc\"],\n        )\n\n    if prog_in[\"season\"] and prog_in[\"episode\"]:\n        # Format:\n        # season_num/season_total.episode_num/episode_total.part_num/part_total\n        # where \"num\" is zero indexed and \"/total\" is optional\n        # and \"num/total\" is also optional.\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"episode-num\",\n            system=\"xmltv_ns\",\n            text=f\"{int(prog_in['season']) - 1}.{int(prog_in['episode']) - 1}.\",\n        )\n\n    if event[\"rating\"]:\n        r = add_xml_child(\n            parent=prog_out,\n            tag=\"rating\",\n            system=\"VCHIP\",\n        )\n        _ = add_xml_child(\n            parent=r,\n            tag=\"value\",\n            text=event[\"rating\"],\n        )\n\n    _ = add_xml_child(\n        parent=prog_out,\n        tag=\"length\",\n        units=\"minutes\",\n        text=event[\"duration\"],\n    )\n\n    if year:\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"date\",\n            text=str(year),\n        )\n\n    if event[\"thumbnail\"]:\n        # Not part of xmltv spec but we're including it anyway.\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"icon\",\n            src=f\"https://zap2it.tmsimg.com/assets/{event['thumbnail']}.jpg\",\n        )\n\n    for f in event[\"filter\"]:\n        if f not in {\n            \"filter-family\",\n            \"filter-movie\",\n            \"filter-news\",\n            \"filter-sports\",\n            \"filter-talk\",\n        }:\n            print(f\"Novel filter '{f}'.\")\n            if not f.startswith(\"filter-\"):\n                continue\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"category\",  # Was: \"genre\"\n            # lang=\"en\",\n            text=f[len(\"filter-\") :].title(),\n        )\n\n    if \"Dolby Digital\" in event[\"tags\"]:\n        audio = \"dolby digital\"\n    elif \"Dolby\" in event[\"tags\"]:\n        audio = \"dolby\"\n    elif \"Surround\" in event[\"tags\"]:\n        audio = \"surround\"\n    elif \"Stereo\" in event[\"tags\"]:\n        audio = \"stereo\"\n    elif \"Mono\" in event[\"tags\"]:\n        audio = \"mono\"\n    else:\n        audio = \"stereo\"\n    r = add_xml_child(\n        parent=prog_out,\n        tag=\"audio\",\n    )\n    _ = add_xml_child(\n        parent=r,\n        tag=\"present\",\n        text=\"yes\",\n    )\n    _ = add_xml_child(\n        parent=r,\n        tag=\"stereo\",\n        text=audio,\n    )\n    if \"DVS\" in event[\"tags\"]:\n        _ = add_xml_child(\n            parent=r,\n            tag=\"stereo\",\n            text=\"bilingual\",\n        )\n        # if False:\n        #     a = strf_time_str(\n        #         event[\"startTime\"],\n        #         format_str=\"%Y-%b-%d %_I:%M%P\",\n        #     )\n        #     t = prog_in[\"title\"]\n        #     e = prog_in[\"episodeTitle\"] if prog_in[\"episodeTitle\"] else \"\"\n        #     c = channel_map[channel_key]\n        #     print(f\"### {a:30s} {t:40s} {e:50s} {c:20s}\")\n\n    if \"CC\" in event[\"tags\"]:\n        r = add_xml_child(\n            parent=prog_out,\n            tag=\"subtitles\",\n            type=\"teletext\",\n        )\n        _ = add_xml_child(\n            parent=r,\n            tag=\"language\",\n            text=\"English\",\n        )\n\n    if \"New\" in event[\"flag\"]:  # and \"Live\" not in event[\"flag\"]:\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"new\",\n        )\n\n\ndef add_programme_tvimate(\n    out: xml.Element,\n    event: Mapping[str, Any],\n    channel_key: str,\n) -> None:\n    prog_out = add_xml_child(\n        parent=out,\n        tag=\"programme\",\n        start=strf_time_str(event[\"startTime\"]),\n        stop=strf_time_str(event[\"endTime\"]),\n        channel=channel_key,\n    )\n\n    prog_in = event[\"program\"]\n\n    title = prog_in[\"title\"]\n    subtitle = prog_in[\"episodeTitle\"]\n    year = toint(prog_in[\"releaseYear\"])\n    season = toint(prog_in[\"season\"])\n    episode = toint(prog_in[\"episode\"])\n    description = prog_in[\"shortDesc\"]\n\n    if title and subtitle and \"filter-sports\" in event[\"filter\"]:\n        title = f\"{title}: {subtitle}\"\n        subtitle = None\n    elif not subtitle and \"filter-movie\" in event[\"filter\"]:\n        if title == \"Movie\":\n            subtitle = None\n        elif year:\n            subtitle = f\"Movie ({year})\"\n        else:\n            subtitle = \"Movie\"\n\n    if title:\n        if \"Live\" in event[\"flag\"]:\n            if \"filter-news\" not in event[\"filter\"]:\n                title += \" ᴸⁱᵛᵉ\"\n        elif \"New\" in event[\"flag\"]:\n            title += \" ᴺᵉʷ\"\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"title\",\n            # lang=\"en\",\n            text=title,\n        )\n\n    if season and episode:\n        season_episode = f\"S{season:02d}E{episode:02d}\"\n    else:\n        season_episode = None\n\n    short = \" \".join([a_ for a_ in [season_episode, subtitle] if a_])\n    description = \"\\n\".join([a_ for a_ in [short, description] if a_])\n    if description:\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"desc\",\n            # lang=\"en\",\n            text=description,\n        )\n\n    # if event[\"rating\"]:\n    #     r = add_xml_child(\n    #         parent=prog_out,\n    #         tag=\"rating\",\n    #         system=\"VCHIP\",\n    #     )\n    #     _ = add_xml_child(\n    #         parent=r,\n    #         tag=\"value\",\n    #         text=event[\"rating\"],\n    #     )\n\n    for f in event[\"filter\"]:\n        if f not in {\n            \"filter-family\",\n            \"filter-movie\",\n            \"filter-news\",\n            \"filter-sports\",\n            \"filter-talk\",\n        }:\n            print(f\"Novel filter '{f}'.\")\n            if not f.startswith(\"filter-\"):\n                continue\n        _ = add_xml_child(\n            parent=prog_out,\n            tag=\"category\",  # Was: \"genre\"\n            # lang=\"en\",\n            text=f[len(\"filter-\") :].title(),\n        )\n\n\ndef get_channel_key(c: Mapping[str, Any]) -> str:\n    # old way:\n    # return f\"I{c['channelNo']}.{c['channelId']}.zap2it.com\"\n    return c[\"callSign\"]\n\n\ndef parse_callsign(coded_callsign: str) -> str:\n    result = _CALLSIGN_REGEX.search(coded_callsign.upper())\n    assert result\n    call, suffix, num = result.groups()\n    assert suffix\n    assert num != \"1\"\n    if call == \"KQS\" and suffix == \"LD\":\n        # Appears to be a bug in their coded callsign.\n        call = \"KQSL\"\n        suffix = \"LD\"\n    if not num:\n        num = \"1\"\n    return f\"{call}-{suffix}-{num}\"\n\n\ndef strf_time_str(tm: str, format_str: str = \"%Y%m%d%H%M%S %z\") -> str:\n    tm = tm.replace(\"Z\", \"+00:00\")\n    return parse_time_iso(tm).strftime(format_str)\n\n\ndef strf_time_int(timestamp: int, format_str: str = \"%Y-%b-%d %_I:%M%P %z\") -> str:\n    return parse_time_int(timestamp).strftime(format_str)\n\n\ndef parse_time_iso(tm: str) -> datetime.datetime:\n    tm = tm.replace(\"Z\", \"+00:00\")\n    return datetime.datetime.fromisoformat(tm).astimezone()\n\n\ndef parse_time_int(timestamp: int) -> datetime.datetime:\n    return datetime.datetime.fromtimestamp(timestamp).astimezone()\n\n\ndef add_xml_child(\n    parent: xml.Element | None,\n    tag: str,\n    text: str | None = None,\n    attrib: Mapping[str, str] | None = None,\n    **extra: Any,\n) -> xml.Element:\n    attrib = {} if attrib is None else dict(attrib)\n    if parent is None:\n        # https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element\n        el = xml.Element(tag, attrib, **extra)\n    else:\n        # https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.SubElement\n        el = xml.SubElement(parent, tag, attrib, **extra)\n    if text is not None:\n        el.text = text\n    return el\n\n\ndef toint(x: str | None, fail: int = 0) -> int:\n    if x is None:\n        return fail\n    return int(x)\n\n\ndef parse_args() -> argparse.Namespace:\n    parser = argparse.ArgumentParser(\n        description=\"Fetch TV data from zap2it.\",\n        epilog=\"This tool is noisy to stdout; with cron use chronic from moreutils.\",\n    )\n    _ = parser.add_argument(\n        \"--delay\",\n        dest=\"delay\",\n        type=int,\n        default=5,\n        help=\"Delay, in seconds, between server fetches.\",\n    )\n    _ = parser.add_argument(\n        \"--url\",\n        dest=\"base_url\",\n        type=str,\n        default=\"tvlistings.gracenote.com\",\n        # default=\"tvlistings.zap2it.com\",\n        help=\"Source url without http prefix.\",\n    )\n    _ = parser.add_argument(\n        \"--days\",\n        dest=\"days\",\n        type=float,\n        default=15,\n        help=\"Num days to fetch.\",\n    )\n    _ = parser.add_argument(\n        \"--path\",\n        dest=\"path\",\n        type=str,\n        default=str(pathlib.Path(__file__).parent.resolve()),\n        help=\"Path to store files.\",\n    )\n    _ = parser.add_argument(\n        \"--aid\",\n        dest=\"zap_aid\",\n        type=str,\n        # Previously we used \"gapzap\" but redditors seem to have found this one.\n        # https://www.reddit.com/r/cordcutters/comments/1m1iba0/zap2it_and_gracenote_listings_are_gone_again/\n        default=\"orbebb\",\n        help=\"Raw zap2it input parameter. (Affiliate ID?)\",\n    )\n    _ = parser.add_argument(\n        \"--country\",\n        dest=\"zap_country\",\n        type=str,\n        default=\"USA\",\n        help=\"Country identifying the listings to fetch.\",\n    )\n    _ = parser.add_argument(\n        \"--device\",\n        dest=\"zap_device\",\n        type=str,\n        default=\"-\",\n        help=\"Raw zap2it input parameter.  (?)\",\n    )\n    _ = parser.add_argument(\n        \"--headend-id\",\n        dest=\"zap_headendId\",\n        type=str,\n        default=\"lineupId\",\n        help=\"Raw zap2it input parameter.  (?)\",\n    )\n    _ = parser.add_argument(\n        \"--is-override\",\n        dest=\"zap_isOverride\",\n        type=bool,\n        default=True,\n        help=\"Raw zap2it input parameter.  (?)\",\n    )\n    _ = parser.add_argument(\n        \"--language\",\n        dest=\"zap_languagecode\",\n        type=str,\n        default=\"en\",\n        help=\"Raw zap2it input parameter.  (Language.)\",\n    )\n    _ = parser.add_argument(\n        \"--pref\",\n        dest=\"zap_pref\",\n        type=str,\n        default=\"\",\n        help=\"Raw zap2it input parameter.  (Preferences?)\",\n    )\n    _ = parser.add_argument(\n        \"--timespan\",\n        dest=\"zap_timespan\",\n        type=int,\n        default=3,\n        help=\"Raw zap2it input parameter.  (Hours of data per fetch?)\",\n    )\n    _ = parser.add_argument(\n        \"--timezone\",\n        dest=\"zap_timezone\",\n        type=str,\n        default=\"\",\n        help=\"Raw zap2it input parameter.  (Time zone?)\",\n    )\n    _ = parser.add_argument(\n        \"--user-id\",\n        dest=\"zap_userId\",\n        type=str,\n        default=\"-\",\n        help=\"Raw zap2it input parameter.  (?)\",\n    )\n    _ = parser.add_argument(\n        \"--zip\",\n        dest=\"zap_postalCode\",\n        type=str,\n        required=True,\n        help=\"The zip/postal code identifying the listings to fetch.\",\n    )\n    _ = parser.add_argument(\n        \"--tvimate\",\n        dest=\"tvimate\",\n        type=bool,\n        default=True,\n        action=argparse.BooleanOptionalAction,\n        help=\"Guide formatted specifically for TViMate.\",\n    )\n\n    return parser.parse_args()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "util.py",
    "content": "\"\"\"Shared utilities.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport urllib.error\nimport urllib.parse\nimport urllib.request\n\n\nclass _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):\n    \"\"\"Redirect handler that only allows http/https schemes.\"\"\"\n\n    def redirect_request(\n        self,\n        req: urllib.request.Request,\n        fp: Any,\n        code: int,\n        msg: str,\n        headers: Any,\n        newurl: str,\n    ) -> urllib.request.Request | None:\n        parsed = urllib.parse.urlparse(newurl)\n        if parsed.scheme not in (\"http\", \"https\"):\n            raise urllib.error.URLError(f\"Unsafe redirect scheme: {parsed.scheme}\")\n        return super().redirect_request(req, fp, code, msg, headers, newurl)\n\n\n_DEFAULT_USER_AGENT = \"VLC/3.0.20 LibVLC/3.0.20\"\n\n\ndef safe_urlopen(url: str, timeout: int = 30, user_agent: str | None = None) -> Any:\n    \"\"\"Open URL with safe redirect handling.\n\n    Args:\n        url: URL to open\n        timeout: Request timeout in seconds\n        user_agent: User-Agent header to send. If None, uses a default VLC User-Agent\n            to avoid being blocked by providers that reject Python's default.\n    \"\"\"\n    parsed = urllib.parse.urlparse(url)\n    if parsed.scheme not in (\"http\", \"https\"):\n        raise urllib.error.URLError(f\"Unsafe URL scheme: {parsed.scheme}\")\n    ua = user_agent if user_agent else _DEFAULT_USER_AGENT\n    req = urllib.request.Request(url, headers={\"User-Agent\": ua})\n    opener = urllib.request.build_opener(_SafeRedirectHandler())\n    return opener.open(req, timeout=timeout)\n"
  },
  {
    "path": "util_test.py",
    "content": "\"\"\"Tests for util.py.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport urllib.error\n\nimport pytest\n\nimport util\n\n\ndef _fake_request(url: str) -> Any:\n    \"\"\"Create a minimal request object for testing.\"\"\"\n\n    class _Req:\n        full_url = url\n        headers: dict[str, str] = {}\n        data = None\n        origin_req_host = \"original.com\"\n\n        def get_method(self) -> str:\n            return \"GET\"\n\n    return _Req()\n\n\nclass TestSafeRedirectHandler:\n    def test_handler_allows_http(self):\n        handler = util._SafeRedirectHandler()\n        req = _fake_request(\"http://original.com\")\n        result = handler.redirect_request(\n            req,\n            fp=None,\n            code=302,\n            msg=\"Found\",\n            headers={},\n            newurl=\"http://redirect.com/path\",\n        )\n        assert result is not None\n\n    def test_handler_allows_https(self):\n        handler = util._SafeRedirectHandler()\n        req = _fake_request(\"https://original.com\")\n        result = handler.redirect_request(\n            req,\n            fp=None,\n            code=302,\n            msg=\"Found\",\n            headers={},\n            newurl=\"https://secure.com/path\",\n        )\n        assert result is not None\n\n    def test_handler_rejects_file_scheme(self):\n        handler = util._SafeRedirectHandler()\n        req = _fake_request(\"http://original.com\")\n        with pytest.raises(urllib.error.URLError, match=\"Unsafe redirect scheme\"):\n            handler.redirect_request(\n                req,\n                fp=None,\n                code=302,\n                msg=\"Found\",\n                headers={},\n                newurl=\"file:///etc/passwd\",\n            )\n\n    def test_handler_rejects_data_scheme(self):\n        handler = util._SafeRedirectHandler()\n        req = _fake_request(\"http://original.com\")\n        with pytest.raises(urllib.error.URLError, match=\"Unsafe redirect scheme\"):\n            handler.redirect_request(\n                req,\n                fp=None,\n                code=302,\n                msg=\"Found\",\n                headers={},\n                newurl=\"data:text/html,<script>alert(1)</script>\",\n            )\n\n    def test_handler_rejects_javascript_scheme(self):\n        handler = util._SafeRedirectHandler()\n        req = _fake_request(\"http://original.com\")\n        with pytest.raises(urllib.error.URLError, match=\"Unsafe redirect scheme\"):\n            handler.redirect_request(\n                req,\n                fp=None,\n                code=302,\n                msg=\"Found\",\n                headers={},\n                newurl=\"javascript:alert(1)\",\n            )\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  },
  {
    "path": "xtream.py",
    "content": "\"\"\"Xtream Codes API client.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any\n\nimport json\nimport urllib.parse\n\nfrom util import safe_urlopen\n\n\n@dataclass(slots=True)\nclass XtreamClient:\n    \"\"\"Client for Xtream Codes API.\n\n    Handles authentication and API calls to Xtream-compatible IPTV providers.\n    \"\"\"\n\n    base_url: str\n    username: str\n    password: str\n\n    def __post_init__(self) -> None:\n        # Normalize URL: strip trailing slashes\n        self.base_url = self.base_url.rstrip(\"/\")\n\n    @property\n    def _base_params(self) -> dict[str, str]:\n        return {\"username\": self.username, \"password\": self.password}\n\n    @property\n    def api_url(self) -> str:\n        params = urllib.parse.urlencode(self._base_params)\n        return f\"{self.base_url}/player_api.php?{params}\"\n\n    def _fetch(self, url: str, timeout: int = 30) -> str:\n        with safe_urlopen(url, timeout=timeout) as resp:\n            return resp.read().decode(\"utf-8\")\n\n    def _api(self, action: str | None = None, timeout: int = 30, **params: Any) -> Any:\n        query = dict(self._base_params)\n        if action:\n            query[\"action\"] = action\n        query.update(params)\n        url = f\"{self.base_url}/player_api.php?{urllib.parse.urlencode(query)}\"\n        return json.loads(self._fetch(url, timeout=timeout))\n\n    def get_server_info(self, timeout: int = 15) -> dict[str, Any]:\n        \"\"\"Returns user_info and server_info; check user_info['auth'] == 1.\"\"\"\n        return self._api(timeout=timeout)\n\n    def get_live_categories(self) -> list[dict[str, Any]]:\n        return self._api(\"get_live_categories\")\n\n    def get_live_streams(self, category_id: int | None = None) -> list[dict[str, Any]]:\n        if category_id:\n            return self._api(\"get_live_streams\", category_id=category_id)\n        return self._api(\"get_live_streams\")\n\n    def get_vod_categories(self) -> list[dict[str, Any]]:\n        return self._api(\"get_vod_categories\")\n\n    def get_vod_streams(self, category_id: int | None = None) -> list[dict[str, Any]]:\n        if category_id:\n            return self._api(\"get_vod_streams\", category_id=category_id)\n        return self._api(\"get_vod_streams\")\n\n    def get_series_categories(self) -> list[dict[str, Any]]:\n        return self._api(\"get_series_categories\")\n\n    def get_series(self, category_id: int | None = None) -> list[dict[str, Any]]:\n        if category_id:\n            return self._api(\"get_series\", category_id=category_id)\n        return self._api(\"get_series\")\n\n    def get_series_info(self, series_id: int) -> dict[str, Any]:\n        return self._api(\"get_series_info\", series_id=series_id)\n\n    def get_vod_info(self, vod_id: int) -> dict[str, Any]:\n        return self._api(\"get_vod_info\", vod_id=vod_id)\n\n    def get_short_epg(self, stream_id: int, limit: int = 10) -> dict[str, Any]:\n        \"\"\"Returns epg_listings for stream; some providers ignore limit.\"\"\"\n        return self._api(\"get_short_epg\", stream_id=stream_id, limit=limit)\n\n    def build_stream_url(self, stream_type: str, stream_id: int, ext: str = \"\") -> str:\n        # URL-encode username/password to handle special chars like # in passwords\n        user = urllib.parse.quote(self.username, safe=\"\")\n        pwd = urllib.parse.quote(self.password, safe=\"\")\n        base = f\"{self.base_url}/{stream_type}/{user}/{pwd}/{stream_id}\"\n        return f\"{base}.{ext}\" if ext else base\n\n    def build_timeshift_url(\n        self,\n        stream_id: int,\n        duration: int,\n        start: str,\n        ext: str = \"ts\",\n    ) -> str:\n        \"\"\"For streams with tv_archive=1. start format: YYYY-MM-DD:HH-MM.\"\"\"\n        # URL-encode username/password to handle special chars like # in passwords\n        user = urllib.parse.quote(self.username, safe=\"\")\n        pwd = urllib.parse.quote(self.password, safe=\"\")\n        return f\"{self.base_url}/timeshift/{user}/{pwd}/{duration}/{start}/{stream_id}.{ext}\"\n\n    @property\n    def epg_url(self) -> str:\n        params = urllib.parse.urlencode(self._base_params)\n        return f\"{self.base_url}/xmltv.php?{params}\"\n"
  },
  {
    "path": "xtream_test.py",
    "content": "\"\"\"Tests for xtream.py - Xtream Codes API client.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import MagicMock, patch\n\nimport json\n\nimport pytest\n\nfrom xtream import XtreamClient\n\n\nclass TestXtreamClient:\n    \"\"\"Tests for XtreamClient.\"\"\"\n\n    def test_api_url_property(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        assert client.api_url == \"http://example.com/player_api.php?username=user&password=pass\"\n\n    def test_epg_url_property(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        assert client.epg_url == \"http://example.com/xmltv.php?username=user&password=pass\"\n\n    def test_url_normalization_strips_trailing_slash(self):\n        client = XtreamClient(\"http://example.com/\", \"user\", \"pass\")\n        assert client.base_url == \"http://example.com\"\n        # No double slashes after the domain\n        assert \"example.com/player_api\" in client.api_url\n\n    def test_url_normalization_strips_multiple_trailing_slashes(self):\n        client = XtreamClient(\"http://example.com///\", \"user\", \"pass\")\n        assert client.base_url == \"http://example.com\"\n\n    def test_special_chars_in_credentials_are_encoded(self):\n        client = XtreamClient(\"http://example.com\", \"user@test\", \"p&ss=word\")\n        # Check that special chars are URL-encoded in api_url\n        assert \"user%40test\" in client.api_url\n        assert \"p%26ss%3Dword\" in client.api_url\n        # Same for epg_url\n        assert \"user%40test\" in client.epg_url\n        assert \"p%26ss%3Dword\" in client.epg_url\n\n    def test_build_stream_url_live_no_ext(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        url = client.build_stream_url(\"live\", 123)\n        assert url == \"http://example.com/live/user/pass/123\"\n\n    def test_build_stream_url_live_with_ext(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        url = client.build_stream_url(\"live\", 123, \"m3u8\")\n        assert url == \"http://example.com/live/user/pass/123.m3u8\"\n\n    def test_build_stream_url_movie(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        url = client.build_stream_url(\"movie\", 456, \"mkv\")\n        assert url == \"http://example.com/movie/user/pass/456.mkv\"\n\n    def test_build_stream_url_series(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        url = client.build_stream_url(\"series\", 789, \"mp4\")\n        assert url == \"http://example.com/series/user/pass/789.mp4\"\n\n    def test_build_timeshift_url(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        url = client.build_timeshift_url(123, 60, \"2024-01-15:14-30\")\n        assert url == \"http://example.com/timeshift/user/pass/60/2024-01-15:14-30/123.ts\"\n\n    def test_build_timeshift_url_custom_ext(self):\n        client = XtreamClient(\"http://example.com\", \"user\", \"pass\")\n        url = client.build_timeshift_url(123, 30, \"2024-01-15:10-00\", ext=\"m3u8\")\n        assert url == \"http://example.com/timeshift/user/pass/30/2024-01-15:10-00/123.m3u8\"\n\n\nclass TestXtreamClientApi:\n    \"\"\"Tests for XtreamClient API methods with mocked network.\"\"\"\n\n    @pytest.fixture\n    def client(self):\n        return XtreamClient(\"http://example.com\", \"user\", \"pass\")\n\n    @pytest.fixture\n    def mock_urlopen(self):\n        with patch(\"xtream.safe_urlopen\") as mock:\n            yield mock\n\n    def _setup_response(self, mock_urlopen, data):\n        \"\"\"Helper to setup mock response.\"\"\"\n        mock_resp = MagicMock()\n        mock_resp.read.return_value = json.dumps(data).encode(\"utf-8\")\n        mock_resp.__enter__ = MagicMock(return_value=mock_resp)\n        mock_resp.__exit__ = MagicMock(return_value=False)\n        mock_urlopen.return_value = mock_resp\n\n    def test_get_live_categories(self, client, mock_urlopen):\n        categories = [{\"category_id\": \"1\", \"category_name\": \"News\"}]\n        self._setup_response(mock_urlopen, categories)\n\n        result = client.get_live_categories()\n\n        assert result == categories\n        mock_urlopen.assert_called_once()\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_live_categories\" in url\n\n    def test_get_live_streams(self, client, mock_urlopen):\n        streams = [{\"stream_id\": 1, \"name\": \"CNN\"}]\n        self._setup_response(mock_urlopen, streams)\n\n        result = client.get_live_streams()\n\n        assert result == streams\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_live_streams\" in url\n\n    def test_get_live_streams_with_category(self, client, mock_urlopen):\n        streams = [{\"stream_id\": 1, \"name\": \"CNN\"}]\n        self._setup_response(mock_urlopen, streams)\n\n        result = client.get_live_streams(category_id=5)\n\n        assert result == streams\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_live_streams\" in url\n        assert \"category_id=5\" in url\n\n    def test_get_vod_categories(self, client, mock_urlopen):\n        categories = [{\"category_id\": \"10\", \"category_name\": \"Movies\"}]\n        self._setup_response(mock_urlopen, categories)\n\n        result = client.get_vod_categories()\n\n        assert result == categories\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_vod_categories\" in url\n\n    def test_get_vod_streams(self, client, mock_urlopen):\n        streams = [{\"stream_id\": 100, \"name\": \"Movie 1\"}]\n        self._setup_response(mock_urlopen, streams)\n\n        result = client.get_vod_streams()\n\n        assert result == streams\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_vod_streams\" in url\n\n    def test_get_vod_streams_with_category(self, client, mock_urlopen):\n        streams = [{\"stream_id\": 100, \"name\": \"Movie 1\"}]\n        self._setup_response(mock_urlopen, streams)\n\n        result = client.get_vod_streams(category_id=10)\n\n        assert result == streams\n        url = mock_urlopen.call_args[0][0]\n        assert \"category_id=10\" in url\n\n    def test_get_series_categories(self, client, mock_urlopen):\n        categories = [{\"category_id\": \"20\", \"category_name\": \"Drama\"}]\n        self._setup_response(mock_urlopen, categories)\n\n        result = client.get_series_categories()\n\n        assert result == categories\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_series_categories\" in url\n\n    def test_get_series(self, client, mock_urlopen):\n        series = [{\"series_id\": 200, \"name\": \"Show 1\"}]\n        self._setup_response(mock_urlopen, series)\n\n        result = client.get_series()\n\n        assert result == series\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_series\" in url\n\n    def test_get_series_with_category(self, client, mock_urlopen):\n        series = [{\"series_id\": 200, \"name\": \"Show 1\"}]\n        self._setup_response(mock_urlopen, series)\n\n        result = client.get_series(category_id=20)\n\n        assert result == series\n        url = mock_urlopen.call_args[0][0]\n        assert \"category_id=20\" in url\n\n    def test_get_series_info(self, client, mock_urlopen):\n        info = {\"info\": {\"name\": \"Show 1\"}, \"episodes\": {\"1\": []}}\n        self._setup_response(mock_urlopen, info)\n\n        result = client.get_series_info(series_id=200)\n\n        assert result == info\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_series_info\" in url\n        assert \"series_id=200\" in url\n\n    def test_get_vod_info(self, client, mock_urlopen):\n        info = {\"info\": {\"name\": \"Movie 1\", \"plot\": \"A story\"}}\n        self._setup_response(mock_urlopen, info)\n\n        result = client.get_vod_info(vod_id=100)\n\n        assert result == info\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_vod_info\" in url\n        assert \"vod_id=100\" in url\n\n    def test_get_server_info(self, client, mock_urlopen):\n        server_info = {\n            \"user_info\": {\n                \"auth\": 1,\n                \"username\": \"user\",\n                \"status\": \"Active\",\n                \"exp_date\": \"1735689600\",\n                \"max_connections\": \"2\",\n            },\n            \"server_info\": {\n                \"url\": \"example.com\",\n                \"port\": \"80\",\n                \"https_port\": \"443\",\n                \"server_protocol\": \"http\",\n            },\n        }\n        self._setup_response(mock_urlopen, server_info)\n\n        result = client.get_server_info()\n\n        assert result == server_info\n        assert result[\"user_info\"][\"auth\"] == 1\n        url = mock_urlopen.call_args[0][0]\n        # get_server_info calls API with no action\n        assert \"action\" not in url\n\n    def test_get_server_info_auth_failed(self, client, mock_urlopen):\n        server_info = {\"user_info\": {\"auth\": 0}}\n        self._setup_response(mock_urlopen, server_info)\n\n        result = client.get_server_info()\n\n        assert result[\"user_info\"][\"auth\"] == 0\n\n    def test_get_server_info_uses_shorter_timeout(self, client, mock_urlopen):\n        self._setup_response(mock_urlopen, {\"user_info\": {\"auth\": 1}})\n\n        client.get_server_info()\n\n        # get_server_info uses 15s timeout by default (vs 30s for other calls)\n        _, kwargs = mock_urlopen.call_args\n        assert kwargs[\"timeout\"] == 15\n\n    def test_custom_timeout_passed_through(self, client, mock_urlopen):\n        self._setup_response(mock_urlopen, [])\n\n        client.get_live_categories()\n\n        # Default timeout is 30s\n        _, kwargs = mock_urlopen.call_args\n        assert kwargs[\"timeout\"] == 30\n\n    def test_api_encodes_special_chars_in_params(self, client, mock_urlopen):\n        self._setup_response(mock_urlopen, {})\n\n        client._api(\"test_action\", foo=\"bar&baz\", key=\"val=ue\")\n\n        url = mock_urlopen.call_args[0][0]\n        assert \"foo=bar%26baz\" in url\n        assert \"key=val%3Due\" in url\n\n    def test_get_short_epg(self, client, mock_urlopen):\n        epg_data = {\n            \"epg_listings\": [\n                {\"title\": \"Show 1\", \"start\": \"2024-01-15 14:00:00\"},\n                {\"title\": \"Show 2\", \"start\": \"2024-01-15 15:00:00\"},\n            ]\n        }\n        self._setup_response(mock_urlopen, epg_data)\n\n        result = client.get_short_epg(stream_id=123)\n\n        assert result == epg_data\n        url = mock_urlopen.call_args[0][0]\n        assert \"action=get_short_epg\" in url\n        assert \"stream_id=123\" in url\n        assert \"limit=10\" in url  # default limit\n\n    def test_get_short_epg_custom_limit(self, client, mock_urlopen):\n        self._setup_response(mock_urlopen, {\"epg_listings\": []})\n\n        client.get_short_epg(stream_id=456, limit=5)\n\n        url = mock_urlopen.call_args[0][0]\n        assert \"stream_id=456\" in url\n        assert \"limit=5\" in url\n\n\nif __name__ == \"__main__\":\n    from testing import run_tests\n\n    run_tests(__file__)\n"
  }
]