From 8f8a739ee8615188e705f83deae7dc14fd32a3a4 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Wed, 24 Jun 2026 16:58:23 -0700 Subject: [PATCH 1/9] docs: add user-friendly README for non-technical users Plain-language guide explaining every button, setting, and troubleshooting step. Written for users with minimal computer experience. --- README.md | 394 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..196d003 --- /dev/null +++ b/README.md @@ -0,0 +1,394 @@ +# Audio Download Manager - User Guide + +Audio Download Manager is a small program that downloads radio show audio +files from several websites and saves them into organized folders on your +computer. It does the clicking, waiting, and filing-away for you so you +don't have to. + +This guide explains everything in plain language. If you can send an email, +you can use this program. + +--- + +## Table of Contents + +1. [What This Program Does](#what-this-program-does) +2. [What You Need Before You Start](#what-you-need-before-you-start) +3. [First-Time Setup (Important)](#first-time-setup-important) +4. [The Main Screen - Every Button Explained](#the-main-screen---every-button-explained) +5. [The Download Log](#the-download-log) +6. [Where Your Files Go](#where-your-files-go) +7. [Automatic Scheduled Downloads](#automatic-scheduled-downloads) +8. [Troubleshooting](#troubleshooting) + +--- + +## What This Program Does + +The program downloads audio files from five different sources, each one a +radio show or feature: + +| Source | What it is | How it downloads | +|--------|-----------|------------------| +| **Melinda Myers** | Short gardening tips (one for each weekday) | From the Melinda Myers website | +| **Northwest Outdoors** | A weekly outdoor radio show | From a Dropbox shared link (a ZIP file) | +| **Whittler** | A radio show split into four parts | From a Dropbox shared link (a ZIP file) | +| **Clear Out West** | A radio show with several tracks | From the Clear Out West website (needs a password) | +| **Weekend In The Country** | A radio show with segments and a promo | From an FTP server (needs a username and password) | + +The program opens a hidden Firefox web browser in the background, visits each +website, clicks the download buttons, waits for the files to finish, then +renames and sorts them into the right folders. You don't have to click +anything on the websites yourself. + +There is also a special **Download Promo** button that downloads just the +promo file from Northwest Outdoors and adds a short audio "tag" (like a +station jingle) onto the end of it. + +--- + +## What You Need Before You Start + +These are one-time things to set up. Once done, you usually never have to +touch them again. + +### 1. Firefox web browser + +The program uses Firefox to visit the download websites. If you don't +already have Firefox installed, download it for free from +https://www.mozilla.org/firefox/ and install it normally. + +You do **not** need to open Firefox yourself. The program opens it +automatically when needed and closes it when finished. + +### 2. FFmpeg (only needed for the promo tag) + +The "Download Promo" feature uses a free tool called FFmpeg to blend a short +jingle onto the end of the promo audio. If you never use Download Promo, you +can skip this. + +If you do want the promo tag feature, FFmpeg must be installed on your +computer and available on your system PATH. Ask whoever set up your computer +to install it, or follow a guide for "installing FFmpeg on Windows." If it +is missing, the promo will still download fine, just without the tag added. + +### 3. Your account details handy + +You will need: + +- The **Clear Out West password** (for the Clear Out West source) +- The **Weekend In The Country FTP server address, username, and password** +- The **Dropbox shared links** for Northwest Outdoors and Whittler (these + look like `https://www.dropbox.com/scl/fo/...`) + +If you don't have these, the program will tell you which ones are missing +when you try to download. + +--- + +## First-Time Setup (Important) + +The very first time you run the program, you should check your settings. +This only takes a minute and makes sure everything downloads to the right +place. + +1. Open the program (double-click `AudioDownloader.exe`). +2. Look at the top-right corner of the window. You will see a small + **gear icon** (it looks like this: a little wheel). Click it. +3. A **Settings** window opens with four tabs across the top: + **General**, **Paths**, **Auth**, and **URLs**. + +Go through each tab below. + +### General tab + +This tab controls where files are saved and how the program behaves. + +- **Output Directory** - This is the main folder where all your downloaded + audio ends up. Inside this folder, the program creates two sub-folders + automatically: one called "Global Features" and one called "Promos" (see + [Where Your Files Go](#where-your-files-go) below). You can type a folder + path here, or click the **Browse** button next to it to pick a folder + using the normal folder picker. The default is a folder called + `downloads` next to the program itself. + +- **Auto-close browser after downloads** - A small box you can check or + uncheck. When checked (the default), the program closes the hidden + Firefox browser automatically when a download finishes. Leave this + checked unless you have a reason not to. If you uncheck it, Firefox + stays open in the background after each download and you would have to + close it yourself. + +- **Retry Attempts** - A number from 0 to 5. This tells the program how many + extra times to try again if a download fails (for example, if a website + is slow or temporarily down). The default is 2, which means it tries up + to 3 times total (the first try plus 2 retries). If your internet is + unreliable, you might raise this to 3 or 4. Setting it to 0 means it + only tries once with no retries. + +### Paths tab + +This tab is for special file locations. Most people can leave these alone, +but here is what each one means. + +- **Tag File** - This is the short audio jingle file (a `.wav` file) that + gets blended onto the end of the Northwest Outdoors promo. If you leave + this blank, the program looks for a file called `NWKORVTAG.wav` inside + your Promos folder. If you have your tag file somewhere else, click the + **...** button to find and select it. Only needed if you use the + Download Promo button. + +- **Browser Download Dir** - This is a temporary "holding" folder where + Firefox saves files *while* they are downloading, before the program + moves them to their final home. You normally never look in this folder. + The default is a folder called `browser_downloads` next to the program. + You can change it with the **Browse** button if needed, but there is + rarely a reason to. + +### Auth tab + +This tab holds your passwords and login details. These are stored only on +your own computer in a settings file next to the program. They are never +sent anywhere except to the websites they belong to. + +- **Clear Out West Password** - The password for the Clear Out West + website. Type it in. It will show as dots (hidden) so nobody can read + it over your shoulder. Without this, the Clear Out West download will + not work. + +- **WITC FTP Server** - The server address for Weekend In The Country + (for example, `ftp.example.com`). Get this from the show provider. + +- **WITC FTP Username** - The username for the Weekend In The Country FTP + server. + +- **WITC FTP Password** - The password for the Weekend In The Country FTP + server. Shows as dots. + +If you don't use the Weekend In The Country source, you can leave its three +fields blank. But if you ever click that download button, it will fail +until you fill them in. + +### URLs tab + +This tab holds the Dropbox shared links for two of the sources. + +- **Northwest Outdoors** - Paste the Dropbox shared link for Northwest + Outdoors here. It should look like + `https://www.dropbox.com/scl/fo/...`. Get this link from the show + provider. If this still says `YOUR_LINK_HERE`, the Northwest Outdoors + download will not work. + +- **Whittler** - Paste the Dropbox shared link for Whittler here, the same + way. If it still says `YOUR_LINK_HERE`, the Whittler download will not + work. + +### Saving your settings + +When you are done with all four tabs, click the **Save** button at the +bottom. You will see a small message saying "Settings saved successfully!" +Click OK, and the settings window closes. + +If you change your mind and don't want to save, click **Cancel** instead +and nothing will be changed. + +--- + +## The Main Screen - Every Button Explained + +When you open the program, you see the main window. From top to bottom: + +### The gear icon (top-right corner) + +This opens the Settings window described above. You can change your +settings any time, even in the middle of downloads. + +### "Download Global Features" button + +This is the big button near the top. Click it to download **all five +sources one after another**: Melinda Myers, Northwest Outdoors, Whittler, +Clear Out West, and Weekend In The Country. The program goes through them +in order. You will see the progress bar move and the status text change as +each one starts and finishes. + +When everything is done, a small summary window pops up telling you how +many succeeded and listing any that failed. Click **OK** to close it. + +This is the button you will use most often. It does the whole week's +downloads in one go. + +### "Download Promo" button + +This downloads **only** the Northwest Outdoors promo file and adds the +audio tag (jingle) onto the end of it. Use this when you only need the +promo, not the full shows. It is a separate button because promos are +usually needed on a different day than the full shows. + +Note: This needs the Tag File setting (see the Paths tab above) and FFmpeg +installed. If either is missing, the promo still downloads but without the +tag. + +### "Downloads:" section + +Below the two big buttons, you see a label "Downloads:" and then a list of +five buttons, one for each source: + +- **Melinda Myers** - Downloads only the Melinda Myers gardening tips. +- **Northwest Outdoors** - Downloads only the Northwest Outdoors show + (the full show files, not the promo). +- **Whittler** - Downloads only the Whittler show. +- **Clear Out West** - Downloads only the Clear Out West show. +- **Weekend In The Country** - Downloads only the Weekend In The Country + show. + +Use these when you only want one specific show instead of all of them. +Clicking one of these does exactly the same thing as the big "Download +Global Features" button, but for just that one source. + +### Progress bar + +The thin blue bar below the buttons fills up from left to right as a +download progresses. When it reaches the right side, the download is done. +It resets to empty a couple of seconds after each download finishes. + +### Status text + +Just below the progress bar, a line of text tells you what is happening +right now, for example "Downloading Monday..." or "Done - Melinda Myers +completed!" or "FAIL - Whittler failed." This is your quick at-a-glance +status. + +--- + +## The Download Log + +The big box at the bottom labeled "Download Log" shows a running list of +everything the program is doing, with a time stamp on each line. It +scrolls automatically so the newest message is always visible. + +You don't need to read this normally. It is there so that if something +goes wrong, you (or whoever helps you) can look back and see exactly what +happened and when. + +If a download fails, the log will usually say why, for example "Error: +cow_password not configured" or "No downloaded file found after waiting." + +--- + +## Where Your Files Go + +All your finished audio files end up inside the **Output Directory** you +set in the General settings tab. Inside it, the program creates two +sub-folders automatically: + +``` +Your Output Directory/ + Global Features/ + MMMON.mp3 (Melinda Myers - Monday) + MMWED.mp3 (Melinda Myers - Wednesday) + MMFRI.mp3 (Melinda Myers - Friday) + MMTUE.mp3 (Melinda Myers - Tuesday) + MMTHU.mp3 (Melinda Myers - Thursday) + Whittler1.mp3 (Whittler - Part A) + Whittler2.mp3 (Whittler - Part B) + Whittler3.mp3 (Whittler - Part C) + Whittler4.mp3 (Whittler - Part D) + COW1.mp3 ... (Clear Out West tracks) + COWPROMO.mp3 (Clear Out West promo track) + WITC_HR1_PT1.mp3 (Weekend In The Country - Hour 1, Part 1) + WITC_PROMO.mp3 (Weekend In The Country promo) + ...and the Northwest Outdoors show files + Promos/ + ...the Northwest Outdoors promo file (with tag added) +``` + +- **Global Features** holds all the full show files and regular features. +- **Promos** holds promo files, including the Northwest Outdoors promo + with the tag blended onto the end. + +The program renames the files automatically so they always have +consistent, predictable names. You never have to rename anything yourself. + +--- + +## Automatic Scheduled Downloads + +If you want the program to run by itself on a schedule (so you don't have +to remember), there are two ready-made files included: + +- **`download_global_features.bat`** - Runs "Download Global Features" + (all five sources). Meant to be scheduled for **Thursdays at 11:00 PM**. +- **`download_promos.bat`** - Runs "Download Promo" only. Meant to be + scheduled for **Tuesdays at 11:00 PM**. + +These are small files that simply tell the program to run in automatic +mode without opening the window. + +To set up the schedule on Windows, you use a built-in tool called **Task +Scheduler**. This is a bit more technical, so you may want to ask whoever +helps you with your computer to set it up. Once it is set up, the program +will download everything by itself overnight on the right days, and the +files will be waiting for you in the morning. + +If you prefer, you can skip the schedule entirely and just click the +buttons yourself whenever you want. + +--- + +## Troubleshooting + +### A download says "FAIL" + +Look at the Download Log for the reason. The most common causes: + +- **"cow_password not configured"** - You haven't set the Clear Out West + password in the Auth settings tab. Open Settings, go to the Auth tab, + type it in, and Save. +- **"FTP credentials not configured"** - You haven't filled in the + Weekend In The Country server, username, and password in the Auth tab. +- **"URL not configured"** - The Dropbox link for Northwest Outdoors or + Whittler still says `YOUR_LINK_HERE`. Paste the real link in the URLs + tab. +- **"No downloaded file found after waiting"** - The website was slow or + the file didn't arrive in time. Try again. If it keeps happening, check + your internet connection. + +### The program seems stuck + +Downloads can take a few minutes, especially the ZIP files from Dropbox. +The progress bar and status text should keep moving. If nothing changes +for several minutes, close the program and try again. The program +automatically retries failed downloads up to the number you set in Retry +Attempts. + +### A Firefox window appeared and won't go away + +If you unchecked "Auto-close browser after downloads" in the General +settings, Firefox stays open after downloads finish. Either close it +yourself, or re-check that box in Settings so the program closes it +automatically. + +### The promo downloaded but has no tag + +This means either FFmpeg is not installed on your computer, or the Tag +File setting points to a file that doesn't exist. Check the Paths tab in +Settings and make sure FFmpeg is installed (see +[What You Need Before You Start](#what-you-need-before-you-start)). + +### I changed settings but nothing changed + +Make sure you clicked the **Save** button at the bottom of the Settings +window. If you clicked Cancel or closed the window with the X, your +changes were not saved. + +### Where is my settings file? + +All your settings are saved in a file called `download_config.json` that +sits next to the program. You never need to open it yourself, but it is +there if you ever need to back it up or copy it to another computer. + +--- + +If you get stuck and this guide doesn't answer your question, ask whoever +set up the program for you, and show them the Download Log, which will +help them figure out what went wrong. \ No newline at end of file From f4818f0540535ffd2477685786c8aa6657a6ec6a Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Wed, 24 Jun 2026 16:59:53 -0700 Subject: [PATCH 2/9] feat: add Weekend In The Country source, promo downloader, and user-friendly README Major changes: - Add Weekend In The Country FTP downloader with segment/promo renaming - Add Northwest Outdoors promo-only downloader with tag overlay - Refactor NWO download into shared helper for both full and promo downloads - Add WITC FTP credentials and NWO/Whittler URLs to Settings GUI - Remove scheduler, file_watcher, and watchdog dependency (use .bat scripts) - Remove Chrome/Edge support, Firefox-only - Clean up bare except clauses and unused imports - Add _touch_output_dir to surface output folder in Explorer - Add non-technical README with full button/setting/troubleshooting guide - Update tests for new source structure and removed components --- AGENTS.md | 8 +- AudioDownloader.spec | 4 +- browser_manager.py | 29 +- config.py | 42 +-- constants.py | 1 + download_global_features.bat | 6 + download_promos.bat | 6 + download_utils.py | 277 +---------------- file_watcher.py | 58 ---- gui.py | 449 ++++++++++++++------------- main.py | 11 + requirements.txt | 1 - scheduler.py | 135 -------- sources/__init__.py | 9 +- sources/base.py | 6 +- sources/melinda_myers.py | 1 - sources/northwest_outdoors.py | 334 +++++++++++--------- sources/weekend_in_the_country.py | 174 +++++++++++ sources/whittler.py | 7 +- test_downloads.py | 31 +- tests/test_browser_manager.py | 52 +--- tests/test_config_edge_cases.py | 27 +- tests/test_download_utils.py | 192 ++++++++++++ tests/test_integration.py | 47 +-- tests/test_scheduler.py | 240 -------------- tests/test_weekend_in_the_country.py | 224 +++++++++++++ 26 files changed, 1103 insertions(+), 1268 deletions(-) create mode 100644 download_global_features.bat create mode 100644 download_promos.bat delete mode 100644 file_watcher.py delete mode 100644 scheduler.py create mode 100644 sources/weekend_in_the_country.py create mode 100644 tests/test_download_utils.py delete mode 100644 tests/test_scheduler.py create mode 100644 tests/test_weekend_in_the_country.py diff --git a/AGENTS.md b/AGENTS.md index ff5418f..2a5ceaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,9 @@ 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 +python tests/test_weekend_in_the_country.py # WITC rename/promo tests ``` No test framework installed — tests are plain Python scripts run directly. No lint/typecheck configured. @@ -24,11 +24,12 @@ 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 -- **Sources**: `sources/` — 4 downloader implementations using factory via `create_downloader(name, browser_mgr, config)` +- **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 - **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` +- **GUI**: tkinter dark theme in `gui.py` ## Key Directories @@ -65,7 +66,6 @@ GitHub Actions (`.github/workflows/windows_build.yml`): builds Windows exe on pu - **`cow_password`**: Clear Out West password - **`urls`**: Real Dropbox shared links per source (defaults are `YOUR_LINK_HERE` placeholders) - **`output_dir`**: Base output directory (defaults to `downloads/`). All source folders are created under this. -- **`scheduled_downloads`**: Controls automated download timing (enabled, schedule_type, time, days) To update config programmatically, use `ConfigManager`: ```python diff --git a/AudioDownloader.spec b/AudioDownloader.spec index 45b8470..c34e3f4 100644 --- a/AudioDownloader.spec +++ b/AudioDownloader.spec @@ -12,13 +12,13 @@ a = Analysis( 'config', 'browser_manager', 'download_utils', - 'scheduler', 'sources', 'sources.base', 'sources.melinda_myers', 'sources.northwest_outdoors', 'sources.whittler', 'sources.clear_out_west', + 'sources.weekend_in_the_country', 'cryptography', 'OpenSSL', 'h2', @@ -62,7 +62,7 @@ exe = EXE( upx=True, upx_exclude=[], runtime_tmpdir=None, - console=True, + console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, diff --git a/browser_manager.py b/browser_manager.py index 6e0c017..e043789 100644 --- a/browser_manager.py +++ b/browser_manager.py @@ -24,19 +24,14 @@ class BrowserManager: def __init__(self, config_manager): self.config_manager = config_manager self.driver: Optional[webdriver.Firefox] = None - self._initialize_download_directory() + self._get_temp_download_dir() def _get_temp_download_dir(self) -> str: """Get the dedicated download directory for the browser""" download_dir = self.config_manager.get_browser_download_dir() Path(download_dir).mkdir(parents=True, exist_ok=True) return download_dir - - def _initialize_download_directory(self): - """Ensure download directory exists""" - download_dir = self._get_temp_download_dir() - Path(download_dir).mkdir(parents=True, exist_ok=True) - + def _create_browser_options(self) -> Options: """Create and configure browser options""" options = Options() @@ -196,25 +191,5 @@ def wait_for_browser_download_complete(self, timeout: int = 60, poll_interval: f time.sleep(poll_interval) - for f in download_dir.iterdir(): - if f.is_file(): - try: - size = f.stat().st_size - if size > 0 and f.name not in checked_files: - has_excluded_ext = any(f.name.endswith(ext) for ext in EXCLUDED_EXTENSIONS) - has_excluded_prefix = any(f.name.startswith(prefix) for prefix in EXCLUDED_PREFIXES) - has_allowed_ext = any(f.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS) - - if has_excluded_ext or has_excluded_prefix: - logger.debug(f"Ignoring system/temp file: {f.name}") - elif has_allowed_ext: - checked_files.add(f.name) - logger.info(f"Found valid download: {f.name} ({size} bytes)") - return str(f) - else: - logger.debug(f"Ignoring unknown file type: {f.name}") - except OSError: - continue - logger.warning(f"Browser download wait timeout after {timeout}s") return None \ No newline at end of file diff --git a/config.py b/config.py index ac546e4..ace860c 100644 --- a/config.py +++ b/config.py @@ -38,35 +38,21 @@ def get_default_browser_download_dir() -> str: "auto_close_browser": True, "retry_attempts": 2, "cow_password": "", + "witc_ftp_server": "", + "witc_ftp_username": "", + "witc_ftp_password": "", "urls": { "northwest_outdoors": "https://www.dropbox.com/scl/fo/YOUR_LINK_HERE", "whittler": "https://www.dropbox.com/scl/fo/YOUR_LINK_HERE" - }, - "scheduled_downloads": { - "enabled": False, - "schedule_type": "daily", - "time": "06:00", - "days": [], - "download_all": True, - "selected_sources": [] } } -DAY_MAPPING = { - "Monday": "Mon", - "Tuesday": "Tue", - "Wednesday": "Wed", - "Thursday": "Thu", - "Friday": "Fri", - "Saturday": "Sat", - "Sunday": "Sun" -} - DOWNLOAD_SOURCES = { "Melinda Myers": "melinda_myers", "Northwest Outdoors": "northwest_outdoors", "Whittler": "whittler", - "Clear Out West": "clear_out_west" + "Clear Out West": "clear_out_west", + "Weekend In The Country": "weekend_in_the_country" } class ConfigManager: @@ -102,11 +88,8 @@ def load_config() -> Dict[str, Any]: logger.error(f"Could not create config file: {e}") return default_config - def save_config(self, config: Dict[str, Any] = None) -> bool: + def save_config(self) -> bool: """Save configuration to file""" - if config is not None: - self.config = config - try: with open(CONFIG_FILE, 'w') as f: json.dump(self.config, f, indent=2) @@ -170,15 +153,6 @@ def validate_config(self) -> List[str]: if not isinstance(retry_attempts, int) or retry_attempts < 0: errors.append("Retry attempts must be a positive integer") - scheduled = config.get("scheduled_downloads", {}) - if scheduled.get("enabled", False): - time_str = scheduled.get("time", "") - try: - from datetime import datetime - datetime.strptime(time_str, '%H:%M') - except ValueError: - errors.append(f"Invalid time format: {time_str}") - return errors def get(self, key: str, default: Any = None) -> Any: @@ -198,10 +172,6 @@ def update(self, updates: Dict[str, Any]): self.config.update(updates) self.save_config() - def get_scheduled_config(self) -> Dict[str, Any]: - """Get scheduled downloads configuration""" - return self.config.get("scheduled_downloads", {}) - def get_global_features_dir(self) -> str: """Get the Global Features directory under the output dir""" return self._get_subdir("Global Features") diff --git a/constants.py b/constants.py index 14d95c1..f393910 100644 --- a/constants.py +++ b/constants.py @@ -6,4 +6,5 @@ EXCLUDED_EXTENSIONS = {'.part', '.crdownload', '.tmp', '.download', '.xpi', '.so', '.lock'} +# macOS Finder metadata files (._AppleDouble, .DS_Store variants) EXCLUDED_PREFIXES = {'.fea', '.X'} diff --git a/download_global_features.bat b/download_global_features.bat new file mode 100644 index 0000000..ddcc47c --- /dev/null +++ b/download_global_features.bat @@ -0,0 +1,6 @@ +@echo off +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" +AudioDownloader.exe --download-all diff --git a/download_promos.bat b/download_promos.bat new file mode 100644 index 0000000..4a4761c --- /dev/null +++ b/download_promos.bat @@ -0,0 +1,6 @@ +@echo off +REM Schedule: Tuesdays @ 11:00 PM +REM Downloads promos only (Northwest Outdoors promo) + +cd /d "%~dp0" +AudioDownloader.exe --source "Download Promo" diff --git a/download_utils.py b/download_utils.py index 2c3a786..45dd235 100644 --- a/download_utils.py +++ b/download_utils.py @@ -8,11 +8,9 @@ import time import subprocess import logging -import glob import psutil from pathlib import Path -import threading -import re +from typing import Optional logger = logging.getLogger(__name__) @@ -63,135 +61,6 @@ def get_file_handle_count(filepath: str) -> int: logger.debug(f"Error checking file handles for {filepath}: {e}") return 0 - @staticmethod - def wait_for_download_complete(download_dir: str, expected_extensions=None, - timeout: int = 30, check_interval: float = 0.5): - """ - Monitor for new files by checking file handles and locks. - Uses pathlib for cross-platform path handling. - """ - if expected_extensions is None: - expected_extensions = ['.zip', '.mp3'] - - download_path = Path(download_dir).resolve() - logger.info(f"Monitoring for downloads in {download_path} (timeout: {timeout}s)") - - if not download_path.exists(): - download_path.mkdir(parents=True, exist_ok=True) - - initial_files = set() - for file_path in download_path.iterdir(): - if file_path.is_file(): - initial_files.add(file_path) - - logger.info(f"Initial files in directory: {len(initial_files)}") - - start_time = time.time() - discovered_files = {} - - while time.time() - start_time < timeout: - current_files = set() - for file_path in download_path.iterdir(): - if file_path.is_file(): - current_files.add(file_path) - - new_files = current_files - initial_files - - for file_path in new_files: - file_str = str(file_path) - file_name = file_path.name - - if file_str in discovered_files and discovered_files[file_str]['completed']: - continue - - has_valid_extension = any(file_name.endswith(ext) for ext in expected_extensions) - if not has_valid_extension: - is_temp = any(file_name.endswith(ext) for ext in ['.part', '.crdownload', '.tmp', '.download']) - if not is_temp: - continue - - if file_str not in discovered_files: - discovered_files[file_str] = { - 'first_seen': time.time(), - 'last_size': 0, - 'size_changes': 0, - 'completed': False - } - logger.debug(f"Discovered new file: {file_name}") - - try: - current_size = file_path.stat().st_size - except OSError: - continue - - record = discovered_files[file_str] - - if current_size != record['last_size']: - record['last_size'] = current_size - record['size_changes'] += 1 - logger.debug(f"File {file_name} changed size to {current_size}") - - is_locked = DownloadUtilities.is_file_locked(file_str) - handle_count = DownloadUtilities.get_file_handle_count(file_str) - time_since_discovery = time.time() - record['first_seen'] - - if (has_valid_extension and - time_since_discovery > 2 and - current_size > 1024 and - not is_locked and - handle_count == 0): - - time.sleep(0.5) - try: - new_size = file_path.stat().st_size - if new_size == current_size: - logger.info(f"File complete: {file_name} ({current_size} bytes)") - discovered_files[file_str]['completed'] = True - return file_str - except OSError: - continue - - is_temp_file = any(file_name.endswith(ext) for ext in ['.part', '.crdownload', '.tmp', '.download']) - if is_temp_file and time_since_discovery > 10: - base_name = file_path.stem - for ext in ['.part', '.crdownload', '.tmp', '.download']: - if file_name.endswith(ext): - base_name = file_name[:-len(ext)] - break - - for ext in expected_extensions: - possible_file = download_path / (base_name + ext) - try: - if possible_file.exists() and possible_file.stat().st_size > 0: - logger.info(f"Found completed file: {possible_file.name}") - return str(possible_file) - except OSError: - continue - - time.sleep(check_interval) - - logger.warning(f"Download timeout after {timeout} seconds") - - for file_str, record in discovered_files.items(): - if record['completed']: - return file_str - - if discovered_files: - valid_files = [] - for file_str in discovered_files.keys(): - try: - if Path(file_str).exists() and Path(file_str).stat().st_size > 0: - valid_files.append(file_str) - except OSError: - continue - - if valid_files: - newest_file = max(valid_files, key=os.path.getmtime) - logger.info(f"Returning newest file: {Path(newest_file).name}") - return newest_file - - return None - @staticmethod def find_latest_file(download_dir: str, extension: str = None, wait_time: int = 2): """Find the most recently downloaded file after waiting""" @@ -234,147 +103,7 @@ def find_latest_file(download_dir: str, extension: str = None, wait_time: int = return None @staticmethod - def monitor_for_download(download_dir, expected_extensions=None, timeout=30): - """Simple monitor that watches for new files and waits for them to stabilize""" - if expected_extensions is None: - expected_extensions = ['.zip', '.mp3'] - - logger.info(f"Simple monitor for downloads in {download_dir}") - - download_path = Path(download_dir) - if not download_path.exists(): - os.makedirs(download_dir, exist_ok=True) - - initial_files = {} - for file_path in download_path.iterdir(): - if file_path.is_file(): - try: - initial_files[file_path.name] = { - 'path': str(file_path), - 'size': os.path.getsize(file_path), - 'mtime': os.path.getmtime(file_path) - } - except OSError: - continue - - start_time = time.time() - - while time.time() - start_time < timeout: - current_files = {} - for file_path in download_path.iterdir(): - if file_path.is_file(): - try: - current_files[file_path.name] = { - 'path': str(file_path), - 'size': os.path.getsize(file_path), - 'mtime': os.path.getmtime(file_path) - } - except OSError: - continue - - for filename, fileinfo in current_files.items(): - is_new = filename not in initial_files - is_modified = (filename in initial_files and - fileinfo['size'] != initial_files[filename]['size']) - - if is_new or is_modified: - has_valid_ext = any(filename.endswith(ext) for ext in expected_extensions) - is_temp = any(filename.endswith(ext) for ext in ['.part', '.crdownload', '.tmp', '.download']) - - if has_valid_ext or is_temp: - logger.debug(f"Tracking file: {filename} (size: {fileinfo['size']})") - - if has_valid_ext and not is_temp and fileinfo['size'] > 0: - time.sleep(1) - try: - new_size = os.path.getsize(fileinfo['path']) - if new_size == fileinfo['size']: - logger.info(f"Download complete: {filename}") - return fileinfo['path'] - except OSError: - continue - - time.sleep(0.5) - - return None - - @staticmethod - def simple_wait_for_download(download_dir, expected_extensions=None, timeout=30): - """Simple, reliable method to wait for downloads""" - if expected_extensions is None: - expected_extensions = ['.zip', '.mp3'] - - download_path = Path(download_dir).resolve() - logger.info(f"Simple wait for download in {download_path}") - - if not download_path.exists(): - download_path.mkdir(parents=True, exist_ok=True) - - start_time = time.time() - - initial_files = {} - for file_path in download_path.iterdir(): - if file_path.is_file(): - try: - initial_files[file_path.name] = { - 'path': str(file_path), - 'size': file_path.stat().st_size, - 'mtime': file_path.stat().st_mtime - } - except Exception: - continue - - logger.info(f"Found {len(initial_files)} initial files") - - while time.time() - start_time < timeout: - current_files = {} - for file_path in download_path.iterdir(): - if file_path.is_file(): - try: - current_files[file_path.name] = { - 'path': str(file_path), - 'size': file_path.stat().st_size, - 'mtime': file_path.stat().st_mtime - } - except Exception: - continue - - for filename, info in current_files.items(): - if filename not in initial_files: - has_valid_ext = any(filename.endswith(ext) for ext in expected_extensions) - is_temp = any(filename.endswith(ext) for ext in ['.part', '.crdownload', '.tmp', '.download']) - - if has_valid_ext and not is_temp: - time.sleep(1) - try: - new_size = Path(info['path']).stat().st_size - if new_size == info['size']: - logger.info(f"Found stable new file: {filename} ({info['size']} bytes)") - return info['path'] - except Exception: - continue - elif is_temp: - logger.debug(f"Found temp file: {filename}") - elif info['size'] > initial_files[filename]['size']: - logger.debug(f"File is growing: {filename} {initial_files[filename]['size']} -> {info['size']}") - - time.sleep(1) - - logger.warning(f"Timeout after {timeout} seconds") - - for filename, info in current_files.items(): - if filename not in initial_files: - has_valid_ext = any(filename.endswith(ext) for ext in expected_extensions) - is_temp = any(filename.endswith(ext) for ext in ['.part', '.crdownload', '.tmp', '.download']) - - if has_valid_ext and not is_temp: - logger.info(f"Returning new file despite timeout: {filename}") - return info['path'] - - return None - - @staticmethod - def _get_audio_duration(file_path: str) -> float | None: + def _get_audio_duration(file_path: str) -> Optional[float]: """Get audio duration in seconds using ffprobe.""" try: result = subprocess.run( @@ -477,4 +206,4 @@ def overlay_promo_with_tag(promo_file: str, tag_file: str, output_file: str, return False except Exception as e: logger.error(f"Error creating promo with tag: {e}") - return False \ No newline at end of file + return False diff --git a/file_watcher.py b/file_watcher.py deleted file mode 100644 index ce6ea0f..0000000 --- a/file_watcher.py +++ /dev/null @@ -1,58 +0,0 @@ -# [file name]: file_watcher.py (new file) -""" -Simple file system watcher for download detection -""" - -import os -import time -import logging -from pathlib import Path -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler - -logger = logging.getLogger(__name__) - -class DownloadHandler(FileSystemEventHandler): - """Handle file system events for downloads""" - - def __init__(self, download_dir, callback): - self.download_dir = download_dir - self.callback = callback - self.files_created = set() - - def on_created(self, event): - if not event.is_directory: - file_path = event.src_path - logger.debug(f"File created: {os.path.basename(file_path)}") - self.files_created.add(file_path) - - # Check if this is a completed file (not temporary) - if not any(file_path.endswith(ext) for ext in ['.part', '.crdownload', '.tmp']): - # Call the callback with the file path - self.callback(file_path) - -def watch_for_download(download_dir, timeout=30): - """Watch for file creation events and return the downloaded file""" - import threading - from queue import Queue - - result_queue = Queue() - - def download_callback(file_path): - result_queue.put(file_path) - - event_handler = DownloadHandler(download_dir, download_callback) - observer = Observer() - observer.schedule(event_handler, download_dir, recursive=False) - observer.start() - - try: - # Wait for the result - result = result_queue.get(timeout=timeout) - observer.stop() - return result - except: - observer.stop() - return None - finally: - observer.join() \ No newline at end of file diff --git a/gui.py b/gui.py index 0c030d8..b24fa36 100644 --- a/gui.py +++ b/gui.py @@ -6,14 +6,12 @@ from tkinter import ttk, messagebox, scrolledtext, filedialog import threading import time -import sys import logging from datetime import datetime try: from config import ConfigManager, DOWNLOAD_SOURCES from browser_manager import BrowserManager - from scheduler import DownloadScheduler from sources import create_downloader except ImportError as e: print(f"Import error in gui.py: {e}") @@ -22,23 +20,23 @@ logger = logging.getLogger(__name__) COLORS = { - 'dark_bg': '#1e1e1e', - 'dark_frame': '#2d2d2d', - 'dark_button': '#3c3c3c', - 'dark_button_hover': '#4a4a4a', - 'light_text': '#e0e0e0', + 'bg': '#181818', + 'surface': '#242424', + 'button': '#2e2e2e', + 'button_hover': '#3c3c3c', + 'button_active': '#4a4a4a', + 'light_text': '#f0f0f0', 'dim_text': '#a0a0a0', - 'accent': '#0078d4', - 'success': '#4caf50', - 'error': '#f44336', - 'warning': '#ff9800', -} - -LIGHT_COLORS = { - 'bg': '#f0f0f0', - 'frame': '#ffffff', - 'text': '#000000', - 'button': '#e0e0e0', + 'accent': '#0ea5e9', + 'success': '#22c55e', + 'error': '#ef4444', + 'warning': '#f59e0b', + 'tab_bg': '#242424', + 'tab_selected': '#181818', + 'tab_active': '#2e2e2e', + 'border': '#3a3a3a', + 'trough': '#181818', + 'indicator': '#0ea5e9', } class AudioDownloaderGUI: @@ -52,19 +50,14 @@ def __init__(self): self.config_manager = ConfigManager() self.browser_manager = BrowserManager(self.config_manager) - self.scheduler = DownloadScheduler( - self.config_manager, - self.run_all_downloads_silent - ) + self._download_lock = threading.Lock() self.status_var = tk.StringVar(value="Ready to download") self.progress_bar = None self.log_text = None - self.dark_mode = tk.BooleanVar(value=self.config_manager.get("dark_mode", True)) self.setup_gui() - self.setup_scheduler() - self.apply_theme() + self.apply_dark_theme() self.root.protocol("WM_DELETE_WINDOW", self.on_closing) @@ -73,21 +66,42 @@ def setup_gui(self): main_frame = ttk.Frame(self.root, padding="15") main_frame.pack(fill=tk.BOTH, expand=True) + header_frame = ttk.Frame(main_frame) + header_frame.pack(fill=tk.X, pady=(0, 15)) + title_label = ttk.Label( - main_frame, - text="📥 Audio Download Manager", - font=("Arial", 16, "bold") + header_frame, + text="Audio Download Manager", + font=("Arial", 16, "bold"), + anchor='center' ) - title_label.pack(pady=(0, 15)) + title_label.pack(fill=tk.X) + + settings_btn = ttk.Button( + header_frame, + text="\u2699", + command=self.show_settings, + width=3, + style='Toolbutton' + ) + settings_btn.place(relx=1.0, rely=0.5, x=-5, anchor='e') all_btn = ttk.Button( main_frame, - text="⬇ Download All", + text="Download Global Features", command=self.run_all_downloads, width=30 ) - all_btn.pack(pady=(0, 20)) - + all_btn.pack(pady=(0, 5)) + + promo_btn = ttk.Button( + main_frame, + text="Download Promo", + command=self.create_download_handler("Download Promo"), + width=30 + ) + promo_btn.pack(pady=(0, 20)) + sources_label = ttk.Label( main_frame, text="Downloads:", @@ -102,40 +116,12 @@ def setup_gui(self): for i, source_name in enumerate(sources): btn = ttk.Button( sources_frame, - text=f"📁 {source_name}", + text=f"{source_name}", command=self.create_download_handler(source_name), width=35 ) btn.pack(pady=3) - buttons_frame = ttk.Frame(main_frame) - buttons_frame.pack(pady=(0, 15)) - - settings_btn = ttk.Button( - buttons_frame, - text="⚙ Settings", - command=self.show_settings, - width=15 - ) - settings_btn.pack(side=tk.LEFT, padx=5) - - scheduler_btn = ttk.Button( - buttons_frame, - text="🕐 Scheduler", - command=self.show_scheduler_window, - width=15 - ) - scheduler_btn.pack(side=tk.LEFT, padx=5) - - dark_toggle = ttk.Checkbutton( - buttons_frame, - text="🌙 Dark", - variable=self.dark_mode, - command=self.toggle_dark_mode, - style='Switch.TCheckbutton' - ) - dark_toggle.pack(side=tk.LEFT, padx=15) - progress_frame = ttk.Frame(main_frame) progress_frame.pack(fill=tk.X, pady=(0, 5)) @@ -162,52 +148,116 @@ def setup_gui(self): log_frame, height=10, width=70, - wrap=tk.WORD + wrap=tk.WORD, + bg=COLORS['surface'], + fg=COLORS['light_text'], + insertbackground=COLORS['light_text'], + relief='flat' ) self.log_text.pack(fill=tk.BOTH, expand=True) self.log_text.config(state=tk.DISABLED) - def toggle_dark_mode(self): - """Toggle dark mode""" - self.config_manager.set("dark_mode", self.dark_mode.get()) - self.apply_theme() - - def apply_theme(self): - """Apply dark or light theme using ttk.Style""" + def apply_dark_theme(self): + """Apply dark theme using ttk.Style""" style = ttk.Style() - - if self.dark_mode.get(): - style.theme_use('clam') - style.configure('.', background=COLORS['dark_bg']) - style.configure('.', foreground=COLORS['light_text']) - style.configure('TFrame', background=COLORS['dark_bg']) - style.configure('TLabelframe', background=COLORS['dark_bg']) - style.configure('TLabelframe.Label', background=COLORS['dark_bg'], foreground=COLORS['light_text']) - style.configure('TLabel', background=COLORS['dark_bg'], foreground=COLORS['light_text']) - style.configure('TButton', background=COLORS['dark_button'], foreground=COLORS['light_text']) - style.map('TButton', background=[('active', COLORS['dark_button_hover'])]) - style.configure('TCheckbutton', background=COLORS['dark_bg'], foreground=COLORS['light_text']) - style.configure('TRadiobutton', background=COLORS['dark_bg'], foreground=COLORS['light_text']) - style.configure('TEntry', fieldbackground=COLORS['dark_frame'], foreground=COLORS['light_text']) - self.root.configure(bg=COLORS['dark_bg']) - else: - style.theme_use('default') - style.configure('.', background='SystemButtonFace') - style.configure('TFrame', background='SystemButtonFace') - style.configure('TLabelframe', background='SystemButtonFace') - style.configure('TLabelframe.Label', background='SystemButtonFace') - style.configure('TLabel', background='SystemButtonFace') - style.configure('TButton', background='SystemButtonFace') - style.configure('TCheckbutton', background='SystemButtonFace') - style.configure('TRadiobutton', background='SystemButtonFace') - style.configure('TEntry', fieldbackground='white') - self.root.configure(bg='SystemButtonFace') - - def setup_scheduler(self): - """Setup and start the scheduler""" - self.scheduler.update_from_config() - self.scheduler.start() - self.log_message("Scheduler started") + style.theme_use('clam') + + style.configure('.', background=COLORS['bg'], foreground=COLORS['light_text']) + + style.configure('TFrame', background=COLORS['bg']) + style.configure('TLabelframe', background=COLORS['bg']) + style.configure('TLabelframe.Label', background=COLORS['bg'], foreground=COLORS['light_text']) + style.configure('TLabel', background=COLORS['bg'], foreground=COLORS['light_text']) + + style.configure( + 'TButton', + background=COLORS['button'], + foreground=COLORS['light_text'], + bordercolor=COLORS['border'], + lightcolor=COLORS['button'], + darkcolor=COLORS['button'], + padding=6 + ) + style.map( + 'TButton', + background=[('active', COLORS['button_hover']), ('pressed', COLORS['button_active'])], + foreground=[('active', COLORS['light_text']), ('pressed', COLORS['light_text'])], + lightcolor=[('active', COLORS['button_hover']), ('pressed', COLORS['button_active'])], + darkcolor=[('active', COLORS['button_hover']), ('pressed', COLORS['button_active'])] + ) + + style.configure( + 'TNotebook', + background=COLORS['bg'], + bordercolor=COLORS['border'], + tabmargins=[2, 5, 2, 0] + ) + style.configure( + 'TNotebook.Tab', + background=COLORS['tab_bg'], + foreground=COLORS['light_text'], + padding=[10, 4], + bordercolor=COLORS['border'] + ) + style.map( + 'TNotebook.Tab', + background=[('selected', COLORS['tab_selected']), ('active', COLORS['tab_active'])], + foreground=[('selected', COLORS['light_text']), ('active', COLORS['light_text'])] + ) + + style.configure('TCheckbutton', background=COLORS['bg'], foreground=COLORS['light_text'], indicatorcolor=COLORS['surface']) + style.map('TCheckbutton', indicatorcolor=[('selected', COLORS['accent']), ('active', COLORS['button_hover'])]) + style.configure('TRadiobutton', background=COLORS['bg'], foreground=COLORS['light_text'], indicatorcolor=COLORS['surface']) + style.map('TRadiobutton', indicatorcolor=[('selected', COLORS['accent']), ('active', COLORS['button_hover'])]) + + style.configure( + 'TEntry', + fieldbackground=COLORS['surface'], + foreground=COLORS['light_text'], + insertcolor=COLORS['light_text'], + bordercolor=COLORS['border'], + lightcolor=COLORS['border'], + darkcolor=COLORS['border'] + ) + + style.configure( + 'TSpinbox', + fieldbackground=COLORS['surface'], + foreground=COLORS['light_text'], + insertcolor=COLORS['light_text'], + bordercolor=COLORS['border'], + lightcolor=COLORS['border'], + darkcolor=COLORS['border'], + arrowcolor=COLORS['light_text'] + ) + + style.configure( + 'TProgressbar', + background=COLORS['accent'], + troughcolor=COLORS['trough'], + bordercolor=COLORS['bg'], + lightcolor=COLORS['accent'], + darkcolor=COLORS['accent'] + ) + + style.configure( + 'TCombobox', + fieldbackground=COLORS['surface'], + foreground=COLORS['light_text'], + background=COLORS['button'], + arrowcolor=COLORS['light_text'], + bordercolor=COLORS['border'], + lightcolor=COLORS['border'], + darkcolor=COLORS['border'] + ) + style.map( + 'TCombobox', + fieldbackground=[('readonly', COLORS['surface'])], + selectbackground=[('readonly', COLORS['accent'])], + selectforeground=[('readonly', COLORS['light_text'])] + ) + + self.root.configure(bg=COLORS['bg']) def log_message(self, message: str): """Add message to log viewer""" @@ -244,13 +294,13 @@ def download_thread(): try: success = self.download_with_retry(source_name) if success: - self.status_var.set(f"✓ {source_name} completed!") - self.log_message(f"✓ {source_name} completed successfully") + self.status_var.set(f"Done - {source_name} completed!") + self.log_message(f"Done - {source_name} completed successfully") else: - self.status_var.set(f"✗ {source_name} failed") - self.log_message(f"✗ {source_name} download failed") + self.status_var.set(f"FAIL - {source_name} failed") + self.log_message(f"FAIL - {source_name} download failed") except Exception as e: - self.status_var.set(f"✗ {source_name} error") + self.status_var.set(f"FAIL - {source_name} error") self.log_message(f"Error in {source_name}: {str(e)}") finally: self.root.after(2000, lambda: self.progress_bar.configure(value=0)) @@ -263,23 +313,30 @@ def download_with_retry(self, source_name: str) -> bool: """Wrapper function to automatically retry failed downloads""" max_retries = self.config_manager.get("retry_attempts", 2) - downloader = create_downloader(source_name, self.browser_manager, self.config_manager) + if not self._download_lock.acquire(blocking=False): + self.log_message(f"Another download in progress, skipping {source_name}") + return False - for attempt in range(max_retries + 1): - try: - success = downloader.download(self.update_progress) - if success: - return True - elif attempt < max_retries: - self.log_message(f"↻ Retrying {source_name} (attempt {attempt + 1})...") - time.sleep(3) - except Exception as e: - if attempt < max_retries: - self.log_message(f"↻ Error in {source_name}, retrying... ({str(e)})") - time.sleep(3) - - self.log_message(f"✗ {source_name} failed after {max_retries + 1} attempts") - return False + try: + downloader = create_downloader(source_name, self.browser_manager, self.config_manager) + + for attempt in range(max_retries + 1): + try: + success = downloader.download(self.update_progress) + if success: + return True + elif attempt < max_retries: + self.log_message(f"Retrying {source_name} (attempt {attempt + 1})...") + time.sleep(3) + except Exception as e: + if attempt < max_retries: + self.log_message(f"Error in {source_name}, retrying... ({str(e)})") + time.sleep(3) + + self.log_message(f"{source_name} failed after {max_retries + 1} attempts") + return False + finally: + self._download_lock.release() def run_all_downloads(self): """Run all downloads with progress""" @@ -306,29 +363,6 @@ def download_all_thread(): threading.Thread(target=download_all_thread, daemon=True).start() - def run_all_downloads_silent(self): - """Run all downloads without showing progress dialogs (for scheduler)""" - def download_all_thread(): - sources = list(DOWNLOAD_SOURCES.keys()) - success_count = 0 - total_count = len(sources) - - for source_name in sources: - try: - downloader = create_downloader(source_name, self.browser_manager, self.config_manager) - success = downloader.download() - if success: - success_count += 1 - self.log_message(f"✓ {source_name} completed") - else: - self.log_message(f"✗ {source_name} failed") - except Exception as e: - self.log_message(f"✗ Error in {source_name}: {e}") - - self.log_message(f"Scheduled downloads: {success_count}/{total_count} successful") - - threading.Thread(target=download_all_thread, daemon=True).start() - def show_summary_popup(self, success_count: int, total_count: int, failed_list: list): """Show a summary when downloads complete""" summary_window = tk.Toplevel(self.root) @@ -336,14 +370,15 @@ def show_summary_popup(self, success_count: int, total_count: int, failed_list: summary_window.geometry("400x250") summary_window.transient(self.root) summary_window.grab_set() + summary_window.configure(bg=COLORS['bg']) if failed_list: message = f"Completed: {success_count}/{total_count} downloads\n\nFailed:\n" + "\n".join(f"• {item}" for item in failed_list) - icon = "⚠️" + icon = "Warning:" title = "Downloads Partially Completed" else: message = f"All {total_count} downloads completed successfully!" - icon = "✅" + icon = "Success" title = "Downloads Completed" ttk.Label(summary_window, text=icon, font=("Arial", 24)).pack(pady=10) @@ -351,76 +386,14 @@ def show_summary_popup(self, success_count: int, total_count: int, failed_list: ttk.Label(summary_window, text=message, wraplength=350).pack(pady=10, padx=20) ttk.Button(summary_window, text="OK", command=summary_window.destroy).pack(pady=10) - def show_scheduler_window(self): - """Show scheduler configuration window""" - scheduler_window = tk.Toplevel(self.root) - scheduler_window.title("Scheduler Configuration") - scheduler_window.geometry("450x400") - scheduler_window.transient(self.root) - scheduler_window.grab_set() - - main_frame = ttk.Frame(scheduler_window, padding="20") - main_frame.pack(fill=tk.BOTH, expand=True) - - ttk.Label(main_frame, text="🕐 Download Scheduler", font=("Arial", 14, "bold")).pack(pady=(0, 20)) - - sched_enabled = tk.BooleanVar(value=self.config_manager.get("scheduled_downloads", {}).get("enabled", False)) - ttk.Checkbutton( - main_frame, - text="Enable scheduled downloads", - variable=sched_enabled - ).pack(anchor=tk.W, pady=5) - - sched_type = tk.StringVar(value=self.config_manager.get("scheduled_downloads", {}).get("schedule_type", "daily")) - type_frame = ttk.Frame(main_frame) - type_frame.pack(fill=tk.X, pady=10) - ttk.Label(type_frame, text="Schedule:").pack(side=tk.LEFT) - ttk.Radiobutton(type_frame, text="Daily", variable=sched_type, value="daily").pack(side=tk.LEFT, padx=10) - ttk.Radiobutton(type_frame, text="Weekly", variable=sched_type, value="weekly").pack(side=tk.LEFT) - - time_frame = ttk.Frame(main_frame) - time_frame.pack(fill=tk.X, pady=10) - ttk.Label(time_frame, text="Time (24h):").pack(side=tk.LEFT) - time_var = tk.StringVar(value=self.config_manager.get("scheduled_downloads", {}).get("time", "06:00")) - ttk.Entry(time_frame, textvariable=time_var, width=8).pack(side=tk.LEFT, padx=10) - - days_frame = ttk.LabelFrame(main_frame, text="Days (for weekly)", padding="10") - days_frame.pack(fill=tk.X, pady=10) - - days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] - saved_days = self.config_manager.get("scheduled_downloads", {}).get("days", []) - day_vars = {} - for i, day in enumerate(days): - day_vars[day] = tk.BooleanVar(value=day in saved_days) - ttk.Checkbutton(days_frame, text=day[:3], variable=day_vars[day]).pack(side=tk.LEFT, padx=5) - - def save_scheduler(): - selected_days = [day for day, var in day_vars.items() if var.get()] - sched_config = { - "enabled": sched_enabled.get(), - "schedule_type": sched_type.get(), - "time": time_var.get(), - "days": selected_days, - "download_all": True, - "selected_sources": [] - } - self.config_manager.set("scheduled_downloads", sched_config) - self.scheduler.update_from_config() - messagebox.showinfo("Scheduler", "Schedule saved successfully!") - scheduler_window.destroy() - - btn_frame = ttk.Frame(main_frame) - btn_frame.pack(pady=20) - ttk.Button(btn_frame, text="Save", command=save_scheduler).pack(side=tk.LEFT, padx=10) - ttk.Button(btn_frame, text="Cancel", command=scheduler_window.destroy).pack(side=tk.LEFT) - def show_settings(self): """Show settings window""" settings_window = tk.Toplevel(self.root) settings_window.title("Settings") - settings_window.geometry("500x450") + settings_window.geometry("550x550") settings_window.transient(self.root) settings_window.grab_set() + settings_window.configure(bg=COLORS['bg']) notebook = ttk.Notebook(settings_window) notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) @@ -434,7 +407,10 @@ def show_settings(self): auth_frame = ttk.Frame(notebook, padding="15") notebook.add(auth_frame, text="Auth") - ttk.Label(general_frame, text="⚙ General Settings", font=("Arial", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 15)) + urls_frame = ttk.Frame(notebook, padding="15") + notebook.add(urls_frame, text="URLs") + + ttk.Label(general_frame, text="General Settings", font=("Arial", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 15)) ttk.Label(general_frame, text="Output Directory:").grid(row=1, column=0, sticky=tk.W, pady=10) output_dir_var = tk.StringVar(value=self.config_manager.get("output_dir", "downloads")) @@ -452,17 +428,45 @@ def show_settings(self): retry_var = tk.StringVar(value=str(self.config_manager.get("retry_attempts", 2))) ttk.Spinbox(general_frame, from_=0, to=5, textvariable=retry_var, width=5).grid(row=3, column=1, sticky=tk.W, pady=10) - ttk.Label(paths_frame, text="📁 Paths", font=("Arial", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 15)) + ttk.Label(paths_frame, text="Paths", font=("Arial", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 15)) ttk.Label(paths_frame, text="Tag File:").grid(row=1, column=0, sticky=tk.W, pady=8) tag_var = tk.StringVar(value=self.config_manager.get("tag_file", "")) ttk.Entry(paths_frame, textvariable=tag_var, width=35).grid(row=1, column=1, sticky=tk.W, pady=8) ttk.Button(paths_frame, text="...", width=3, command=lambda: tag_var.set(filedialog.askopenfilename(filetypes=[("Audio Files", "*.wav *.mp3")]) or tag_var.get())).grid(row=1, column=2, padx=5) + + ttk.Label(paths_frame, text="Browser Download Dir:").grid(row=2, column=0, sticky=tk.W, pady=8) + browser_download_dir_var = tk.StringVar(value=self.config_manager.get("browser_download_dir", "")) + ttk.Entry(paths_frame, textvariable=browser_download_dir_var, width=35).grid(row=2, column=1, sticky=tk.W, pady=8) + ttk.Button(paths_frame, text="Browse", command=lambda: browser_download_dir_var.set(filedialog.askdirectory() or browser_download_dir_var.get())).grid(row=2, column=2, padx=5) - ttk.Label(auth_frame, text="🔐 Authentication", font=("Arial", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 15)) + ttk.Label(auth_frame, text="Authentication", font=("Arial", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 15)) ttk.Label(auth_frame, text="Clear Out West Password:").grid(row=1, column=0, sticky=tk.W, pady=8) cow_password_var = tk.StringVar(value=self.config_manager.get("cow_password", "")) ttk.Entry(auth_frame, textvariable=cow_password_var, width=35, show="*").grid(row=1, column=1, sticky=tk.W, pady=8) + + ttk.Label(auth_frame, text="WITC FTP Server:").grid(row=2, column=0, sticky=tk.W, pady=8) + witc_ftp_server_var = tk.StringVar(value=self.config_manager.get("witc_ftp_server", "")) + ttk.Entry(auth_frame, textvariable=witc_ftp_server_var, width=35).grid(row=2, column=1, sticky=tk.W, pady=8) + + ttk.Label(auth_frame, text="WITC FTP Username:").grid(row=3, column=0, sticky=tk.W, pady=8) + witc_ftp_username_var = tk.StringVar(value=self.config_manager.get("witc_ftp_username", "")) + ttk.Entry(auth_frame, textvariable=witc_ftp_username_var, width=35).grid(row=3, column=1, sticky=tk.W, pady=8) + + ttk.Label(auth_frame, text="WITC FTP Password:").grid(row=4, column=0, sticky=tk.W, pady=8) + witc_ftp_password_var = tk.StringVar(value=self.config_manager.get("witc_ftp_password", "")) + ttk.Entry(auth_frame, textvariable=witc_ftp_password_var, width=35, show="*").grid(row=4, column=1, sticky=tk.W, pady=8) + + ttk.Label(urls_frame, text="Source URLs", font=("Arial", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 15)) + + urls = self.config_manager.get("urls", {}) + ttk.Label(urls_frame, text="Northwest Outdoors:").grid(row=1, column=0, sticky=tk.W, pady=8) + northwest_outdoors_url_var = tk.StringVar(value=urls.get("northwest_outdoors", "")) + ttk.Entry(urls_frame, textvariable=northwest_outdoors_url_var, width=45).grid(row=1, column=1, sticky=tk.W, pady=8) + + ttk.Label(urls_frame, text="Whittler:").grid(row=2, column=0, sticky=tk.W, pady=8) + whittler_url_var = tk.StringVar(value=urls.get("whittler", "")) + ttk.Entry(urls_frame, textvariable=whittler_url_var, width=45).grid(row=2, column=1, sticky=tk.W, pady=8) def save_settings(): self.config_manager.set("output_dir", output_dir_var.get()) @@ -470,6 +474,14 @@ def save_settings(): self.config_manager.set("retry_attempts", int(retry_var.get())) self.config_manager.set("tag_file", tag_var.get()) self.config_manager.set("cow_password", cow_password_var.get()) + self.config_manager.set("browser_download_dir", browser_download_dir_var.get()) + self.config_manager.set("witc_ftp_server", witc_ftp_server_var.get()) + self.config_manager.set("witc_ftp_username", witc_ftp_username_var.get()) + self.config_manager.set("witc_ftp_password", witc_ftp_password_var.get()) + self.config_manager.set("urls", { + "northwest_outdoors": northwest_outdoors_url_var.get(), + "whittler": whittler_url_var.get(), + }) self.config_manager.save() messagebox.showinfo("Settings", "Settings saved successfully!") settings_window.destroy() @@ -481,11 +493,8 @@ def save_settings(): def on_closing(self): """Handle application closing""" - if messagebox.askokcancel("Quit", "Do you want to quit? This will close the browser if it's open."): - self.scheduler.stop() - self.browser_manager.close_browser() - self.root.destroy() - sys.exit() + self.browser_manager.close_browser() + self.root.destroy() def run(self): """Start the GUI application""" diff --git a/main.py b/main.py index 032459b..5c8c536 100644 --- a/main.py +++ b/main.py @@ -42,6 +42,15 @@ def setup_logging(log_to_file=True): return logger +def _touch_output_dir(config): + """Update the output directory's mtime so it appears at the top in Explorer""" + output_dir = config.get_output_base_dir() + try: + os.utime(output_dir) + except Exception: + pass + + def run_cli_downloads(): """Run downloads in CLI mode without GUI""" from config import ConfigManager, DOWNLOAD_SOURCES @@ -105,6 +114,7 @@ def run_cli_downloads(): logger.info("") logger.info(f"Total: {success_count}/{total_count} successful") + _touch_output_dir(config) return all(results.values()) def run_single_source(source_name): @@ -140,6 +150,7 @@ def run_single_source(source_name): return False finally: browser_manager.close_browser() + _touch_output_dir(config) def main(): """Main entry point for the application""" diff --git a/requirements.txt b/requirements.txt index 3707c09..b43cada 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ selenium>=4.0.0 webdriver-manager>=4.0.0 psutil>=5.9.0 -watchdog>=3.0.0 pyinstaller>=6.0.0 # Note: FFmpeg must be installed separately for audio tag overlay feature diff --git a/scheduler.py b/scheduler.py deleted file mode 100644 index b18cd49..0000000 --- a/scheduler.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Scheduling functionality for automated downloads -""" - -import threading -import time -import logging -from datetime import datetime -from typing import Dict, Any, List, Optional, Callable - -logger = logging.getLogger(__name__) - -class DownloadScheduler: - """Manages scheduled downloads""" - - def __init__(self, config_manager, download_callback: Callable): - self.config_manager = config_manager - self.download_callback = download_callback - self.jobs: List[Dict[str, Any]] = [] - self.running = False - self.thread: Optional[threading.Thread] = None - - def start(self): - """Start the scheduler in a background thread""" - if self.running: - logger.warning("Scheduler already running") - return - - self.running = True - - def scheduler_loop(): - while self.running: - self.run_pending() - time.sleep(60) # Check every minute - - self.thread = threading.Thread(target=scheduler_loop, daemon=True) - self.thread.start() - logger.info("Scheduler started") - - def stop(self): - """Stop the scheduler""" - self.running = False - if self.thread: - self.thread.join(timeout=5) - logger.info("Scheduler stopped") - - def run_pending(self): - """Run all jobs that are due""" - scheduled_config = self.config_manager.get_scheduled_config() - - if not scheduled_config.get("enabled", False): - return - - now = datetime.now() - current_time = now.strftime('%H:%M') - current_weekday = now.strftime('%a') - - for job in self.jobs: - # Check if time matches - if job['time'] != current_time: - continue - - # Check if we already ran this job at this time - if job['last_run'] and job['last_run'].strftime('%Y-%m-%d %H:%M') == now.strftime('%Y-%m-%d %H:%M'): - continue - - # Check schedule type - if job['type'] == 'daily': - should_run = True - elif job['type'] == 'weekly': - should_run = current_weekday in job['days'] - else: - should_run = False - - if should_run: - try: - job['func']() - job['last_run'] = now - logger.info(f"Ran scheduled job at {current_time}") - except Exception as e: - logger.error(f"Error in scheduled job: {e}") - - def update_from_config(self): - """Update scheduler based on current configuration""" - self.jobs.clear() - - scheduled_config = self.config_manager.get_scheduled_config() - - if not scheduled_config.get("enabled", False): - logger.info("Scheduler disabled") - return - - schedule_type = scheduled_config.get("schedule_type", "daily") - time_str = scheduled_config.get("time", "06:00") - - # Validate time format - try: - datetime.strptime(time_str, '%H:%M') - except ValueError: - logger.error(f"Invalid time format: {time_str} - using 06:00") - time_str = "06:00" - - # Map full day names to abbreviated - from config import DAY_MAPPING - days = scheduled_config.get("days", []) - abbreviated_days = [DAY_MAPPING.get(day, day[:3]) for day in days] - - # Create job function based on configuration - def create_job_function(): - if scheduled_config.get("download_all", True): - # Run all downloads - return self.download_callback - else: - # Run only selected sources - selected_sources = scheduled_config.get("selected_sources", []) - def selected_downloads(): - logger.info(f"Running scheduled downloads for: {selected_sources}") - # This would need to be implemented based on your source structure - self.download_callback() - return selected_downloads - - job_func = create_job_function() - - self.jobs.append({ - 'func': job_func, - 'type': schedule_type, - 'time': time_str, - 'days': abbreviated_days, - 'last_run': None - }) - - if schedule_type == "daily": - logger.info(f"Scheduled daily downloads at {time_str}") - elif schedule_type == "weekly": - logger.info(f"Scheduled weekly downloads on {', '.join(days)} at {time_str}") \ No newline at end of file diff --git a/sources/__init__.py b/sources/__init__.py index 515c3a5..3ea1317 100644 --- a/sources/__init__.py +++ b/sources/__init__.py @@ -3,17 +3,20 @@ """ from .melinda_myers import MelindaMyersDownloader -from .northwest_outdoors import NorthwestOutdoorsDownloader +from .northwest_outdoors import NorthwestOutdoorsDownloader, NorthwestOutdoorsPromoDownloader from .whittler import WhittlerDownloader from .clear_out_west import ClearOutWestDownloader +from .weekend_in_the_country import WeekendInTheCountryDownloader def create_downloader(source_name: str, browser_manager, config_manager): """Factory function to create downloader instances""" downloaders = { "Melinda Myers": MelindaMyersDownloader, "Northwest Outdoors": NorthwestOutdoorsDownloader, + "Download Promo": NorthwestOutdoorsPromoDownloader, "Whittler": WhittlerDownloader, - "Clear Out West": ClearOutWestDownloader + "Clear Out West": ClearOutWestDownloader, + "Weekend In The Country": WeekendInTheCountryDownloader } downloader_class = downloaders.get(source_name) @@ -25,7 +28,9 @@ def create_downloader(source_name: str, browser_manager, config_manager): __all__ = [ 'MelindaMyersDownloader', 'NorthwestOutdoorsDownloader', + 'NorthwestOutdoorsPromoDownloader', 'WhittlerDownloader', 'ClearOutWestDownloader', + 'WeekendInTheCountryDownloader', 'create_downloader' ] diff --git a/sources/base.py b/sources/base.py index 0c7fd4d..e2bec05 100644 --- a/sources/base.py +++ b/sources/base.py @@ -48,7 +48,7 @@ def handle_dropbox_popup(self, driver): driver.execute_script("arguments[0].click();", elem) time.sleep(2) return - except: + except Exception: continue except Exception as e: @@ -95,7 +95,7 @@ def wait_for_download_and_get_file(self, timeout: int = 30): if f.is_file(): try: known_files[f.name] = f.stat().st_size - except: + except Exception: known_files[f.name] = 0 logger.info(f"Initial files in directory ({len(known_files)}): {list(known_files.keys())}") @@ -147,7 +147,7 @@ def wait_for_download_and_get_file(self, timeout: int = 30): else: logger.info(f"[{elapsed:.1f}s] FILE STILL GROWING: {f.name} ({current_size} -> {new_size})") known_files[f.name] = new_size - except: + except Exception: pass elif current_size != prev_size and prev_size > 0: if any(f.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS): diff --git a/sources/melinda_myers.py b/sources/melinda_myers.py index 58198ab..5dc3282 100644 --- a/sources/melinda_myers.py +++ b/sources/melinda_myers.py @@ -2,7 +2,6 @@ Melinda Myers download source """ -import os import time import logging import shutil diff --git a/sources/northwest_outdoors.py b/sources/northwest_outdoors.py index 34d218a..0c1932c 100644 --- a/sources/northwest_outdoors.py +++ b/sources/northwest_outdoors.py @@ -18,167 +18,223 @@ logger = logging.getLogger(__name__) -class NorthwestOutdoorsDownloader(BaseDownloader): - """Download Northwest Outdoors files""" +def _download_nwo_zip(downloader, update_callback=None): + """Download and extract the Northwest Outdoors ZIP from Dropbox. + + Args: + downloader: A BaseDownloader subclass instance. + update_callback: Optional progress callback. + + Returns: + Path to temp directory with extracted files, or None on failure. + """ + if not downloader.browser_manager.start_browser(): + logger.error("Failed to start browser") + return None + + driver = downloader.browser_manager.get_driver() + if not driver: + logger.error("Failed to get driver") + return None + + if update_callback: + update_callback(5, "Accessing download page...") + + logger.info("Navigating to Dropbox URL...") + all_urls = downloader.config_manager.get("urls", {}) + url = all_urls.get("northwest_outdoors") + if not url or "YOUR_LINK" in url or "REMOVED" in url: + logger.error(f"northwest_outdoors URL not configured properly: {url}") + if update_callback: + update_callback(100, "Error: northwest_outdoors URL not configured") + return None + driver.get(url) + + logger.info("Waiting for page to load...") + time.sleep(10) + + logger.info("Waiting for download button to be clickable...") + wait = WebDriverWait(driver, 30) + download_button = wait.until( + EC.element_to_be_clickable((By.XPATH, "/html/body/div[1]/span/span/div/span/div/div/div/div/div[2]/div/div[1]/span/div/div[2]/span[1]/button/span/span/span")) + ) + time.sleep(2) + download_button.click() + time.sleep(3) + + if update_callback: + update_callback(30, "Confirming download...") + + confirm_button = None + for xpath in [ + "//button[contains(., 'continue with download')]", + "/html/body/div[9]/div/div/div/div[3]/div/span/button/span" + ]: + try: + confirm_button = WebDriverWait(driver, 5).until( + EC.element_to_be_clickable((By.XPATH, xpath)) + ) + break + except Exception: + continue + + if confirm_button: + time.sleep(1) + confirm_button.click() + time.sleep(2) + else: + logger.warning("No confirm button found") + time.sleep(3) + + if update_callback: + update_callback(40, "Waiting for download...") + + downloaded_file = downloader.wait_for_download_and_get_file(timeout=300) + + if not downloaded_file: + logger.error("No downloaded file found after waiting") + if update_callback: + update_callback(100, "Download failed - no file found") + return None + + if update_callback: + update_callback(60, "Processing download...") + + logger.info("Extracting files...") + temp_dir = Path(tempfile.gettempdir()) / "nwo_extract" + temp_dir.mkdir(exist_ok=True) + + with zipfile.ZipFile(downloaded_file, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + logger.info(f"Extracted {len(zip_ref.namelist())} files") + + os.remove(downloaded_file) + + return temp_dir + + +class NorthwestOutdoorsDownloader(BaseDownloader): + """Download Northwest Outdoors non-promo files (Global Features)""" def download(self, update_callback=None) -> bool: logger.info("=== STARTING NORTHWEST OUTDOORS DOWNLOAD ===") - - if not self.browser_manager.start_browser(): - logger.error("Failed to start browser") + + temp_dir = _download_nwo_zip(self, update_callback) + if temp_dir is None: + if self.should_auto_close_browser(): + self.browser_manager.close_browser() return False - + try: - driver = self.browser_manager.get_driver() - if not driver: - logger.error("Failed to get driver") - return False - - if update_callback: - update_callback(5, "Accessing download page...") - - logger.info("Navigating to Dropbox URL...") - all_urls = self.config_manager.get("urls", {}) - logger.info(f"All URLs from config: {all_urls}") - url = all_urls.get("northwest_outdoors") - logger.info(f"URL from config: [{url}]") - if not url or "YOUR_LINK" in url or "REMOVED" in url: - logger.error(f"northwest_outdoors URL not configured properly in download_config.json: {url}") - if update_callback: - update_callback(100, "Error: northwest_outdoors URL not configured") - return False - driver.get(url) - - logger.info("Waiting for page to load...") - time.sleep(10) - - logger.info("Waiting for download button to be clickable...") - wait = WebDriverWait(driver, 30) - download_button = wait.until( - EC.element_to_be_clickable((By.XPATH, "/html/body/div[1]/span/span/div/span/div/div/div/div/div[2]/div/div[1]/span/div/div[2]/span[1]/button/span/span/span")) - ) - time.sleep(2) - logger.info("Found download button, clicking...") - download_button.click() - logger.info("Download button clicked") - time.sleep(3) - - logger.info("Waiting for confirm popup...") - if update_callback: - update_callback(30, "Confirming download...") - - confirm_button = None - for xpath in [ - "//button[contains(., 'continue with download')]", - "/html/body/div[9]/div/div/div/div[3]/div/span/button/span" - ]: - try: - confirm_button = WebDriverWait(driver, 5).until( - EC.element_to_be_clickable((By.XPATH, xpath)) - ) - logger.info(f"Found confirm button with XPath: {xpath}") - break - except: - logger.info(f"XPath not found: {xpath}") - - if confirm_button: - time.sleep(1) - logger.info("Clicking confirm button...") - confirm_button.click() - logger.info("Confirm button clicked") - time.sleep(2) - else: - logger.warning("No confirm button found") - time.sleep(3) - - logger.info("Polling for download file...") - if update_callback: - update_callback(40, "Waiting for download...") - - downloaded_file = self.wait_for_download_and_get_file(timeout=300) - - if not downloaded_file: - logger.error("No downloaded file found after waiting") + global_features_dir = Path(self.config_manager.get_global_features_dir()) + global_features_dir.mkdir(parents=True, exist_ok=True) + + found_files = False + for extracted_file in temp_dir.iterdir(): + if not extracted_file.is_file(): + continue + + if 'promo' in extracted_file.name.lower(): + logger.info(f"Skipping promo file: {extracted_file.name}") + continue + + found_files = True if update_callback: - update_callback(100, "Download failed - no file found") - return False - - logger.info(f"Download detected: {downloaded_file}") + update_callback(90, f"Moving {extracted_file.name}...") + + output_path = global_features_dir / extracted_file.name + shutil.copy(extracted_file, output_path) + logger.info(f"Copied {extracted_file.name} to {output_path}") + + shutil.rmtree(temp_dir, ignore_errors=True) + if update_callback: - update_callback(60, "Processing download...") - - logger.info("Extracting files...") - temp_dir = Path(tempfile.gettempdir()) / "nwo_extract" - temp_dir.mkdir(exist_ok=True) - - with zipfile.ZipFile(downloaded_file, 'r') as zip_ref: - zip_ref.extractall(temp_dir) - logger.info(f"Extracted {len(zip_ref.namelist())} files") - - os.remove(downloaded_file) - - global_features_dir = Path(self.config_manager.get_global_features_dir()) + update_callback(100, "Complete") + + logger.info("=== NORTHWEST OUTDOORS DOWNLOAD COMPLETE ===") + + if not found_files: + logger.warning("No non-promo files found in download") + + return True + + except Exception as e: + logger.error(f"Error processing Northwest Outdoors download: {e}") + import traceback + traceback.print_exc() + return False + finally: + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + + +class NorthwestOutdoorsPromoDownloader(BaseDownloader): + """Download only promo files from Northwest Outdoors""" + def download(self, update_callback=None) -> bool: + logger.info("=== STARTING NORTHWEST OUTDOORS PROMO DOWNLOAD ===") + + temp_dir = _download_nwo_zip(self, update_callback) + if temp_dir is None: + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + return False + + try: promos_dir = Path(self.config_manager.get_promos_dir()) tag_file = self.config_manager.get_tag_file() - - global_features_dir.mkdir(parents=True, exist_ok=True) + promos_dir.mkdir(parents=True, exist_ok=True) - - logger.info("Processing extracted files...") + + found_promo = False for extracted_file in temp_dir.iterdir(): if not extracted_file.is_file(): continue - - filename_lower = extracted_file.name.lower() - - if 'promo' in filename_lower: - logger.info(f"Processing promo file: {extracted_file.name}") - if update_callback: - update_callback(80, "Processing promo with tag...") - - output_file = promos_dir / extracted_file.name - - if Path(tag_file).exists(): - from download_utils import DownloadUtilities - success = DownloadUtilities.overlay_promo_with_tag( - str(extracted_file), - tag_file, - str(output_file), - overlap_seconds=10 - ) - - if success: - logger.info(f"Promo with tag saved to {output_file}") - else: - logger.warning("Tag overlay failed, saving promo without tag") - shutil.copy(extracted_file, output_file) + + if 'promo' not in extracted_file.name.lower(): + continue + + found_promo = True + logger.info(f"Processing promo file: {extracted_file.name}") + if update_callback: + update_callback(80, "Processing promo with tag...") + + output_file = promos_dir / extracted_file.name + + if Path(tag_file).exists(): + from download_utils import DownloadUtilities + success = DownloadUtilities.overlay_promo_with_tag( + str(extracted_file), + tag_file, + str(output_file), + overlap_seconds=10 + ) + + if success: + logger.info(f"Promo with tag saved to {output_file}") else: - logger.warning(f"Tag file not found: {tag_file}, saving promo without tag") + logger.warning("Tag overlay failed, saving promo without tag") shutil.copy(extracted_file, output_file) else: - if update_callback: - update_callback(90, f"Moving {extracted_file.name}...") - - output_path = global_features_dir / extracted_file.name - shutil.copy(extracted_file, output_path) - logger.info(f"Copied {extracted_file.name} to {output_path}") - + logger.warning(f"Tag file not found: {tag_file}, saving promo without tag") + shutil.copy(extracted_file, output_file) + shutil.rmtree(temp_dir, ignore_errors=True) - + if update_callback: update_callback(100, "Complete") - - logger.info("=== NORTHWEST OUTDOORS DOWNLOAD COMPLETE ===") - - if self.should_auto_close_browser(): - self.browser_manager.close_browser() - + + logger.info("=== NORTHWEST OUTDOORS PROMO DOWNLOAD COMPLETE ===") + + if not found_promo: + logger.warning("No promo files found in download") + return True - + except Exception as e: - logger.error(f"Error in Northwest Outdoors download: {e}") + logger.error(f"Error in Northwest Outdoors promo download: {e}") import traceback traceback.print_exc() + return False + finally: if self.should_auto_close_browser(): self.browser_manager.close_browser() - return False diff --git a/sources/weekend_in_the_country.py b/sources/weekend_in_the_country.py new file mode 100644 index 0000000..43ef8ad --- /dev/null +++ b/sources/weekend_in_the_country.py @@ -0,0 +1,174 @@ +""" +Weekend In The Country download source (FTP) +""" + +import re +import logging +from pathlib import Path + +from .base import BaseDownloader + +logger = logging.getLogger(__name__) + + +class WeekendInTheCountryDownloader(BaseDownloader): + """Download Weekend In The Country files via FTP""" + def download(self, update_callback=None) -> bool: + logger.info("=== STARTING WEEKEND IN THE COUNTRY DOWNLOAD ===") + + server = self.config_manager.get("witc_ftp_server", "") + username = self.config_manager.get("witc_ftp_username", "") + password = self.config_manager.get("witc_ftp_password", "") + + if not server or not username or not password: + logger.error("FTP credentials not configured for Weekend In The Country") + if update_callback: + update_callback(100, "Error: FTP credentials not configured") + return False + + if update_callback: + update_callback(10, "Connecting to FTP server...") + + from ftplib import FTP, error_perm + + ftp = FTP() + ftp.connect(server, timeout=30) + ftp.login(username, password) + ftp.encoding = 'utf-8' + + logger.info(f"Connected to {server}") + + output_dir = Path(self.config_manager.get_global_features_dir()) + output_dir.mkdir(parents=True, exist_ok=True) + + try: + if update_callback: + update_callback(20, "Finding MP3 files...") + + mp3_files = self._find_mp3_files(ftp) + + if not mp3_files: + logger.warning("No MP3 files found on FTP server") + if update_callback: + update_callback(100, "No MP3 files found") + return False + + logger.info(f"Found {len(mp3_files)} MP3 file(s)") + if update_callback: + update_callback(30, f"Found {len(mp3_files)} MP3 file(s)") + + downloaded = 0 + for i, remote_path in enumerate(mp3_files): + filename = Path(remote_path).name + local_path = output_dir / filename + + if local_path.exists(): + logger.info(f"Skipping (already exists): {filename}") + continue + + if update_callback: + progress = 30 + int((i / len(mp3_files)) * 60) + update_callback(progress, f"Downloading {filename}...") + + logger.info(f"Downloading: {remote_path}") + + with open(local_path, 'wb') as f: + ftp.retrbinary(f'RETR {remote_path}', f.write) + + logger.info(f"Downloaded: {filename}") + downloaded += 1 + + if update_callback: + update_callback(100, f"Downloaded {downloaded} file(s)") + + self._process_files(output_dir) + + logger.info(f"=== WEEKEND IN THE COUNTRY DOWNLOAD COMPLETE ({downloaded} files) ===") + return True + + except Exception as e: + logger.error(f"Error in Weekend In The Country download: {e}") + import traceback + traceback.print_exc() + if update_callback: + update_callback(100, f"Error: {e}") + return False + finally: + try: + ftp.quit() + except Exception: + pass + + def _process_files(self, output_dir): + """Rename downloaded files to WITC naming convention""" + seg_re = re.compile(r'hr(\d+)_seg(\d+)', re.IGNORECASE) + date_re = re.compile(r'(\d{2}-\d{2}-\d{2})') + promos = [] + segments = [] + + for f in output_dir.iterdir(): + if not f.is_file() or not f.name.lower().endswith('.mp3'): + continue + if not f.name.startswith('Weekend in the Country'): + continue + + name = f.name + seg_match = seg_re.search(name) + if seg_match: + segments.append((f, seg_match.group(1), seg_match.group(2))) + continue + + if 'promo' in name.lower(): + date_match = date_re.search(name) + promos.append((f, date_match.group(1) if date_match else '')) + + for f, hr, pt in segments: + new_name = f.parent / f"WITC_HR{hr}_PT{pt}.mp3" + try: + f.rename(new_name) + logger.info(f"Renamed: {f.name} -> {new_name.name}") + except OSError as e: + logger.warning(f"Failed to rename {f.name}: {e}") + + 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" + try: + f.rename(new_name) + logger.info(f"Renamed promo: {f.name} -> {new_name.name}") + except OSError as e: + logger.warning(f"Failed to rename promo {f.name}: {e}") + + def _find_mp3_files(self, ftp, path=""): + """Recursively find all MP3 files on the FTP server""" + mp3_files = [] + + try: + items = [] + ftp.retrlines(f'LIST {path}', items.append) + except Exception as e: + logger.warning(f"Cannot list path '{path}': {e}") + return mp3_files + + for line in items: + try: + parts = line.split() + if len(parts) < 9: + continue + + name = ' '.join(parts[8:]).strip() + if not name or name in ('.', '..'): + continue + + full_path = f"{path}/{name}" if path else name + is_dir = parts[0].startswith('d') + + if is_dir: + mp3_files.extend(self._find_mp3_files(ftp, full_path)) + elif name.lower().endswith('.mp3'): + mp3_files.append(full_path) + except Exception as e: + logger.warning(f"Error parsing listing line '{line}': {e}") + continue + + return mp3_files diff --git a/sources/whittler.py b/sources/whittler.py index 2f30c9c..c5cdede 100644 --- a/sources/whittler.py +++ b/sources/whittler.py @@ -3,7 +3,7 @@ """ import os -import glob + import zipfile import time import logging @@ -76,7 +76,7 @@ def download(self, update_callback=None) -> bool: ) logger.info(f"Found confirm button with XPath: {xpath}") break - except: + except Exception: logger.info(f"XPath not found: {xpath}") if confirm_button: @@ -114,8 +114,6 @@ def download(self, update_callback=None) -> bool: zip_ref.extractall(temp_dir) logger.info(f"Extracted {len(zip_ref.namelist())} files") - os.remove(downloaded_file) - if update_callback: update_callback(80, "Moving files to Global Features...") @@ -140,6 +138,7 @@ def download(self, update_callback=None) -> bool: shutil.copy(file_path, new_path) logger.info(f"Copied: {file_path.name} -> {new_filename}") + os.remove(downloaded_file) shutil.rmtree(temp_dir, ignore_errors=True) if update_callback: diff --git a/test_downloads.py b/test_downloads.py index f8e2eea..d34a712 100644 --- a/test_downloads.py +++ b/test_downloads.py @@ -81,41 +81,14 @@ def test_download_detection(): file1 = DownloadSimulator.create_test_file(test_dir, "test1.mp3", size_kb=10) time.sleep(0.5) - print("\n2. Testing simple_wait_for_download...") - result = DownloadUtilities.simple_wait_for_download( - test_dir, - expected_extensions=['.mp3'], - timeout=5 - ) - print(f"Result: {result}") - - print("\n3. Testing monitor_for_download...") - test_dir2 = tempfile.mkdtemp(prefix='download_monitor_test_') - - def create_file_in_background(): - time.sleep(1) - DownloadSimulator.create_test_file(test_dir2, "test2.mp3", size_kb=20) - - import threading - t = threading.Thread(target=create_file_in_background) - t.start() - - result = DownloadUtilities.monitor_for_download( - test_dir2, - expected_extensions=['.mp3'], - timeout=10 - ) - t.join() - print(f"Result: {result}") - - print("\n4. Testing find_latest_file...") + + print("\n2. Testing find_latest_file...") DownloadSimulator.create_test_file(test_dir, "test3.mp3", size_kb=15) time.sleep(1) result = DownloadUtilities.find_latest_file(test_dir, extension='.mp3') print(f"Result: {result}") shutil.rmtree(test_dir, ignore_errors=True) - shutil.rmtree(test_dir2, ignore_errors=True) print("\n" + "=" * 50) print("All tests completed!") diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index dc03d8b..9bb00db 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -31,8 +31,6 @@ def test_browser_manager_has_required_methods(self): 'start_browser', 'close_browser', 'get_driver', - 'get_browser_type', - 'set_browser_type', ] for method in required_methods: @@ -43,19 +41,6 @@ def test_browser_manager_has_required_methods(self): print(f" ✗ Import failed: {e}") raise - def test_browser_type_defaults(self): - """Test default browser type settings""" - try: - from browser_manager import BrowserManager - bm = BrowserManager.__new__(BrowserManager) - - default_browser = getattr(bm, 'browser_type', 'chrome') - assert default_browser in ['chrome', 'firefox', 'edge'], f"Invalid default: {default_browser}" - - print(f" ✓ Default browser type: {default_browser}") - except Exception as e: - print(f" ✗ Test failed: {e}") - def test_selenium_webdriver_imports(self): """Test that Selenium WebDriver can be imported""" try: @@ -65,7 +50,6 @@ def test_selenium_webdriver_imports(self): assert hasattr(webdriver, 'Chrome'), "Should have Chrome webdriver" assert hasattr(webdriver, 'Firefox'), "Should have Firefox webdriver" - assert hasattr(webdriver, 'Edge'), "Should have Edge webdriver" print(" ✓ Selenium WebDriver imports successful") except ImportError as e: @@ -77,7 +61,6 @@ def test_webdriver_manager_imports(self): try: from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.firefox import GeckoDriverManager - from webdriver_manager.microsoft import EdgeChromiumDriverManager print(" ✓ webdriver_manager imports successful") except ImportError as e: @@ -88,23 +71,6 @@ def test_webdriver_manager_imports(self): class TestBrowserStartup: """Test browser startup logic""" - @patch('browser_manager.webdriver.Chrome') - @patch('browser_manager.ChromeDriverManager') - def test_start_chrome_browser(self, mock_driver_manager, mock_chrome): - """Test Chrome browser startup""" - try: - from browser_manager import BrowserManager - - mock_driver_manager.return_value.install.return_value = "/path/to/chromedriver" - mock_chrome.return_value = Mock() - - bm = BrowserManager.__new__(BrowserManager) - bm.browser_type = 'chrome' - - print(" ✓ Chrome browser startup logic works") - except Exception as e: - print(f" Note: {e} (expected without full browser setup)") - @patch('browser_manager.webdriver.Firefox') @patch('browser_manager.GeckoDriverManager') def test_start_firefox_browser(self, mock_driver_manager, mock_firefox): @@ -116,26 +82,23 @@ def test_start_firefox_browser(self, mock_driver_manager, mock_firefox): mock_firefox.return_value = Mock() bm = BrowserManager.__new__(BrowserManager) - bm.browser_type = 'firefox' print(" ✓ Firefox browser startup logic works") except Exception as e: print(f" Note: {e} (expected without full browser setup)") def test_browser_options_configured(self): - """Test that browser options can be configured""" + """Test that Firefox browser options can be configured""" try: - from selenium.webdriver.chrome.options import Options - from selenium.webdriver.firefox.options import Options as FirefoxOptions + from selenium.webdriver.firefox.options import Options - chrome_opts = Options() - chrome_opts.add_argument("--headless") - chrome_opts.add_argument("--no-sandbox") - chrome_opts.add_argument("--disable-dev-shm-usage") + opts = Options() + opts.set_preference("browser.download.folderList", 2) + opts.set_preference("browser.download.dir", "/tmp/downloads") - assert "--headless" in chrome_opts.arguments + assert opts.preferences["browser.download.folderList"] == 2 - print(" ✓ Browser options configuration works") + print(" ✓ Firefox browser options configuration works") except ImportError as e: print(f" ✗ Import failed: {e}") raise @@ -182,7 +145,6 @@ def run_tests(): tests = [ tester.test_browser_manager_imports, tester.test_browser_manager_has_required_methods, - tester.test_browser_type_defaults, tester.test_selenium_webdriver_imports, tester.test_webdriver_manager_imports, startup_tester.test_browser_options_configured, diff --git a/tests/test_config_edge_cases.py b/tests/test_config_edge_cases.py index 9df145c..1aabafa 100644 --- a/tests/test_config_edge_cases.py +++ b/tests/test_config_edge_cases.py @@ -30,7 +30,7 @@ def test_default_config_has_all_required_keys(self): "output_dir", "tag_file", "browser_download_dir", "auto_close_browser", "retry_attempts", "cow_password", - "urls", "scheduled_downloads" + "urls" ] for key in required_keys: @@ -114,30 +114,6 @@ def test_retry_attempts_validation(self): print(" ✓ Retry attempts validation works correctly") - def test_scheduled_time_format_validation(self): - """Test scheduled download time format validation""" - from datetime import datetime - - valid_times = ["00:00", "12:30", "23:59", "06:00"] - for time_str in valid_times: - try: - datetime.strptime(time_str, '%H:%M') - is_valid = True - except ValueError: - is_valid = False - assert is_valid, f"Time {time_str} should be valid" - - invalid_times = ["25:00", "12:60", "abc", "12", "12:30:00"] - for time_str in invalid_times: - try: - datetime.strptime(time_str, '%H:%M') - is_valid = True - except ValueError: - is_valid = False - assert not is_valid, f"Time {time_str} should be invalid" - - print(" ✓ Time format validation works correctly") - def test_config_validate_returns_errors_for_missing_required(self): """Test that validate_config returns errors for missing required fields""" cm = ConfigManager() @@ -166,7 +142,6 @@ def run_tests(): tester.test_config_merge_user_overrides_defaults, tester.test_output_dir_affects_paths, tester.test_retry_attempts_validation, - tester.test_scheduled_time_format_validation, tester.test_config_validate_returns_errors_for_missing_required, ] diff --git a/tests/test_download_utils.py b/tests/test_download_utils.py new file mode 100644 index 0000000..7cfd2ce --- /dev/null +++ b/tests/test_download_utils.py @@ -0,0 +1,192 @@ +""" +Test download utilities, especially the FFmpeg promo tag overlay +""" +import sys +import os +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +class TestOverlayPromoWithTag: + """Test the overlay_promo_with_tag method""" + + def test_missing_promo_file(self): + """Should return False if promo file doesn't exist""" + from download_utils import DownloadUtilities + result = DownloadUtilities.overlay_promo_with_tag( + "/nonexistent/promo.mp3", "/nonexistent/tag.wav", "/tmp/output.mp3" + ) + assert result is False + print(" ✓ Missing promo file returns False") + + def test_missing_tag_file(self): + """Should return False if tag file doesn't exist""" + from download_utils import DownloadUtilities + + # Create a fake promo file + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: + promo.write(b'fake audio data') + promo_path = promo.name + + try: + result = DownloadUtilities.overlay_promo_with_tag( + promo_path, "/nonexistent/tag.wav", "/tmp/output.mp3" + ) + assert result is False + print(" ✓ Missing tag file returns False") + finally: + os.unlink(promo_path) + + def test_successful_overlay(self): + """Should return True when FFmpeg succeeds""" + from download_utils import DownloadUtilities + + # Create fake input files + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: + promo.write(b'fake audio') + promo_path = promo.name + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tag: + tag.write(b'fake tag audio') + tag_path = tag.name + + output_path = tempfile.mktemp(suffix='.mp3') + + try: + # Mock _get_audio_duration to return 30 seconds + with patch.object(DownloadUtilities, '_get_audio_duration', return_value=30.0): + # Mock subprocess.run to simulate FFmpeg success and create output file + def mock_run(cmd, **kwargs): + mock_result = MagicMock() + if 'ffprobe' in cmd[0]: + mock_result.returncode = 0 + mock_result.stdout = '30.0' + else: # ffmpeg + mock_result.returncode = 0 + mock_result.stderr = '' + # Create the output file to simulate success + Path(output_path).write_bytes(b'fake output') + return mock_result + + with patch('download_utils.subprocess.run', side_effect=mock_run): + result = DownloadUtilities.overlay_promo_with_tag( + promo_path, tag_path, output_path, overlap_seconds=10 + ) + + assert result is True + print(" ✓ Successful overlay returns True") + finally: + if os.path.exists(promo_path): + os.unlink(promo_path) + if os.path.exists(tag_path): + os.unlink(tag_path) + if os.path.exists(output_path): + os.unlink(output_path) + + def test_ffmpeg_failure(self): + """Should return False when FFmpeg fails""" + from download_utils import DownloadUtilities + + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: + promo.write(b'fake audio') + promo_path = promo.name + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tag: + tag.write(b'fake tag audio') + tag_path = tag.name + + output_path = tempfile.mktemp(suffix='.mp3') + + try: + with patch.object(DownloadUtilities, '_get_audio_duration', return_value=30.0): + def mock_run(cmd, **kwargs): + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = 'Some error' + return mock_result + + with patch('download_utils.subprocess.run', side_effect=mock_run): + result = DownloadUtilities.overlay_promo_with_tag( + promo_path, tag_path, output_path, overlap_seconds=10 + ) + + assert result is False + print(" ✓ FFmpeg failure returns False") + finally: + if os.path.exists(promo_path): + os.unlink(promo_path) + if os.path.exists(tag_path): + os.unlink(tag_path) + if os.path.exists(output_path): + os.unlink(output_path) + + def test_promo_too_short(self): + """Should return False if promo is shorter than overlap""" + from download_utils import DownloadUtilities + + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: + promo.write(b'fake audio') + promo_path = promo.name + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tag: + tag.write(b'fake tag audio') + tag_path = tag.name + + try: + # Promo is 5 seconds but overlap is 10 + with patch.object(DownloadUtilities, '_get_audio_duration', return_value=5.0): + result = DownloadUtilities.overlay_promo_with_tag( + promo_path, tag_path, "/tmp/output.mp3", overlap_seconds=10 + ) + + assert result is False + print(" ✓ Short promo returns False") + finally: + if os.path.exists(promo_path): + os.unlink(promo_path) + if os.path.exists(tag_path): + os.unlink(tag_path) + + +def run_tests(): + """Run all download utils tests""" + print("=" * 60) + print("Running Download Utils Tests") + print("=" * 60) + + tester = TestOverlayPromoWithTag() + + tests = [ + tester.test_missing_promo_file, + tester.test_missing_tag_file, + tester.test_successful_overlay, + tester.test_ffmpeg_failure, + tester.test_promo_too_short, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) diff --git a/tests/test_integration.py b/tests/test_integration.py index 45e9201..e111a72 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -35,25 +35,27 @@ def test_config_manager_workflow(self): def test_source_initialization(self): """Test that all sources can be initialized""" - from sources.base import BaseDownloader - from config import ConfigManager - - cm = ConfigManager() + from sources import ( + MelindaMyersDownloader, + NorthwestOutdoorsDownloader, + NorthwestOutdoorsPromoDownloader, + WhittlerDownloader, + ClearOutWestDownloader, + WeekendInTheCountryDownloader, + ) source_classes = [ - 'MelindaMyersDownloader', - 'NorthwestOutdoorsDownloader', - 'WhittlerDownloader', - 'ClearOutWestDownloader', + MelindaMyersDownloader, + NorthwestOutdoorsDownloader, + NorthwestOutdoorsPromoDownloader, + WhittlerDownloader, + ClearOutWestDownloader, + WeekendInTheCountryDownloader, ] - for class_name in source_classes: - try: - module = __import__(f'sources.{class_name.lower().replace("downloader", "")}', fromlist=[class_name]) - cls = getattr(module, class_name) - print(f" ✓ {class_name} can be imported") - except (ImportError, AttributeError) as e: - print(f" Note: {class_name} - {e}") + for cls in source_classes: + assert cls is not None, f"Source class import failed" + print(f" ✓ {cls.__name__} can be imported") def test_downloader_has_required_methods(self): """Test BaseDownloader has required methods""" @@ -61,7 +63,6 @@ def test_downloader_has_required_methods(self): required_methods = [ 'download', - 'cleanup', 'should_auto_close_browser', ] @@ -88,7 +89,7 @@ def test_promo_tag_workflow(self): class TestEndToEndScenarios: """Test end-to-end scenarios""" - @patch('sources.base.BaseDownloader.start_browser') + @patch('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 @@ -98,9 +99,9 @@ def test_northwest_outdoors_workflow(self, mock_start_browser): from browser_manager import BrowserManager cm = ConfigManager() - bm = BrowserManager() + bm = BrowserManager(cm) - downloader = NorthwestOutdoorsDownloader(cm, bm) + downloader = NorthwestOutdoorsDownloader(bm, cm) url = cm.get("urls", {}).get("northwest_outdoors", "") is_valid = bool(url) and "YOUR_LINK" not in url @@ -108,7 +109,7 @@ 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('sources.base.BaseDownloader.start_browser') + @patch('browser_manager.BrowserManager.start_browser') def test_whittler_workflow(self, mock_start_browser): """Test Whittler download workflow""" mock_start_browser.return_value = True @@ -118,9 +119,9 @@ def test_whittler_workflow(self, mock_start_browser): from browser_manager import BrowserManager cm = ConfigManager() - bm = BrowserManager() + bm = BrowserManager(cm) - downloader = WhittlerDownloader(cm, bm) + downloader = WhittlerDownloader(bm, cm) url = cm.get("urls", {}).get("whittler", "") is_valid = bool(url) and "YOUR_LINK" not in url @@ -185,6 +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 temp_config = tempfile.NamedTemporaryFile(delete=False, suffix='.json') temp_config.close() @@ -214,6 +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 temp_config = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') temp_config.write("{ invalid json }") diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py deleted file mode 100644 index 27a912e..0000000 --- a/tests/test_scheduler.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Test scheduler functionality -""" - -import sys -from pathlib import Path -from datetime import datetime, time - -sys.path.insert(0, str(Path(__file__).parent.parent)) - - -class TestSchedulerParsing: - """Test scheduler time and day parsing""" - - def test_time_format_parsing(self): - """Test various time format parsing""" - from scheduler import parse_time - - test_times = [ - ("06:00", (6, 0)), - ("00:00", (0, 0)), - ("12:30", (12, 30)), - ("23:59", (23, 59)), - ("18:15", (18, 15)), - ] - - for time_str, expected in test_times: - try: - result = parse_time(time_str) - if result: - assert result == expected, f"{time_str}: expected {expected}, got {result}" - print(f" ✓ {time_str} -> {result}") - except Exception as e: - print(f" Note: {time_str} - {e}") - - def test_invalid_time_format_parsing(self): - """Test invalid time formats are rejected""" - invalid_times = [ - "25:00", - "12:60", - "12:00:00", - "abc", - "", - None, - ] - - for time_str in invalid_times: - try: - from scheduler import parse_time - result = parse_time(time_str) - is_invalid = result is None - except Exception: - is_invalid = True - - assert is_invalid, f"Time {time_str} should be invalid" - print(f" ✓ {time_str} correctly rejected") - - def test_day_mapping(self): - """Test day name mapping""" - from config import DAY_MAPPING - - expected_mappings = { - "Monday": "Mon", - "Tuesday": "Tue", - "Wednesday": "Wed", - "Thursday": "Thu", - "Friday": "Fri", - "Saturday": "Sat", - "Sunday": "Sun" - } - - for full, short in expected_mappings.items(): - actual = DAY_MAPPING.get(full) - assert actual == short, f"{full}: expected {short}, got {actual}" - print(f" ✓ {full} -> {actual}") - - def test_schedule_types(self): - """Test different schedule types""" - valid_schedule_types = ["daily", "weekly", "monthly", "once"] - - for schedule_type in valid_schedule_types: - assert schedule_type in valid_schedule_types - print(f" ✓ Schedule type '{schedule_type}' is valid") - - def test_day_list_validation(self): - """Test day list validation""" - valid_days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - invalid_days = ["Monday", "monday", "Mon,Tue", "1", ""] - - for day in valid_days: - is_valid = day in valid_days - assert is_valid, f"'{day}' should be valid" - - for day in invalid_days: - is_invalid = day not in valid_days - assert is_invalid, f"'{day}' should be invalid" - - -class TestSchedulerExecution: - """Test scheduler execution logic""" - - def test_next_run_calculation(self): - """Test next run time calculation""" - from datetime import datetime, timedelta - - now = datetime.now() - - test_cases = [ - (time(6, 0), "daily", None), - (time(12, 0), "daily", None), - (time(0, 0), "daily", None), - ] - - for target_time, schedule_type, days in test_cases: - if now.time() < target_time: - expected = now.replace(hour=target_time.hour, minute=target_time.minute) - else: - expected = now.replace(hour=target_time.hour, minute=target_time.minute) + timedelta(days=1) - - print(f" ✓ Next run for {target_time}: {expected.strftime('%H:%M')}") - - def test_schedule_enabled_check(self): - """Test schedule enabled/disabled logic""" - schedule = { - "enabled": True, - "schedule_type": "daily", - "time": "06:00", - "days": [], - "download_all": True, - "selected_sources": [] - } - - is_enabled = schedule.get("enabled", False) - assert is_enabled, "Schedule should be enabled" - print(" ✓ Schedule enabled check works") - - def test_selected_sources_handling(self): - """Test selected sources vs download_all flag""" - test_cases = [ - ({"download_all": True, "selected_sources": []}, ["all_sources"]), - ({"download_all": False, "selected_sources": ["northwest_outdoors"]}, ["northwest_outdoors"]), - ({"download_all": True, "selected_sources": ["whittler"]}, ["all_sources"]), - ] - - for schedule, expected in test_cases: - if schedule.get("download_all"): - result = ["all_sources"] - else: - result = schedule.get("selected_sources", []) - - assert result == expected, f"Expected {expected}, got {result}" - print(f" ✓ download_all={schedule.get('download_all')} -> {result}") - - -class TestSchedulerIntegration: - """Test scheduler integration with config""" - - def test_scheduled_config_structure(self): - """Test scheduled_downloads config structure""" - from config import DEFAULT_CONFIG - - scheduled = DEFAULT_CONFIG.get("scheduled_downloads", {}) - - required_keys = ["enabled", "schedule_type", "time", "days", "download_all", "selected_sources"] - for key in required_keys: - assert key in scheduled, f"Missing key: {key}" - - print(f" ✓ Scheduled config has all required keys: {list(scheduled.keys())}") - - def test_scheduled_config_defaults(self): - """Test scheduled_downloads default values""" - from config import DEFAULT_CONFIG - - scheduled = DEFAULT_CONFIG.get("scheduled_downloads", {}) - - assert scheduled.get("enabled") == False - assert scheduled.get("schedule_type") == "daily" - assert scheduled.get("time") == "06:00" - assert scheduled.get("days") == [] - assert scheduled.get("download_all") == True - assert scheduled.get("selected_sources") == [] - - print(" ✓ Scheduled config defaults are correct") - - def test_get_scheduled_config_method(self): - """Test ConfigManager.get_scheduled_config method""" - try: - from config import ConfigManager - - cm = ConfigManager() - scheduled = cm.get_scheduled_config() - - assert isinstance(scheduled, dict), "Should return dict" - assert "enabled" in scheduled, "Should have enabled key" - print(f" ✓ get_scheduled_config returns: {list(scheduled.keys())}") - except Exception as e: - print(f" Note: {e}") - - -def run_tests(): - """Run all scheduler tests""" - print("=" * 60) - print("Running Scheduler Tests") - print("=" * 60) - - tests = [ - TestSchedulerParsing().test_day_mapping, - TestSchedulerParsing().test_schedule_types, - TestSchedulerParsing().test_day_list_validation, - TestSchedulerExecution().test_next_run_calculation, - TestSchedulerExecution().test_schedule_enabled_check, - TestSchedulerExecution().test_selected_sources_handling, - TestSchedulerIntegration().test_scheduled_config_structure, - TestSchedulerIntegration().test_scheduled_config_defaults, - TestSchedulerIntegration().test_get_scheduled_config_method, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") - failed += 1 - except Exception as e: - print(f" ✗ {test.__name__}: {e}") - failed += 1 - - print("=" * 60) - print(f"Results: {passed} passed, {failed} failed") - print("=" * 60) - - return 0 if failed == 0 else 1 - - -if __name__ == "__main__": - sys.exit(run_tests()) \ No newline at end of file diff --git a/tests/test_weekend_in_the_country.py b/tests/test_weekend_in_the_country.py new file mode 100644 index 0000000..363244d --- /dev/null +++ b/tests/test_weekend_in_the_country.py @@ -0,0 +1,224 @@ +""" +Tests for Weekend In The Country downloader (rename logic and promo selection) +""" + +import sys +import re +import tempfile +import shutil +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def _make_file(dir_path, name): + """Create an empty file in dir_path""" + (dir_path / name).touch() + + +def _simulate_process_files(output_dir): + """Replicate the rename logic from weekend_in_the_country.py""" + seg_re = re.compile(r'hr(\d+)_seg(\d+)', re.IGNORECASE) + date_re = re.compile(r'(\d{2}-\d{2}-\d{2})') + promos = [] + segments = [] + + for f in output_dir.iterdir(): + if not f.is_file() or not f.name.lower().endswith('.mp3'): + continue + if not f.name.startswith('Weekend in the Country'): + continue + + name = f.name + seg_match = seg_re.search(name) + if seg_match: + segments.append((f, seg_match.group(1), seg_match.group(2))) + continue + + if 'promo' in name.lower(): + date_match = date_re.search(name) + promos.append((f, date_match.group(1) if date_match else '')) + + for f, hr, pt in segments: + new_name = f.parent / f"WITC_HR{hr}_PT{pt}.mp3" + f.rename(new_name) + + 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" + f.rename(new_name) + + +def test_segments_renamed_correctly(): + """Segment files are renamed to WITC_HR{num}_PT{num}""" + tmp = Path(tempfile.mkdtemp()) + 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) + + 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") + finally: + shutil.rmtree(tmp) + + +def test_no_segments_skipped(): + """Files without hr/seg pattern are left untouched""" + tmp = Path(tempfile.mkdtemp()) + 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) + + names = {f.name for f in tmp.iterdir() if f.is_file()} + assert "WITC_HR1_PT1.mp3" in names + assert "some_other_file.mp3" in names + print(" ✓ Non-WITC files left untouched") + finally: + shutil.rmtree(tmp) + + +def test_promos_renamed_with_date(): + """Each promo is renamed to WITC_PROMO_MM-DD-YY.mp3""" + tmp = Path(tempfile.mkdtemp()) + 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) + + 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") + finally: + shutil.rmtree(tmp) + + +def test_promo_without_date_defaults(): + """Promo with unparseable date is renamed without tag""" + tmp = Path(tempfile.mkdtemp()) + try: + _make_file(tmp, "Weekend in the Country_baddate promo.mp3") + + _simulate_process_files(tmp) + + 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") + finally: + shutil.rmtree(tmp) + + +def test_single_promo_kept(): + """Single promo is renamed with date""" + tmp = Path(tempfile.mkdtemp()) + try: + _make_file(tmp, "Weekend in the Country_06-27-26 promo.mp3") + + _simulate_process_files(tmp) + + 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") + finally: + shutil.rmtree(tmp) + + +def test_no_promo_no_error(): + """No promo files is handled gracefully""" + tmp = Path(tempfile.mkdtemp()) + try: + _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") + + _simulate_process_files(tmp) + + files = {f.name for f in tmp.iterdir() if f.is_file()} + assert files == {"WITC_HR1_PT1.mp3"}, f"Got {files}" + print(" ✓ No promo files handled gracefully") + finally: + shutil.rmtree(tmp) + + +def test_non_weekend_files_ignored(): + """Files not starting with 'Weekend in the Country' are ignored""" + tmp = Path(tempfile.mkdtemp()) + try: + _make_file(tmp, "completely_different.mp3") + _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") + + _simulate_process_files(tmp) + + files = {f.name for f in tmp.iterdir() if f.is_file()} + assert "WITC_HR1_PT1.mp3" in files + assert "completely_different.mp3" in files + print(" ✓ Non-WITC files ignored by rename logic") + finally: + shutil.rmtree(tmp) + + +def test_promo_with_past_date_handled(): + """Past-date promo is renamed with its date""" + tmp = Path(tempfile.mkdtemp()) + try: + _make_file(tmp, "Weekend in the Country_06-20-26 promo.mp3") + + _simulate_process_files(tmp) + + 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") + finally: + shutil.rmtree(tmp) + + +def run_tests(): + """Run all Weekend In The Country tests""" + print("=" * 60) + print("Running Weekend In The Country Tests") + print("=" * 60) + + tests = [ + test_segments_renamed_correctly, + test_no_segments_skipped, + test_promos_renamed_with_date, + test_promo_without_date_defaults, + test_single_promo_kept, + test_no_promo_no_error, + test_non_weekend_files_ignored, + test_promo_with_past_date_handled, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) From ea3da88c1fba2877ba900306239c5b975c783ab2 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Wed, 24 Jun 2026 17:11:31 -0700 Subject: [PATCH 3/9] fix: skip version bump and release on PR events The version job was running on PRs, where checkout is in detached HEAD. The push to main failed because there was no local main branch. Now version bump only runs on push events; build runs on both but skips release on PRs. --- .github/workflows/windows_build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index aea9d11..f14c116 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -9,6 +9,7 @@ on: jobs: version: + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: write @@ -42,6 +43,7 @@ jobs: build: needs: version + if: always() runs-on: windows-latest permissions: contents: write @@ -49,7 +51,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ needs.version.outputs.new_version }} + ref: ${{ needs.version.outputs.new_version || github.ref }} - name: Set up Python uses: actions/setup-python@v5 @@ -74,6 +76,7 @@ jobs: retention-days: 5 - name: Create Release + if: github.event_name == 'push' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.new_version }} From c35854e49a841398e30dd4c08a42bc189519420d Mon Sep 17 00:00:00 2001 From: wardbryan3 <58064462+wardbryan3@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:22:30 -0700 Subject: [PATCH 4/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sources/weekend_in_the_country.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/sources/weekend_in_the_country.py b/sources/weekend_in_the_country.py index 43ef8ad..87d3db4 100644 --- a/sources/weekend_in_the_country.py +++ b/sources/weekend_in_the_country.py @@ -29,12 +29,22 @@ def download(self, update_callback=None) -> bool: if update_callback: update_callback(10, "Connecting to FTP server...") - from ftplib import FTP, error_perm + from ftplib import FTP ftp = FTP() - ftp.connect(server, timeout=30) - ftp.login(username, password) - ftp.encoding = 'utf-8' + try: + ftp.connect(server, timeout=30) + ftp.login(username, password) + ftp.encoding = 'utf-8' + except Exception as e: + logger.error(f"FTP connection/login failed for {server}: {e}") + if update_callback: + update_callback(100, f"Error: FTP connection/login failed: {e}") + try: + ftp.close() + except Exception: + pass + return False logger.info(f"Connected to {server}") From 7f0e13193951a36923e680e5c6e40f60eeea8526 Mon Sep 17 00:00:00 2001 From: wardbryan3 <58064462+wardbryan3@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:23:16 -0700 Subject: [PATCH 5/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sources/northwest_outdoors.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sources/northwest_outdoors.py b/sources/northwest_outdoors.py index 0c1932c..c323a57 100644 --- a/sources/northwest_outdoors.py +++ b/sources/northwest_outdoors.py @@ -102,9 +102,7 @@ def _download_nwo_zip(downloader, update_callback=None): update_callback(60, "Processing download...") logger.info("Extracting files...") - temp_dir = Path(tempfile.gettempdir()) / "nwo_extract" - temp_dir.mkdir(exist_ok=True) - + temp_dir = Path(tempfile.mkdtemp(prefix="nwo_extract_")) with zipfile.ZipFile(downloaded_file, 'r') as zip_ref: zip_ref.extractall(temp_dir) logger.info(f"Extracted {len(zip_ref.namelist())} files") From 5df03e7b62cae794b7705bc6564cc48d4c6a2076 Mon Sep 17 00:00:00 2001 From: wardbryan3 <58064462+wardbryan3@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:23:43 -0700 Subject: [PATCH 6/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sources/northwest_outdoors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sources/northwest_outdoors.py b/sources/northwest_outdoors.py index c323a57..4d35e03 100644 --- a/sources/northwest_outdoors.py +++ b/sources/northwest_outdoors.py @@ -225,6 +225,7 @@ def download(self, update_callback=None) -> bool: if not found_promo: logger.warning("No promo files found in download") + return False return True From 84f2073a846dca85ca71770cfd0c534c24ad0258 Mon Sep 17 00:00:00 2001 From: wardbryan3 <58064462+wardbryan3@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:24:05 -0700 Subject: [PATCH 7/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constants.py b/constants.py index f393910..52dcb4b 100644 --- a/constants.py +++ b/constants.py @@ -6,5 +6,5 @@ EXCLUDED_EXTENSIONS = {'.part', '.crdownload', '.tmp', '.download', '.xpi', '.so', '.lock'} -# macOS Finder metadata files (._AppleDouble, .DS_Store variants) +# Prefixes of system/metadata/temp files to ignore during download detection EXCLUDED_PREFIXES = {'.fea', '.X'} From 26ceec0d23d598e6fa6a5b2854bfc23bdbad10d3 Mon Sep 17 00:00:00 2001 From: wardbryan3 <58064462+wardbryan3@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:24:29 -0700 Subject: [PATCH 8/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/windows_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index f14c116..799b8d5 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -76,7 +76,7 @@ jobs: retention-days: 5 - name: Create Release - if: github.event_name == 'push' + if: github.event_name == 'push' && needs.version.result == 'success' && needs.version.outputs.new_version != '' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.new_version }} From 9b2659fc4d0c09a79434a4026b1fa619ee22f206 Mon Sep 17 00:00:00 2001 From: wardbryan3 <58064462+wardbryan3@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:24:47 -0700 Subject: [PATCH 9/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sources/northwest_outdoors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sources/northwest_outdoors.py b/sources/northwest_outdoors.py index 4d35e03..aebd385 100644 --- a/sources/northwest_outdoors.py +++ b/sources/northwest_outdoors.py @@ -153,6 +153,7 @@ def download(self, update_callback=None) -> bool: if not found_files: logger.warning("No non-promo files found in download") + return False return True