Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/windows_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ jobs:
with:
python-version: '3.11'

- name: Install Firefox
uses: browser-actions/setup-firefox@latest

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install selenium webdriver-manager psutil watchdog pyinstaller
if (Test-Path requirements.txt) { pip install -r requirements.txt }

- name: Run tests
run: python run_all_tests.py

- name: Build with PyInstaller using .spec file
run: |
pyinstaller --clean AudioDownloader.spec
Expand Down
156 changes: 52 additions & 104 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,139 +2,87 @@

## What This Is

Audio Download Manager — a Python desktop app that uses Selenium (Firefox) to download radio show audio files from 5 Dropbox sources and organizes them into Dropbox folders.
Python desktop app using Selenium (Firefox) to download radio show audio from 5 Dropbox sources.

## Developer Commands
## Environment

Virtual environment at `.venv/` (gitignored). Use it for all commands:
```bash
python main.py # Launch GUI (default)
python main.py --download-all # CLI: download from all sources
python main.py --source "Name" # CLI: download from one source
python test_downloads.py # Run standalone test suite
python test_detection_standalone.py # Run download detection test
python tests/test_config_edge_cases.py # Config tests
python tests/test_integration.py # Integration tests
python tests/test_scheduler.py # Scheduler tests
python tests/test_sources.py # URL validation tests
python tests/test_browser_manager.py # Browser manager tests
.venv/bin/python3 main.py # GUI (default)
.venv/bin/python3 main.py --download-all # CLI: all sources
.venv/bin/python3 main.py --source "Melinda Myers" # CLI: one source
```

No test framework installed — tests are plain Python scripts run directly. No lint/typecheck configured.

## Architecture

- **Entry point**: `main.py` — 3 modes: GUI (tkinter), CLI download-all, CLI single-source
- **Sources**: `sources/` — 5 downloader implementations using factory via `create_downloader(name, browser_mgr, config)`
- **Base class**: `sources/base.py` — `BaseDownloader` with `download()` abstract method
- **Browser**: Firefox only (uses `webdriver-manager` for GeckoDriver auto-install)
- **Config**: `download_config.json` (gitignored, contains credentials) — auto-created with defaults on first run
- **GUI**: tkinter with dark theme in `gui.py`

## Key Directories

| Directory | Purpose | Gitignored? |
|---|---|---|
| `downloads/` | Test mode output | yes |
| `browser_downloads/` | Selenium staging dir | yes |
| `sources/` | Download source implementations | no |
| `tests/` | Test suite | no |

## Important Conventions & Gotchas

- **Test mode defaults to `True`** — downloads go to `downloads/` not real Dropbox paths
- **Two download directories**: `browser_downloads/` is the Selenium staging area; `downloads/` (test) or Dropbox paths (prod) are final output
- **FFmpeg is external** — must be installed on the system separately for audio tag overlay (`download_utils.py`)
- **Windows-only build**: PyInstaller spec builds `.exe` on Windows CI (`AudioDownloader.spec`)
- **Source names vs keys**: `DOWNLOAD_SOURCES` maps display names (e.g. `"Melinda Myers"`) to module keys (e.g. `"melinda_myers"`) — always use display names with `create_downloader()`
- **Config merge**: `DEFAULT_CONFIG.copy()` then `.update(saved_config)` — top-level keys only, nested dicts like `urls` are fully replaced
- **Browser lifecycle**: `BrowserManager` is shared across source downloads; each source gets a fresh `BrowserManager` in CLI mode
- **Constants use UPPERCASE**: `ALLOWED_EXTENSIONS`, `EXCLUDED_EXTENSIONS`, `EXCLUDED_PREFIXES` in `constants.py` — always reference them with exact uppercase names

## Dependencies

`selenium`, `webdriver-manager`, `psutil`, `watchdog`, `pyinstaller` (+ `ffmpeg` system package)

## CI
Install dependencies into `.venv/`:
```bash
.venv/bin/python3 -m pip install selenium webdriver-manager psutil watchdog pyinstaller
```

GitHub Actions (`.github/workflows/windows_build.yml`): builds Windows exe on push to `main`/`master` and on semver tags. Releases created for tags.
## Commands

## Config Management
```bash
.venv/bin/python3 test_downloads.py # Standalone test suite
.venv/bin/python3 test_detection_standalone.py # Download detection test
.venv/bin/python3 tests/test_config_edge_cases.py # Config tests
.venv/bin/python3 tests/test_integration.py # Integration tests
.venv/bin/python3 tests/test_scheduler.py # Scheduler tests
.venv/bin/python3 tests/test_sources.py # URL validation tests
.venv/bin/python3 tests/test_browser_manager.py # Browser manager tests
```

`download_config.json` is gitignored and auto-created from `DEFAULT_CONFIG` on first run. Key fields that users must set:
No test framework — tests are plain Python scripts run directly. No lint/typecheck configured.

- **`email`, `password`**: Westwood One login credentials
- **`cow_password`**: Clear Out West password
- **`urls`**: Real Dropbox shared links per source (defaults are `YOUR_LINK_HERE` placeholders)
- **`test_mode`**: Defaults to `True`. When `True`, output goes to `downloads/` subfolder instead of real Dropbox paths. Set to `False` for production.
- **`scheduled_downloads`**: Controls automated download timing (enabled, schedule_type, time, days)
## Architecture

To update config programmatically, use `ConfigManager`:
```python
from config import ConfigManager
cm = ConfigManager()
cm.set("email", "user@example.com")
cm.save()
```
- **Entry point**: `main.py` — GUI (tkinter), CLI download-all, CLI single-source
- **Sources**: `sources/` — 5 downloaders via factory `create_downloader(name, browser_mgr, config)`
- **Base class**: `sources/base.py` — `BaseDownloader` with `download()` abstract method
- **Browser**: Firefox only (`webdriver-manager` for GeckoDriver auto-install)
- **Config**: `download_config.json` (gitignored, auto-created with defaults)

## Adding New Download Sources
## Gotchas

New sources require registration in **4 places**:
- **Test mode defaults `True`** — output goes to `downloads/`, not Dropbox paths
- **Two download dirs**: `browser_downloads/` is Selenium staging; `downloads/` (test) or Dropbox (prod) is final output
- **Source names vs keys**: `DOWNLOAD_SOURCES` maps display names (`"Melinda Myers"`) to module keys (`"melinda_myers"`) — use display names with `create_downloader()`
- **Config merge**: `DEFAULT_CONFIG.copy()` then `.update(saved_config)` — top-level keys only, nested dicts like `urls` are fully replaced
- **Constants use UPPERCASE**: `ALLOWED_EXTENSIONS`, `EXCLUDED_EXTENSIONS`, `EXCLUDED_PREFIXES` in `constants.py`
- **FFmpeg required**: Must be on system PATH for audio tag overlay (`download_utils.py`)
- **Windows-only build**: PyInstaller spec builds `.exe` on Windows CI

1. **Create source module**: `sources/<snake_case_name>.py` with a class inheriting from `BaseDownloader`, implementing `download(update_callback=None) -> bool`
## Dependencies

2. **Register in factory** (`sources/__init__.py`):
- Add import: `from .<snake_case_name> import <CamelCaseName>Downloader`
- Add to `downloaders` dict in `create_downloader()`: `"Display Name": <CamelCaseName>Downloader`
`selenium`, `webdriver-manager`, `psutil`, `watchdog`, `pyinstaller` (+ `ffmpeg` system package)

3. **Register in config** (`config.py`):
- Add to `DOWNLOAD_SOURCES` dict: `"Display Name": "snake_case_name"`
## Adding Sources

4. **Register in PyInstaller spec** (`AudioDownloader.spec`):
- Add to `hiddenimports` list: `'sources.<snake_case_name>'`
- This is critical — PyInstaller won't auto-discover dynamically imported source modules, and the exe will crash on that source.
Register in 4 places:
1. `sources/<snake_case_name>.py` — inherit `BaseDownloader`, implement `download(update_callback=None) -> bool`
2. `sources/__init__.py` — import and add to `downloaders` dict in `create_downloader()`
3. `config.py` — add to `DOWNLOAD_SOURCES` dict
4. `AudioDownloader.spec` — add to `hiddenimports` (PyInstaller won't auto-discover dynamic imports)

After adding a source, verify:
Verify:
```bash
python -c "from sources import create_downloader; from browser_manager import BrowserManager; from config import ConfigManager; bm = BrowserManager(ConfigManager()); d = create_downloader('Display Name', bm, ConfigManager()); print('OK')"
```

## Release Workflow

To publish a new version:

1. **Bump version** in `__init__.py`:
```python
__version__ = "1.1.9" # Use semver (major.minor.patch)
```

2. **Commit** the change:
```bash
git add __init__.py
git commit -m "chore: bump version to 1.1.9"
```
1. Bump `__version__` in `__init__.py` (semver: `1.1.9`)
2. `git commit -m "chore: bump version to X.Y.Z"`
3. `git tag X.Y.Z && git push origin main --tags`

3. **Create and push a semver tag**:
```bash
git tag 1.1.9
git push origin main --tags
```
CI (`.github/workflows/windows_build.yml`) builds `dist/AudioDownloader.exe` and creates a GitHub Release.

4. **CI does the rest**: The tag triggers `.github/workflows/windows_build.yml` which:
- Builds `dist/AudioDownloader.exe` via PyInstaller
- Creates a GitHub Release at `wardbryan3/SeleniumDownloader/releases` with the exe attached
`update_checker.py` compares `__version__` against `wardbryan3/SeleniumDownloader/releases/latest`.

The app's `update_checker.py` queries `wardbryan3/SeleniumDownloader/releases/latest`, extracts the `tag_name`, compares it against `__version__` using `parse_version()`, and notifies users of available updates in the GUI.

## Updating the Executable Locally

For local builds (before pushing):
## Local Build

```bash
pyinstaller --clean AudioDownloader.spec
```

Output: `dist/AudioDownloader.exe`

The `.spec` file's `hiddenimports` list must be kept in sync whenever modules are added or removed. If a new module is added (not in `sources/`), add it to `hiddenimports` — PyInstaller cannot detect runtime-imported modules like those in the factory pattern.
Keep `hiddenimports` in sync when adding modules — PyInstaller cannot detect runtime-imported modules.

The `AudioDownloader.exe` reads config from the same directory it's placed in (`download_config.json` auto-created on first run). It does not bundle FFmpeg — FFmpeg must be separately available on the user's system PATH for tag overlay to work.
Executable reads config from its own directory. Does not bundle FFmpeg.
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Audio Downloader

Python desktop app that uses Selenium (Firefox) to download radio show audio from 5 sources.

## Quick Start

**Windows users:** Download `AudioDownloader.exe` directly from [GitHub Releases](https://github.com/wardbryan3/SeleniumDownloader/releases) — no Python or dependencies needed.

**From source:**
```bash
git clone ...
cd SeleniumDownloader
python3 -m venv .venv
.venv/bin/python3 -m pip install -r requirements.txt

# Run GUI (default)
.venv/bin/python3 main.py

# CLI: all sources
.venv/bin/python3 main.py --download-all

# CLI: single source
.venv/bin/python3 main.py --source "Melinda Myers"
```

## Prerequisites

- **Python 3.11+**
- **Firefox** (GeckoDriver auto-installed via webdriver-manager)
- **FFmpeg** (system package, required for audio tag overlay):
- Linux: `apt install ffmpeg`
- Windows: Download from https://ffmpeg.org/download.html

## Configuration

On first run, `download_config.json` is auto-created with defaults (test mode on).

**Required fields to update:**
- `email` / `password` — Westwood One login
- `cow_password` — Clear Out West password
- `urls` — Replace `YOUR_LINK_HERE` placeholders with real URLs
- `test_mode` — Set to `False` for production (uses real Dropbox paths)

## Testing

```bash
.venv/bin/python3 run_all_tests.py
```

All tests are plain Python scripts (no framework). See `AGENTS.md` for developer details.

## Building

```bash
# Local build (Windows only)
pyinstaller --clean AudioDownloader.spec
# Output: dist/AudioDownloader.exe
```

CI automatically builds on push to `main` and creates a GitHub Release on tags.

## Release

1. Bump `__version__` in `__init__.py` (semver: `1.1.9`)
2. `git commit -m "chore: bump version to X.Y.Z"`
3. `git tag X.Y.Z && git push origin main --tags`

The app checks `wardbryan3/SeleniumDownloader/releases/latest` and notifies users of updates.
80 changes: 80 additions & 0 deletions run_all_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Run all test files in the project.
Usage: .venv/bin/python3 run_all_tests.py
"""

import sys
import subprocess
from pathlib import Path

# All test files to run
TEST_FILES = [
"test_downloads.py",
"test_detection_standalone.py",
"tests/test_config_edge_cases.py",
"tests/test_integration.py",
"tests/test_scheduler.py",
"tests/test_scheduler_unit.py",
"tests/test_browser_manager.py",
"tests/test_browser_manager_unit.py",
"tests/test_sources.py",
"tests/test_sources_unit.py",
"tests/test_download_utils.py",
"tests/test_update_checker.py",
"tests/test_config_unit.py",
]

def main():
project_root = Path(__file__).parent
failed = 0
results = []

print("=" * 60)
print("Running All Tests")
print("=" * 60)
print()

for test_file in TEST_FILES:
test_path = project_root / test_file
if not test_path.exists():
print(f" SKIP (not found): {test_file}")
continue

print(f"Running: {test_file}...")
result = subprocess.run(
[sys.executable, str(test_path)],
capture_output=False,
cwd=str(project_root)
)

if result.returncode == 0:
print(f" [PASS] PASSED: {test_file}")
results.append((test_file, "PASSED"))
else:
print(f" [FAIL] FAILED: {test_file} (exit code: {result.returncode})")
results.append((test_file, "FAILED"))
failed += 1
print()

print("=" * 60)
print("Summary:")
print("=" * 60)
for test_file, status in results:
marker = "[PASS]" if status == "PASSED" else "[FAIL]"
print(f" {marker} {test_file}: {status}")

print()
print(f"Total: {len(results)} tests, {len(results) - failed} passed, {failed} failed")

if failed > 0:
print()
print("Some tests FAILED!")
sys.exit(1)
else:
print()
print("All tests PASSED!")
sys.exit(0)


if __name__ == "__main__":
main()
8 changes: 4 additions & 4 deletions test_downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ def test_config_paths():
for folder in ['Global Features', 'WWO SPOTS', 'Promos']:
path = config.get_global_features_dir().replace('Global Features', folder)
if Path(path).exists():
print(f" {folder} folder exists")
print(f"[PASS] {folder} folder exists")
else:
print(f" {folder} folder missing")
print(f"[FAIL] {folder} folder missing")

print("\n" + "=" * 50)

Expand Down Expand Up @@ -188,9 +188,9 @@ def test_all_sources():
for name in DOWNLOAD_SOURCES.keys():
try:
d = create_downloader(name, bm, config)
print(f" {name}: OK")
print(f"[PASS] {name}: OK")
except Exception as e:
print(f" {name}: {e}")
print(f"[FAIL] {name}: {e}")

print("\n" + "=" * 50)

Expand Down
Loading
Loading