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
4 changes: 2 additions & 2 deletions .github/workflows/windows_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ jobs:
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
sed -i "s/__version__ = .*/__version__ = \"${{ steps.bump.outputs.new_version }}\"/" __init__.py
git add __init__.py
sed -i "s/__version__ = .*/__version__ = \"${{ steps.bump.outputs.new_version }}\"/" audio_downloader/__init__.py
git add audio_downloader/__init__.py
git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }}"
git tag ${{ steps.bump.outputs.new_version }}
git push origin ${{ steps.bump.outputs.new_version }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ test_config.py
# Build artifacts
build/
dist/

# Superpowers specs/plans (AI design docs)
docs/superpowers/
46 changes: 24 additions & 22 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ Audio Download Manager — a Python desktop app that uses Selenium (Firefox) to
## Developer 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 -m audio_downloader # Launch GUI (default)
python -m audio_downloader --download-all # CLI: download from all sources
python -m audio_downloader --source "Name" # CLI: download from one source
python tests/test_downloads.py # Run standalone test suite
python tests/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_sources.py # URL validation tests
Expand All @@ -23,33 +23,35 @@ No test framework installed — tests are plain Python scripts run directly. No

## Architecture

- **Entry point**: `main.py` — 3 modes: GUI (tkinter), CLI download-all, CLI single-source
- **Windows batch scripts**: `download_global_features.bat` (Thu 11PM), `download_promos.bat` (Tue 11PM)
- **Sources**: `sources/` — 6 downloader implementations using factory via `create_downloader(name, browser_mgr, config)`
- **Base class**: `sources/base.py` — `BaseDownloader` with `download()` abstract method
- **Entry point**: `audio_downloader/main.py` (run via `python -m audio_downloader`) — 3 modes: GUI (tkinter), CLI download-all, CLI single-source
- **Windows batch scripts**: `scripts/download_global_features.bat` (Thu 11PM), `scripts/download_promos.bat` (Tue 11PM)
- **Sources**: `audio_downloader/sources/` — 6 downloader implementations using factory via `create_downloader(name, browser_mgr, config)`
- **Base class**: `audio_downloader/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 dark theme in `gui.py`
- **GUI**: tkinter dark theme in `audio_downloader/gui.py`

## Key Directories

| Directory | Purpose | Gitignored? |
|---|---|---|
| `downloads/` | Test mode output | yes |
| `browser_downloads/` | Selenium staging dir | yes |
| `sources/` | Download source implementations | no |
| `audio_downloader/` | Application package (sources, config, GUI, etc.) | no |
| `audio_downloader/sources/` | Download source implementations | no |
| `tests/` | Test suite | no |
| `scripts/` | Windows batch scripts | 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`)
- **FFmpeg is external** — must be installed on the system separately for audio tag overlay (`audio_downloader/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
- **Constants use UPPERCASE**: `ALLOWED_EXTENSIONS`, `EXCLUDED_EXTENSIONS`, `EXCLUDED_PREFIXES` in `audio_downloader/constants.py` — always reference them with exact uppercase names

## Dependencies

Expand All @@ -69,7 +71,7 @@ GitHub Actions (`.github/workflows/windows_build.yml`): builds Windows exe on pu

To update config programmatically, use `ConfigManager`:
```python
from config import ConfigManager
from audio_downloader.config import ConfigManager
cm = ConfigManager()
cm.set("output_dir", "/path/to/output")
cm.save()
Expand All @@ -79,36 +81,36 @@ cm.save()

New sources require registration in **4 places**:

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

2. **Register in factory** (`sources/__init__.py`):
2. **Register in factory** (`audio_downloader/sources/__init__.py`):
- Add import: `from .<snake_case_name> import <CamelCaseName>Downloader`
- Add to `downloaders` dict in `create_downloader()`: `"Display Name": <CamelCaseName>Downloader`

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

4. **Register in PyInstaller spec** (`AudioDownloader.spec`):
- Add to `hiddenimports` list: `'sources.<snake_case_name>'`
- Add to `hiddenimports` list: `'audio_downloader.sources.<snake_case_name>'`
- This is critical — PyInstaller won't auto-discover dynamically imported source modules, and the exe will crash on that source.

After adding a source, 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')"
python -c "from audio_downloader.sources import create_downloader; from audio_downloader.browser_manager import BrowserManager; from audio_downloader.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`:
1. **Bump version** in `audio_downloader/__init__.py`:
```python
__version__ = "1.1.9" # Use semver (major.minor.patch)
```

2. **Commit** the change:
```bash
git add __init__.py
git add audio_downloader/__init__.py
git commit -m "chore: bump version to 1.1.9"
```

Expand All @@ -132,6 +134,6 @@ 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.
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 `audio_downloader/sources/`), add it to `hiddenimports` — PyInstaller cannot detect runtime-imported modules like those in the factory pattern.

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.
24 changes: 12 additions & 12 deletions AudioDownloader.spec
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@


a = Analysis(
['main.py'],
['audio_downloader/__main__.py'],
pathex=[os.path.dirname(os.path.abspath(SPEC))],
binaries=[],
datas=[],
collect_all=['selenium', 'webdriver_manager'],
hiddenimports=[
'gui',
'config',
'browser_manager',
'download_utils',
'sources',
'sources.base',
'sources.melinda_myers',
'sources.northwest_outdoors',
'sources.whittler',
'sources.clear_out_west',
'sources.weekend_in_the_country',
'audio_downloader.gui',
'audio_downloader.config',
'audio_downloader.browser_manager',
'audio_downloader.download_utils',
'audio_downloader.sources',
'audio_downloader.sources.base',
'audio_downloader.sources.melinda_myers',
'audio_downloader.sources.northwest_outdoors',
'audio_downloader.sources.whittler',
'audio_downloader.sources.clear_out_west',
'audio_downloader.sources.weekend_in_the_country',
'cryptography',
'OpenSSL',
'h2',
Expand Down
File renamed without changes.
2 changes: 2 additions & 0 deletions audio_downloader/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from audio_downloader.main import main
main()
2 changes: 1 addition & 1 deletion browser_manager.py → audio_downloader/browser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from selenium.common.exceptions import TimeoutException
from webdriver_manager.firefox import GeckoDriverManager

from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES
from audio_downloader.constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES

logger = logging.getLogger(__name__)

Expand Down
10 changes: 8 additions & 2 deletions config.py → audio_downloader/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def ensure_folders(self) -> bool:
self.get_output_base_dir(),
self.get_global_features_dir(),
self.get_promos_dir(),
self.get_spots_dir(),
]

for folder in folders:
Expand All @@ -138,8 +139,9 @@ def validate_config(self) -> List[str]:

folders_to_check = [
("Base output", self.get_output_base_dir()),
("Global Features", self.get_global_features_dir()),
("GLOBAL FEATURES", self.get_global_features_dir()),
("Promos", self.get_promos_dir()),
("Spots", self.get_spots_dir()),
]

for name, folder in folders_to_check:
Expand Down Expand Up @@ -174,12 +176,16 @@ def update(self, updates: Dict[str, Any]):

def get_global_features_dir(self) -> str:
"""Get the Global Features directory under the output dir"""
return self._get_subdir("Global Features")
return self._get_subdir("GLOBAL FEATURES")

def get_promos_dir(self) -> str:
"""Get the Promos directory under the output dir"""
return self._get_subdir("Promos")

def get_spots_dir(self) -> str:
"""Get the Spots directory under the output dir"""
return self._get_subdir("Spots")

def get_tag_file(self) -> str:
"""Get the audio tag file path"""
config_tag_file = self.config.get("tag_file")
Expand Down
File renamed without changes.
File renamed without changes.
8 changes: 4 additions & 4 deletions gui.py → audio_downloader/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
from datetime import datetime

try:
from config import ConfigManager, DOWNLOAD_SOURCES
from browser_manager import BrowserManager
from sources import create_downloader
from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES
from audio_downloader.browser_manager import BrowserManager
from audio_downloader.sources import create_downloader
except ImportError as e:
print(f"Import error in gui.py: {e}")
raise
Expand Down Expand Up @@ -88,7 +88,7 @@ def setup_gui(self):

all_btn = ttk.Button(
main_frame,
text="Download Global Features",
text="Download GLOBAL FEATURES",
command=self.run_all_downloads,
width=30
)
Expand Down
56 changes: 47 additions & 9 deletions main.py → audio_downloader/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import logging
import argparse

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

def setup_logging(log_to_file=True):
"""Configure logging for the application"""
Expand Down Expand Up @@ -53,9 +52,9 @@ def _touch_output_dir(config):

def run_cli_downloads():
"""Run downloads in CLI mode without GUI"""
from config import ConfigManager, DOWNLOAD_SOURCES
from browser_manager import BrowserManager
from sources import create_downloader
from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES
from audio_downloader.browser_manager import BrowserManager
from audio_downloader.sources import create_downloader

logger = logging.getLogger("audio_downloader")
logger.info("=" * 50)
Expand Down Expand Up @@ -117,11 +116,39 @@ def run_cli_downloads():
_touch_output_dir(config)
return all(results.values())

def run_promo_download():
"""Download just the Northwest Outdoors promo file"""
from audio_downloader.config import ConfigManager
from audio_downloader.browser_manager import BrowserManager
from audio_downloader.sources import create_downloader

logger = logging.getLogger("audio_downloader")
logger.info("=" * 50)
logger.info("DOWNLOADING PROMO")
logger.info("=" * 50)

config = ConfigManager()
browser_manager = BrowserManager(config)
downloader = create_downloader("Download Promo", browser_manager, config)

try:
success = downloader.download()
if success:
logger.info("PROMO: SUCCESS")
else:
logger.error("PROMO: FAILED")
return success
except Exception as e:
logger.error(f"PROMO: ERROR - {e}")
return False
finally:
browser_manager.close_browser()

def run_single_source(source_name):
"""Download from a single source"""
from config import ConfigManager, DOWNLOAD_SOURCES
from browser_manager import BrowserManager
from sources import create_downloader
from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES
from audio_downloader.browser_manager import BrowserManager
from audio_downloader.sources import create_downloader

logger = logging.getLogger("audio_downloader")

Expand Down Expand Up @@ -176,10 +203,21 @@ def main():
type=str,
help='Download from a specific source (e.g., "Melinda Myers")'
)

parser.add_argument(
'--download-promo',
action='store_true',
help='Download the Northwest Outdoors promo only'
)

args = parser.parse_args()

if args.download_all:
if args.download_promo:
setup_logging()
success = run_promo_download()
sys.exit(0 if success else 1)

elif args.download_all:
setup_logging()
logger = logging.getLogger("audio_downloader")
success = run_cli_downloads()
Expand All @@ -195,7 +233,7 @@ def main():
logger = logging.getLogger("audio_downloader")

try:
from gui import AudioDownloaderGUI
from audio_downloader.gui import AudioDownloaderGUI

logger.info("Starting Audio Download Manager (GUI Mode)")
app = AudioDownloaderGUI()
Expand Down
File renamed without changes.
4 changes: 2 additions & 2 deletions sources/base.py → audio_downloader/sources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from selenium.webdriver.common.by import By

from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES
from audio_downloader.constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -99,7 +99,7 @@ def wait_for_download_and_get_file(self, timeout: int = 30):
known_files[f.name] = 0
logger.info(f"Initial files in directory ({len(known_files)}): {list(known_files.keys())}")

from download_utils import DownloadUtilities
from audio_downloader.download_utils import DownloadUtilities

iteration = 0
while time.time() - start_time < timeout:
Expand Down
File renamed without changes.
File renamed without changes.
Loading
Loading