diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index aea9d11..799b8d5 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' && needs.version.result == 'success' && needs.version.outputs.new_version != '' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.new_version }} 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/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 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..52dcb4b 100644 --- a/constants.py +++ b/constants.py @@ -6,4 +6,5 @@ EXCLUDED_EXTENSIONS = {'.part', '.crdownload', '.tmp', '.download', '.xpi', '.so', '.lock'} +# Prefixes of system/metadata/temp files to ignore during download detection 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..aebd385 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.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") + + 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}") + 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, "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...") + 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(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") - if update_callback: - update_callback(100, "Download failed - no file found") + update_callback(100, "Complete") + + logger.info("=== NORTHWEST OUTDOORS DOWNLOAD COMPLETE ===") + + if not found_files: + logger.warning("No non-promo files found in download") return False - - logger.info(f"Download detected: {downloaded_file}") - 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()) + + 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 False + 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..87d3db4 --- /dev/null +++ b/sources/weekend_in_the_country.py @@ -0,0 +1,184 @@ +""" +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 + + ftp = FTP() + 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}") + + 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())