Full Code of jdrbc/podly_pure_podcasts for AI

main 8584ec4a8f99 cached
260 files
1.3 MB
308.5k tokens
1162 symbols
1 requests
Download .txt
Showing preview only (1,385K chars total). Download the full file or copy to clipboard to get everything.
Repository: jdrbc/podly_pure_podcasts
Branch: main
Commit: 8584ec4a8f99
Files: 260
Total size: 1.3 MB

Directory structure:
gitextract_mp86zz6d/

├── .cursor/
│   └── rules/
│       └── testing-conventions.mdc
├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   └── feature_request.md
│   ├── pull_request_template.md
│   └── workflows/
│       ├── conventional-commit-check.yml
│       ├── docker-publish.yml
│       ├── lint-and-format.yml
│       └── release.yml
├── .gitignore
├── .pylintrc
├── .releaserc.cjs
├── .worktrees/
│   └── .gitignore
├── AGENTS.md
├── Dockerfile
├── LICENCE
├── Pipfile
├── Pipfile.lite
├── README.md
├── SECURITY.md
├── compose.dev.cpu.yml
├── compose.dev.nvidia.yml
├── compose.dev.rocm.yml
├── compose.yml
├── docker-entrypoint.sh
├── docs/
│   ├── contributors.md
│   ├── how_to_run_beginners.md
│   ├── how_to_run_railway.md
│   └── todo.txt
├── frontend/
│   ├── .gitignore
│   ├── README.md
│   ├── eslint.config.js
│   ├── index.html
│   ├── package.json
│   ├── postcss.config.js
│   ├── src/
│   │   ├── App.css
│   │   ├── App.tsx
│   │   ├── components/
│   │   │   ├── AddFeedForm.tsx
│   │   │   ├── AudioPlayer.tsx
│   │   │   ├── DiagnosticsModal.tsx
│   │   │   ├── DownloadButton.tsx
│   │   │   ├── EpisodeProcessingStatus.tsx
│   │   │   ├── FeedDetail.tsx
│   │   │   ├── FeedList.tsx
│   │   │   ├── PlayButton.tsx
│   │   │   ├── ProcessingStatsButton.tsx
│   │   │   ├── ReprocessButton.tsx
│   │   │   └── config/
│   │   │       ├── ConfigContext.tsx
│   │   │       ├── ConfigTabs.tsx
│   │   │       ├── index.ts
│   │   │       ├── sections/
│   │   │       │   ├── AppSection.tsx
│   │   │       │   ├── LLMSection.tsx
│   │   │       │   ├── OutputSection.tsx
│   │   │       │   ├── ProcessingSection.tsx
│   │   │       │   ├── WhisperSection.tsx
│   │   │       │   └── index.ts
│   │   │       ├── shared/
│   │   │       │   ├── ConnectionStatusCard.tsx
│   │   │       │   ├── EnvOverrideWarningModal.tsx
│   │   │       │   ├── EnvVarHint.tsx
│   │   │       │   ├── Field.tsx
│   │   │       │   ├── SaveButton.tsx
│   │   │       │   ├── Section.tsx
│   │   │       │   ├── TestButton.tsx
│   │   │       │   ├── constants.ts
│   │   │       │   └── index.ts
│   │   │       └── tabs/
│   │   │           ├── AdvancedTab.tsx
│   │   │           ├── DefaultTab.tsx
│   │   │           ├── DiscordTab.tsx
│   │   │           ├── UserManagementTab.tsx
│   │   │           └── index.ts
│   │   ├── contexts/
│   │   │   ├── AudioPlayerContext.tsx
│   │   │   ├── AuthContext.tsx
│   │   │   └── DiagnosticsContext.tsx
│   │   ├── hooks/
│   │   │   ├── useConfigState.ts
│   │   │   └── useEpisodeStatus.ts
│   │   ├── index.css
│   │   ├── main.tsx
│   │   ├── pages/
│   │   │   ├── BillingPage.tsx
│   │   │   ├── ConfigPage.tsx
│   │   │   ├── HomePage.tsx
│   │   │   ├── JobsPage.tsx
│   │   │   ├── LandingPage.tsx
│   │   │   └── LoginPage.tsx
│   │   ├── services/
│   │   │   └── api.ts
│   │   ├── types/
│   │   │   └── index.ts
│   │   ├── utils/
│   │   │   ├── clipboard.ts
│   │   │   ├── diagnostics.ts
│   │   │   └── httpError.ts
│   │   └── vite-env.d.ts
│   ├── tailwind.config.js
│   ├── tsconfig.app.json
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── pyproject.toml
├── run_podly_docker.sh
├── scripts/
│   ├── ci.sh
│   ├── create_migration.sh
│   ├── downgrade_db.sh
│   ├── generate_lockfiles.sh
│   ├── manual_publish.sh
│   ├── new_worktree.sh
│   ├── start_services.sh
│   ├── test_full_workflow.py
│   └── upgrade_db.sh
├── src/
│   ├── app/
│   │   ├── __init__.py
│   │   ├── auth/
│   │   │   ├── __init__.py
│   │   │   ├── bootstrap.py
│   │   │   ├── discord_service.py
│   │   │   ├── discord_settings.py
│   │   │   ├── feed_tokens.py
│   │   │   ├── guards.py
│   │   │   ├── middleware.py
│   │   │   ├── passwords.py
│   │   │   ├── rate_limiter.py
│   │   │   ├── service.py
│   │   │   ├── settings.py
│   │   │   └── state.py
│   │   ├── background.py
│   │   ├── config_store.py
│   │   ├── db_commit.py
│   │   ├── db_guard.py
│   │   ├── extensions.py
│   │   ├── feeds.py
│   │   ├── ipc.py
│   │   ├── job_manager.py
│   │   ├── jobs_manager.py
│   │   ├── jobs_manager_run_service.py
│   │   ├── logger.py
│   │   ├── models.py
│   │   ├── post_cleanup.py
│   │   ├── posts.py
│   │   ├── processor.py
│   │   ├── routes/
│   │   │   ├── __init__.py
│   │   │   ├── auth_routes.py
│   │   │   ├── billing_routes.py
│   │   │   ├── config_routes.py
│   │   │   ├── discord_routes.py
│   │   │   ├── feed_routes.py
│   │   │   ├── jobs_routes.py
│   │   │   ├── main_routes.py
│   │   │   ├── post_routes.py
│   │   │   └── post_stats_utils.py
│   │   ├── runtime_config.py
│   │   ├── static/
│   │   │   └── .gitignore
│   │   ├── templates/
│   │   │   └── index.html
│   │   ├── timeout_decorator.py
│   │   └── writer/
│   │       ├── __init__.py
│   │       ├── __main__.py
│   │       ├── actions/
│   │       │   ├── __init__.py
│   │       │   ├── cleanup.py
│   │       │   ├── feeds.py
│   │       │   ├── jobs.py
│   │       │   ├── processor.py
│   │       │   ├── system.py
│   │       │   └── users.py
│   │       ├── client.py
│   │       ├── executor.py
│   │       ├── model_ops.py
│   │       ├── protocol.py
│   │       └── service.py
│   ├── boundary_refinement_prompt.jinja
│   ├── main.py
│   ├── migrations/
│   │   ├── README
│   │   ├── alembic.ini
│   │   ├── env.py
│   │   ├── script.py.mako
│   │   └── versions/
│   │       ├── 0d954a44fa8e_feed_access.py
│   │       ├── 16311623dd58_env_hash.py
│   │       ├── 185d3448990e_stripe.py
│   │       ├── 18c2402c9202_cleanup_retention_days.py
│   │       ├── 2e25a15d11de_per_feed_auto_whitelist.py
│   │       ├── 31d767deb401_credits.py
│   │       ├── 35b12b2d9feb_landing_page.py
│   │       ├── 3c7f5f7640e4_add_counters_reset_timestamp.py
│   │       ├── 3d232f215842_migration.py
│   │       ├── 3eb0a3a0870b_discord.py
│   │       ├── 401071604e7b_config_tables.py
│   │       ├── 58b4eedd4c61_add_last_active_to_user.py
│   │       ├── 5bccc39c9685_zero_initial_allowance.py
│   │       ├── 608e0b27fcda_stronger_access_token.py
│   │       ├── 611dcb5d7f12_add_image_url_to_post_model_for_episode_.py
│   │       ├── 6e0e16299dcb_alternate_feed_id.py
│   │       ├── 73a6b9f9b643_allow_null_feed_id_for_aggregate_tokens.py
│   │       ├── 770771437280_episode_whitelist.py
│   │       ├── 7de4e57ec4bb_discord_settings.py
│   │       ├── 802a2365976d_gruanular_credits.py
│   │       ├── 82cfcc8e0326_refined_cuts.py
│   │       ├── 89d86978f407_limit_users.py
│   │       ├── 91ff431c832e_download_count.py
│   │       ├── 999b921ffc58_migration.py
│   │       ├── a6f5df1a50ac_add_users_table.py
│   │       ├── ab643af6472e_add_manual_feed_allowance_to_user.py
│   │       ├── b038c2f99086_add_processingjob_table_for_async_.py
│   │       ├── b92e47a03bb2_refactor_transcripts_to_db_tables_.py
│   │       ├── bae70e584468_.py
│   │       ├── c0f8893ce927_add_skipped_jobs_columns.py
│   │       ├── ded4b70feadb_add_image_metadata_to_feed.py
│   │       ├── e1325294473b_add_autoprocess_on_download.py
│   │       ├── eb51923af483_multiple_supporters.py
│   │       ├── f6d5fee57cc3_tz_fix.py
│   │       ├── f7a4195e0953_add_enable_boundary_refinement_to_llm_.py
│   │       └── fa3a95ecd67d_audio_processing_paths.py
│   ├── podcast_processor/
│   │   ├── __init__.py
│   │   ├── ad_classifier.py
│   │   ├── ad_merger.py
│   │   ├── audio.py
│   │   ├── audio_processor.py
│   │   ├── boundary_refiner.py
│   │   ├── cue_detector.py
│   │   ├── llm_concurrency_limiter.py
│   │   ├── llm_error_classifier.py
│   │   ├── llm_model_call_utils.py
│   │   ├── model_output.py
│   │   ├── podcast_downloader.py
│   │   ├── podcast_processor.py
│   │   ├── processing_status_manager.py
│   │   ├── prompt.py
│   │   ├── token_rate_limiter.py
│   │   ├── transcribe.py
│   │   ├── transcription_manager.py
│   │   └── word_boundary_refiner.py
│   ├── shared/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── defaults.py
│   │   ├── interfaces.py
│   │   ├── llm_utils.py
│   │   ├── processing_paths.py
│   │   └── test_utils.py
│   ├── system_prompt.txt
│   ├── tests/
│   │   ├── __init__.py
│   │   ├── conftest.py
│   │   ├── test_ad_classifier.py
│   │   ├── test_ad_classifier_rate_limiting_integration.py
│   │   ├── test_aggregate_feed.py
│   │   ├── test_audio_processor.py
│   │   ├── test_config_error_handling.py
│   │   ├── test_feeds.py
│   │   ├── test_filenames.py
│   │   ├── test_helpers.py
│   │   ├── test_llm_concurrency_limiter.py
│   │   ├── test_llm_error_classifier.py
│   │   ├── test_parse_model_output.py
│   │   ├── test_podcast_downloader.py
│   │   ├── test_podcast_processor_cleanup.py
│   │   ├── test_post_cleanup.py
│   │   ├── test_post_routes.py
│   │   ├── test_posts.py
│   │   ├── test_process_audio.py
│   │   ├── test_rate_limiting_config.py
│   │   ├── test_rate_limiting_edge_cases.py
│   │   ├── test_session_auth.py
│   │   ├── test_token_limit_config.py
│   │   ├── test_token_rate_limiter.py
│   │   ├── test_transcribe.py
│   │   └── test_transcription_manager.py
│   ├── user_prompt.jinja
│   └── word_boundary_refinement_prompt.jinja
└── tests/
    └── test_cue_detector.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .cursor/rules/testing-conventions.mdc
================================================
---
description: Writing tests
globs: 
alwaysApply: false
---
# Testing Conventions

This document describes testing conventions used in the Podly project.

## Fixtures and Dependency Injection

The project uses pytest fixtures for dependency injection and test setup. Common fixtures are defined in [src/tests/conftest.py](mdc:src/tests/conftest.py).

Key fixtures include:
- `app` - Flask application context for testing
- `test_config` - Configuration loaded from config.yml
- `mock_db_session` - Mock database session
- Mock classes for core components (TranscriptionManager, AdClassifier, etc.)

## SQLAlchemy Model Mocking

When testing code that uses SQLAlchemy models, prefer creating custom mock classes over using `MagicMock(spec=ModelClass)` to avoid Flask context issues:

```python
# Example from test_podcast_downloader.py
class MockPost:
    """A mock Post class that doesn't require Flask context."""
    def __init__(self, id=1, title="Test Episode", download_url="https://example.com/podcast.mp3"):
        self.id = id
        self.title = title
        self.download_url = download_url
```

See [src/tests/test_podcast_downloader.py](mdc:src/tests/test_podcast_downloader.py) for a complete example.

## Dependency Injection

Prefer injecting dependencies via the contstructor rather than patching. See [src/tests/test_podcast_processor.py](mdc:src/tests/test_podcast_processor.py) for examples of:
- Creating test fixtures with mock dependencies
- Testing error handling with failing components
- Using Flask app context when needed

## Improving Coverage

When writing tests to improve coverage:
1. Focus on one module at a time
2. Create mock objects for dependencies
3. Test successful and error paths 
4. Use `monkeypatch` to replace functions that access external resources
5. Use `tmp_path` fixture for file operations

See [src/tests/test_feeds.py](mdc:src/tests/test_feeds.py) for comprehensive examples of these patterns.


================================================
FILE: .dockerignore
================================================
# Python cache files
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/

# Git
.git/
.github/
.gitignore

# Editor files
.vscode/
.idea/
*.swp
*.swo

# Virtual environments
venv/
.env/
.venv/
env/
ENV/

# Build artifacts
*.so
*.egg-info/
dist/
build/

# Input/Output directories (these can be mounted as volumes instead)
in/
processing/

# App instance data
src/app/instance/
src/instance/

# Logs
*.log

# Database files
*.db
*.sqlite
*.sqlite3

# Local configuration files
.env
.env.*
!.env.example

# Node / JS
node_modules/
.DS_Store
*.DS_Store

# Frontend specific
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/coverage/
frontend/.nyc_output/
frontend/.eslintcache

# Documentation
docs/
*.md
!README.md

# Coverage / lint caches
.coverage
coverage.xml
htmlcov/
.ruff_cache/


================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: jdrbc


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Report a problem or regression
title: "[Bug]: "
labels: bug
assignees: ""
---

## Summary
- 

## Steps to reproduce
1. 

## Expected behavior
- 

## Actual behavior
- 

## Environment
- App version/commit: 
- OS: 
- Deployment: local / docker / other

## Logs or screenshots
- 

## Additional context
- 


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea or enhancement
title: "[Feature]: "
labels: enhancement
assignees: ""
---

## Summary
- 

## Problem to solve
- 

## Proposed solution
- 

## Alternatives considered
- 

## Additional context
- 


================================================
FILE: .github/pull_request_template.md
================================================
## Summary
- 

## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor
- [ ] Docs
- [ ] Other

## Testing
- [ ] `scripts/ci.sh`
- [ ] Not run (explain below)

## Docs
- [ ] Not needed
- [ ] Updated (details below)

## Related issues
- 

## Notes
- 

## Checklist
- [ ] Target branch is `Preview`
- [ ] Docs updated if needed
- [ ] Tests run or explicitly skipped with reasoning
- [ ] If merging to main, at least one commit in this PR follows Conventional Commits (e.g., `feat:`, `fix:`, `chore:`) Please refer to https://www.conventionalcommits.org/en/v1.0.0/#summary for more details.


================================================
FILE: .github/workflows/conventional-commit-check.yml
================================================
name: Conventional Commit Check

on:
  pull_request:
    branches:
      - main

permissions:
  contents: read

jobs:
  conventional-commit:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Ensure at least one Conventional Commit
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          set -euo pipefail
          echo "Checking commit subjects between $BASE_SHA and $HEAD_SHA"
          subjects=$(git log --format=%s "$BASE_SHA..$HEAD_SHA")
          if [ -z "$subjects" ]; then
            echo "No commits found in range."
            exit 1
          fi

          if echo "$subjects" | grep -Eq '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?(!)?: .+'; then
            echo "Conventional Commit found."
          else
            echo "No Conventional Commit found in this PR."
            echo "Add at least one commit like: feat: ..., fix(scope): ..., chore: ..."
            echo "Please refer to https://www.conventionalcommits.org/en/v1.0.0/#summary for more details."
            exit 1
          fi


================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Build and Publish Docker Images

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
    branches: [main]
  release:
    types: [published]

permissions:
  contents: read
  packages: write
  
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository_owner }}/podly-pure-podcasts

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      skip: ${{ steps.check_files.outputs.skip }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check for documentation-only changes
        id: check_files
        run: |
          # For PRs, compare against the base branch. For pushes, compare against the previous commit.
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE_REF="${{ github.event.pull_request.base.ref }}"
            echo "Fetching base branch origin/$BASE_REF"
            git fetch --no-tags origin "$BASE_REF"
            BASE_SHA=$(git rev-parse "origin/$BASE_REF")
            HEAD_SHA=$(git rev-parse "${{ github.sha }}")
            echo "Comparing PR commits: $BASE_SHA...$HEAD_SHA"
            files_changed=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA")
          elif [ "${{ github.event_name }}" = "release" ]; then
            echo "Release event detected; building images for release tag"
            TARGET_REF="${{ github.event.release.target_commitish }}"
            echo "Fetching release target origin/$TARGET_REF"
            git fetch --no-tags origin "$TARGET_REF" || true
            HEAD_SHA=$(git rev-parse "${{ github.sha }}")
            BASE_SHA=$(git rev-parse "origin/$TARGET_REF" 2>/dev/null || git rev-parse "$TARGET_REF" 2>/dev/null || echo "$HEAD_SHA")
            files_changed=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA" 2>/dev/null || echo "release-trigger")
          else
            echo "Comparing push commits: HEAD~1...HEAD"
            if git rev-parse HEAD~1 >/dev/null 2>&1; then
              files_changed=$(git diff --name-only HEAD~1 HEAD)
            else
              echo "Single commit push detected; using initial commit diff"
              files_changed=$(git diff-tree --no-commit-id --name-only -r HEAD)
            fi
          fi

          echo "Files changed:"
          echo "$files_changed"

          # If no files are documentation, then we should continue
          non_doc_files=$(echo "$files_changed" | grep -v -E '(\.md$|^docs/|LICENCE)')

          if [ "${{ github.event_name }}" = "release" ]; then
            echo "Release build detected. Skipping documentation-only shortcut."
            echo "skip=false" >> $GITHUB_OUTPUT
          elif [ -z "$non_doc_files" ]; then
            echo "Only documentation files were changed. Skipping build and publish."
            echo "skip=true" >> $GITHUB_OUTPUT
          else
            echo "Code files were changed. Proceeding with build and publish."
            echo "skip=false" >> $GITHUB_OUTPUT
          fi
        shell: bash

  ## test if build is successful, but don't run every permutation on PRs
  build-amd64-pr-lite:
    needs: changes
    if: ${{ needs.changes.outputs.skip == 'false' && github.event_name == 'pull_request' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        variant:
          - name: "lite"
            base: "python:3.11-slim"
            gpu: "false"
            gpu_nvidia: "false"
            gpu_amd: "false"
            lite_build: "true"
    env:
      ARCH: amd64
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Free up disk space
        if: ${{ matrix.variant.gpu == 'true' || matrix.variant.gpu_nvidia == 'true' || matrix.variant.gpu_amd == 'true' }}
        run: |
          echo "Available disk space before cleanup:"
          df -h
          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
          sudo rm -rf /opt/microsoft/msedge /opt/microsoft/powershell /opt/pipx /usr/lib/mono
          sudo rm -rf /usr/local/.ghcup /usr/share/swift
          docker system prune -af
          echo "Available disk space after cleanup:"
          df -h

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: |
            image=moby/buildkit:v0.12.0

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch,suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=ref,event=pr,suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=semver,pattern={{version}},suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=raw,value=${{ matrix.variant.name }}-${{ env.ARCH }},enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          platforms: linux/${{ env.ARCH }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            BASE_IMAGE=${{ matrix.variant.base }}
            USE_GPU=${{ matrix.variant.gpu }}
            USE_GPU_NVIDIA=${{ matrix.variant.gpu_nvidia }}
            USE_GPU_AMD=${{ matrix.variant.gpu_amd }}
            LITE_BUILD=${{ matrix.variant.lite_build }}
          # Temporarily disabled due to GitHub Actions Cache service outage
          # cache-from: type=gha
          # cache-to: type=gha,mode=max

  build-amd64:
    needs: changes
    if: ${{ needs.changes.outputs.skip == 'false' && github.event_name != 'pull_request' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        variant:
          - name: "latest"
            base: "python:3.11-slim"
            gpu: "false"
            gpu_nvidia: "false"
            gpu_amd: "false"
            lite_build: "false"
          - name: "lite"
            base: "python:3.11-slim"
            gpu: "false"
            gpu_nvidia: "false"
            gpu_amd: "false"
            lite_build: "true"
          - name: "gpu-nvidia"
            base: "nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04"
            gpu: "true"
            gpu_nvidia: "true"
            gpu_amd: "false"
            lite_build: "false"
          - name: "gpu-amd"
            base: "rocm/dev-ubuntu-22.04:6.4-complete"
            gpu: "false"
            gpu_nvidia: "false"
            gpu_amd: "true"
            lite_build: "false"
    env:
      ARCH: amd64
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Free up disk space
        if: ${{ matrix.variant.gpu == 'true' || matrix.variant.gpu_nvidia == 'true' || matrix.variant.gpu_amd == 'true' }}
        run: |
          echo "Available disk space before cleanup:"
          df -h
          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
          sudo rm -rf /opt/microsoft/msedge /opt/microsoft/powershell /opt/pipx /usr/lib/mono
          sudo rm -rf /usr/local/.ghcup /usr/share/swift
          docker system prune -af
          echo "Available disk space after cleanup:"
          df -h

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: |
            image=moby/buildkit:v0.12.0

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch,suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=ref,event=pr,suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=semver,pattern={{version}},suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=raw,value=${{ matrix.variant.name }}-${{ env.ARCH }},enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          platforms: linux/${{ env.ARCH }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            BASE_IMAGE=${{ matrix.variant.base }}
            USE_GPU=${{ matrix.variant.gpu }}
            USE_GPU_NVIDIA=${{ matrix.variant.gpu_nvidia }}
            USE_GPU_AMD=${{ matrix.variant.gpu_amd }}
            LITE_BUILD=${{ matrix.variant.lite_build }}
          # Temporarily disabled due to GitHub Actions Cache service outage
          # cache-from: type=gha
          # cache-to: type=gha,mode=max

  build-arm64:
    needs: changes
    if: ${{ needs.changes.outputs.skip == 'false' && github.event_name != 'pull_request' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        variant:
          - {
              name: "latest",
              base: "python:3.11-slim",
              gpu: "false",
              gpu_nvidia: "false",
              gpu_amd: "false",
              lite_build: "false",
            }
          - {
              name: "lite",
              base: "python:3.11-slim",
              gpu: "false",
              gpu_nvidia: "false",
              gpu_amd: "false",
              lite_build: "true",
            }
    env:
      ARCH: arm64
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Free up disk space
        if: ${{ matrix.variant.gpu == 'true' || matrix.variant.gpu_nvidia == 'true' || matrix.variant.gpu_amd == 'true' }}
        run: |
          echo "Available disk space before cleanup:"
          df -h
          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
          sudo rm -rf /opt/microsoft/msedge /opt/microsoft/powershell /opt/pipx /usr/lib/mono
          sudo rm -rf /usr/local/.ghcup /usr/share/swift
          docker system prune -af
          echo "Available disk space after cleanup:"
          df -h

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: |
            image=moby/buildkit:v0.12.0

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch,suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=ref,event=pr,suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=semver,pattern={{version}},suffix=-${{ matrix.variant.name }}-${{ env.ARCH }}
            type=raw,value=${{ matrix.variant.name }}-${{ env.ARCH }},enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          platforms: linux/${{ env.ARCH }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            BASE_IMAGE=${{ matrix.variant.base }}
            USE_GPU=${{ matrix.variant.gpu }}
            USE_GPU_NVIDIA=${{ matrix.variant.gpu_nvidia }}
            USE_GPU_AMD=${{ matrix.variant.gpu_amd }}
            LITE_BUILD=${{ matrix.variant.lite_build }}
          # Temporarily disabled due to GitHub Actions Cache service outage
          # cache-from: type=gha
          # cache-to: type=gha,mode=max

  manifest:
    needs: [changes, build-amd64, build-arm64]
    if: ${{ needs.changes.outputs.skip == 'false' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        variant:
          - "latest"
          - "lite"
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (manifest)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch,suffix=-${{ matrix.variant }}
            type=ref,event=pr,suffix=-${{ matrix.variant }}
            type=semver,pattern={{version}},suffix=-${{ matrix.variant }}
            type=raw,value=${{ matrix.variant }},enable={{is_default_branch}}

      - name: Create and push manifest
        run: |
          set -euo pipefail
          tags="${{ steps.meta.outputs.tags }}"
          while IFS= read -r tag; do
            [ -z "$tag" ] && continue
            echo "Creating manifest for ${tag}"
            docker buildx imagetools create \
              -t "${tag}" \
              "${tag}-amd64" \
              "${tag}-arm64"
          done <<< "$tags"


================================================
FILE: .github/workflows/lint-and-format.yml
================================================
name: Python Linting, Formatting, and Testing

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  lint-format-test:
    runs-on: ubuntu-latest
    env:
      PIPENV_VENV_IN_PROJECT: "1"
      PIP_DISABLE_PIP_VERSION_CHECK: "1"

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python
        id: python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"
          cache-dependency-path: "Pipfile.lock"

      - name: Install ffmpeg
        run: sudo apt-get update -y && sudo apt-get install -y --no-install-recommends ffmpeg

      - name: Install pipenv
        run: pip install pipenv

      - name: Cache pipenv virtualenv
        uses: actions/cache@v4
        with:
          path: .venv
          key: ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('Pipfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-

      - name: Cache mypy
        uses: actions/cache@v4
        with:
          path: .mypy_cache
          key: ${{ runner.os }}-mypy-${{ steps.python.outputs.python-version }}-${{ hashFiles('Pipfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-mypy-${{ steps.python.outputs.python-version }}-

      - name: Install dependencies
        run: pipenv install --dev --deploy

      - name: Install dependencies for mypy
        run: pipenv run mypy . --install-types --non-interactive --explicit-package-bases --exclude 'migrations' --exclude 'build' --exclude 'scripts' --exclude 'src/tests' --exclude 'src/tests/test_routes.py' --exclude 'src/app/routes.py'

      - name: Run pylint
        run: pipenv run pylint src --ignore=migrations,tests

      - name: Run black
        run: pipenv run black --check src

      - name: Run isort
        run: pipenv run isort --check-only src

      - name: Run pytest
        run: pipenv run pytest --disable-warnings


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: write
  issues: write
  pull-requests: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Run semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: >
          npx --yes
          -p semantic-release
          -p @semantic-release/changelog
          -p @semantic-release/git
          semantic-release


================================================
FILE: .gitignore
================================================
.worktrees/*

__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
*.manifest
*.spec
pip-log.txt
pip-delete-this-directory.txt
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
*.mo
*.pot
*.log
out/*
processing/*
config/app.log
.vscode/*
in/**/*.mp3
srv/**/*.mp3
*.pickle
.env
.env.local
config/config.yml
*.db
*.sqlite
**/sqlite3.db-*
**/*.sqlite-*
.DS_Store
src/instance/data/*

# Frontend build logs
frontend-build.log

# Claude Code local notes (not committed)
.claude-notes/
CLAUDE_NOTES.md


================================================
FILE: .pylintrc
================================================
[MASTER]
ignore=frontend,migrations,scripts
ignore-paths=^src/(migrations|tests)/
disable=
    C0114, # missing-module-docstring
    C0115, # missing-class-docstring
    C0116, # missing-function-docstring
    R0913, # too-many-arguments
    R0914, # too-many-locals
    R0903, # too-few-public-methods
    W1203, # logging-fstring-interpolation
    W1514, # using-constant-test
    E0401, # import-error
    C0301, # line-too-long
    R0911, # too-many-return-statements

[DESIGN]
# Allow more statements per function to accommodate complex processing routines
max-statements=100

[MASTER:src/tests/*.py]
disable=
    W0621, # redefined-outer-name
    W0212, # protected-access
    W0613, # Unused argument
    C0415, # Import outside toplevel
    W0622,
    R0902


[MASTER:scripts/*.py]
disable=
    R0917, 
    W0718


[SIMILARITIES]

# Minimum lines number of a similarity.
min-similarity-lines=10

# Ignore comments when computing similarities.
ignore-comments=yes

# Ignore docstrings when computing similarities.
ignore-docstrings=yes

# Ignore imports when computing similarities.
ignore-imports=no



================================================
FILE: .releaserc.cjs
================================================
const { execSync } = require("node:child_process");

const resolveRepositoryUrl = () => {
  if (process.env.GITHUB_REPOSITORY) {
    return `https://github.com/${process.env.GITHUB_REPOSITORY}.git`;
  }

  try {
    return execSync("git remote get-url origin", { stdio: "pipe" })
      .toString()
      .trim();
  } catch {
    return undefined;
  }
};

module.exports = {
  branches: ["main"],
  repositoryUrl: resolveRepositoryUrl(),
  tagFormat: "v${version}",
  plugins: [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
    [
      "@semantic-release/git",
      {
        assets: ["CHANGELOG.md"],
        message:
          "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
      },
    ],
    "@semantic-release/github",
  ],
};


================================================
FILE: .worktrees/.gitignore
================================================
*
!.gitignore

================================================
FILE: AGENTS.md
================================================
Project-specific rules:
- Do not create Alembic migrations yourself; request the user to generate migrations after model changes.
- Only use ./scripts/ci.sh to run tests & lints - do not attempt to run directly
- use pipenv
- All database writes must go through the `writer` service. Do not use `db.session.commit()` directly in application code. Use `writer_client.action()` instead.


================================================
FILE: Dockerfile
================================================
# Multi-stage build for combined frontend and backend
ARG BASE_IMAGE=python:3.11-slim
FROM node:18-alpine AS frontend-build

WORKDIR /app

# Copy frontend package files
COPY frontend/package*.json ./
RUN npm ci

# Copy frontend source code
COPY frontend/ ./

# Build frontend assets with explicit error handling
RUN set -e && \
    npm run build && \
    test -d dist && \
    echo "Frontend build successful - dist directory created"

# Backend stage
FROM ${BASE_IMAGE} AS backend

# Environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ARG CUDA_VERSION=12.4.1
ARG ROCM_VERSION=6.4
ARG USE_GPU=false
ARG USE_GPU_NVIDIA=${USE_GPU}
ARG USE_GPU_AMD=false
ARG LITE_BUILD=false

WORKDIR /app

# Install dependencies based on base image
RUN if [ -f /etc/debian_version ]; then \
    apt-get update && \
    apt-get install -y ca-certificates && \
    # Determine if we need to install Python 3.11
    INSTALL_PYTHON=true && \
    if command -v python3 >/dev/null 2>&1; then \
        if python3 --version 2>&1 | grep -q "3.11"; then \
            INSTALL_PYTHON=false; \
        fi; \
    fi && \
    if [ "$INSTALL_PYTHON" = "true" ]; then \
        apt-get install -y software-properties-common && \
        if ! apt-cache show python3.11 > /dev/null 2>&1; then \
            add-apt-repository ppa:deadsnakes/ppa -y && \
            apt-get update; \
        fi && \
        DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        python3.11 \
        python3.11-distutils \
        python3.11-dev \
        python3-pip && \
        update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 && \
        update-alternatives --set python3 /usr/bin/python3.11; \
    fi && \
    # Install other dependencies
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    ffmpeg \
    sqlite3 \
    libsqlite3-dev \
    build-essential \
    gosu && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* ; \
    fi

# Install python3-tomli if Python version is less than 3.11 (separate step for ARM compatibility)
RUN if [ -f /etc/debian_version ]; then \
    PYTHON_MINOR=$(python3 --version 2>&1 | grep -o 'Python 3\.[0-9]*' | cut -d '.' -f2) && \
    if [ "$PYTHON_MINOR" -lt 11 ]; then \
    apt-get update && \
    apt-get install -y python3-tomli && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* ; \
    fi ; \
    fi

# Copy all Pipfiles/lock files
COPY Pipfile Pipfile.lock Pipfile.lite Pipfile.lite.lock ./

# Remove problematic distutils-installed packages that may conflict
RUN if [ -f /etc/debian_version ]; then \
    apt-get remove -y python3-blinker 2>/dev/null || true; \
    fi

# Install pipenv and dependencies
RUN if command -v pip >/dev/null 2>&1; then \
    pip install --no-cache-dir pipenv; \
    elif command -v pip3 >/dev/null 2>&1; then \
    pip3 install --no-cache-dir pipenv; \
    else \
    python3 -m pip install --no-cache-dir pipenv; \
    fi

# Set pip timeout and retries for better reliability
ENV PIP_DEFAULT_TIMEOUT=1000
ENV PIP_RETRIES=3
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV PIP_NO_CACHE_DIR=1

# Set pipenv configuration for better CI reliability
ENV PIPENV_VENV_IN_PROJECT=1
ENV PIPENV_TIMEOUT=1200

# Install dependencies conditionally based on LITE_BUILD
RUN set -e && \
    if [ "${LITE_BUILD}" = "true" ]; then \
    echo "Installing lite dependencies (without Whisper)"; \
    echo "Using lite Pipfile:" && \
    PIPENV_PIPFILE=Pipfile.lite pipenv install --deploy --system; \
    else \
    echo "Installing full dependencies (including Whisper)"; \
    echo "Using full Pipfile:" && \
    PIPENV_PIPFILE=Pipfile pipenv install --deploy --system; \
    fi

# Install PyTorch with CUDA support if using NVIDIA image (skip if LITE_BUILD)
RUN if [ "${LITE_BUILD}" = "true" ]; then \
    echo "Skipping PyTorch installation in lite mode"; \
    elif [ "${USE_GPU}" = "true" ] || [ "${USE_GPU_NVIDIA}" = "true" ]; then \
    if command -v pip >/dev/null 2>&1; then \
    pip install --no-cache-dir nvidia-cudnn-cu12 torch; \
    elif command -v pip3 >/dev/null 2>&1; then \
    pip3 install --no-cache-dir nvidia-cudnn-cu12 torch; \
    else \
    python3 -m pip install --no-cache-dir nvidia-cudnn-cu12 torch; \
    fi; \
    elif [ "${USE_GPU_AMD}" = "true" ]; then \
    if command -v pip >/dev/null 2>&1; then \
    pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/rocm${ROCM_VERSION}; \
    elif command -v pip3 >/dev/null 2>&1; then \
    pip3 install --no-cache-dir torch --index-url https://download.pytorch.org/whl/rocm${ROCM_VERSION}; \
    else \
    python3 -m pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/rocm${ROCM_VERSION}; \
    fi; \
    else \
    if command -v pip >/dev/null 2>&1; then \
    pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu; \
    elif command -v pip3 >/dev/null 2>&1; then \
    pip3 install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu; \
    else \
    python3 -m pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu; \
    fi; \
    fi

# Copy application code
COPY src/ ./src/
RUN rm -rf ./src/instance
COPY scripts/ ./scripts/
RUN chmod +x scripts/start_services.sh

# Copy built frontend assets to Flask static folder
COPY --from=frontend-build /app/dist ./src/app/static

# Create non-root user for running the application
RUN groupadd -r appuser && \
    useradd --no-log-init -r -g appuser -d /home/appuser appuser && \
    mkdir -p /home/appuser && \
    chown -R appuser:appuser /home/appuser

# Create necessary directories and set permissions
RUN mkdir -p /app/processing /app/src/instance /app/src/instance/data /app/src/instance/data/in /app/src/instance/data/srv /app/src/instance/config /app/src/instance/db && \
    chown -R appuser:appuser /app

# Copy entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod 755 /docker-entrypoint.sh

EXPOSE 5001

# Run the application through the entrypoint script
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["./scripts/start_services.sh"]


================================================
FILE: LICENCE
================================================

MIT License

Copyright (c) 2024 John Rogers

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: Pipfile
================================================
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
speechrecognition = "*"
openai = "*"
python-dotenv = "*"
jinja2 = "*"
flask = "*"
pyrss2gen = "*"
feedparser = "*"
certifi = "*"
cd = "*"
pyyaml = "*"
prompt-toolkit = "*"
pypodcastparser = "*"
werkzeug = "*"
exceptiongroup = "*"
zeroconf = "*"
waitress = "*"
validators = "*"
beartype = "*"
openai-whisper = "*"
flask-sqlalchemy = "*"
flask-migrate = "*"
Flask-APScheduler = "*"
ffmpeg-python = "*"
litellm = "*"  # Pin to avoid fastuuid dependency
bleach = "*"
types-bleach = "*"
groq = "*"
async_timeout = "*"
pytest-cov = "*"
flask-cors = "*"
bcrypt = "*"
httpx-aiohttp = "*"
stripe = "*"

[dev-packages]
black = "*"
mypy = "*"
types-pyyaml = "*"
types-requests = "*"
types-waitress = "*"
pylint = "*"
pytest = "*"
dill = "*"
isort = "*"
types-flask-migrate = "*"
pytest-mock = "*"
watchdog = "*"
requests = "*"
types-flask-cors = "*"

[requires]
python_version = "3.11"


================================================
FILE: Pipfile.lite
================================================
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
speechrecognition = "*"
openai = "*"
python-dotenv = "*"
jinja2 = "*"
flask = "*"
pyrss2gen = "*"
feedparser = "*"
certifi = "*"
cd = "*"
pyyaml = "*"
prompt-toolkit = "*"
pypodcastparser = "*"
werkzeug = "*"
exceptiongroup = "*"
zeroconf = "*"
waitress = "*"
validators = "*"
beartype = "*"
flask-sqlalchemy = "*"
flask-migrate = "*"
Flask-APScheduler = "*"
ffmpeg-python = "*"
litellm = ">=1.59.8,<1.75.0"  # Pin to avoid fastuuid dependency
bleach = "*"
types-bleach = "*"
groq = "*"
async_timeout = "*"
pytest-cov = "*"
flask-cors = "*"
bcrypt = "*"
stripe = "*"

[dev-packages]
black = "*"
mypy = "*"
types-pyyaml = "*"
types-requests = "*"
types-waitress = "*"
pylint = "*"
pytest = "*"
dill = "*"
isort = "*"
types-flask-migrate = "*"
pytest-mock = "*"
watchdog = "*"
requests = "*"
types-flask-cors = "*"

[requires]
python_version = "3.11"


================================================
FILE: README.md
================================================
<h2 align="center">
<img width="50%" src="src/app/static/images/logos/logo_with_text.png" />

</h2>

<p align="center">
<p align="center">Ad-block for podcasts. Create an ad-free RSS feed.</p>
<p align="center">
  <a href="https://discord.gg/FRB98GtF6N" target="_blank">
      <img src="https://img.shields.io/badge/discord-join-blue.svg?logo=discord&logoColor=white" alt="Discord">
  </a>
</p>

## Overview

Podly uses Whisper and Chat GPT to remove ads from podcasts.

<img width="100%" src="docs/images/screenshot.png" />

## How To Run

You have a few options to get started:

- [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/podly?referralCode=NMdeg5&utm_medium=integration&utm_source=template&utm_campaign=generic)
   - quick and easy setup in the cloud, follow our [Railway deployment guide](docs/how_to_run_railway.md). 
   - Use this if you want to share your Podly server with others.
- **Run Locally**: 
   - For local development and customization, 
   - see our [beginner's guide for running locally](docs/how_to_run_beginners.md). 
   - Use this for the most cost-optimal & private setup.
- **[Join The Preview Server](https://podly.up.railway.app/)**: 
   - pay what you want (limited sign ups available)


## How it works:

- You request an episode
- Podly downloads the requested episode
- Whisper transcribes the episode
- LLM labels ad segments
- Podly removes the ad segments
- Podly delivers the ad-free version of the podcast to you

### Cost Breakdown
*Monthly cost breakdown for 5 podcasts*

| Cost    | Hosting  | Transcription | LLM    |
|---------|----------|---------------|--------|
| **free**| local    | local         | local  |
| **$2**  | local    | local         | remote |
| **$5**  | local    | remote        | remote |
| **$10** | public (railway)  | remote        | remote |
| **Pay What You Want** | [preview server](https://podly.up.railway.app/)    | n/a         | n/a  |
| **$5.99/mo** | https://zeroads.ai/ | production fork of podly | |


## Contributing

See [contributing guide](docs/contributors.md) for local setup & contribution instructions.


================================================
FILE: SECURITY.md
================================================
# Security Policy

## Supported Versions

We only support the latest on main & preview.

## Reporting a Vulnerability

Please use the Private Vulnerability Reporting feature on GitHub:

- Navigate to the Security tab of this repository.
- Select "Vulnerability reporting" from the left-hand sidebar.
- Click "Report a vulnerability" to open a private advisory.

Include as much detail as possible:

- Steps to reproduce.
- Potential impact.
- Any suggested fixes.

This allows us to collaborate with you on a fix in a private workspace before the issue is made public.


================================================
FILE: compose.dev.cpu.yml
================================================
services:
  podly:
    container_name: podly-pure-podcasts
    image: podly-pure-podcasts
    volumes:
      - ./src/instance:/app/src/instance
    env_file:
      - ./.env.local
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - BASE_IMAGE=${BASE_IMAGE:-python:3.11-slim}
        - CUDA_VERSION=${CUDA_VERSION:-12.4.1}
        - USE_GPU=${USE_GPU:-false}
        - USE_GPU_NVIDIA=${USE_GPU_NVIDIA:-false}
        - USE_GPU_AMD=${USE_GPU_AMD:-false}
        - LITE_BUILD=${LITE_BUILD:-false}
    ports:
      - "5001:5001"
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:--1}
      - SERVER_THREADS=${SERVER_THREADS:-1}
    restart: unless-stopped
    healthcheck:
      test:
        [
          "CMD",
          "python3",
          "-c",
          "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5001/')",
        ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

networks:
  default:
    name: podly-pure-podcasts-network


================================================
FILE: compose.dev.nvidia.yml
================================================
services:
  podly:
    extends:
      file: compose.dev.cpu.yml
      service: podly
    env_file:
      - ./.env.local
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - CUDA_VISIBLE_DEVICES=0
      - CORS_ORIGINS=*
      - SERVER_THREADS=${SERVER_THREADS:-1}
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

networks:
  default:
    name: podly-pure-podcasts-network


================================================
FILE: compose.dev.rocm.yml
================================================
services:
  podly:
    extends:
      file: compose.dev.cpu.yml
      service: podly
    env_file:
      - ./.env.local
    devices:
      - /dev/kfd
      - /dev/dri
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:--1}
      - CORS_ORIGINS=*
      - SERVER_THREADS=${SERVER_THREADS:-1}
      # Don't ask me why this is needed for ROCM. See
      # https://github.com/openai/whisper/discussions/55#discussioncomment-3714528
      - HSA_OVERRIDE_GFX_VERSION=10.3.0
    security_opt:
      - seccomp=unconfined

networks:
  default:
    name: podly-pure-podcasts-network
# This would be ideal. Not currently supported, apparently. Or I just wasn't able to figure out the driver arg.
# Tried: amdgpu, amd, rocm
#    deploy:
#      resources:
#        reservations:
#          devices:
#            - capabilities: [gpu]
#              driver: "amdgpu"
#              count: 1


================================================
FILE: compose.yml
================================================
services:
  podly:
    container_name: podly-pure-podcasts
    ports:
      - "5001:5001"
    image: ghcr.io/podly-pure-podcasts/podly-pure-podcasts:${BRANCH:-main-latest}
    volumes:
      - ./src/instance:/app/src/instance
    env_file:
      - ./.env.local
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:--1}
      - SERVER_THREADS=${SERVER_THREADS:-1}
    restart: unless-stopped
    healthcheck:
      test:
        [
          "CMD",
          "python3",
          "-c",
          "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5001/')",
        ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

networks:
  default:
    name: podly-pure-podcasts-network


================================================
FILE: docker-entrypoint.sh
================================================
#!/bin/bash
set -e

# Check if PUID/PGID env variables are set
if [ -n "${PUID}" ] && [ -n "${PGID}" ] && [ "$(id -u)" = "0" ]; then
    echo "Using custom UID:GID = ${PUID}:${PGID}"
    
    # Update user/group IDs if needed
    usermod -o -u "$PUID" appuser
    groupmod -o -g "$PGID" appuser
    
    # Ensure required directories exist
    mkdir -p /app/src/instance /app/src/instance/data /app/src/instance/data/in /app/src/instance/data/srv /app/src/instance/config /app/src/instance/db /app/src/instance/logs
    
    # Set permissions for all application directories
    APP_DIRS="/home/appuser /app/processing /app/src/instance /app/src/instance/data /app/src/instance/config /app/src/instance/db /app/src/instance/logs /app/scripts"
    chown -R appuser:appuser $APP_DIRS 2>/dev/null || true
    
    # Ensure log file exists and has correct permissions in new location
    touch /app/src/instance/logs/app.log
    chmod 664 /app/src/instance/logs/app.log
    chown appuser:appuser /app/src/instance/logs/app.log

    # Run as appuser
    export HOME=/home/appuser
    exec gosu appuser "$@"
else
    # Run as current user (but don't assume it's appuser)
    exec "$@"
fi 

================================================
FILE: docs/contributors.md
================================================
# Contributor Guide

### Quick Start (Docker - recommended for local setup)

1. Make the script executable and run:

```bash
chmod +x run_podly_docker.sh
./run_podly_docker.sh --build
./run_podly_docker.sh # foreground with logs 
./run_podly_docker.sh -d # or detached
```

This automatically detects NVIDIA GPUs and uses them if available.

After the server starts:

- Open `http://localhost:5001` in your browser
- Configure settings at `http://localhost:5001/config`
- Add podcast feeds and start processing

## Usage

Once the server is running:

1. Open `http://localhost:5001`
2. Configure settings in the Config page at `http://localhost:5001/config`
3. Add podcast RSS feeds through the web interface
4. Open your podcast app and subscribe to the Podly endpoint (e.g., `http://localhost:5001/feed/1`)
5. Select an episode and download

## Transcription Options

Podly supports multiple options for audio transcription:

1. **Local Whisper (Default)**
   - Slower but self-contained
2. **OpenAI Hosted Whisper**
   - Fast and accurate; billed per-feed via Stripe
3. **Groq Hosted Whisper**
   - Fast and cost-effective

Select your preferred method in the Config page (`/config`).

## Remote Setup

Podly automatically detects reverse proxies and generates appropriate URLs via request headers.

### Reverse Proxy Examples

**Nginx:**

```nginx
server {
    listen 443 ssl;
    server_name your-domain.com;

    location / {
        proxy_pass http://localhost:5001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
    }
}
```

**Traefik (docker-compose.yml):**

```yaml
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.podly.rule=Host(`your-domain.com`)"
  - "traefik.http.routers.podly.tls.certresolver=letsencrypt"
  - "traefik.http.services.podly.loadbalancer.server.port=5001"
```

> **Note**: Most modern reverse proxies automatically set the required headers. No manual configuration is needed in most cases.

### Built-in Authentication

Podly ships with built-in authentication so you can secure feeds without relying on a reverse proxy.

- Set `REQUIRE_AUTH=true` to enable protection. By default it is `false`, preserving existing behaviour.
- When auth is enabled, Podly fails fast on startup unless `PODLY_ADMIN_PASSWORD` is supplied and meets the strength policy (≥12 characters with upper, lower, digit, symbol). Override the initial username with `PODLY_ADMIN_USERNAME` (default `podly_admin`).
- Provide a long, random `PODLY_SECRET_KEY` so Flask sessions remain valid across restarts. If you omit it, the app generates a new key on each boot and all users are signed out.
- On first boot with an empty database, Podly seeds an admin user using the supplied credentials. **If you are enabling auth on an existing install, start from a fresh data volume.**
- After signing in, open the Config page to rotate your password and manage additional users. When you change the admin password, update the corresponding environment variable in your deployment platform so restarts continue to succeed.
- Use the "Copy protected feed" button to generate feed-specific access tokens that are embedded in subscription URLs so podcast clients can authenticate without your primary password. Rate limiting is still applied to repeated authentication failures.

## Ubuntu Service

Add a service file to /etc/systemd/system/podly.service

```
[Unit]
Description=Podly Podcast Service
After=network.target

[Service]
User=yourusername
Group=yourusername
WorkingDirectory=/path/to/your/app
ExecStart=/usr/bin/pipenv run python src/main.py
Restart=always

[Install]
WantedBy=multi-user.target
```

enable the service

```
sudo systemctl daemon-reload
sudo systemctl enable podly.service
```

## Database Update

The database auto-migrates on launch.

To add a migration after data model change:

```bash
pipenv run flask --app ./src/main.py db migrate -m "[change description]"
```

On next launch, the database updates automatically.

## Releases and Commit Messages

This repo uses `semantic-release` to automate versioning and GitHub releases. It relies on
Conventional Commits to determine the next version.

For pull requests, include **at least one** commit that follows the Conventional Commit format:

- `feat: add new episode filter`
- `fix(api): handle empty feed`
- `chore: update dependencies`

If no Conventional Commit is present, the release pipeline will have nothing to publish.

## Docker Support

Podly can be run in Docker with support for both NVIDIA GPU and non-NVIDIA environments.

### Docker Options

```bash
./run_podly_docker.sh --dev          # rebuild containers for local changes
./run_podly_docker.sh --production   # use published images
./run_podly_docker.sh --lite         # smaller image without local Whisper
./run_podly_docker.sh --cpu          # force CPU mode
./run_podly_docker.sh --gpu          # force GPU mode
./run_podly_docker.sh --build        # build only
./run_podly_docker.sh --test-build   # test build
./run_podly_docker.sh -d             # detached
```

### Development vs Production Modes

**Development Mode** (default):

- Uses local Docker builds
- Requires rebuilding after code changes: `./run_podly_docker.sh --dev`
- Mounts essential directories (config, input/output, database) and live code for development
- Good for: development, testing, customization

**Production Mode**:

- Uses pre-built images from GitHub Container Registry
- No building required - images are pulled automatically
- Same volume mounts as development
- Good for: deployment, quick setup, consistent environments

```bash
# Start with existing local container
./run_podly_docker.sh

# Rebuild and start after making code changes
./run_podly_docker.sh --dev

# Use published images (no local building required)
./run_podly_docker.sh --production
```

### Docker Environment Configuration

**Environment Variables**:

- `PUID`/`PGID`: User/group IDs for file permissions (automatically set by run script)
- `CUDA_VISIBLE_DEVICES`: GPU device selection for CUDA acceleration
- `CORS_ORIGINS`: Backend CORS configuration (defaults to accept requests from any origin)

## FAQ

Q: What does "whitelisted" mean in the UI?

A: It means an episode is eligible for download and ad removal. By default, new episodes are automatically whitelisted (`automatically_whitelist_new_episodes`), and only a limited number of old episodes are auto-whitelisted (`number_of_episodes_to_whitelist_from_archive_of_new_feed`). Adjust these settings in the Config page (/config).

Q: How can I enable whisper GPU acceleration?

A: There are two ways to enable GPU acceleration:

1. **Using Docker**:

   - Use the provided Docker setup with `run_podly_docker.sh` which automatically detects and uses NVIDIA GPUs if available
   - You can force GPU mode with `./run_podly_docker.sh --gpu` or force CPU mode with `./run_podly_docker.sh --cpu`

2. **In a local environment**:
   - Install the CUDA version of PyTorch to your virtual environment:
   ```bash
   pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
   ```

## Contributing

We welcome contributions to Podly! Here's how you can help:

### Development Setup

1. Fork the repository
2. Clone your fork:
   ```bash
   git clone https://github.com/yourusername/podly.git
   ```
3. Create a new branch for your feature:
   ```bash
   git checkout -b feature/your-feature-name
   ```
4. Create a pull request with a target branch of Preview

#### Application Ports

Both local and Docker deployments provide a consistent experience:

- **Application**: Runs on port 5001 (configurable via web UI at `/config`)
  - Serves both the web interface and API endpoints
  - Frontend is built as static assets and served by the backend
- **Development**: `run_podly_docker.sh` serves everything on port 5001
  - Local script builds frontend to static assets (like Docker)
  - Restart `./run_podly_docker.sh` after frontend changes to rebuild assets

#### Development Modes

Both scripts provide equivalent core functionality with some unique features:

**Common Options (work in both scripts)**:

- `-b/--background` or `-d/--detach`: Run in background mode
- `-h/--help`: Show help information

**Local Development**

**Docker Development** (`./run_podly_docker.sh`):

- **Development mode**: `./run_podly_docker.sh --dev` - rebuilds containers with code changes
- **Production mode**: `./run_podly_docker.sh --production` - uses pre-built images
- **Docker-specific options**: `--build`, `--test-build`, `--gpu`, `--cpu`, `--cuda=VERSION`, `--rocm=VERSION`, `--branch=BRANCH`

**Functional Equivalence**:
Both scripts provide the same core user experience:

- Application runs on port 5001 (configurable)
- Frontend served as static assets by Flask backend
- Same web interface and API endpoints
- Compatible background/detached modes

### Running Tests

Before submitting a pull request, you can run the same tests that run in CI:

To prep your pipenv environment to run this script, you will need to first run:

```bash
pipenv install --dev
```

Then, to run the checks,

```bash
scripts/ci.sh
```

This will run all the necessary checks including:

- Type checking with mypy
- Code formatting checks
- Unit tests
- Linting

### Pull Request Process

1. Ensure all tests pass locally
2. Update the documentation if needed
3. Create a Pull Request with a clear description of the changes
4. Link any related issues

### Code Style

- We use black for code formatting
- Type hints are required for all new code
- Follow existing patterns in the codebase


================================================
FILE: docs/how_to_run_beginners.md
================================================
# How To Run: Ultimate Beginner's Guide

This guide will walk you through setting up Podly from scratch using Docker. Podly creates ad-free RSS feeds for podcasts by automatically detecting and removing advertisement segments.

## Highly Recommend!

Want an expert to guide you through the setup? Download an AI powered IDE like cursor https://www.cursor.com/ or windsurf https://windsurf.com/

Most IDEs have a free tier you can use to get started. Alternatively, you can use your own [LLM API key in Cursor](https://docs.cursor.com/settings/api-keys) (you'll need a key for Podly anyways).

Open the AI chat in the IDE. Enable 'Agent' mode if available, which will allow the IDE to help you run commands, view the output, and debug or take corrective steps if necessary.

Paste one of the prompts below into the chat box.

If you don't have the repo downloaded:

```
Help me install docker and run Podly https://github.com/podly-pure-podcasts/podly_pure_podcasts
After the project is cloned, help me:
- install docker & docker compose
- run `./run_podly_docker.sh --build` then `./run_podly_docker.sh -d`
- configure the app via the web UI at http://localhost:5001/config
Be sure to check if a dependency is already installed before downloading.
We recommend Docker because installing ffmpeg & local whisper can be difficult.
The Docker image has both ffmpeg & local whisper preconfigured.
Podly works with many different LLMs, it does not require an OpenAI key.
Check your work by retrieving the index page from localhost:5001 at the end.
```

If you do have the repo pulled, open this file and prompt:

```
Review this project, follow this guide and start Podly on my computer.
Briefly, help me:
- install docker & docker compose
- run `./run_podly_docker.sh --build` and then `./run_podly_docker.sh -d`
- configure the app via the web UI at http://localhost:5001/config
Be sure to check if a dependency is already installed before downloading.
We recommend docker because installing ffmpeg & local whisper can be difficult.
The docker image has both ffmpeg & local whisper preconfigured.
Podly works with many different LLMs; it does not need to work with OpenAI.
Check your work by retrieving the index page from localhost:5001 at the end.
```

## Prerequisites

### Install Docker and Docker Compose

#### On Windows:

1. Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/)
2. During installation, make sure "Use WSL 2 instead of Hyper-V" is checked
3. Restart your computer when prompted
4. Open Docker Desktop and wait for it to start completely

#### On macOS:

1. Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/)
2. Drag Docker to your Applications folder
3. Launch Docker Desktop from Applications
4. Follow the setup assistant

#### On Linux (Ubuntu/Debian):

```bash
# Update package index
sudo apt update

# Install Docker
sudo apt install docker.io docker-compose-v2

# Add your user to the docker group
sudo usermod -aG docker $USER

# Log out and log back in for group changes to take effect
```

#### Verify Installation:

Open a terminal/command prompt and run:

```bash
docker --version
docker compose version
```

You should see version information for both commands.

### 2. Get an OpenAI API Key

1. Go to [OpenAI's API platform](https://platform.openai.com/)
2. Sign up for an account or log in if you already have one
3. Navigate to the [API Keys section](https://platform.openai.com/api-keys)
4. Click "Create new secret key"
5. Give it a name (e.g., "Podly")
6. **Important**: Copy the key immediately and save it somewhere safe - you won't be able to see it again!
7. Your API key will look something like: `sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`

> **Note**: OpenAI API usage requires payment. Make sure to set up billing and usage limits in your OpenAI account to avoid unexpected charges.

## Setup Podly

### Download the Project

```bash
git clone https://github.com/normand1/podly_pure_podcasts.git
cd podly_pure_podcasts
```

## Running Podly

### Run the Application via Docker

```bash
chmod +x run_podly_docker.sh
./run_podly_docker.sh --build
./run_podly_docker.sh            # foreground
./run_podly_docker.sh -d         # detached
```

### Optional: Enable Authentication

The Docker image reads environment variables from `.env` files or your shell. To require login:

1. Export the variables before running Podly, or add them to `config/.env`:

```bash
export REQUIRE_AUTH=true
export PODLY_ADMIN_USERNAME='podly_admin'
export PODLY_ADMIN_PASSWORD='SuperSecurePass!2024'
export PODLY_SECRET_KEY='replace-with-a-strong-64-char-secret'
```

2. Start Podly as usual. On first boot with auth enabled and an empty database, the admin account is created automatically. If you are turning auth on for an existing volume, clear the `sqlite3.db` file so the bootstrap can succeed.

3. Sign in at `http://localhost:5001`, then visit the Config page to change your password, add users, and copy RSS URLs with the "Copy protected feed" button. Podly generates feed-specific access tokens and embeds them in the link so podcast players can subscribe without exposing your main password. Remember to update your environment variables whenever you rotate the admin password.

### First Run

1. Docker will download and build the necessary image (this may take 5-15 minutes)
2. Look for "Running on http://0.0.0.0:5001"
3. Open your browser to `http://localhost:5001`
4. Configure settings at `http://localhost:5001/config`
   - Alternatively, set secrets via Docker env file `.env.local` in the project root and restart the container. See .env.local.example

## Advanced Options

```bash
# Force CPU-only processing (if you have GPU issues)
./run_podly_docker.sh --cpu

# Force GPU processing
./run_podly_docker.sh --gpu

# Just build the container without running
./run_podly_docker.sh --build

# Test build from scratch (useful for troubleshooting)
./run_podly_docker.sh --test-build
```

## Using Podly

### Adding Your First Podcast

1. In the web interface, look for an "Add Podcast" or similar button
2. Paste the RSS feed URL of your podcast
3. Podly will start processing new episodes automatically
4. Processed episodes will have advertisements removed

### Getting Your Ad-Free RSS Feed

1. After adding a podcast, Podly will generate a new RSS feed URL
2. Use this new URL in your podcast app instead of the original
3. Your podcast app will now download ad-free versions!

## Troubleshooting

### "Docker command not found"

- Make sure Docker Desktop is running
- On Windows, restart your terminal after installing Docker
- On Linux, make sure you logged out and back in after adding yourself to the docker group

### Cannot connect to the Docker daemon. Is the docker daemon running?

- If using docker desktop, open up the app, otherwise start the daemon

### "Permission denied" errors

- On macOS/Linux, make sure the script is executable: `chmod +x run_podly_docker.sh`
- On Windows, try running Command Prompt as Administrator

### OpenAI API errors

- Double-check your API key in the Config page at `/config`
- Make sure you have billing set up in your OpenAI account
- Check your usage limits haven't been exceeded

### Port 5001 already in use

- Another application is using port 5001
- **Docker users**: Either stop that application or modify the port in `compose.dev.cpu.yml` and `compose.yml`
- **Native users**: Change the port in the Config page under App settings
- To kill processes on that port run `lsof -i :5001 | grep LISTEN | awk '{print $2}' | xargs kill -9`

### Out of memory errors

- Close other applications to free up RAM
- Consider using `--cpu` flag if you have limited memory

## Stopping Podly

To stop the application:

If you have launched it in the foreground by omitting the `-d` parameter:
1. In the terminal where Podly is running, press `Ctrl+C`
2. Wait for the container to stop gracefully

If you have launched it in the background using the `-d` parameter:
1. In the terminal where Podly is running, execute `docker compose down`
2. Wait for the container to stop gracefully

In both cases this output should appear to indicate that it has stopped:

```sh
[+] Running 2/2
 ✔ Container podly-pure-podcasts        Removed
 ✔ Network podly-pure-podcasts-network  Removed
```

## Upgrading Podly

To upgrade the application while you are in the terminal where it is running:
1. [Stop it](#stopping-podly)
2. Execute `git pull`
3. [Run it again](#running-podly)

## Getting Help

If you encounter issues ask in our discord, we're friendly!

https://discord.gg/FRB98GtF6N

## What's Next?

Once you have Podly running:

- Explore the web interface to add more podcasts
- Configure settings in the Config page
- Consider setting up automatic background processing
- Enjoy your ad-free podcasts!


================================================
FILE: docs/how_to_run_railway.md
================================================
# How to Run on Railway

This guide will walk you through deploying Podly on Railway using the one-click template.

## 0. Important! Set Budgets

Both Railway and Groq allow you to set budgets on your processing. Set a $10 (minimum possible, expect smaller bill) budget on Railway. Set a $5 budget for Groq.

## 1. Get Free Groq API Key

Podly uses Groq to transcribe podcasts quickly and for free.

1.  Go to [https://console.groq.com/keys](https://console.groq.com/keys).
2.  Sign up for a free account.
3.  Create a new API key.
4.  Copy the key and paste it into the `GROQ_API_KEY` field during the Railway deployment.

## 2. Deploy Railway Template

Click the button below to deploy Podly to Railway. This is a sponsored link that supports the project!

[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/podly?referralCode=NMdeg5&utm_medium=integration&utm_source=template&utm_campaign=generic)

If you want to be a beta-tester, you can deploy the preview branch instead:

[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/podly-preview?referralCode=NMdeg5&utm_medium=integration&utm_source=template&utm_campaign=generic)

## 3. Configure Networking

After the deployment is complete, you need to expose the service to the internet.

1.  Click on the new deployment in your Railway dashboard.
2.  Go to the **Settings** tab.
3.  Under **Networking**, find the **Public Networking** section and click **Generate Domain**.
4.  You can now access Podly at the generated URL.
5.  (Optional) To change the domain name, click **Edit** and enter a new name.

![Setting up Railway Networking](images/setting_up_railway_networking.png)

## 4. Set Budgets & Expected Pricing

Set a $10 budget on Railway and a $5 budget on Groq (or use the free tier for Groq which will slow processing).

Podly is designed to run efficiently on Railway's hobby plan.

If you process a large volume of podcasts, you can check the **Config** page in your Podly deployment for estimated monthly costs based on your usage.

## 5. Secure Your Deployment

Podly now uses secure session cookies for the web dashboard while keeping HTTP Basic authentication for RSS feeds and audio downloads. Before inviting listeners, secure the app:

1. In the Railway dashboard, open your Podly service and head to **Variables**.
2. Add `REQUIRE_AUTH` with value `true`.
3. Add a strong `PODLY_ADMIN_PASSWORD` (minimum 12 characters including uppercase, lowercase, digit, and symbol). Optionally set `PODLY_ADMIN_USERNAME`.
4. Provide a long, random `PODLY_SECRET_KEY` so session cookies survive restarts. (If you omit it, Podly will generate a new key each deploy and sign everyone out.)
5. Redeploy the service. On first boot Podly seeds the admin user and requires those credentials on every request.

> **Important:** Enabling auth on an existing deployment requires a fresh data volume. Create a new Railway deployment or wipe the existing storage so the initial admin can be seeded.

After signing in, use the Config page to change your password, add additional users, and copy RSS links via the "Copy protected feed" button. Podly issues feed-specific access tokens and embeds them in each URL so listeners can subscribe without knowing your main password. When you rotate passwords, update the corresponding Railway variables so restarts succeed.

## 6. Using Podly

1.  Open your new Podly URL in a browser.
2.  Navigate to the **Feeds** page.
3.  Add the RSS feed URL of a podcast you want to process.
4.  Go to your favorite podcast client and subscribe to the new feed URL provided by Podly (e.g., `https://your-podly-app.up.railway.app/feed/1`).
5.  Download and enjoy ad-free episodes!


================================================
FILE: docs/todo.txt
================================================
- config audit & testing (advanced and basic)
- move host/port/threads to docker config
reaudit security + testing
ci.sh
test railway
login for public facing
podcast rss search

'basic' config page - just put in groq api key + test + save on populate
also show if api key is set or blank

test hide 'local' whisper in lite build

================================================
FILE: frontend/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?


================================================
FILE: frontend/README.md
================================================
# Podly Frontend

This is the React + TypeScript + Vite frontend for Podly. The frontend is built and served as part of the main Podly application.

## Development

The frontend is integrated into the main Podly application and served as static assets by the Flask backend on port 5001.

### Development Workflows

1. **Docker (recommended)**: The Docker build compiles the frontend during image creation and serves static assets from Flask.

2. **Direct Frontend Development**: You can run the frontend development server separately for advanced frontend work:

   ```bash
   cd frontend
   npm install
   npm run dev
   ```

   This starts the Vite development server on port 5173 with hot reloading and proxies API calls to the backend on port 5001.

### Build Process

- **Direct Development** (`npm run dev`): Vite dev server serves files with hot reloading on port 5173 and proxies API calls to backend on port 5001
- **Docker**: Multi-stage build compiles frontend assets during image creation and copies them to the Flask static directory

## Technology Stack

- **React 18+** with TypeScript
- **Vite** for build tooling and development server
- **Tailwind CSS** for styling
- **React Router** for client-side routing
- **Tanstack Query** for data fetching

## Configuration

The frontend configuration is handled through:

- **Environment Variables**: Set via Vite's environment variable system
- **Vite Config**: `vite.config.ts` for build and development settings
  - Development server runs on port 5173
  - Proxies API calls to backend on port 5001 (configurable via `BACKEND_TARGET`)
- **Tailwind Config**: `tailwind.config.js` for styling configuration


================================================
FILE: frontend/eslint.config.js
================================================
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': [
        'warn',
        { allowConstantExport: true },
      ],
    },
  },
)


================================================
FILE: frontend/index.html
================================================
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Podly</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>


================================================
FILE: frontend/package.json
================================================
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@tailwindcss/line-clamp": "^0.4.4",
    "@tanstack/react-query": "^5.77.0",
    "axios": "^1.9.0",
    "clsx": "^2.1.1",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "react-hot-toast": "^2.6.0",
    "react-router-dom": "^7.6.1",
    "tailwind-merge": "^3.3.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.25.0",
    "@types/react": "^19.1.2",
    "@types/react-dom": "^19.1.2",
    "@vitejs/plugin-react": "^4.4.1",
    "autoprefixer": "^10.4.21",
    "eslint": "^9.25.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "postcss": "^8.5.3",
    "tailwindcss": "^3.4.17",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.30.1",
    "vite": "^6.3.5"
  }
}


================================================
FILE: frontend/postcss.config.js
================================================
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
} 

================================================
FILE: frontend/src/App.css
================================================
html, body {
  margin: 0 !important;
  padding: 0 !important;
  height: 100% !important;
  overflow: hidden !important;
}

#root {
  height: 100vh !important;
  overflow: hidden !important;
  max-width: none !important;
  margin: 0 !important;
  padding: 0 !important;
}

.logo {
  height: 6em;
  padding: 1.5em;
  will-change: filter;
  transition: filter 300ms;
}
.logo:hover {
  filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
  filter: drop-shadow(0 0 2em #61dafbaa);
}

@keyframes logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: no-preference) {
  .logo {
    animation: logo-spin infinite 20s linear;
  }
}

.card {
  padding: 2em;
}

.read-the-docs {
  color: #888;
}

/* Audio Player Styles */
.audio-player-progress {
  transition: all 0.1s ease;
}

.audio-player-progress:hover {
  height: 6px;
}

.audio-player-progress-thumb {
  transition: all 0.2s ease;
  transform: scale(0);
}

.audio-player-progress:hover .audio-player-progress-thumb {
  transform: scale(1);
}

.audio-player-volume-slider {
  transition: all 0.2s ease;
}

/* Custom scrollbar for better UX */
::-webkit-scrollbar {
  width: 6px;
}

::-webkit-scrollbar-track {
  background: #f1f1f1;
}

::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}


================================================
FILE: frontend/src/App.tsx
================================================
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation } from 'react-router-dom';
import { AudioPlayerProvider } from './contexts/AudioPlayerContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect, useRef } from 'react';
import HomePage from './pages/HomePage';
import JobsPage from './pages/JobsPage';
import ConfigPage from './pages/ConfigPage';
import LoginPage from './pages/LoginPage';
import LandingPage from './pages/LandingPage';
import BillingPage from './pages/BillingPage';
import AudioPlayer from './components/AudioPlayer';
import { billingApi } from './services/api';
import { DiagnosticsProvider, useDiagnostics } from './contexts/DiagnosticsContext';
import DiagnosticsModal from './components/DiagnosticsModal';
import './App.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 0,
      gcTime: 0,
      refetchOnMount: 'always',
      refetchOnWindowFocus: 'always',
      refetchOnReconnect: 'always',
    },
  },
});

function AppShell() {
  const { status, requireAuth, isAuthenticated, user, logout, landingPageEnabled } = useAuth();
  const { open: openDiagnostics } = useDiagnostics();
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
  const mobileMenuRef = useRef<HTMLDivElement>(null);
  const location = useLocation();
  const { data: billingSummary } = useQuery({
    queryKey: ['billing', 'summary'],
    queryFn: billingApi.getSummary,
    enabled: !!user && requireAuth && isAuthenticated,
    retry: false,
  });

  // Close mobile menu on route change
  useEffect(() => {
    setMobileMenuOpen(false);
  }, [location.pathname]);

  // Close mobile menu when clicking outside
  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) {
        setMobileMenuOpen(false);
      }
    }
    if (mobileMenuOpen) {
      document.addEventListener('mousedown', handleClickOutside);
      return () => document.removeEventListener('mousedown', handleClickOutside);
    }
  }, [mobileMenuOpen]);

  if (status === 'loading') {
    return (
      <div className="h-screen flex items-center justify-center bg-gray-50">
        <div className="flex flex-col items-center gap-4">
          <div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600" />
          <p className="text-sm text-gray-600">Loading authentication…</p>
        </div>
      </div>
    );
  }

  // Show landing page for unauthenticated users when auth is required
  // But allow access to /login route
  if (requireAuth && !isAuthenticated) {
    return (
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        {landingPageEnabled ? (
          <Route path="*" element={<LandingPage />} />
        ) : (
          <>
            <Route path="/" element={<Navigate to="/login" replace />} />
            <Route path="*" element={<Navigate to="/login" replace />} />
          </>
        )}
      </Routes>
    );
  }

  const isAdmin = !requireAuth || user?.role === 'admin';
  const showConfigLink = !requireAuth || isAdmin;
  const showJobsLink = !requireAuth || isAdmin;
  const showBillingLink = requireAuth && !isAdmin;

  return (
    <div className="h-screen bg-gray-50 flex flex-col overflow-hidden">
      <header className="bg-white shadow-sm border-b flex-shrink-0">
        <div className="px-2 sm:px-4 lg:px-6">
          <div className="flex items-center justify-between h-12">
            <div className="flex items-center">
              <Link to="/" className="flex items-center">
                <img 
                  src="/images/logos/logo.webp" 
                  alt="Podly" 
                  className="h-6 w-auto"
                />
                <h1 className="ml-2 text-lg font-semibold text-gray-900">
                  Podly
                </h1>
              </Link>
            </div>

            {/* Desktop Navigation */}
            <nav className="hidden md:flex items-center space-x-4">
              <Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900">
                Home
              </Link>
              {showBillingLink && (
                <Link to="/billing" className="text-sm font-medium text-gray-700 hover:text-gray-900">
                  Billing
                </Link>
              )}
              {showJobsLink && (
                <Link to="/jobs" className="text-sm font-medium text-gray-700 hover:text-gray-900">
                  Jobs
                </Link>
              )}
              {showConfigLink && (
                <Link to="/config" className="text-sm font-medium text-gray-700 hover:text-gray-900">
                  Config
                </Link>
              )}
              <button
                type="button"
                onClick={() => openDiagnostics()}
                className="text-sm font-medium text-gray-700 hover:text-gray-900"
              >
                Report issue
              </button>
              {requireAuth && user && (
                <div className="flex items-center gap-3 text-sm text-gray-600 flex-shrink-0">
                  {billingSummary && !isAdmin && (
                    <>
                      <div
                        className="px-2 py-1 rounded-md border border-blue-200 text-blue-700 bg-blue-50 text-xs whitespace-nowrap"
                        title="Feeds included in your plan"
                      >
                        Feeds {billingSummary.feeds_in_use}/{billingSummary.feed_allowance}
                      </div>
                      <Link
                        to="/billing"
                        className="px-2 py-1 rounded-md border border-blue-200 text-blue-700 bg-white hover:bg-blue-50 text-xs whitespace-nowrap transition-colors"
                      >
                        Change plan
                      </Link>
                    </>
                  )}
                  <span className="hidden sm:inline whitespace-nowrap">{user.username}</span>
                  <button
                    onClick={logout}
                    className="px-3 py-1 border border-gray-200 rounded-md hover:bg-gray-100 transition-colors whitespace-nowrap"
                  >
                    Logout
                  </button>
                </div>
              )}
            </nav>

            {/* Mobile: Credits + Hamburger */}
            <div className="md:hidden flex items-center gap-2">
              {requireAuth && user && billingSummary && !isAdmin && (
                <>
                  <div
                    className="px-2 py-1 rounded-md border border-blue-200 text-blue-700 bg-blue-50 text-xs whitespace-nowrap"
                    title="Feeds included in your plan"
                  >
                    Feeds {billingSummary.feeds_in_use}/{billingSummary.feed_allowance}
                  </div>
                  <Link
                    to="/billing"
                    className="px-2 py-1 rounded-md border border-blue-200 text-blue-700 bg-white text-xs whitespace-nowrap"
                  >
                    Change plan
                  </Link>
                </>
              )}

              {/* Hamburger Button */}
              <div className="relative" ref={mobileMenuRef}>
                <button
                  onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
                  className="p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
                  aria-label="Toggle menu"
                >
                  {mobileMenuOpen ? (
                    <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                    </svg>
                  ) : (
                    <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
                    </svg>
                  )}
                </button>

                {/* Mobile Menu Dropdown */}
                {mobileMenuOpen && (
                  <div className="absolute right-0 top-full mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
                    <Link
                      to="/"
                      className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                    >
                      Home
                    </Link>
                    {showBillingLink && (
                      <Link
                        to="/billing"
                        className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                      >
                        Billing
                      </Link>
                    )}
                    {showJobsLink && (
                      <Link
                        to="/jobs"
                        className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                      >
                        Jobs
                      </Link>
                    )}
                    {showConfigLink && (
                      <Link
                        to="/config"
                        className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                      >
                        Config
                      </Link>
                    )}
                    <button
                      type="button"
                      onClick={() => {
                        openDiagnostics();
                        setMobileMenuOpen(false);
                      }}
                      className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                    >
                      Report issue
                    </button>
                    {requireAuth && user && (
                      <>
                        <div className="border-t border-gray-100 my-2" />
                        <div className="px-4 py-2 text-sm text-gray-500">
                          {user.username}
                        </div>
                        <button
                          onClick={() => {
                            logout();
                            setMobileMenuOpen(false);
                          }}
                          className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                        >
                          Logout
                        </button>
                      </>
                    )}
                  </div>
                )}
              </div>
            </div>
          </div>
        </div>
      </header>

      <main className="flex-1 px-2 sm:px-4 lg:px-6 py-4 overflow-auto">
        <Routes>
          <Route path="/" element={<HomePage />} />
          {showBillingLink && <Route path="/billing" element={<BillingPage />} />}
          {showJobsLink && <Route path="/jobs" element={<JobsPage />} />}
          {showConfigLink && <Route path="/config" element={<ConfigPage />} />}
          <Route path="*" element={<Navigate to="/" replace />} />
        </Routes>
      </main>

      <AudioPlayer />
      <DiagnosticsModal />
      <Toaster position="top-center" toastOptions={{ duration: 3000 }} />
    </div>
  );
}

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <AudioPlayerProvider>
          <DiagnosticsProvider>
            <Router>
              <AppShell />
            </Router>
          </DiagnosticsProvider>
        </AudioPlayerProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}

export default App;


================================================
FILE: frontend/src/components/AddFeedForm.tsx
================================================
import { useState, useEffect, useCallback } from 'react';
import { feedsApi } from '../services/api';
import type { PodcastSearchResult } from '../types';
import { diagnostics, emitDiagnosticError } from '../utils/diagnostics';
import { getHttpErrorInfo } from '../utils/httpError';

interface AddFeedFormProps {
  onSuccess: () => void;
  onUpgradePlan?: () => void;
  planLimitReached?: boolean;
}

type AddMode = 'url' | 'search';

const PAGE_SIZE = 10;

export default function AddFeedForm({ onSuccess, onUpgradePlan, planLimitReached }: AddFeedFormProps) {
  const [url, setUrl] = useState('');
  const [activeMode, setActiveMode] = useState<AddMode>('search');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState('');
  const [addingFeedUrl, setAddingFeedUrl] = useState<string | null>(null);
  const [upgradePrompt, setUpgradePrompt] = useState<string | null>(null);

  const [searchTerm, setSearchTerm] = useState('');
  const [searchResults, setSearchResults] = useState<PodcastSearchResult[]>([]);
  const [searchError, setSearchError] = useState('');
  const [isSearching, setIsSearching] = useState(false);
  const [searchPage, setSearchPage] = useState(1);
  const [totalResults, setTotalResults] = useState(0);
  const [hasSearched, setHasSearched] = useState(false);

  const resetSearchState = () => {
    setSearchResults([]);
    setSearchError('');
    setSearchPage(1);
    setTotalResults(0);
    setHasSearched(false);
  };

  const handleSubmitManual = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!url.trim()) return;

    diagnostics.add('info', 'Add feed (manual) submitted', { via: 'url', hasUrl: true });
    setError('');
    await addFeed(url.trim(), 'url');
  };

  const addFeed = async (feedUrl: string, source: AddMode) => {
    if (planLimitReached) {
      setUpgradePrompt('Your plan is full. Increase your feed allowance to add more.');
      return;
    }
    setIsSubmitting(true);
    setAddingFeedUrl(source === 'url' ? 'manual' : feedUrl);
    setError('');
    setUpgradePrompt(null);

    try {
      diagnostics.add('info', 'Add feed request', { source, hasUrl: !!feedUrl });
      await feedsApi.addFeed(feedUrl);
      if (source === 'url') {
        setUrl('');
      }
      diagnostics.add('info', 'Add feed success', { source });
      onSuccess();
    } catch (err) {
      console.error('Failed to add feed:', err);
      const { status, data, message } = getHttpErrorInfo(err);
      const code = data && typeof data === 'object' ? (data as { error?: unknown }).error : undefined;
      const errorCode = typeof code === 'string' ? code : undefined;

      emitDiagnosticError({
        title: 'Failed to add feed',
        message,
        kind: status ? 'http' : 'network',
        details: {
          source,
          feedUrl,
          status,
          response: data,
        },
      });

      if (errorCode === 'FEED_LIMIT_REACHED') {
        setUpgradePrompt(message || 'Plan limit reached. Increase your feeds to add more.');
      } else {
        setError(message || 'Failed to add feed. Please check the URL and try again.');
      }
    } finally {
      setIsSubmitting(false);
      setAddingFeedUrl(null);
    }
  };

  const performSearch = useCallback(async (term: string) => {
    if (!term.trim()) {
      setSearchResults([]);
      setTotalResults(0);
      setHasSearched(false);
      setSearchError('');
      return;
    }

    setIsSearching(true);
    setSearchError('');

    try {
      diagnostics.add('info', 'Search podcasts request', { term: term.trim() });
      const response = await feedsApi.searchFeeds(term.trim());
      setSearchResults(response.results);
      setTotalResults(response.total ?? response.results.length);
      setSearchPage(1);
      setHasSearched(true);
      diagnostics.add('info', 'Search podcasts success', {
        term: term.trim(),
        total: response.total ?? response.results.length,
      });
    } catch (err) {
      console.error('Podcast search failed:', err);
      diagnostics.add('error', 'Search podcasts failed', { term: term.trim() });
      setSearchError('Failed to search podcasts. Please try again.');
      setSearchResults([]);
    } finally {
      setIsSearching(false);
    }
  }, []);

  useEffect(() => {
    const delayDebounceFn = setTimeout(() => {
      if (searchTerm.trim()) {
        performSearch(searchTerm);
      } else {
        setSearchResults([]);
        setTotalResults(0);
        setHasSearched(false);
      }
    }, 500);

    return () => clearTimeout(delayDebounceFn);
  }, [searchTerm, performSearch]);

  const handleSearchSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await performSearch(searchTerm);
  };

  const handleAddFromSearch = async (result: PodcastSearchResult) => {
    await addFeed(result.feedUrl, 'search');
  };

  const totalPages =
    totalResults === 0 ? 1 : Math.max(1, Math.ceil(totalResults / PAGE_SIZE));
  const startIndex =
    totalResults === 0 ? 0 : (searchPage - 1) * PAGE_SIZE + 1;
  const endIndex =
    totalResults === 0
      ? 0
      : Math.min(searchPage * PAGE_SIZE, totalResults);
  const displayedResults = searchResults.slice(
    (searchPage - 1) * PAGE_SIZE,
    (searchPage - 1) * PAGE_SIZE + PAGE_SIZE
  );

  return (
    <div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4 sm:p-6">
      <h3 className="text-lg font-medium text-gray-900 mb-4">Add New Podcast Feed</h3>
      {planLimitReached && (
        <div className="mb-3 text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
          Your plan is full. Increase your feed allowance to add more.
        </div>
      )}

      <div className="flex flex-col sm:flex-row gap-2 mb-4">
        <button
          type="button"
          onClick={() => {
            setActiveMode('url');
          }}
          className={`flex-1 px-3 py-2 rounded-md border transition-colors ${
            activeMode === 'url'
              ? 'bg-blue-50 border-blue-500 text-blue-700'
              : 'border-gray-200 text-gray-600 hover:bg-gray-100'
          }`}
        >
          Enter RSS URL
        </button>
        <button
          type="button"
          onClick={() => {
            setActiveMode('search');
            setError('');
            resetSearchState();
          }}
          className={`flex-1 px-3 py-2 rounded-md border ${
            activeMode === 'search'
              ? 'bg-blue-50 border-blue-500 text-blue-700'
              : 'border-gray-200 text-gray-600 hover:bg-gray-100'
          }`}
        >
          Search Podcasts
        </button>
      </div>

      {activeMode === 'url' && (
        <form onSubmit={handleSubmitManual} className="space-y-4">
          <div>
            <label htmlFor="feed-url" className="block text-sm font-medium text-gray-700 mb-1">
              RSS Feed URL
            </label>
            <input
              type="url"
              id="feed-url"
              value={url}
              onChange={(e) => setUrl(e.target.value)}
              placeholder="https://example.com/podcast/feed.xml"
              className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              required
              disabled={!!planLimitReached}
            />
          </div>

      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}
      {upgradePrompt && (
        <div className="flex flex-col sm:flex-row sm:items-center gap-2 p-3 border border-amber-200 bg-amber-50 rounded-md text-sm text-amber-800">
          <span>{upgradePrompt}</span>
          {onUpgradePlan && (
            <button
              type="button"
              onClick={onUpgradePlan}
              className="inline-flex items-center justify-center px-3 py-2 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700"
            >
              Increase plan
            </button>
          )}
        </div>
      )}

        <div className="flex flex-col sm:flex-row sm:justify-end gap-3">
          <button
            type="submit"
            disabled={isSubmitting || !url.trim() || !!planLimitReached}
            className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium transition-colors sm:w-auto w-full"
          >
            {isSubmitting && addingFeedUrl === 'manual' ? 'Adding...' : 'Add Feed'}
          </button>
        </div>
        </form>
      )}

      {activeMode === 'search' && (
        <div className="space-y-4">
          <form onSubmit={handleSearchSubmit} className="flex flex-col md:flex-row gap-3">
            <div className="flex-1">
              <label htmlFor="search-term" className="block text-sm font-medium text-gray-700 mb-1">
                Search keyword
              </label>
              <input
                type="text"
                id="search-term"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
                placeholder="e.g. history, space, entrepreneurship"
                className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                disabled={!!planLimitReached}
              />
            </div>

            <div className="flex items-end">
              <button
                type="submit"
                disabled={isSearching || !!planLimitReached}
                className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium transition-colors w-full md:w-auto"
              >
                {isSearching ? 'Searching...' : 'Search'}
              </button>
            </div>
          </form>

          {searchError && (
            <div className="text-red-600 text-sm">{searchError}</div>
          )}

          {isSearching && searchResults.length === 0 && (
            <div className="text-sm text-gray-600">Searching for podcasts...</div>
          )}

          {!isSearching && searchResults.length === 0 && totalResults === 0 && hasSearched && !searchError && (
            <div className="text-sm text-gray-600">No podcasts found. Try a different search term.</div>
          )}

          {searchResults.length > 0 && (
            <div className="space-y-3">
              <div className="flex justify-between items-center text-sm text-gray-500">
                <span>
                  Showing {startIndex}-{endIndex} of {totalResults} results
                </span>
                <div className="flex gap-2">
                  <button
                    type="button"
                    onClick={() =>
                      setSearchPage((prev) => Math.max(prev - 1, 1))
                    }
                    disabled={isSearching || searchPage <= 1}
                    className="px-3 py-1 border border-gray-200 rounded-md disabled:text-gray-400 disabled:border-gray-200 hover:bg-gray-100 transition-colors"
                  >
                    Previous
                  </button>
                  <button
                    type="button"
                    onClick={() =>
                      setSearchPage((prev) => Math.min(prev + 1, totalPages))
                    }
                    disabled={isSearching || searchPage >= totalPages}
                    className="px-3 py-1 border border-gray-200 rounded-md disabled:text-gray-400 disabled:border-gray-200 hover:bg-gray-100 transition-colors"
                  >
                    Next
                  </button>
                </div>
              </div>

              <ul className="space-y-3 max-h-[45vh] sm:max-h-80 overflow-y-auto pr-2">
                {displayedResults.map((result) => (
                  <li
                    key={result.feedUrl}
                    className="flex gap-3 p-3 border border-gray-200 rounded-md bg-gray-50"
                  >
                    {result.artworkUrl ? (
                      <img
                        src={result.artworkUrl}
                        alt={result.title}
                        className="w-16 h-16 rounded-md object-cover"
                      />
                    ) : (
                      <div className="w-16 h-16 rounded-md bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
                        No Image
                      </div>
                    )}
                    <div className="flex-1">
                      <h4 className="font-medium text-gray-900">{result.title}</h4>
                      {result.author && (
                        <p className="text-sm text-gray-600">{result.author}</p>
                      )}
                      {result.genres.length > 0 && (
                        <p className="text-xs text-gray-500 mt-1">
                          {result.genres.join(' · ')}
                        </p>
                      )}
                      <p className="text-xs text-gray-500 break-all mt-2">{result.feedUrl}</p>
                    </div>
                    <div className="flex items-center">
                      <button
                        type="button"
                        onClick={() => handleAddFromSearch(result)}
                        disabled={planLimitReached || (isSubmitting && addingFeedUrl === result.feedUrl)}
                        className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-3 py-2 rounded-md text-sm transition-colors"
                      >
                        {isSubmitting && addingFeedUrl === result.feedUrl ? 'Adding...' : 'Add'}
                      </button>
                    </div>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  );
}


================================================
FILE: frontend/src/components/AudioPlayer.tsx
================================================
import React, { useState, useRef, useEffect } from 'react';
import { useAudioPlayer } from '../contexts/AudioPlayerContext';

// Simple SVG icons to replace Heroicons
const PlayIcon = ({ className }: { className: string }) => (
  <svg className={className} fill="currentColor" viewBox="0 0 24 24">
    <path d="M8 5v14l11-7z"/>
  </svg>
);

const PauseIcon = ({ className }: { className: string }) => (
  <svg className={className} fill="currentColor" viewBox="0 0 24 24">
    <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
  </svg>
);

const SpeakerWaveIcon = ({ className }: { className: string }) => (
  <svg className={className} fill="currentColor" 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>
);

const SpeakerXMarkIcon = ({ className }: { className: string }) => (
  <svg className={className} fill="currentColor" 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>
);

const XMarkIcon = ({ className }: { className: string }) => (
  <svg className={className} fill="currentColor" viewBox="0 0 24 24">
    <path d="M6 18L18 6M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
  </svg>
);

export default function AudioPlayer() {
  const {
    currentEpisode,
    isPlaying,
    currentTime,
    duration,
    volume,
    isLoading,
    error,
    togglePlayPause,
    seekTo,
    setVolume
  } = useAudioPlayer();

  const [isDragging, setIsDragging] = useState(false);
  const [dragTime, setDragTime] = useState(0);
  const [showVolumeSlider, setShowVolumeSlider] = useState(false);
  const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
  const [dismissedError, setDismissedError] = useState<string | null>(null);
  const progressBarRef = useRef<HTMLDivElement>(null);
  const volumeSliderRef = useRef<HTMLDivElement>(null);

  // Reset dismissed error when a new error occurs
  useEffect(() => {
    if (error && error !== dismissedError) {
      setDismissedError(null);
    }
  }, [error, dismissedError]);

  // Close volume slider when clicking outside
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (volumeSliderRef.current && !volumeSliderRef.current.contains(event.target as Node)) {
        setShowVolumeSlider(false);
      }
    };

    if (showVolumeSlider) {
      document.addEventListener('mousedown', handleClickOutside);
      return () => document.removeEventListener('mousedown', handleClickOutside);
    }
  }, [showVolumeSlider]);

  // Don't render if no episode is loaded
  if (!currentEpisode) {
    return null;
  }

  console.log('AudioPlayer rendering with:', {
    currentEpisode: currentEpisode?.title,
    isPlaying,
    isLoading,
    error,
    duration
  });

  const formatTime = (seconds: number) => {
    if (isNaN(seconds)) return '0:00';
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const remainingSeconds = Math.floor(seconds % 60);
    
    if (hours > 0) {
      return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
    }
    return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
  };

  const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!progressBarRef.current || !duration) return;
    
    const rect = progressBarRef.current.getBoundingClientRect();
    const clickX = e.clientX - rect.left;
    const newTime = (clickX / rect.width) * duration;
    seekTo(newTime);
  };

  const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
    setIsDragging(true);
    handleProgressClick(e);
  };

  const handleProgressMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!isDragging || !progressBarRef.current || !duration) return;
    
    const rect = progressBarRef.current.getBoundingClientRect();
    const clickX = e.clientX - rect.left;
    const newTime = Math.max(0, Math.min((clickX / rect.width) * duration, duration));
    setDragTime(newTime);
  };

  const handleProgressMouseUp = () => {
    if (isDragging) {
      seekTo(dragTime);
      setIsDragging(false);
    }
  };

  const handleVolumeChange = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!volumeSliderRef.current) return;
    
    const rect = volumeSliderRef.current.getBoundingClientRect();
    const clickX = e.clientX - rect.left;
    const newVolume = Math.max(0, Math.min(clickX / rect.width, 1));
    setVolume(newVolume);
  };

  const toggleMute = () => {
    setVolume(volume > 0 ? 0 : 1);
  };

  const dismissError = () => {
    setDismissedError(error);
  };

  const displayTime = isDragging ? dragTime : currentTime;
  const progressPercentage = duration > 0 ? (displayTime / duration) * 100 : 0;
  const shouldShowError = error && error !== dismissedError;

  return (
    <div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-50">
      <div className="max-w-7xl mx-auto px-4 py-3">
        {shouldShowError && (
          <div className="mb-2 p-2 bg-red-100 border border-red-300 rounded text-red-700 text-sm flex items-center justify-between">
            <span>{error}</span>
            <button
              onClick={dismissError}
              className="ml-2 p-1 hover:bg-red-200 rounded transition-colors"
              aria-label="Dismiss error"
            >
              <XMarkIcon className="w-4 h-4" />
            </button>
          </div>
        )}
        
        <div className="flex items-center space-x-4">
          {/* Episode Info */}
          <div className="flex-1 min-w-0">
            <div className="flex items-center space-x-3">
              <div className="w-12 h-12 bg-gray-200 rounded flex-shrink-0 flex items-center justify-center">
                <span className="text-gray-500 text-xs">🎵</span>
              </div>
              <div className="min-w-0 flex-1">
                <h4 className="text-sm font-medium text-gray-900 truncate">
                  {currentEpisode.title}
                </h4>
                <p className="text-xs text-gray-500 truncate">
                  Episode • {formatTime(duration)}
                </p>
              </div>
            </div>
          </div>

          {/* Player Controls */}
          <div className="flex-1 max-w-2xl">
            {/* Control Buttons */}
            <div 
              className="flex items-center justify-center space-x-4 mb-2 relative"
              onMouseEnter={() => setShowKeyboardShortcuts(true)}
              onMouseLeave={() => setShowKeyboardShortcuts(false)}
            >
              <button
                onClick={togglePlayPause}
                disabled={isLoading}
                className="p-2 bg-gray-900 text-white rounded-full hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
              >
                {isLoading ? (
                  <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
                ) : isPlaying ? (
                  <PauseIcon className="w-6 h-6" />
                ) : (
                  <PlayIcon className="w-6 h-6" />
                )}
              </button>
              
              {/* Keyboard Shortcuts Tooltip */}
              {showKeyboardShortcuts && (
                <div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs rounded py-2 px-3 whitespace-nowrap z-10">
                  <div className="space-y-1">
                    <div>Space: Play/Pause</div>
                    <div>← →: Seek ±10s</div>
                    <div>↑ ↓: Volume ±10%</div>
                  </div>
                  <div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
                </div>
              )}
            </div>

            {/* Progress Bar */}
            <div className="flex items-center space-x-2 text-xs text-gray-500">
              <span className="w-10 text-right">{formatTime(displayTime)}</span>
              <div
                ref={progressBarRef}
                className="flex-1 h-1 bg-gray-200 rounded-full cursor-pointer relative group audio-player-progress"
                onMouseDown={handleProgressMouseDown}
                onMouseMove={handleProgressMouseMove}
                onMouseUp={handleProgressMouseUp}
                onMouseLeave={handleProgressMouseUp}
                onClick={handleProgressClick}
              >
                <div
                  className="h-full bg-gray-900 rounded-full relative"
                  style={{ width: `${progressPercentage}%` }}
                >
                  <div className="absolute right-0 top-1/2 transform -translate-y-1/2 w-3 h-3 bg-gray-900 rounded-full audio-player-progress-thumb" />
                </div>
              </div>
              <span className="w-10">{formatTime(duration)}</span>
            </div>
          </div>

          {/* Volume Control */}
          <div className="flex items-center space-x-2 relative">
            <button
              onClick={toggleMute}
              onMouseEnter={() => setShowVolumeSlider(true)}
              className="p-1 text-gray-600 hover:text-gray-900 transition-colors"
            >
              {volume === 0 ? (
                <SpeakerXMarkIcon className="w-5 h-5" />
              ) : (
                <SpeakerWaveIcon className="w-5 h-5" />
              )}
            </button>
            
            {showVolumeSlider && (
              <div
                ref={volumeSliderRef}
                className="absolute bottom-full right-0 mb-2 p-2 bg-white border border-gray-200 rounded shadow-lg audio-player-volume-slider"
                onMouseEnter={() => setShowVolumeSlider(true)}
              >
                <div
                  className="w-20 h-1 bg-gray-200 rounded-full cursor-pointer relative group"
                  onClick={handleVolumeChange}
                >
                  <div
                    className="h-full bg-gray-900 rounded-full relative"
                    style={{ width: `${volume * 100}%` }}
                  >
                    <div className="absolute right-0 top-1/2 transform -translate-y-1/2 w-3 h-3 bg-gray-900 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
                  </div>
                </div>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
} 

================================================
FILE: frontend/src/components/DiagnosticsModal.tsx
================================================
import { useEffect, useMemo, useState } from 'react';
import { useDiagnostics } from '../contexts/DiagnosticsContext';
import { DIAGNOSTIC_UPDATED_EVENT, diagnostics, type DiagnosticsEntry } from '../utils/diagnostics';

const GITHUB_NEW_ISSUE_URL = 'https://github.com/podly-pure-podcasts/podly_pure_podcasts/issues/new';

const buildIssueUrl = (title: string, body: string) => {
  const url = new URL(GITHUB_NEW_ISSUE_URL);
  url.searchParams.set('title', title);
  url.searchParams.set('body', body);
  return url.toString();
};

const formatTs = (ts: number) => {
  try {
    return new Date(ts).toISOString();
  } catch {
    return String(ts);
  }
};

export default function DiagnosticsModal() {
  const { isOpen, close, clear, getEntries, currentError } = useDiagnostics();
  const [entries, setEntries] = useState<DiagnosticsEntry[]>(() => getEntries());

  useEffect(() => {
    if (!isOpen) return;

    // Refresh immediately when opened
    setEntries(getEntries());

    const handler = () => {
      setEntries(getEntries());
    };

    window.addEventListener(DIAGNOSTIC_UPDATED_EVENT, handler);
    return () => window.removeEventListener(DIAGNOSTIC_UPDATED_EVENT, handler);
  }, [getEntries, isOpen]);

  const recentEntries = useMemo(() => entries.slice(-80), [entries]);

  const issueTitle = currentError?.title
    ? `[FE] ${currentError.title}`
    : '[FE] Troubleshooting info';

  const issueBody = useMemo(() => {
    const env = {
      userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : null,
      url: typeof window !== 'undefined' ? window.location.href : null,
      time: new Date().toISOString(),
    };

    const payload = {
      error: currentError,
      env,
      logs: recentEntries,
    };

    const json = JSON.stringify(diagnostics.sanitize(payload), null, 2);

    return [
      '## What happened',
      '(Describe what you clicked / expected / saw)',
      '',
      '## Diagnostics (auto-collected)',
      '```json',
      json,
      '```',
    ].join('\n');
  }, [currentError, recentEntries]);

  const issueUrl = useMemo(() => buildIssueUrl(issueTitle, issueBody), [issueTitle, issueBody]);

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
      <div className="absolute inset-0 bg-black/40" onClick={close} />

      <div className="relative w-full max-w-3xl bg-white rounded-xl border border-gray-200 shadow-lg overflow-hidden">
        <div className="flex items-start justify-between gap-4 px-5 py-4 border-b border-gray-200">
          <div>
            <h2 className="text-base font-semibold text-gray-900">Troubleshooting</h2>
            <p className="text-sm text-gray-600">
              {currentError
                ? 'An error occurred. You can report it with logs.'
                : 'Use this to collect logs for a bug report.'}
            </p>
          </div>
          <button
            type="button"
            onClick={close}
            className="px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-100"
          >
            Dismiss
          </button>
        </div>

        {currentError && (
          <div className="px-5 py-4 border-b border-gray-200 bg-red-50">
            <div className="text-sm font-medium text-red-900">{currentError.title}</div>
            <div className="text-sm text-red-800 mt-1">{currentError.message}</div>
          </div>
        )}

        <div className="px-5 py-4">
          <div className="flex flex-col sm:flex-row gap-2 sm:items-center sm:justify-between mb-3">
            <div className="text-sm text-gray-700">
              Showing last {recentEntries.length} log entries (session only).
            </div>
            <div className="flex gap-2">
              <a
                href={issueUrl}
                target="_blank"
                rel="noreferrer"
                className="inline-flex items-center justify-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
              >
                Report on GitHub
              </a>
              <button
                type="button"
                onClick={() => {
                  try {
                    navigator.clipboard.writeText(issueBody);
                  } catch {
                    // ignore
                  }
                }}
                className="inline-flex items-center justify-center px-3 py-2 rounded-md border border-gray-200 text-sm font-medium hover:bg-gray-100"
              >
                Copy logs
              </button>
              <button
                type="button"
                onClick={() => {
                  clear();
                }}
                className="inline-flex items-center justify-center px-3 py-2 rounded-md border border-gray-200 text-sm font-medium hover:bg-gray-100"
              >
                Clear
              </button>
            </div>
          </div>

          <div className="border border-gray-200 rounded-md bg-gray-50 overflow-hidden">
            <div className="max-h-[45vh] overflow-auto">
              <pre className="text-xs text-gray-800 p-3 whitespace-pre-wrap break-words">
{recentEntries
  .map((e) => {
    const base = `[${formatTs(e.ts)}] ${e.level.toUpperCase()}: ${e.message}`;
    if (e.data === undefined) return base;
    try {
      return base + `\n  ${JSON.stringify(e.data)}`;
    } catch {
      return base;
    }
  })
  .join('\n')}
              </pre>
            </div>
          </div>

          <div className="text-xs text-gray-500 mt-2">
            Sensitive fields like tokens/cookies are redacted.
          </div>
        </div>
      </div>
    </div>
  );
}


================================================
FILE: frontend/src/components/DownloadButton.tsx
================================================
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { feedsApi } from '../services/api';
import ReprocessButton from './ReprocessButton';
import { configApi } from '../services/api';
import { toast } from 'react-hot-toast';
import { useEpisodeStatus } from '../hooks/useEpisodeStatus';

interface DownloadButtonProps {
  episodeGuid: string;
  isWhitelisted: boolean;
  hasProcessedAudio: boolean;
  feedId?: number;
  canModifyEpisodes?: boolean;
  className?: string;
}

export default function DownloadButton({
  episodeGuid,
  isWhitelisted,
  hasProcessedAudio,
  feedId,
  canModifyEpisodes = true,
  className = ''
}: DownloadButtonProps) {
  const [error, setError] = useState<string | null>(null);
  const queryClient = useQueryClient();
  
  const { data: status } = useEpisodeStatus(episodeGuid, isWhitelisted, hasProcessedAudio, feedId);
  
  const isProcessing = status?.status === 'pending' || status?.status === 'running' || status?.status === 'starting';
  const isCompleted = hasProcessedAudio || status?.status === 'completed';
  const downloadUrl = status?.download_url || (hasProcessedAudio ? `/api/posts/${episodeGuid}/download` : undefined);

  const handleDownloadClick = async () => {
    if (!isWhitelisted) {
      setError('Post must be whitelisted before processing');
      return;
    }

    // Guard when LLM API key is not configured - use fresh server check
    try {
      const { configured } = await configApi.isConfigured();
      if (!configured) {
        toast.error('Add an API key in Config before processing.');
        return;
      }
    } catch (err) {
      if (!(axios.isAxiosError(err) && err.response?.status === 403)) {
        toast.error('Unable to verify configuration. Please try again.');
        return;
      }
    }

    if (isCompleted && downloadUrl) {
      // Already processed, download directly
      try {
        await feedsApi.downloadPost(episodeGuid);
      } catch (err) {
        console.error('Error downloading file:', err);
        setError('Failed to download file');
      }
      return;
    }

    try {
      setError(null);
      // Optimistically update status to show processing state immediately
      queryClient.setQueryData(['episode-status', episodeGuid], {
        status: 'starting',
        step: 0,
        step_name: 'Starting',
        total_steps: 4,
        message: 'Requesting processing...'
      });

      const response = await feedsApi.processPost(episodeGuid);
      
      // Invalidate to trigger polling in the hook
      queryClient.invalidateQueries({ queryKey: ['episode-status', episodeGuid] });

      if (response.status === 'not_started') {
          setError('No processing job found');
      }
    } catch (err: unknown) {
      console.error('Error starting processing:', err);
      const errorMessage = err && typeof err === 'object' && 'response' in err
        ? (err as { response?: { data?: { error?: string; message?: string } } }).response?.data?.message 
          || (err as { response?: { data?: { error?: string } } }).response?.data?.error 
          || 'Failed to start processing'
        : 'Failed to start processing';
      setError(errorMessage);
      // Invalidate to clear optimistic update if failed
      queryClient.invalidateQueries({ queryKey: ['episode-status', episodeGuid] });
    }
  };

  // Show completed state with download button only
  if (isCompleted && downloadUrl) {
    return (
      <div className={`${className}`}>
        <div className="flex gap-2">
          <button
            onClick={handleDownloadClick}
            className="px-3 py-1 text-xs rounded font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700"
            title="Download processed episode"
          >
            Download
          </button>
          <ReprocessButton
            episodeGuid={episodeGuid}
            isWhitelisted={isWhitelisted}
            feedId={feedId}
            canModifyEpisodes={canModifyEpisodes}
            onReprocessStart={() => {
              queryClient.invalidateQueries({ queryKey: ['episode-status', episodeGuid] });
            }}
          />
        </div>
        {error && (
          <div className="text-xs text-red-600 mt-1">
            {error}
          </div>
        )}
      </div>
    );
  }

  // If user can't modify episodes, don't show the Process button
  if (!canModifyEpisodes) {
    return null;
  }

  // If processing, hide the button (EpisodeProcessingStatus will show progress)
  if (isProcessing) {
    return null;
  }

  return (
    <div className={`space-y-2 ${className}`}>
      <button
        onClick={handleDownloadClick}
        className="px-3 py-1 text-xs rounded font-medium transition-colors border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400 hover:text-gray-900"
        title="Start processing episode"
      >
        Process
      </button>

      {/* Error message */}
      {error && (
        <div className="text-xs text-red-600 text-center">
          {error}
        </div>
      )}
    </div>
  );
}


================================================
FILE: frontend/src/components/EpisodeProcessingStatus.tsx
================================================
import { useEpisodeStatus } from '../hooks/useEpisodeStatus';

interface EpisodeProcessingStatusProps {
  episodeGuid: string;
  isWhitelisted: boolean;
  hasProcessedAudio: boolean;
  feedId?: number;
  className?: string;
}

export default function EpisodeProcessingStatus({
  episodeGuid,
  isWhitelisted,
  hasProcessedAudio,
  feedId,
  className = ''
}: EpisodeProcessingStatusProps) {
  const { data: status } = useEpisodeStatus(episodeGuid, isWhitelisted, hasProcessedAudio, feedId);

  if (!status) return null;

  // Don't show anything if completed (DownloadButton handles this) or not started
  if (status.status === 'completed' || status.status === 'not_started') {
    return null;
  }

  const getProgressPercentage = () => {
    if (!status) return 0;
    return (status.step / status.total_steps) * 100;
  };

  const getStepIcon = (stepNumber: number) => {
    if (!status) return '○';

    if (status.step > stepNumber) {
      return '✓'; // Completed
    } else if (status.step === stepNumber) {
      return '●'; // Current
    } else {
      return '○'; // Not started
    }
  };

  return (
    <div className={`space-y-2 min-w-[200px] ${className}`}>
      {/* Progress indicator */}
      <div className="space-y-1">
        {/* Progress bar */}
        <div className="w-full bg-gray-200 rounded-full h-1.5">
          <div
            className={`h-1.5 rounded-full transition-all duration-300 ${
              status.status === 'error' || status.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
            }`}
            style={{ width: `${getProgressPercentage()}%` }}
          />
        </div>

        {/* Step indicators */}
        <div className="flex justify-between text-xs text-gray-600">
          {[1, 2, 3, 4].map((stepNumber) => (
            <div
              key={stepNumber}
              className={`flex flex-col items-center ${
                status.step === stepNumber ? 'text-blue-600 font-medium' : ''
              } ${
                status.step > stepNumber ? 'text-green-600' : ''
              }`}
            >
              <span className="text-xs">{getStepIcon(stepNumber)}</span>
              <span className="text-xs">{stepNumber}/4</span>
            </div>
          ))}
        </div>

        {/* Current step name */}
        <div className="text-xs text-center text-gray-600">
          {status.step_name}
        </div>
      </div>

      {/* Error message */}
      {(status.error || status.status === 'failed' || status.status === 'error') && (
        <div className="text-xs text-red-600 text-center">
          {status.error || 'Processing failed'}
        </div>
      )}
    </div>
  );
}


================================================
FILE: frontend/src/components/FeedDetail.tsx
================================================
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useRef, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import type { Feed, Episode, PagedResult, ConfigResponse } from '../types';
import { feedsApi, configApi } from '../services/api';
import DownloadButton from './DownloadButton';
import PlayButton from './PlayButton';
import ProcessingStatsButton from './ProcessingStatsButton';
import EpisodeProcessingStatus from './EpisodeProcessingStatus';
import { useAuth } from '../contexts/AuthContext';
import { copyToClipboard } from '../utils/clipboard';
import { emitDiagnosticError } from '../utils/diagnostics';
import { getHttpErrorInfo } from '../utils/httpError';

interface FeedDetailProps {
  feed: Feed;
  onClose?: () => void;
  onFeedDeleted?: () => void;
}

type SortOption = 'newest' | 'oldest' | 'title';

interface ProcessingEstimate {
  post_guid: string;
  estimated_minutes: number;
  can_process: boolean;
  reason: string | null;
}

const EPISODES_PAGE_SIZE = 25;

export default function FeedDetail({ feed, onClose, onFeedDeleted }: FeedDetailProps) {
  const { requireAuth, isAuthenticated, user } = useAuth();
  const [sortBy, setSortBy] = useState<SortOption>('newest');
  const [showStickyHeader, setShowStickyHeader] = useState(false);
  const [showHelp, setShowHelp] = useState(false);
  const [showMenu, setShowMenu] = useState(false);
  const queryClient = useQueryClient();
  const scrollContainerRef = useRef<HTMLDivElement>(null);
  const feedHeaderRef = useRef<HTMLDivElement>(null);
  const [currentFeed, setCurrentFeed] = useState(feed);
  const [pendingEpisode, setPendingEpisode] = useState<Episode | null>(null);
  const [showProcessingModal, setShowProcessingModal] = useState(false);
  const [processingEstimate, setProcessingEstimate] = useState<ProcessingEstimate | null>(null);
  const [isEstimating, setIsEstimating] = useState(false);
  const [estimateError, setEstimateError] = useState<string | null>(null);
  const [page, setPage] = useState(1);

  const isAdmin = !requireAuth || user?.role === 'admin';
  const whitelistedOnly = requireAuth && !isAdmin;

  const { data: configResponse } = useQuery<ConfigResponse>({
    queryKey: ['config'],
    queryFn: configApi.getConfig,
    enabled: isAdmin,
  });

  const {
    data: episodesPage,
    isLoading,
    isFetching,
    error,
  } = useQuery<PagedResult<Episode>, Error, PagedResult<Episode>, [string, number, number, boolean]>({
    queryKey: ['episodes', currentFeed.id, page, whitelistedOnly],
    queryFn: () =>
      feedsApi.getFeedPosts(currentFeed.id, {
        page,
        pageSize: EPISODES_PAGE_SIZE,
        whitelistedOnly,
      }),
    placeholderData: (previousData) => previousData,
  });

  const whitelistMutation = useMutation({
    mutationFn: ({ guid, whitelisted, triggerProcessing }: { guid: string; whitelisted: boolean; triggerProcessing?: boolean }) =>
      feedsApi.togglePostWhitelist(guid, whitelisted, triggerProcessing),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['episodes', currentFeed.id] });
    },
    onError: (err) => {
      const { status, data, message } = getHttpErrorInfo(err);
      emitDiagnosticError({
        title: 'Failed to update whitelist status',
        message,
        kind: status ? 'http' : 'network',
        details: {
          status,
          response: data,
        },
      });
    },
  });

  const bulkWhitelistMutation = useMutation({
    mutationFn: () => feedsApi.toggleAllPostsWhitelist(currentFeed.id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['episodes', currentFeed.id] });
    },
  });

  const refreshFeedMutation = useMutation({
    mutationFn: () => feedsApi.refreshFeed(currentFeed.id),
    onSuccess: (data) => {
      queryClient.invalidateQueries({ queryKey: ['feeds'] });
      queryClient.invalidateQueries({ queryKey: ['episodes', currentFeed.id] });
      toast.success(data?.message ?? 'Feed refreshed');
    },
    onError: (err) => {
      console.error('Failed to refresh feed', err);
      const { status, data, message } = getHttpErrorInfo(err);
      emitDiagnosticError({
        title: 'Failed to refresh feed',
        message,
        kind: status ? 'http' : 'network',
        details: {
          status,
          response: data,
          feedId: currentFeed.id,
        },
      });
    },
  });

  const updateFeedSettingsMutation = useMutation({
    mutationFn: (override: boolean | null) =>
      feedsApi.updateFeedSettings(currentFeed.id, {
        auto_whitelist_new_episodes_override: override,
      }),
    onSuccess: (data) => {
      setCurrentFeed(data);
      queryClient.invalidateQueries({ queryKey: ['feeds'] });
      toast.success('Feed settings updated');
    },
    onError: (err) => {
      const { status, data, message } = getHttpErrorInfo(err);
      emitDiagnosticError({
        title: 'Failed to update feed settings',
        message,
        kind: status ? 'http' : 'network',
        details: {
          status,
          response: data,
          feedId: currentFeed.id,
        },
      });
      toast.error('Failed to update feed settings');
    },
  });

  const deleteFeedMutation = useMutation({
    mutationFn: () => feedsApi.deleteFeed(currentFeed.id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['feeds'] });
      if (onFeedDeleted) {
        onFeedDeleted();
      }
    },
    onError: (err) => {
      console.error('Failed to delete feed', err);
      const { status, data, message } = getHttpErrorInfo(err);
      emitDiagnosticError({
        title: 'Failed to delete feed',
        message,
        kind: status ? 'http' : 'network',
        details: {
          status,
          response: data,
          feedId: currentFeed.id,
        },
      });
    },
  });

  const joinFeedMutation = useMutation({
    mutationFn: () => feedsApi.joinFeed(currentFeed.id),
    onSuccess: (data) => {
      toast.success('Joined feed');
      setCurrentFeed(data);
      queryClient.invalidateQueries({ queryKey: ['feeds'] });
    },
    onError: (err) => {
      console.error('Failed to join feed', err);
      const { status, data, message } = getHttpErrorInfo(err);
      emitDiagnosticError({
        title: 'Failed to join feed',
        message,
        kind: status ? 'http' : 'network',
        details: {
          status,
          response: data,
          feedId: currentFeed.id,
        },
      });
    },
  });

  const leaveFeedMutation = useMutation({
    mutationFn: () => feedsApi.leaveFeed(currentFeed.id),
    onSuccess: () => {
      toast.success('Removed from your feeds');
      setCurrentFeed((prev) => (prev ? { ...prev, is_member: false, is_active_subscription: false } : prev));
      queryClient.invalidateQueries({ queryKey: ['feeds'] });
      if (onFeedDeleted && !isAdmin) {
        onFeedDeleted();
      }
    },
    onError: (err) => {
      console.error('Failed to leave feed', err);
      const { status, data, message } = getHttpErrorInfo(err);
      emitDiagnosticError({
        title: 'Failed to remove feed',
        message,
        kind: status ? 'http' : 'network',
        details: {
          status,
          response: data,
          feedId: currentFeed.id,
        },
      });
    },
  });

  useEffect(() => {
    setCurrentFeed(feed);
  }, [feed]);

  useEffect(() => {
    setPage(1);
  }, [feed.id, whitelistedOnly]);

  // Handle scroll to show/hide sticky header
  useEffect(() => {
    const scrollContainer = scrollContainerRef.current;
    const feedHeader = feedHeaderRef.current;

    if (!scrollContainer || !feedHeader) return;

    const handleScroll = () => {
      const scrollTop = scrollContainer.scrollTop;
      const feedHeaderHeight = feedHeader.offsetHeight;

      // Show sticky header when scrolled past the feed header
      setShowStickyHeader(scrollTop > feedHeaderHeight - 100);
    };

    scrollContainer.addEventListener('scroll', handleScroll);
    return () => scrollContainer.removeEventListener('scroll', handleScroll);
  }, []);

  // Handle click outside to close menu
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (showMenu && !(event.target as Element).closest('.menu-container')) {
        setShowMenu(false);
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [showMenu]);

  const handleWhitelistToggle = (episode: Episode) => {
    if (!episode.whitelisted) {
      setPendingEpisode(episode);
      setShowProcessingModal(true);
      setProcessingEstimate(null);
      setEstimateError(null);
      setIsEstimating(true);
      feedsApi
        .getProcessingEstimate(episode.guid)
        .then((estimate) => {
          setProcessingEstimate(estimate);
        })
        .catch((err) => {
          console.error('Failed to load processing estimate', err);
          const { status, data, message } = getHttpErrorInfo(err);
          emitDiagnosticError({
            title: 'Failed to load processing estimate',
            message,
            kind: status ? 'http' : 'network',
            details: {
              status,
              response: data,
              postGuid: episode.guid,
            },
          });
          setEstimateError(message ?? 'Unable to estimate processing time');
        })
        .finally(() => setIsEstimating(false));
      return;
    }

    whitelistMutation.mutate({
      guid: episode.guid,
      whitelisted: false,
    });
  };

  const handleConfirmProcessing = () => {
    if (!pendingEpisode) return;
    whitelistMutation.mutate(
      {
        guid: pendingEpisode.guid,
        whitelisted: true,
        triggerProcessing: true,
      },
      {
        onSuccess: () => {
          setShowProcessingModal(false);
          setPendingEpisode(null);
          setProcessingEstimate(null);
        },
      }
    );
  };

  const handleCancelProcessing = () => {
    setShowProcessingModal(false);
    setPendingEpisode(null);
    setProcessingEstimate(null);
    setEstimateError(null);
  };

  const handleAutoWhitelistOverrideChange = (value: string) => {
    const override =
      value === 'inherit' ? null : value === 'on';
    updateFeedSettingsMutation.mutate(override);
  };

  const isMember = Boolean(currentFeed.is_member);
  const isActiveSubscription = currentFeed.is_active_subscription !== false;

  // Admins can manage everything; regular users are read-only.
  const canDeleteFeed = isAdmin; // only admins can delete feeds
  const canModifyEpisodes = !requireAuth ? true : Boolean(isAdmin);
  const canBulkModifyEpisodes = !requireAuth ? true : Boolean(isAdmin);
  const canSubscribe = !requireAuth || isMember;
  const showPodlyRssButton = !(requireAuth && isAdmin && !isMember);
  const showWhitelistUi = canModifyEpisodes && isAdmin;
  const appAutoWhitelistDefault =
    configResponse?.config?.app?.automatically_whitelist_new_episodes;
  const autoWhitelistDefaultLabel =
    appAutoWhitelistDefault === undefined
      ? 'Unknown'
      : appAutoWhitelistDefault
        ? 'On'
        : 'Off';
  const autoWhitelistOverrideValue =
    currentFeed.auto_whitelist_new_episodes_override ?? null;
  const autoWhitelistSelectValue =
    autoWhitelistOverrideValue === true
      ? 'on'
      : autoWhitelistOverrideValue === false
        ? 'off'
        : 'inherit';

  const episodes = episodesPage?.items ?? [];
  const totalCount = episodesPage?.total ?? 0;
  const whitelistedCount =
    episodesPage?.whitelisted_total ?? episodes.filter((ep: Episode) => ep.whitelisted).length;
  const totalPages = Math.max(
    1,
    episodesPage?.total_pages ?? Math.ceil(totalCount / EPISODES_PAGE_SIZE)
  );
  const hasEpisodes = totalCount > 0;
  const visibleStart = hasEpisodes ? (page - 1) * EPISODES_PAGE_SIZE + 1 : 0;
  const visibleEnd = hasEpisodes ? Math.min(totalCount, page * EPISODES_PAGE_SIZE) : 0;

  useEffect(() => {
    if (page > totalPages && totalPages > 0) {
      setPage(totalPages);
    }
  }, [page, totalPages]);

  const handleBulkWhitelistToggle = () => {
    if (requireAuth && !isAdmin) {
      toast.error('Only admins can bulk toggle whitelist status.');
      return;
    }
    bulkWhitelistMutation.mutate();
  };

  const handleDeleteFeed = () => {
    if (confirm(`Are you sure you want to delete "${currentFeed.title}"? This action cannot be undone.`)) {
      deleteFeedMutation.mutate();
    }
  };

  const episodesToShow = useMemo(() => episodes, [episodes]);

  const sortedEpisodes = useMemo(() => {
    const list = [...episodesToShow];
    return list.sort((a, b) => {
      switch (sortBy) {
        case 'newest':
          return new Date(b.release_date || 0).getTime() - new Date(a.release_date || 0).getTime();
        case 'oldest':
          return new Date(a.release_date || 0).getTime() - new Date(b.release_date || 0).getTime();
        case 'title':
          return a.title.localeCompare(b.title);
        default:
          return 0;
      }
    });
  }, [episodesToShow, sortBy]);

  // Calculate whitelist status for bulk button
  const allWhitelisted = totalCount > 0 && whitelistedCount === totalCount;

  const formatDate = (dateString: string | null) => {
    if (!dateString) return 'Unknown date';
    return new Date(dateString).toLocaleDateString('en-US', {
      year: 'numeric',
      month: 'short',
      day: 'numeric'
    });
  };

  const formatDuration = (seconds: number | null) => {
    if (!seconds) return '';
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    if (hours > 0) {
      return `${hours}h ${minutes}m`;
    }
    return `${minutes}m`;
  };

  const handleCopyRssToClipboard = async () => {
    if (requireAuth && !isAuthenticated) {
      toast.error('Please sign in to copy a protected RSS URL.');
      return;
    }

    try {
      let rssUrl: string;
      if (requireAuth) {
        const response = await feedsApi.createProtectedFeedShareLink(currentFeed.id);
        rssUrl = response.url;
      } else {
        rssUrl = new URL(`/feed/${currentFeed.id}`, window.location.origin).toString();
      }

      await copyToClipboard(rssUrl, 'Copy the Feed RSS URL:', 'Feed URL copied to clipboard!');
    } catch (err) {
      console.error('Failed to copy feed URL', err);
      toast.error('Failed to copy feed URL');
    }
  };

  const handleCopyOriginalRssToClipboard = async () => {
    try {
      const rssUrl = currentFeed.rss_url || '';
      if (!rssUrl) throw new Error('No RSS URL');

      await copyToClipboard(rssUrl, 'Copy the Original RSS URL:', 'Original RSS URL copied to clipboard');
    } catch (err) {
      console.error('Failed to copy original RSS URL', err);
      toast.error('Failed to copy original RSS URL');
    }
  };

  return (
    <div className="h-full flex flex-col bg-white relative">
      {/* Mobile Header */}
      <div className="flex items-center justify-between p-4 border-b lg:hidden">
        <h2 className="text-lg font-semibold text-gray-900">Podcast Details</h2>
        {onClose && (
          <button
            onClick={onClose}
            className="p-2 text-gray-400 hover:text-gray-600"
          >
            <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        )}
      </div>

      {/* Sticky Header - appears when scrolling */}
      <div className={`absolute top-16 lg:top-0 left-0 right-0 z-10 bg-white border-b transition-all duration-300 ${
        showStickyHeader ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'
      }`}>
        <div className="p-4">
          <div className="flex items-center gap-3">
            {currentFeed.image_url && (
              <img
                src={currentFeed.image_url}
                alt={currentFeed.title}
                className="w-10 h-10 rounded-lg object-cover"
              />
            )}
            <div className="flex-1 min-w-0">
              <h2 className="font-semibold text-gray-900 truncate">{currentFeed.title}</h2>
              {currentFeed.author && (
                <p className="text-sm text-gray-600 truncate">by {currentFeed.author}</p>
              )}
            </div>
            <select
              value={sortBy}
              onChange={(e) => setSortBy(e.target.value as SortOption)}
              className="text-sm border border-gray-300 rounded-md px-3 py-1 bg-white"
            >
              <option value="newest">Newest First</option>
              <option value="oldest">Oldest First</option>
              <option value="title">Title A-Z</option>
            </select>

            {/* do not add addtional controls to sticky headers */}
          </div>
        </div>
      </div>

      {/* Scrollable Content */}
      <div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
        {/* Feed Info Header */}
        <div ref={feedHeaderRef} className="p-6 border-b">
          <div className="flex flex-col gap-6">
            {/* Top Section: Image and Title */}
            <div className="flex items-end gap-6">
              {/* Podcast Image */}
              <div className="flex-shrink-0">
                {currentFeed.image_url ? (
                  <img
                    src={currentFeed.image_url}
                    alt={currentFeed.title}
                    className="w-32 h-32 sm:w-40 sm:h-40 rounded-lg object-cover shadow-lg"
                  />
                ) : (
                  <div className="w-32 h-32 sm:w-40 sm:h-40 rounded-lg bg-gray-200 flex items-center justify-center shadow-lg">
                    <svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
                    </svg>
                  </div>
                )}
              </div>

              {/* Title aligned to bottom-left of image */}
              <div className="flex-1 min-w-0 pb-2">
                <h1 className="text-2xl font-bold text-gray-900 mb-1">{currentFeed.title}</h1>
                {currentFeed.author && (
                  <p className="text-lg text-gray-600">by {currentFeed.author}</p>
                )}
                <div className="mt-2 text-sm text-gray-500">
                  <span>{totalCount} episodes visible</span>
                </div>
                {requireAuth && isAdmin && (
                  <div className="mt-2 flex items-center gap-2 flex-wrap text-sm">
                    <span
                      className={`px-2 py-1 rounded-full text-xs font-medium border ${
                        isMember
                          ? 'bg-green-50 text-green-700 border-green-200'
                          : 'bg-gray-100 text-gray-600 border-gray-200'
                      }`}
                    >
                      {isMember ? 'Joined' : 'Not joined'}
                    </span>
                    {isMember && !isActiveSubscription && (
                      <span className="px-2 py-1 rounded-full text-xs font-medium border bg-amber-50 text-amber-700 border-amber-200">
                        Paused
                      </span>
                    )}
                  </div>
                )}
              </div>
            </div>

            {/* RSS Button and Menu */}
            <div className="flex items-center gap-3">
              {/* Podly RSS Subscribe Button */}
              {showPodlyRssButton && (
                <button
                  onClick={handleCopyRssToClipboard}
                  title="Copy Podly RSS feed URL"
                  className={`flex items-center gap-3 px-5 py-2 bg-black hover:bg-gray-900 text-white rounded-lg font-medium transition-colors ${
                    !canSubscribe ? 'opacity-60 cursor-not-allowed' : ''
                  }`}
                  disabled={!canSubscribe}
                >
                  <img
                    src="/rss-round-color-icon.svg"
                    alt="Podly RSS"
                    className="w-6 h-6"
                    aria-hidden="true"
                  />
                  <span className="text-white">
                    {canSubscribe ? 'Subscribe to Podly RSS' : 'Join feed to subscribe'}
                  </span>
                </button>
              )}

              {requireAuth && isAdmin && !isMember && (
                <button
                  onClick={() => joinFeedMutation.mutate()}
                  disabled={joinFeedMutation.isPending}
                  className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
                    joinFeedMutation.isPending
                      ? 'bg-blue-100 text-blue-300 cursor-not-allowed'
                      : 'bg-blue-600 text-white hover:bg-blue-700'
                  }`}
                >
                  <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
                  </svg>
                  Join feed
                </button>
              )}

              {canModifyEpisodes && (
                <button
                  onClick={() => refreshFeedMutation.mutate()}
                  disabled={refreshFeedMutation.isPending}
                  title="Refresh feed from source"
                  className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
                    refreshFeedMutation.isPending
                      ? 'bg-gray-200 text-gray-500 cursor-not-allowed'
                      : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
                  }`}
                >
                  <img
                    className={`w-4 h-4 ${refreshFeedMutation.isPending ? 'animate-spin' : ''}`}
                    src="/reload-icon.svg"
                    alt="Refresh feed"
                    aria-hidden="true"
                  />
                  <span>Refresh Feed</span>
                </button>
              )}

              {/* Ellipsis Menu */}
              <div className="relative menu-container">
                <button
                  onClick={() => setShowMenu(!showMenu)}
                  className="w-10 h-10 rounded-lg bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-gray-600 hover:text-gray-800 transition-colors"
                >
                  <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
                    <path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
                  </svg>
                </button>

                {/* Dropdown Menu */}
                {showMenu && (
                  <div className="absolute top-full right-0 mt-1 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-20 max-w-[calc(100vw-2rem)]">
                      {canBulkModifyEpisodes && (
                        <>
                          <button
                            onClick={() => {
                              if (!allWhitelisted) {
                                handleBulkWhitelistToggle();
                            }
                            setShowMenu(false);
                          }}
                          disabled={bulkWhitelistMutation.isPending || totalCount === 0 || allWhitelisted}
                          className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
                        >
                          <span className="text-green-600">✓</span>
                          Enable all episodes
                        </button>

                        <button
                          onClick={() => {
                            if (allWhitelisted) {
                              handleBulkWhitelistToggle();
                            }
                            setShowMenu(false);
                          }}
                          disabled={bulkWhitelistMutation.isPending || totalCount === 0 || !allWhitelisted}
                          className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
                        >
                          <span className="text-red-600">⛔</span>
                          Disable all episodes
                        </button>
                      </>
                      )}

                      {isAdmin && (
                        <button
                          onClick={() => {
                            setShowHelp(!showHelp);
                            setShowMenu(false);
                          }}
                          className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
                        >
                          <span className="text-blue-600">ℹ️</span>
                          Explain whitelist
                        </button>
                      )}

                    <button
                      onClick={() => {
                        handleCopyOriginalRssToClipboard();
                        setShowMenu(false);
                      }}
                      className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
                    >
                      <img src="/rss-round-color-icon.svg" alt="Original RSS" className="w-4 h-4" />
                      Original RSS feed
                    </button>

                    {requireAuth && isAdmin && isMember && (
                      <>
                        <div className="border-t border-gray-100 my-1"></div>
                        <button
                          onClick={() => {
                            leaveFeedMutation.mutate();
                            setShowMenu(false);
                          }}
                          disabled={leaveFeedMutation.isPending}
                          className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
                        >
                          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
                          </svg>
                          Leave feed
                        </button>
                      </>
                    )}

                    {canDeleteFeed && (
                      <>
                        <div className="border-t border-gray-100 my-1"></div>

                        <button
                          onClick={() => {
                            handleDeleteFeed();
                            setShowMenu(false);
                          }}
                          disabled={deleteFeedMutation.isPending}
                          className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
                        >
                          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                          </svg>
                          Delete feed
                        </button>
                      </>
                    )}
                  </div>
                )}
              </div>
            </div>

            {/* Feed Description */}
            {currentFeed.description && (
              <div className="text-gray-700 leading-relaxed">
                <p>{currentFeed.description.replace(/<[^>]*>/g, '')}</p>
              </div>
            )}

            {isAdmin && (
              <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
                <div className="flex flex-col gap-2">
                  <div>
                    <label className="text-sm font-medium text-gray-900">
                      Auto-whitelist new episodes
                    </label>
                    <p className="text-xs text-gray-600">
                      Overrides the global setting. Global default: {autoWhitelistDefaultLabel}.
                    </p>
                  </div>
                  <select
                    value={autoWhitelistSelectValue}
                    onChange={(e) => handleAutoWhitelistOverrideChange(e.target.value)}
                    disabled={updateFeedSettingsMutation.isPending}
                    className={`text-sm border border-gray-300 rounded-md px-3 py-2 bg-white ${
                      updateFeedSettingsMutation.isPending
                        ? 'opacity-60 cursor-not-allowed'
                        : ''
                    }`}
                  >
                    <option value="inherit">Use global setting ({autoWhitelistDefaultLabel})</option>
                    <option value="on">On</option>
                    <option value="off">Off</option>
                  </select>
                </div>
              </div>
            )}
          </div>
        </div>

        {/* Inactive Subscription Warning */}
        {currentFeed.is_member && currentFeed.is_active_subscription === false && (
          <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
            <svg className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
            </svg>
            <div>
              <h3 className="text-sm font-medium text-amber-800">Processing Paused</h3>
              <p className="text-sm text-amber-700 mt-1">
                This feed exceeds your plan's allowance. New episodes will not be processed automatically until you upgrade your plan or leave other feeds.
              </p>
            </div>
          </div>
        )}

        {/* Episodes Header with Sort Only */}
        <div className="p-4 border-b bg-gray-50">
          <div className="flex items-center justify-between">
            <h3 className="text-lg font-semibold text-gray-900">Episodes</h3>
            <select
              value={sortBy}
              onChange={(e) => setSortBy(e.target.value as SortOption)}
              className="text-sm border border-gray-300 rounded-md px-3 py-1 bg-white"
            >
              <option value="newest">Newest First</option>
              <option value="oldest">Oldest First</option>
              <option value="title">Title A-Z</option>
            </select>
          </div>
        </div>

            {/* Help Explainer (admins only) */}
            {showHelp && isAdmin && (
          <div className="bg-blue-50 border-b border-blue-200 p-4">
            <div className="max-w-2xl">
              <h4 className="font-semibold text-blue-900 mb-2">About Enabling & Disabling Ad Removal</h4>
              <div className="text-sm text-blue-800 space-y-2 text-left">
                <p>
                  <strong>Enabled episodes</strong> are processed by Podly to automatically detect and remove advertisements,
                  giving you a clean, ad-free listening experience.
                </p>
                <p>
                  <strong>Disabled episodes</strong> are not processed and won't be available for download through Podly.
                  This is useful for episodes you don't want to listen to.
                </p>
                <p>
                  <strong>Why whitelist episodes?</strong> Processing takes time and computational resources.
                  Enable only the episodes you want to hear to keep your feed focused. This is useful when adding a new feed with a large back catalog.
                </p>
              </div>
              <button
                onClick={() => setShowHelp(false)}
                className="mt-3 text-xs text-blue-600 hover:text-blue-800 font-medium"
              >
                Got it, hide this explanation
              </button>
            </div>
          </div>
        )}

        {/* Episodes List */}
        <div>
          {isLoading ? (
            <div className="p-6">
              <div className="animate-pulse space-y-4">
                {[...Array(5)].map((_, i) => (
                  <div key={i} className="h-20 bg-gray-200 rounded"></div>
                ))}
              </div>
            </div>
          ) : error ? (
            <div className="p-6">
              <p className="text-red-600">Failed to load episodes</p>
            </div>
          ) : sortedEpisodes.length === 0 ? (
            <div className="p-6 text-center">
              <p className="text-gray-500">No episodes found</p>
            </div>
          ) : (
            <div className="divide-y divide-gray-200">
              {sortedEpisodes.map((episode) => (
                <div key={episode.id} className="p-4 hover:bg-gray-50">
                  <div className={`flex flex-col ${episode.whitelisted ? 'gap-3' : 'gap-2'}`}>
                    {/* Top Section: Thumbnail and Title */}
                    <div className="flex items-start gap-3">
                      {/* Episode/Podcast Thumbnail */}
                      <div className="flex-shrink-0">
                        {(episode.image_url || currentFeed.image_url) ? (
                          <img
                            src={episode.image_url || currentFeed.image_url}
                            alt={episode.title}
                            className="w-16 h-16 rounded-lg object-cover"
                          />
                        ) : (
                          <div className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center">
                            <svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
                            </svg>
                          </div>
                        )}
                      </div>

                      {/* Title and Feed Name */}
                      <div className="flex-1 min-w-0 text-left">
                        <h4 className="font-medium text-gray-900 mb-1 line-clamp-2 text-left">
                          {episode.title}
                        </h4>
                        <p className="text-sm text-gray-600 text-left">
                          {currentFeed.title}
                        </p>
                      </div>
                    </div>

                    {/* Episode Description */}
                    {episode.description && (
                      <div className="text-left">
                        <p className="text-sm text-gray-500 line-clamp-3">
                          {episode.description.replace(/<[^>]*>/g, '').substring(0, 300)}...
                        </p>
                      </div>
                    )}

                    {/* Metadata: Status, Date and Duration */}
                    <div className="flex items-center gap-2 text-sm text-gray-500">
                      {showWhitelistUi && (
                        <>
                          <button
                            onClick={() => handleWhitelistToggle(episode)}
                            disabled={whitelistMutation.isPending}
                            className={`px-2 py-1 text-xs font-medium rounded-full transition-colors flex items-center justify-center gap-1 ${
                              episode.whitelisted
                                ? 'bg-green-100 text-green-800 hover:bg-green-200'
                                : 'bg-gray-100 text-gray-800 hover:bg-gray-200'
                            } ${whitelistMutation.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
                          >
                            {whitelistMutation.isPending ? (
                              <>
                                <svg className="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
                                </svg>
                                <span>...</span>
                              </>
                            ) : episode.whitelisted ? (
                              <>
                                <span>✅</span>
                                <span>Enabled</span>
                              </>
                            ) : (
                              <>
                                <span>⛔</span>
                                <span>Disabled</span>
                              </>
                            )}
                          </button>
                          <span>•</span>
                        </>
                      )}
                      <span>{formatDate(episode.release_date)}</span>
                      {episode.duration && (
                        <>
                          <span>•</span>
                          <span>{formatDuration(episode.duration)}</span>
                        </>
                      )}
                      <>
                        <span>•</span>
                        <span>
                          {episode.download_count ? episode.download_count : 0} {episode.download_count === 1 ? 'download' : 'downloads'}
                        </span>
                      </>
                    </div>

                    {/* Bottom Controls - only show if episode is whitelisted */}
                    {episode.whitelisted && (
                      <div className="flex items-center justify-between">
                        {/* Left side: Download buttons */}
                        <div className="flex items-center gap-2">
                          <DownloadButton
                            episodeGuid={episode.guid}
                            isWhitelisted={episode.whitelisted}
                            hasProcessedAudio={episode.has_processed_audio}
                            feedId={currentFeed.id}
                            canModifyEpisodes={canModifyEpisodes}
                            className="min-w-[100px]"
                          />

                          <EpisodeProcessingStatus
                            episodeGuid={episode.guid}
                            isWhitelisted={episode.whitelisted}
                            hasProcessedAudio={episode.has_processed_audio}
                            feedId={currentFeed.id}
                          />

                          <ProcessingStatsButton
                            episodeGuid={episode.guid}
                            hasProcessedAudio={episode.has_processed_audio}
                          />
                        </div>

                        {/* Right side: Play button */}
                        <div className="flex-shrink-0 w-12 flex justify-end">
                          {episode.has_processed_audio && (
                            <PlayButton
                              episode={episode}
                              className="ml-2"
                            />
                          )}
                        </div>
                      </div>
                    )}
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>

        {totalCount > 0 && (
          <div className="flex items-center justify-between px-4 py-3 border-t bg-white">
            <div className="text-sm text-gray-600">
              Showing {visibleStart}-{visibleEnd} of {totalCount} episodes
            </div>
            <div className="flex items-center gap-2">
              <button
                onClick={() => setPage((prev) => Math.max(1, prev - 1))}
                disabled={page === 1 || isLoading || isFetching}
                className={`px-3 py-1 text-sm rounded-md border transition-colors ${
                  page === 1 || isLoading || isFetching
                    ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'
                    : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
                }`}
              >
                Previous
              </button>
              <span className="text-sm text-gray-700">
                Page {page} of {totalPages}
              </span>
              <button
                onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
                disabled={page >= totalPages || isLoading || isFetching}
                className={`px-3 py-1 text-sm rounded-md border transition-colors ${
                  page >= totalPages || isLoading || isFetching
                    ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'
                    : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
                }`}
              >
                Next
              </button>
            </div>
          </div>
        )}
      </div>

      {showProcessingModal && pendingEpisode && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={handleCancelProcessing}>
          <div
            className="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6 space-y-4"
            onClick={(event) => event.stopPropagation()}
          >
            <h3 className="text-lg font-semibold text-gray-900">Enable episode</h3>
            <p className="text-sm text-gray-600">{pendingEpisode.title}</p>
            {isEstimating && (
              <div className="flex items-center gap-2 text-sm text-gray-500">
                <div className="w-4 h-4 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
                Estimating processing time…
              </div>
            )}
            {!isEstimating && estimateError && (
              <p className="text-sm text-red-600">{estimateError}</p>
            )}
            {!isEstimating && processingEstimate && (
              <div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-700 space-y-1">
                <p><strong>Estimated minutes:</strong> {processingEstimate.estimated_minutes.toFixed(2)}</p>
                {!processingEstimate.can_process && (
                  <p className="text-red-600 font-medium">Processing not available for this episode.</p>
                )}
              </div>
            )}
            <div className="flex justify-end gap-3">
              <button
                onClick={handleCancelProcessing}
                className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800"
              >
                Cancel
              </button>
              <button
                onClick={handleConfirmProcessing}
                disabled={
                  whitelistMutation.isPending ||
                  isEstimating ||
                  !processingEstimate?.can_process
                }
                className={`px-4 py-2 rounded-lg text-sm font-medium ${
                  whitelistMutation.isPending || isEstimating || !processingEstimate?.can_process
                    ? 'bg-gray-200 text-gray-500 cursor-not-allowed'
                    : 'bg-blue-600 text-white hover:bg-blue-700'
                }`}
              >
                {whitelistMutation.isPending ? 'Starting…' : 'Confirm & process'}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}


================================================
FILE: frontend/src/components/FeedList.tsx
================================================
import { useMemo, useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import type { Feed } from '../types';

interface FeedListProps {
  feeds: Feed[];
  onFeedDeleted: () => void;
  onFeedSelected: (feed: Feed) => void;
  selectedFeedId?: number;
}

export default function FeedList({ feeds, onFeedDeleted: _onFeedDeleted, onFeedSelected, selectedFeedId }: FeedListProps) {
  const [searchTerm, setSearchTerm] = useState('');
  const { requireAuth, user } = useAuth();
  const showMembership = Boolean(requireAuth && user?.role === 'admin');

  // Ensure feeds is an array
  const feedsArray = Array.isArray(feeds) ? feeds : [];

  const filteredFeeds = useMemo(() => {
    const term = searchTerm.trim().toLowerCase();
    if (!term) {
      return feedsArray;
    }
    return feedsArray.filter((feed) => {
      const title = feed.title?.toLowerCase() ?? '';
      const author = feed.author?.toLowerCase() ?? '';
      return title.includes(term) || author.includes(term);
    });
  }, [feedsArray, searchTerm]);

  if (feedsArray.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 text-lg">No podcast feeds added yet.</p>
        <p className="text-gray-400 mt-2">Click "Add Feed" to get started.</p>
      </div>
    );
  }

  return (
    <div className="flex flex-col h-full">
      <div className="mb-3">
        <label htmlFor="feed-search" className="sr-only">
          Search feeds
        </label>
        <input
          id="feed-search"
          type="search"
          placeholder="Search feeds"
          value={searchTerm}
          onChange={(event) => setSearchTerm(event.target.value)}
          className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
        />
      </div>
      <div className="space-y-2 overflow-y-auto h-full pb-20">
        {filteredFeeds.length === 0 ? (
          <div className="flex h-full items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-8 text-center">
            <p className="text-sm text-gray-500">
              No podcasts match &quot;{searchTerm}&quot;
            </p>
          </div>
        ) : (
          filteredFeeds.map((feed) => (
            <div 
              key={feed.id} 
              className={`bg-white rounded-lg shadow border cursor-pointer transition-all hover:shadow-md group ${
                selectedFeedId === feed.id ? 'ring-2 ring-blue-500 border-blue-200' : ''
              }`}
              onClick={() => onFeedSelected(feed)}
            >
              <div className="p-4">
                <div className="flex items-start gap-3">
                  {/* Podcast Image */}
                  <div className="flex-shrink-0">
                    {feed.image_url ? (
                      <img
                        src={feed.image_url}
                        alt={feed.title}
                        className="w-12 h-12 rounded-lg object-cover"
                      />
                    ) : (
                      <div className="w-12 h-12 rounded-lg bg-gray-200 flex items-center justify-center">
                        <svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
                        </svg>
                      </div>
                    )}
                  </div>

                  {/* Feed Info */}
                  <div className="flex-1 min-w-0">
                    <h3 className="font-medium text-gray-900 line-clamp-2">{feed.title}</h3>
                    {feed.author && (
                      <p className="text-sm text-gray-600 mt-1">by {feed.author}</p>
                    )}
                    <div className="flex items-center justify-between mt-2">
                      <span className="text-xs text-gray-500">{feed.posts_count} episodes</span>
                      {showMembership && (
                        <div className="flex items-center gap-2">
                          <span
                            className={`px-2 py-0.5 rounded-full text-[11px] font-medium ${
                              feed.is_member
                                ? 'bg-green-100 text-green-700 border border-green-200'
                                : 'bg-gray-100 text-gray-600 border border-gray-200'
                            }`}
                          >
                            {feed.is_member ? 'Joined' : 'Not joined'}
                          </span>
                          {feed.is_member && feed.is_active_subscription === false && (
                            <span className="px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-700 border border-amber-200">
                              Paused
                            </span>
                          )}
                        </div>
                      )}
                    </div>
                  </div>
                </div>
              </div>
            </div>
          ))
        )}
      </div>
    </div>
  );
} 


================================================
FILE: frontend/src/components/PlayButton.tsx
================================================
import { useAudioPlayer } from '../contexts/AudioPlayerContext';
import type { Episode } from '../types';

interface PlayButtonProps {
  episode: Episode;
  className?: string;
}

const PlayIcon = ({ className }: { className: string }) => (
  <svg className={className} fill="currentColor" viewBox="0 0 24 24">
    <path d="M8 5v14l11-7z"/>
  </svg>
);

const PauseIcon = ({ className }: { className: string }) => (
  <svg className={className} fill="currentColor" viewBox="0 0 24 24">
    <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
  </svg>
);

export default function PlayButton({ episode, className = '' }: PlayButtonProps) {
  const { currentEpisode, isPlaying, isLoading, playEpisode, togglePlayPause } = useAudioPlayer();
  
  const isCurrentEpisode = currentEpisode?.id === episode.id;
  const canPlay = episode.has_processed_audio;

  console.log(`PlayButton for "${episode.title}":`, {
    has_processed_audio: episode.has_processed_audio,
    whitelisted: episode.whitelisted,
    canPlay
  });

  const getDisabledReason = () => {
    if (!episode.has_processed_audio) {
      return 'Episode not processed yet';
    }
    return '';
  };

  const handleClick = () => {
    console.log('PlayButton clicked for episode:', episode.title);
    console.log('canPlay:', canPlay);
    console.log('isCurrentEpisode:', isCurrentEpisode);
    
    if (!canPlay) return;
    
    if (isCurrentEpisode) {
      console.log('Toggling play/pause for current episode');
      togglePlayPause();
    } else {
      console.log('Playing new episode');
      playEpisode(episode);
    }
  };

  const isDisabled = !canPlay || (isLoading && isCurrentEpisode);
  const disabledReason = getDisabledReason();
  const title = isDisabled && disabledReason 
    ? disabledReason 
    : isCurrentEpisode 
      ? (isPlaying ? 'Pause' : 'Play') 
      : 'Play episode';

  return (
    <button
      onClick={handleClick}
      disabled={isDisabled}
      className={`p-2 rounded-full transition-colors ${
        isDisabled 
          ? 'bg-gray-300 text-gray-500 cursor-not-allowed' 
          : 'bg-blue-600 text-white hover:bg-blue-700'
      } ${className}`}
      title={title}
    >
      {isLoading && isCurrentEpisode ? (
        <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
      ) : isCurrentEpisode && isPlaying ? (
        <PauseIcon className="w-4 h-4" />
      ) : (
        <PlayIcon className="w-4 h-4" />
      )}
    </button>
  );
} 

================================================
FILE: frontend/src/components/ProcessingStatsButton.tsx
================================================
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { feedsApi } from '../services/api';

interface ProcessingStatsButtonProps {
  episodeGuid: string;
  hasProcessedAudio: boolean;
  className?: string;
}

export default function ProcessingStatsButton({
  episodeGuid,
  hasProcessedAudio,
  className = ''
}: ProcessingStatsButtonProps) {
  const [showModal, setShowModal] = useState(false);
  const [activeTab, setActiveTab] = useState<'overview' | 'model-calls' | 'transcript' | 'identifications'>('overview');
  const [expandedModelCalls, setExpandedModelCalls] = useState<Set<number>>(new Set());

  const { data: stats, isLoading, error } = useQuery({
    queryKey: ['episode-stats', episodeGuid],
    queryFn: () => feedsApi.getPostStats(episodeGuid),
    enabled: showModal && hasProcessedAudio, // Only fetch when modal is open and episode is processed
  });

  const formatDuration = (seconds: number) => {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const secs = Math.round(seconds % 60); // Round to nearest whole second

    if (hours > 0) {
      return `${hours}h ${minutes}m ${secs}s`;
    }
    return `${minutes}m ${secs}s`;
  };

  const formatTimestamp = (timestamp: string | null) => {
    if (!timestamp) return 'N/A';
    return new Date(timestamp).toLocaleString();
  };

  const toggleModelCallDetails = (callId: number) => {
    const newExpanded = new Set(expandedModelCalls);
    if (newExpanded.has(callId)) {
      newExpanded.delete(callId);
    } else {
      newExpanded.add(callId);
    }
    setExpandedModelCalls(newExpanded);
  };

  if (!hasProcessedAudio) {
    return null;
  }

  return (
    <>
      <button
        onClick={() => setShowModal(true)}
        className={`px-3 py-1 text-xs rounded font-medium transition-colors border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400 hover:text-gray-900 flex items-center gap-1 ${className}`}
      >
        Stats
      </button>

      {/* Modal */}
      {showModal && (
        <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
          <div className="bg-white rounded-lg max-w-6xl w-full max-h-[90vh] overflow-hidden">
            {/* Header */}
            <div className="flex items-center justify-between p-6 border-b">
              <h2 className="text-xl font-bold text-gray-900 text-left">Processing Statistics & Debug</h2>
              <button
                onClick={() => setShowModal(false)}
                className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
              >
                <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                </svg>
              </button>
            </div>

            {/* Tabs */}
            <div className="border-b">
              <nav className="flex space-x-8 px-6">
                {[
                  { id: 'overview', label: 'Overview' },
                  { id: 'model-calls', label: 'Model Calls' },
                  { id: 'transcript', label: 'Transcript Segments' },
                  { id: 'identifications', label: 'Identifications' }
                ].map((tab) => (
                  <button
                    key={tab.id}
                    onClick={() => setActiveTab(tab.id as 'overview' | 'model-calls' | 'transcript' | 'identifications')}
                    className={`py-4 px-1 border-b-2 font-medium text-sm ${
                      activeTab === tab.id
                        ? 'border-blue-500 text-blue-600'
                        : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
                    }`}
                  >
                    {tab.label}
                    {stats && tab.id === 'model-calls' && stats.model_calls && ` (${stats.model_calls.length})`}
                    {stats && tab.id === 'transcript' && stats.transcript_segments && ` (${stats.transcript_segments.length})`}
                    {stats && tab.id === 'identifications' && stats.identifications && ` (${stats.identifications.length})`}
                  </button>
                ))}
              </nav>
            </div>

            {/* Content */}
            <div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
              {isLoading ? (
                <div className="flex items-center justify-center py-12">
                  <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
                  <span className="ml-3 text-gray-600">Loading stats...</span>
                </div>
              ) : error ? (
                <div className="text-center py-12">
                  <p className="text-red-600">Failed to load processing statistics</p>
                </div>
              ) : stats ? (
                <>
                  {/* Overview Tab */}
                  {activeTab === 'overview' && (
                    <div className="space-y-6">
                      {/* Episode Info */}
                      <div className="bg-gray-50 rounded-lg p-4">
                        <h3 className="font-semibold text-gray-900 mb-2 text-left">Episode Information</h3>
                        <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
                          <div className="text-left">
                            <span className="font-medium text-gray-700">Title:</span>
                            <span className="ml-2 text-gray-600">{stats.post?.title || 'Unknown'}</span>
                          </div>
                          <div className="text-left">
                            <span className="font-medium text-gray-700">Duration:</span>
                            <span className="ml-2 text-gray-600">
                              {stats.post?.duration ? formatDuration(stats.post.duration) : 'Unknown'}
                            </span>
                          </div>
                        </div>
                      </div>

                      {/* Key Metrics */}
                      <div>
                        <h3 className="font-semibold text-gray-900 mb-4 text-left">Key Metrics</h3>
                        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
                          <div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 text-center">
                            <div className="text-2xl font-bold text-blue-600">
                              {stats.processing_stats?.total_segments || 0}
                            </div>
                            <div className="text-sm text-blue-800">Transcript Segments</div>
                          </div>

                          <div className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 text-center">
                            <div className="text-2xl font-bold text-green-600">
                              {stats.processing_stats?.content_segments || 0}
                            </div>
                            <div className="text-sm text-green-800">Content Segments</div>
                          </div>

                          <div className="bg-gradient-to-br from-red-50 to-red-100 rounded-lg p-4 text-center">
                            <div className="text-2xl font-bold text-red-600">
                              {stats.processing_stats?.ad_segments_count || 0}
                            </div>
                            <div className="text-sm text-red-800">Ad Segments Removed</div>
                          </div>
                        </div>
                      </div>

                      {/* Model Performance */}
                      <div>
                        <h3 className="font-semibold text-gray-900 mb-4 text-left">AI Model Performance</h3>
                        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
                          {/* Model Call Status */}
                          <div className="bg-white border rounded-lg p-4">
                            <h4 className="font-medium text-gray-900 mb-3 text-left">Processing Status</h4>
                            <div className="space-y-2">
                              {Object.entries(stats.processing_stats?.model_call_statuses || {}).map(([status, count]) => (
                                <div key={status} className="flex justify-between items-center">
                                  <span className="text-sm text-gray-600 capitalize">{status}</span>
                                  <span className={`px-2 py-1 rounded-full text-xs font-medium ${
                                    status === 'success' ? 'bg-green-100 text-green-800' :
                                    status === 'failed' ? 'bg-red-100 text-red-800' :
                                    'bg-gray-100 text-gray-800'
                                  }`}>
                                    {count}
                                  </span>
                                </div>
                              ))}
                            </div>
                          </div>

                          {/* Model Types */}
                          <div className="bg-white border rounded-lg p-4">
                            <h4 className="font-medium text-gray-900 mb-3 text-left">Models Used</h4>
                            <div className="space-y-2">
                              {Object.entries(stats.processing_stats?.model_types || {}).map(([model, count]) => (
                                <div key={model} className="flex justify-between items-center">
                                  <span className="text-sm text-gray-600">{model}</span>
                                  <span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
                                    {count} calls
                                  </span>
                                </div>
                              ))}
                            </div>
                          </div>
                        </div>
                      </div>
                    </div>
                  )}

                  {/* Model Calls Tab */}
                  {activeTab === 'model-calls' && (
                    <div>
                      <h3 className="font-semibold text-gray-900 mb-4 text-left">Model Calls ({stats.model_calls?.length || 0})</h3>
                      <div className="bg-white border rounded-lg overflow-hidden">
                        <div className="overflow-x-auto">
                          <table className="min-w-full divide-y divide-gray-200">
                            <thead className="bg-gray-50">
                              <tr>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Model</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Segment Range</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Timestamp</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Retries</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
                              </tr>
                            </thead>
                            <tbody className="bg-white divide-y divide-gray-200">
                              {(stats.model_calls || []).map((call) => (
                                <>
                                  <tr key={call.id} className="hover:bg-gray-50">
                                    <td className="px-4 py-3 text-sm text-gray-900">{call.id}</td>
                                    <td className="px-4 py-3 text-sm text-gray-900">{call.model_name}</td>
                                    <td className="px-4 py-3 text-sm text-gray-600">{call.segment_range}</td>
                                    <td className="px-4 py-3">
                                      <span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
                                        call.status === 'success' ? 'bg-green-100 text-green-800' :
                                        call.status === 'failed' ? 'bg-red-100 text-red-800' :
                                        'bg-yellow-100 text-yellow-800'
                                      }`}>
                                        {call.status}
                                      </span>
                                    </td>
                                    <td className="px-4 py-3 text-sm text-gray-600">{formatTimestamp(call.timestamp)}</td>
                                    <td className="px-4 py-3 text-sm text-gray-600">{call.retry_attempts}</td>
                                    <td className="px-4 py-3">
                                      <button
                                        onClick={() => toggleModelCallDetails(call.id)}
                                        className="text-blue-600 hover:text-blue-800 text-sm font-medium"
                                      >
                                        {expandedModelCalls.has(call.id) ? 'Hide' : 'Details'}
                                      </button>
                                    </td>
                                  </tr>
                                  {expandedModelCalls.has(call.id) && (
                                    <tr className="bg-gray-50">
                                      <td colSpan={7} className="px-4 py-4">
                                        <div className="space-y-4">
                                          {call.prompt && (
                                            <div>
                                              <h5 className="font-medium text-gray-900 mb-2 text-left">Prompt:</h5>
                                              <div className="bg-gray-100 p-3 rounded text-sm font-mono whitespace-pre-wrap max-h-40 overflow-y-auto text-left">
                                                {call.prompt}
                                              </div>
                                            </div>
                                          )}
                                          {call.error_message && (
                                            <div>
                                              <h5 className="font-medium text-red-900 mb-2 text-left">Error Message:</h5>
                                              <div className="bg-red-50 p-3 rounded text-sm font-mono whitespace-pre-wrap text-left">
                                                {call.error_message}
                                              </div>
                                            </div>
                                          )}
                                          {call.response && (
                                            <div>
                                              <h5 className="font-medium text-gray-900 mb-2 text-left">Response:</h5>
                                              <div className="bg-gray-100 p-3 rounded text-sm font-mono whitespace-pre-wrap max-h-40 overflow-y-auto text-left">
                                                {call.response}
                                              </div>
                                            </div>
                                          )}
                                        </div>
                                      </td>
                                    </tr>
                                  )}
                                </>
                              ))}
                            </tbody>
                          </table>
                        </div>
                      </div>
                    </div>
                  )}

                  {/* Transcript Segments Tab */}
                  {activeTab === 'transcript' && (
                    <div>
                      <h3 className="font-semibold text-gray-900 mb-4 text-left">Transcript Segments ({stats.transcript_segments?.length || 0})</h3>
                      <div className="bg-white border rounded-lg overflow-hidden">
                        <div className="overflow-x-auto">
                          <table className="min-w-full divide-y divide-gray-200">
                            <thead className="bg-gray-50">
                              <tr>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Seq #</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time Range</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Label</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Text</th>
                              </tr>
                            </thead>
                            <tbody className="bg-white divide-y divide-gray-200">
                              {(stats.transcript_segments || []).map((segment) => (
                                <tr key={segment.id} className={`hover:bg-gray-50 ${
                                  segment.primary_label === 'ad' ? 'bg-red-50' : ''
                                }`}>
                                  <td className="px-4 py-3 text-sm text-gray-900">{segment.sequence_num}</td>
                                  <td className="px-4 py-3 text-sm text-gray-600">
                                    {segment.start_time}s - {segment.end_time}s
                                  </td>
                                  <td className="px-4 py-3">
                                    <span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
                                      segment.primary_label === 'ad'
                                        ? 'bg-red-100 text-red-800'
                                        : 'bg-green-100 text-green-800'
                                    }`}>
                                      {segment.primary_label === 'ad'
                                        ? (segment.mixed ? 'Ad (mixed)' : 'Ad')
                                        : 'Content'}
                                    </span>
                                  </td>
                                  <td className="px-4 py-3 text-sm text-gray-900 max-w-md">
                                    <div className="truncate text-left" title={segment.text}>
                                      {segment.text}
                                    </div>
                                  </td>
                                </tr>
                              ))}
                            </tbody>
                          </table>
                        </div>
                      </div>
                    </div>
                  )}

                  {/* Identifications Tab */}
                  {activeTab === 'identifications' && (
                    <div>
                      <h3 className="font-semibold text-gray-900 mb-4 text-left">Identifications ({stats.identifications?.length || 0})</h3>
                      <div className="bg-white border rounded-lg overflow-hidden">
                        <div className="overflow-x-auto">
                          <table className="min-w-full divide-y divide-gray-200">
                            <thead className="bg-gray-50">
                              <tr>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Segment ID</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time Range</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Label</th>
                                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Confidence</th>
          
Download .txt
gitextract_mp86zz6d/

├── .cursor/
│   └── rules/
│       └── testing-conventions.mdc
├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   └── feature_request.md
│   ├── pull_request_template.md
│   └── workflows/
│       ├── conventional-commit-check.yml
│       ├── docker-publish.yml
│       ├── lint-and-format.yml
│       └── release.yml
├── .gitignore
├── .pylintrc
├── .releaserc.cjs
├── .worktrees/
│   └── .gitignore
├── AGENTS.md
├── Dockerfile
├── LICENCE
├── Pipfile
├── Pipfile.lite
├── README.md
├── SECURITY.md
├── compose.dev.cpu.yml
├── compose.dev.nvidia.yml
├── compose.dev.rocm.yml
├── compose.yml
├── docker-entrypoint.sh
├── docs/
│   ├── contributors.md
│   ├── how_to_run_beginners.md
│   ├── how_to_run_railway.md
│   └── todo.txt
├── frontend/
│   ├── .gitignore
│   ├── README.md
│   ├── eslint.config.js
│   ├── index.html
│   ├── package.json
│   ├── postcss.config.js
│   ├── src/
│   │   ├── App.css
│   │   ├── App.tsx
│   │   ├── components/
│   │   │   ├── AddFeedForm.tsx
│   │   │   ├── AudioPlayer.tsx
│   │   │   ├── DiagnosticsModal.tsx
│   │   │   ├── DownloadButton.tsx
│   │   │   ├── EpisodeProcessingStatus.tsx
│   │   │   ├── FeedDetail.tsx
│   │   │   ├── FeedList.tsx
│   │   │   ├── PlayButton.tsx
│   │   │   ├── ProcessingStatsButton.tsx
│   │   │   ├── ReprocessButton.tsx
│   │   │   └── config/
│   │   │       ├── ConfigContext.tsx
│   │   │       ├── ConfigTabs.tsx
│   │   │       ├── index.ts
│   │   │       ├── sections/
│   │   │       │   ├── AppSection.tsx
│   │   │       │   ├── LLMSection.tsx
│   │   │       │   ├── OutputSection.tsx
│   │   │       │   ├── ProcessingSection.tsx
│   │   │       │   ├── WhisperSection.tsx
│   │   │       │   └── index.ts
│   │   │       ├── shared/
│   │   │       │   ├── ConnectionStatusCard.tsx
│   │   │       │   ├── EnvOverrideWarningModal.tsx
│   │   │       │   ├── EnvVarHint.tsx
│   │   │       │   ├── Field.tsx
│   │   │       │   ├── SaveButton.tsx
│   │   │       │   ├── Section.tsx
│   │   │       │   ├── TestButton.tsx
│   │   │       │   ├── constants.ts
│   │   │       │   └── index.ts
│   │   │       └── tabs/
│   │   │           ├── AdvancedTab.tsx
│   │   │           ├── DefaultTab.tsx
│   │   │           ├── DiscordTab.tsx
│   │   │           ├── UserManagementTab.tsx
│   │   │           └── index.ts
│   │   ├── contexts/
│   │   │   ├── AudioPlayerContext.tsx
│   │   │   ├── AuthContext.tsx
│   │   │   └── DiagnosticsContext.tsx
│   │   ├── hooks/
│   │   │   ├── useConfigState.ts
│   │   │   └── useEpisodeStatus.ts
│   │   ├── index.css
│   │   ├── main.tsx
│   │   ├── pages/
│   │   │   ├── BillingPage.tsx
│   │   │   ├── ConfigPage.tsx
│   │   │   ├── HomePage.tsx
│   │   │   ├── JobsPage.tsx
│   │   │   ├── LandingPage.tsx
│   │   │   └── LoginPage.tsx
│   │   ├── services/
│   │   │   └── api.ts
│   │   ├── types/
│   │   │   └── index.ts
│   │   ├── utils/
│   │   │   ├── clipboard.ts
│   │   │   ├── diagnostics.ts
│   │   │   └── httpError.ts
│   │   └── vite-env.d.ts
│   ├── tailwind.config.js
│   ├── tsconfig.app.json
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── pyproject.toml
├── run_podly_docker.sh
├── scripts/
│   ├── ci.sh
│   ├── create_migration.sh
│   ├── downgrade_db.sh
│   ├── generate_lockfiles.sh
│   ├── manual_publish.sh
│   ├── new_worktree.sh
│   ├── start_services.sh
│   ├── test_full_workflow.py
│   └── upgrade_db.sh
├── src/
│   ├── app/
│   │   ├── __init__.py
│   │   ├── auth/
│   │   │   ├── __init__.py
│   │   │   ├── bootstrap.py
│   │   │   ├── discord_service.py
│   │   │   ├── discord_settings.py
│   │   │   ├── feed_tokens.py
│   │   │   ├── guards.py
│   │   │   ├── middleware.py
│   │   │   ├── passwords.py
│   │   │   ├── rate_limiter.py
│   │   │   ├── service.py
│   │   │   ├── settings.py
│   │   │   └── state.py
│   │   ├── background.py
│   │   ├── config_store.py
│   │   ├── db_commit.py
│   │   ├── db_guard.py
│   │   ├── extensions.py
│   │   ├── feeds.py
│   │   ├── ipc.py
│   │   ├── job_manager.py
│   │   ├── jobs_manager.py
│   │   ├── jobs_manager_run_service.py
│   │   ├── logger.py
│   │   ├── models.py
│   │   ├── post_cleanup.py
│   │   ├── posts.py
│   │   ├── processor.py
│   │   ├── routes/
│   │   │   ├── __init__.py
│   │   │   ├── auth_routes.py
│   │   │   ├── billing_routes.py
│   │   │   ├── config_routes.py
│   │   │   ├── discord_routes.py
│   │   │   ├── feed_routes.py
│   │   │   ├── jobs_routes.py
│   │   │   ├── main_routes.py
│   │   │   ├── post_routes.py
│   │   │   └── post_stats_utils.py
│   │   ├── runtime_config.py
│   │   ├── static/
│   │   │   └── .gitignore
│   │   ├── templates/
│   │   │   └── index.html
│   │   ├── timeout_decorator.py
│   │   └── writer/
│   │       ├── __init__.py
│   │       ├── __main__.py
│   │       ├── actions/
│   │       │   ├── __init__.py
│   │       │   ├── cleanup.py
│   │       │   ├── feeds.py
│   │       │   ├── jobs.py
│   │       │   ├── processor.py
│   │       │   ├── system.py
│   │       │   └── users.py
│   │       ├── client.py
│   │       ├── executor.py
│   │       ├── model_ops.py
│   │       ├── protocol.py
│   │       └── service.py
│   ├── boundary_refinement_prompt.jinja
│   ├── main.py
│   ├── migrations/
│   │   ├── README
│   │   ├── alembic.ini
│   │   ├── env.py
│   │   ├── script.py.mako
│   │   └── versions/
│   │       ├── 0d954a44fa8e_feed_access.py
│   │       ├── 16311623dd58_env_hash.py
│   │       ├── 185d3448990e_stripe.py
│   │       ├── 18c2402c9202_cleanup_retention_days.py
│   │       ├── 2e25a15d11de_per_feed_auto_whitelist.py
│   │       ├── 31d767deb401_credits.py
│   │       ├── 35b12b2d9feb_landing_page.py
│   │       ├── 3c7f5f7640e4_add_counters_reset_timestamp.py
│   │       ├── 3d232f215842_migration.py
│   │       ├── 3eb0a3a0870b_discord.py
│   │       ├── 401071604e7b_config_tables.py
│   │       ├── 58b4eedd4c61_add_last_active_to_user.py
│   │       ├── 5bccc39c9685_zero_initial_allowance.py
│   │       ├── 608e0b27fcda_stronger_access_token.py
│   │       ├── 611dcb5d7f12_add_image_url_to_post_model_for_episode_.py
│   │       ├── 6e0e16299dcb_alternate_feed_id.py
│   │       ├── 73a6b9f9b643_allow_null_feed_id_for_aggregate_tokens.py
│   │       ├── 770771437280_episode_whitelist.py
│   │       ├── 7de4e57ec4bb_discord_settings.py
│   │       ├── 802a2365976d_gruanular_credits.py
│   │       ├── 82cfcc8e0326_refined_cuts.py
│   │       ├── 89d86978f407_limit_users.py
│   │       ├── 91ff431c832e_download_count.py
│   │       ├── 999b921ffc58_migration.py
│   │       ├── a6f5df1a50ac_add_users_table.py
│   │       ├── ab643af6472e_add_manual_feed_allowance_to_user.py
│   │       ├── b038c2f99086_add_processingjob_table_for_async_.py
│   │       ├── b92e47a03bb2_refactor_transcripts_to_db_tables_.py
│   │       ├── bae70e584468_.py
│   │       ├── c0f8893ce927_add_skipped_jobs_columns.py
│   │       ├── ded4b70feadb_add_image_metadata_to_feed.py
│   │       ├── e1325294473b_add_autoprocess_on_download.py
│   │       ├── eb51923af483_multiple_supporters.py
│   │       ├── f6d5fee57cc3_tz_fix.py
│   │       ├── f7a4195e0953_add_enable_boundary_refinement_to_llm_.py
│   │       └── fa3a95ecd67d_audio_processing_paths.py
│   ├── podcast_processor/
│   │   ├── __init__.py
│   │   ├── ad_classifier.py
│   │   ├── ad_merger.py
│   │   ├── audio.py
│   │   ├── audio_processor.py
│   │   ├── boundary_refiner.py
│   │   ├── cue_detector.py
│   │   ├── llm_concurrency_limiter.py
│   │   ├── llm_error_classifier.py
│   │   ├── llm_model_call_utils.py
│   │   ├── model_output.py
│   │   ├── podcast_downloader.py
│   │   ├── podcast_processor.py
│   │   ├── processing_status_manager.py
│   │   ├── prompt.py
│   │   ├── token_rate_limiter.py
│   │   ├── transcribe.py
│   │   ├── transcription_manager.py
│   │   └── word_boundary_refiner.py
│   ├── shared/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── defaults.py
│   │   ├── interfaces.py
│   │   ├── llm_utils.py
│   │   ├── processing_paths.py
│   │   └── test_utils.py
│   ├── system_prompt.txt
│   ├── tests/
│   │   ├── __init__.py
│   │   ├── conftest.py
│   │   ├── test_ad_classifier.py
│   │   ├── test_ad_classifier_rate_limiting_integration.py
│   │   ├── test_aggregate_feed.py
│   │   ├── test_audio_processor.py
│   │   ├── test_config_error_handling.py
│   │   ├── test_feeds.py
│   │   ├── test_filenames.py
│   │   ├── test_helpers.py
│   │   ├── test_llm_concurrency_limiter.py
│   │   ├── test_llm_error_classifier.py
│   │   ├── test_parse_model_output.py
│   │   ├── test_podcast_downloader.py
│   │   ├── test_podcast_processor_cleanup.py
│   │   ├── test_post_cleanup.py
│   │   ├── test_post_routes.py
│   │   ├── test_posts.py
│   │   ├── test_process_audio.py
│   │   ├── test_rate_limiting_config.py
│   │   ├── test_rate_limiting_edge_cases.py
│   │   ├── test_session_auth.py
│   │   ├── test_token_limit_config.py
│   │   ├── test_token_rate_limiter.py
│   │   ├── test_transcribe.py
│   │   └── test_transcription_manager.py
│   ├── user_prompt.jinja
│   └── word_boundary_refinement_prompt.jinja
└── tests/
    └── test_cue_detector.py
Download .txt
SYMBOL INDEX (1162 symbols across 182 files)

FILE: frontend/src/App.tsx
  function AppShell (line 32) | function AppShell() {
  function App (line 293) | function App() {

FILE: frontend/src/components/AddFeedForm.tsx
  type AddFeedFormProps (line 7) | interface AddFeedFormProps {
  type AddMode (line 13) | type AddMode = 'url' | 'search';
  constant PAGE_SIZE (line 15) | const PAGE_SIZE = 10;
  function AddFeedForm (line 17) | function AddFeedForm({ onSuccess, onUpgradePlan, planLimitReached }: Add...

FILE: frontend/src/components/AudioPlayer.tsx
  function AudioPlayer (line 35) | function AudioPlayer() {

FILE: frontend/src/components/DiagnosticsModal.tsx
  constant GITHUB_NEW_ISSUE_URL (line 5) | const GITHUB_NEW_ISSUE_URL = 'https://github.com/podly-pure-podcasts/pod...
  function DiagnosticsModal (line 22) | function DiagnosticsModal() {

FILE: frontend/src/components/DownloadButton.tsx
  type DownloadButtonProps (line 10) | interface DownloadButtonProps {
  function DownloadButton (line 19) | function DownloadButton({

FILE: frontend/src/components/EpisodeProcessingStatus.tsx
  type EpisodeProcessingStatusProps (line 3) | interface EpisodeProcessingStatusProps {
  function EpisodeProcessingStatus (line 11) | function EpisodeProcessingStatus({

FILE: frontend/src/components/FeedDetail.tsx
  type FeedDetailProps (line 15) | interface FeedDetailProps {
  type SortOption (line 21) | type SortOption = 'newest' | 'oldest' | 'title';
  type ProcessingEstimate (line 23) | interface ProcessingEstimate {
  constant EPISODES_PAGE_SIZE (line 30) | const EPISODES_PAGE_SIZE = 25;
  function FeedDetail (line 32) | function FeedDetail({ feed, onClose, onFeedDeleted }: FeedDetailProps) {

FILE: frontend/src/components/FeedList.tsx
  type FeedListProps (line 5) | interface FeedListProps {
  function FeedList (line 12) | function FeedList({ feeds, onFeedDeleted: _onFeedDeleted, onFeedSelected...

FILE: frontend/src/components/PlayButton.tsx
  type PlayButtonProps (line 4) | interface PlayButtonProps {
  function PlayButton (line 21) | function PlayButton({ episode, className = '' }: PlayButtonProps) {

FILE: frontend/src/components/ProcessingStatsButton.tsx
  type ProcessingStatsButtonProps (line 5) | interface ProcessingStatsButtonProps {
  function ProcessingStatsButton (line 11) | function ProcessingStatsButton({

FILE: frontend/src/components/ReprocessButton.tsx
  type ReprocessButtonProps (line 5) | interface ReprocessButtonProps {
  function ReprocessButton (line 14) | function ReprocessButton({

FILE: frontend/src/components/config/ConfigContext.tsx
  type ConfigTabId (line 4) | type ConfigTabId = 'default' | 'advanced' | 'users' | 'discord';
  type AdvancedSubtab (line 5) | type AdvancedSubtab = 'llm' | 'whisper' | 'processing' | 'output' | 'app';
  type ConfigContextValue (line 7) | interface ConfigContextValue extends UseConfigStateReturn {
  function useConfigContext (line 18) | function useConfigContext(): ConfigContextValue {

FILE: frontend/src/components/config/ConfigTabs.tsx
  constant TABS (line 12) | const TABS: { id: ConfigTabId; label: string; adminOnly?: boolean }[] = [
  function ConfigTabs (line 19) | function ConfigTabs() {

FILE: frontend/src/components/config/sections/AppSection.tsx
  function AppSection (line 4) | function AppSection() {

FILE: frontend/src/components/config/sections/LLMSection.tsx
  constant LLM_MODEL_ALIASES (line 8) | const LLM_MODEL_ALIASES: string[] = [
  function LLMSection (line 20) | function LLMSection() {
  function BaseUrlInfoBox (line 205) | function BaseUrlInfoBox() {

FILE: frontend/src/components/config/sections/OutputSection.tsx
  function OutputSection (line 4) | function OutputSection() {

FILE: frontend/src/components/config/sections/ProcessingSection.tsx
  function ProcessingSection (line 4) | function ProcessingSection() {

FILE: frontend/src/components/config/sections/WhisperSection.tsx
  function WhisperSection (line 8) | function WhisperSection() {

FILE: frontend/src/components/config/shared/ConnectionStatusCard.tsx
  type ConnectionStatusCardProps (line 1) | interface ConnectionStatusCardProps {
  function ConnectionStatusCard (line 9) | function ConnectionStatusCard({

FILE: frontend/src/components/config/shared/EnvOverrideWarningModal.tsx
  type EnvOverrideWarningModalProps (line 4) | interface EnvOverrideWarningModalProps {
  function EnvOverrideWarningModal (line 11) | function EnvOverrideWarningModal({

FILE: frontend/src/components/config/shared/EnvVarHint.tsx
  type EnvVarHintProps (line 3) | interface EnvVarHintProps {
  function EnvVarHint (line 7) | function EnvVarHint({ meta }: EnvVarHintProps) {

FILE: frontend/src/components/config/shared/Field.tsx
  type FieldProps (line 5) | interface FieldProps {
  function Field (line 13) | function Field({

FILE: frontend/src/components/config/shared/SaveButton.tsx
  type SaveButtonProps (line 1) | interface SaveButtonProps {
  function SaveButton (line 7) | function SaveButton({ onSave, isPending, className = '' }: SaveButtonPro...

FILE: frontend/src/components/config/shared/Section.tsx
  type SectionProps (line 3) | interface SectionProps {
  function Section (line 9) | function Section({ title, children, className = '' }: SectionProps) {

FILE: frontend/src/components/config/shared/TestButton.tsx
  type TestButtonProps (line 1) | interface TestButtonProps {
  function TestButton (line 7) | function TestButton({ onClick, label, className = '' }: TestButtonProps) {

FILE: frontend/src/components/config/shared/constants.ts
  constant ENV_FIELD_LABELS (line 1) | const ENV_FIELD_LABELS: Record<string, string> = {

FILE: frontend/src/components/config/tabs/AdvancedTab.tsx
  constant SUBTABS (line 10) | const SUBTABS: { id: AdvancedSubtab; label: string }[] = [
  function AdvancedTab (line 18) | function AdvancedTab() {

FILE: frontend/src/components/config/tabs/DefaultTab.tsx
  function DefaultTab (line 6) | function DefaultTab() {
  function GroqHelpBox (line 127) | function GroqHelpBox() {
  function GroqPricingBox (line 164) | function GroqPricingBox() {

FILE: frontend/src/components/config/tabs/DiscordTab.tsx
  function DiscordTab (line 7) | function DiscordTab() {
  function StatusIndicator (line 216) | function StatusIndicator({ enabled }: { enabled: boolean }) {
  function SetupInstructions (line 231) | function SetupInstructions() {

FILE: frontend/src/components/config/tabs/UserManagementTab.tsx
  function UserManagementTab (line 11) | function UserManagementTab() {
  type AccountSecurityProps (line 64) | interface AccountSecurityProps {
  function AccountSecuritySection (line 69) | function AccountSecuritySection({ changePassword, refreshUser }: Account...
  type UserLimitSectionProps (line 151) | interface UserLimitSectionProps {
  function UserLimitSection (line 160) | function UserLimitSection({ currentUsers, userLimit, onChangeLimit, onSa...
  type UserManagementProps (line 200) | interface UserManagementProps {
  function UserManagementSection (line 209) | function UserManagementSection({ currentUser, refreshUser, logout, manag...
  function getErrorMessage (line 521) | function getErrorMessage(error: unknown, fallback = 'Request failed.') {

FILE: frontend/src/contexts/AudioPlayerContext.tsx
  type AudioPlayerState (line 5) | interface AudioPlayerState {
  type AudioPlayerContextType (line 15) | interface AudioPlayerContextType extends AudioPlayerState {
  type AudioPlayerAction (line 23) | type AudioPlayerAction =
  function audioPlayerReducer (line 42) | function audioPlayerReducer(state: AudioPlayerState, action: AudioPlayer...
  function AudioPlayerProvider (line 65) | function AudioPlayerProvider({ children }: { children: React.ReactNode }) {
  function useAudioPlayer (line 276) | function useAudioPlayer() {

FILE: frontend/src/contexts/AuthContext.tsx
  type AuthStatus (line 6) | type AuthStatus = 'loading' | 'ready';
  type AuthContextValue (line 8) | interface AuthContextValue {
  type InternalState (line 22) | interface InternalState {
  function AuthProvider (line 29) | function AuthProvider({ children }: { children: ReactNode }) {

FILE: frontend/src/contexts/DiagnosticsContext.tsx
  type DiagnosticsContextValue (line 6) | type DiagnosticsContextValue = {
  function DiagnosticsProvider (line 30) | function DiagnosticsProvider({ children }: { children: ReactNode }) {

FILE: frontend/src/hooks/useConfigState.ts
  constant DEFAULT_ENV_HINTS (line 14) | const DEFAULT_ENV_HINTS: Record<string, EnvOverrideEntry> = {
  type ConnectionStatus (line 52) | interface ConnectionStatus {
  type UseConfigStateReturn (line 58) | interface UseConfigStateReturn {
  function useConfigState (line 105) | function useConfigState(): UseConfigStateReturn {

FILE: frontend/src/hooks/useEpisodeStatus.ts
  function useEpisodeStatus (line 5) | function useEpisodeStatus(episodeGuid: string, isWhitelisted: boolean, h...

FILE: frontend/src/pages/BillingPage.tsx
  function BillingPage (line 8) | function BillingPage() {

FILE: frontend/src/pages/ConfigPage.tsx
  function ConfigPage (line 3) | function ConfigPage() {

FILE: frontend/src/pages/HomePage.tsx
  function HomePage (line 15) | function HomePage() {

FILE: frontend/src/pages/JobsPage.tsx
  function getStatusColor (line 5) | function getStatusColor(status: string) {
  function StatusBadge (line 24) | function StatusBadge({ status }: { status: string }) {
  function ProgressBar (line 33) | function ProgressBar({ value }: { value: number }) {
  function RunStat (line 45) | function RunStat({ label, value }: { label: string; value: number }) {
  function formatDateTime (line 54) | function formatDateTime(value: string | null): string {
  function JobsPage (line 66) | function JobsPage() {

FILE: frontend/src/pages/LandingPage.tsx
  function LandingPage (line 5) | function LandingPage() {

FILE: frontend/src/pages/LoginPage.tsx
  function LoginPage (line 8) | function LoginPage() {

FILE: frontend/src/services/api.ts
  constant API_BASE_URL (line 20) | const API_BASE_URL = '';

FILE: frontend/src/types/index.ts
  type Feed (line 1) | interface Feed {
  type Episode (line 15) | interface Episode {
  type PagedResult (line 30) | interface PagedResult<T> {
  type Job (line 39) | interface Job {
  type JobManagerRun (line 56) | interface JobManagerRun {
  type JobManagerStatus (line 74) | interface JobManagerStatus {
  type CleanupPreview (line 78) | interface CleanupPreview {
  type CleanupRunResult (line 84) | interface CleanupRunResult {
  type LLMConfig (line 95) | interface LLMConfig {
  type WhisperConfig (line 111) | type WhisperConfig =
  type ProcessingConfigUI (line 133) | interface ProcessingConfigUI {
  type OutputConfigUI (line 137) | interface OutputConfigUI {
  type AppConfigUI (line 145) | interface AppConfigUI {
  type CombinedConfig (line 155) | interface CombinedConfig {
  type EnvOverrideEntry (line 163) | interface EnvOverrideEntry {
  type EnvOverrideMap (line 170) | type EnvOverrideMap = Record<string, EnvOverrideEntry>;
  type ConfigResponse (line 172) | interface ConfigResponse {
  type PodcastSearchResult (line 177) | interface PodcastSearchResult {
  type AuthUser (line 186) | interface AuthUser {
  type ManagedUser (line 195) | interface ManagedUser extends AuthUser {
  type DiscordStatus (line 201) | interface DiscordStatus {
  type BillingSummary (line 205) | interface BillingSummary {
  type LandingStatus (line 218) | interface LandingStatus {

FILE: frontend/src/utils/clipboard.ts
  function copyToClipboard (line 3) | async function copyToClipboard(text: string, promptMessage: string = 'Co...

FILE: frontend/src/utils/diagnostics.ts
  type DiagnosticsLevel (line 1) | type DiagnosticsLevel = 'debug' | 'info' | 'warn' | 'error';
  type DiagnosticsEntry (line 3) | type DiagnosticsEntry = {
  type DiagnosticsState (line 10) | type DiagnosticsState = {
  type DiagnosticErrorPayload (line 15) | type DiagnosticErrorPayload = {
  constant STORAGE_KEY (line 22) | const STORAGE_KEY = 'podly.diagnostics.v1';
  constant MAX_ENTRIES (line 23) | const MAX_ENTRIES = 200;
  constant MAX_ENTRY_MESSAGE_CHARS (line 24) | const MAX_ENTRY_MESSAGE_CHARS = 500;
  constant MAX_JSON_CHARS (line 25) | const MAX_JSON_CHARS = 120_000;
  constant SENSITIVE_KEY_RE (line 27) | const SENSITIVE_KEY_RE = /(authorization|cookie|set-cookie|token|access[...
  constant SENSITIVE_VALUE_REPLACEMENT (line 28) | const SENSITIVE_VALUE_REPLACEMENT = '[REDACTED]';
  constant DIAGNOSTIC_UPDATED_EVENT (line 113) | const DIAGNOSTIC_UPDATED_EVENT = 'podly:diagnostic-updated';
  constant DIAGNOSTIC_ERROR_EVENT (line 153) | const DIAGNOSTIC_ERROR_EVENT = 'podly:diagnostic-error';

FILE: frontend/src/utils/httpError.ts
  type ApiErrorData (line 3) | type ApiErrorData = {
  type HttpErrorInfo (line 9) | type HttpErrorInfo = {

FILE: frontend/vite.config.ts
  constant BACKEND_TARGET (line 7) | const BACKEND_TARGET = 'http://localhost:5001'

FILE: scripts/test_full_workflow.py
  function log (line 10) | def log(msg):
  function check_health (line 14) | def check_health():
  function add_feed (line 27) | def add_feed(url):
  function get_feeds (line 43) | def get_feeds():
  function get_posts (line 55) | def get_posts(feed_id):
  function whitelist_post (line 67) | def whitelist_post(guid):
  function check_status (line 89) | def check_status(guid):
  function wait_for_processing (line 96) | def wait_for_processing(guid, timeout=300):
  function main (line 123) | def main():

FILE: src/app/__init__.py
  function _env_bool (line 43) | def _env_bool(name: str, default: bool = False) -> bool:
  function _get_sqlite_busy_timeout_ms (line 50) | def _get_sqlite_busy_timeout_ms() -> int:
  function setup_dirs (line 55) | def setup_dirs() -> None:
  class SchedulerConfig (line 73) | class SchedulerConfig:
  function _set_sqlite_pragmas (line 85) | def _set_sqlite_pragmas(dbapi_connection: Any, connection_record: Any) -...
  function setup_scheduler (line 102) | def setup_scheduler(app: Flask) -> None:
  function create_app (line 109) | def create_app() -> Flask:
  function create_web_app (line 119) | def create_web_app() -> Flask:
  function create_writer_app (line 133) | def create_writer_app() -> Flask:
  function _create_configured_app (line 145) | def _create_configured_app(
  function _clear_scheduler_jobstore (line 192) | def _clear_scheduler_jobstore() -> None:
  function _validate_env_key_conflicts (line 234) | def _validate_env_key_conflicts() -> None:
  function _create_flask_app (line 260) | def _create_flask_app() -> Flask:
  function _load_auth_settings (line 265) | def _load_auth_settings() -> AuthSettings:
  function _apply_auth_settings (line 273) | def _apply_auth_settings(app: Flask, auth_settings: AuthSettings) -> None:
  function _configure_session (line 279) | def _configure_session(app: Flask, auth_settings: AuthSettings) -> None:
  function _configure_cors (line 303) | def _configure_cors(app: Flask) -> None:
  function _configure_scheduler (line 324) | def _configure_scheduler(app: Flask) -> None:
  function _configure_database (line 328) | def _configure_database(app: Flask) -> None:
  function _configure_external_loggers (line 350) | def _configure_external_loggers() -> None:
  function _configure_readonly_sessions (line 355) | def _configure_readonly_sessions(app: Flask) -> None:
  function _initialize_extensions (line 409) | def _initialize_extensions(app: Flask) -> None:
  function _register_routes_and_middleware (line 419) | def _register_routes_and_middleware(app: Flask) -> None:
  function _register_api_logging (line 426) | def _register_api_logging(app: Flask) -> None:
  function _run_app_startup (line 455) | def _run_app_startup(auth_settings: AuthSettings) -> None:
  function _hydrate_web_config (line 466) | def _hydrate_web_config() -> None:
  function _start_scheduler_and_jobs (line 473) | def _start_scheduler_and_jobs(app: Flask) -> None:

FILE: src/app/auth/bootstrap.py
  function bootstrap_admin_user (line 17) | def bootstrap_admin_user(auth_settings: AuthSettings) -> None:

FILE: src/app/auth/discord_service.py
  class DiscordAuthError (line 23) | class DiscordAuthError(Exception):
  class DiscordGuildRequirementError (line 27) | class DiscordGuildRequirementError(DiscordAuthError):
  class DiscordRegistrationDisabledError (line 31) | class DiscordRegistrationDisabledError(DiscordAuthError):
  class DiscordUser (line 36) | class DiscordUser:
  function generate_oauth_state (line 41) | def generate_oauth_state() -> str:
  function build_authorization_url (line 46) | def build_authorization_url(
  function exchange_code_for_token (line 66) | def exchange_code_for_token(settings: DiscordSettings, code: str) -> dic...
  function get_discord_user (line 85) | def get_discord_user(access_token: str) -> DiscordUser:
  function check_guild_membership (line 100) | def check_guild_membership(access_token: str, settings: DiscordSettings)...
  function find_or_create_user_from_discord (line 116) | def find_or_create_user_from_discord(

FILE: src/app/auth/discord_settings.py
  class DiscordSettings (line 14) | class DiscordSettings:
  function load_discord_settings (line 23) | def load_discord_settings() -> DiscordSettings:
  function _load_from_database (line 72) | def _load_from_database() -> "DiscordSettingsModel | None":
  function reload_discord_settings (line 84) | def reload_discord_settings(app: "Flask") -> DiscordSettings:

FILE: src/app/auth/feed_tokens.py
  function _hash_token (line 17) | def _hash_token(secret_value: str) -> str:
  class FeedTokenAuthResult (line 22) | class FeedTokenAuthResult:
  function _validate_token_access (line 28) | def _validate_token_access(token: FeedAccessToken, user: User, path: str...
  function create_feed_access_token (line 56) | def create_feed_access_token(user: User, feed: Feed | None) -> tuple[str...
  function authenticate_feed_token (line 68) | def authenticate_feed_token(
  function _verify_subscription (line 102) | def _verify_subscription(user: User, feed_id: int) -> bool:
  function _resolve_user_id_from_feed_path (line 120) | def _resolve_user_id_from_feed_path(path: str) -> Optional[int]:
  function _resolve_feed_id (line 130) | def _resolve_feed_id(path: str) -> Optional[int]:

FILE: src/app/auth/guards.py
  function require_admin (line 14) | def require_admin(
  function is_auth_enabled (line 56) | def is_auth_enabled() -> bool:

FILE: src/app/auth/middleware.py
  function init_auth_middleware (line 64) | def init_auth_middleware(app: Any) -> None:
  function _load_session_user (line 110) | def _load_session_user() -> AuthenticatedUser | None:
  function _is_token_protected_endpoint (line 127) | def _is_token_protected_endpoint(path: str) -> bool:
  function _authenticate_feed_token_from_query (line 131) | def _authenticate_feed_token_from_query() -> FeedTokenAuthResult | None:
  function _is_public_request (line 140) | def _is_public_request(path: str) -> bool:
  function _json_unauthorized (line 153) | def _json_unauthorized(message: str = "Authentication required.") -> Res...
  function _token_unauthorized (line 159) | def _token_unauthorized() -> Response:
  function _too_many_requests (line 164) | def _too_many_requests(retry_after: int) -> Response:

FILE: src/app/auth/passwords.py
  function hash_password (line 6) | def hash_password(password: str, *, rounds: int = 12) -> str:
  function verify_password (line 13) | def verify_password(password: str, password_hash: str) -> bool:

FILE: src/app/auth/rate_limiter.py
  class FailureState (line 9) | class FailureState:
  class FailureRateLimiter (line 15) | class FailureRateLimiter:
    method __init__ (line 18) | def __init__(
    method register_failure (line 29) | def register_failure(self, key: str) -> int:
    method register_success (line 51) | def register_success(self, key: str) -> None:
    method retry_after (line 55) | def retry_after(self, key: str) -> int | None:
    method _prune_stale (line 72) | def _prune_stale(self, now: datetime) -> None:

FILE: src/app/auth/service.py
  class AuthServiceError (line 15) | class AuthServiceError(Exception):
  class InvalidCredentialsError (line 19) | class InvalidCredentialsError(AuthServiceError):
  class PasswordValidationError (line 23) | class PasswordValidationError(AuthServiceError):
  class DuplicateUserError (line 27) | class DuplicateUserError(AuthServiceError):
  class LastAdminRemovalError (line 31) | class LastAdminRemovalError(AuthServiceError):
  class UserLimitExceededError (line 35) | class UserLimitExceededError(AuthServiceError):
  class AuthenticatedUser (line 43) | class AuthenticatedUser:
  function _normalize_username (line 49) | def _normalize_username(username: str) -> str:
  function authenticate (line 53) | def authenticate(username: str, password: str) -> AuthenticatedUser | None:
  function list_users (line 62) | def list_users() -> Sequence[User]:
  function create_user (line 69) | def create_user(username: str, password: str, role: str = "user") -> User:
  function change_password (line 97) | def change_password(user: User, current_password: str, new_password: str...
  function update_password (line 104) | def update_password(user: User, new_password: str) -> None:
  function delete_user (line 115) | def delete_user(user: User) -> None:
  function set_role (line 124) | def set_role(user: User, role: str) -> None:
  function set_manual_feed_allowance (line 139) | def set_manual_feed_allowance(user: User, allowance: int | None) -> None:
  function update_user_last_active (line 150) | def update_user_last_active(user_id: int) -> None:
  function _count_admins (line 159) | def _count_admins() -> int:
  function _enforce_user_limit (line 163) | def _enforce_user_limit() -> None:

FILE: src/app/auth/settings.py
  function _str_to_bool (line 7) | def _str_to_bool(value: str | None, default: bool = False) -> bool:
  class AuthSettings (line 15) | class AuthSettings:
    method admin_password_required (line 23) | def admin_password_required(self) -> bool:
    method without_password (line 26) | def without_password(self) -> "AuthSettings":
  function load_auth_settings (line 31) | def load_auth_settings() -> AuthSettings:

FILE: src/app/background.py
  function add_background_job (line 11) | def add_background_job(minutes: int) -> None:
  function schedule_cleanup_job (line 26) | def schedule_cleanup_job(retention_days: Optional[int]) -> None:

FILE: src/app/config_store.py
  function _is_empty (line 35) | def _is_empty(value: Any) -> bool:
  function _parse_int (line 39) | def _parse_int(val: Any) -> Optional[int]:
  function _parse_bool (line 46) | def _parse_bool(val: Any) -> Optional[bool]:
  function _set_if_empty (line 57) | def _set_if_empty(obj: Any, attr: str, new_val: Any) -> bool:
  function _set_if_default (line 66) | def _set_if_default(obj: Any, attr: str, new_val: Any, default_val: Any)...
  function _ensure_row (line 75) | def _ensure_row(model: type, defaults: Dict[str, Any]) -> Any:
  function ensure_defaults (line 105) | def ensure_defaults() -> None:
  function _apply_llm_env_overrides_to_db (line 167) | def _apply_llm_env_overrides_to_db(llm: Any) -> bool:
  function _apply_whisper_env_overrides_to_db (line 267) | def _apply_whisper_env_overrides_to_db(whisper: Any) -> bool:
  function _apply_env_overrides_to_db_first_boot (line 376) | def _apply_env_overrides_to_db_first_boot() -> None:
  function read_combined (line 405) | def read_combined() -> Dict[str, Any]:
  function _update_section_llm (line 479) | def _update_section_llm(data: Dict[str, Any]) -> None:
  function _update_section_whisper (line 509) | def _update_section_whisper(data: Dict[str, Any]) -> None:
  function _update_section_processing (line 561) | def _update_section_processing(data: Dict[str, Any]) -> None:
  function _update_section_output (line 577) | def _update_section_output(data: Dict[str, Any]) -> None:
  function _update_section_app (line 596) | def _update_section_app(data: Dict[str, Any]) -> Tuple[Optional[int], Op...
  function _maybe_reschedule_refresh_job (line 621) | def _maybe_reschedule_refresh_job(
  function _maybe_disable_cleanup_job (line 649) | def _maybe_disable_cleanup_job(
  function update_combined (line 666) | def update_combined(payload: Dict[str, Any]) -> Dict[str, Any]:
  function to_pydantic_config (line 688) | def to_pydantic_config() -> PydanticConfig:
  function hydrate_runtime_config_inplace (line 803) | def hydrate_runtime_config_inplace(db_config: Optional[PydanticConfig] =...
  function _log_initial_snapshot (line 825) | def _log_initial_snapshot(cfg: PydanticConfig) -> None:
  function _apply_top_level_env_overrides (line 836) | def _apply_top_level_env_overrides(cfg: PydanticConfig) -> None:
  function _apply_whisper_env_overrides (line 850) | def _apply_whisper_env_overrides(cfg: PydanticConfig) -> None:
  function _apply_llm_model_override (line 885) | def _apply_llm_model_override(cfg: PydanticConfig) -> None:
  function _configure_local_whisper (line 891) | def _configure_local_whisper(cfg: PydanticConfig) -> None:
  function _configure_remote_whisper (line 918) | def _configure_remote_whisper(cfg: PydanticConfig) -> None:
  function _configure_groq_whisper (line 983) | def _configure_groq_whisper(cfg: PydanticConfig) -> None:
  function _apply_whisper_type_override (line 1022) | def _apply_whisper_type_override(cfg: PydanticConfig) -> None:
  function _commit_runtime_config (line 1054) | def _commit_runtime_config(cfg: PydanticConfig) -> None:
  function _log_final_snapshot (line 1068) | def _log_final_snapshot() -> None:
  function ensure_defaults_and_hydrate (line 1077) | def ensure_defaults_and_hydrate() -> None:
  function _calculate_env_hash (line 1088) | def _calculate_env_hash() -> str:
  function _check_and_apply_env_changes (line 1131) | def _check_and_apply_env_changes() -> None:
  function _apply_llm_env_overrides (line 1164) | def _apply_llm_env_overrides(llm: LLMSettings) -> bool:
  function _apply_whisper_remote_overrides (line 1240) | def _apply_whisper_remote_overrides(whisper: WhisperSettings) -> bool:
  function _apply_whisper_groq_overrides (line 1274) | def _apply_whisper_groq_overrides(whisper: WhisperSettings) -> bool:
  function _apply_whisper_env_overrides_force (line 1291) | def _apply_whisper_env_overrides_force(whisper: WhisperSettings) -> bool:
  function _apply_env_overrides_to_db_force (line 1325) | def _apply_env_overrides_to_db_force() -> None:

FILE: src/app/db_commit.py
  function safe_commit (line 7) | def safe_commit(

FILE: src/app/db_guard.py
  function reset_session (line 15) | def reset_session(
  function db_guard (line 48) | def db_guard(

FILE: src/app/feeds.py
  function is_feed_active_for_user (line 21) | def is_feed_active_for_user(feed_id: int, user: User) -> bool:
  function _should_auto_whitelist_new_posts (line 47) | def _should_auto_whitelist_new_posts(feed: Feed, post: Optional[Post] = ...
  function _get_base_url (line 82) | def _get_base_url() -> str:
  function fetch_feed (line 121) | def fetch_feed(url: str) -> feedparser.FeedParserDict:
  function refresh_feed (line 129) | def refresh_feed(feed: Feed) -> None:
  function add_or_refresh_feed (line 190) | def add_or_refresh_feed(url: str) -> Feed:
  function add_feed (line 204) | def add_feed(feed_data: feedparser.FeedParserDict) -> Feed:
  class ItunesRSSItem (line 267) | class ItunesRSSItem(PyRSS2Gen.RSSItem):  # type: ignore[misc]
    method __init__ (line 268) | def __init__(
    method publish_extensions (line 289) | def publish_extensions(self, handler: Any) -> None:
  function feed_item (line 296) | def feed_item(post: Post, prepend_feed_title: bool = False) -> PyRSS2Gen...
  function generate_feed_xml (line 332) | def generate_feed_xml(feed: Feed) -> Any:
  function generate_aggregate_feed_xml (line 373) | def generate_aggregate_feed_xml(user: Optional[User]) -> Any:
  function get_user_aggregate_posts (line 416) | def get_user_aggregate_posts(user_id: int, limit_per_feed: int = 3) -> l...
  function _append_feed_token_params (line 445) | def _append_feed_token_params(url: str) -> str:
  function make_post (line 471) | def make_post(feed: Feed, entry: feedparser.FeedParserDict) -> Post:
  function _get_entry_field (line 519) | def _get_entry_field(entry: feedparser.FeedParserDict, field: str) -> Op...
  function _parse_datetime_string (line 524) | def _parse_datetime_string(
  function _parse_struct_time (line 536) | def _parse_struct_time(value: Optional[Any], field: str) -> Optional[dat...
  function _normalize_to_utc (line 550) | def _normalize_to_utc(dt: Optional[datetime.datetime]) -> Optional[datet...
  function _parse_release_date (line 558) | def _parse_release_date(
  function _format_pub_date (line 577) | def _format_pub_date(release_date: Optional[datetime.datetime]) -> Optio...
  function get_guid (line 589) | def get_guid(entry: feedparser.FeedParserDict) -> str:
  function get_duration (line 598) | def get_duration(entry: feedparser.FeedParserDict) -> Optional[int]:

FILE: src/app/ipc.py
  class QueueManager (line 8) | class QueueManager(BaseManager):
  function _get_default_authkey (line 16) | def _get_default_authkey() -> bytes:
  function _ensure_process_authkey (line 24) | def _ensure_process_authkey(authkey: bytes) -> None:
  function get_queue (line 33) | def get_queue() -> Queue[Any]:
  function make_server_manager (line 37) | def make_server_manager(
  function make_client_manager (line 51) | def make_client_manager(

FILE: src/app/job_manager.py
  class JobManager (line 10) | class JobManager:
    method __init__ (line 15) | def __init__(
    method job_id (line 34) | def job_id(self) -> Optional[str]:
    method _reload_job (line 37) | def _reload_job(self) -> Optional[ProcessingJob]:
    method get_active_job (line 45) | def get_active_job(self) -> Optional[ProcessingJob]:
    method ensure_job (line 51) | def ensure_job(self) -> ProcessingJob:
    method fail (line 80) | def fail(self, message: str, step: int = 0, progress: float = 0.0) -> ...
    method complete (line 87) | def complete(self, message: str = "Processing complete") -> Processing...
    method skip (line 95) | def skip(
    method _load_and_validate_post (line 111) | def _load_and_validate_post(
    method _mark_job_skipped (line 179) | def _mark_job_skipped(self, reason: str) -> Optional[ProcessingJob]:
    method start_processing (line 201) | def start_processing(self, priority: str) -> Dict[str, Any]:

FILE: src/app/jobs_manager.py
  class JobsManager (line 23) | class JobsManager:
    method __init__ (line 34) | def __init__(self) -> None:
    method _set_run_id (line 65) | def _set_run_id(self, run_id: Optional[str]) -> None:
    method _get_run_id (line 69) | def _get_run_id(self) -> Optional[str]:
    method _wake_worker (line 73) | def _wake_worker(self) -> None:
    method _wait_for_work (line 76) | def _wait_for_work(self, timeout: float = 5.0) -> None:
    method start_post_processing (line 82) | def start_post_processing(
    method enqueue_pending_jobs (line 118) | def enqueue_pending_jobs(
    method _ensure_jobs_for_all_posts (line 155) | def _ensure_jobs_for_all_posts(self, run_id: Optional[str]) -> int:
    method get_post_status (line 175) | def get_post_status(self, post_guid: str) -> Dict[str, Any]:
    method get_job_status (line 236) | def get_job_status(self, job_id: str) -> Dict[str, Any]:
    method list_active_jobs (line 260) | def list_active_jobs(self, limit: int = 100) -> List[Dict[str, Any]]:
    method list_all_jobs_detailed (line 307) | def list_all_jobs_detailed(self, limit: int = 200) -> List[Dict[str, A...
    method cancel_job (line 353) | def cancel_job(self, job_id: str) -> Dict[str, Any]:
    method cancel_post_jobs (line 379) | def cancel_post_jobs(self, post_guid: str) -> Dict[str, Any]:
    method cleanup_stale_jobs (line 399) | def cleanup_stale_jobs(self, older_than: timedelta) -> int:
    method cleanup_stuck_pending_jobs (line 413) | def cleanup_stuck_pending_jobs(self, stuck_threshold_minutes: int = 10...
    method clear_all_jobs (line 441) | def clear_all_jobs(self) -> Dict[str, Any]:
    method start_refresh_all_feeds (line 459) | def start_refresh_all_feeds(
    method _cleanup_inconsistent_posts (line 479) | def _cleanup_inconsistent_posts(self) -> None:
    method _cleanup_and_process_new_posts (line 489) | def _cleanup_and_process_new_posts(
    method _dequeue_next_job (line 525) | def _dequeue_next_job(self) -> Optional[Tuple[str, str]]:
    method _worker_loop (line 552) | def _worker_loop(self) -> None:
    method _process_job (line 578) | def _process_job(self, job_id: str, post_guid: str) -> None:
  function get_jobs_manager (line 690) | def get_jobs_manager() -> JobsManager:
  function scheduled_refresh_all_feeds (line 696) | def scheduled_refresh_all_feeds() -> None:

FILE: src/app/jobs_manager_run_service.py
  function _session_get (line 18) | def _session_get(session: Any, ident: str) -> Optional[JobsManagerRun]:
  function _build_context_payload (line 32) | def _build_context_payload(
  function get_or_create_singleton_run (line 43) | def get_or_create_singleton_run(
  function ensure_active_run (line 75) | def ensure_active_run(
  function get_active_run (line 82) | def get_active_run(session: Any) -> Optional[JobsManagerRun]:
  function recalculate_run_counts (line 87) | def recalculate_run_counts(session: Any) -> Optional[JobsManagerRun]:
  function serialize_run (line 160) | def serialize_run(run: JobsManagerRun) -> Dict[str, object]:
  function build_run_status_snapshot (line 191) | def build_run_status_snapshot(session: Any) -> Optional[Dict[str, object]]:

FILE: src/app/logger.py
  class ExtraFormatter (line 6) | class ExtraFormatter(logging.Formatter):
    method format (line 39) | def format(self, record: logging.LogRecord) -> str:
  function setup_logger (line 53) | def setup_logger(

FILE: src/app/models.py
  function generate_uuid (line 12) | def generate_uuid() -> str:
  function generate_job_id (line 17) | def generate_job_id() -> str:
  class Feed (line 23) | class Feed(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 44) | def __repr__(self) -> str:
  class FeedAccessToken (line 48) | class FeedAccessToken(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 66) | def __repr__(self) -> str:
  class Post (line 73) | class Post(db.Model):  # type: ignore[name-defined, misc]
    method audio_len_bytes (line 103) | def audio_len_bytes(self) -> int:
  class TranscriptSegment (line 113) | class TranscriptSegment(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 135) | def __repr__(self) -> str:
  class User (line 139) | class User(db.Model):  # type: ignore[name-defined, misc]
    method _normalize_username (line 170) | def _normalize_username(self, key: str, value: str) -> str:
    method set_password (line 174) | def set_password(self, password: str) -> None:
    method verify_password (line 177) | def verify_password(self, password: str) -> bool:
    method __repr__ (line 180) | def __repr__(self) -> str:
  class ModelCall (line 184) | class ModelCall(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 216) | def __repr__(self) -> str:
  class Identification (line 220) | class Identification(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 242) | def __repr__(self) -> str:
  class JobsManagerRun (line 250) | class JobsManagerRun(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 275) | def __repr__(self) -> str:
  class ProcessingJob (line 282) | class ProcessingJob(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 324) | def __repr__(self) -> str:
  class UserFeed (line 328) | class UserFeed(db.Model):  # type: ignore[name-defined, misc]
    method __repr__ (line 343) | def __repr__(self) -> str:
  class LLMSettings (line 350) | class LLMSettings(db.Model):  # type: ignore[name-defined, misc]
  class WhisperSettings (line 387) | class WhisperSettings(db.Model):  # type: ignore[name-defined, misc]
  class ProcessingSettings (line 432) | class ProcessingSettings(db.Model):  # type: ignore[name-defined, misc]
  class OutputSettings (line 453) | class OutputSettings(db.Model):  # type: ignore[name-defined, misc]
  class AppSettings (line 476) | class AppSettings(db.Model):  # type: ignore[name-defined, misc]
  class DiscordSettings (line 518) | class DiscordSettings(db.Model):  # type: ignore[name-defined, misc]

FILE: src/app/post_cleanup.py
  function _build_cleanup_query (line 23) | def _build_cleanup_query(
  function count_cleanup_candidates (line 46) | def count_cleanup_candidates(
  function cleanup_processed_posts (line 64) | def cleanup_processed_posts(retention_days: Optional[int]) -> int:
  function scheduled_cleanup_processed_posts (line 119) | def scheduled_cleanup_processed_posts() -> None:
  function _remove_associated_files (line 138) | def _remove_associated_files(post: Post) -> None:
  function _load_latest_completed_map (line 157) | def _load_latest_completed_map(
  function _processed_timestamp_before_cutoff (line 175) | def _processed_timestamp_before_cutoff(
  function _get_processed_file_timestamp (line 190) | def _get_processed_file_timestamp(post: Post) -> Optional[datetime]:

FILE: src/app/posts.py
  function _collect_processed_paths (line 12) | def _collect_processed_paths(post: Post) -> List[Path]:
  function _dedupe_and_find_existing (line 52) | def _dedupe_and_find_existing(paths: List[Path]) -> tuple[List[Path], Op...
  function _remove_file_if_exists (line 71) | def _remove_file_if_exists(path: Optional[Path], file_type: str, post_id...
  function remove_associated_files (line 88) | def remove_associated_files(post: Post) -> None:
  function clear_post_processing_data (line 135) | def clear_post_processing_data(post: Post) -> None:
  class PostException (line 166) | class PostException(Exception):

FILE: src/app/processor.py
  class ProcessorSingleton (line 5) | class ProcessorSingleton:
    method get_instance (line 11) | def get_instance(cls) -> PodcastProcessor:
    method reset_instance (line 18) | def reset_instance(cls) -> None:
  function get_processor (line 23) | def get_processor() -> PodcastProcessor:

FILE: src/app/routes/__init__.py
  function register_routes (line 13) | def register_routes(app: Flask) -> None:

FILE: src/app/routes/auth_routes.py
  function _auth_enabled (line 40) | def _auth_enabled() -> bool:
  function auth_status (line 46) | def auth_status() -> Response:
  function login (line 54) | def login() -> RouteResult:
  function logout (line 112) | def logout() -> RouteResult:
  function auth_me (line 125) | def auth_me() -> RouteResult:
  function change_password_route (line 154) | def change_password_route() -> RouteResult:
  function list_users_route (line 186) | def list_users_route() -> RouteResult:
  function create_user_route (line 221) | def create_user_route() -> RouteResult:
  function update_user_route (line 267) | def update_user_route(username: str) -> RouteResult:
  function delete_user_route (line 301) | def delete_user_route(username: str) -> RouteResult:
  function _require_authenticated_user (line 323) | def _require_authenticated_user() -> User | None:
  function _unauthorized_response (line 334) | def _unauthorized_response() -> RouteResult:

FILE: src/app/routes/billing_routes.py
  function _get_stripe_client (line 18) | def _get_stripe_client() -> tuple[Optional[Any], Optional[str]]:
  function _product_id (line 30) | def _product_id() -> Optional[str]:
  function _min_subscription_amount_cents (line 34) | def _min_subscription_amount_cents() -> int:
  function _user_feed_usage (line 55) | def _user_feed_usage(user: User) -> dict[str, int]:
  function billing_summary (line 69) | def billing_summary() -> Any:
  function _build_return_urls (line 144) | def _build_return_urls() -> tuple[str, str]:
  function update_subscription (line 152) | def update_subscription() -> Any:  # pylint: disable=too-many-statements
  function billing_portal_session (line 367) | def billing_portal_session() -> Any:
  function _update_user_from_subscription (line 400) | def _update_user_from_subscription(sub: Any) -> None:
  function stripe_webhook (line 428) | def stripe_webhook() -> Any:

FILE: src/app/routes/config_routes.py
  function _mask_secret (line 24) | def _mask_secret(value: Any | None) -> str | None:
  function _sanitize_config_for_client (line 39) | def _sanitize_config_for_client(cfg: Dict[str, Any]) -> Dict[str, Any]:
  function api_get_config (line 61) | def api_get_config() -> flask.Response:
  function _hydrate_runtime_config (line 86) | def _hydrate_runtime_config(data: Dict[str, Any]) -> None:
  function _hydrate_llm_config (line 92) | def _hydrate_llm_config(data: Dict[str, Any]) -> None:
  function _hydrate_whisper_config (line 129) | def _hydrate_whisper_config(data: Dict[str, Any]) -> None:
  function _overlay_whisper_dict (line 142) | def _overlay_whisper_dict(target: Dict[str, Any], source: Dict[str, Any]...
  function _overlay_whisper_object (line 153) | def _overlay_whisper_object(target: Dict[str, Any], source: Any) -> None:
  function _overlay_remote_whisper_fields (line 164) | def _overlay_remote_whisper_fields(target: Dict[str, Any], source: Any) ...
  function _overlay_groq_whisper_fields (line 177) | def _overlay_groq_whisper_fields(target: Dict[str, Any], source: Any) ->...
  function _get_attr_or_value (line 186) | def _get_attr_or_value(source: Any, key: str, default: Any) -> Any:
  function _hydrate_app_config (line 192) | def _hydrate_app_config(data: Dict[str, Any]) -> None:
  function _first_env (line 215) | def _first_env(env_names: list[str]) -> tuple[str | None, str | None]:
  function _register_override (line 224) | def _register_override(
  function _register_llm_overrides (line 244) | def _register_llm_overrides(overrides: Dict[str, Any]) -> None:
  function _register_groq_shared_overrides (line 260) | def _register_groq_shared_overrides(overrides: Dict[str, Any]) -> None:
  function _register_remote_whisper_overrides (line 269) | def _register_remote_whisper_overrides(overrides: Dict[str, Any]) -> None:
  function _register_groq_whisper_overrides (line 304) | def _register_groq_whisper_overrides(overrides: Dict[str, Any]) -> None:
  function _register_local_whisper_overrides (line 324) | def _register_local_whisper_overrides(overrides: Dict[str, Any]) -> None:
  function _determine_whisper_type_for_metadata (line 333) | def _determine_whisper_type_for_metadata(data: Dict[str, Any]) -> str | ...
  function _build_env_override_metadata (line 354) | def _build_env_override_metadata(data: Dict[str, Any]) -> Dict[str, Any]:
  function api_put_config (line 379) | def api_put_config() -> flask.Response:
  function api_test_llm (line 428) | def api_test_llm() -> flask.Response:
  function _make_error_response (line 498) | def _make_error_response(error_msg: str, status_code: int = 400) -> flas...
  function _make_success_response (line 502) | def _make_success_response(message: str, **extra_data: Any) -> flask.Res...
  function _get_whisper_config_value (line 508) | def _get_whisper_config_value(
  function _get_env_whisper_api_key (line 523) | def _get_env_whisper_api_key(whisper_type: str) -> str | None:
  function _determine_whisper_type (line 533) | def _determine_whisper_type(whisper_cfg: Dict[str, Any]) -> str | None:
  function _test_local_whisper (line 547) | def _test_local_whisper(whisper_cfg: Dict[str, Any]) -> flask.Response:
  function _test_remote_whisper (line 575) | def _test_remote_whisper(whisper_cfg: Dict[str, Any]) -> flask.Response:
  function _test_groq_whisper (line 597) | def _test_groq_whisper(whisper_cfg: Dict[str, Any]) -> flask.Response:
  function api_test_whisper (line 615) | def api_test_whisper() -> flask.Response:
  function api_get_whisper_capabilities (line 643) | def api_get_whisper_capabilities() -> flask.Response:
  function api_configured_check (line 671) | def api_configured_check() -> flask.Response:

FILE: src/app/routes/discord_routes.py
  function _get_discord_settings (line 42) | def _get_discord_settings() -> DiscordSettings | None:
  function _mask_secret (line 46) | def _mask_secret(value: str | None) -> str | None:
  function _has_env_override (line 55) | def _has_env_override(env_var: str) -> bool:
  function discord_status (line 61) | def discord_status() -> Response:
  function discord_config_get (line 72) | def discord_config_get() -> Response | tuple[Response, int]:
  function discord_config_put (line 127) | def discord_config_put() -> Response | tuple[Response, int]:
  function discord_login (line 195) | def discord_login() -> Response | tuple[Response, int]:
  function discord_callback (line 211) | def discord_callback() -> Response:

FILE: src/app/routes/feed_routes.py
  function fix_url (line 58) | def fix_url(url: str) -> str:
  function _user_feed_count (line 65) | def _user_feed_count(user_id: int) -> int:
  function _get_latest_post (line 69) | def _get_latest_post(feed: Feed) -> Post | None:
  function _ensure_user_feed_membership (line 78) | def _ensure_user_feed_membership(feed: Feed, user_id: int | None) -> tup...
  function _whitelist_latest_for_first_member (line 92) | def _whitelist_latest_for_first_member(
  function _handle_developer_mode_feed (line 121) | def _handle_developer_mode_feed(url: str, user: Optional[User]) -> Respo...
  function _check_feed_allowance (line 159) | def _check_feed_allowance(user: User, url: str) -> Optional[ResponseRetu...
  function add_feed (line 193) | def add_feed() -> ResponseReturnValue:
  function create_feed_share_link (line 242) | def create_feed_share_link(feed_id: int) -> ResponseReturnValue:
  function search_feeds (line 287) | def search_feeds() -> ResponseReturnValue:
  function get_feed (line 370) | def get_feed(f_id: int) -> Response:
  function delete_feed (line 388) | def delete_feed(f_id: int) -> ResponseReturnValue:  # pylint: disable=to...
  function refresh_feed_endpoint (line 443) | def refresh_feed_endpoint(f_id: int) -> ResponseReturnValue:
  function update_feed_settings_endpoint (line 473) | def update_feed_settings_endpoint(feed_id: int) -> ResponseReturnValue:
  function _refresh_feed_background (line 511) | def _refresh_feed_background(app: Flask, feed_id: int) -> None:
  function refresh_all_feeds_endpoint (line 528) | def refresh_all_feeds_endpoint() -> Response:
  function _enqueue_pending_jobs_async (line 544) | def _enqueue_pending_jobs_async(app: Flask) -> None:
  function _cleanup_feed_directories (line 552) | def _cleanup_feed_directories(feed: Feed) -> None:
  function get_feed_by_alt_or_url (line 607) | def get_feed_by_alt_or_url(something_or_rss: str) -> Response:
  function api_feeds (line 627) | def api_feeds() -> ResponseReturnValue:
  function api_join_feed (line 655) | def api_join_feed(feed_id: int) -> ResponseReturnValue:
  function api_exit_feed (line 703) | def api_exit_feed(feed_id: int) -> ResponseReturnValue:
  function api_leave_feed (line 724) | def api_leave_feed(feed_id: int) -> ResponseReturnValue:
  function get_user_aggregate_feed (line 742) | def get_user_aggregate_feed(user_id: int) -> Response:
  function get_aggregate_feed_redirect (line 772) | def get_aggregate_feed_redirect() -> ResponseReturnValue:
  function create_aggregate_feed_link (line 803) | def create_aggregate_feed_link() -> ResponseReturnValue:
  function _require_user_or_error (line 886) | def _require_user_or_error(
  function _serialize_feed (line 906) | def _serialize_feed(

FILE: src/app/routes/jobs_routes.py
  function api_list_active_jobs (line 20) | def api_list_active_jobs() -> ResponseReturnValue:
  function api_list_all_jobs (line 30) | def api_list_all_jobs() -> ResponseReturnValue:
  function api_job_manager_status (line 40) | def api_job_manager_status() -> ResponseReturnValue:
  function api_cancel_job (line 46) | def api_cancel_job(job_id: str) -> ResponseReturnValue:
  function api_cleanup_preview (line 73) | def api_cleanup_preview() -> ResponseReturnValue:
  function api_run_cleanup (line 86) | def api_run_cleanup() -> ResponseReturnValue:

FILE: src/app/routes/main_routes.py
  function index (line 22) | def index() -> flask.Response:
  function landing_status (line 35) | def landing_status() -> flask.Response:
  function catch_all (line 83) | def catch_all(path: str) -> flask.Response:
  function whitelist_all (line 105) | def whitelist_all(f_id: str, val: str) -> flask.Response:
  function set_whitelist (line 136) | def set_whitelist(p_guid: str, val: str) -> flask.Response:

FILE: src/app/routes/post_routes.py
  function _is_latest_post (line 37) | def _is_latest_post(feed: Feed, post: Post) -> bool:
  function _increment_download_count (line 47) | def _increment_download_count(post: Post) -> None:
  function _ensure_whitelisted_for_download (line 57) | def _ensure_whitelisted_for_download(
  function _missing_processed_audio_response (line 86) | def _missing_processed_audio_response(post: Post, p_guid: str) -> flask....
  function api_feed_posts (line 122) | def api_feed_posts(feed_id: int) -> flask.Response:
  function api_post_processing_estimate (line 199) | def api_post_processing_estimate(p_guid: str) -> ResponseReturnValue:
  function get_post_json (line 225) | def get_post_json(p_guid: str) -> flask.Response:
  function post_debug (line 295) | def post_debug(p_guid: str) -> flask.Response:
  function api_post_stats (line 346) | def api_post_stats(p_guid: str) -> flask.Response:
  function api_toggle_whitelist (line 499) | def api_toggle_whitelist(p_guid: str) -> ResponseReturnValue:
  function api_toggle_whitelist_all (line 567) | def api_toggle_whitelist_all(feed_id: int) -> ResponseReturnValue:
  function api_process_post (line 625) | def api_process_post(p_guid: str) -> ResponseReturnValue:
  function api_reprocess_post (line 707) | def api_reprocess_post(p_guid: str) -> ResponseReturnValue:
  function api_post_status (line 831) | def api_post_status(p_guid: str) -> ResponseReturnValue:
  function api_get_post_audio (line 843) | def api_get_post_audio(p_guid: str) -> ResponseReturnValue:
  function api_download_post (line 893) | def api_download_post(p_guid: str) -> flask.Response:
  function api_download_original_post (line 928) | def api_download_original_post(p_guid: str) -> flask.Response:
  function download_post_legacy (line 964) | def download_post_legacy(p_guid: str) -> flask.Response:
  function download_original_post_legacy (line 969) | def download_original_post_legacy(p_guid: str) -> flask.Response:

FILE: src/app/routes/post_stats_utils.py
  function count_model_calls (line 6) | def count_model_calls(
  function parse_refined_windows (line 24) | def parse_refined_windows(raw_refined: Any) -> List[Tuple[float, float]]:
  function is_mixed_segment (line 50) | def is_mixed_segment(

FILE: src/app/timeout_decorator.py
  class TimeoutException (line 8) | class TimeoutException(Exception):
  function timeout_decorator (line 12) | def timeout_decorator(timeout: int) -> Callable[[Callable[..., T]], Call...

FILE: src/app/writer/actions/cleanup.py
  function cleanup_missing_audio_paths_action (line 18) | def cleanup_missing_audio_paths_action(params: Dict[str, Any]) -> int:
  function clear_post_processing_data_action (line 59) | def clear_post_processing_data_action(params: Dict[str, Any]) -> Dict[st...
  function cleanup_processed_post_action (line 109) | def cleanup_processed_post_action(params: Dict[str, Any]) -> Dict[str, A...

FILE: src/app/writer/actions/feeds.py
  function refresh_feed_action (line 23) | def refresh_feed_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function add_feed_action (line 67) | def add_feed_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function update_feed_settings_action (line 109) | def update_feed_settings_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function increment_download_count_action (line 127) | def increment_download_count_action(params: Dict[str, Any]) -> Dict[str,...
  function whitelist_post_action (line 140) | def whitelist_post_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function ensure_user_feed_membership_action (line 151) | def ensure_user_feed_membership_action(params: Dict[str, Any]) -> Dict[s...
  function remove_user_feed_membership_action (line 170) | def remove_user_feed_membership_action(params: Dict[str, Any]) -> Dict[s...
  function whitelist_latest_post_for_feed_action (line 182) | def whitelist_latest_post_for_feed_action(params: Dict[str, Any]) -> Dic...
  function toggle_whitelist_all_for_feed_action (line 202) | def toggle_whitelist_all_for_feed_action(params: Dict[str, Any]) -> Dict...
  function create_dev_test_feed_action (line 215) | def create_dev_test_feed_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function delete_feed_cascade_action (line 268) | def delete_feed_cascade_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function _hash_token (line 343) | def _hash_token(secret_value: str) -> str:
  function create_feed_access_token_action (line 347) | def create_feed_access_token_action(params: Dict[str, Any]) -> Dict[str,...
  function touch_feed_access_token_action (line 389) | def touch_feed_access_token_action(params: Dict[str, Any]) -> Dict[str, ...

FILE: src/app/writer/actions/jobs.py
  function dequeue_job_action (line 9) | def dequeue_job_action(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
  function cleanup_stale_jobs_action (line 38) | def cleanup_stale_jobs_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function clear_all_jobs_action (line 51) | def clear_all_jobs_action(params: Dict[str, Any]) -> int:
  function create_job_action (line 59) | def create_job_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function cancel_existing_jobs_action (line 78) | def cancel_existing_jobs_action(params: Dict[str, Any]) -> int:
  function update_job_status_action (line 101) | def update_job_status_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function mark_cancelled_action (line 136) | def mark_cancelled_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function reassign_pending_jobs_action (line 154) | def reassign_pending_jobs_action(params: Dict[str, Any]) -> int:

FILE: src/app/writer/actions/processor.py
  function upsert_model_call_action (line 13) | def upsert_model_call_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function upsert_whisper_model_call_action (line 75) | def upsert_whisper_model_call_action(params: Dict[str, Any]) -> Dict[str...
  function _normalize_segments_payload (line 137) | def _normalize_segments_payload(
  function replace_transcription_action (line 156) | def replace_transcription_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function mark_model_call_failed_action (line 213) | def mark_model_call_failed_action(params: Dict[str, Any]) -> Dict[str, A...
  function insert_identifications_action (line 231) | def insert_identifications_action(params: Dict[str, Any]) -> Dict[str, A...
  function replace_identifications_action (line 258) | def replace_identifications_action(params: Dict[str, Any]) -> Dict[str, ...

FILE: src/app/writer/actions/system.py
  function ensure_active_run_action (line 12) | def ensure_active_run_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function update_discord_settings_action (line 34) | def update_discord_settings_action(params: Dict[str, Any]) -> Dict[str, ...
  function update_combined_config_action (line 55) | def update_combined_config_action(params: Dict[str, Any]) -> Dict[str, A...

FILE: src/app/writer/actions/users.py
  function create_user_action (line 8) | def create_user_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function update_user_password_action (line 29) | def update_user_password_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function delete_user_action (line 46) | def delete_user_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function set_user_role_action (line 69) | def set_user_role_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function set_manual_feed_allowance_action (line 84) | def set_manual_feed_allowance_action(params: Dict[str, Any]) -> Dict[str...
  function upsert_discord_user_action (line 107) | def upsert_discord_user_action(params: Dict[str, Any]) -> Dict[str, Any]:
  function set_user_billing_fields_action (line 145) | def set_user_billing_fields_action(params: Dict[str, Any]) -> Dict[str, ...
  function set_user_billing_by_customer_id_action (line 167) | def set_user_billing_by_customer_id_action(params: Dict[str, Any]) -> Di...
  function update_user_last_active_action (line 187) | def update_user_last_active_action(params: Dict[str, Any]) -> Dict[str, ...

FILE: src/app/writer/client.py
  class WriterClient (line 13) | class WriterClient:
    method __init__ (line 14) | def __init__(self) -> None:
    method connect (line 18) | def connect(self) -> None:
    method _should_use_local_fallback (line 23) | def _should_use_local_fallback(self) -> bool:
    method _local_execute (line 33) | def _local_execute(self, cmd: WriteCommand) -> WriteResult:
    method _local_execute_single (line 57) | def _local_execute_single(
    method _local_execute_transaction (line 64) | def _local_execute_transaction(
    method _local_execute_action (line 95) | def _local_execute_action(self, cmd: WriteCommand) -> WriteResult:
    method _local_execute_model (line 114) | def _local_execute_model(
    method submit (line 128) | def submit(
    method create (line 157) | def create(
    method update (line 165) | def update(
    method delete (line 174) | def delete(self, model: str, pk: Any, wait: bool = True) -> Optional[W...
    method action (line 183) | def action(

FILE: src/app/writer/executor.py
  class CommandExecutor (line 15) | class CommandExecutor:
    method __init__ (line 16) | def __init__(self, app: Flask):
    method _register_default_actions (line 22) | def _register_default_actions(self) -> None:
    method _discover_models (line 141) | def _discover_models(self) -> Dict[str, Any]:
    method register_action (line 149) | def register_action(self, name: str, func: Callable[[Dict[str, Any]], ...
    method process_command (line 152) | def process_command(self, cmd: WriteCommand) -> WriteResult:
    method _execute_single_command (line 203) | def _execute_single_command(self, cmd: WriteCommand) -> WriteResult:
    method _handle_transaction (line 222) | def _handle_transaction(self, cmd: WriteCommand) -> WriteResult:
    method _handle_action (line 269) | def _handle_action(self, cmd: WriteCommand) -> WriteResult:

FILE: src/app/writer/model_ops.py
  function execute_model_command (line 8) | def execute_model_command(

FILE: src/app/writer/protocol.py
  class WriteCommandType (line 6) | class WriteCommandType(Enum):
  class WriteCommand (line 17) | class WriteCommand:
  class WriteResult (line 27) | class WriteResult:

FILE: src/app/writer/service.py
  function run_writer_service (line 14) | def run_writer_service() -> None:

FILE: src/main.py
  function main (line 8) | def main() -> None:

FILE: src/migrations/env.py
  function get_engine (line 17) | def get_engine():
  function get_engine_url (line 26) | def get_engine_url():
  function get_metadata (line 46) | def get_metadata():
  function run_migrations_offline (line 52) | def run_migrations_offline():
  function run_migrations_online (line 71) | def run_migrations_online():

FILE: src/migrations/versions/0d954a44fa8e_feed_access.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 49) | def downgrade():

FILE: src/migrations/versions/16311623dd58_env_hash.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 29) | def downgrade():

FILE: src/migrations/versions/185d3448990e_stripe.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 73) | def downgrade():

FILE: src/migrations/versions/18c2402c9202_cleanup_retention_days.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 29) | def downgrade():

FILE: src/migrations/versions/2e25a15d11de_per_feed_auto_whitelist.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 31) | def downgrade():

FILE: src/migrations/versions/31d767deb401_credits.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 119) | def downgrade():

FILE: src/migrations/versions/35b12b2d9feb_landing_page.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 34) | def downgrade():

FILE: src/migrations/versions/3c7f5f7640e4_add_counters_reset_timestamp.py
  function upgrade (line 21) | def upgrade() -> None:
  function downgrade (line 45) | def downgrade() -> None:

FILE: src/migrations/versions/3d232f215842_migration.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 34) | def downgrade():

FILE: src/migrations/versions/3eb0a3a0870b_discord.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 35) | def downgrade():

FILE: src/migrations/versions/401071604e7b_config_tables.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 264) | def downgrade():

FILE: src/migrations/versions/58b4eedd4c61_add_last_active_to_user.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 27) | def downgrade():

FILE: src/migrations/versions/5bccc39c9685_zero_initial_allowance.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 25) | def downgrade():

FILE: src/migrations/versions/608e0b27fcda_stronger_access_token.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 29) | def downgrade():

FILE: src/migrations/versions/611dcb5d7f12_add_image_url_to_post_model_for_episode_.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 27) | def downgrade():

FILE: src/migrations/versions/6e0e16299dcb_alternate_feed_id.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 27) | def downgrade():

FILE: src/migrations/versions/73a6b9f9b643_allow_null_feed_id_for_aggregate_tokens.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 27) | def downgrade():

FILE: src/migrations/versions/770771437280_episode_whitelist.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 31) | def downgrade():

FILE: src/migrations/versions/7de4e57ec4bb_discord_settings.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 36) | def downgrade():

FILE: src/migrations/versions/802a2365976d_gruanular_credits.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 40) | def downgrade():

FILE: src/migrations/versions/82cfcc8e0326_refined_cuts.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 32) | def downgrade():

FILE: src/migrations/versions/89d86978f407_limit_users.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 27) | def downgrade():

FILE: src/migrations/versions/91ff431c832e_download_count.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 49) | def downgrade():

FILE: src/migrations/versions/999b921ffc58_migration.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 99) | def downgrade():

FILE: src/migrations/versions/a6f5df1a50ac_add_users_table.py
  function upgrade (line 20) | def upgrade() -> None:
  function downgrade (line 44) | def downgrade() -> None:

FILE: src/migrations/versions/ab643af6472e_add_manual_feed_allowance_to_user.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 37) | def downgrade():

FILE: src/migrations/versions/b038c2f99086_add_processingjob_table_for_async_.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 48) | def downgrade():

FILE: src/migrations/versions/b92e47a03bb2_refactor_transcripts_to_db_tables_.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 101) | def downgrade():

FILE: src/migrations/versions/bae70e584468_.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 64) | def downgrade():

FILE: src/migrations/versions/c0f8893ce927_add_skipped_jobs_columns.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 47) | def downgrade():

FILE: src/migrations/versions/ded4b70feadb_add_image_metadata_to_feed.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 25) | def downgrade():

FILE: src/migrations/versions/e1325294473b_add_autoprocess_on_download.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 34) | def downgrade():

FILE: src/migrations/versions/eb51923af483_multiple_supporters.py
  function _table_exists (line 22) | def _table_exists(table_name: str) -> bool:
  function _column_exists (line 29) | def _column_exists(table_name: str, column_name: str) -> bool:
  function upgrade (line 37) | def upgrade():
  function downgrade (line 128) | def downgrade():

FILE: src/migrations/versions/f6d5fee57cc3_tz_fix.py
  function upgrade (line 21) | def upgrade():
  function downgrade (line 71) | def downgrade():

FILE: src/migrations/versions/f7a4195e0953_add_enable_boundary_refinement_to_llm_.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 34) | def downgrade():

FILE: src/migrations/versions/fa3a95ecd67d_audio_processing_paths.py
  function upgrade (line 19) | def upgrade():
  function downgrade (line 30) | def downgrade():

FILE: src/podcast_processor/ad_classifier.py
  class ClassifyParams (line 41) | class ClassifyParams:
    method __init__ (line 42) | def __init__(
  class ClassifyException (line 57) | class ClassifyException(Exception):
  class AdClassifier (line 61) | class AdClassifier:
    method __init__ (line 64) | def __init__(
    method classify (line 126) | def classify(
    method _step (line 223) | def _step(
    method _process_chunk (line 291) | def _process_chunk(
    method _build_chunk_payload (line 338) | def _build_chunk_payload(
    method _combine_overlap_segments (line 411) | def _combine_overlap_segments(
    method _compute_next_overlap_segments (line 443) | def _compute_next_overlap_segments(
    method _apply_overlap_cap (line 494) | def _apply_overlap_cap(
    method _segments_covering_tail (line 533) | def _segments_covering_tail(
    method _validate_token_limit (line 555) | def _validate_token_limit(self, user_prompt_str: str, system_prompt: s...
    method _prepare_api_call (line 589) | def _prepare_api_call(
    method _generate_user_prompt (line 663) | def _generate_user_prompt(
    method _get_or_create_model_call (line 688) | def _get_or_create_model_call(
    method _should_call_llm (line 721) | def _should_call_llm(self, model_call: ModelCall) -> bool:
    method _perform_llm_call (line 725) | def _perform_llm_call(self, *, model_call: ModelCall, system_prompt: s...
    method _handle_test_mode_call (line 741) | def _handle_test_mode_call(self, model_call: ModelCall) -> None:
    method _process_successful_response (line 763) | def _process_successful_response(
    method _create_identifications (line 795) | def _create_identifications(
    method _adjust_confidence (line 880) | def _adjust_confidence(
    method _maybe_add_preroll_context (line 893) | def _maybe_add_preroll_context(
    method _find_matching_segment (line 940) | def _find_matching_segment(
    method _segment_has_ad_identification (line 956) | def _segment_has_ad_identification(self, transcript_segment_id: int) -...
    method _is_retryable_error (line 971) | def _is_retryable_error(self, error: Exception) -> bool:
    method _call_model (line 987) | def _call_model(
    method _handle_retryable_error (line 1112) | def _handle_retryable_error(
    method _handle_retry_exhausted (line 1155) | def _handle_retry_exhausted(
    method _get_segments_bulk (line 1182) | def _get_segments_bulk(
    method _get_existing_ids_bulk (line 1204) | def _get_existing_ids_bulk(
    method _create_identifications_bulk (line 1224) | def _create_identifications_bulk(
    method expand_neighbors_bulk (line 1241) | def expand_neighbors_bulk(
    method _should_expand_neighbor (line 1331) | def _should_expand_neighbor(
    method _neighbor_confidence (line 1347) | def _neighbor_confidence(
    method _refine_boundaries (line 1361) | def _refine_boundaries(
    method _group_into_blocks (line 1464) | def _group_into_blocks(
    method _create_block (line 1494) | def _create_block(self, identifications: List[Identification]) -> Dict...
    method _apply_refinement (line 1503) | def _apply_refinement(

FILE: src/podcast_processor/ad_merger.py
  class AdGroup (line 9) | class AdGroup:
  class AdMerger (line 18) | class AdMerger:
    method __init__ (line 19) | def __init__(self) -> None:
    method merge (line 28) | def merge(
    method _group_by_proximity (line 51) | def _group_by_proximity(
    method _create_group (line 77) | def _create_group(
    method _extract_keywords (line 92) | def _extract_keywords(self, segments: List[TranscriptSegment]) -> List...
    method _refine_by_content (line 117) | def _refine_by_content(
    method _should_merge (line 159) | def _should_merge(self, group1: AdGroup, group2: AdGroup) -> bool:
    method _is_valid_group (line 181) | def _is_valid_group(self, group: AdGroup) -> bool:

FILE: src/podcast_processor/audio.py
  function get_audio_duration_ms (line 13) | def get_audio_duration_ms(file_path: str) -> Optional[int]:
  function clip_segments_with_fade (line 31) | def clip_segments_with_fade(
  function _clip_segments_complex (line 61) | def _clip_segments_complex(
  function _clip_segments_simple (line 109) | def _clip_segments_simple(
  function trim_file (line 174) | def trim_file(in_path: Path, out_path: Path, start_ms: int, end_ms: int)...
  function split_audio (line 204) | def split_audio(

FILE: src/podcast_processor/audio_processor.py
  class AudioProcessor (line 12) | class AudioProcessor:
    method __init__ (line 15) | def __init__(
    method get_ad_segments (line 35) | def get_ad_segments(self, post: Post) -> List[Tuple[float, float]]:
    method _apply_refined_boundaries (line 122) | def _apply_refined_boundaries(self, post: Post, ad_groups: Any) -> None:
    method _safe_get_post_row (line 141) | def _safe_get_post_row(self, post: Post) -> Optional[Post]:
    method _parse_refined_boundaries (line 148) | def _parse_refined_boundaries(
    method _refined_overlap_window_for_group (line 187) | def _refined_overlap_window_for_group(
    method merge_ad_segments (line 207) | def merge_ad_segments(
    method _get_last_segment_if_near_end (line 259) | def _get_last_segment_if_near_end(
    method _merge_close_segments (line 272) | def _merge_close_segments(
    method _filter_short_segments (line 288) | def _filter_short_segments(
    method _restore_last_segment_if_needed (line 296) | def _restore_last_segment_if_needed(
    method _extend_last_segment_to_end_if_needed (line 307) | def _extend_last_segment_to_end_if_needed(
    method process_audio (line 320) | def process_audio(self, post: Post, output_path: str) -> None:

FILE: src/podcast_processor/boundary_refiner.py
  class BoundaryRefinement (line 28) | class BoundaryRefinement:
  class BoundaryRefiner (line 35) | class BoundaryRefiner:
    method __init__ (line 36) | def __init__(self, config: Config, logger: Optional[logging.Logger] = ...
    method _load_template (line 41) | def _load_template(self) -> Template:
    method refine (line 57) | def refine(
    method _update_model_call (line 262) | def _update_model_call(
    method _get_context (line 292) | def _get_context(
    method _heuristic_refine (line 308) | def _heuristic_refine(
    method _validate (line 359) | def _validate(

FILE: src/podcast_processor/cue_detector.py
  class CueDetector (line 5) | class CueDetector:
    method __init__ (line 6) | def __init__(self) -> None:
    method has_cue (line 29) | def has_cue(self, text: str) -> bool:
    method analyze (line 37) | def analyze(self, text: str) -> Dict[str, bool]:
    method highlight_cues (line 47) | def highlight_cues(self, text: str) -> str:

FILE: src/podcast_processor/llm_concurrency_limiter.py
  class LLMConcurrencyLimiter (line 16) | class LLMConcurrencyLimiter:
    method __init__ (line 19) | def __init__(self, max_concurrent_calls: int):
    method acquire (line 36) | def acquire(self, timeout: Optional[float] = None) -> bool:
    method release (line 60) | def release(self) -> None:
    method get_available_slots (line 69) | def get_available_slots(self) -> int:
    method get_active_calls (line 73) | def get_active_calls(self) -> int:
  function get_concurrency_limiter (line 82) | def get_concurrency_limiter(max_concurrent_calls: int = 3) -> LLMConcurr...
  class ConcurrencyContext (line 93) | class ConcurrencyContext:
    method __init__ (line 96) | def __init__(self, limiter: LLMConcurrencyLimiter, timeout: Optional[f...
    method __enter__ (line 108) | def __enter__(self) -> "ConcurrencyContext":
    method __exit__ (line 117) | def __exit__(

FILE: src/podcast_processor/llm_error_classifier.py
  class LLMErrorClassifier (line 13) | class LLMErrorClassifier:
    method is_retryable_error (line 52) | def is_retryable_error(cls, error: Union[Exception, str]) -> bool:
    method get_error_category (line 81) | def get_error_category(cls, error: Union[Exception, str]) -> str:
    method get_suggested_backoff (line 111) | def get_suggested_backoff(cls, error: Union[Exception, str], attempt: ...
    method _matches_patterns (line 135) | def _matches_patterns(text: str, patterns: list[re.Pattern[str]]) -> b...

FILE: src/podcast_processor/llm_model_call_utils.py
  function render_prompt_and_upsert_model_call (line 9) | def render_prompt_and_upsert_model_call(
  function try_upsert_model_call (line 43) | def try_upsert_model_call(
  function try_update_model_call (line 80) | def try_update_model_call(
  function extract_litellm_content (line 114) | def extract_litellm_content(response: Any) -> str:

FILE: src/podcast_processor/model_output.py
  class AdSegmentPrediction (line 10) | class AdSegmentPrediction(BaseModel):
  class AdSegmentPredictionList (line 15) | class AdSegmentPredictionList(BaseModel):
  function _attempt_json_repair (line 28) | def _attempt_json_repair(json_str: str) -> str:
  function clean_and_parse_model_output (line 94) | def clean_and_parse_model_output(model_output: str) -> AdSegmentPredicti...

FILE: src/podcast_processor/podcast_downloader.py
  class PodcastDownloader (line 21) | class PodcastDownloader:
    method __init__ (line 26) | def __init__(
    method download_episode (line 32) | def download_episode(self, post: Post, dest_path: str) -> Optional[str]:
    method get_and_make_download_path (line 88) | def get_and_make_download_path(self, post_title: str) -> Path:
  function sanitize_title (line 110) | def sanitize_title(title: str) -> str:
  function find_audio_link (line 115) | def find_audio_link(entry: Any) -> str:
  function _iter_enclosure_audio_urls (line 143) | def _iter_enclosure_audio_urls(entry: Any, audio_mime_types: Set[str]) -...
  function _iter_link_audio_urls (line 157) | def _iter_link_audio_urls(
  function download_episode (line 182) | def download_episode(post: Post, dest_path: str) -> Optional[str]:
  function get_and_make_download_path (line 186) | def get_and_make_download_path(post_title: str) -> Path:

FILE: src/podcast_processor/podcast_processor.py
  function get_post_processed_audio_path (line 35) | def get_post_processed_audio_path(post: Post) -> Optional[ProcessingPaths]:
  function get_post_processed_audio_path_cached (line 53) | def get_post_processed_audio_path_cached(
  class PodcastProcessor (line 72) | class PodcastProcessor:
    method __init__ (line 81) | def __init__(
    method process (line 126) | def process(
    method _acquire_processing_lock (line 271) | def _acquire_processing_lock(
    method _perform_processing_steps (line 323) | def _perform_processing_steps(
    method _raise_if_cancelled (line 374) | def _raise_if_cancelled(
    method _classify_ad_segments (line 387) | def _classify_ad_segments(
    method _simulate_developer_processing (line 415) | def _simulate_developer_processing(
    method _handle_download_step (line 491) | def _handle_download_step(
    method make_dirs (line 570) | def make_dirs(self, processing_paths: ProcessingPaths) -> None:
    method get_system_prompt (line 577) | def get_system_prompt(self, system_prompt_path: str) -> str:
    method get_user_prompt_template (line 582) | def get_user_prompt_template(self, prompt_template_path: str) -> Templ...
    method remove_audio_files_and_reset_db (line 587) | def remove_audio_files_and_reset_db(self, post_id: Optional[int]) -> N...
    method _remove_unprocessed_audio (line 631) | def _remove_unprocessed_audio(self, post: Post) -> None:
    method _check_existing_processed_audio (line 652) | def _check_existing_processed_audio(self, post: Post) -> bool:
  class ProcessorException (line 709) | class ProcessorException(Exception):

FILE: src/podcast_processor/processing_status_manager.py
  class ProcessingStatusManager (line 12) | class ProcessingStatusManager:
    method __init__ (line 18) | def __init__(self, db_session: Any, logger: Optional[logging.Logger] =...
    method generate_job_id (line 22) | def generate_job_id(self) -> str:
    method create_job (line 26) | def create_job(
    method cancel_existing_jobs (line 57) | def cancel_existing_jobs(self, post_guid: str, current_job_id: str) ->...
    method update_job_status (line 66) | def update_job_status(
    method mark_cancelled (line 117) | def mark_cancelled(self, job_id: str, error_message: Optional[str] = N...

FILE: src/podcast_processor/prompt.py
  function transcript_excerpt_for_prompt (line 13) | def transcript_excerpt_for_prompt(
  function generate_system_prompt (line 29) | def generate_system_prompt() -> str:

FILE: src/podcast_processor/token_rate_limiter.py
  class TokenRateLimiter (line 18) | class TokenRateLimiter:
    method __init__ (line 26) | def __init__(self, tokens_per_minute: int = 30000, window_minutes: int...
    method count_tokens (line 45) | def count_tokens(self, messages: List[Dict[str, str]], model: str) -> ...
    method _cleanup_old_usage (line 67) | def _cleanup_old_usage(self, current_time: float) -> None:
    method _get_current_usage (line 73) | def _get_current_usage(self, current_time: float) -> int:
    method check_rate_limit (line 78) | def check_rate_limit(
    method record_usage (line 120) | def record_usage(self, messages: List[Dict[str, str]], model: str) -> ...
    method wait_if_needed (line 137) | def wait_if_needed(self, messages: List[Dict[str, str]], model: str) -...
    method get_usage_stats (line 156) | def get_usage_stats(self) -> Dict[str, Union[int, float]]:
  function get_rate_limiter (line 176) | def get_rate_limiter(tokens_per_minute: int = 30000) -> TokenRateLimiter:
  function configure_rate_limiter_for_model (line 184) | def configure_rate_limiter_for_model(model: str) -> TokenRateLimiter:

FILE: src/podcast_processor/transcribe.py
  class Segment (line 17) | class Segment(BaseModel):
  class Transcriber (line 23) | class Transcriber(ABC):
    method model_name (line 27) | def model_name(self) -> str:
    method transcribe (line 31) | def transcribe(self, audio_file_path: str) -> List[Segment]:
  class LocalTranscriptSegment (line 35) | class LocalTranscriptSegment(BaseModel):
    method to_segment (line 47) | def to_segment(self) -> Segment:
  class TestWhisperTranscriber (line 51) | class TestWhisperTranscriber(Transcriber):
    method __init__ (line 53) | def __init__(self, logger: logging.Logger):
    method model_name (line 57) | def model_name(self) -> str:
    method transcribe (line 60) | def transcribe(self, _: str) -> List[Segment]:
  class LocalWhisperTranscriber (line 68) | class LocalWhisperTranscriber(Transcriber):
    method __init__ (line 70) | def __init__(self, logger: logging.Logger, whisper_model: str):
    method model_name (line 75) | def model_name(self) -> str:
    method convert_to_pydantic (line 79) | def convert_to_pydantic(
    method local_seg_to_seg (line 85) | def local_seg_to_seg(local_segments: List[LocalTranscriptSegment]) -> ...
    method transcribe (line 88) | def transcribe(self, audio_file_path: str) -> List[Segment]:
  class OpenAIWhisperTranscriber (line 116) | class OpenAIWhisperTranscriber(Transcriber):
    method __init__ (line 118) | def __init__(self, logger: logging.Logger, config: RemoteWhisperConfig):
    method model_name (line 129) | def model_name(self) -> str:
    method transcribe (line 132) | def transcribe(self, audio_file_path: str) -> List[Segment]:
    method convert_segments (line 173) | def convert_segments(segments: List[TranscriptionSegment]) -> List[Seg...
    method add_offset_to_segments (line 184) | def add_offset_to_segments(
    method get_segments_for_chunk (line 194) | def get_segments_for_chunk(self, chunk_path: str) -> List[Transcriptio...
  class GroqTranscriptionSegment (line 220) | class GroqTranscriptionSegment(BaseModel):
  class GroqWhisperTranscriber (line 226) | class GroqWhisperTranscriber(Transcriber):
    method __init__ (line 228) | def __init__(self, logger: logging.Logger, config: GroqWhisperConfig):
    method model_name (line 237) | def model_name(self) -> str:
    method transcribe (line 240) | def transcribe(self, audio_file_path: str) -> List[Segment]:
    method convert_segments (line 279) | def convert_segments(segments: List[GroqTranscriptionSegment]) -> List...
    method add_offset_to_segments (line 290) | def add_offset_to_segments(
    method get_segments_for_chunk (line 300) | def get_segments_for_chunk(self, chunk_path: str) -> List[GroqTranscri...

FILE: src/podcast_processor/transcription_manager.py
  class TranscriptionManager (line 24) | class TranscriptionManager:
    method __init__ (line 27) | def __init__(
    method _create_transcriber (line 45) | def _create_transcriber(self) -> Transcriber:
    method _check_existing_transcription (line 62) | def _check_existing_transcription(
    method _get_or_create_whisper_model_call (line 122) | def _get_or_create_whisper_model_call(self, post: Post) -> ModelCall:
    method transcribe (line 146) | def transcribe(self, post: Post) -> List[TranscriptSegment]:

FILE: src/podcast_processor/word_boundary_refiner.py
  class WordBoundaryRefinement (line 32) | class WordBoundaryRefinement:
  class WordBoundaryRefiner (line 39) | class WordBoundaryRefiner:
    method __init__ (line 46) | def __init__(self, config: Config, logger: Optional[logging.Logger] = ...
    method _load_template (line 51) | def _load_template(self) -> Template:
    method refine (line 67) | def refine(
    method _fallback (line 206) | def _fallback(self, ad_start: float, ad_end: float) -> WordBoundaryRef...
    method _constrain_start (line 214) | def _constrain_start(self, estimated_start: float, orig_start: float) ...
    method _constrain_end (line 217) | def _constrain_end(self, estimated_end: float, orig_end: float) -> float:
    method _parse_json (line 221) | def _parse_json(self, content: str) -> Optional[Dict[str, Any]]:
    method _has_text (line 234) | def _has_text(value: Any) -> bool:
    method _extract_payload (line 242) | def _extract_payload(self, parsed: Dict[str, Any]) -> Dict[str, Any]:
    method _default_reason (line 260) | def _default_reason(reason: str, *, changed: bool) -> str:
    method _result_status (line 266) | def _result_status(
    method _refine_start (line 273) | def _refine_start(
    method _refine_end (line 320) | def _refine_end(
    method _get_context (line 350) | def _get_context(
    method _context_by_seq_window (line 369) | def _context_by_seq_window(
    method _context_by_time_overlap (line 404) | def _context_by_time_overlap(
    method _segment_overlaps (line 423) | def _segment_overlaps(
    method _estimate_phrase_times (line 436) | def _estimate_phrase_times(
    method _estimate_phrase_time (line 462) | def _estimate_phrase_time(
    method _find_phrase_match (line 537) | def _find_phrase_match(
    method _find_subsequence (line 566) | def _find_subsequence(
    method _estimate_word_time (line 584) | def _estimate_word_time(
    method _find_segment (line 620) | def _find_segment(
    method _split_words (line 635) | def _split_words(self, text: str) -> List[str]:
    method _normalize_token (line 642) | def _normalize_token(self, token: str) -> str:
    method _resolve_word_index (line 650) | def _resolve_word_index(
    method _update_model_call (line 677) | def _update_model_call(

FILE: src/shared/config.py
  class ProcessingConfig (line 10) | class ProcessingConfig(BaseModel):
    method validate_overlap_limits (line 19) | def validate_overlap_limits(self) -> "ProcessingConfig":
  class OutputConfig (line 26) | class OutputConfig(BaseModel):
    method min_ad_segment_separation_seconds (line 33) | def min_ad_segment_separation_seconds(self) -> int:
    method min_ad_segment_separation_seconds (line 38) | def min_ad_segment_separation_seconds(self, value: int) -> None:
  class TestWhisperConfig (line 45) | class TestWhisperConfig(BaseModel):
  class RemoteWhisperConfig (line 49) | class RemoteWhisperConfig(BaseModel):
  class GroqWhisperConfig (line 59) | class GroqWhisperConfig(BaseModel):
  class LocalWhisperConfig (line 67) | class LocalWhisperConfig(BaseModel):
  class Config (line 72) | class Config(BaseModel):
    method redacted (line 153) | def redacted(self) -> Config:
    method validate_whisper_config (line 162) | def validate_whisper_config(self) -> "Config":

FILE: src/shared/interfaces.py
  class Post (line 7) | class Post(Protocol):
    method whitelisted (line 16) | def whitelisted(self) -> bool:

FILE: src/shared/llm_utils.py
  function model_uses_max_completion_tokens (line 20) | def model_uses_max_completion_tokens(model_name: str | None) -> bool:

FILE: src/shared/processing_paths.py
  class ProcessingPaths (line 8) | class ProcessingPaths:
  function paths_from_unprocessed_path (line 12) | def paths_from_unprocessed_path(
  function get_job_unprocessed_path (line 31) | def get_job_unprocessed_path(post_guid: str, job_id: str, post_title: st...
  function get_instance_dir (line 44) | def get_instance_dir() -> Path:
  function get_base_podcast_data_dir (line 52) | def get_base_podcast_data_dir() -> Path:
  function get_in_root (line 59) | def get_in_root() -> Path:
  function get_srv_root (line 63) | def get_srv_root() -> Path:

FILE: src/shared/test_utils.py
  function create_standard_test_config (line 8) | def create_standard_test_config(

FILE: src/tests/conftest.py
  function app (line 46) | def app() -> Generator[Flask, None, None]:
  function test_config (line 59) | def test_config() -> Config:
  function test_logger (line 64) | def test_logger() -> logging.Logger:
  function mock_db_session (line 69) | def mock_db_session() -> MagicMock:
  function mock_transcription_manager (line 80) | def mock_transcription_manager() -> MagicMock:
  function mock_ad_classifier (line 94) | def mock_ad_classifier() -> MagicMock:
  function mock_audio_processor (line 101) | def mock_audio_processor() -> MagicMock:
  function mock_downloader (line 108) | def mock_downloader() -> MagicMock:
  function mock_status_manager (line 116) | def mock_status_manager() -> MagicMock:

FILE: src/tests/test_ad_classifier.py
  function app (line 22) | def app() -> Generator[Flask, None, None]:
  function test_config (line 35) | def test_config() -> Config:
  function mock_db_session (line 40) | def mock_db_session() -> MagicMock:
  function test_classifier (line 51) | def test_classifier(test_config: Config) -> AdClassifier:
  function test_classifier_with_mocks (line 57) | def test_classifier_with_mocks(
  function test_call_model (line 72) | def test_call_model(test_config: Config, app: Flask) -> None:
  function test_call_model_retry_on_internal_error (line 115) | def test_call_model_retry_on_internal_error(test_config: Config, app: Fl...
  function test_process_chunk (line 169) | def test_process_chunk(test_config: Config, app: Flask) -> None:
  function test_compute_next_overlap_segments_includes_context (line 247) | def test_compute_next_overlap_segments_includes_context(
  function test_compute_next_overlap_segments_respects_cap (line 274) | def test_compute_next_overlap_segments_respects_cap(
  function test_compute_next_overlap_segments_baseline_overlap_without_ads (line 300) | def test_compute_next_overlap_segments_baseline_overlap_without_ads(
  function test_create_identifications_skips_existing_ad_label (line 323) | def test_create_identifications_skips_existing_ad_label(
  function test_build_chunk_payload_trims_for_token_limit (line 360) | def test_build_chunk_payload_trims_for_token_limit(

FILE: src/tests/test_ad_classifier_rate_limiting_integration.py
  class TestAdClassifierRateLimiting (line 13) | class TestAdClassifierRateLimiting:
    method test_rate_limiter_initialization_enabled (line 16) | def test_rate_limiter_initialization_enabled(self):
    method test_rate_limiter_initialization_disabled (line 29) | def test_rate_limiter_initialization_disabled(self):
    method test_rate_limiter_custom_limit (line 38) | def test_rate_limiter_custom_limit(self):
    method test_is_retryable_error_rate_limit_errors (line 48) | def test_is_retryable_error_rate_limit_errors(self):
    method test_is_retryable_error_non_retryable (line 67) | def test_is_retryable_error_non_retryable(self):
    method test_call_model_with_rate_limiter (line 86) | def test_call_model_with_rate_limiter(self, mock_isinstance, mock_lite...
    method test_rate_limit_backoff_timing (line 142) | def test_rate_limit_backoff_timing(self, mock_sleep):
    method test_rate_limiter_model_specific_configs (line 162) | def test_rate_limiter_model_specific_configs(self):

FILE: src/tests/test_aggregate_feed.py
  function test_get_user_aggregate_posts_auth_disabled (line 8) | def test_get_user_aggregate_posts_auth_disabled(app):
  function test_get_user_aggregate_posts_auth_enabled (line 47) | def test_get_user_aggregate_posts_auth_enabled(app):

FILE: src/tests/test_audio_processor.py
  function test_processor (line 15) | def test_processor(
  function test_processor_with_mocks (line 24) | def test_processor_with_mocks(
  function test_get_ad_segments (line 44) | def test_get_ad_segments(app: Flask) -> None:
  function test_merge_ad_segments (line 82) | def test_merge_ad_segments(
  function test_merge_ad_segments_with_short_segments (line 106) | def test_merge_ad_segments_with_short_segments(
  function test_merge_ad_segments_end_extension (line 128) | def test_merge_ad_segments_end_extension(
  function test_process_audio (line 148) | def test_process_audio(

FILE: src/tests/test_config_error_handling.py
  class TestConfigurationErrorHandling (line 15) | class TestConfigurationErrorHandling:
    method test_config_with_none_values (line 18) | def test_config_with_none_values(self) -> None:
    method test_zero_values (line 38) | def test_zero_values(self) -> None:
    method test_very_large_values (line 59) | def test_very_large_values(self) -> None:
    method test_boolean_field_validation (line 83) | def test_boolean_field_validation(self) -> None:
  class TestEnvKeyValidation (line 117) | class TestEnvKeyValidation:
    method test_llm_and_groq_conflict_raises (line 120) | def test_llm_and_groq_conflict_raises(self, monkeypatch: Any) -> None:
    method test_whisper_remote_allows_different_key (line 128) | def test_whisper_remote_allows_different_key(self, monkeypatch: Any) -...

FILE: src/tests/test_feeds.py
  class MockPost (line 30) | class MockPost:
    method __init__ (line 33) | def __init__(
    method audio_len_bytes (line 59) | def audio_len_bytes(self):
  class MockFeed (line 63) | class MockFeed:
    method __init__ (line 66) | def __init__(
  function mock_feed_data (line 87) | def mock_feed_data():
  function mock_db_session (line 129) | def mock_db_session(monkeypatch):
  function mock_post (line 137) | def mock_post():
  function mock_feed (line 143) | def mock_feed():
  function test_fetch_feed (line 149) | def test_fetch_feed(mock_parse, mock_feed_data):
  function test_refresh_feed (line 158) | def test_refresh_feed(mock_db_session):
  function test_should_auto_whitelist_new_posts_requires_members (line 183) | def test_should_auto_whitelist_new_posts_requires_members(
  function test_should_auto_whitelist_new_posts_true_with_members (line 195) | def test_should_auto_whitelist_new_posts_true_with_members(monkeypatch, ...
  function test_should_auto_whitelist_requires_members (line 206) | def test_should_auto_whitelist_requires_members(
  function test_should_auto_whitelist_with_members (line 219) | def test_should_auto_whitelist_with_members(monkeypatch, mock_feed, mock...
  function test_should_auto_whitelist_true_when_auth_disabled (line 230) | def test_should_auto_whitelist_true_when_auth_disabled(monkeypatch, mock...
  function test_should_auto_whitelist_true_when_no_users (line 239) | def test_should_auto_whitelist_true_when_no_users(
  function test_should_auto_whitelist_respects_feed_override_true (line 252) | def test_should_auto_whitelist_respects_feed_override_true(monkeypatch, ...
  function test_should_auto_whitelist_respects_feed_override_false (line 261) | def test_should_auto_whitelist_respects_feed_override_false(monkeypatch,...
  function test_refresh_feed_unwhitelists_without_members (line 274) | def test_refresh_feed_unwhitelists_without_members(
  function test_refresh_feed_whitelists_when_member_exists (line 301) | def test_refresh_feed_whitelists_when_member_exists(
  function test_add_or_refresh_feed_existing (line 326) | def test_add_or_refresh_feed_existing(
  function test_add_or_refresh_feed_new (line 347) | def test_add_or_refresh_feed_new(
  function test_add_feed (line 369) | def test_add_feed(mock_post_class, mock_writer_client, mock_feed_data, m...
  function test_feed_item (line 409) | def test_feed_item(mock_post, app):
  function test_feed_item_with_reverse_proxy (line 439) | def test_feed_item_with_reverse_proxy(mock_post, app):
  function test_feed_item_with_reverse_proxy_custom_port (line 472) | def test_feed_item_with_reverse_proxy_custom_port(mock_post, app):
  function test_get_base_url_without_reverse_proxy (line 505) | def test_get_base_url_without_reverse_proxy():
  function test_get_base_url_with_reverse_proxy_default_port (line 514) | def test_get_base_url_with_reverse_proxy_default_port():
  function test_get_base_url_with_reverse_proxy_custom_port (line 536) | def test_get_base_url_with_reverse_proxy_custom_port():
  function test_get_base_url_localhost (line 561) | def test_get_base_url_localhost():
  function test_generate_feed_xml_filters_processed_whitelisted (line 574) | def test_generate_feed_xml_filters_processed_whitelisted(
  function test_generate_feed_xml_includes_all_when_autoprocess_enabled (line 637) | def test_generate_feed_xml_includes_all_when_autoprocess_enabled(
  function test_make_post (line 706) | def test_make_post(mock_post_class, mock_feed):
  function test_get_guid_uses_id_if_valid_uuid (line 746) | def test_get_guid_uses_id_if_valid_uuid(mock_uuid5, mock_find_audio_link...
  function test_get_guid_generates_uuid_if_invalid_id (line 763) | def test_get_guid_generates_uuid_if_invalid_id(
  function test_get_duration_with_valid_duration (line 787) | def test_get_duration_with_valid_duration():
  function test_get_duration_with_invalid_duration (line 796) | def test_get_duration_with_invalid_duration():
  function test_get_duration_with_missing_duration (line 805) | def test_get_duration_with_missing_duration():
  function test_get_base_url_no_request_context_fallback (line 814) | def test_get_base_url_no_request_context_fallback():
  function test_get_base_url_with_http2_pseudo_headers (line 824) | def test_get_base_url_with_http2_pseudo_headers():
  function test_get_base_url_with_strict_transport_security (line 849) | def test_get_base_url_with_strict_transport_security():
  function test_get_base_url_fallback_http_without_sts (line 875) | def test_get_base_url_fallback_http_without_sts():

FILE: src/tests/test_filenames.py
  function test_filenames (line 8) | def test_filenames() -> None:

FILE: src/tests/test_helpers.py
  function create_test_config (line 10) | def create_test_config(**overrides: Any) -> Config:

FILE: src/tests/test_llm_concurrency_limiter.py
  class TestLLMConcurrencyLimiter (line 17) | class TestLLMConcurrencyLimiter:
    method test_initialization (line 20) | def test_initialization(self):
    method test_initialization_invalid_value (line 27) | def test_initialization_invalid_value(self):
    method test_acquire_and_release (line 39) | def test_acquire_and_release(self):
    method test_acquire_timeout (line 67) | def test_acquire_timeout(self):
    method test_context_manager (line 82) | def test_context_manager(self):
    method test_context_manager_timeout (line 95) | def test_context_manager_timeout(self):
    method test_thread_safety (line 109) | def test_thread_safety(self):
  class TestGlobalConcurrencyLimiter (line 149) | class TestGlobalConcurrencyLimiter:
    method test_get_concurrency_limiter_singleton (line 152) | def test_get_concurrency_limiter_singleton(self):
    method test_get_concurrency_limiter_different_limits (line 165) | def test_get_concurrency_limiter_different_limits(self):

FILE: src/tests/test_llm_error_classifier.py
  class TestLLMErrorClassifier (line 10) | class TestLLMErrorClassifier:
    method test_rate_limit_errors (line 13) | def test_rate_limit_errors(self):
    method test_timeout_errors (line 27) | def test_timeout_errors(self):
    method test_server_errors (line 40) | def test_server_errors(self):
    method test_non_retryable_errors (line 53) | def test_non_retryable_errors(self):
    method test_auth_vs_client_errors (line 69) | def test_auth_vs_client_errors(self):
    method test_unknown_errors (line 89) | def test_unknown_errors(self):
    method test_suggested_backoff (line 101) | def test_suggested_backoff(self):
    method test_exception_objects (line 126) | def test_exception_objects(self):
    method test_case_insensitive_matching (line 140) | def test_case_insensitive_matching(self):

FILE: src/tests/test_parse_model_output.py
  function test_clean_parse_output (line 11) | def test_clean_parse_output() -> None:
  function test_parse_multiple_segments_output (line 26) | def test_parse_multiple_segments_output() -> None:
  function test_clean_parse_output_malformed (line 43) | def test_clean_parse_output_malformed() -> None:
  function test_clean_parse_output_with_content_type (line 51) | def test_clean_parse_output_with_content_type() -> None:
  function test_clean_parse_output_truncated_missing_closing_brackets (line 63) | def test_clean_parse_output_truncated_missing_closing_brackets() -> None:
  function test_clean_parse_output_truncated_multiple_segments (line 72) | def test_clean_parse_output_truncated_multiple_segments() -> None:
  function test_clean_parse_output_truncated_with_content_type (line 84) | def test_clean_parse_output_truncated_with_content_type() -> None:

FILE: src/tests/test_podcast_downloader.py
  function test_post (line 14) | def test_post(app):
  function downloader (line 39) | def downloader(tmp_path):
  function mock_entry (line 45) | def mock_entry():
  function test_sanitize_title (line 60) | def test_sanitize_title():
  function test_get_and_make_download_path (line 68) | def test_get_and_make_download_path(downloader):
  function test_find_audio_link_with_audio_link (line 79) | def test_find_audio_link_with_audio_link(mock_entry):
  function test_find_audio_link_without_audio_link (line 83) | def test_find_audio_link_without_audio_link():
  function test_download_episode_already_exists (line 92) | def test_download_episode_already_exists(mock_get, test_post, downloader...
  function test_download_episode_new_file (line 110) | def test_download_episode_new_file(mock_get, test_post, downloader, app):
  function test_download_episode_download_failed (line 138) | def test_download_episode_download_failed(mock_get, test_post, downloade...
  function test_download_episode_invalid_url (line 165) | def test_download_episode_invalid_url(
  function test_download_episode_invalid_post_title (line 180) | def test_download_episode_invalid_post_title(mock_get, test_post, downlo...

FILE: src/tests/test_podcast_processor_cleanup.py
  function test_remove_unprocessed_audio_deletes_file (line 14) | def test_remove_unprocessed_audio_deletes_file(app, tmp_path) -> None:

FILE: src/tests/test_post_cleanup.py
  function _create_feed (line 18) | def _create_feed() -> Feed:
  function _create_post (line 31) | def _create_post(feed: Feed, guid: str, download_url: str) -> Post:
  function test_cleanup_removes_expired_posts (line 45) | def test_cleanup_removes_expired_posts(app, tmp_path) -> None:
  function test_cleanup_skips_when_retention_disabled (line 141) | def test_cleanup_skips_when_retention_disabled(app) -> None:
  function test_cleanup_includes_non_whitelisted_processed_posts (line 166) | def test_cleanup_includes_non_whitelisted_processed_posts(app, tmp_path)...
  function test_cleanup_skips_unprocessed_unwhitelisted_posts (line 205) | def test_cleanup_skips_unprocessed_unwhitelisted_posts(app) -> None:

FILE: src/tests/test_post_routes.py
  function test_download_endpoints_increment_counter (line 13) | def test_download_endpoints_increment_counter(app, tmp_path):
  function test_download_triggers_processing_when_enabled (line 67) | def test_download_triggers_processing_when_enabled(app):
  function test_download_missing_audio_returns_404_when_disabled (line 111) | def test_download_missing_audio_returns_404_when_disabled(app):
  function test_download_auto_whitelists_post (line 144) | def test_download_auto_whitelists_post(app, tmp_path):
  function test_download_rejects_when_not_whitelisted_and_toggle_off (line 188) | def test_download_rejects_when_not_whitelisted_and_toggle_off(app):
  function test_toggle_whitelist_all_requires_admin (line 219) | def test_toggle_whitelist_all_requires_admin(app):
  function test_feed_posts_pagination_and_filtering (line 269) | def test_feed_posts_pagination_and_filtering(app):

FILE: src/tests/test_posts.py
  class TestPostsFunctions (line 8) | class TestPostsFunctions:
    method test_remove_associated_files_files_dont_exist (line 16) | def test_remove_associated_files_files_dont_exist(

FILE: src/tests/test_process_audio.py
  function test_get_duration_ms (line 14) | def test_get_duration_ms() -> None:
  function test_clip_segment_with_fade (line 18) | def test_clip_segment_with_fade() -> None:
  function test_clip_segment_with_fade_beginning (line 44) | def test_clip_segment_with_fade_beginning() -> None:
  function test_clip_segment_with_fade_end (line 70) | def test_clip_segment_with_fade_end() -> None:
  function test_split_audio (line 99) | def test_split_audio() -> None:

FILE: src/tests/test_rate_limiting_config.py
  class TestRateLimitingConfig (line 10) | class TestRateLimitingConfig:
    method test_default_rate_limiting_config (line 13) | def test_default_rate_limiting_config(self) -> None:
    method test_custom_rate_limiting_config (line 37) | def test_custom_rate_limiting_config(self) -> None:
    method test_partial_rate_limiting_config (line 66) | def test_partial_rate_limiting_config(self) -> None:
    method test_config_field_descriptions (line 93) | def test_config_field_descriptions(self) -> None:

FILE: src/tests/test_rate_limiting_edge_cases.py
  class TestRateLimitingEdgeCases (line 15) | class TestRateLimitingEdgeCases:
    method test_token_counting_edge_cases (line 18) | def test_token_counting_edge_cases(self) -> None:
    method test_rate_limiter_boundary_conditions (line 38) | def test_rate_limiter_boundary_conditions(self) -> None:
    method test_rate_limiter_time_window_edge (line 58) | def test_rate_limiter_time_window_edge(self) -> None:
    method test_config_validation_boundary_values (line 72) | def test_config_validation_boundary_values(self) -> None:
    method test_error_classification_comprehensive (line 86) | def test_error_classification_comprehensive(self) -> None:
    method test_backoff_progression (line 134) | def test_backoff_progression(self, mock_sleep: Any) -> None:
    method test_rate_limiter_with_very_short_window (line 206) | def test_rate_limiter_with_very_short_window(self) -> None:
    method test_model_configuration_case_sensitivity (line 220) | def test_model_configuration_case_sensitivity(self) -> None:
    method test_thread_safety_stress (line 252) | def test_thread_safety_stress(self) -> None:

FILE: src/tests/test_session_auth.py
  function auth_app (line 18) | def auth_app() -> Flask:
  function test_login_sets_session_cookie_and_allows_authenticated_requests (line 78) | def test_login_sets_session_cookie_and_allows_authenticated_requests(
  function test_logout_clears_session (line 100) | def test_logout_clears_session(auth_app: Flask) -> None:
  function test_protected_route_without_session_returns_json_401 (line 112) | def test_protected_route_without_session_returns_json_401(auth_app: Flas...
  function test_feed_requires_token_when_no_session (line 120) | def test_feed_requires_token_when_no_session(auth_app: Flask) -> None:
  function test_share_link_generates_token_and_allows_query_access (line 128) | def test_share_link_generates_token_and_allows_query_access(auth_app: Fl...
  function test_share_link_returns_same_token_for_user_and_feed (line 176) | def test_share_link_returns_same_token_for_user_and_feed(auth_app: Flask...

FILE: src/tests/test_token_limit_config.py
  function test_config_validation (line 8) | def test_config_validation() -> None:

FILE: src/tests/test_token_rate_limiter.py
  class TestTokenRateLimiter (line 16) | class TestTokenRateLimiter:
    method test_initialization (line 19) | def test_initialization(self) -> None:
    method test_count_tokens (line 32) | def test_count_tokens(self) -> None:
    method test_token_counting_fallback (line 54) | def test_token_counting_fallback(self) -> None:
    method test_cleanup_old_usage (line 63) | def test_cleanup_old_usage(self) -> None:
    method test_get_current_usage (line 81) | def test_get_current_usage(self) -> None:
    method test_check_rate_limit_within_limits (line 95) | def test_check_rate_limit_within_limits(self) -> None:
    method test_check_rate_limit_exceeds_limits (line 105) | def test_check_rate_limit_exceeds_limits(self) -> None:
    method test_record_usage (line 126) | def test_record_usage(self) -> None:
    method test_wait_if_needed_no_wait (line 140) | def test_wait_if_needed_no_wait(self) -> None:
    method test_wait_if_needed_with_wait (line 158) | def test_wait_if_needed_with_wait(self) -> None:
    method test_get_usage_stats (line 179) | def test_get_usage_stats(self) -> None:
    method test_thread_safety (line 202) | def test_thread_safety(self) -> None:
  class TestGlobalRateLimiter (line 226) | class TestGlobalRateLimiter:
    method test_get_rate_limiter_singleton (line 229) | def test_get_rate_limiter_singleton(self) -> None:
    method test_get_rate_limiter_different_limits (line 237) | def test_get_rate_limiter_different_limits(self) -> None:
    method test_configure_rate_limiter_for_model_anthropic (line 246) | def test_configure_rate_limiter_for_model_anthropic(self) -> None:
    method test_configure_rate_limiter_for_model_openai (line 253) | def test_configure_rate_limiter_for_model_openai(self) -> None:
    method test_configure_rate_limiter_for_model_gemini (line 268) | def test_configure_rate_limiter_for_model_gemini(self) -> None:
    method test_configure_rate_limiter_for_model_unknown (line 280) | def test_configure_rate_limiter_for_model_unknown(self) -> None:
    method test_configure_rate_limiter_partial_match (line 285) | def test_configure_rate_limiter_partial_match(self) -> None:

FILE: src/tests/test_transcribe.py
  function test_remote_transcribe (line 12) | def test_remote_transcribe() -> None:
  function test_local_transcribe (line 30) | def test_local_transcribe() -> None:
  function test_groq_transcribe (line 43) | def test_groq_transcribe(mocker: Any) -> None:
  function test_offset (line 82) | def test_offset() -> None:

FILE: src/tests/test_transcription_manager.py
  class MockTranscriber (line 16) | class MockTranscriber(Transcriber):
    method __init__ (line 19) | def __init__(self, mock_response=None):
    method model_name (line 24) | def model_name(self) -> str:
    method transcribe (line 28) | def transcribe(self, audio_path):
  function app (line 36) | def app() -> Generator[Flask, None, None]:
  function test_config (line 49) | def test_config() -> Config:
  function test_logger (line 57) | def test_logger() -> logging.Logger:
  function mock_db_session (line 62) | def mock_db_session() -> MagicMock:
  function mock_transcriber (line 73) | def mock_transcriber() -> MockTranscriber:
  function test_manager (line 84) | def test_manager(
  function test_check_existing_transcription_success (line 108) | def test_check_existing_transcription_success(
  function test_check_existing_transcription_no_model_call (line 147) | def test_check_existing_transcription_no_model_call(
  function test_transcribe_new (line 162) | def test_transcribe_new(
  function test_transcribe_handles_error (line 203) | def test_transcribe_handles_error(
  function test_transcribe_reuses_placeholder_model_call (line 246) | def test_transcribe_reuses_placeholder_model_call(

FILE: tests/test_cue_detector.py
  class TestCueDetector (line 8) | class TestCueDetector(unittest.TestCase):
    method setUp (line 9) | def setUp(self) -> None:
    method test_highlight_cues_url (line 12) | def test_highlight_cues_url(self) -> None:
    method test_highlight_cues_promo (line 18) | def test_highlight_cues_promo(self) -> None:
    method test_highlight_cues_cta (line 26) | def test_highlight_cues_cta(self) -> None:
    method test_highlight_cues_multiple (line 31) | def test_highlight_cues_multiple(self) -> None:
    method test_highlight_cues_no_cues (line 44) | def test_highlight_cues_no_cues(self) -> None:
    method test_integration_prompt (line 48) | def test_integration_prompt(self) -> None:
Condensed preview — 260 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,411K chars).
[
  {
    "path": ".cursor/rules/testing-conventions.mdc",
    "chars": 1952,
    "preview": "---\ndescription: Writing tests\nglobs: \nalwaysApply: false\n---\n# Testing Conventions\n\nThis document describes testing con"
  },
  {
    "path": ".dockerignore",
    "chars": 808,
    "preview": "# Python cache files\n__pycache__/\n*.py[cod]\n*$py.class\n.pytest_cache/\n.mypy_cache/\n\n# Git\n.git/\n.github/\n.gitignore\n\n# E"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 61,
    "preview": "# These are supported funding model platforms\n\ngithub: jdrbc\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 332,
    "preview": "---\nname: Bug report\nabout: Report a problem or regression\ntitle: \"[Bug]: \"\nlabels: bug\nassignees: \"\"\n---\n\n## Summary\n- "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 244,
    "preview": "---\nname: Feature request\nabout: Suggest an idea or enhancement\ntitle: \"[Feature]: \"\nlabels: enhancement\nassignees: \"\"\n-"
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 596,
    "preview": "## Summary\n- \n\n## Type of change\n- [ ] Bug fix\n- [ ] New feature\n- [ ] Refactor\n- [ ] Docs\n- [ ] Other\n\n## Testing\n- [ ]"
  },
  {
    "path": ".github/workflows/conventional-commit-check.yml",
    "chars": 1259,
    "preview": "name: Conventional Commit Check\n\non:\n  pull_request:\n    branches:\n      - main\n\npermissions:\n  contents: read\n\njobs:\n  "
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "chars": 13801,
    "preview": "name: Build and Publish Docker Images\n\non:\n  push:\n    branches: [main]\n    tags: [\"v*\"]\n  pull_request:\n    branches: ["
  },
  {
    "path": ".github/workflows/lint-and-format.yml",
    "chars": 2036,
    "preview": "name: Python Linting, Formatting, and Testing\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n    "
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 687,
    "preview": "name: Release\n\non:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  issues: wri"
  },
  {
    "path": ".gitignore",
    "chars": 670,
    "preview": ".worktrees/*\n\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib"
  },
  {
    "path": ".pylintrc",
    "chars": 1109,
    "preview": "[MASTER]\nignore=frontend,migrations,scripts\nignore-paths=^src/(migrations|tests)/\ndisable=\n    C0114, # missing-module-d"
  },
  {
    "path": ".releaserc.cjs",
    "chars": 878,
    "preview": "const { execSync } = require(\"node:child_process\");\n\nconst resolveRepositoryUrl = () => {\n  if (process.env.GITHUB_REPOS"
  },
  {
    "path": ".worktrees/.gitignore",
    "chars": 13,
    "preview": "*\n!.gitignore"
  },
  {
    "path": "AGENTS.md",
    "chars": 385,
    "preview": "Project-specific rules:\n- Do not create Alembic migrations yourself; request the user to generate migrations after model"
  },
  {
    "path": "Dockerfile",
    "chars": 6164,
    "preview": "# Multi-stage build for combined frontend and backend\nARG BASE_IMAGE=python:3.11-slim\nFROM node:18-alpine AS frontend-bu"
  },
  {
    "path": "LICENCE",
    "chars": 1069,
    "preview": "\nMIT License\n\nCopyright (c) 2024 John Rogers\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "Pipfile",
    "chars": 962,
    "preview": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nspeechrecognition = \"*\"\nopenai = "
  },
  {
    "path": "Pipfile.lite",
    "chars": 936,
    "preview": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nspeechrecognition = \"*\"\nopenai = "
  },
  {
    "path": "README.md",
    "chars": 2127,
    "preview": "<h2 align=\"center\">\n<img width=\"50%\" src=\"src/app/static/images/logos/logo_with_text.png\" />\n\n</h2>\n\n<p align=\"center\">\n"
  },
  {
    "path": "SECURITY.md",
    "chars": 569,
    "preview": "# Security Policy\n\n## Supported Versions\n\nWe only support the latest on main & preview.\n\n## Reporting a Vulnerability\n\nP"
  },
  {
    "path": "compose.dev.cpu.yml",
    "chars": 1079,
    "preview": "services:\n  podly:\n    container_name: podly-pure-podcasts\n    image: podly-pure-podcasts\n    volumes:\n      - ./src/ins"
  },
  {
    "path": "compose.dev.nvidia.yml",
    "chars": 505,
    "preview": "services:\n  podly:\n    extends:\n      file: compose.dev.cpu.yml\n      service: podly\n    env_file:\n      - ./.env.local\n"
  },
  {
    "path": "compose.dev.rocm.yml",
    "chars": 948,
    "preview": "services:\n  podly:\n    extends:\n      file: compose.dev.cpu.yml\n      service: podly\n    env_file:\n      - ./.env.local\n"
  },
  {
    "path": "compose.yml",
    "chars": 789,
    "preview": "services:\n  podly:\n    container_name: podly-pure-podcasts\n    ports:\n      - \"5001:5001\"\n    image: ghcr.io/podly-pure-"
  },
  {
    "path": "docker-entrypoint.sh",
    "chars": 1182,
    "preview": "#!/bin/bash\nset -e\n\n# Check if PUID/PGID env variables are set\nif [ -n \"${PUID}\" ] && [ -n \"${PGID}\" ] && [ \"$(id -u)\" ="
  },
  {
    "path": "docs/contributors.md",
    "chars": 9776,
    "preview": "# Contributor Guide\n\n### Quick Start (Docker - recommended for local setup)\n\n1. Make the script executable and run:\n\n```"
  },
  {
    "path": "docs/how_to_run_beginners.md",
    "chars": 8900,
    "preview": "# How To Run: Ultimate Beginner's Guide\n\nThis guide will walk you through setting up Podly from scratch using Docker. Po"
  },
  {
    "path": "docs/how_to_run_railway.md",
    "chars": 3722,
    "preview": "# How to Run on Railway\n\nThis guide will walk you through deploying Podly on Railway using the one-click template.\n\n## 0"
  },
  {
    "path": "docs/todo.txt",
    "chars": 328,
    "preview": "- config audit & testing (advanced and basic)\n- move host/port/threads to docker config\nreaudit security + testing\nci.sh"
  },
  {
    "path": "frontend/.gitignore",
    "chars": 253,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
  },
  {
    "path": "frontend/README.md",
    "chars": 1669,
    "preview": "# Podly Frontend\n\nThis is the React + TypeScript + Vite frontend for Podly. The frontend is built and served as part of "
  },
  {
    "path": "frontend/eslint.config.js",
    "chars": 734,
    "preview": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reac"
  },
  {
    "path": "frontend/index.html",
    "chars": 356,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/f"
  },
  {
    "path": "frontend/package.json",
    "chars": 990,
    "preview": "{\n  \"name\": \"frontend\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n "
  },
  {
    "path": "frontend/postcss.config.js",
    "chars": 80,
    "preview": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n} "
  },
  {
    "path": "frontend/src/App.css",
    "chars": 1404,
    "preview": "html, body {\n  margin: 0 !important;\n  padding: 0 !important;\n  height: 100% !important;\n  overflow: hidden !important;\n"
  },
  {
    "path": "frontend/src/App.tsx",
    "chars": 12064,
    "preview": "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { Toaster } from 'react-hot-toast';\nimp"
  },
  {
    "path": "frontend/src/components/AddFeedForm.tsx",
    "chars": 13953,
    "preview": "import { useState, useEffect, useCallback } from 'react';\nimport { feedsApi } from '../services/api';\nimport type { Podc"
  },
  {
    "path": "frontend/src/components/AudioPlayer.tsx",
    "chars": 11056,
    "preview": "import React, { useState, useRef, useEffect } from 'react';\nimport { useAudioPlayer } from '../contexts/AudioPlayerConte"
  },
  {
    "path": "frontend/src/components/DiagnosticsModal.tsx",
    "chars": 5734,
    "preview": "import { useEffect, useMemo, useState } from 'react';\nimport { useDiagnostics } from '../contexts/DiagnosticsContext';\ni"
  },
  {
    "path": "frontend/src/components/DownloadButton.tsx",
    "chars": 5150,
    "preview": "import { useState } from 'react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport axios from 'axios';\nimp"
  },
  {
    "path": "frontend/src/components/EpisodeProcessingStatus.tsx",
    "chars": 2676,
    "preview": "import { useEpisodeStatus } from '../hooks/useEpisodeStatus';\n\ninterface EpisodeProcessingStatusProps {\n  episodeGuid: s"
  },
  {
    "path": "frontend/src/components/FeedDetail.tsx",
    "chars": 44931,
    "preview": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useState, useEffect, useRef, use"
  },
  {
    "path": "frontend/src/components/FeedList.tsx",
    "chars": 5419,
    "preview": "import { useMemo, useState } from 'react';\nimport { useAuth } from '../contexts/AuthContext';\nimport type { Feed } from "
  },
  {
    "path": "frontend/src/components/PlayButton.tsx",
    "chars": 2494,
    "preview": "import { useAudioPlayer } from '../contexts/AudioPlayerContext';\nimport type { Episode } from '../types';\n\ninterface Pla"
  },
  {
    "path": "frontend/src/components/ProcessingStatsButton.tsx",
    "chars": 23945,
    "preview": "import { useState } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { feedsApi } from '../service"
  },
  {
    "path": "frontend/src/components/ReprocessButton.tsx",
    "chars": 4874,
    "preview": "import { useState } from 'react';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { feedsApi } from '../s"
  },
  {
    "path": "frontend/src/components/config/ConfigContext.tsx",
    "chars": 855,
    "preview": "import { createContext, useContext } from 'react';\nimport type { UseConfigStateReturn } from '../../hooks/useConfigState"
  },
  {
    "path": "frontend/src/components/config/ConfigTabs.tsx",
    "chars": 5315,
    "preview": "import { useMemo, useEffect, useCallback } from 'react';\nimport { useSearchParams } from 'react-router-dom';\nimport { us"
  },
  {
    "path": "frontend/src/components/config/index.ts",
    "chars": 361,
    "preview": "export { default as ConfigTabs } from './ConfigTabs';\nexport { ConfigContext, useConfigContext } from './ConfigContext';"
  },
  {
    "path": "frontend/src/components/config/sections/AppSection.tsx",
    "chars": 3412,
    "preview": "import { useConfigContext } from '../ConfigContext';\nimport { Section, Field, SaveButton } from '../shared';\n\nexport def"
  },
  {
    "path": "frontend/src/components/config/sections/LLMSection.tsx",
    "chars": 9173,
    "preview": "import { useState } from 'react';\nimport { toast } from 'react-hot-toast';\nimport { configApi } from '../../../services/"
  },
  {
    "path": "frontend/src/components/config/sections/OutputSection.tsx",
    "chars": 2037,
    "preview": "import { useConfigContext } from '../ConfigContext';\nimport { Section, Field, SaveButton } from '../shared';\n\nexport def"
  },
  {
    "path": "frontend/src/components/config/sections/ProcessingSection.tsx",
    "chars": 941,
    "preview": "import { useConfigContext } from '../ConfigContext';\nimport { Section, Field, SaveButton } from '../shared';\n\nexport def"
  },
  {
    "path": "frontend/src/components/config/sections/WhisperSection.tsx",
    "chars": 7857,
    "preview": "import { useMemo } from 'react';\nimport { toast } from 'react-hot-toast';\nimport { configApi } from '../../../services/a"
  },
  {
    "path": "frontend/src/components/config/sections/index.ts",
    "chars": 298,
    "preview": "export { default as LLMSection } from './LLMSection';\nexport { default as WhisperSection } from './WhisperSection';\nexpo"
  },
  {
    "path": "frontend/src/components/config/shared/ConnectionStatusCard.tsx",
    "chars": 1049,
    "preview": "interface ConnectionStatusCardProps {\n  title: string;\n  status: 'loading' | 'ok' | 'error';\n  message: string;\n  error?"
  },
  {
    "path": "frontend/src/components/config/shared/EnvOverrideWarningModal.tsx",
    "chars": 2807,
    "preview": "import type { EnvOverrideMap } from '../../../types';\nimport { ENV_FIELD_LABELS } from './constants';\n\ninterface EnvOver"
  },
  {
    "path": "frontend/src/components/config/shared/EnvVarHint.tsx",
    "chars": 330,
    "preview": "import type { EnvOverrideEntry } from '../../../types';\n\ninterface EnvVarHintProps {\n  meta?: EnvOverrideEntry;\n}\n\nexpor"
  },
  {
    "path": "frontend/src/components/config/shared/Field.tsx",
    "chars": 764,
    "preview": "import type { ReactNode } from 'react';\nimport type { EnvOverrideEntry } from '../../../types';\nimport EnvVarHint from '"
  },
  {
    "path": "frontend/src/components/config/shared/SaveButton.tsx",
    "chars": 540,
    "preview": "interface SaveButtonProps {\n  onSave: () => void;\n  isPending: boolean;\n  className?: string;\n}\n\nexport default function"
  },
  {
    "path": "frontend/src/components/config/shared/Section.tsx",
    "chars": 436,
    "preview": "import type { ReactNode } from 'react';\n\ninterface SectionProps {\n  title: string;\n  children: ReactNode;\n  className?: "
  },
  {
    "path": "frontend/src/components/config/shared/TestButton.tsx",
    "chars": 445,
    "preview": "interface TestButtonProps {\n  onClick: () => void;\n  label: string;\n  className?: string;\n}\n\nexport default function Tes"
  },
  {
    "path": "frontend/src/components/config/shared/constants.ts",
    "chars": 515,
    "preview": "export const ENV_FIELD_LABELS: Record<string, string> = {\n  'groq.api_key': 'Groq API Key',\n  'llm.llm_api_key': 'LLM AP"
  },
  {
    "path": "frontend/src/components/config/shared/index.ts",
    "chars": 456,
    "preview": "export { default as Section } from './Section';\nexport { default as Field } from './Field';\nexport { default as EnvVarHi"
  },
  {
    "path": "frontend/src/components/config/tabs/AdvancedTab.tsx",
    "chars": 1516,
    "preview": "import { useConfigContext, type AdvancedSubtab } from '../ConfigContext';\nimport {\n  LLMSection,\n  WhisperSection,\n  Pro"
  },
  {
    "path": "frontend/src/components/config/tabs/DefaultTab.tsx",
    "chars": 7557,
    "preview": "import { useState } from 'react';\nimport { useConfigContext } from '../ConfigContext';\nimport { Section, Field, Connecti"
  },
  {
    "path": "frontend/src/components/config/tabs/DiscordTab.tsx",
    "chars": 9294,
    "preview": "import { useState, useEffect } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-quer"
  },
  {
    "path": "frontend/src/components/config/tabs/UserManagementTab.tsx",
    "chars": 20392,
    "preview": "import { useMemo, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport { useQuery } from '@tanstack/r"
  },
  {
    "path": "frontend/src/components/config/tabs/index.ts",
    "chars": 232,
    "preview": "export { default as DefaultTab } from './DefaultTab';\nexport { default as AdvancedTab } from './AdvancedTab';\nexport { d"
  },
  {
    "path": "frontend/src/contexts/AudioPlayerContext.tsx",
    "chars": 9347,
    "preview": "import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback } from 'react';\nimport type { Epis"
  },
  {
    "path": "frontend/src/contexts/AuthContext.tsx",
    "chars": 4431,
    "preview": "import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } "
  },
  {
    "path": "frontend/src/contexts/DiagnosticsContext.tsx",
    "chars": 2886,
    "preview": "/* eslint-disable react-refresh/only-export-components */\n\nimport { createContext, useCallback, useContext, useEffect, u"
  },
  {
    "path": "frontend/src/hooks/useConfigState.ts",
    "chars": 16540,
    "preview": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useMutation, useQuery } from '@tanst"
  },
  {
    "path": "frontend/src/hooks/useEpisodeStatus.ts",
    "chars": 1069,
    "preview": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useEffect } from 'react';\nimport { feedsApi }"
  },
  {
    "path": "frontend/src/index.css",
    "chars": 59,
    "preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
  },
  {
    "path": "frontend/src/main.tsx",
    "chars": 338,
    "preview": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport './App.css'"
  },
  {
    "path": "frontend/src/pages/BillingPage.tsx",
    "chars": 7492,
    "preview": "import { useEffect, useState } from 'react';\nimport { useQuery, useMutation } from '@tanstack/react-query';\nimport { bil"
  },
  {
    "path": "frontend/src/pages/ConfigPage.tsx",
    "chars": 125,
    "preview": "import ConfigTabs from '../components/config/ConfigTabs';\n\nexport default function ConfigPage() {\n  return <ConfigTabs /"
  },
  {
    "path": "frontend/src/pages/HomePage.tsx",
    "chars": 9578,
    "preview": "import { useMutation, useQuery } from '@tanstack/react-query';\nimport { useEffect, useState } from 'react';\nimport { fee"
  },
  {
    "path": "frontend/src/pages/JobsPage.tsx",
    "chars": 17965,
    "preview": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { jobsApi } from '../services/api';\nimport type"
  },
  {
    "path": "frontend/src/pages/LandingPage.tsx",
    "chars": 14167,
    "preview": "import { Link } from 'react-router-dom';\nimport { useQuery } from '@tanstack/react-query';\nimport { landingApi } from '."
  },
  {
    "path": "frontend/src/pages/LoginPage.tsx",
    "chars": 8677,
    "preview": "import type { FormEvent } from 'react';\nimport { useState, useEffect } from 'react';\nimport axios from 'axios';\nimport {"
  },
  {
    "path": "frontend/src/services/api.ts",
    "chars": 19181,
    "preview": "import axios from 'axios';\nimport { diagnostics } from '../utils/diagnostics';\nimport type {\n  Feed,\n  Episode,\n  Job,\n "
  },
  {
    "path": "frontend/src/types/index.ts",
    "chars": 5387,
    "preview": "export interface Feed {\n  id: number;\n  rss_url: string;\n  title: string;\n  description?: string;\n  author?: string;\n  i"
  },
  {
    "path": "frontend/src/utils/clipboard.ts",
    "chars": 1287,
    "preview": "import { toast } from 'react-hot-toast';\n\nexport async function copyToClipboard(text: string, promptMessage: string = 'C"
  },
  {
    "path": "frontend/src/utils/diagnostics.ts",
    "chars": 6102,
    "preview": "export type DiagnosticsLevel = 'debug' | 'info' | 'warn' | 'error';\n\nexport type DiagnosticsEntry = {\n  ts: number;\n  le"
  },
  {
    "path": "frontend/src/utils/httpError.ts",
    "chars": 851,
    "preview": "import type { AxiosError } from 'axios';\n\nexport type ApiErrorData = {\n  message?: unknown;\n  error?: unknown;\n  [key: s"
  },
  {
    "path": "frontend/src/vite-env.d.ts",
    "chars": 38,
    "preview": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "frontend/tailwind.config.js",
    "chars": 172,
    "preview": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\"./index.html\", \"./src/**/*.{js,ts,jsx,tsx}\"]"
  },
  {
    "path": "frontend/tsconfig.app.json",
    "chars": 755,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2020\",\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "chars": 119,
    "preview": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "frontend/tsconfig.node.json",
    "chars": 630,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2022\","
  },
  {
    "path": "frontend/vite.config.ts",
    "chars": 995,
    "preview": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n\n// For development, the frontend developme"
  },
  {
    "path": "pyproject.toml",
    "chars": 595,
    "preview": "[tool.pylint]\ninit-hook = 'import sys; sys.path.append(\"./src\")'\n\ndisable = [\n    \"logging-fstring-interpolation\",\n    \""
  },
  {
    "path": "run_podly_docker.sh",
    "chars": 9277,
    "preview": "#!/bin/bash\n\n# Colors for output\nYELLOW='\\033[1;33m'\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nNC='\\033[0m' # No Color\n\n# Cent"
  },
  {
    "path": "scripts/ci.sh",
    "chars": 1415,
    "preview": "#!/bin/bash\n\n# format\necho '============================================================='\necho \"Running 'pipenv run bla"
  },
  {
    "path": "scripts/create_migration.sh",
    "chars": 1190,
    "preview": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Usage: ./scripts/create_migration.sh \"message\"\n# Creates migrations using the p"
  },
  {
    "path": "scripts/downgrade_db.sh",
    "chars": 335,
    "preview": "#!/usr/bin/env bash\n\nSCRIPT_DIR=$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\nREPO_ROOT=$(cd \"$SCRIPT_DIR/..\" && pwd)\n\ne"
  },
  {
    "path": "scripts/generate_lockfiles.sh",
    "chars": 666,
    "preview": "#!/bin/bash\nset -e\n\n# Generate lock file for the regular Pipfile\necho \"Locking Pipfile...\"\npipenv lock\n\n# Temporarily mo"
  },
  {
    "path": "scripts/manual_publish.sh",
    "chars": 1248,
    "preview": "#!/bin/bash\n\nset -euo pipefail\n\n# Branch name becomes part of a manual tag (slashes replaced)\nBRANCH=$(git rev-parse --a"
  },
  {
    "path": "scripts/new_worktree.sh",
    "chars": 1948,
    "preview": "#!/usr/bin/env bash\nset -euo pipefail\n\nusage() {\n  echo \"Usage: $0 <branch-name> [<start-point>]\" >&2\n  exit 1\n}\n\nif [[ "
  },
  {
    "path": "scripts/start_services.sh",
    "chars": 1035,
    "preview": "#!/bin/bash\nset -e\n\n# 1. Start Writer Service in background\necho \"Starting Writer Service...\"\nexport PYTHONPATH=\"/app/sr"
  },
  {
    "path": "scripts/test_full_workflow.py",
    "chars": 6577,
    "preview": "import json\nimport sys\nimport time\n\nimport requests\n\nBASE_URL = \"http://localhost:5001\"\n\n\ndef log(msg):\n    print(f\"[TES"
  },
  {
    "path": "scripts/upgrade_db.sh",
    "chars": 244,
    "preview": "#!/usr/bin/env bash\n\nSCRIPT_DIR=$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\nREPO_ROOT=$(cd \"$SCRIPT_DIR/..\" && pwd)\n\ne"
  },
  {
    "path": "src/app/__init__.py",
    "chars": 15349,
    "preview": "import importlib\nimport json\nimport logging\nimport os\nimport secrets\nimport sys\nfrom pathlib import Path\nfrom typing imp"
  },
  {
    "path": "src/app/auth/__init__.py",
    "chars": 270,
    "preview": "\"\"\"\nAuthentication package exposing configuration helpers and utilities.\n\"\"\"\n\nfrom .guards import is_auth_enabled, requi"
  },
  {
    "path": "src/app/auth/bootstrap.py",
    "chars": 2264,
    "preview": "from __future__ import annotations\n\nimport logging\n\nfrom flask import current_app\n\nfrom app.db_commit import safe_commit"
  },
  {
    "path": "src/app/auth/discord_service.py",
    "chars": 4315,
    "preview": "from __future__ import annotations\n\nimport logging\nimport secrets\nfrom dataclasses import dataclass\nfrom typing import A"
  },
  {
    "path": "src/app/auth/discord_settings.py",
    "chars": 2836,
    "preview": "from __future__ import annotations\n\nimport os\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING\n\nif TYP"
  },
  {
    "path": "src/app/auth/feed_tokens.py",
    "chars": 4761,
    "preview": "from __future__ import annotations\n\nimport hashlib\nimport logging\nimport secrets\nfrom dataclasses import dataclass\nfrom "
  },
  {
    "path": "src/app/auth/guards.py",
    "chars": 1744,
    "preview": "\"\"\"Authorization guard utilities for admin and authenticated user checks.\"\"\"\n\nfrom typing import TYPE_CHECKING, Tuple\n\ni"
  },
  {
    "path": "src/app/auth/middleware.py",
    "chars": 4795,
    "preview": "from __future__ import annotations\n\nimport re\nfrom typing import Any\n\nfrom flask import Response, current_app, g, jsonif"
  },
  {
    "path": "src/app/auth/passwords.py",
    "chars": 623,
    "preview": "from __future__ import annotations\n\nimport bcrypt\n\n\ndef hash_password(password: str, *, rounds: int = 12) -> str:\n    \"\""
  },
  {
    "path": "src/app/auth/rate_limiter.py",
    "chars": 2414,
    "preview": "from __future__ import annotations\n\nfrom collections.abc import MutableMapping\nfrom dataclasses import dataclass\nfrom da"
  },
  {
    "path": "src/app/auth/service.py",
    "chars": 5781,
    "preview": "from __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Sequence, cast\n\n"
  },
  {
    "path": "src/app/auth/settings.py",
    "chars": 1669,
    "preview": "from __future__ import annotations\n\nimport os\nfrom dataclasses import dataclass, replace\n\n\ndef _str_to_bool(value: str |"
  },
  {
    "path": "src/app/auth/state.py",
    "chars": 126,
    "preview": "from __future__ import annotations\n\nfrom .rate_limiter import FailureRateLimiter\n\nfailure_rate_limiter = FailureRateLimi"
  },
  {
    "path": "src/app/background.py",
    "chars": 1300,
    "preview": "from datetime import datetime, timedelta\nfrom typing import Optional\n\nfrom app.extensions import scheduler\nfrom app.jobs"
  },
  {
    "path": "src/app/config_store.py",
    "chars": 44985,
    "preview": "from __future__ import annotations\n\nimport hashlib\nimport logging\nimport os\nfrom typing import Any, Dict, Optional, Tupl"
  },
  {
    "path": "src/app/db_commit.py",
    "chars": 855,
    "preview": "from __future__ import annotations\n\nimport logging\nfrom typing import Any\n\n\ndef safe_commit(\n    session: Any,\n    *,\n  "
  },
  {
    "path": "src/app/db_guard.py",
    "chars": 1728,
    "preview": "\"\"\"Shared helpers to protect long-lived sessions in background threads.\"\"\"\n\nfrom __future__ import annotations\n\nimport l"
  },
  {
    "path": "src/app/extensions.py",
    "chars": 408,
    "preview": "import os\n\nfrom flask_apscheduler import APScheduler  # type: ignore\nfrom flask_migrate import Migrate\nfrom flask_sqlalc"
  },
  {
    "path": "src/app/feeds.py",
    "chars": 20704,
    "preview": "import datetime\nimport logging\nimport uuid\nfrom email.utils import format_datetime, parsedate_to_datetime\nfrom typing im"
  },
  {
    "path": "src/app/ipc.py",
    "chars": 1900,
    "preview": "import multiprocessing\nimport os\nfrom multiprocessing.managers import BaseManager\nfrom queue import Queue\nfrom typing im"
  },
  {
    "path": "src/app/job_manager.py",
    "chars": 8018,
    "preview": "import logging\nimport os\nfrom typing import Any, Dict, Optional, Tuple\n\nfrom app.extensions import db as _db\nfrom app.mo"
  },
  {
    "path": "src/app/jobs_manager.py",
    "chars": 28039,
    "preview": "import logging\nimport os\nfrom datetime import datetime, timedelta\nfrom threading import Event, Lock, Thread\nfrom typing "
  },
  {
    "path": "src/app/jobs_manager_run_service.py",
    "chars": 8318,
    "preview": "\"\"\"Helpers for managing the singleton JobsManagerRun row.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom da"
  },
  {
    "path": "src/app/logger.py",
    "chars": 2936,
    "preview": "import json\nimport logging\nimport os\n\n\nclass ExtraFormatter(logging.Formatter):\n    \"\"\"Formatter that appends structured"
  },
  {
    "path": "src/app/models.py",
    "chars": 20716,
    "preview": "import os\nimport uuid\nfrom datetime import datetime\n\nfrom sqlalchemy.orm import validates\n\nfrom app.auth.passwords impor"
  },
  {
    "path": "src/app/post_cleanup.py",
    "chars": 6890,
    "preview": "\"\"\"Cleanup job for pruning processed posts and associated artifacts.\"\"\"\n\nfrom __future__ import annotations\n\nimport logg"
  },
  {
    "path": "src/app/posts.py",
    "chars": 6011,
    "preview": "import logging\nfrom pathlib import Path\nfrom typing import List, Optional\n\nfrom app.models import Post\nfrom app.writer.c"
  },
  {
    "path": "src/app/processor.py",
    "chars": 767,
    "preview": "from app.runtime_config import config\nfrom podcast_processor.podcast_processor import PodcastProcessor\n\n\nclass Processor"
  },
  {
    "path": "src/app/routes/__init__.py",
    "chars": 704,
    "preview": "from flask import Flask\n\nfrom .auth_routes import auth_bp\nfrom .billing_routes import billing_bp\nfrom .config_routes imp"
  },
  {
    "path": "src/app/routes/auth_routes.py",
    "chars": 10867,
    "preview": "from __future__ import annotations\n\nimport logging\nfrom typing import cast\n\nfrom flask import Blueprint, Response, curre"
  },
  {
    "path": "src/app/routes/billing_routes.py",
    "chars": 18038,
    "preview": "import logging\nimport os\nfrom typing import Any, Optional\n\nfrom flask import Blueprint, jsonify, request\n\nfrom app.exten"
  },
  {
    "path": "src/app/routes/config_routes.py",
    "chars": 23807,
    "preview": "import logging\nimport os\nfrom typing import Any, Dict\n\nimport flask\nimport litellm\nfrom flask import Blueprint, jsonify,"
  },
  {
    "path": "src/app/routes/discord_routes.py",
    "chars": 10645,
    "preview": "from __future__ import annotations\n\nimport logging\nimport os\nfrom typing import TYPE_CHECKING\n\nfrom flask import (\n    B"
  },
  {
    "path": "src/app/routes/feed_routes.py",
    "chars": 33056,
    "preview": "import logging\nimport re\nimport secrets\nfrom pathlib import Path\nfrom threading import Thread\nfrom typing import Any, Op"
  },
  {
    "path": "src/app/routes/jobs_routes.py",
    "chars": 3744,
    "preview": "import logging\n\nimport flask\nfrom flask import Blueprint, request\nfrom flask.typing import ResponseReturnValue\n\nfrom app"
  },
  {
    "path": "src/app/routes/main_routes.py",
    "chars": 5389,
    "preview": "import logging\nimport os\n\nimport flask\nfrom flask import Blueprint, send_from_directory\n\nfrom app.auth.guards import req"
  },
  {
    "path": "src/app/routes/post_routes.py",
    "chars": 33107,
    "preview": "import logging\nimport math\nimport os\nfrom pathlib import Path\nfrom typing import Any, Dict, Optional, cast\n\nimport flask"
  },
  {
    "path": "src/app/routes/post_stats_utils.py",
    "chars": 1788,
    "preview": "from __future__ import annotations\n\nfrom typing import Any, Dict, Iterable, List, Tuple\n\n\ndef count_model_calls(\n    mod"
  },
  {
    "path": "src/app/runtime_config.py",
    "chars": 2628,
    "preview": "\"\"\"\nRuntime configuration module - isolated to prevent circular imports.\nInitializes the global config object that is us"
  },
  {
    "path": "src/app/static/.gitignore",
    "chars": 149,
    "preview": "# This file ensures the static directory exists in the repository.\n# Frontend build assets are generated here but not co"
  },
  {
    "path": "src/app/templates/index.html",
    "chars": 3080,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-w"
  },
  {
    "path": "src/app/timeout_decorator.py",
    "chars": 1377,
    "preview": "import functools\nimport threading\nfrom typing import Any, Callable, List, Optional, TypeVar\n\nT = TypeVar(\"T\")\n\n\nclass Ti"
  },
  {
    "path": "src/app/writer/__init__.py",
    "chars": 131,
    "preview": "from .executor import CommandExecutor\nfrom .service import run_writer_service\n\n__all__ = [\"CommandExecutor\", \"run_writer"
  },
  {
    "path": "src/app/writer/__main__.py",
    "chars": 93,
    "preview": "from .service import run_writer_service\n\nif __name__ == \"__main__\":\n    run_writer_service()\n"
  },
  {
    "path": "src/app/writer/actions/__init__.py",
    "chars": 3542,
    "preview": "\"\"\"Writer action function re-exports.\n\nMypy runs with `--no-implicit-reexport`, so imports use explicit aliasing.\n\"\"\"\n\n#"
  },
  {
    "path": "src/app/writer/actions/cleanup.py",
    "chars": 3909,
    "preview": "import logging\nimport os\nfrom typing import Any, Dict\n\nfrom app.extensions import db\nfrom app.jobs_manager_run_service i"
  },
  {
    "path": "src/app/writer/actions/feeds.py",
    "chars": 12680,
    "preview": "import hashlib\nimport secrets\nimport uuid\nfrom datetime import datetime\nfrom typing import Any, Dict\n\nfrom sqlalchemy im"
  },
  {
    "path": "src/app/writer/actions/jobs.py",
    "chars": 4855,
    "preview": "from datetime import datetime, timedelta\nfrom typing import Any, Dict, Optional\n\nfrom app.extensions import db\nfrom app."
  },
  {
    "path": "src/app/writer/actions/processor.py",
    "chars": 9271,
    "preview": "from __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any, Dict, Iterable, List\n\nfrom sql"
  },
  {
    "path": "src/app/writer/actions/system.py",
    "chars": 2368,
    "preview": "import logging\nfrom datetime import datetime\nfrom typing import Any, Dict\n\nfrom app.extensions import db\nfrom app.jobs_m"
  },
  {
    "path": "src/app/writer/actions/users.py",
    "chars": 6700,
    "preview": "from datetime import datetime\nfrom typing import Any, Dict\n\nfrom app.extensions import db\nfrom app.models import FeedAcc"
  },
  {
    "path": "src/app/writer/client.py",
    "chars": 7193,
    "preview": "import os\nimport uuid\nfrom queue import Empty\nfrom typing import Any, Callable, Dict, Optional, cast\n\nfrom flask import "
  },
  {
    "path": "src/app/writer/executor.py",
    "chars": 11058,
    "preview": "import logging\nfrom typing import Any, Callable, Dict\n\nfrom flask import Flask\n\nfrom app import models\nfrom app.extensio"
  },
  {
    "path": "src/app/writer/model_ops.py",
    "chars": 1422,
    "preview": "from __future__ import annotations\n\nfrom typing import Any\n\nfrom app.writer.protocol import WriteCommand, WriteCommandTy"
  },
  {
    "path": "src/app/writer/protocol.py",
    "chars": 778,
    "preview": "from dataclasses import dataclass\nfrom enum import Enum\nfrom typing import Any, Dict, Optional\n\n\nclass WriteCommandType("
  },
  {
    "path": "src/app/writer/service.py",
    "chars": 2577,
    "preview": "import logging\nimport threading\nimport time\n\nfrom app.ipc import get_queue, make_server_manager\nfrom app.logger import s"
  },
  {
    "path": "src/boundary_refinement_prompt.jinja",
    "chars": 2676,
    "preview": "You are analyzing podcast transcript segments to precisely identify advertisement boundaries.\n\nYour job is to determine "
  },
  {
    "path": "src/main.py",
    "chars": 554,
    "preview": "import os\n\nfrom waitress import serve\n\nfrom app import create_web_app\n\n\ndef main() -> None:\n    \"\"\"Main entry point for "
  },
  {
    "path": "src/migrations/README",
    "chars": 41,
    "preview": "Single-database configuration for Flask.\n"
  },
  {
    "path": "src/migrations/alembic.ini",
    "chars": 885,
    "preview": "# A generic, single database configuration.\n\n[alembic]\n# template used to generate migration files\n# file_template = %%("
  },
  {
    "path": "src/migrations/env.py",
    "chars": 3324,
    "preview": "import logging\nfrom logging.config import fileConfig\n\nfrom alembic import context\nfrom flask import current_app\n\n# this "
  },
  {
    "path": "src/migrations/script.py.mako",
    "chars": 494,
    "preview": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom ale"
  },
  {
    "path": "src/migrations/versions/0d954a44fa8e_feed_access.py",
    "chars": 1733,
    "preview": "\"\"\"feed_access\n\nRevision ID: 0d954a44fa8e\nRevises: 91ff431c832e\nCreate Date: 2025-11-04 21:43:07.716121\n\n\"\"\"\n\nimport sql"
  },
  {
    "path": "src/migrations/versions/16311623dd58_env_hash.py",
    "chars": 841,
    "preview": "\"\"\"env_hash\n\nRevision ID: 16311623dd58\nRevises: 5bccc39c9685\nCreate Date: 2025-12-14 10:32:15.843860\n\n\"\"\"\n\nimport sqlalc"
  },
  {
    "path": "src/migrations/versions/185d3448990e_stripe.py",
    "chars": 6270,
    "preview": "\"\"\"stripe\n\nRevision ID: 185d3448990e\nRevises: 35b12b2d9feb\nCreate Date: 2025-12-10 21:51:55.888021\n\n\"\"\"\n\nimport sqlalche"
  },
  {
    "path": "src/migrations/versions/18c2402c9202_cleanup_retention_days.py",
    "chars": 871,
    "preview": "\"\"\"cleanup_retention_days\n\nRevision ID: 18c2402c9202\nRevises: a6f5df1a50ac\nCreate Date: 2025-11-03 22:05:56.956113\n\n\"\"\"\n"
  },
  {
    "path": "src/migrations/versions/2e25a15d11de_per_feed_auto_whitelist.py",
    "chars": 904,
    "preview": "\"\"\"per feed auto whitelist\n\nRevision ID: 2e25a15d11de\nRevises: 82cfcc8e0326\nCreate Date: 2026-01-12 12:47:42.611999\n\n\"\"\""
  },
  {
    "path": "src/migrations/versions/31d767deb401_credits.py",
    "chars": 7109,
    "preview": "\"\"\"credits\n\nRevision ID: 31d767deb401\nRevises: 608e0b27fcda\nCreate Date: 2025-11-29 11:42:27.900494\n\n\"\"\"\n\nimport sqlalch"
  },
  {
    "path": "src/migrations/versions/35b12b2d9feb_landing_page.py",
    "chars": 973,
    "preview": "\"\"\"landing page\n\nRevision ID: 35b12b2d9feb\nRevises: eb51923af483\nCreate Date: 2025-12-01 23:49:10.400190\n\n\"\"\"\n\nimport sq"
  },
  {
    "path": "src/migrations/versions/3c7f5f7640e4_add_counters_reset_timestamp.py",
    "chars": 1616,
    "preview": "\"\"\"add counters reset timestamp to jobs_manager_run\n\nRevision ID: 3c7f5f7640e4\nRevises: c0f8893ce927\nCreate Date: 2026-1"
  },
  {
    "path": "src/migrations/versions/3d232f215842_migration.py",
    "chars": 983,
    "preview": "\"\"\"migration\n\nRevision ID: 3d232f215842\nRevises: f7a4195e0953\nCreate Date: 2026-01-11 18:35:34.763013\n\n\"\"\"\n\nimport sqlal"
  },
  {
    "path": "src/migrations/versions/3eb0a3a0870b_discord.py",
    "chars": 1163,
    "preview": "\"\"\"discord\n\nRevision ID: 3eb0a3a0870b\nRevises: 31d767deb401\nCreate Date: 2025-11-29 12:41:40.446049\n\n\"\"\"\n\nimport sqlalch"
  },
  {
    "path": "src/migrations/versions/401071604e7b_config_tables.py",
    "chars": 8994,
    "preview": "\"\"\"Create settings tables and seed defaults\n\nRevision ID: 401071604e7b\nRevises: 611dcb5d7f12\nCreate Date: 2025-09-28 00:"
  },
  {
    "path": "src/migrations/versions/58b4eedd4c61_add_last_active_to_user.py",
    "chars": 805,
    "preview": "\"\"\"add_last_active_to_user\n\nRevision ID: 58b4eedd4c61\nRevises: 73a6b9f9b643\nCreate Date: 2025-12-20 14:01:36.022682\n\n\"\"\""
  },
  {
    "path": "src/migrations/versions/5bccc39c9685_zero_initial_allowance.py",
    "chars": 562,
    "preview": "\"\"\"zero initial allowance\n\nRevision ID: 5bccc39c9685\nRevises: ab643af6472e\nCreate Date: 2025-12-12 14:21:35.530141\n\n\"\"\"\n"
  },
  {
    "path": "src/migrations/versions/608e0b27fcda_stronger_access_token.py",
    "chars": 859,
    "preview": "\"\"\"stronger_access_token\n\nRevision ID: 608e0b27fcda\nRevises: f6d5fee57cc3\nCreate Date: 2025-11-05 21:27:10.923394\n\n\"\"\"\n\n"
  },
  {
    "path": "src/migrations/versions/611dcb5d7f12_add_image_url_to_post_model_for_episode_.py",
    "chars": 822,
    "preview": "\"\"\"Add image_url to Post model for episode thumbnails\n\nRevision ID: 611dcb5d7f12\nRevises: b038c2f99086\nCreate Date: 2025"
  },
  {
    "path": "src/migrations/versions/6e0e16299dcb_alternate_feed_id.py",
    "chars": 783,
    "preview": "\"\"\"alternate feed ID\n\nRevision ID: 6e0e16299dcb\nRevises: 770771437280\nCreate Date: 2024-11-23 11:04:37.861614\n\n\"\"\"\n\nimpo"
  },
  {
    "path": "src/migrations/versions/73a6b9f9b643_allow_null_feed_id_for_aggregate_tokens.py",
    "chars": 886,
    "preview": "\"\"\"allow_null_feed_id_for_aggregate_tokens\n\nRevision ID: 73a6b9f9b643\nRevises: 89d86978f407\nCreate Date: 2025-12-14 13:2"
  },
  {
    "path": "src/migrations/versions/770771437280_episode_whitelist.py",
    "chars": 1799,
    "preview": "\"\"\"episode whitelist\n\nRevision ID: 770771437280\nRevises: fa3a95ecd67d\nCreate Date: 2024-11-16 08:27:46.081562\n\n\"\"\"\n\nimpo"
  },
  {
    "path": "src/migrations/versions/7de4e57ec4bb_discord_settings.py",
    "chars": 1163,
    "preview": "\"\"\"discord settings\n\nRevision ID: 7de4e57ec4bb\nRevises: 3eb0a3a0870b\nCreate Date: 2025-11-29 12:47:45.289285\n\n\"\"\"\n\nimpor"
  },
  {
    "path": "src/migrations/versions/802a2365976d_gruanular_credits.py",
    "chars": 1717,
    "preview": "\"\"\"gruanular credits\n\nRevision ID: 802a2365976d\nRevises: 7de4e57ec4bb\nCreate Date: 2025-11-29 19:10:18.950548\n\n\"\"\"\n\nimpo"
  },
  {
    "path": "src/migrations/versions/82cfcc8e0326_refined_cuts.py",
    "chars": 1022,
    "preview": "\"\"\"refined cuts\n\nRevision ID: 82cfcc8e0326\nRevises: 3d232f215842\nCreate Date: 2026-01-11 20:44:32.127284\n\n\"\"\"\n\nimport sq"
  },
  {
    "path": "src/migrations/versions/89d86978f407_limit_users.py",
    "chars": 816,
    "preview": "\"\"\"limit users\n\nRevision ID: 89d86978f407\nRevises: 16311623dd58\nCreate Date: 2025-12-14 12:45:22.788888\n\n\"\"\"\n\nimport sql"
  },
  {
    "path": "src/migrations/versions/91ff431c832e_download_count.py",
    "chars": 2583,
    "preview": "\"\"\"download_count\n\nRevision ID: 91ff431c832e\nRevises: 18c2402c9202\nCreate Date: 2025-11-03 23:24:04.934488\n\n\"\"\"\n\nimport "
  },
  {
    "path": "src/migrations/versions/999b921ffc58_migration.py",
    "chars": 5344,
    "preview": "\"\"\"migration\n\nRevision ID: 999b921ffc58\nRevises: 401071604e7b\nCreate Date: 2025-10-18 15:11:24.463135\n\n\"\"\"\n\nimport sqlal"
  },
  {
    "path": "src/migrations/versions/a6f5df1a50ac_add_users_table.py",
    "chars": 1307,
    "preview": "\"\"\"add users table\n\nRevision ID: a6f5df1a50ac\nRevises: 3c7f5f7640e4\nCreate Date: 2024-05-15 00:00:00.000000\n\"\"\"\n\nfrom __"
  },
  {
    "path": "src/migrations/versions/ab643af6472e_add_manual_feed_allowance_to_user.py",
    "chars": 1689,
    "preview": "\"\"\"add_manual_feed_allowance_to_user\n\nRevision ID: ab643af6472e\nRevises: 185d3448990e\nCreate Date: 2025-12-12 14:06:14.4"
  },
  {
    "path": "src/migrations/versions/b038c2f99086_add_processingjob_table_for_async_.py",
    "chars": 2042,
    "preview": "\"\"\"Add ProcessingJob table for async episode processing\n\nRevision ID: b038c2f99086\nRevises: b92e47a03bb2\nCreate Date: 20"
  },
  {
    "path": "src/migrations/versions/b92e47a03bb2_refactor_transcripts_to_db_tables_.py",
    "chars": 4603,
    "preview": "\"\"\"Refactor transcripts to DB tables: TranscriptSegment, ModelCall, Identification\n\nRevision ID: b92e47a03bb2\nRevises: d"
  },
  {
    "path": "src/migrations/versions/bae70e584468_.py",
    "chars": 2067,
    "preview": "\"\"\"empty message\n\nRevision ID: bae70e584468\nRevises:\nCreate Date: 2024-10-20 14:45:30.170794\n\n\"\"\"\n\nimport sqlalchemy as "
  },
  {
    "path": "src/migrations/versions/c0f8893ce927_add_skipped_jobs_columns.py",
    "chars": 1589,
    "preview": "\"\"\"add skipped jobs counters\n\nRevision ID: c0f8893ce927\nRevises: 999b921ffc58\nCreate Date: 2026-11-27 00:00:00.000000\n\n\""
  },
  {
    "path": "src/migrations/versions/ded4b70feadb_add_image_metadata_to_feed.py",
    "chars": 612,
    "preview": "\"\"\"Add image metadata to feed\n\nRevision ID: ded4b70feadb\nRevises: 6e0e16299dcb\nCreate Date: 2025-03-01 14:30:20.177608\n\n"
  },
  {
    "path": "src/migrations/versions/e1325294473b_add_autoprocess_on_download.py",
    "chars": 1018,
    "preview": "\"\"\"add autoprocess_on_download\n\nRevision ID: e1325294473b\nRevises: 58b4eedd4c61\nCreate Date: 2025-12-25 20:45:12.595954\n"
  }
]

// ... and 60 more files (download for full content)

About this extraction

This page contains the full source code of the jdrbc/podly_pure_podcasts GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 260 files (1.3 MB), approximately 308.5k tokens, and a symbol index with 1162 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!