diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index c15978d..af02012 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index b637888..15e28e2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ test_config.py # Build artifacts build/ dist/ + +# Superpowers specs/plans (AI design docs) +docs/superpowers/ diff --git a/AGENTS.md b/AGENTS.md index 2a5ceaa..3882308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -23,13 +23,13 @@ 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 @@ -37,19 +37,21 @@ No test framework installed — tests are plain Python scripts run directly. No |---|---|---| | `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 @@ -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() @@ -79,36 +81,36 @@ cm.save() New sources require registration in **4 places**: -1. **Create source module**: `sources/.py` with a class inheriting from `BaseDownloader`, implementing `download(update_callback=None) -> bool` +1. **Create source module**: `audio_downloader/sources/.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 . import Downloader` - Add to `downloaders` dict in `create_downloader()`: `"Display Name": 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.'` + - Add to `hiddenimports` list: `'audio_downloader.sources.'` - 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" ``` @@ -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. diff --git a/AudioDownloader.spec b/AudioDownloader.spec index c34e3f4..bc0e45c 100644 --- a/AudioDownloader.spec +++ b/AudioDownloader.spec @@ -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', diff --git a/__init__.py b/audio_downloader/__init__.py similarity index 100% rename from __init__.py rename to audio_downloader/__init__.py diff --git a/audio_downloader/__main__.py b/audio_downloader/__main__.py new file mode 100644 index 0000000..f055c02 --- /dev/null +++ b/audio_downloader/__main__.py @@ -0,0 +1,2 @@ +from audio_downloader.main import main +main() \ No newline at end of file diff --git a/browser_manager.py b/audio_downloader/browser_manager.py similarity index 98% rename from browser_manager.py rename to audio_downloader/browser_manager.py index e043789..c602378 100644 --- a/browser_manager.py +++ b/audio_downloader/browser_manager.py @@ -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__) diff --git a/config.py b/audio_downloader/config.py similarity index 95% rename from config.py rename to audio_downloader/config.py index ace860c..01d679f 100644 --- a/config.py +++ b/audio_downloader/config.py @@ -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: @@ -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: @@ -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") diff --git a/constants.py b/audio_downloader/constants.py similarity index 100% rename from constants.py rename to audio_downloader/constants.py diff --git a/download_utils.py b/audio_downloader/download_utils.py similarity index 100% rename from download_utils.py rename to audio_downloader/download_utils.py diff --git a/gui.py b/audio_downloader/gui.py similarity index 98% rename from gui.py rename to audio_downloader/gui.py index b24fa36..3dc3aa7 100644 --- a/gui.py +++ b/audio_downloader/gui.py @@ -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 @@ -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 ) diff --git a/main.py b/audio_downloader/main.py similarity index 78% rename from main.py rename to audio_downloader/main.py index 5c8c536..8eefb0e 100644 --- a/main.py +++ b/audio_downloader/main.py @@ -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""" @@ -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) @@ -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") @@ -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() @@ -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() diff --git a/sources/__init__.py b/audio_downloader/sources/__init__.py similarity index 100% rename from sources/__init__.py rename to audio_downloader/sources/__init__.py diff --git a/sources/base.py b/audio_downloader/sources/base.py similarity index 97% rename from sources/base.py rename to audio_downloader/sources/base.py index e2bec05..006ea46 100644 --- a/sources/base.py +++ b/audio_downloader/sources/base.py @@ -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__) @@ -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: diff --git a/sources/clear_out_west.py b/audio_downloader/sources/clear_out_west.py similarity index 100% rename from sources/clear_out_west.py rename to audio_downloader/sources/clear_out_west.py diff --git a/sources/melinda_myers.py b/audio_downloader/sources/melinda_myers.py similarity index 100% rename from sources/melinda_myers.py rename to audio_downloader/sources/melinda_myers.py diff --git a/sources/northwest_outdoors.py b/audio_downloader/sources/northwest_outdoors.py similarity index 95% rename from sources/northwest_outdoors.py rename to audio_downloader/sources/northwest_outdoors.py index aebd385..57e50a3 100644 --- a/sources/northwest_outdoors.py +++ b/audio_downloader/sources/northwest_outdoors.py @@ -3,6 +3,7 @@ """ import os +import re import zipfile import time import logging @@ -113,7 +114,7 @@ def _download_nwo_zip(downloader, update_callback=None): class NorthwestOutdoorsDownloader(BaseDownloader): - """Download Northwest Outdoors non-promo files (Global Features)""" + """Download Northwest Outdoors non-promo files (GLOBAL FEATURES)""" def download(self, update_callback=None) -> bool: logger.info("=== STARTING NORTHWEST OUTDOORS DOWNLOAD ===") @@ -128,6 +129,7 @@ def download(self, update_callback=None) -> bool: global_features_dir.mkdir(parents=True, exist_ok=True) found_files = False + nwo_date_re = re.compile(r'^NWoutdoors\d{6}\.mp3$', re.IGNORECASE) for extracted_file in temp_dir.iterdir(): if not extracted_file.is_file(): continue @@ -136,6 +138,10 @@ def download(self, update_callback=None) -> bool: logger.info(f"Skipping promo file: {extracted_file.name}") continue + if nwo_date_re.match(extracted_file.name): + logger.info(f"Skipping date-stamped file: {extracted_file.name}") + continue + found_files = True if update_callback: update_callback(90, f"Moving {extracted_file.name}...") @@ -200,7 +206,7 @@ def download(self, update_callback=None) -> bool: output_file = promos_dir / extracted_file.name if Path(tag_file).exists(): - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities success = DownloadUtilities.overlay_promo_with_tag( str(extracted_file), tag_file, diff --git a/sources/weekend_in_the_country.py b/audio_downloader/sources/weekend_in_the_country.py similarity index 95% rename from sources/weekend_in_the_country.py rename to audio_downloader/sources/weekend_in_the_country.py index 87d3db4..18d273e 100644 --- a/sources/weekend_in_the_country.py +++ b/audio_downloader/sources/weekend_in_the_country.py @@ -140,14 +140,17 @@ def _process_files(self, output_dir): except OSError as e: logger.warning(f"Failed to rename {f.name}: {e}") + spots_dir = Path(self.config_manager.get_spots_dir()) + spots_dir.mkdir(parents=True, exist_ok=True) + for f, date_str in promos: date_tag = f"_{date_str}" if date_str else "" - new_name = f.parent / f"WITC_PROMO{date_tag}.mp3" + new_name = spots_dir / f"WITC_PROMO{date_tag}.mp3" try: f.rename(new_name) - logger.info(f"Renamed promo: {f.name} -> {new_name.name}") + logger.info(f"Moved promo: {f.name} -> {new_name}") except OSError as e: - logger.warning(f"Failed to rename promo {f.name}: {e}") + logger.warning(f"Failed to move promo {f.name}: {e}") def _find_mp3_files(self, ftp, path=""): """Recursively find all MP3 files on the FTP server""" diff --git a/sources/whittler.py b/audio_downloader/sources/whittler.py similarity index 98% rename from sources/whittler.py rename to audio_downloader/sources/whittler.py index c5cdede..2c388d7 100644 --- a/sources/whittler.py +++ b/audio_downloader/sources/whittler.py @@ -115,7 +115,7 @@ def download(self, update_callback=None) -> bool: logger.info(f"Extracted {len(zip_ref.namelist())} files") if update_callback: - update_callback(80, "Moving files to Global Features...") + update_callback(80, "Moving files to GLOBAL FEATURES...") output_dir = Path(self.config_manager.get_global_features_dir()) output_dir.mkdir(parents=True, exist_ok=True) diff --git a/docs/superpowers/specs/2026-06-26-weekly-automation-design.md b/docs/superpowers/specs/2026-06-26-weekly-automation-design.md new file mode 100644 index 0000000..a2db0b0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-weekly-automation-design.md @@ -0,0 +1,224 @@ +# Weekly Download Automation Design + +## Overview + +Consolidate the radio show download pipeline into a single automated weekly run. Replace browser-based scraping with direct HTTP/FTP downloads where possible, parallelize independent sources, and integrate the WITC promo rename logic into the downloader itself. + +## Goals + +- One scheduled task downloads everything for the coming week +- Eliminate browser dependency for all sources except Clear Out West (phase 2: eliminate entirely if feasible) +- Parallelize downloads for speed +- All cart filenames remain stable (automation system reads folders directly) +- No stale content — everything fresh before it airs + +## Schedule + +### Single run: Tuesday 11:00 PM via Windows Task Scheduler + +One bat script: `scripts/download_weekly.bat` + +```bat +@echo off +cd /d "%~dp0.." +AudioDownloader.exe --download-all +``` + +### Why Tuesday + +All content is available by Monday except the Northwest Outdoors promo, which updates Tuesday. Tuesday night is the earliest point where everything can be fetched in one pass. With a month of Melinda Myers content on FTP, the coming week's tips are always available. + +### Air schedule vs. download timing + +| Source | Airs | Content available | Downloaded | +|---|---|---|---| +| Melinda Myers | Mon-Fri | ~1 month ahead (FTP) | Previous Tuesday | +| WITC promo | Throughout week | By Monday (FTP) | Tuesday 11PM | +| WITC show | Saturday morning | By Monday (FTP) | Tuesday 11PM | +| NW Outdoors show | Saturday | By Monday | Tuesday 11PM | +| NW Outdoors promo | Saturday | By Tuesday | Tuesday 11PM | +| Whittler | Sunday | By Monday | Tuesday 11PM | +| Clear Out West | Sunday | By Monday | Tuesday 11PM | + +Everything airs fresh. Melinda Myers for the coming Mon-Fri is downloaded the prior Tuesday — no stale gap. + +## Source Changes + +### 1. Melinda Myers — Rewrite to FTP + +**Current:** Selenium web scraping of melindamyers.com, downloading day-by-day +**New:** FTP download, like WITC + +Flow: +1. Connect to MM FTP server using configured credentials +2. List all MP3 files recursively +3. Extract dates from filenames (regex, flexible format — try MM-DD-YY, MMDDYY, YYYY-MM-DD) +4. Find files matching the coming week's Mon, Tue, Wed, Thu, Fri dates +5. Download those 5 files +6. Rename to stable cart filenames: + - `MMMON.mp3` (Monday) + - `MMTUE.mp3` (Tuesday) + - `MMWED.mp3` (Wednesday) + - `MMTHU.mp3` (Thursday) + - `MMFRI.mp3` (Friday) +7. Place in `GLOBAL FEATURES/` + +Config additions: +```json +{ + "mm_ftp_server": "", + "mm_ftp_username": "", + "mm_ftp_password": "" +} +``` + +### 2. Northwest Outdoors — Direct HTTP download, merge show + promo + +**Current:** Two separate Selenium runs, each downloads the same Dropbox ZIP +**New:** Single `requests.get(url + "?dl=1")` download, extract once, split show vs. promo + +Flow: +1. Download ZIP via direct HTTP (`requests` library, `?dl=1` parameter) +2. Extract to temp directory +3. Non-promo, non-date-stamped files → `GLOBAL FEATURES/` (show content) +4. Promo files → `Promos/` with tag overlay (existing `DownloadUtilities.overlay_promo_with_tag` logic) +5. Clean up temp directory + +Eliminates: redundant ZIP download, browser startup, XPath waiting, popup handling. + +### 3. Whittler — Direct HTTP download + +**Current:** Selenium + Dropbox ZIP download +**New:** `requests.get(url + "?dl=1")` direct download + +Flow: +1. Download ZIP via direct HTTP +2. Extract to temp directory +3. Map "Part A/B/C/D" to `Whittler1.mp3`–`Whittler4.mp3` (existing logic) +4. Place in `GLOBAL FEATURES/` +5. Clean up temp directory + +### 4. Clear Out West — Phase 1: Keep Selenium, Phase 2: Investigate requests + +**Phase 1 (this design):** Keep Selenium-based download as-is. It requires password login on clearoutwest.com, which is a simple HTML form POST that could be done with `requests` but needs investigation first. + +**Phase 2 (future):** Replace with `requests.Session()` — POST password to login form, follow redirect, download MP3/ZIP links directly. If feasible, eliminates the last browser dependency. + +### 5. Weekend In The Country — Integrate promo rename + +**Current:** FTP download + separate `witc_promo_rename.bat` for promo rename +**New:** FTP download + integrated promo rename in `_process_files()` + +The FTP server provides two promos: this week's and next week's. + +Flow: +1. Connect to WITC FTP (existing logic) +2. Download all MP3 files (existing logic) +3. Rename segments to `WITC_HR{hr}_PT{pt}.mp3` in `GLOBAL FEATURES/` (existing logic) +4. Rename promos to `WITC_PROMO_MM-DD-YY.mp3` in `Spots/` (existing logic) +5. **NEW:** Find the upcoming Saturday's date +6. **NEW:** Copy `WITC_PROMO_{upcoming-saturday}.mp3` to `WITC_PROMO.mp3` (stable cart) +7. **NEW:** Delete any `WITC_PROMO_*.mp3` files dated before the upcoming Saturday +8. **NEW:** Preserve `WITC_PROMO_*.mp3` files dated after the upcoming Saturday (next week's promo) + +This replaces the fragile `witc_promo_rename.bat` PowerShell script and fixes the bug where next week's promo was being deleted. + +## Parallelization + +### Architecture: ThreadPoolExecutor with source groups + +``` +ThreadPoolExecutor(max_workers=5): + Thread 1: Melinda Myers (FTP) + Thread 2: WITC (FTP) + Thread 3: NW Outdoors show + promo (HTTP) + Thread 4: Whittler (HTTP) + Thread 5: Clear Out West (Selenium — only browser source) +``` + +All sources run concurrently. FTP and HTTP sources are pure I/O — no browser, no shared download directory, no conflicts. Clear Out West gets its own `BrowserManager` instance. + +**Key changes to enable parallelization:** +- Each source gets its own temp directory (not shared `browser_downloads/`) +- Each browser source gets its own `BrowserManager` (already supported — CLI mode creates fresh managers) +- `run_cli_downloads()` uses `ThreadPoolExecutor` instead of sequential loop +- Thread-safe result collection and logging +- NW Outdoors promo download merged into NW Outdoors show download (single ZIP, split locally) + +**GIL consideration:** Not a problem. All sources are I/O-bound (network waits, file writes, `time.sleep`). The GIL releases during I/O operations. + +### Error handling + +- Each source runs in its own thread with try/except +- Failures are collected, not fatal — one source failing doesn't stop others +- Final summary reports per-source success/failure +- Exit code: 0 if all succeeded, 1 if any failed (same as current behavior) + +## CLI Changes + +### `--download-all` (modified) + +Currently downloads 5 sources sequentially in one browser. New behavior: +- Downloads all sources in parallel via ThreadPoolExecutor +- Includes NW Outdoors promo (currently excluded — was a separate `--download-promo` run) +- No shared browser — each source manages its own transport + +### `--download-promo` (deprecated) + +Merged into `--download-all`. Kept for backward compatibility but calls the merged NW Outdoors download. + +### `--source` (unchanged) + +Single-source download still works for manual re-downloads. + +## File Changes Summary + +### New files +- `scripts/download_weekly.bat` — single weekly bat script + +### Modified files +- `audio_downloader/sources/melinda_myers.py` — full rewrite to FTP +- `audio_downloader/sources/northwest_outdoors.py` — rewrite to HTTP, merge show + promo +- `audio_downloader/sources/whittler.py` — rewrite to HTTP +- `audio_downloader/sources/weekend_in_the_country.py` — add promo rename to `_process_files()` +- `audio_downloader/main.py` — parallelize `run_cli_downloads()`, include promo in `--download-all` +- `audio_downloader/config.py` — add MM FTP credentials to `DEFAULT_CONFIG` +- `AudioDownloader.spec` — add `requests` to hiddenimports if needed + +### Deleted files +- `scripts/witc_promo_rename.bat` — logic moved into WITC downloader +- `scripts/download_promos.bat` — merged into `--download-all` +- `scripts/download_global_features.bat` — replaced by `download_weekly.bat` + +## Dependencies + +### New +- `requests` — HTTP downloads for Dropbox sources (NW Outdoors, Whittler) + +### Potentially removable (future) +- `selenium` — only needed for Clear Out West after this redesign +- `webdriver-manager` — same + +### Unchanged +- `psutil`, `watchdog`, `pyinstaller` +- `ffmpeg` — system package, still needed for tag overlay + +## Cart Filename Reference + +| Source | Folder | Cart filenames | +|---|---|---| +| Melinda Myers | GLOBAL FEATURES/ | MMMON.mp3, MMTUE.mp3, MMWED.mp3, MMTHU.mp3, MMFRI.mp3 | +| NW Outdoors (show) | GLOBAL FEATURES/ | Original ZIP filenames (non-promo, non-date-stamped) | +| NW Outdoors (promo) | Promos/ | Original promo filename from ZIP (with tag overlay) | +| Whittler | GLOBAL FEATURES/ | Whittler1.mp3, Whittler2.mp3, Whittler3.mp3, Whittler4.mp3 | +| Clear Out West | GLOBAL FEATURES/ | COW1.mp3, COW2.mp3, ..., COWPROMO.mp3 | +| WITC (show) | GLOBAL FEATURES/ | WITC_HR{hr}_PT{pt}.mp3 | +| WITC (promo) | Spots/ | WITC_PROMO.mp3 (stable), WITC_PROMO_MM-DD-YY.mp3 (dated) | + +## Open Questions + +1. **Melinda Myers FTP date format** — Need to confirm the exact date format in filenames. Design uses flexible regex to try multiple formats. Verify against actual FTP listing before implementation. + +2. **Clear Out West via requests** — Phase 2 investigation. The login is a simple HTML form POST that may work with `requests.Session()`. If feasible, eliminate the last browser dependency entirely. + +3. **Dropbox `?dl=1` reliability** — The `?dl=1` parameter has been stable for years but is not officially documented for shared folder links. If it breaks, fall back to the Dropbox API SDK (`dropbox` package) which supports shared link downloads without authentication. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..675e106 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=64.0"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "audio-download-manager" +version = "1.1.9" +description = "Download radio show audio files from multiple sources" +requires-python = ">=3.10" +dependencies = [ + "selenium>=4.0.0", + "webdriver-manager>=4.0.0", + "psutil>=5.9.0", +] + +[tool.setuptools.packages.find] +include = ["audio_downloader", "audio_downloader.*"] \ No newline at end of file diff --git a/download_global_features.bat b/scripts/download_global_features.bat similarity index 92% rename from download_global_features.bat rename to scripts/download_global_features.bat index ddcc47c..a4d8012 100644 --- a/download_global_features.bat +++ b/scripts/download_global_features.bat @@ -2,5 +2,5 @@ REM Schedule: Thursdays @ 11:00 PM REM Downloads all global feature sources (Melinda Myers, NW Outdoors, Whittler, Clear Out West, Weekend In The Country) -cd /d "%~dp0" +cd /d "%~dp0.." AudioDownloader.exe --download-all diff --git a/download_promos.bat b/scripts/download_promos.bat similarity index 62% rename from download_promos.bat rename to scripts/download_promos.bat index 4a4761c..2c5237b 100644 --- a/download_promos.bat +++ b/scripts/download_promos.bat @@ -2,5 +2,5 @@ REM Schedule: Tuesdays @ 11:00 PM REM Downloads promos only (Northwest Outdoors promo) -cd /d "%~dp0" -AudioDownloader.exe --source "Download Promo" +cd /d "%~dp0.." +AudioDownloader.exe --download-promo diff --git a/scripts/witc_promo_rename.bat b/scripts/witc_promo_rename.bat new file mode 100644 index 0000000..ee94224 --- /dev/null +++ b/scripts/witc_promo_rename.bat @@ -0,0 +1,26 @@ +@echo off +REM WITC Promo Rename — copies the current week's promo to WITC_PROMO.mp3 +REM Finds the upcoming Saturday, matches WITC_PROMO_MM-DD-YY.mp3, +REM and copies it as WITC_PROMO.mp3 in the Spots folder. + +cd /d "%~dp0.." + +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + $ErrorActionPreference = 'Stop'; ^ + $config = Get-Content download_config.json -Raw | ConvertFrom-Json; ^ + $outDir = $config.output_dir; ^ + if (-not [IO.Path]::IsPathRooted($outDir)) { $outDir = Join-Path (Get-Location) $outDir }; ^ + $spotsDir = Join-Path $outDir Spots; ^ + $today = Get-Date; ^ + $dow = [int]$today.DayOfWeek; ^ + $daysUntilSat = (6 - $dow + 7) %% 7; if ($daysUntilSat -eq 0) { $daysUntilSat = 7 }; ^ + $saturday = $today.AddDays($daysUntilSat); ^ + $dateStr = '{0:D2}-{1:D2}-{2:D2}' -f $saturday.Month, $saturday.Day, ($saturday.Year %% 100); ^ + $src = Join-Path $spotsDir ('WITC_PROMO_' + $dateStr + '.mp3'); ^ + $dst = Join-Path $spotsDir 'WITC_PROMO.mp3'; ^ + if (Test-Path $src) { ^ + Copy-Item $src $dst -Force; ^ + Get-ChildItem $spotsDir -Filter 'WITC_PROMO_*.mp3' -Exclude ('WITC_PROMO_' + $dateStr + '.mp3'), 'WITC_PROMO.mp3' | Remove-Item; ^ + Write-Host ('Copied WITC_PROMO_' + $dateStr + '.mp3 -> WITC_PROMO.mp3, cleaned up old promos') ^ + } ^ + else { Write-Host ('No promo found for date ' + $dateStr) } diff --git a/tests/__init__.py b/tests/__init__.py index ab7f426..f5221bc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -10,9 +10,9 @@ import tempfile from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent)) +sys.path.insert(0, str(Path(__file__).parent.parent)) -from config import ConfigManager, DEFAULT_CONFIG, CONFIG_FILE +from audio_downloader.config import ConfigManager, DEFAULT_CONFIG, CONFIG_FILE def test_set_method(): """Test that set() method works""" diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index 9bb00db..3f582dc 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -16,7 +16,7 @@ class TestBrowserManager: def test_browser_manager_imports(self): """Test that browser_manager can be imported""" try: - import browser_manager + import audio_downloader.browser_manager as browser_manager assert hasattr(browser_manager, 'BrowserManager'), "Should have BrowserManager class" print(" ✓ browser_manager imports successfully") except ImportError as e: @@ -26,7 +26,7 @@ def test_browser_manager_imports(self): def test_browser_manager_has_required_methods(self): """Test BrowserManager has required methods""" try: - from browser_manager import BrowserManager + from audio_downloader.browser_manager import BrowserManager required_methods = [ 'start_browser', 'close_browser', @@ -71,12 +71,12 @@ def test_webdriver_manager_imports(self): class TestBrowserStartup: """Test browser startup logic""" - @patch('browser_manager.webdriver.Firefox') - @patch('browser_manager.GeckoDriverManager') + @patch('audio_downloader.browser_manager.webdriver.Firefox') + @patch('audio_downloader.browser_manager.GeckoDriverManager') def test_start_firefox_browser(self, mock_driver_manager, mock_firefox): """Test Firefox browser startup""" try: - from browser_manager import BrowserManager + from audio_downloader.browser_manager import BrowserManager mock_driver_manager.return_value.install.return_value = "/path/to/geckodriver" mock_firefox.return_value = Mock() @@ -122,7 +122,7 @@ def test_driver_quit_handles_errors(self): def test_close_browser_method_exists(self): """Test close_browser method exists""" try: - from browser_manager import BrowserManager + from audio_downloader.browser_manager import BrowserManager assert hasattr(BrowserManager, 'close_browser'), "Missing close_browser method" diff --git a/tests/test_config_edge_cases.py b/tests/test_config_edge_cases.py index 1aabafa..3db8fcb 100644 --- a/tests/test_config_edge_cases.py +++ b/tests/test_config_edge_cases.py @@ -10,7 +10,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) -from config import ConfigManager, DEFAULT_CONFIG +from audio_downloader.config import ConfigManager, DEFAULT_CONFIG class TestConfigEdgeCases: """Test configuration edge cases""" @@ -94,11 +94,11 @@ def test_output_dir_affects_paths(self): assert "/tmp/custom_output" in base_dir gf_dir = cm.get_global_features_dir() - assert "Global Features" in gf_dir + assert "GLOBAL FEATURES" in gf_dir assert "/tmp/custom_output" in gf_dir print(f" ✓ Output base: {base_dir}") - print(f" ✓ Global Features: {gf_dir}") + print(f" ✓ GLOBAL FEATURES: {gf_dir}") def test_retry_attempts_validation(self): """Test retry_attempts must be valid integer""" diff --git a/test_detection_standalone.py b/tests/test_detection_standalone.py similarity index 93% rename from test_detection_standalone.py rename to tests/test_detection_standalone.py index ad1e78d..d314419 100644 --- a/test_detection_standalone.py +++ b/tests/test_detection_standalone.py @@ -13,7 +13,7 @@ import logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -from sources.base import BaseDownloader +from audio_downloader.sources.base import BaseDownloader class MockBrowserManager: def __init__(self): @@ -27,11 +27,14 @@ def get(self, key, default=None): return default def get_global_features_dir(self): - return str(Path.home() / "downloads" / "Global Features") + return str(Path.home() / "downloads" / "GLOBAL FEATURES") def get_promos_dir(self): return str(Path.home() / "downloads" / "Promos") + def get_spots_dir(self): + return str(Path.home() / "downloads" / "Spots") + def get_tag_file(self): return str(Path.home() / "downloads" / "Promos" / "tag.wav") diff --git a/tests/test_download_utils.py b/tests/test_download_utils.py index 7cfd2ce..9809063 100644 --- a/tests/test_download_utils.py +++ b/tests/test_download_utils.py @@ -15,7 +15,7 @@ class TestOverlayPromoWithTag: def test_missing_promo_file(self): """Should return False if promo file doesn't exist""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities result = DownloadUtilities.overlay_promo_with_tag( "/nonexistent/promo.mp3", "/nonexistent/tag.wav", "/tmp/output.mp3" ) @@ -24,7 +24,7 @@ def test_missing_promo_file(self): def test_missing_tag_file(self): """Should return False if tag file doesn't exist""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities # Create a fake promo file with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: @@ -42,7 +42,7 @@ def test_missing_tag_file(self): def test_successful_overlay(self): """Should return True when FFmpeg succeeds""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities # Create fake input files with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: @@ -71,7 +71,7 @@ def mock_run(cmd, **kwargs): Path(output_path).write_bytes(b'fake output') return mock_result - with patch('download_utils.subprocess.run', side_effect=mock_run): + with patch('audio_downloader.download_utils.subprocess.run', side_effect=mock_run): result = DownloadUtilities.overlay_promo_with_tag( promo_path, tag_path, output_path, overlap_seconds=10 ) @@ -88,7 +88,7 @@ def mock_run(cmd, **kwargs): def test_ffmpeg_failure(self): """Should return False when FFmpeg fails""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: promo.write(b'fake audio') @@ -108,7 +108,7 @@ def mock_run(cmd, **kwargs): mock_result.stderr = 'Some error' return mock_result - with patch('download_utils.subprocess.run', side_effect=mock_run): + with patch('audio_downloader.download_utils.subprocess.run', side_effect=mock_run): result = DownloadUtilities.overlay_promo_with_tag( promo_path, tag_path, output_path, overlap_seconds=10 ) @@ -125,7 +125,7 @@ def mock_run(cmd, **kwargs): def test_promo_too_short(self): """Should return False if promo is shorter than overlap""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: promo.write(b'fake audio') diff --git a/test_downloads.py b/tests/test_downloads.py similarity index 85% rename from test_downloads.py rename to tests/test_downloads.py index d34a712..c52b6d1 100644 --- a/test_downloads.py +++ b/tests/test_downloads.py @@ -3,12 +3,15 @@ """ import os +import sys import time import tempfile import shutil from pathlib import Path import logging +sys.path.insert(0, str(Path(__file__).parent.parent)) + logger = logging.getLogger(__name__) class DownloadSimulator: @@ -66,8 +69,8 @@ def simulate_incremental_download(directory: str, filename: str, total_size_kb: def test_download_detection(): """Test the download detection logic""" - from download_utils import DownloadUtilities - from sources.base import BaseDownloader + from audio_downloader.download_utils import DownloadUtilities + from audio_downloader.sources.base import BaseDownloader print("=" * 50) print("Testing Download Detection") @@ -97,7 +100,7 @@ def test_download_detection(): def test_config_paths(): """Test configuration paths""" - from config import ConfigManager + from audio_downloader.config import ConfigManager print("=" * 50) print("Testing Configuration Paths") @@ -106,14 +109,15 @@ def test_config_paths(): config = ConfigManager() print(f"\nOutput base: {config.get_output_base_dir()}") - print(f"Global Features: {config.get_global_features_dir()}") + print(f"GLOBAL FEATURES: {config.get_global_features_dir()}") print(f"Promos: {config.get_promos_dir()}") + print(f"Spots: {config.get_spots_dir()}") print(f"Tag file: {config.get_tag_file()}") config.ensure_folders() - for folder in ['Global Features', 'Promos']: - path = config.get_global_features_dir().replace('Global Features', folder) + for folder in ['GLOBAL FEATURES', 'Promos', 'Spots']: + path = config.get_global_features_dir().replace('GLOBAL FEATURES', folder) if Path(path).exists(): print(f"✓ {folder} folder exists") else: @@ -124,8 +128,8 @@ def test_config_paths(): def test_browser_manager(): """Test browser manager initialization""" - from browser_manager import BrowserManager - from config import ConfigManager + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.config import ConfigManager print("=" * 50) print("Testing Browser Manager") @@ -143,9 +147,9 @@ def test_browser_manager(): def test_all_sources(): """Test creating all downloader instances""" - from sources import create_downloader - from browser_manager import BrowserManager - from config import ConfigManager, DOWNLOAD_SOURCES + from audio_downloader.sources import create_downloader + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES print("=" * 50) print("Testing All Download Sources") diff --git a/tests/test_integration.py b/tests/test_integration.py index e111a72..4829fa7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,7 +15,7 @@ class TestDownloadWorkflow: def test_config_manager_workflow(self): """Test full ConfigManager workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -35,7 +35,7 @@ def test_config_manager_workflow(self): def test_source_initialization(self): """Test that all sources can be initialized""" - from sources import ( + from audio_downloader.sources import ( MelindaMyersDownloader, NorthwestOutdoorsDownloader, NorthwestOutdoorsPromoDownloader, @@ -59,7 +59,7 @@ def test_source_initialization(self): def test_downloader_has_required_methods(self): """Test BaseDownloader has required methods""" - from sources.base import BaseDownloader + from audio_downloader.sources.base import BaseDownloader required_methods = [ 'download', @@ -73,7 +73,7 @@ def test_downloader_has_required_methods(self): def test_promo_tag_workflow(self): """Test promo tag overlay workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -89,14 +89,14 @@ def test_promo_tag_workflow(self): class TestEndToEndScenarios: """Test end-to-end scenarios""" - @patch('browser_manager.BrowserManager.start_browser') + @patch('audio_downloader.browser_manager.BrowserManager.start_browser') def test_northwest_outdoors_workflow(self, mock_start_browser): """Test Northwest Outdoors download workflow""" mock_start_browser.return_value = True - from sources.northwest_outdoors import NorthwestOutdoorsDownloader - from config import ConfigManager - from browser_manager import BrowserManager + from audio_downloader.sources.northwest_outdoors import NorthwestOutdoorsDownloader + from audio_downloader.config import ConfigManager + from audio_downloader.browser_manager import BrowserManager cm = ConfigManager() bm = BrowserManager(cm) @@ -109,14 +109,14 @@ def test_northwest_outdoors_workflow(self, mock_start_browser): assert is_valid, "URL should be valid" print(f" ✓ Northwest Outdoors workflow ready with valid URL") - @patch('browser_manager.BrowserManager.start_browser') + @patch('audio_downloader.browser_manager.BrowserManager.start_browser') def test_whittler_workflow(self, mock_start_browser): """Test Whittler download workflow""" mock_start_browser.return_value = True - from sources.whittler import WhittlerDownloader - from config import ConfigManager - from browser_manager import BrowserManager + from audio_downloader.sources.whittler import WhittlerDownloader + from audio_downloader.config import ConfigManager + from audio_downloader.browser_manager import BrowserManager cm = ConfigManager() bm = BrowserManager(cm) @@ -131,7 +131,7 @@ def test_whittler_workflow(self, mock_start_browser): def test_output_dir_workflow(self): """Test output directory workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -149,7 +149,7 @@ def test_output_dir_workflow(self): def test_validate_config_workflow(self): """Test config validation workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -165,7 +165,7 @@ def test_validate_config_workflow(self): def test_browser_download_dir_workflow(self): """Test browser download directory workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager from pathlib import Path cm = ConfigManager() @@ -186,7 +186,7 @@ class TestErrorHandling: def test_missing_config_file_creates_default(self): """Test that missing config file creates default""" import tempfile - from config import ConfigManager + from audio_downloader.config import ConfigManager temp_config = tempfile.NamedTemporaryFile(delete=False, suffix='.json') temp_config.close() @@ -195,7 +195,7 @@ def test_missing_config_file_creates_default(self): original_file = ConfigManager.CONFIG_FILE if hasattr(ConfigManager, 'CONFIG_FILE') else None try: - import config + import audio_downloader.config as config config.CONFIG_FILE = temp_config.name if os.path.exists(temp_config.name): @@ -216,7 +216,7 @@ def test_missing_config_file_creates_default(self): def test_invalid_json_handled(self): """Test that invalid JSON is handled gracefully""" import tempfile - from config import ConfigManager + from audio_downloader.config import ConfigManager temp_config = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') temp_config.write("{ invalid json }") diff --git a/tests/test_sources.py b/tests/test_sources.py index 2531fcc..47ad3c6 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -2,12 +2,13 @@ Test URL validation logic for all download sources """ +import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) -from config import ConfigManager +from audio_downloader.config import ConfigManager def test_northwest_outdoors_url_validation(): @@ -145,6 +146,37 @@ def test_validation_logic_matches_whittler(): print(" ✓ Whittler validation logic matches source") +def test_northwest_outdoors_date_stamped_file_filter(): + """NWoutdoors<6digits>.mp3 files should be filtered out after unzip""" + pattern = re.compile(r'^NWoutdoors\d{6}\.mp3$', re.IGNORECASE) + + should_match = [ + "NWoutdoors062776.mp3", + "NWoutdoors123456.mp3", + "nwoutdoors062776.mp3", + "NWOUTDOORS062776.mp3", + ] + should_not_match = [ + "NWoutdoors06277.mp3", + "NWoutdoors0627766.mp3", + "NWoutdoors062776.wav", + "NWoutdoorsabcdef.mp3", + "something_NWoutdoors062776.mp3", + "NWoutdoors062776_promo.mp3", + "NWO062776.mp3", + "promo_file.mp3", + "regular_show.mp3", + ] + + for name in should_match: + assert pattern.match(name), f"'{name}' should match the date pattern" + + for name in should_not_match: + assert not pattern.match(name), f"'{name}' should NOT match the date pattern" + + print(" ✓ NWoutdoors date-stamped file filter works correctly") + + def run_tests(): """Run all source URL validation tests""" print("=" * 60) @@ -160,6 +192,7 @@ def run_tests(): test_url_with_special_characters, test_validation_logic_matches_northwest_outdoors, test_validation_logic_matches_whittler, + test_northwest_outdoors_date_stamped_file_filter, ] passed = 0 diff --git a/tests/test_weekend_in_the_country.py b/tests/test_weekend_in_the_country.py index 363244d..0dc41c5 100644 --- a/tests/test_weekend_in_the_country.py +++ b/tests/test_weekend_in_the_country.py @@ -16,8 +16,11 @@ def _make_file(dir_path, name): (dir_path / name).touch() -def _simulate_process_files(output_dir): +def _simulate_process_files(output_dir, spots_dir=None): """Replicate the rename logic from weekend_in_the_country.py""" + if spots_dir is None: + spots_dir = output_dir + seg_re = re.compile(r'hr(\d+)_seg(\d+)', re.IGNORECASE) date_re = re.compile(r'(\d{2}-\d{2}-\d{2})') promos = [] @@ -45,26 +48,29 @@ def _simulate_process_files(output_dir): for f, date_str in promos: date_tag = f"_{date_str}" if date_str else "" - new_name = f.parent / f"WITC_PROMO{date_tag}.mp3" + new_name = spots_dir / f"WITC_PROMO{date_tag}.mp3" f.rename(new_name) def test_segments_renamed_correctly(): """Segment files are renamed to WITC_HR{num}_PT{num}""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg2.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr2_seg1.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr2_seg4.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) files = {f.name for f in tmp.iterdir() if f.is_file()} expected = {"WITC_HR1_PT1.mp3", "WITC_HR1_PT2.mp3", "WITC_HR2_PT1.mp3", "WITC_HR2_PT4.mp3"} assert files == expected, f"Got {files}" - print(" ✓ Segments renamed correctly") + assert len(list(spots.iterdir())) == 0, "No files should be in Spots" + print(" ✓ Segments renamed correctly, spots dir empty") finally: shutil.rmtree(tmp) @@ -72,11 +78,13 @@ def test_segments_renamed_correctly(): def test_no_segments_skipped(): """Files without hr/seg pattern are left untouched""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") _make_file(tmp, "some_other_file.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) names = {f.name for f in tmp.iterdir() if f.is_file()} assert "WITC_HR1_PT1.mp3" in names @@ -87,50 +95,64 @@ def test_no_segments_skipped(): def test_promos_renamed_with_date(): - """Each promo is renamed to WITC_PROMO_MM-DD-YY.mp3""" + """Each promo is moved to Spots and renamed to WITC_PROMO_MM-DD-YY.mp3""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26 promo.mp3") _make_file(tmp, "Weekend in the Country_07-04-26 promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert "WITC_PROMO_06-27-26.mp3" in files, "First promo missing" - assert "WITC_PROMO_07-04-26.mp3" in files, "Second promo missing" - assert "Weekend in the Country_06-27-26 promo.mp3" not in files, \ - "Original should be renamed" - print(" ✓ Both promos renamed with date tags") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert "Weekend in the Country_06-27-26 promo.mp3" not in global_files, \ + "Promo should be removed from global features" + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert "WITC_PROMO_06-27-26.mp3" in spots_files, "First promo missing in Spots" + assert "WITC_PROMO_07-04-26.mp3" in spots_files, "Second promo missing in Spots" + print(" ✓ Both promos moved to Spots and renamed with date tags") finally: shutil.rmtree(tmp) def test_promo_without_date_defaults(): - """Promo with unparseable date is renamed without tag""" + """Promo with unparseable date is moved to Spots and renamed without tag""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_baddate promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert "WITC_PROMO.mp3" in files, "Promo with bad date should still be kept" - print(" ✓ Promo with unparseable date handled") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert "Weekend in the Country_baddate promo.mp3" not in global_files + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert "WITC_PROMO.mp3" in spots_files, "Promo with bad date should still be kept" + print(" ✓ Promo with unparseable date moved to Spots") finally: shutil.rmtree(tmp) def test_single_promo_kept(): - """Single promo is renamed with date""" + """Single promo is moved to Spots and renamed with date""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26 promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert files == {"WITC_PROMO_06-27-26.mp3"}, f"Got {files}" - print(" ✓ Single promo renamed with date") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert len(global_files) == 0, f"GLOBAL FEATURES should be empty, got {global_files}" + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert spots_files == {"WITC_PROMO_06-27-26.mp3"}, f"Got {spots_files}" + print(" ✓ Single promo moved to Spots and renamed with date") finally: shutil.rmtree(tmp) @@ -138,13 +160,16 @@ def test_single_promo_kept(): def test_no_promo_no_error(): """No promo files is handled gracefully""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) files = {f.name for f in tmp.iterdir() if f.is_file()} assert files == {"WITC_HR1_PT1.mp3"}, f"Got {files}" + assert len(list(spots.iterdir())) == 0, "Spots should be empty" print(" ✓ No promo files handled gracefully") finally: shutil.rmtree(tmp) @@ -153,11 +178,13 @@ def test_no_promo_no_error(): def test_non_weekend_files_ignored(): """Files not starting with 'Weekend in the Country' are ignored""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "completely_different.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) files = {f.name for f in tmp.iterdir() if f.is_file()} assert "WITC_HR1_PT1.mp3" in files @@ -168,16 +195,21 @@ def test_non_weekend_files_ignored(): def test_promo_with_past_date_handled(): - """Past-date promo is renamed with its date""" + """Past-date promo is moved to Spots and renamed with its date""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-20-26 promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert "WITC_PROMO_06-20-26.mp3" in files, "Past promo should be renamed with date" - print(" ✓ Past-date promo renamed with date") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert "Weekend in the Country_06-20-26 promo.mp3" not in global_files + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert "WITC_PROMO_06-20-26.mp3" in spots_files, "Past promo should be renamed with date" + print(" ✓ Past-date promo moved to Spots and renamed with date") finally: shutil.rmtree(tmp)