From 88f50e7b9d440b8075cebc3d284dc8e1896641cf Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:14:01 -0700 Subject: [PATCH 01/11] refactor: move source files into audio_downloader/ package --- audio_downloader/__init__.py | 6 + audio_downloader/browser_manager.py | 195 +++++++ audio_downloader/config.py | 223 ++++++++ audio_downloader/constants.py | 10 + audio_downloader/download_utils.py | 209 ++++++++ audio_downloader/gui.py | 502 ++++++++++++++++++ audio_downloader/main.py | 248 +++++++++ audio_downloader/sources/__init__.py | 36 ++ audio_downloader/sources/base.py | 177 ++++++ audio_downloader/sources/clear_out_west.py | 240 +++++++++ audio_downloader/sources/melinda_myers.py | 113 ++++ .../sources/northwest_outdoors.py | 246 +++++++++ .../sources/weekend_in_the_country.py | 187 +++++++ audio_downloader/sources/whittler.py | 160 ++++++ 14 files changed, 2552 insertions(+) create mode 100644 audio_downloader/__init__.py create mode 100644 audio_downloader/browser_manager.py create mode 100644 audio_downloader/config.py create mode 100644 audio_downloader/constants.py create mode 100644 audio_downloader/download_utils.py create mode 100644 audio_downloader/gui.py create mode 100644 audio_downloader/main.py create mode 100644 audio_downloader/sources/__init__.py create mode 100644 audio_downloader/sources/base.py create mode 100644 audio_downloader/sources/clear_out_west.py create mode 100644 audio_downloader/sources/melinda_myers.py create mode 100644 audio_downloader/sources/northwest_outdoors.py create mode 100644 audio_downloader/sources/weekend_in_the_country.py create mode 100644 audio_downloader/sources/whittler.py diff --git a/audio_downloader/__init__.py b/audio_downloader/__init__.py new file mode 100644 index 0000000..026254f --- /dev/null +++ b/audio_downloader/__init__.py @@ -0,0 +1,6 @@ +""" +Audio Download Manager Package +""" + +__version__ = "1.1.9" +__author__ = "Bryan Ward" \ No newline at end of file diff --git a/audio_downloader/browser_manager.py b/audio_downloader/browser_manager.py new file mode 100644 index 0000000..e043789 --- /dev/null +++ b/audio_downloader/browser_manager.py @@ -0,0 +1,195 @@ +""" +Browser management for Selenium operations +""" + +import logging +from typing import Optional, List, Dict +from pathlib import Path + +from selenium import webdriver +from selenium.webdriver.firefox.service import Service +from selenium.webdriver.firefox.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import TimeoutException +from webdriver_manager.firefox import GeckoDriverManager + +from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES + +logger = logging.getLogger(__name__) + +class BrowserManager: + """Manages browser lifecycle and operations""" + + def __init__(self, config_manager): + self.config_manager = config_manager + self.driver: Optional[webdriver.Firefox] = None + 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 _create_browser_options(self) -> Options: + """Create and configure browser options""" + options = Options() + download_dir = self._get_temp_download_dir() + + options.set_preference("browser.download.folderList", 2) + options.set_preference("browser.download.dir", str(Path(download_dir).resolve())) + options.set_preference("browser.download.manager.showWhenStarting", False) + options.set_preference("browser.helperApps.neverAsk.saveToDisk", + "application/zip, audio/mpeg, application/octet-stream") + options.set_preference("media.play-stand-alone", False) + options.set_preference("pdfjs.disabled", True) + + options.set_preference("browser.download.manager.useWindow", False) + options.set_preference("browser.download.manager.focusWhenStarting", False) + options.set_preference("browser.download.manager.showAlertOnComplete", False) + options.set_preference("browser.download.manager.closeWhenDone", False) + + return options + + def start_browser(self) -> bool: + """Start the browser if not already running""" + if self.driver is not None: + logger.info("Browser already running") + return True + + try: + options = self._create_browser_options() + service = Service(GeckoDriverManager().install()) + self.driver = webdriver.Firefox(service=service, options=options) + + # Maximize window to avoid element obscuring + self.driver.maximize_window() + + logger.info("Browser started successfully") + return True + except Exception as e: + logger.error(f"Error starting browser: {e}") + return False + + def close_browser(self): + """Close the browser if it's open""" + if self.driver is not None: + try: + self.driver.quit() + self.driver = None + logger.info("Browser closed") + except Exception as e: + logger.error(f"Error closing browser: {e}") + + def get_driver(self) -> Optional[webdriver.Firefox]: + """Get the browser driver, starting it if necessary""" + if self.driver is None: + if not self.start_browser(): + return None + return self.driver + + def is_browser_open(self) -> bool: + """Check if browser is currently open""" + return self.driver is not None + + def get_browser_downloads(self, timeout: int = 5) -> List[Dict]: + """ + Get the list of downloads from Firefox's about:downloads page. + Returns list of dicts with: name, path, state, size + """ + if not self.driver: + logger.debug("No driver, returning empty downloads") + return [] + + try: + self.driver.get("about:downloads") + + wait = WebDriverWait(self.driver, timeout) + wait.until(EC.presence_of_element_located(("css selector", "#downloadsList"))) + + download_items = self.driver.execute_script(""" + const list = document.getElementById('downloadsList'); + if (!list) return []; + + const items = list.querySelectorAll('richlistitem'); + const downloads = []; + + items.forEach(item => { + const nameEl = item.querySelector('.downloadTarget'); + const stateEl = item.querySelector('.downloadState'); + const progressEl = item.querySelector('.downloadProgress'); + const fileSizeEl = item.querySelector('.downloadSize'); + + if (nameEl) { + downloads.push({ + name: nameEl.textContent.trim(), + state: stateEl ? stateEl.textContent.trim() : 'unknown', + progress: progressEl ? progressEl.value : 100, + size: fileSizeEl ? fileSizeEl.textContent.trim() : 'unknown' + }); + } + }); + + return downloads; + """) + + if download_items: + logger.info(f"Browser downloads found: {len(download_items)} items") + for item in download_items: + logger.info(f" - {item.get('name', '?')}: state={item.get('state', '?')}, progress={item.get('progress', '?')}%") + else: + logger.debug("No downloads in browser") + + return download_items + + except TimeoutException: + logger.debug("Timeout waiting for about:downloads page") + return [] + except Exception as e: + logger.debug(f"Error getting browser downloads: {e}") + return [] + + def wait_for_browser_download_complete(self, timeout: int = 60, poll_interval: float = 1.0) -> str: + """ + Wait for Firefox to report a download as complete. + Returns the file path of the completed download, or None if timeout. + Only accepts audio/document archive files. + """ + import time + download_dir = Path(self._get_temp_download_dir()) + + start_time = time.time() + checked_files = set() + + while time.time() - start_time < timeout: + downloads = self.get_browser_downloads(timeout=3) + + for dl in downloads: + name = dl.get('name', '') + state = dl.get('state', '').lower() + progress = dl.get('progress', 0) + + if not name: + continue + + file_path = download_dir / name + + if file_path.exists(): + try: + size = file_path.stat().st_size + if size > 0: + if progress >= 100 or 'complete' in state or 'finished' in state or state == '': + if name not in checked_files: + checked_files.add(name) + logger.info(f"Browser confirmed download complete: {name} ({size} bytes)") + return str(file_path) + else: + logger.debug(f"Download in progress: {name} ({progress}%, {size} bytes)") + except OSError: + continue + + time.sleep(poll_interval) + + logger.warning(f"Browser download wait timeout after {timeout}s") + return None \ No newline at end of file diff --git a/audio_downloader/config.py b/audio_downloader/config.py new file mode 100644 index 0000000..01d679f --- /dev/null +++ b/audio_downloader/config.py @@ -0,0 +1,223 @@ +""" +Configuration management for Audio Download Manager +""" + +import os +import sys +import json +import logging +from pathlib import Path +from typing import Dict, Any, List + +logger = logging.getLogger(__name__) + +# Detect if running as frozen executable (PyInstaller) or Python script +if getattr(sys, 'frozen', False): + # Running as compiled executable + APP_DIR = Path(sys.executable).parent +else: + # Running as Python script + APP_DIR = Path(__file__).parent + +CONFIG_FILE = str(APP_DIR / "download_config.json") + +def get_default_browser_download_dir() -> str: + """Get platform-appropriate browser download directory""" + project_root = Path(__file__).parent + if sys.platform == "win32": + return str(project_root / "browser_downloads") + else: + return str(project_root / "browser_downloads") + +BROWSER_DOWNLOAD_DIR = get_default_browser_download_dir() + +DEFAULT_CONFIG = { + "output_dir": "downloads", + "tag_file": "", + "browser_download_dir": BROWSER_DOWNLOAD_DIR, + "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" + } +} + +DOWNLOAD_SOURCES = { + "Melinda Myers": "melinda_myers", + "Northwest Outdoors": "northwest_outdoors", + "Whittler": "whittler", + "Clear Out West": "clear_out_west", + "Weekend In The Country": "weekend_in_the_country" +} + +class ConfigManager: + """Manages application configuration""" + + def __init__(self): + self.config = self.load_config() + + @staticmethod + def load_config() -> Dict[str, Any]: + """Load configuration from file or return defaults""" + logger.info(f"Loading config from: {CONFIG_FILE}") + try: + if os.path.exists(CONFIG_FILE): + logger.info("Config file exists, loading...") + with open(CONFIG_FILE, 'r') as f: + saved_config = json.load(f) + logger.info(f"Saved config URLs: {saved_config.get('urls', {})}") + merged_config = DEFAULT_CONFIG.copy() + merged_config.update(saved_config) + logger.info("Configuration loaded successfully") + return merged_config + except Exception as e: + logger.error(f"Error loading config: {e}") + + logger.info("Using default configuration") + default_config = DEFAULT_CONFIG.copy() + try: + with open(CONFIG_FILE, 'w') as f: + json.dump(default_config, f, indent=2) + logger.info("Created default configuration file") + except Exception as e: + logger.error(f"Could not create config file: {e}") + return default_config + + def save_config(self) -> bool: + """Save configuration to file""" + try: + with open(CONFIG_FILE, 'w') as f: + json.dump(self.config, f, indent=2) + logger.info("Configuration saved successfully") + return True + except Exception as e: + logger.error(f"Error saving config: {e}") + return False + + def get_output_base_dir(self) -> str: + """Get base output directory""" + output_dir = self.config.get("output_dir", "downloads") + p = Path(output_dir) + if not p.is_absolute(): + p = Path.cwd() / p + return str(p) + + def _get_subdir(self, relative_path: str) -> str: + """Get a subdirectory under the output directory""" + return os.path.join(self.get_output_base_dir(), relative_path) + + def ensure_folders(self) -> bool: + """Ensure all required output folders exist""" + folders = [ + self.get_output_base_dir(), + self.get_global_features_dir(), + self.get_promos_dir(), + self.get_spots_dir(), + ] + + for folder in folders: + if folder: + try: + Path(folder).mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error(f"Could not create folder {folder}: {e}") + return False + return True + + def validate_config(self) -> List[str]: + """Validate configuration and return list of errors""" + errors = [] + config = self.config + + if not config.get("cow_password"): + errors.append("COW password is required") + + folders_to_check = [ + ("Base output", self.get_output_base_dir()), + ("GLOBAL FEATURES", self.get_global_features_dir()), + ("Promos", self.get_promos_dir()), + ("Spots", self.get_spots_dir()), + ] + + for name, folder in folders_to_check: + if folder: + try: + Path(folder).mkdir(parents=True, exist_ok=True) + except Exception as e: + errors.append(f"Cannot create {name} folder: {e}") + + retry_attempts = config.get("retry_attempts", 2) + if not isinstance(retry_attempts, int) or retry_attempts < 0: + errors.append("Retry attempts must be a positive integer") + + return errors + + def get(self, key: str, default: Any = None) -> Any: + """Get a configuration value""" + return self.config.get(key, default) + + def set(self, key: str, value: Any): + """Set a single configuration value""" + self.config[key] = value + + def save(self) -> bool: + """Save configuration to file (alias for save_config)""" + return self.save_config() + + def update(self, updates: Dict[str, Any]): + """Update configuration with new values""" + self.config.update(updates) + self.save_config() + + def get_global_features_dir(self) -> str: + """Get the Global Features directory under the output dir""" + return self._get_subdir("GLOBAL FEATURES") + + def get_promos_dir(self) -> str: + """Get the Promos directory under the output dir""" + return self._get_subdir("Promos") + + def get_spots_dir(self) -> str: + """Get the Spots directory under the output dir""" + return self._get_subdir("Spots") + + def get_tag_file(self) -> str: + """Get the audio tag file path""" + config_tag_file = self.config.get("tag_file") + if config_tag_file: + return config_tag_file + return os.path.join(self.get_promos_dir(), "NWKORVTAG.wav") + + def get_browser_download_dir(self) -> str: + """Get the dedicated browser download directory""" + return self.config.get("browser_download_dir", BROWSER_DOWNLOAD_DIR) + + def clear_browser_download_dir(self): + """Clear the browser download directory before starting downloads""" + download_dir = Path(self.get_browser_download_dir()) + download_dir.mkdir(parents=True, exist_ok=True) + + for f in download_dir.iterdir(): + try: + if f.is_file(): + f.unlink() + elif f.is_dir(): + import shutil + shutil.rmtree(f) + except Exception as e: + logger.warning(f"Could not delete {f}: {e}") + + def get_browser_download_files(self) -> set: + """Get the set of files currently in browser download directory""" + download_dir = Path(self.get_browser_download_dir()) + files = set() + if download_dir.exists(): + for f in download_dir.iterdir(): + if f.is_file(): + files.add(f.name) + return files diff --git a/audio_downloader/constants.py b/audio_downloader/constants.py new file mode 100644 index 0000000..52dcb4b --- /dev/null +++ b/audio_downloader/constants.py @@ -0,0 +1,10 @@ +""" +Shared constants for download handling +""" + +ALLOWED_EXTENSIONS = {'.zip', '.mp3', '.wav', '.pdf', '.rar', '.7z', '.tar', '.gz'} + +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/audio_downloader/download_utils.py b/audio_downloader/download_utils.py new file mode 100644 index 0000000..45dd235 --- /dev/null +++ b/audio_downloader/download_utils.py @@ -0,0 +1,209 @@ +# [file name]: download_utils.py +""" +Download utilities for monitoring and file handling +""" + +import os +import sys +import time +import subprocess +import logging +import psutil +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +class DownloadUtilities: + """Utility functions for download monitoring""" + + @staticmethod + def get_file_hash(file_path: str, block_size: int = 65536) -> str: + """Generate MD5 hash of a file""" + import hashlib + hasher = hashlib.md5() + try: + with open(file_path, 'rb') as f: + for block in iter(lambda: f.read(block_size), b''): + hasher.update(block) + return hasher.hexdigest() + except Exception as e: + logger.debug(f"Error hashing file {file_path}: {e}") + return "" + + @staticmethod + def is_file_locked(filepath: str) -> bool: + """Check if a file is locked/opened by another process""" + try: + # Try to open the file in exclusive mode + with open(filepath, 'rb'): + return False + except IOError: + return True + except Exception: + return False + + @staticmethod + def get_file_handle_count(filepath: str) -> int: + """Get the number of open handles to a file""" + try: + filepath = os.path.abspath(filepath) + count = 0 + for proc in psutil.process_iter(['pid', 'name']): + try: + for item in proc.open_files(): + if item.path == filepath: + count += 1 + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + return count + except Exception as e: + logger.debug(f"Error checking file handles for {filepath}: {e}") + return 0 + + @staticmethod + def find_latest_file(download_dir: str, extension: str = None, wait_time: int = 2): + """Find the most recently downloaded file after waiting""" + time.sleep(wait_time) + download_path = Path(download_dir).resolve() + + if not download_path.exists(): + return None + + all_files = [] + for file_path in download_path.iterdir(): + if file_path.is_file(): + if extension is None or str(file_path).endswith(extension): + if any(str(file_path).endswith(ext) for ext in ['.part', '.crdownload', '.tmp', '.download']): + continue + try: + if file_path.stat().st_size > 0: + all_files.append(str(file_path)) + except OSError: + continue + + if not all_files: + return None + + try: + newest_file = max(all_files, key=lambda f: Path(f).stat().st_mtime) + + size1 = Path(newest_file).stat().st_size + time.sleep(0.5) + size2 = Path(newest_file).stat().st_size + + if size1 == size2 and size1 > 0: + return newest_file + else: + time.sleep(1) + return newest_file + + except Exception as e: + logger.error(f"Error finding latest file: {e}") + return None + + @staticmethod + def _get_audio_duration(file_path: str) -> Optional[float]: + """Get audio duration in seconds using ffprobe.""" + try: + result = subprocess.run( + [ + 'ffprobe', '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + file_path, + ], + capture_output=True, + text=True, + timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0, + ) + if result.returncode == 0 and result.stdout.strip(): + return float(result.stdout.strip()) + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): + pass + return None + + @staticmethod + def overlay_promo_with_tag(promo_file: str, tag_file: str, output_file: str, + overlap_seconds: int = 10) -> bool: + """ + Keep the full promo except the last N seconds, then append the last + N seconds mixed with the tag file so both play simultaneously. + + Args: + promo_file: Path to the promo MP3 file + tag_file: Path to the WAV tag file + output_file: Path for the output MP3 file + overlap_seconds: How many seconds to overlap (default: 10) + + Returns: + True if successful, False otherwise + """ + logger.info(f"Processing promo: {promo_file} with tag overlay") + + if not Path(promo_file).exists(): + logger.error(f"Promo file not found: {promo_file}") + return False + + if not Path(tag_file).exists(): + logger.error(f"Tag file not found: {tag_file}") + return False + + promo_duration = DownloadUtilities._get_audio_duration(promo_file) + if promo_duration is None or promo_duration <= overlap_seconds: + logger.warning( + f"Could not determine promo duration ({promo_duration}s), " + f"skipping tag overlay" + ) + return False + + pre_duration = promo_duration - overlap_seconds + filter_complex = ( + f'[0:a]atrim=0:{pre_duration},asetpts=PTS-STARTPTS[pre];' + f'[0:a]atrim=start={pre_duration},asetpts=PTS-STARTPTS[ending];' + f'[ending][1:a]amix=inputs=2:duration=first:normalize=0[mixed];' + f'[pre][mixed]concat=n=2:v=0:a=1[out]' + ) + + cmd = [ + 'ffmpeg', '-y', + '-i', promo_file, + '-i', tag_file, + '-filter_complex', filter_complex, + '-map', '[out]', + '-codec:a', 'libmp3lame', + '-q:a', '2', + output_file, + ] + + create_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0 + + try: + logger.info(f"Running FFmpeg: {' '.join(cmd)}") + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + creationflags=create_flags, + ) + + if result.returncode == 0 and Path(output_file).exists(): + logger.info(f"Promo with tag saved to {output_file}") + return True + else: + logger.error(f"FFmpeg returncode: {result.returncode}") + if result.stderr: + logger.error(f"FFmpeg stderr: {result.stderr[:1000]}") + return False + + except subprocess.TimeoutExpired: + logger.error("FFmpeg timed out") + return False + except FileNotFoundError: + logger.error("FFmpeg not found. Please install FFmpeg.") + return False + except Exception as e: + logger.error(f"Error creating promo with tag: {e}") + return False diff --git a/audio_downloader/gui.py b/audio_downloader/gui.py new file mode 100644 index 0000000..e974e07 --- /dev/null +++ b/audio_downloader/gui.py @@ -0,0 +1,502 @@ +""" +GUI application for Audio Download Manager +""" + +import tkinter as tk +from tkinter import ttk, messagebox, scrolledtext, filedialog +import threading +import time +import logging +from datetime import datetime + +try: + from config import ConfigManager, DOWNLOAD_SOURCES + from browser_manager import BrowserManager + from sources import create_downloader +except ImportError as e: + print(f"Import error in gui.py: {e}") + raise + +logger = logging.getLogger(__name__) + +COLORS = { + 'bg': '#181818', + 'surface': '#242424', + 'button': '#2e2e2e', + 'button_hover': '#3c3c3c', + 'button_active': '#4a4a4a', + 'light_text': '#f0f0f0', + 'dim_text': '#a0a0a0', + '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: + """Main GUI application""" + + def __init__(self): + self.root = tk.Tk() + self.root.title("Audio Download Manager") + self.root.geometry("600x750") + + self.config_manager = ConfigManager() + self.browser_manager = BrowserManager(self.config_manager) + + self._download_lock = threading.Lock() + + self.status_var = tk.StringVar(value="Ready to download") + self.progress_bar = None + self.log_text = None + + self.setup_gui() + self.apply_dark_theme() + + self.root.protocol("WM_DELETE_WINDOW", self.on_closing) + + def setup_gui(self): + """Setup the GUI interface""" + 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( + header_frame, + text="Audio Download Manager", + font=("Arial", 16, "bold"), + anchor='center' + ) + 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 GLOBAL FEATURES", + command=self.run_all_downloads, + width=30 + ) + 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:", + font=("Arial", 11, "bold") + ) + sources_label.pack(pady=(0, 5)) + + sources_frame = ttk.Frame(main_frame) + sources_frame.pack(fill=tk.X, pady=(0, 15)) + + sources = list(DOWNLOAD_SOURCES.keys()) + for i, source_name in enumerate(sources): + btn = ttk.Button( + sources_frame, + text=f"{source_name}", + command=self.create_download_handler(source_name), + width=35 + ) + btn.pack(pady=3) + + progress_frame = ttk.Frame(main_frame) + progress_frame.pack(fill=tk.X, pady=(0, 5)) + + self.progress_bar = ttk.Progressbar( + progress_frame, + orient='horizontal', + mode='determinate', + length=550, + maximum=100 + ) + self.progress_bar.pack(fill=tk.X) + + status_label = ttk.Label( + main_frame, + textvariable=self.status_var, + wraplength=550 + ) + status_label.pack(pady=(5, 10)) + + log_frame = ttk.LabelFrame(main_frame, text="Download Log", padding="10") + log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5)) + + self.log_text = scrolledtext.ScrolledText( + log_frame, + height=10, + width=70, + 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 apply_dark_theme(self): + """Apply dark theme using ttk.Style""" + style = ttk.Style() + 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""" + timestamp = datetime.now().strftime('%H:%M:%S') + log_entry = f"{timestamp} - {message}\n" + + def update_log(): + self.log_text.config(state=tk.NORMAL) + self.log_text.insert(tk.END, log_entry) + self.log_text.config(state=tk.DISABLED) + self.log_text.see(tk.END) + + self.root.after(0, update_log) + logger.info(message) + + def update_progress(self, value: float, status_text: str = ""): + """Update progress bar and status from any thread""" + def update_ui(): + self.progress_bar['value'] = value + if status_text: + self.status_var.set(status_text) + self.root.update_idletasks() + + self.root.after(0, update_ui) + + def create_download_handler(self, source_name: str): + """Create a handler for download buttons""" + def handler(): + self.progress_bar['value'] = 0 + self.status_var.set(f"Starting {source_name}...") + self.log_message(f"Starting {source_name} download...") + + def download_thread(): + try: + success = self.download_with_retry(source_name) + if success: + self.status_var.set(f"Done - {source_name} completed!") + self.log_message(f"Done - {source_name} completed successfully") + else: + 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"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)) + + threading.Thread(target=download_thread, daemon=True).start() + + return handler + + 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) + + if not self._download_lock.acquire(blocking=False): + self.log_message(f"Another download in progress, skipping {source_name}") + 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""" + def download_all_thread(): + sources = list(DOWNLOAD_SOURCES.keys()) + success_count = 0 + failed_downloads = [] + + for i, source_name in enumerate(sources): + progress = (i / len(sources)) * 100 + self.update_progress( + progress, + f"Downloading all - Starting: {source_name} ({i+1}/{len(sources)})" + ) + + success = self.download_with_retry(source_name) + if success: + success_count += 1 + else: + failed_downloads.append(source_name) + + self.update_progress(100, "All downloads completed!") + self.show_summary_popup(success_count, len(sources), failed_downloads) + + 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) + summary_window.title("Download Summary") + 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 = "Warning:" + title = "Downloads Partially Completed" + else: + message = f"All {total_count} downloads completed successfully!" + icon = "Success" + title = "Downloads Completed" + + ttk.Label(summary_window, text=icon, font=("Arial", 24)).pack(pady=10) + ttk.Label(summary_window, text=title, font=("Arial", 12, "bold")).pack(pady=5) + 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_settings(self): + """Show settings window""" + settings_window = tk.Toplevel(self.root) + settings_window.title("Settings") + 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) + + general_frame = ttk.Frame(notebook, padding="15") + notebook.add(general_frame, text="General") + + paths_frame = ttk.Frame(notebook, padding="15") + notebook.add(paths_frame, text="Paths") + + auth_frame = ttk.Frame(notebook, padding="15") + notebook.add(auth_frame, text="Auth") + + 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")) + ttk.Entry(general_frame, textvariable=output_dir_var, width=30).grid(row=1, column=1, sticky=tk.W, pady=10) + ttk.Button(general_frame, text="Browse", command=lambda: output_dir_var.set(filedialog.askdirectory() or output_dir_var.get())).grid(row=1, column=2, padx=5, pady=10) + + auto_close_var = tk.BooleanVar(value=self.config_manager.get("auto_close_browser", True)) + ttk.Checkbutton( + general_frame, + text="Auto-close browser after downloads", + variable=auto_close_var + ).grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=5) + + ttk.Label(general_frame, text="Retry Attempts:").grid(row=3, column=0, sticky=tk.W, pady=10) + 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="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="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()) + self.config_manager.set("auto_close_browser", auto_close_var.get()) + 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() + + btn_frame = ttk.Frame(settings_window) + btn_frame.pack(pady=15) + ttk.Button(btn_frame, text="Save", command=save_settings).pack(side=tk.LEFT, padx=10) + ttk.Button(btn_frame, text="Cancel", command=settings_window.destroy).pack(side=tk.LEFT) + + def on_closing(self): + """Handle application closing""" + self.browser_manager.close_browser() + self.root.destroy() + + def run(self): + """Start the GUI application""" + self.log_message("Application started - Browser will open when downloads begin") + self.root.mainloop() diff --git a/audio_downloader/main.py b/audio_downloader/main.py new file mode 100644 index 0000000..251bdda --- /dev/null +++ b/audio_downloader/main.py @@ -0,0 +1,248 @@ +""" +Audio Download Manager - Main Entry Point +Downloads shows for a radio station + +Usage: + python main.py - Run GUI + python main.py --download-all - Run downloads in CLI mode (no GUI) + python main.py --source "Melinda Myers" - Download from specific source +""" + +import os +import sys +import logging +import argparse + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def setup_logging(log_to_file=True): + """Configure logging for the application""" + logger = logging.getLogger("audio_downloader") + logger.setLevel(logging.DEBUG) + + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + if log_to_file: + file_handler = logging.FileHandler("audio_downloader.log") + file_handler.setLevel(logging.DEBUG) + + for module in ['sources', 'sources.base', 'browser_manager', 'download_utils']: + mod_logger = logging.getLogger(module) + mod_logger.setLevel(logging.DEBUG) + mod_logger.addHandler(file_handler) + + file_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + file_handler.setFormatter(file_format) + logger.addHandler(file_handler) + + console_format = logging.Formatter('%(levelname)s - %(message)s') + console_handler.setFormatter(console_format) + logger.addHandler(console_handler) + + 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 + from browser_manager import BrowserManager + from sources import create_downloader + + logger = logging.getLogger("audio_downloader") + logger.info("=" * 50) + logger.info("AUDIO DOWNLOAD MANAGER - CLI MODE") + logger.info("=" * 50) + + config = ConfigManager() + logger.info(f"Output directory: {config.get_output_base_dir()}") + logger.info(f"Browser download dir: {config.get_browser_download_dir()}") + logger.info("") + + config.ensure_folders() + config.clear_browser_download_dir() + logger.info("Cleared browser download directory") + logger.info("") + + results = {} + + browser_manager = BrowserManager(config) + + try: + for source_name in DOWNLOAD_SOURCES.keys(): + logger.info("") + logger.info(f"--- Downloading from: {source_name} ---") + + config.clear_browser_download_dir() + + downloader = create_downloader(source_name, browser_manager, config) + + try: + success = downloader.download() + results[source_name] = success + + if success: + logger.info(f"✓ {source_name}: SUCCESS") + else: + logger.error(f"✗ {source_name}: FAILED") + except Exception as e: + logger.error(f"✗ {source_name}: ERROR - {e}") + results[source_name] = False + finally: + browser_manager.close_browser() + + logger.info("") + logger.info("=" * 50) + logger.info("DOWNLOAD SUMMARY") + logger.info("=" * 50) + + success_count = sum(1 for v in results.values() if v) + total_count = len(results) + + for source_name, success in results.items(): + status = "✓ SUCCESS" if success else "✗ FAILED" + logger.info(f" {source_name}: {status}") + + logger.info("") + logger.info(f"Total: {success_count}/{total_count} successful") + + _touch_output_dir(config) + return all(results.values()) + +def run_promo_download(): + """Download just the Northwest Outdoors promo file""" + from config import ConfigManager + from browser_manager import BrowserManager + from sources import create_downloader + + logger = logging.getLogger("audio_downloader") + logger.info("=" * 50) + logger.info("DOWNLOADING PROMO") + logger.info("=" * 50) + + config = ConfigManager() + browser_manager = BrowserManager(config) + downloader = create_downloader("Download Promo", browser_manager, config) + + try: + success = downloader.download() + if success: + logger.info("PROMO: SUCCESS") + else: + logger.error("PROMO: FAILED") + return success + except Exception as e: + logger.error(f"PROMO: ERROR - {e}") + return False + finally: + browser_manager.close_browser() + +def run_single_source(source_name): + """Download from a single source""" + from config import ConfigManager, DOWNLOAD_SOURCES + from browser_manager import BrowserManager + from sources import create_downloader + + logger = logging.getLogger("audio_downloader") + + if source_name not in DOWNLOAD_SOURCES: + logger.error(f"Unknown source: {source_name}") + logger.info(f"Available sources: {list(DOWNLOAD_SOURCES.keys())}") + return False + + logger.info("=" * 50) + logger.info(f"DOWNLOADING: {source_name}") + logger.info("=" * 50) + + config = ConfigManager() + browser_manager = BrowserManager(config) + downloader = create_downloader(source_name, browser_manager, config) + + try: + success = downloader.download() + if success: + logger.info(f"✓ {source_name}: SUCCESS") + else: + logger.error(f"✗ {source_name}: FAILED") + return success + except Exception as e: + logger.error(f"✗ {source_name}: ERROR - {e}") + return False + finally: + browser_manager.close_browser() + _touch_output_dir(config) + +def main(): + """Main entry point for the application""" + parser = argparse.ArgumentParser( + description="Audio Download Manager", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python main.py Run GUI mode + python main.py --download-all Download from all sources + python main.py --source "Melinda Myers" Download from specific source + """ + ) + + parser.add_argument( + '--download-all', + action='store_true', + help='Run downloads for all sources in CLI mode (no GUI)' + ) + + parser.add_argument( + '--source', + type=str, + help='Download from a specific source (e.g., "Melinda Myers")' + ) + + parser.add_argument( + '--download-promo', + action='store_true', + help='Download the Northwest Outdoors promo only' + ) + + args = parser.parse_args() + + if args.download_promo: + setup_logging() + success = run_promo_download() + sys.exit(0 if success else 1) + + elif args.download_all: + setup_logging() + logger = logging.getLogger("audio_downloader") + success = run_cli_downloads() + sys.exit(0 if success else 1) + + elif args.source: + setup_logging() + success = run_single_source(args.source) + sys.exit(0 if success else 1) + + else: + setup_logging(log_to_file=True) + logger = logging.getLogger("audio_downloader") + + try: + from gui import AudioDownloaderGUI + + logger.info("Starting Audio Download Manager (GUI Mode)") + app = AudioDownloaderGUI() + app.run() + except Exception as e: + logger.error(f"Application error: {e}", exc_info=True) + print(f"Fatal error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/audio_downloader/sources/__init__.py b/audio_downloader/sources/__init__.py new file mode 100644 index 0000000..3ea1317 --- /dev/null +++ b/audio_downloader/sources/__init__.py @@ -0,0 +1,36 @@ +""" +Sources package initialization +""" + +from .melinda_myers import MelindaMyersDownloader +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, + "Weekend In The Country": WeekendInTheCountryDownloader + } + + downloader_class = downloaders.get(source_name) + if downloader_class: + return downloader_class(browser_manager, config_manager) + + raise ValueError(f"Unknown download source: {source_name}") + +__all__ = [ + 'MelindaMyersDownloader', + 'NorthwestOutdoorsDownloader', + 'NorthwestOutdoorsPromoDownloader', + 'WhittlerDownloader', + 'ClearOutWestDownloader', + 'WeekendInTheCountryDownloader', + 'create_downloader' +] diff --git a/audio_downloader/sources/base.py b/audio_downloader/sources/base.py new file mode 100644 index 0000000..e2bec05 --- /dev/null +++ b/audio_downloader/sources/base.py @@ -0,0 +1,177 @@ +# [file name]: base.py +""" +Base class for download sources +""" + +import logging +import time +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from pathlib import Path + +from selenium.webdriver.common.by import By + +from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES + +logger = logging.getLogger(__name__) + +class BaseDownloader(ABC): + """Base class for all download sources""" + + def __init__(self, browser_manager, config_manager): + self.browser_manager = browser_manager + self.config_manager = config_manager + + @abstractmethod + def download(self, update_callback=None) -> bool: + """Download from this source""" + pass + + def handle_dropbox_popup(self, driver): + """Handle Dropbox sign-in popup if it appears""" + try: + time.sleep(1) + + popup_selectors = [ + "//a[contains(text(), 'Continue')]", + "//button[contains(text(), 'Continue')]", + "//a[contains(text(), 'Sign in')]", + "//button[contains(text(), 'Download')]", + ] + + for selector in popup_selectors: + try: + elements = driver.find_elements(By.XPATH, selector) + for elem in elements: + if elem.is_displayed(): + logger.info(f"Clicking popup button: {selector}") + driver.execute_script("arguments[0].click();", elem) + time.sleep(2) + return + except Exception: + continue + + except Exception as e: + logger.debug(f"No popup to handle: {e}") + + def find_coming_weekday(self, weekday: int) -> str: + """Find the date string for the coming weekday""" + today = datetime.now().date() + days_until_weekday = (weekday - today.weekday() + 7) % 7 + + if days_until_weekday == 0 and today.weekday() == 1: + days_until_weekday = 7 + + coming_weekday = today + timedelta(days=days_until_weekday) + return coming_weekday.strftime('%m%d%y') + + def get_download_dir(self) -> str: + """Get the dedicated browser download directory""" + download_dir = self.config_manager.get_browser_download_dir() + logger.info(f"Download directory: {download_dir}") + return download_dir + + def should_auto_close_browser(self) -> bool: + """Check if browser should auto-close""" + return self.config_manager.get("auto_close_browser", True) + + def wait_for_download_and_get_file(self, timeout: int = 30): + """ + Wait for download to complete and return the file path. + Uses browser-based detection combined with filesystem monitoring. + Only accepts audio/document/archive files. + """ + import time + download_dir = Path(self.get_download_dir()) + logger.info(f"=== WAIT FOR DOWNLOAD START ===") + logger.info(f"Download directory: {download_dir}") + logger.info(f"Timeout: {timeout}s") + + + + start_time = time.time() + known_files = {} + for f in download_dir.iterdir(): + if f.is_file(): + try: + known_files[f.name] = f.stat().st_size + except Exception: + known_files[f.name] = 0 + logger.info(f"Initial files in directory ({len(known_files)}): {list(known_files.keys())}") + + from download_utils import DownloadUtilities + + iteration = 0 + while time.time() - start_time < timeout: + iteration += 1 + elapsed = time.time() - start_time + + if iteration % 5 == 0: + all_files = [f for f in download_dir.iterdir() if f.is_file()] + logger.info(f"[{elapsed:.1f}s] Directory contents: {[f.name for f in all_files]}") + + browser_result = self.browser_manager.wait_for_browser_download_complete( + timeout=1, + poll_interval=0.3 + ) + if browser_result: + logger.info(f"Browser confirmed download: {Path(browser_result).name}") + logger.info(f"=== WAIT FOR DOWNLOAD END (SUCCESS) ===") + return browser_result + + for f in download_dir.iterdir(): + if f.is_file(): + try: + has_excluded = any(f.name.endswith(ext) for ext in EXCLUDED_EXTENSIONS) + has_excluded_prefix = any(f.name.startswith(prefix) for prefix in EXCLUDED_PREFIXES) + if has_excluded or has_excluded_prefix: + continue + + current_size = f.stat().st_size + prev_size = known_files.get(f.name, 0) + + if f.name not in known_files: + has_allowed = any(f.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS) + if has_allowed: + logger.info(f"[{elapsed:.1f}s] NEW DOWNLOAD: {f.name} ({current_size} bytes)") + + if current_size > 0: + time.sleep(1) + try: + new_size = f.stat().st_size + if new_size == current_size: + known_files[f.name] = current_size + logger.info(f"[{elapsed:.1f}s] FILE STABLE: {f.name} ({current_size} bytes) - ACCEPTING") + logger.info(f"=== WAIT FOR DOWNLOAD END (NEW FILE) ===") + return str(f) + else: + logger.info(f"[{elapsed:.1f}s] FILE STILL GROWING: {f.name} ({current_size} -> {new_size})") + known_files[f.name] = new_size + except Exception: + pass + elif current_size != prev_size and prev_size > 0: + if any(f.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS): + logger.info(f"[{elapsed:.1f}s] FILE GROWING: {f.name} ({prev_size} -> {current_size} bytes)") + known_files[f.name] = current_size + except Exception as e: + logger.debug(f"Error checking file {f.name}: {e}") + + time.sleep(0.3) + + logger.warning(f"=== DOWNLOAD TIMEOUT after {timeout}s ===") + all_files = [f for f in download_dir.iterdir() if f.is_file()] + logger.info(f"Final directory contents: {[f.name for f in all_files]}") + + fallback = DownloadUtilities.find_latest_file(str(download_dir), wait_time=1) + if fallback: + result_path = Path(fallback) + has_excluded = any(result_path.name.endswith(ext) for ext in EXCLUDED_EXTENSIONS) + has_allowed = any(result_path.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS) + if has_excluded or not has_allowed: + logger.info(f"Fallback file not a valid download, ignoring: {result_path.name}") + fallback = None + else: + logger.info(f"Fallback found: {result_path.name}") + + logger.info(f"=== WAIT FOR DOWNLOAD END {'(' + Path(fallback).name + ')' if fallback else '(FAILED)'} ===") + return fallback diff --git a/audio_downloader/sources/clear_out_west.py b/audio_downloader/sources/clear_out_west.py new file mode 100644 index 0000000..1ae68d8 --- /dev/null +++ b/audio_downloader/sources/clear_out_west.py @@ -0,0 +1,240 @@ +""" +Clear Out West download source +""" + +import time +import re +import logging +import shutil +from pathlib import Path + +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import TimeoutException + +from .base import BaseDownloader + +logger = logging.getLogger(__name__) + +class ClearOutWestDownloader(BaseDownloader): + """Download Clear Out West files""" + + def download(self, update_callback=None) -> bool: + logger.info("=== STARTING CLEAR OUT WEST DOWNLOAD ===") + + if not self.browser_manager.start_browser(): + logger.error("Failed to start browser") + return False + + try: + driver = self.browser_manager.get_driver() + if not driver: + logger.error("Failed to get driver") + return False + + password = self.config_manager.get("cow_password") + if not password: + logger.error("cow_password not configured in download_config.json") + if update_callback: + update_callback(100, "Error: cow_password not configured") + return False + + if update_callback: + update_callback(5, "Accessing website...") + + logger.info("Navigating to Clear Out West...") + driver.get("https://www.clearoutwest.com/download-radio-stations-only.html") + + logger.info("Waiting for page to load...") + time.sleep(5) + + WebDriverWait(driver, 30).until( + EC.presence_of_element_located((By.TAG_NAME, "body")) + ) + time.sleep(2) + + if update_callback: + update_callback(15, "Checking for login...") + + if self._handle_login(driver, password): + logger.info("Logged in successfully") + time.sleep(3) + if update_callback: + update_callback(30, "Logged in, finding downloads...") + else: + logger.info("No login required") + if update_callback: + update_callback(30, "No login needed, finding downloads...") + + time.sleep(2) + + if update_callback: + update_callback(40, "Looking for download link...") + + logger.info("Looking for all download links...") + all_hrefs = [] + page_url = driver.current_url + + download_selectors = [ + "//a[contains(@href, '.mp3')]", + "//a[contains(@href, '.zip')]", + ] + + for selector in download_selectors: + try: + links = driver.find_elements(By.XPATH, selector) + for link in links: + if link.is_displayed() and link.is_enabled(): + href = link.get_attribute('href') + if href and (href.endswith('.mp3') or href.endswith('.zip')): + logger.info(f"Found download link: {href[:50]}...") + if href not in all_hrefs: + all_hrefs.append(href) + except Exception as e: + logger.debug(f"Selector {selector} failed: {e}") + continue + + if not all_hrefs: + logger.error("No download links found") + if update_callback: + update_callback(100, "Download failed - no links found") + return False + + logger.info(f"Found {len(all_hrefs)} download links") + + output_dir = Path(self.config_manager.get_global_features_dir()) + output_dir.mkdir(parents=True, exist_ok=True) + + for index, href in enumerate(all_hrefs, start=1): + if update_callback: + update_callback(20 * index, f"Downloading {index}/{len(all_hrefs)}...") + + logger.info(f"Downloading file {index}/{len(all_hrefs)}...") + time.sleep(2) + + filename = href.split('/')[-1] + + WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.TAG_NAME, "body")) + ) + time.sleep(1) + + link_xpath = f"//a[contains(@href, '{filename}')]" + WebDriverWait(driver, 10).until( + EC.element_to_be_clickable((By.XPATH, link_xpath)) + ) + link = driver.find_element(By.XPATH, link_xpath) + driver.execute_script("arguments[0].click();", link) + logger.info(f"Clicked link {index}") + + downloaded_file = self.wait_for_download_and_get_file(timeout=90) + + if not downloaded_file: + logger.error(f"Download failed for file {index}") + if update_callback: + update_callback(100, f"Download failed - file {index}") + return False + + logger.info(f"Download detected: {downloaded_file}") + + ext = Path(downloaded_file).suffix + match = re.search(r'track(\d+)', filename) + if match: + track_num = match.group(1).lstrip('0') or '0' + if track_num == '5': + new_name = f"COWPROMO{ext}" + else: + new_name = f"COW{track_num}{ext}" + else: + new_name = f"COW{index}{ext}" + output_path = output_dir / new_name + + if Path(downloaded_file).resolve() != output_path.resolve(): + shutil.move(downloaded_file, output_path) + logger.info(f"Moved and renamed to {new_name}") + + time.sleep(2) + + if index < len(all_hrefs): + logger.info("Navigating back to download page...") + driver.get(page_url) + time.sleep(3) + + if update_callback: + update_callback(100, "Complete") + + logger.info("=== CLEAR OUT WEST DOWNLOAD COMPLETE ===") + + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + + return True + + except Exception as e: + logger.error(f"Error in Clear Out West download: {e}") + import traceback + traceback.print_exc() + + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + return False + + def _handle_login(self, driver, password: str) -> bool: + """Handle login if required. Returns True if logged in.""" + logger.info("Checking for login form...") + + login_selectors = [ + "//input[@type='password']", + "//form[contains(@action, 'login')]", + "//input[contains(@name, 'password')]", + ] + + for selector in login_selectors: + try: + password_fields = driver.find_elements(By.XPATH, selector) + if password_fields: + logger.info("Login page detected, entering password...") + + password_field = driver.find_element(By.XPATH, selector) + password_field.clear() + password_field.send_keys(password) + + time.sleep(1) + + submit_button = None + submit_selectors = [ + "//button[@type='submit']", + "//input[@type='submit']", + "//button[contains(text(), 'Submit')]", + "//button[contains(text(), 'Download')]", + "//form//button", + ] + + for btn_selector in submit_selectors: + buttons = driver.find_elements(By.XPATH, btn_selector) + for btn in buttons: + if btn.is_displayed() and btn.is_enabled(): + submit_button = btn + break + if submit_button: + break + + if submit_button: + logger.info("Submitting login...") + driver.execute_script("arguments[0].click();", submit_button) + time.sleep(3) + logger.info("Login submitted") + return True + else: + logger.info("Submitting form via enter key...") + driver.find_element(By.TAG_NAME, "form").submit() + time.sleep(3) + return True + + except Exception as e: + logger.debug(f"Login selector {selector} not found: {e}") + continue + + logger.info("No login form found") + return False diff --git a/audio_downloader/sources/melinda_myers.py b/audio_downloader/sources/melinda_myers.py new file mode 100644 index 0000000..5dc3282 --- /dev/null +++ b/audio_downloader/sources/melinda_myers.py @@ -0,0 +1,113 @@ +""" +Melinda Myers download source +""" + +import time +import logging +import shutil +from pathlib import Path + +from selenium.webdriver.common.by import By + +from .base import BaseDownloader + +logger = logging.getLogger(__name__) + +class MelindaMyersDownloader(BaseDownloader): + """Download Melinda Myers audio files""" + + def download(self, update_callback=None) -> bool: + logger.info("Starting Melinda Myers download") + + if not self.browser_manager.start_browser(): + return False + + try: + output_dir = Path(self.config_manager.get_global_features_dir()) + output_dir.mkdir(parents=True, exist_ok=True) + + driver = self.browser_manager.get_driver() + if not driver: + return False + + for i, day_name in [(0, "Monday"), (2, "Wednesday"), (4, "Friday")]: + if update_callback: + update_callback(0, f"Downloading {day_name}...") + + driver.get("https://www.melindamyers.com/media/") + time.sleep(2) + + try: + link_element = driver.find_element(By.PARTIAL_LINK_TEXT, "Audio_Tips_3x") + link_element.click() + time.sleep(2) + + weekday = self.find_coming_weekday(i) + download_link = driver.find_element(By.PARTIAL_LINK_TEXT, weekday) + download_link.click() + + logger.info(f"Initiated download for {day_name}") + + downloaded_file = self.wait_for_download_and_get_file(timeout=15) + + if downloaded_file: + day_map = {0: "MMMON.mp3", 2: "MMWED.mp3", 4: "MMFRI.mp3"} + new_name = day_map.get(i) + if new_name: + new_path = output_dir / new_name + if Path(downloaded_file).resolve() != new_path.resolve(): + shutil.move(downloaded_file, new_path) + logger.info(f"Saved {day_name} as {new_name}") + else: + logger.warning(f"No file downloaded for {day_name}") + + except Exception as e: + logger.error(f"Error downloading {day_name}: {e}") + continue + + for i, day_name in [(1, "Tuesday"), (3, "Thursday")]: + if update_callback: + update_callback(0, f"Downloading {day_name}...") + + driver.get("https://www.melindamyers.com/media/") + time.sleep(2) + + try: + link_element = driver.find_element(By.PARTIAL_LINK_TEXT, "Audio_Tips_5x") + link_element.click() + time.sleep(2) + + weekday = self.find_coming_weekday(i) + download_link = driver.find_element(By.PARTIAL_LINK_TEXT, weekday) + download_link.click() + + logger.info(f"Initiated download for {day_name}") + + downloaded_file = self.wait_for_download_and_get_file(timeout=60) + + if downloaded_file: + day_map = {1: "MMTUE.mp3", 3: "MMTHU.mp3"} + new_name = day_map.get(i) + if new_name: + new_path = output_dir / new_name + if Path(downloaded_file).resolve() != new_path.resolve(): + shutil.move(downloaded_file, new_path) + logger.info(f"Saved {day_name} as {new_name}") + else: + logger.warning(f"No file downloaded for {day_name}") + + except Exception as e: + logger.error(f"Error downloading {day_name}: {e}") + continue + + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + + logger.info("Melinda Myers download completed") + return True + + except Exception as e: + logger.error(f"Error in Melinda Myers download: {e}") + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + return False diff --git a/audio_downloader/sources/northwest_outdoors.py b/audio_downloader/sources/northwest_outdoors.py new file mode 100644 index 0000000..f528078 --- /dev/null +++ b/audio_downloader/sources/northwest_outdoors.py @@ -0,0 +1,246 @@ +""" +Northwest Outdoors download source +""" + +import os +import re +import zipfile +import time +import logging +import shutil +import tempfile +from pathlib import Path + +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +from .base import BaseDownloader + +logger = logging.getLogger(__name__) + + +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 ===") + + 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: + global_features_dir = Path(self.config_manager.get_global_features_dir()) + global_features_dir.mkdir(parents=True, exist_ok=True) + + found_files = False + nwo_date_re = re.compile(r'^NWoutdoors\d{6}\.mp3$', re.IGNORECASE) + for extracted_file in temp_dir.iterdir(): + if not extracted_file.is_file(): + continue + + if 'promo' in extracted_file.name.lower(): + logger.info(f"Skipping promo file: {extracted_file.name}") + continue + + if nwo_date_re.match(extracted_file.name): + logger.info(f"Skipping date-stamped file: {extracted_file.name}") + continue + + found_files = True + if update_callback: + update_callback(90, f"Moving {extracted_file.name}...") + + 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(100, "Complete") + + logger.info("=== NORTHWEST OUTDOORS DOWNLOAD COMPLETE ===") + + if not found_files: + logger.warning("No non-promo files found in download") + return False + + 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() + + promos_dir.mkdir(parents=True, exist_ok=True) + + found_promo = False + for extracted_file in temp_dir.iterdir(): + if not extracted_file.is_file(): + continue + + 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("Tag overlay failed, saving promo without tag") + shutil.copy(extracted_file, output_file) + else: + 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 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 promo download: {e}") + import traceback + traceback.print_exc() + return False + finally: + if self.should_auto_close_browser(): + self.browser_manager.close_browser() diff --git a/audio_downloader/sources/weekend_in_the_country.py b/audio_downloader/sources/weekend_in_the_country.py new file mode 100644 index 0000000..18d273e --- /dev/null +++ b/audio_downloader/sources/weekend_in_the_country.py @@ -0,0 +1,187 @@ +""" +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}") + + spots_dir = Path(self.config_manager.get_spots_dir()) + spots_dir.mkdir(parents=True, exist_ok=True) + + for f, date_str in promos: + date_tag = f"_{date_str}" if date_str else "" + new_name = spots_dir / f"WITC_PROMO{date_tag}.mp3" + try: + f.rename(new_name) + logger.info(f"Moved promo: {f.name} -> {new_name}") + except OSError as e: + logger.warning(f"Failed to move promo {f.name}: {e}") + + def _find_mp3_files(self, ftp, path=""): + """Recursively find all MP3 files on the FTP server""" + 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/audio_downloader/sources/whittler.py b/audio_downloader/sources/whittler.py new file mode 100644 index 0000000..2c388d7 --- /dev/null +++ b/audio_downloader/sources/whittler.py @@ -0,0 +1,160 @@ +""" +Whittler download source +""" + +import os + +import zipfile +import time +import logging +import shutil +import tempfile +from pathlib import Path + +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +from .base import BaseDownloader + +logger = logging.getLogger(__name__) + +class WhittlerDownloader(BaseDownloader): + """Download Whittler files""" + + def download(self, update_callback=None) -> bool: + logger.info("=== STARTING WHITTLER DOWNLOAD ===") + + if not self.browser_manager.start_browser(): + logger.error("Failed to start 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...") + url = self.config_manager.get("urls", {}).get("whittler") + if not url or "YOUR_LINK" in url: + logger.error("whittler URL not configured in download_config.json") + if update_callback: + update_callback(100, "Error: whittler 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 Exception: + logger.info(f"XPath not found: {xpath}") + + if confirm_button: + time.sleep(1) + logger.info("Clicking confirm button...") + confirm_button.click() + logger.info("Confirm button clicked") + time.sleep(2) + else: + logger.warning("No confirm button found") + time.sleep(3) + + logger.info("Polling for download file...") + if update_callback: + update_callback(40, "Waiting for download...") + + downloaded_file = self.wait_for_download_and_get_file(timeout=300) + + if not downloaded_file: + logger.error("No downloaded file found after waiting") + if update_callback: + update_callback(100, "Download failed - no file found") + return False + + logger.info(f"Download detected: {downloaded_file}") + + if update_callback: + update_callback(60, "Extracting files...") + + logger.info("Extracting files...") + temp_dir = Path(tempfile.gettempdir()) / "whittler_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") + + if update_callback: + update_callback(80, "Moving files to GLOBAL FEATURES...") + + output_dir = Path(self.config_manager.get_global_features_dir()) + output_dir.mkdir(parents=True, exist_ok=True) + + part_mapping = { + "Part A": "Whittler1", + "Part B": "Whittler2", + "Part C": "Whittler3", + "Part D": "Whittler4" + } + + logger.info("Renaming and copying files...") + for old_part, new_name in part_mapping.items(): + pattern = temp_dir / f"*{old_part}*.mp3" + files = list(temp_dir.glob(f"*{old_part}*.mp3")) + + for file_path in files: + new_filename = f"{new_name}.mp3" + new_path = output_dir / new_filename + 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: + update_callback(100, "Complete") + + logger.info("=== WHITTLER DOWNLOAD COMPLETE ===") + + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + + return True + + except Exception as e: + logger.error(f"Error in Whittler download: {e}") + import traceback + traceback.print_exc() + if self.should_auto_close_browser(): + self.browser_manager.close_browser() + return False From d57b53b99341fecc8f9664d1d89297002a05076f Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:14:14 -0700 Subject: [PATCH 02/11] refactor: remove source files from root (moved to audio_downloader/) --- __init__.py | 6 - browser_manager.py | 195 ------------ config.py | 217 ------------- constants.py | 10 - download_utils.py | 209 ------------- gui.py | 502 ------------------------------ main.py | 209 ------------- sources/__init__.py | 36 --- sources/base.py | 177 ----------- sources/clear_out_west.py | 240 -------------- sources/melinda_myers.py | 113 ------- sources/northwest_outdoors.py | 240 -------------- sources/weekend_in_the_country.py | 184 ----------- sources/whittler.py | 160 ---------- 14 files changed, 2498 deletions(-) delete mode 100644 __init__.py delete mode 100644 browser_manager.py delete mode 100644 config.py delete mode 100644 constants.py delete mode 100644 download_utils.py delete mode 100644 gui.py delete mode 100644 main.py delete mode 100644 sources/__init__.py delete mode 100644 sources/base.py delete mode 100644 sources/clear_out_west.py delete mode 100644 sources/melinda_myers.py delete mode 100644 sources/northwest_outdoors.py delete mode 100644 sources/weekend_in_the_country.py delete mode 100644 sources/whittler.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 026254f..0000000 --- a/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Audio Download Manager Package -""" - -__version__ = "1.1.9" -__author__ = "Bryan Ward" \ No newline at end of file diff --git a/browser_manager.py b/browser_manager.py deleted file mode 100644 index e043789..0000000 --- a/browser_manager.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Browser management for Selenium operations -""" - -import logging -from typing import Optional, List, Dict -from pathlib import Path - -from selenium import webdriver -from selenium.webdriver.firefox.service import Service -from selenium.webdriver.firefox.options import Options -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from selenium.common.exceptions import TimeoutException -from webdriver_manager.firefox import GeckoDriverManager - -from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES - -logger = logging.getLogger(__name__) - -class BrowserManager: - """Manages browser lifecycle and operations""" - - def __init__(self, config_manager): - self.config_manager = config_manager - self.driver: Optional[webdriver.Firefox] = None - 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 _create_browser_options(self) -> Options: - """Create and configure browser options""" - options = Options() - download_dir = self._get_temp_download_dir() - - options.set_preference("browser.download.folderList", 2) - options.set_preference("browser.download.dir", str(Path(download_dir).resolve())) - options.set_preference("browser.download.manager.showWhenStarting", False) - options.set_preference("browser.helperApps.neverAsk.saveToDisk", - "application/zip, audio/mpeg, application/octet-stream") - options.set_preference("media.play-stand-alone", False) - options.set_preference("pdfjs.disabled", True) - - options.set_preference("browser.download.manager.useWindow", False) - options.set_preference("browser.download.manager.focusWhenStarting", False) - options.set_preference("browser.download.manager.showAlertOnComplete", False) - options.set_preference("browser.download.manager.closeWhenDone", False) - - return options - - def start_browser(self) -> bool: - """Start the browser if not already running""" - if self.driver is not None: - logger.info("Browser already running") - return True - - try: - options = self._create_browser_options() - service = Service(GeckoDriverManager().install()) - self.driver = webdriver.Firefox(service=service, options=options) - - # Maximize window to avoid element obscuring - self.driver.maximize_window() - - logger.info("Browser started successfully") - return True - except Exception as e: - logger.error(f"Error starting browser: {e}") - return False - - def close_browser(self): - """Close the browser if it's open""" - if self.driver is not None: - try: - self.driver.quit() - self.driver = None - logger.info("Browser closed") - except Exception as e: - logger.error(f"Error closing browser: {e}") - - def get_driver(self) -> Optional[webdriver.Firefox]: - """Get the browser driver, starting it if necessary""" - if self.driver is None: - if not self.start_browser(): - return None - return self.driver - - def is_browser_open(self) -> bool: - """Check if browser is currently open""" - return self.driver is not None - - def get_browser_downloads(self, timeout: int = 5) -> List[Dict]: - """ - Get the list of downloads from Firefox's about:downloads page. - Returns list of dicts with: name, path, state, size - """ - if not self.driver: - logger.debug("No driver, returning empty downloads") - return [] - - try: - self.driver.get("about:downloads") - - wait = WebDriverWait(self.driver, timeout) - wait.until(EC.presence_of_element_located(("css selector", "#downloadsList"))) - - download_items = self.driver.execute_script(""" - const list = document.getElementById('downloadsList'); - if (!list) return []; - - const items = list.querySelectorAll('richlistitem'); - const downloads = []; - - items.forEach(item => { - const nameEl = item.querySelector('.downloadTarget'); - const stateEl = item.querySelector('.downloadState'); - const progressEl = item.querySelector('.downloadProgress'); - const fileSizeEl = item.querySelector('.downloadSize'); - - if (nameEl) { - downloads.push({ - name: nameEl.textContent.trim(), - state: stateEl ? stateEl.textContent.trim() : 'unknown', - progress: progressEl ? progressEl.value : 100, - size: fileSizeEl ? fileSizeEl.textContent.trim() : 'unknown' - }); - } - }); - - return downloads; - """) - - if download_items: - logger.info(f"Browser downloads found: {len(download_items)} items") - for item in download_items: - logger.info(f" - {item.get('name', '?')}: state={item.get('state', '?')}, progress={item.get('progress', '?')}%") - else: - logger.debug("No downloads in browser") - - return download_items - - except TimeoutException: - logger.debug("Timeout waiting for about:downloads page") - return [] - except Exception as e: - logger.debug(f"Error getting browser downloads: {e}") - return [] - - def wait_for_browser_download_complete(self, timeout: int = 60, poll_interval: float = 1.0) -> str: - """ - Wait for Firefox to report a download as complete. - Returns the file path of the completed download, or None if timeout. - Only accepts audio/document archive files. - """ - import time - download_dir = Path(self._get_temp_download_dir()) - - start_time = time.time() - checked_files = set() - - while time.time() - start_time < timeout: - downloads = self.get_browser_downloads(timeout=3) - - for dl in downloads: - name = dl.get('name', '') - state = dl.get('state', '').lower() - progress = dl.get('progress', 0) - - if not name: - continue - - file_path = download_dir / name - - if file_path.exists(): - try: - size = file_path.stat().st_size - if size > 0: - if progress >= 100 or 'complete' in state or 'finished' in state or state == '': - if name not in checked_files: - checked_files.add(name) - logger.info(f"Browser confirmed download complete: {name} ({size} bytes)") - return str(file_path) - else: - logger.debug(f"Download in progress: {name} ({progress}%, {size} bytes)") - except OSError: - continue - - time.sleep(poll_interval) - - 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 deleted file mode 100644 index ace860c..0000000 --- a/config.py +++ /dev/null @@ -1,217 +0,0 @@ -""" -Configuration management for Audio Download Manager -""" - -import os -import sys -import json -import logging -from pathlib import Path -from typing import Dict, Any, List - -logger = logging.getLogger(__name__) - -# Detect if running as frozen executable (PyInstaller) or Python script -if getattr(sys, 'frozen', False): - # Running as compiled executable - APP_DIR = Path(sys.executable).parent -else: - # Running as Python script - APP_DIR = Path(__file__).parent - -CONFIG_FILE = str(APP_DIR / "download_config.json") - -def get_default_browser_download_dir() -> str: - """Get platform-appropriate browser download directory""" - project_root = Path(__file__).parent - if sys.platform == "win32": - return str(project_root / "browser_downloads") - else: - return str(project_root / "browser_downloads") - -BROWSER_DOWNLOAD_DIR = get_default_browser_download_dir() - -DEFAULT_CONFIG = { - "output_dir": "downloads", - "tag_file": "", - "browser_download_dir": BROWSER_DOWNLOAD_DIR, - "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" - } -} - -DOWNLOAD_SOURCES = { - "Melinda Myers": "melinda_myers", - "Northwest Outdoors": "northwest_outdoors", - "Whittler": "whittler", - "Clear Out West": "clear_out_west", - "Weekend In The Country": "weekend_in_the_country" -} - -class ConfigManager: - """Manages application configuration""" - - def __init__(self): - self.config = self.load_config() - - @staticmethod - def load_config() -> Dict[str, Any]: - """Load configuration from file or return defaults""" - logger.info(f"Loading config from: {CONFIG_FILE}") - try: - if os.path.exists(CONFIG_FILE): - logger.info("Config file exists, loading...") - with open(CONFIG_FILE, 'r') as f: - saved_config = json.load(f) - logger.info(f"Saved config URLs: {saved_config.get('urls', {})}") - merged_config = DEFAULT_CONFIG.copy() - merged_config.update(saved_config) - logger.info("Configuration loaded successfully") - return merged_config - except Exception as e: - logger.error(f"Error loading config: {e}") - - logger.info("Using default configuration") - default_config = DEFAULT_CONFIG.copy() - try: - with open(CONFIG_FILE, 'w') as f: - json.dump(default_config, f, indent=2) - logger.info("Created default configuration file") - except Exception as e: - logger.error(f"Could not create config file: {e}") - return default_config - - def save_config(self) -> bool: - """Save configuration to file""" - try: - with open(CONFIG_FILE, 'w') as f: - json.dump(self.config, f, indent=2) - logger.info("Configuration saved successfully") - return True - except Exception as e: - logger.error(f"Error saving config: {e}") - return False - - def get_output_base_dir(self) -> str: - """Get base output directory""" - output_dir = self.config.get("output_dir", "downloads") - p = Path(output_dir) - if not p.is_absolute(): - p = Path.cwd() / p - return str(p) - - def _get_subdir(self, relative_path: str) -> str: - """Get a subdirectory under the output directory""" - return os.path.join(self.get_output_base_dir(), relative_path) - - def ensure_folders(self) -> bool: - """Ensure all required output folders exist""" - folders = [ - self.get_output_base_dir(), - self.get_global_features_dir(), - self.get_promos_dir(), - ] - - for folder in folders: - if folder: - try: - Path(folder).mkdir(parents=True, exist_ok=True) - except Exception as e: - logger.error(f"Could not create folder {folder}: {e}") - return False - return True - - def validate_config(self) -> List[str]: - """Validate configuration and return list of errors""" - errors = [] - config = self.config - - if not config.get("cow_password"): - errors.append("COW password is required") - - folders_to_check = [ - ("Base output", self.get_output_base_dir()), - ("Global Features", self.get_global_features_dir()), - ("Promos", self.get_promos_dir()), - ] - - for name, folder in folders_to_check: - if folder: - try: - Path(folder).mkdir(parents=True, exist_ok=True) - except Exception as e: - errors.append(f"Cannot create {name} folder: {e}") - - retry_attempts = config.get("retry_attempts", 2) - if not isinstance(retry_attempts, int) or retry_attempts < 0: - errors.append("Retry attempts must be a positive integer") - - return errors - - def get(self, key: str, default: Any = None) -> Any: - """Get a configuration value""" - return self.config.get(key, default) - - def set(self, key: str, value: Any): - """Set a single configuration value""" - self.config[key] = value - - def save(self) -> bool: - """Save configuration to file (alias for save_config)""" - return self.save_config() - - def update(self, updates: Dict[str, Any]): - """Update configuration with new values""" - self.config.update(updates) - self.save_config() - - def get_global_features_dir(self) -> str: - """Get the Global Features directory under the output dir""" - return self._get_subdir("Global Features") - - def get_promos_dir(self) -> str: - """Get the Promos directory under the output dir""" - return self._get_subdir("Promos") - - def get_tag_file(self) -> str: - """Get the audio tag file path""" - config_tag_file = self.config.get("tag_file") - if config_tag_file: - return config_tag_file - return os.path.join(self.get_promos_dir(), "NWKORVTAG.wav") - - def get_browser_download_dir(self) -> str: - """Get the dedicated browser download directory""" - return self.config.get("browser_download_dir", BROWSER_DOWNLOAD_DIR) - - def clear_browser_download_dir(self): - """Clear the browser download directory before starting downloads""" - download_dir = Path(self.get_browser_download_dir()) - download_dir.mkdir(parents=True, exist_ok=True) - - for f in download_dir.iterdir(): - try: - if f.is_file(): - f.unlink() - elif f.is_dir(): - import shutil - shutil.rmtree(f) - except Exception as e: - logger.warning(f"Could not delete {f}: {e}") - - def get_browser_download_files(self) -> set: - """Get the set of files currently in browser download directory""" - download_dir = Path(self.get_browser_download_dir()) - files = set() - if download_dir.exists(): - for f in download_dir.iterdir(): - if f.is_file(): - files.add(f.name) - return files diff --git a/constants.py b/constants.py deleted file mode 100644 index 52dcb4b..0000000 --- a/constants.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Shared constants for download handling -""" - -ALLOWED_EXTENSIONS = {'.zip', '.mp3', '.wav', '.pdf', '.rar', '.7z', '.tar', '.gz'} - -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_utils.py b/download_utils.py deleted file mode 100644 index 45dd235..0000000 --- a/download_utils.py +++ /dev/null @@ -1,209 +0,0 @@ -# [file name]: download_utils.py -""" -Download utilities for monitoring and file handling -""" - -import os -import sys -import time -import subprocess -import logging -import psutil -from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) - -class DownloadUtilities: - """Utility functions for download monitoring""" - - @staticmethod - def get_file_hash(file_path: str, block_size: int = 65536) -> str: - """Generate MD5 hash of a file""" - import hashlib - hasher = hashlib.md5() - try: - with open(file_path, 'rb') as f: - for block in iter(lambda: f.read(block_size), b''): - hasher.update(block) - return hasher.hexdigest() - except Exception as e: - logger.debug(f"Error hashing file {file_path}: {e}") - return "" - - @staticmethod - def is_file_locked(filepath: str) -> bool: - """Check if a file is locked/opened by another process""" - try: - # Try to open the file in exclusive mode - with open(filepath, 'rb'): - return False - except IOError: - return True - except Exception: - return False - - @staticmethod - def get_file_handle_count(filepath: str) -> int: - """Get the number of open handles to a file""" - try: - filepath = os.path.abspath(filepath) - count = 0 - for proc in psutil.process_iter(['pid', 'name']): - try: - for item in proc.open_files(): - if item.path == filepath: - count += 1 - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - return count - except Exception as e: - logger.debug(f"Error checking file handles for {filepath}: {e}") - return 0 - - @staticmethod - def find_latest_file(download_dir: str, extension: str = None, wait_time: int = 2): - """Find the most recently downloaded file after waiting""" - time.sleep(wait_time) - download_path = Path(download_dir).resolve() - - if not download_path.exists(): - return None - - all_files = [] - for file_path in download_path.iterdir(): - if file_path.is_file(): - if extension is None or str(file_path).endswith(extension): - if any(str(file_path).endswith(ext) for ext in ['.part', '.crdownload', '.tmp', '.download']): - continue - try: - if file_path.stat().st_size > 0: - all_files.append(str(file_path)) - except OSError: - continue - - if not all_files: - return None - - try: - newest_file = max(all_files, key=lambda f: Path(f).stat().st_mtime) - - size1 = Path(newest_file).stat().st_size - time.sleep(0.5) - size2 = Path(newest_file).stat().st_size - - if size1 == size2 and size1 > 0: - return newest_file - else: - time.sleep(1) - return newest_file - - except Exception as e: - logger.error(f"Error finding latest file: {e}") - return None - - @staticmethod - def _get_audio_duration(file_path: str) -> Optional[float]: - """Get audio duration in seconds using ffprobe.""" - try: - result = subprocess.run( - [ - 'ffprobe', '-v', 'error', - '-show_entries', 'format=duration', - '-of', 'default=noprint_wrappers=1:nokey=1', - file_path, - ], - capture_output=True, - text=True, - timeout=10, - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0, - ) - if result.returncode == 0 and result.stdout.strip(): - return float(result.stdout.strip()) - except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): - pass - return None - - @staticmethod - def overlay_promo_with_tag(promo_file: str, tag_file: str, output_file: str, - overlap_seconds: int = 10) -> bool: - """ - Keep the full promo except the last N seconds, then append the last - N seconds mixed with the tag file so both play simultaneously. - - Args: - promo_file: Path to the promo MP3 file - tag_file: Path to the WAV tag file - output_file: Path for the output MP3 file - overlap_seconds: How many seconds to overlap (default: 10) - - Returns: - True if successful, False otherwise - """ - logger.info(f"Processing promo: {promo_file} with tag overlay") - - if not Path(promo_file).exists(): - logger.error(f"Promo file not found: {promo_file}") - return False - - if not Path(tag_file).exists(): - logger.error(f"Tag file not found: {tag_file}") - return False - - promo_duration = DownloadUtilities._get_audio_duration(promo_file) - if promo_duration is None or promo_duration <= overlap_seconds: - logger.warning( - f"Could not determine promo duration ({promo_duration}s), " - f"skipping tag overlay" - ) - return False - - pre_duration = promo_duration - overlap_seconds - filter_complex = ( - f'[0:a]atrim=0:{pre_duration},asetpts=PTS-STARTPTS[pre];' - f'[0:a]atrim=start={pre_duration},asetpts=PTS-STARTPTS[ending];' - f'[ending][1:a]amix=inputs=2:duration=first:normalize=0[mixed];' - f'[pre][mixed]concat=n=2:v=0:a=1[out]' - ) - - cmd = [ - 'ffmpeg', '-y', - '-i', promo_file, - '-i', tag_file, - '-filter_complex', filter_complex, - '-map', '[out]', - '-codec:a', 'libmp3lame', - '-q:a', '2', - output_file, - ] - - create_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0 - - try: - logger.info(f"Running FFmpeg: {' '.join(cmd)}") - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=120, - creationflags=create_flags, - ) - - if result.returncode == 0 and Path(output_file).exists(): - logger.info(f"Promo with tag saved to {output_file}") - return True - else: - logger.error(f"FFmpeg returncode: {result.returncode}") - if result.stderr: - logger.error(f"FFmpeg stderr: {result.stderr[:1000]}") - return False - - except subprocess.TimeoutExpired: - logger.error("FFmpeg timed out") - return False - except FileNotFoundError: - logger.error("FFmpeg not found. Please install FFmpeg.") - return False - except Exception as e: - logger.error(f"Error creating promo with tag: {e}") - return False diff --git a/gui.py b/gui.py deleted file mode 100644 index b24fa36..0000000 --- a/gui.py +++ /dev/null @@ -1,502 +0,0 @@ -""" -GUI application for Audio Download Manager -""" - -import tkinter as tk -from tkinter import ttk, messagebox, scrolledtext, filedialog -import threading -import time -import logging -from datetime import datetime - -try: - from config import ConfigManager, DOWNLOAD_SOURCES - from browser_manager import BrowserManager - from sources import create_downloader -except ImportError as e: - print(f"Import error in gui.py: {e}") - raise - -logger = logging.getLogger(__name__) - -COLORS = { - 'bg': '#181818', - 'surface': '#242424', - 'button': '#2e2e2e', - 'button_hover': '#3c3c3c', - 'button_active': '#4a4a4a', - 'light_text': '#f0f0f0', - 'dim_text': '#a0a0a0', - '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: - """Main GUI application""" - - def __init__(self): - self.root = tk.Tk() - self.root.title("Audio Download Manager") - self.root.geometry("600x750") - - self.config_manager = ConfigManager() - self.browser_manager = BrowserManager(self.config_manager) - - self._download_lock = threading.Lock() - - self.status_var = tk.StringVar(value="Ready to download") - self.progress_bar = None - self.log_text = None - - self.setup_gui() - self.apply_dark_theme() - - self.root.protocol("WM_DELETE_WINDOW", self.on_closing) - - def setup_gui(self): - """Setup the GUI interface""" - 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( - header_frame, - text="Audio Download Manager", - font=("Arial", 16, "bold"), - anchor='center' - ) - 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 Global Features", - command=self.run_all_downloads, - width=30 - ) - 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:", - font=("Arial", 11, "bold") - ) - sources_label.pack(pady=(0, 5)) - - sources_frame = ttk.Frame(main_frame) - sources_frame.pack(fill=tk.X, pady=(0, 15)) - - sources = list(DOWNLOAD_SOURCES.keys()) - for i, source_name in enumerate(sources): - btn = ttk.Button( - sources_frame, - text=f"{source_name}", - command=self.create_download_handler(source_name), - width=35 - ) - btn.pack(pady=3) - - progress_frame = ttk.Frame(main_frame) - progress_frame.pack(fill=tk.X, pady=(0, 5)) - - self.progress_bar = ttk.Progressbar( - progress_frame, - orient='horizontal', - mode='determinate', - length=550, - maximum=100 - ) - self.progress_bar.pack(fill=tk.X) - - status_label = ttk.Label( - main_frame, - textvariable=self.status_var, - wraplength=550 - ) - status_label.pack(pady=(5, 10)) - - log_frame = ttk.LabelFrame(main_frame, text="Download Log", padding="10") - log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5)) - - self.log_text = scrolledtext.ScrolledText( - log_frame, - height=10, - width=70, - 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 apply_dark_theme(self): - """Apply dark theme using ttk.Style""" - style = ttk.Style() - 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""" - timestamp = datetime.now().strftime('%H:%M:%S') - log_entry = f"{timestamp} - {message}\n" - - def update_log(): - self.log_text.config(state=tk.NORMAL) - self.log_text.insert(tk.END, log_entry) - self.log_text.config(state=tk.DISABLED) - self.log_text.see(tk.END) - - self.root.after(0, update_log) - logger.info(message) - - def update_progress(self, value: float, status_text: str = ""): - """Update progress bar and status from any thread""" - def update_ui(): - self.progress_bar['value'] = value - if status_text: - self.status_var.set(status_text) - self.root.update_idletasks() - - self.root.after(0, update_ui) - - def create_download_handler(self, source_name: str): - """Create a handler for download buttons""" - def handler(): - self.progress_bar['value'] = 0 - self.status_var.set(f"Starting {source_name}...") - self.log_message(f"Starting {source_name} download...") - - def download_thread(): - try: - success = self.download_with_retry(source_name) - if success: - self.status_var.set(f"Done - {source_name} completed!") - self.log_message(f"Done - {source_name} completed successfully") - else: - 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"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)) - - threading.Thread(target=download_thread, daemon=True).start() - - return handler - - 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) - - if not self._download_lock.acquire(blocking=False): - self.log_message(f"Another download in progress, skipping {source_name}") - 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""" - def download_all_thread(): - sources = list(DOWNLOAD_SOURCES.keys()) - success_count = 0 - failed_downloads = [] - - for i, source_name in enumerate(sources): - progress = (i / len(sources)) * 100 - self.update_progress( - progress, - f"Downloading all - Starting: {source_name} ({i+1}/{len(sources)})" - ) - - success = self.download_with_retry(source_name) - if success: - success_count += 1 - else: - failed_downloads.append(source_name) - - self.update_progress(100, "All downloads completed!") - self.show_summary_popup(success_count, len(sources), failed_downloads) - - 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) - summary_window.title("Download Summary") - 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 = "Warning:" - title = "Downloads Partially Completed" - else: - message = f"All {total_count} downloads completed successfully!" - icon = "Success" - title = "Downloads Completed" - - ttk.Label(summary_window, text=icon, font=("Arial", 24)).pack(pady=10) - ttk.Label(summary_window, text=title, font=("Arial", 12, "bold")).pack(pady=5) - 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_settings(self): - """Show settings window""" - settings_window = tk.Toplevel(self.root) - settings_window.title("Settings") - 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) - - general_frame = ttk.Frame(notebook, padding="15") - notebook.add(general_frame, text="General") - - paths_frame = ttk.Frame(notebook, padding="15") - notebook.add(paths_frame, text="Paths") - - auth_frame = ttk.Frame(notebook, padding="15") - notebook.add(auth_frame, text="Auth") - - 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")) - ttk.Entry(general_frame, textvariable=output_dir_var, width=30).grid(row=1, column=1, sticky=tk.W, pady=10) - ttk.Button(general_frame, text="Browse", command=lambda: output_dir_var.set(filedialog.askdirectory() or output_dir_var.get())).grid(row=1, column=2, padx=5, pady=10) - - auto_close_var = tk.BooleanVar(value=self.config_manager.get("auto_close_browser", True)) - ttk.Checkbutton( - general_frame, - text="Auto-close browser after downloads", - variable=auto_close_var - ).grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=5) - - ttk.Label(general_frame, text="Retry Attempts:").grid(row=3, column=0, sticky=tk.W, pady=10) - 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="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="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()) - self.config_manager.set("auto_close_browser", auto_close_var.get()) - 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() - - btn_frame = ttk.Frame(settings_window) - btn_frame.pack(pady=15) - ttk.Button(btn_frame, text="Save", command=save_settings).pack(side=tk.LEFT, padx=10) - ttk.Button(btn_frame, text="Cancel", command=settings_window.destroy).pack(side=tk.LEFT) - - def on_closing(self): - """Handle application closing""" - self.browser_manager.close_browser() - self.root.destroy() - - def run(self): - """Start the GUI application""" - self.log_message("Application started - Browser will open when downloads begin") - self.root.mainloop() diff --git a/main.py b/main.py deleted file mode 100644 index 5c8c536..0000000 --- a/main.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Audio Download Manager - Main Entry Point -Downloads shows for a radio station - -Usage: - python main.py - Run GUI - python main.py --download-all - Run downloads in CLI mode (no GUI) - python main.py --source "Melinda Myers" - Download from specific source -""" - -import os -import sys -import logging -import argparse - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -def setup_logging(log_to_file=True): - """Configure logging for the application""" - logger = logging.getLogger("audio_downloader") - logger.setLevel(logging.DEBUG) - - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - - if log_to_file: - file_handler = logging.FileHandler("audio_downloader.log") - file_handler.setLevel(logging.DEBUG) - - for module in ['sources', 'sources.base', 'browser_manager', 'download_utils']: - mod_logger = logging.getLogger(module) - mod_logger.setLevel(logging.DEBUG) - mod_logger.addHandler(file_handler) - - file_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - file_handler.setFormatter(file_format) - logger.addHandler(file_handler) - - console_format = logging.Formatter('%(levelname)s - %(message)s') - console_handler.setFormatter(console_format) - logger.addHandler(console_handler) - - 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 - from browser_manager import BrowserManager - from sources import create_downloader - - logger = logging.getLogger("audio_downloader") - logger.info("=" * 50) - logger.info("AUDIO DOWNLOAD MANAGER - CLI MODE") - logger.info("=" * 50) - - config = ConfigManager() - logger.info(f"Output directory: {config.get_output_base_dir()}") - logger.info(f"Browser download dir: {config.get_browser_download_dir()}") - logger.info("") - - config.ensure_folders() - config.clear_browser_download_dir() - logger.info("Cleared browser download directory") - logger.info("") - - results = {} - - browser_manager = BrowserManager(config) - - try: - for source_name in DOWNLOAD_SOURCES.keys(): - logger.info("") - logger.info(f"--- Downloading from: {source_name} ---") - - config.clear_browser_download_dir() - - downloader = create_downloader(source_name, browser_manager, config) - - try: - success = downloader.download() - results[source_name] = success - - if success: - logger.info(f"✓ {source_name}: SUCCESS") - else: - logger.error(f"✗ {source_name}: FAILED") - except Exception as e: - logger.error(f"✗ {source_name}: ERROR - {e}") - results[source_name] = False - finally: - browser_manager.close_browser() - - logger.info("") - logger.info("=" * 50) - logger.info("DOWNLOAD SUMMARY") - logger.info("=" * 50) - - success_count = sum(1 for v in results.values() if v) - total_count = len(results) - - for source_name, success in results.items(): - status = "✓ SUCCESS" if success else "✗ FAILED" - logger.info(f" {source_name}: {status}") - - 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): - """Download from a single source""" - from config import ConfigManager, DOWNLOAD_SOURCES - from browser_manager import BrowserManager - from sources import create_downloader - - logger = logging.getLogger("audio_downloader") - - if source_name not in DOWNLOAD_SOURCES: - logger.error(f"Unknown source: {source_name}") - logger.info(f"Available sources: {list(DOWNLOAD_SOURCES.keys())}") - return False - - logger.info("=" * 50) - logger.info(f"DOWNLOADING: {source_name}") - logger.info("=" * 50) - - config = ConfigManager() - browser_manager = BrowserManager(config) - downloader = create_downloader(source_name, browser_manager, config) - - try: - success = downloader.download() - if success: - logger.info(f"✓ {source_name}: SUCCESS") - else: - logger.error(f"✗ {source_name}: FAILED") - return success - except Exception as e: - logger.error(f"✗ {source_name}: ERROR - {e}") - return False - finally: - browser_manager.close_browser() - _touch_output_dir(config) - -def main(): - """Main entry point for the application""" - parser = argparse.ArgumentParser( - description="Audio Download Manager", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python main.py Run GUI mode - python main.py --download-all Download from all sources - python main.py --source "Melinda Myers" Download from specific source - """ - ) - - parser.add_argument( - '--download-all', - action='store_true', - help='Run downloads for all sources in CLI mode (no GUI)' - ) - - parser.add_argument( - '--source', - type=str, - help='Download from a specific source (e.g., "Melinda Myers")' - ) - - args = parser.parse_args() - - if args.download_all: - setup_logging() - logger = logging.getLogger("audio_downloader") - success = run_cli_downloads() - sys.exit(0 if success else 1) - - elif args.source: - setup_logging() - success = run_single_source(args.source) - sys.exit(0 if success else 1) - - else: - setup_logging(log_to_file=True) - logger = logging.getLogger("audio_downloader") - - try: - from gui import AudioDownloaderGUI - - logger.info("Starting Audio Download Manager (GUI Mode)") - app = AudioDownloaderGUI() - app.run() - except Exception as e: - logger.error(f"Application error: {e}", exc_info=True) - print(f"Fatal error: {e}") - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/sources/__init__.py b/sources/__init__.py deleted file mode 100644 index 3ea1317..0000000 --- a/sources/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Sources package initialization -""" - -from .melinda_myers import MelindaMyersDownloader -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, - "Weekend In The Country": WeekendInTheCountryDownloader - } - - downloader_class = downloaders.get(source_name) - if downloader_class: - return downloader_class(browser_manager, config_manager) - - raise ValueError(f"Unknown download source: {source_name}") - -__all__ = [ - 'MelindaMyersDownloader', - 'NorthwestOutdoorsDownloader', - 'NorthwestOutdoorsPromoDownloader', - 'WhittlerDownloader', - 'ClearOutWestDownloader', - 'WeekendInTheCountryDownloader', - 'create_downloader' -] diff --git a/sources/base.py b/sources/base.py deleted file mode 100644 index e2bec05..0000000 --- a/sources/base.py +++ /dev/null @@ -1,177 +0,0 @@ -# [file name]: base.py -""" -Base class for download sources -""" - -import logging -import time -from abc import ABC, abstractmethod -from datetime import datetime, timedelta -from pathlib import Path - -from selenium.webdriver.common.by import By - -from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES - -logger = logging.getLogger(__name__) - -class BaseDownloader(ABC): - """Base class for all download sources""" - - def __init__(self, browser_manager, config_manager): - self.browser_manager = browser_manager - self.config_manager = config_manager - - @abstractmethod - def download(self, update_callback=None) -> bool: - """Download from this source""" - pass - - def handle_dropbox_popup(self, driver): - """Handle Dropbox sign-in popup if it appears""" - try: - time.sleep(1) - - popup_selectors = [ - "//a[contains(text(), 'Continue')]", - "//button[contains(text(), 'Continue')]", - "//a[contains(text(), 'Sign in')]", - "//button[contains(text(), 'Download')]", - ] - - for selector in popup_selectors: - try: - elements = driver.find_elements(By.XPATH, selector) - for elem in elements: - if elem.is_displayed(): - logger.info(f"Clicking popup button: {selector}") - driver.execute_script("arguments[0].click();", elem) - time.sleep(2) - return - except Exception: - continue - - except Exception as e: - logger.debug(f"No popup to handle: {e}") - - def find_coming_weekday(self, weekday: int) -> str: - """Find the date string for the coming weekday""" - today = datetime.now().date() - days_until_weekday = (weekday - today.weekday() + 7) % 7 - - if days_until_weekday == 0 and today.weekday() == 1: - days_until_weekday = 7 - - coming_weekday = today + timedelta(days=days_until_weekday) - return coming_weekday.strftime('%m%d%y') - - def get_download_dir(self) -> str: - """Get the dedicated browser download directory""" - download_dir = self.config_manager.get_browser_download_dir() - logger.info(f"Download directory: {download_dir}") - return download_dir - - def should_auto_close_browser(self) -> bool: - """Check if browser should auto-close""" - return self.config_manager.get("auto_close_browser", True) - - def wait_for_download_and_get_file(self, timeout: int = 30): - """ - Wait for download to complete and return the file path. - Uses browser-based detection combined with filesystem monitoring. - Only accepts audio/document/archive files. - """ - import time - download_dir = Path(self.get_download_dir()) - logger.info(f"=== WAIT FOR DOWNLOAD START ===") - logger.info(f"Download directory: {download_dir}") - logger.info(f"Timeout: {timeout}s") - - - - start_time = time.time() - known_files = {} - for f in download_dir.iterdir(): - if f.is_file(): - try: - known_files[f.name] = f.stat().st_size - except Exception: - known_files[f.name] = 0 - logger.info(f"Initial files in directory ({len(known_files)}): {list(known_files.keys())}") - - from download_utils import DownloadUtilities - - iteration = 0 - while time.time() - start_time < timeout: - iteration += 1 - elapsed = time.time() - start_time - - if iteration % 5 == 0: - all_files = [f for f in download_dir.iterdir() if f.is_file()] - logger.info(f"[{elapsed:.1f}s] Directory contents: {[f.name for f in all_files]}") - - browser_result = self.browser_manager.wait_for_browser_download_complete( - timeout=1, - poll_interval=0.3 - ) - if browser_result: - logger.info(f"Browser confirmed download: {Path(browser_result).name}") - logger.info(f"=== WAIT FOR DOWNLOAD END (SUCCESS) ===") - return browser_result - - for f in download_dir.iterdir(): - if f.is_file(): - try: - has_excluded = any(f.name.endswith(ext) for ext in EXCLUDED_EXTENSIONS) - has_excluded_prefix = any(f.name.startswith(prefix) for prefix in EXCLUDED_PREFIXES) - if has_excluded or has_excluded_prefix: - continue - - current_size = f.stat().st_size - prev_size = known_files.get(f.name, 0) - - if f.name not in known_files: - has_allowed = any(f.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS) - if has_allowed: - logger.info(f"[{elapsed:.1f}s] NEW DOWNLOAD: {f.name} ({current_size} bytes)") - - if current_size > 0: - time.sleep(1) - try: - new_size = f.stat().st_size - if new_size == current_size: - known_files[f.name] = current_size - logger.info(f"[{elapsed:.1f}s] FILE STABLE: {f.name} ({current_size} bytes) - ACCEPTING") - logger.info(f"=== WAIT FOR DOWNLOAD END (NEW FILE) ===") - return str(f) - else: - logger.info(f"[{elapsed:.1f}s] FILE STILL GROWING: {f.name} ({current_size} -> {new_size})") - known_files[f.name] = new_size - except Exception: - pass - elif current_size != prev_size and prev_size > 0: - if any(f.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS): - logger.info(f"[{elapsed:.1f}s] FILE GROWING: {f.name} ({prev_size} -> {current_size} bytes)") - known_files[f.name] = current_size - except Exception as e: - logger.debug(f"Error checking file {f.name}: {e}") - - time.sleep(0.3) - - logger.warning(f"=== DOWNLOAD TIMEOUT after {timeout}s ===") - all_files = [f for f in download_dir.iterdir() if f.is_file()] - logger.info(f"Final directory contents: {[f.name for f in all_files]}") - - fallback = DownloadUtilities.find_latest_file(str(download_dir), wait_time=1) - if fallback: - result_path = Path(fallback) - has_excluded = any(result_path.name.endswith(ext) for ext in EXCLUDED_EXTENSIONS) - has_allowed = any(result_path.name.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS) - if has_excluded or not has_allowed: - logger.info(f"Fallback file not a valid download, ignoring: {result_path.name}") - fallback = None - else: - logger.info(f"Fallback found: {result_path.name}") - - logger.info(f"=== WAIT FOR DOWNLOAD END {'(' + Path(fallback).name + ')' if fallback else '(FAILED)'} ===") - return fallback diff --git a/sources/clear_out_west.py b/sources/clear_out_west.py deleted file mode 100644 index 1ae68d8..0000000 --- a/sources/clear_out_west.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Clear Out West download source -""" - -import time -import re -import logging -import shutil -from pathlib import Path - -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from selenium.common.exceptions import TimeoutException - -from .base import BaseDownloader - -logger = logging.getLogger(__name__) - -class ClearOutWestDownloader(BaseDownloader): - """Download Clear Out West files""" - - def download(self, update_callback=None) -> bool: - logger.info("=== STARTING CLEAR OUT WEST DOWNLOAD ===") - - if not self.browser_manager.start_browser(): - logger.error("Failed to start browser") - return False - - try: - driver = self.browser_manager.get_driver() - if not driver: - logger.error("Failed to get driver") - return False - - password = self.config_manager.get("cow_password") - if not password: - logger.error("cow_password not configured in download_config.json") - if update_callback: - update_callback(100, "Error: cow_password not configured") - return False - - if update_callback: - update_callback(5, "Accessing website...") - - logger.info("Navigating to Clear Out West...") - driver.get("https://www.clearoutwest.com/download-radio-stations-only.html") - - logger.info("Waiting for page to load...") - time.sleep(5) - - WebDriverWait(driver, 30).until( - EC.presence_of_element_located((By.TAG_NAME, "body")) - ) - time.sleep(2) - - if update_callback: - update_callback(15, "Checking for login...") - - if self._handle_login(driver, password): - logger.info("Logged in successfully") - time.sleep(3) - if update_callback: - update_callback(30, "Logged in, finding downloads...") - else: - logger.info("No login required") - if update_callback: - update_callback(30, "No login needed, finding downloads...") - - time.sleep(2) - - if update_callback: - update_callback(40, "Looking for download link...") - - logger.info("Looking for all download links...") - all_hrefs = [] - page_url = driver.current_url - - download_selectors = [ - "//a[contains(@href, '.mp3')]", - "//a[contains(@href, '.zip')]", - ] - - for selector in download_selectors: - try: - links = driver.find_elements(By.XPATH, selector) - for link in links: - if link.is_displayed() and link.is_enabled(): - href = link.get_attribute('href') - if href and (href.endswith('.mp3') or href.endswith('.zip')): - logger.info(f"Found download link: {href[:50]}...") - if href not in all_hrefs: - all_hrefs.append(href) - except Exception as e: - logger.debug(f"Selector {selector} failed: {e}") - continue - - if not all_hrefs: - logger.error("No download links found") - if update_callback: - update_callback(100, "Download failed - no links found") - return False - - logger.info(f"Found {len(all_hrefs)} download links") - - output_dir = Path(self.config_manager.get_global_features_dir()) - output_dir.mkdir(parents=True, exist_ok=True) - - for index, href in enumerate(all_hrefs, start=1): - if update_callback: - update_callback(20 * index, f"Downloading {index}/{len(all_hrefs)}...") - - logger.info(f"Downloading file {index}/{len(all_hrefs)}...") - time.sleep(2) - - filename = href.split('/')[-1] - - WebDriverWait(driver, 10).until( - EC.presence_of_element_located((By.TAG_NAME, "body")) - ) - time.sleep(1) - - link_xpath = f"//a[contains(@href, '{filename}')]" - WebDriverWait(driver, 10).until( - EC.element_to_be_clickable((By.XPATH, link_xpath)) - ) - link = driver.find_element(By.XPATH, link_xpath) - driver.execute_script("arguments[0].click();", link) - logger.info(f"Clicked link {index}") - - downloaded_file = self.wait_for_download_and_get_file(timeout=90) - - if not downloaded_file: - logger.error(f"Download failed for file {index}") - if update_callback: - update_callback(100, f"Download failed - file {index}") - return False - - logger.info(f"Download detected: {downloaded_file}") - - ext = Path(downloaded_file).suffix - match = re.search(r'track(\d+)', filename) - if match: - track_num = match.group(1).lstrip('0') or '0' - if track_num == '5': - new_name = f"COWPROMO{ext}" - else: - new_name = f"COW{track_num}{ext}" - else: - new_name = f"COW{index}{ext}" - output_path = output_dir / new_name - - if Path(downloaded_file).resolve() != output_path.resolve(): - shutil.move(downloaded_file, output_path) - logger.info(f"Moved and renamed to {new_name}") - - time.sleep(2) - - if index < len(all_hrefs): - logger.info("Navigating back to download page...") - driver.get(page_url) - time.sleep(3) - - if update_callback: - update_callback(100, "Complete") - - logger.info("=== CLEAR OUT WEST DOWNLOAD COMPLETE ===") - - if self.should_auto_close_browser(): - self.browser_manager.close_browser() - - return True - - except Exception as e: - logger.error(f"Error in Clear Out West download: {e}") - import traceback - traceback.print_exc() - - if self.should_auto_close_browser(): - self.browser_manager.close_browser() - return False - - def _handle_login(self, driver, password: str) -> bool: - """Handle login if required. Returns True if logged in.""" - logger.info("Checking for login form...") - - login_selectors = [ - "//input[@type='password']", - "//form[contains(@action, 'login')]", - "//input[contains(@name, 'password')]", - ] - - for selector in login_selectors: - try: - password_fields = driver.find_elements(By.XPATH, selector) - if password_fields: - logger.info("Login page detected, entering password...") - - password_field = driver.find_element(By.XPATH, selector) - password_field.clear() - password_field.send_keys(password) - - time.sleep(1) - - submit_button = None - submit_selectors = [ - "//button[@type='submit']", - "//input[@type='submit']", - "//button[contains(text(), 'Submit')]", - "//button[contains(text(), 'Download')]", - "//form//button", - ] - - for btn_selector in submit_selectors: - buttons = driver.find_elements(By.XPATH, btn_selector) - for btn in buttons: - if btn.is_displayed() and btn.is_enabled(): - submit_button = btn - break - if submit_button: - break - - if submit_button: - logger.info("Submitting login...") - driver.execute_script("arguments[0].click();", submit_button) - time.sleep(3) - logger.info("Login submitted") - return True - else: - logger.info("Submitting form via enter key...") - driver.find_element(By.TAG_NAME, "form").submit() - time.sleep(3) - return True - - except Exception as e: - logger.debug(f"Login selector {selector} not found: {e}") - continue - - logger.info("No login form found") - return False diff --git a/sources/melinda_myers.py b/sources/melinda_myers.py deleted file mode 100644 index 5dc3282..0000000 --- a/sources/melinda_myers.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Melinda Myers download source -""" - -import time -import logging -import shutil -from pathlib import Path - -from selenium.webdriver.common.by import By - -from .base import BaseDownloader - -logger = logging.getLogger(__name__) - -class MelindaMyersDownloader(BaseDownloader): - """Download Melinda Myers audio files""" - - def download(self, update_callback=None) -> bool: - logger.info("Starting Melinda Myers download") - - if not self.browser_manager.start_browser(): - return False - - try: - output_dir = Path(self.config_manager.get_global_features_dir()) - output_dir.mkdir(parents=True, exist_ok=True) - - driver = self.browser_manager.get_driver() - if not driver: - return False - - for i, day_name in [(0, "Monday"), (2, "Wednesday"), (4, "Friday")]: - if update_callback: - update_callback(0, f"Downloading {day_name}...") - - driver.get("https://www.melindamyers.com/media/") - time.sleep(2) - - try: - link_element = driver.find_element(By.PARTIAL_LINK_TEXT, "Audio_Tips_3x") - link_element.click() - time.sleep(2) - - weekday = self.find_coming_weekday(i) - download_link = driver.find_element(By.PARTIAL_LINK_TEXT, weekday) - download_link.click() - - logger.info(f"Initiated download for {day_name}") - - downloaded_file = self.wait_for_download_and_get_file(timeout=15) - - if downloaded_file: - day_map = {0: "MMMON.mp3", 2: "MMWED.mp3", 4: "MMFRI.mp3"} - new_name = day_map.get(i) - if new_name: - new_path = output_dir / new_name - if Path(downloaded_file).resolve() != new_path.resolve(): - shutil.move(downloaded_file, new_path) - logger.info(f"Saved {day_name} as {new_name}") - else: - logger.warning(f"No file downloaded for {day_name}") - - except Exception as e: - logger.error(f"Error downloading {day_name}: {e}") - continue - - for i, day_name in [(1, "Tuesday"), (3, "Thursday")]: - if update_callback: - update_callback(0, f"Downloading {day_name}...") - - driver.get("https://www.melindamyers.com/media/") - time.sleep(2) - - try: - link_element = driver.find_element(By.PARTIAL_LINK_TEXT, "Audio_Tips_5x") - link_element.click() - time.sleep(2) - - weekday = self.find_coming_weekday(i) - download_link = driver.find_element(By.PARTIAL_LINK_TEXT, weekday) - download_link.click() - - logger.info(f"Initiated download for {day_name}") - - downloaded_file = self.wait_for_download_and_get_file(timeout=60) - - if downloaded_file: - day_map = {1: "MMTUE.mp3", 3: "MMTHU.mp3"} - new_name = day_map.get(i) - if new_name: - new_path = output_dir / new_name - if Path(downloaded_file).resolve() != new_path.resolve(): - shutil.move(downloaded_file, new_path) - logger.info(f"Saved {day_name} as {new_name}") - else: - logger.warning(f"No file downloaded for {day_name}") - - except Exception as e: - logger.error(f"Error downloading {day_name}: {e}") - continue - - if self.should_auto_close_browser(): - self.browser_manager.close_browser() - - logger.info("Melinda Myers download completed") - return True - - except Exception as e: - logger.error(f"Error in Melinda Myers download: {e}") - if self.should_auto_close_browser(): - self.browser_manager.close_browser() - return False diff --git a/sources/northwest_outdoors.py b/sources/northwest_outdoors.py deleted file mode 100644 index aebd385..0000000 --- a/sources/northwest_outdoors.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Northwest Outdoors download source -""" - -import os -import zipfile -import time -import logging -import shutil -import tempfile -from pathlib import Path - -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC - -from .base import BaseDownloader - -logger = logging.getLogger(__name__) - - -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 ===") - - 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: - 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(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(100, "Complete") - - logger.info("=== NORTHWEST OUTDOORS DOWNLOAD COMPLETE ===") - - if not found_files: - logger.warning("No non-promo files found in download") - return False - - 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() - - promos_dir.mkdir(parents=True, exist_ok=True) - - found_promo = False - for extracted_file in temp_dir.iterdir(): - if not extracted_file.is_file(): - continue - - 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("Tag overlay failed, saving promo without tag") - shutil.copy(extracted_file, output_file) - else: - 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 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 promo download: {e}") - import traceback - traceback.print_exc() - return False - finally: - if self.should_auto_close_browser(): - self.browser_manager.close_browser() diff --git a/sources/weekend_in_the_country.py b/sources/weekend_in_the_country.py deleted file mode 100644 index 87d3db4..0000000 --- a/sources/weekend_in_the_country.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -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 deleted file mode 100644 index c5cdede..0000000 --- a/sources/whittler.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -Whittler download source -""" - -import os - -import zipfile -import time -import logging -import shutil -import tempfile -from pathlib import Path - -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC - -from .base import BaseDownloader - -logger = logging.getLogger(__name__) - -class WhittlerDownloader(BaseDownloader): - """Download Whittler files""" - - def download(self, update_callback=None) -> bool: - logger.info("=== STARTING WHITTLER DOWNLOAD ===") - - if not self.browser_manager.start_browser(): - logger.error("Failed to start 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...") - url = self.config_manager.get("urls", {}).get("whittler") - if not url or "YOUR_LINK" in url: - logger.error("whittler URL not configured in download_config.json") - if update_callback: - update_callback(100, "Error: whittler 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 Exception: - logger.info(f"XPath not found: {xpath}") - - if confirm_button: - time.sleep(1) - logger.info("Clicking confirm button...") - confirm_button.click() - logger.info("Confirm button clicked") - time.sleep(2) - else: - logger.warning("No confirm button found") - time.sleep(3) - - logger.info("Polling for download file...") - if update_callback: - update_callback(40, "Waiting for download...") - - downloaded_file = self.wait_for_download_and_get_file(timeout=300) - - if not downloaded_file: - logger.error("No downloaded file found after waiting") - if update_callback: - update_callback(100, "Download failed - no file found") - return False - - logger.info(f"Download detected: {downloaded_file}") - - if update_callback: - update_callback(60, "Extracting files...") - - logger.info("Extracting files...") - temp_dir = Path(tempfile.gettempdir()) / "whittler_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") - - if update_callback: - update_callback(80, "Moving files to Global Features...") - - output_dir = Path(self.config_manager.get_global_features_dir()) - output_dir.mkdir(parents=True, exist_ok=True) - - part_mapping = { - "Part A": "Whittler1", - "Part B": "Whittler2", - "Part C": "Whittler3", - "Part D": "Whittler4" - } - - logger.info("Renaming and copying files...") - for old_part, new_name in part_mapping.items(): - pattern = temp_dir / f"*{old_part}*.mp3" - files = list(temp_dir.glob(f"*{old_part}*.mp3")) - - for file_path in files: - new_filename = f"{new_name}.mp3" - new_path = output_dir / new_filename - 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: - update_callback(100, "Complete") - - logger.info("=== WHITTLER DOWNLOAD COMPLETE ===") - - if self.should_auto_close_browser(): - self.browser_manager.close_browser() - - return True - - except Exception as e: - logger.error(f"Error in Whittler download: {e}") - import traceback - traceback.print_exc() - if self.should_auto_close_browser(): - self.browser_manager.close_browser() - return False From 087c983a33068fdcd63053ad15018d8b4764c1a1 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:16:20 -0700 Subject: [PATCH 03/11] refactor: move test scripts to tests/ and batch files to scripts/ --- .../download_global_features.bat | 0 download_promos.bat => scripts/download_promos.bat | 0 .../test_detection_standalone.py | 0 test_downloads.py => tests/test_downloads.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename download_global_features.bat => scripts/download_global_features.bat (100%) rename download_promos.bat => scripts/download_promos.bat (100%) rename test_detection_standalone.py => tests/test_detection_standalone.py (100%) rename test_downloads.py => tests/test_downloads.py (100%) diff --git a/download_global_features.bat b/scripts/download_global_features.bat similarity index 100% rename from download_global_features.bat rename to scripts/download_global_features.bat diff --git a/download_promos.bat b/scripts/download_promos.bat similarity index 100% rename from download_promos.bat rename to scripts/download_promos.bat diff --git a/test_detection_standalone.py b/tests/test_detection_standalone.py similarity index 100% rename from test_detection_standalone.py rename to tests/test_detection_standalone.py diff --git a/test_downloads.py b/tests/test_downloads.py similarity index 100% rename from test_downloads.py rename to tests/test_downloads.py From 8c6839b9a89b3e575f06f54147a2ed6d44a3fc0c Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:18:28 -0700 Subject: [PATCH 04/11] refactor: update imports to use audio_downloader package prefix Source files now live inside the audio_downloader/ package, so sibling module imports are updated from bare names to the package-qualified form (e.g. 'from config import' -> 'from audio_downloader.config import'). Also remove the sys.path.insert hack from main.py that is no longer needed now that the code is importable as a package. --- audio_downloader/browser_manager.py | 2 +- audio_downloader/gui.py | 6 +++--- audio_downloader/main.py | 21 +++++++++---------- audio_downloader/sources/base.py | 4 ++-- .../sources/northwest_outdoors.py | 2 +- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/audio_downloader/browser_manager.py b/audio_downloader/browser_manager.py index e043789..c602378 100644 --- a/audio_downloader/browser_manager.py +++ b/audio_downloader/browser_manager.py @@ -14,7 +14,7 @@ from selenium.common.exceptions import TimeoutException from webdriver_manager.firefox import GeckoDriverManager -from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES +from audio_downloader.constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES logger = logging.getLogger(__name__) diff --git a/audio_downloader/gui.py b/audio_downloader/gui.py index e974e07..3dc3aa7 100644 --- a/audio_downloader/gui.py +++ b/audio_downloader/gui.py @@ -10,9 +10,9 @@ from datetime import datetime try: - from config import ConfigManager, DOWNLOAD_SOURCES - from browser_manager import BrowserManager - from sources import create_downloader + from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.sources import create_downloader except ImportError as e: print(f"Import error in gui.py: {e}") raise diff --git a/audio_downloader/main.py b/audio_downloader/main.py index 251bdda..8eefb0e 100644 --- a/audio_downloader/main.py +++ b/audio_downloader/main.py @@ -13,7 +13,6 @@ import logging import argparse -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) def setup_logging(log_to_file=True): """Configure logging for the application""" @@ -53,9 +52,9 @@ def _touch_output_dir(config): def run_cli_downloads(): """Run downloads in CLI mode without GUI""" - from config import ConfigManager, DOWNLOAD_SOURCES - from browser_manager import BrowserManager - from sources import create_downloader + from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.sources import create_downloader logger = logging.getLogger("audio_downloader") logger.info("=" * 50) @@ -119,9 +118,9 @@ def run_cli_downloads(): def run_promo_download(): """Download just the Northwest Outdoors promo file""" - from config import ConfigManager - from browser_manager import BrowserManager - from sources import create_downloader + from audio_downloader.config import ConfigManager + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.sources import create_downloader logger = logging.getLogger("audio_downloader") logger.info("=" * 50) @@ -147,9 +146,9 @@ def run_promo_download(): def run_single_source(source_name): """Download from a single source""" - from config import ConfigManager, DOWNLOAD_SOURCES - from browser_manager import BrowserManager - from sources import create_downloader + from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.sources import create_downloader logger = logging.getLogger("audio_downloader") @@ -234,7 +233,7 @@ def main(): logger = logging.getLogger("audio_downloader") try: - from gui import AudioDownloaderGUI + from audio_downloader.gui import AudioDownloaderGUI logger.info("Starting Audio Download Manager (GUI Mode)") app = AudioDownloaderGUI() diff --git a/audio_downloader/sources/base.py b/audio_downloader/sources/base.py index e2bec05..006ea46 100644 --- a/audio_downloader/sources/base.py +++ b/audio_downloader/sources/base.py @@ -11,7 +11,7 @@ from selenium.webdriver.common.by import By -from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES +from audio_downloader.constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES logger = logging.getLogger(__name__) @@ -99,7 +99,7 @@ def wait_for_download_and_get_file(self, timeout: int = 30): known_files[f.name] = 0 logger.info(f"Initial files in directory ({len(known_files)}): {list(known_files.keys())}") - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities iteration = 0 while time.time() - start_time < timeout: diff --git a/audio_downloader/sources/northwest_outdoors.py b/audio_downloader/sources/northwest_outdoors.py index f528078..57e50a3 100644 --- a/audio_downloader/sources/northwest_outdoors.py +++ b/audio_downloader/sources/northwest_outdoors.py @@ -206,7 +206,7 @@ def download(self, update_callback=None) -> bool: output_file = promos_dir / extracted_file.name if Path(tag_file).exists(): - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities success = DownloadUtilities.overlay_promo_with_tag( str(extracted_file), tag_file, From 29764a5e7bb6af486728d57fb22d939628f87b9b Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:23:12 -0700 Subject: [PATCH 05/11] refactor: update test imports to use audio_downloader package prefix - Update all bare imports (config, sources, browser_manager, download_utils) to use audio_downloader. prefix - Fix tests/__init__.py sys.path to point to project root (parent.parent) - Add sys.path.insert to test_downloads.py for project root discovery - Update mock patch paths in test_integration.py and test_browser_manager.py to use audio_downloader. prefix - Keep sys.path.insert lines in test files for direct execution --- tests/__init__.py | 4 +- tests/test_browser_manager.py | 12 ++-- tests/test_config_edge_cases.py | 6 +- tests/test_detection_standalone.py | 7 ++- tests/test_download_utils.py | 14 ++--- tests/test_downloads.py | 26 ++++---- tests/test_integration.py | 36 +++++------ tests/test_sources.py | 35 ++++++++++- tests/test_weekend_in_the_country.py | 92 +++++++++++++++++++--------- 9 files changed, 152 insertions(+), 80 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index ab7f426..f5221bc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -10,9 +10,9 @@ import tempfile from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent)) +sys.path.insert(0, str(Path(__file__).parent.parent)) -from config import ConfigManager, DEFAULT_CONFIG, CONFIG_FILE +from audio_downloader.config import ConfigManager, DEFAULT_CONFIG, CONFIG_FILE def test_set_method(): """Test that set() method works""" diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index 9bb00db..3f582dc 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -16,7 +16,7 @@ class TestBrowserManager: def test_browser_manager_imports(self): """Test that browser_manager can be imported""" try: - import browser_manager + import audio_downloader.browser_manager as browser_manager assert hasattr(browser_manager, 'BrowserManager'), "Should have BrowserManager class" print(" ✓ browser_manager imports successfully") except ImportError as e: @@ -26,7 +26,7 @@ def test_browser_manager_imports(self): def test_browser_manager_has_required_methods(self): """Test BrowserManager has required methods""" try: - from browser_manager import BrowserManager + from audio_downloader.browser_manager import BrowserManager required_methods = [ 'start_browser', 'close_browser', @@ -71,12 +71,12 @@ def test_webdriver_manager_imports(self): class TestBrowserStartup: """Test browser startup logic""" - @patch('browser_manager.webdriver.Firefox') - @patch('browser_manager.GeckoDriverManager') + @patch('audio_downloader.browser_manager.webdriver.Firefox') + @patch('audio_downloader.browser_manager.GeckoDriverManager') def test_start_firefox_browser(self, mock_driver_manager, mock_firefox): """Test Firefox browser startup""" try: - from browser_manager import BrowserManager + from audio_downloader.browser_manager import BrowserManager mock_driver_manager.return_value.install.return_value = "/path/to/geckodriver" mock_firefox.return_value = Mock() @@ -122,7 +122,7 @@ def test_driver_quit_handles_errors(self): def test_close_browser_method_exists(self): """Test close_browser method exists""" try: - from browser_manager import BrowserManager + from audio_downloader.browser_manager import BrowserManager assert hasattr(BrowserManager, 'close_browser'), "Missing close_browser method" diff --git a/tests/test_config_edge_cases.py b/tests/test_config_edge_cases.py index 1aabafa..3db8fcb 100644 --- a/tests/test_config_edge_cases.py +++ b/tests/test_config_edge_cases.py @@ -10,7 +10,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) -from config import ConfigManager, DEFAULT_CONFIG +from audio_downloader.config import ConfigManager, DEFAULT_CONFIG class TestConfigEdgeCases: """Test configuration edge cases""" @@ -94,11 +94,11 @@ def test_output_dir_affects_paths(self): assert "/tmp/custom_output" in base_dir gf_dir = cm.get_global_features_dir() - assert "Global Features" in gf_dir + assert "GLOBAL FEATURES" in gf_dir assert "/tmp/custom_output" in gf_dir print(f" ✓ Output base: {base_dir}") - print(f" ✓ Global Features: {gf_dir}") + print(f" ✓ GLOBAL FEATURES: {gf_dir}") def test_retry_attempts_validation(self): """Test retry_attempts must be valid integer""" diff --git a/tests/test_detection_standalone.py b/tests/test_detection_standalone.py index ad1e78d..d314419 100644 --- a/tests/test_detection_standalone.py +++ b/tests/test_detection_standalone.py @@ -13,7 +13,7 @@ import logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -from sources.base import BaseDownloader +from audio_downloader.sources.base import BaseDownloader class MockBrowserManager: def __init__(self): @@ -27,11 +27,14 @@ def get(self, key, default=None): return default def get_global_features_dir(self): - return str(Path.home() / "downloads" / "Global Features") + return str(Path.home() / "downloads" / "GLOBAL FEATURES") def get_promos_dir(self): return str(Path.home() / "downloads" / "Promos") + def get_spots_dir(self): + return str(Path.home() / "downloads" / "Spots") + def get_tag_file(self): return str(Path.home() / "downloads" / "Promos" / "tag.wav") diff --git a/tests/test_download_utils.py b/tests/test_download_utils.py index 7cfd2ce..9809063 100644 --- a/tests/test_download_utils.py +++ b/tests/test_download_utils.py @@ -15,7 +15,7 @@ class TestOverlayPromoWithTag: def test_missing_promo_file(self): """Should return False if promo file doesn't exist""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities result = DownloadUtilities.overlay_promo_with_tag( "/nonexistent/promo.mp3", "/nonexistent/tag.wav", "/tmp/output.mp3" ) @@ -24,7 +24,7 @@ def test_missing_promo_file(self): def test_missing_tag_file(self): """Should return False if tag file doesn't exist""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities # Create a fake promo file with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: @@ -42,7 +42,7 @@ def test_missing_tag_file(self): def test_successful_overlay(self): """Should return True when FFmpeg succeeds""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities # Create fake input files with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: @@ -71,7 +71,7 @@ def mock_run(cmd, **kwargs): Path(output_path).write_bytes(b'fake output') return mock_result - with patch('download_utils.subprocess.run', side_effect=mock_run): + with patch('audio_downloader.download_utils.subprocess.run', side_effect=mock_run): result = DownloadUtilities.overlay_promo_with_tag( promo_path, tag_path, output_path, overlap_seconds=10 ) @@ -88,7 +88,7 @@ def mock_run(cmd, **kwargs): def test_ffmpeg_failure(self): """Should return False when FFmpeg fails""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: promo.write(b'fake audio') @@ -108,7 +108,7 @@ def mock_run(cmd, **kwargs): mock_result.stderr = 'Some error' return mock_result - with patch('download_utils.subprocess.run', side_effect=mock_run): + with patch('audio_downloader.download_utils.subprocess.run', side_effect=mock_run): result = DownloadUtilities.overlay_promo_with_tag( promo_path, tag_path, output_path, overlap_seconds=10 ) @@ -125,7 +125,7 @@ def mock_run(cmd, **kwargs): def test_promo_too_short(self): """Should return False if promo is shorter than overlap""" - from download_utils import DownloadUtilities + from audio_downloader.download_utils import DownloadUtilities with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as promo: promo.write(b'fake audio') diff --git a/tests/test_downloads.py b/tests/test_downloads.py index d34a712..c52b6d1 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -3,12 +3,15 @@ """ import os +import sys import time import tempfile import shutil from pathlib import Path import logging +sys.path.insert(0, str(Path(__file__).parent.parent)) + logger = logging.getLogger(__name__) class DownloadSimulator: @@ -66,8 +69,8 @@ def simulate_incremental_download(directory: str, filename: str, total_size_kb: def test_download_detection(): """Test the download detection logic""" - from download_utils import DownloadUtilities - from sources.base import BaseDownloader + from audio_downloader.download_utils import DownloadUtilities + from audio_downloader.sources.base import BaseDownloader print("=" * 50) print("Testing Download Detection") @@ -97,7 +100,7 @@ def test_download_detection(): def test_config_paths(): """Test configuration paths""" - from config import ConfigManager + from audio_downloader.config import ConfigManager print("=" * 50) print("Testing Configuration Paths") @@ -106,14 +109,15 @@ def test_config_paths(): config = ConfigManager() print(f"\nOutput base: {config.get_output_base_dir()}") - print(f"Global Features: {config.get_global_features_dir()}") + print(f"GLOBAL FEATURES: {config.get_global_features_dir()}") print(f"Promos: {config.get_promos_dir()}") + print(f"Spots: {config.get_spots_dir()}") print(f"Tag file: {config.get_tag_file()}") config.ensure_folders() - for folder in ['Global Features', 'Promos']: - path = config.get_global_features_dir().replace('Global Features', folder) + for folder in ['GLOBAL FEATURES', 'Promos', 'Spots']: + path = config.get_global_features_dir().replace('GLOBAL FEATURES', folder) if Path(path).exists(): print(f"✓ {folder} folder exists") else: @@ -124,8 +128,8 @@ def test_config_paths(): def test_browser_manager(): """Test browser manager initialization""" - from browser_manager import BrowserManager - from config import ConfigManager + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.config import ConfigManager print("=" * 50) print("Testing Browser Manager") @@ -143,9 +147,9 @@ def test_browser_manager(): def test_all_sources(): """Test creating all downloader instances""" - from sources import create_downloader - from browser_manager import BrowserManager - from config import ConfigManager, DOWNLOAD_SOURCES + from audio_downloader.sources import create_downloader + from audio_downloader.browser_manager import BrowserManager + from audio_downloader.config import ConfigManager, DOWNLOAD_SOURCES print("=" * 50) print("Testing All Download Sources") diff --git a/tests/test_integration.py b/tests/test_integration.py index e111a72..4829fa7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,7 +15,7 @@ class TestDownloadWorkflow: def test_config_manager_workflow(self): """Test full ConfigManager workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -35,7 +35,7 @@ def test_config_manager_workflow(self): def test_source_initialization(self): """Test that all sources can be initialized""" - from sources import ( + from audio_downloader.sources import ( MelindaMyersDownloader, NorthwestOutdoorsDownloader, NorthwestOutdoorsPromoDownloader, @@ -59,7 +59,7 @@ def test_source_initialization(self): def test_downloader_has_required_methods(self): """Test BaseDownloader has required methods""" - from sources.base import BaseDownloader + from audio_downloader.sources.base import BaseDownloader required_methods = [ 'download', @@ -73,7 +73,7 @@ def test_downloader_has_required_methods(self): def test_promo_tag_workflow(self): """Test promo tag overlay workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -89,14 +89,14 @@ def test_promo_tag_workflow(self): class TestEndToEndScenarios: """Test end-to-end scenarios""" - @patch('browser_manager.BrowserManager.start_browser') + @patch('audio_downloader.browser_manager.BrowserManager.start_browser') def test_northwest_outdoors_workflow(self, mock_start_browser): """Test Northwest Outdoors download workflow""" mock_start_browser.return_value = True - from sources.northwest_outdoors import NorthwestOutdoorsDownloader - from config import ConfigManager - from browser_manager import BrowserManager + from audio_downloader.sources.northwest_outdoors import NorthwestOutdoorsDownloader + from audio_downloader.config import ConfigManager + from audio_downloader.browser_manager import BrowserManager cm = ConfigManager() bm = BrowserManager(cm) @@ -109,14 +109,14 @@ def test_northwest_outdoors_workflow(self, mock_start_browser): assert is_valid, "URL should be valid" print(f" ✓ Northwest Outdoors workflow ready with valid URL") - @patch('browser_manager.BrowserManager.start_browser') + @patch('audio_downloader.browser_manager.BrowserManager.start_browser') def test_whittler_workflow(self, mock_start_browser): """Test Whittler download workflow""" mock_start_browser.return_value = True - from sources.whittler import WhittlerDownloader - from config import ConfigManager - from browser_manager import BrowserManager + from audio_downloader.sources.whittler import WhittlerDownloader + from audio_downloader.config import ConfigManager + from audio_downloader.browser_manager import BrowserManager cm = ConfigManager() bm = BrowserManager(cm) @@ -131,7 +131,7 @@ def test_whittler_workflow(self, mock_start_browser): def test_output_dir_workflow(self): """Test output directory workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -149,7 +149,7 @@ def test_output_dir_workflow(self): def test_validate_config_workflow(self): """Test config validation workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager cm = ConfigManager() @@ -165,7 +165,7 @@ def test_validate_config_workflow(self): def test_browser_download_dir_workflow(self): """Test browser download directory workflow""" - from config import ConfigManager + from audio_downloader.config import ConfigManager from pathlib import Path cm = ConfigManager() @@ -186,7 +186,7 @@ class TestErrorHandling: def test_missing_config_file_creates_default(self): """Test that missing config file creates default""" import tempfile - from config import ConfigManager + from audio_downloader.config import ConfigManager temp_config = tempfile.NamedTemporaryFile(delete=False, suffix='.json') temp_config.close() @@ -195,7 +195,7 @@ def test_missing_config_file_creates_default(self): original_file = ConfigManager.CONFIG_FILE if hasattr(ConfigManager, 'CONFIG_FILE') else None try: - import config + import audio_downloader.config as config config.CONFIG_FILE = temp_config.name if os.path.exists(temp_config.name): @@ -216,7 +216,7 @@ def test_missing_config_file_creates_default(self): def test_invalid_json_handled(self): """Test that invalid JSON is handled gracefully""" import tempfile - from config import ConfigManager + from audio_downloader.config import ConfigManager temp_config = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') temp_config.write("{ invalid json }") diff --git a/tests/test_sources.py b/tests/test_sources.py index 2531fcc..47ad3c6 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -2,12 +2,13 @@ Test URL validation logic for all download sources """ +import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) -from config import ConfigManager +from audio_downloader.config import ConfigManager def test_northwest_outdoors_url_validation(): @@ -145,6 +146,37 @@ def test_validation_logic_matches_whittler(): print(" ✓ Whittler validation logic matches source") +def test_northwest_outdoors_date_stamped_file_filter(): + """NWoutdoors<6digits>.mp3 files should be filtered out after unzip""" + pattern = re.compile(r'^NWoutdoors\d{6}\.mp3$', re.IGNORECASE) + + should_match = [ + "NWoutdoors062776.mp3", + "NWoutdoors123456.mp3", + "nwoutdoors062776.mp3", + "NWOUTDOORS062776.mp3", + ] + should_not_match = [ + "NWoutdoors06277.mp3", + "NWoutdoors0627766.mp3", + "NWoutdoors062776.wav", + "NWoutdoorsabcdef.mp3", + "something_NWoutdoors062776.mp3", + "NWoutdoors062776_promo.mp3", + "NWO062776.mp3", + "promo_file.mp3", + "regular_show.mp3", + ] + + for name in should_match: + assert pattern.match(name), f"'{name}' should match the date pattern" + + for name in should_not_match: + assert not pattern.match(name), f"'{name}' should NOT match the date pattern" + + print(" ✓ NWoutdoors date-stamped file filter works correctly") + + def run_tests(): """Run all source URL validation tests""" print("=" * 60) @@ -160,6 +192,7 @@ def run_tests(): test_url_with_special_characters, test_validation_logic_matches_northwest_outdoors, test_validation_logic_matches_whittler, + test_northwest_outdoors_date_stamped_file_filter, ] passed = 0 diff --git a/tests/test_weekend_in_the_country.py b/tests/test_weekend_in_the_country.py index 363244d..0dc41c5 100644 --- a/tests/test_weekend_in_the_country.py +++ b/tests/test_weekend_in_the_country.py @@ -16,8 +16,11 @@ def _make_file(dir_path, name): (dir_path / name).touch() -def _simulate_process_files(output_dir): +def _simulate_process_files(output_dir, spots_dir=None): """Replicate the rename logic from weekend_in_the_country.py""" + if spots_dir is None: + spots_dir = output_dir + seg_re = re.compile(r'hr(\d+)_seg(\d+)', re.IGNORECASE) date_re = re.compile(r'(\d{2}-\d{2}-\d{2})') promos = [] @@ -45,26 +48,29 @@ def _simulate_process_files(output_dir): for f, date_str in promos: date_tag = f"_{date_str}" if date_str else "" - new_name = f.parent / f"WITC_PROMO{date_tag}.mp3" + new_name = spots_dir / f"WITC_PROMO{date_tag}.mp3" f.rename(new_name) def test_segments_renamed_correctly(): """Segment files are renamed to WITC_HR{num}_PT{num}""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg2.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr2_seg1.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr2_seg4.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) files = {f.name for f in tmp.iterdir() if f.is_file()} expected = {"WITC_HR1_PT1.mp3", "WITC_HR1_PT2.mp3", "WITC_HR2_PT1.mp3", "WITC_HR2_PT4.mp3"} assert files == expected, f"Got {files}" - print(" ✓ Segments renamed correctly") + assert len(list(spots.iterdir())) == 0, "No files should be in Spots" + print(" ✓ Segments renamed correctly, spots dir empty") finally: shutil.rmtree(tmp) @@ -72,11 +78,13 @@ def test_segments_renamed_correctly(): def test_no_segments_skipped(): """Files without hr/seg pattern are left untouched""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") _make_file(tmp, "some_other_file.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) names = {f.name for f in tmp.iterdir() if f.is_file()} assert "WITC_HR1_PT1.mp3" in names @@ -87,50 +95,64 @@ def test_no_segments_skipped(): def test_promos_renamed_with_date(): - """Each promo is renamed to WITC_PROMO_MM-DD-YY.mp3""" + """Each promo is moved to Spots and renamed to WITC_PROMO_MM-DD-YY.mp3""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26 promo.mp3") _make_file(tmp, "Weekend in the Country_07-04-26 promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert "WITC_PROMO_06-27-26.mp3" in files, "First promo missing" - assert "WITC_PROMO_07-04-26.mp3" in files, "Second promo missing" - assert "Weekend in the Country_06-27-26 promo.mp3" not in files, \ - "Original should be renamed" - print(" ✓ Both promos renamed with date tags") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert "Weekend in the Country_06-27-26 promo.mp3" not in global_files, \ + "Promo should be removed from global features" + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert "WITC_PROMO_06-27-26.mp3" in spots_files, "First promo missing in Spots" + assert "WITC_PROMO_07-04-26.mp3" in spots_files, "Second promo missing in Spots" + print(" ✓ Both promos moved to Spots and renamed with date tags") finally: shutil.rmtree(tmp) def test_promo_without_date_defaults(): - """Promo with unparseable date is renamed without tag""" + """Promo with unparseable date is moved to Spots and renamed without tag""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_baddate promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert "WITC_PROMO.mp3" in files, "Promo with bad date should still be kept" - print(" ✓ Promo with unparseable date handled") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert "Weekend in the Country_baddate promo.mp3" not in global_files + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert "WITC_PROMO.mp3" in spots_files, "Promo with bad date should still be kept" + print(" ✓ Promo with unparseable date moved to Spots") finally: shutil.rmtree(tmp) def test_single_promo_kept(): - """Single promo is renamed with date""" + """Single promo is moved to Spots and renamed with date""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26 promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert files == {"WITC_PROMO_06-27-26.mp3"}, f"Got {files}" - print(" ✓ Single promo renamed with date") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert len(global_files) == 0, f"GLOBAL FEATURES should be empty, got {global_files}" + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert spots_files == {"WITC_PROMO_06-27-26.mp3"}, f"Got {spots_files}" + print(" ✓ Single promo moved to Spots and renamed with date") finally: shutil.rmtree(tmp) @@ -138,13 +160,16 @@ def test_single_promo_kept(): def test_no_promo_no_error(): """No promo files is handled gracefully""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) files = {f.name for f in tmp.iterdir() if f.is_file()} assert files == {"WITC_HR1_PT1.mp3"}, f"Got {files}" + assert len(list(spots.iterdir())) == 0, "Spots should be empty" print(" ✓ No promo files handled gracefully") finally: shutil.rmtree(tmp) @@ -153,11 +178,13 @@ def test_no_promo_no_error(): def test_non_weekend_files_ignored(): """Files not starting with 'Weekend in the Country' are ignored""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "completely_different.mp3") _make_file(tmp, "Weekend in the Country_06-27-26_hr1_seg1.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) files = {f.name for f in tmp.iterdir() if f.is_file()} assert "WITC_HR1_PT1.mp3" in files @@ -168,16 +195,21 @@ def test_non_weekend_files_ignored(): def test_promo_with_past_date_handled(): - """Past-date promo is renamed with its date""" + """Past-date promo is moved to Spots and renamed with its date""" tmp = Path(tempfile.mkdtemp()) + spots = tmp / "Spots" + spots.mkdir() try: _make_file(tmp, "Weekend in the Country_06-20-26 promo.mp3") - _simulate_process_files(tmp) + _simulate_process_files(tmp, spots) - files = {f.name for f in tmp.iterdir() if f.is_file()} - assert "WITC_PROMO_06-20-26.mp3" in files, "Past promo should be renamed with date" - print(" ✓ Past-date promo renamed with date") + global_files = {f.name for f in tmp.iterdir() if f.is_file()} + assert "Weekend in the Country_06-20-26 promo.mp3" not in global_files + + spots_files = {f.name for f in spots.iterdir() if f.is_file()} + assert "WITC_PROMO_06-20-26.mp3" in spots_files, "Past promo should be renamed with date" + print(" ✓ Past-date promo moved to Spots and renamed with date") finally: shutil.rmtree(tmp) From 100c2ceaa7aac9c9d25cda3eba2c5e347e98ecc5 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:23:53 -0700 Subject: [PATCH 06/11] feat: add __main__.py and pyproject.toml for package entry point - audio_downloader/__main__.py enables 'python -m audio_downloader' - pyproject.toml enables 'pip install -e .' with setuptools backend - Declares dependencies: selenium, webdriver-manager, psutil --- audio_downloader/__main__.py | 2 ++ pyproject.toml | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 audio_downloader/__main__.py create mode 100644 pyproject.toml diff --git a/audio_downloader/__main__.py b/audio_downloader/__main__.py new file mode 100644 index 0000000..f055c02 --- /dev/null +++ b/audio_downloader/__main__.py @@ -0,0 +1,2 @@ +from audio_downloader.main import main +main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..675e106 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=64.0"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "audio-download-manager" +version = "1.1.9" +description = "Download radio show audio files from multiple sources" +requires-python = ">=3.10" +dependencies = [ + "selenium>=4.0.0", + "webdriver-manager>=4.0.0", + "psutil>=5.9.0", +] + +[tool.setuptools.packages.find] +include = ["audio_downloader", "audio_downloader.*"] \ No newline at end of file From 396247f5b1330c0cbe092269f569d0462b2042fb Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:24:32 -0700 Subject: [PATCH 07/11] refactor: update PyInstaller spec for audio_downloader package --- AudioDownloader.spec | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/AudioDownloader.spec b/AudioDownloader.spec index c34e3f4..bc0e45c 100644 --- a/AudioDownloader.spec +++ b/AudioDownloader.spec @@ -2,23 +2,23 @@ a = Analysis( - ['main.py'], + ['audio_downloader/__main__.py'], pathex=[os.path.dirname(os.path.abspath(SPEC))], binaries=[], datas=[], collect_all=['selenium', 'webdriver_manager'], hiddenimports=[ - 'gui', - 'config', - 'browser_manager', - 'download_utils', - 'sources', - 'sources.base', - 'sources.melinda_myers', - 'sources.northwest_outdoors', - 'sources.whittler', - 'sources.clear_out_west', - 'sources.weekend_in_the_country', + 'audio_downloader.gui', + 'audio_downloader.config', + 'audio_downloader.browser_manager', + 'audio_downloader.download_utils', + 'audio_downloader.sources', + 'audio_downloader.sources.base', + 'audio_downloader.sources.melinda_myers', + 'audio_downloader.sources.northwest_outdoors', + 'audio_downloader.sources.whittler', + 'audio_downloader.sources.clear_out_west', + 'audio_downloader.sources.weekend_in_the_country', 'cryptography', 'OpenSSL', 'h2', From 16145440516ffa0141b1bd8e53e65621844766fb Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Thu, 25 Jun 2026 18:27:11 -0700 Subject: [PATCH 08/11] docs: update paths for audio_downloader package reorganization - bat scripts: cd to %~dp0.. to navigate from scripts/ to repo root - AGENTS.md: update all references from root-level .py files to audio_downloader/ paths - CI: update sed/git add to target audio_downloader/__init__.py --- .github/workflows/windows_build.yml | 4 +-- AGENTS.md | 46 +++++++++++++++------------- scripts/download_global_features.bat | 2 +- scripts/download_promos.bat | 4 +-- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index c15978d..af02012 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -42,8 +42,8 @@ jobs: else git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - sed -i "s/__version__ = .*/__version__ = \"${{ steps.bump.outputs.new_version }}\"/" __init__.py - git add __init__.py + sed -i "s/__version__ = .*/__version__ = \"${{ steps.bump.outputs.new_version }}\"/" audio_downloader/__init__.py + git add audio_downloader/__init__.py git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }}" git tag ${{ steps.bump.outputs.new_version }} git push origin ${{ steps.bump.outputs.new_version }} diff --git a/AGENTS.md b/AGENTS.md index 2a5ceaa..3882308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,11 +7,11 @@ Audio Download Manager — a Python desktop app that uses Selenium (Firefox) to ## Developer Commands ```bash -python main.py # Launch GUI (default) -python main.py --download-all # CLI: download from all sources -python main.py --source "Name" # CLI: download from one source -python test_downloads.py # Run standalone test suite -python test_detection_standalone.py # Run download detection test +python -m audio_downloader # Launch GUI (default) +python -m audio_downloader --download-all # CLI: download from all sources +python -m audio_downloader --source "Name" # CLI: download from one source +python tests/test_downloads.py # Run standalone test suite +python tests/test_detection_standalone.py # Run download detection test python tests/test_config_edge_cases.py # Config tests python tests/test_integration.py # Integration tests python tests/test_sources.py # URL validation tests @@ -23,13 +23,13 @@ No test framework installed — tests are plain Python scripts run directly. No ## Architecture -- **Entry point**: `main.py` — 3 modes: GUI (tkinter), CLI download-all, CLI single-source -- **Windows batch scripts**: `download_global_features.bat` (Thu 11PM), `download_promos.bat` (Tue 11PM) -- **Sources**: `sources/` — 6 downloader implementations using factory via `create_downloader(name, browser_mgr, config)` -- **Base class**: `sources/base.py` — `BaseDownloader` with `download()` abstract method +- **Entry point**: `audio_downloader/main.py` (run via `python -m audio_downloader`) — 3 modes: GUI (tkinter), CLI download-all, CLI single-source +- **Windows batch scripts**: `scripts/download_global_features.bat` (Thu 11PM), `scripts/download_promos.bat` (Tue 11PM) +- **Sources**: `audio_downloader/sources/` — 6 downloader implementations using factory via `create_downloader(name, browser_mgr, config)` +- **Base class**: `audio_downloader/sources/base.py` — `BaseDownloader` with `download()` abstract method - **Browser**: Firefox only (uses `webdriver-manager` for GeckoDriver auto-install) - **Config**: `download_config.json` (gitignored, contains credentials) — auto-created with defaults on first run -- **GUI**: tkinter dark theme in `gui.py` +- **GUI**: tkinter dark theme in `audio_downloader/gui.py` ## Key Directories @@ -37,19 +37,21 @@ No test framework installed — tests are plain Python scripts run directly. No |---|---|---| | `downloads/` | Test mode output | yes | | `browser_downloads/` | Selenium staging dir | yes | -| `sources/` | Download source implementations | no | +| `audio_downloader/` | Application package (sources, config, GUI, etc.) | no | +| `audio_downloader/sources/` | Download source implementations | no | | `tests/` | Test suite | no | +| `scripts/` | Windows batch scripts | no | ## Important Conventions & Gotchas - **Test mode defaults to `True`** — downloads go to `downloads/` not real Dropbox paths - **Two download directories**: `browser_downloads/` is the Selenium staging area; `downloads/` (test) or Dropbox paths (prod) are final output -- **FFmpeg is external** — must be installed on the system separately for audio tag overlay (`download_utils.py`) +- **FFmpeg is external** — must be installed on the system separately for audio tag overlay (`audio_downloader/download_utils.py`) - **Windows-only build**: PyInstaller spec builds `.exe` on Windows CI (`AudioDownloader.spec`) - **Source names vs keys**: `DOWNLOAD_SOURCES` maps display names (e.g. `"Melinda Myers"`) to module keys (e.g. `"melinda_myers"`) — always use display names with `create_downloader()` - **Config merge**: `DEFAULT_CONFIG.copy()` then `.update(saved_config)` — top-level keys only, nested dicts like `urls` are fully replaced - **Browser lifecycle**: `BrowserManager` is shared across source downloads; each source gets a fresh `BrowserManager` in CLI mode -- **Constants use UPPERCASE**: `ALLOWED_EXTENSIONS`, `EXCLUDED_EXTENSIONS`, `EXCLUDED_PREFIXES` in `constants.py` — always reference them with exact uppercase names +- **Constants use UPPERCASE**: `ALLOWED_EXTENSIONS`, `EXCLUDED_EXTENSIONS`, `EXCLUDED_PREFIXES` in `audio_downloader/constants.py` — always reference them with exact uppercase names ## Dependencies @@ -69,7 +71,7 @@ GitHub Actions (`.github/workflows/windows_build.yml`): builds Windows exe on pu To update config programmatically, use `ConfigManager`: ```python -from config import ConfigManager +from audio_downloader.config import ConfigManager cm = ConfigManager() cm.set("output_dir", "/path/to/output") cm.save() @@ -79,36 +81,36 @@ cm.save() New sources require registration in **4 places**: -1. **Create source module**: `sources/.py` with a class inheriting from `BaseDownloader`, implementing `download(update_callback=None) -> bool` +1. **Create source module**: `audio_downloader/sources/.py` with a class inheriting from `BaseDownloader`, implementing `download(update_callback=None) -> bool` -2. **Register in factory** (`sources/__init__.py`): +2. **Register in factory** (`audio_downloader/sources/__init__.py`): - Add import: `from . import Downloader` - Add to `downloaders` dict in `create_downloader()`: `"Display Name": Downloader` -3. **Register in config** (`config.py`): +3. **Register in config** (`audio_downloader/config.py`): - Add to `DOWNLOAD_SOURCES` dict: `"Display Name": "snake_case_name"` 4. **Register in PyInstaller spec** (`AudioDownloader.spec`): - - Add to `hiddenimports` list: `'sources.'` + - Add to `hiddenimports` list: `'audio_downloader.sources.'` - This is critical — PyInstaller won't auto-discover dynamically imported source modules, and the exe will crash on that source. After adding a source, verify: ```bash -python -c "from sources import create_downloader; from browser_manager import BrowserManager; from config import ConfigManager; bm = BrowserManager(ConfigManager()); d = create_downloader('Display Name', bm, ConfigManager()); print('OK')" +python -c "from audio_downloader.sources import create_downloader; from audio_downloader.browser_manager import BrowserManager; from audio_downloader.config import ConfigManager; bm = BrowserManager(ConfigManager()); d = create_downloader('Display Name', bm, ConfigManager()); print('OK')" ``` ## Release Workflow To publish a new version: -1. **Bump version** in `__init__.py`: +1. **Bump version** in `audio_downloader/__init__.py`: ```python __version__ = "1.1.9" # Use semver (major.minor.patch) ``` 2. **Commit** the change: ```bash - git add __init__.py + git add audio_downloader/__init__.py git commit -m "chore: bump version to 1.1.9" ``` @@ -132,6 +134,6 @@ pyinstaller --clean AudioDownloader.spec Output: `dist/AudioDownloader.exe` -The `.spec` file's `hiddenimports` list must be kept in sync whenever modules are added or removed. If a new module is added (not in `sources/`), add it to `hiddenimports` — PyInstaller cannot detect runtime-imported modules like those in the factory pattern. +The `.spec` file's `hiddenimports` list must be kept in sync whenever modules are added or removed. If a new module is added (not in `audio_downloader/sources/`), add it to `hiddenimports` — PyInstaller cannot detect runtime-imported modules like those in the factory pattern. The `AudioDownloader.exe` reads config from the same directory it's placed in (`download_config.json` auto-created on first run). It does not bundle FFmpeg — FFmpeg must be separately available on the user's system PATH for tag overlay to work. diff --git a/scripts/download_global_features.bat b/scripts/download_global_features.bat index ddcc47c..a4d8012 100644 --- a/scripts/download_global_features.bat +++ b/scripts/download_global_features.bat @@ -2,5 +2,5 @@ REM Schedule: Thursdays @ 11:00 PM REM Downloads all global feature sources (Melinda Myers, NW Outdoors, Whittler, Clear Out West, Weekend In The Country) -cd /d "%~dp0" +cd /d "%~dp0.." AudioDownloader.exe --download-all diff --git a/scripts/download_promos.bat b/scripts/download_promos.bat index 4a4761c..2c5237b 100644 --- a/scripts/download_promos.bat +++ b/scripts/download_promos.bat @@ -2,5 +2,5 @@ REM Schedule: Tuesdays @ 11:00 PM REM Downloads promos only (Northwest Outdoors promo) -cd /d "%~dp0" -AudioDownloader.exe --source "Download Promo" +cd /d "%~dp0.." +AudioDownloader.exe --download-promo From 4cdcafa0d906ca2b9a2de9edf70917832888efed Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 26 Jun 2026 09:24:15 -0700 Subject: [PATCH 09/11] docs: weekly download automation design spec --- .../2026-06-26-weekly-automation-design.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-26-weekly-automation-design.md diff --git a/docs/superpowers/specs/2026-06-26-weekly-automation-design.md b/docs/superpowers/specs/2026-06-26-weekly-automation-design.md new file mode 100644 index 0000000..a2db0b0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-weekly-automation-design.md @@ -0,0 +1,224 @@ +# Weekly Download Automation Design + +## Overview + +Consolidate the radio show download pipeline into a single automated weekly run. Replace browser-based scraping with direct HTTP/FTP downloads where possible, parallelize independent sources, and integrate the WITC promo rename logic into the downloader itself. + +## Goals + +- One scheduled task downloads everything for the coming week +- Eliminate browser dependency for all sources except Clear Out West (phase 2: eliminate entirely if feasible) +- Parallelize downloads for speed +- All cart filenames remain stable (automation system reads folders directly) +- No stale content — everything fresh before it airs + +## Schedule + +### Single run: Tuesday 11:00 PM via Windows Task Scheduler + +One bat script: `scripts/download_weekly.bat` + +```bat +@echo off +cd /d "%~dp0.." +AudioDownloader.exe --download-all +``` + +### Why Tuesday + +All content is available by Monday except the Northwest Outdoors promo, which updates Tuesday. Tuesday night is the earliest point where everything can be fetched in one pass. With a month of Melinda Myers content on FTP, the coming week's tips are always available. + +### Air schedule vs. download timing + +| Source | Airs | Content available | Downloaded | +|---|---|---|---| +| Melinda Myers | Mon-Fri | ~1 month ahead (FTP) | Previous Tuesday | +| WITC promo | Throughout week | By Monday (FTP) | Tuesday 11PM | +| WITC show | Saturday morning | By Monday (FTP) | Tuesday 11PM | +| NW Outdoors show | Saturday | By Monday | Tuesday 11PM | +| NW Outdoors promo | Saturday | By Tuesday | Tuesday 11PM | +| Whittler | Sunday | By Monday | Tuesday 11PM | +| Clear Out West | Sunday | By Monday | Tuesday 11PM | + +Everything airs fresh. Melinda Myers for the coming Mon-Fri is downloaded the prior Tuesday — no stale gap. + +## Source Changes + +### 1. Melinda Myers — Rewrite to FTP + +**Current:** Selenium web scraping of melindamyers.com, downloading day-by-day +**New:** FTP download, like WITC + +Flow: +1. Connect to MM FTP server using configured credentials +2. List all MP3 files recursively +3. Extract dates from filenames (regex, flexible format — try MM-DD-YY, MMDDYY, YYYY-MM-DD) +4. Find files matching the coming week's Mon, Tue, Wed, Thu, Fri dates +5. Download those 5 files +6. Rename to stable cart filenames: + - `MMMON.mp3` (Monday) + - `MMTUE.mp3` (Tuesday) + - `MMWED.mp3` (Wednesday) + - `MMTHU.mp3` (Thursday) + - `MMFRI.mp3` (Friday) +7. Place in `GLOBAL FEATURES/` + +Config additions: +```json +{ + "mm_ftp_server": "", + "mm_ftp_username": "", + "mm_ftp_password": "" +} +``` + +### 2. Northwest Outdoors — Direct HTTP download, merge show + promo + +**Current:** Two separate Selenium runs, each downloads the same Dropbox ZIP +**New:** Single `requests.get(url + "?dl=1")` download, extract once, split show vs. promo + +Flow: +1. Download ZIP via direct HTTP (`requests` library, `?dl=1` parameter) +2. Extract to temp directory +3. Non-promo, non-date-stamped files → `GLOBAL FEATURES/` (show content) +4. Promo files → `Promos/` with tag overlay (existing `DownloadUtilities.overlay_promo_with_tag` logic) +5. Clean up temp directory + +Eliminates: redundant ZIP download, browser startup, XPath waiting, popup handling. + +### 3. Whittler — Direct HTTP download + +**Current:** Selenium + Dropbox ZIP download +**New:** `requests.get(url + "?dl=1")` direct download + +Flow: +1. Download ZIP via direct HTTP +2. Extract to temp directory +3. Map "Part A/B/C/D" to `Whittler1.mp3`–`Whittler4.mp3` (existing logic) +4. Place in `GLOBAL FEATURES/` +5. Clean up temp directory + +### 4. Clear Out West — Phase 1: Keep Selenium, Phase 2: Investigate requests + +**Phase 1 (this design):** Keep Selenium-based download as-is. It requires password login on clearoutwest.com, which is a simple HTML form POST that could be done with `requests` but needs investigation first. + +**Phase 2 (future):** Replace with `requests.Session()` — POST password to login form, follow redirect, download MP3/ZIP links directly. If feasible, eliminates the last browser dependency. + +### 5. Weekend In The Country — Integrate promo rename + +**Current:** FTP download + separate `witc_promo_rename.bat` for promo rename +**New:** FTP download + integrated promo rename in `_process_files()` + +The FTP server provides two promos: this week's and next week's. + +Flow: +1. Connect to WITC FTP (existing logic) +2. Download all MP3 files (existing logic) +3. Rename segments to `WITC_HR{hr}_PT{pt}.mp3` in `GLOBAL FEATURES/` (existing logic) +4. Rename promos to `WITC_PROMO_MM-DD-YY.mp3` in `Spots/` (existing logic) +5. **NEW:** Find the upcoming Saturday's date +6. **NEW:** Copy `WITC_PROMO_{upcoming-saturday}.mp3` to `WITC_PROMO.mp3` (stable cart) +7. **NEW:** Delete any `WITC_PROMO_*.mp3` files dated before the upcoming Saturday +8. **NEW:** Preserve `WITC_PROMO_*.mp3` files dated after the upcoming Saturday (next week's promo) + +This replaces the fragile `witc_promo_rename.bat` PowerShell script and fixes the bug where next week's promo was being deleted. + +## Parallelization + +### Architecture: ThreadPoolExecutor with source groups + +``` +ThreadPoolExecutor(max_workers=5): + Thread 1: Melinda Myers (FTP) + Thread 2: WITC (FTP) + Thread 3: NW Outdoors show + promo (HTTP) + Thread 4: Whittler (HTTP) + Thread 5: Clear Out West (Selenium — only browser source) +``` + +All sources run concurrently. FTP and HTTP sources are pure I/O — no browser, no shared download directory, no conflicts. Clear Out West gets its own `BrowserManager` instance. + +**Key changes to enable parallelization:** +- Each source gets its own temp directory (not shared `browser_downloads/`) +- Each browser source gets its own `BrowserManager` (already supported — CLI mode creates fresh managers) +- `run_cli_downloads()` uses `ThreadPoolExecutor` instead of sequential loop +- Thread-safe result collection and logging +- NW Outdoors promo download merged into NW Outdoors show download (single ZIP, split locally) + +**GIL consideration:** Not a problem. All sources are I/O-bound (network waits, file writes, `time.sleep`). The GIL releases during I/O operations. + +### Error handling + +- Each source runs in its own thread with try/except +- Failures are collected, not fatal — one source failing doesn't stop others +- Final summary reports per-source success/failure +- Exit code: 0 if all succeeded, 1 if any failed (same as current behavior) + +## CLI Changes + +### `--download-all` (modified) + +Currently downloads 5 sources sequentially in one browser. New behavior: +- Downloads all sources in parallel via ThreadPoolExecutor +- Includes NW Outdoors promo (currently excluded — was a separate `--download-promo` run) +- No shared browser — each source manages its own transport + +### `--download-promo` (deprecated) + +Merged into `--download-all`. Kept for backward compatibility but calls the merged NW Outdoors download. + +### `--source` (unchanged) + +Single-source download still works for manual re-downloads. + +## File Changes Summary + +### New files +- `scripts/download_weekly.bat` — single weekly bat script + +### Modified files +- `audio_downloader/sources/melinda_myers.py` — full rewrite to FTP +- `audio_downloader/sources/northwest_outdoors.py` — rewrite to HTTP, merge show + promo +- `audio_downloader/sources/whittler.py` — rewrite to HTTP +- `audio_downloader/sources/weekend_in_the_country.py` — add promo rename to `_process_files()` +- `audio_downloader/main.py` — parallelize `run_cli_downloads()`, include promo in `--download-all` +- `audio_downloader/config.py` — add MM FTP credentials to `DEFAULT_CONFIG` +- `AudioDownloader.spec` — add `requests` to hiddenimports if needed + +### Deleted files +- `scripts/witc_promo_rename.bat` — logic moved into WITC downloader +- `scripts/download_promos.bat` — merged into `--download-all` +- `scripts/download_global_features.bat` — replaced by `download_weekly.bat` + +## Dependencies + +### New +- `requests` — HTTP downloads for Dropbox sources (NW Outdoors, Whittler) + +### Potentially removable (future) +- `selenium` — only needed for Clear Out West after this redesign +- `webdriver-manager` — same + +### Unchanged +- `psutil`, `watchdog`, `pyinstaller` +- `ffmpeg` — system package, still needed for tag overlay + +## Cart Filename Reference + +| Source | Folder | Cart filenames | +|---|---|---| +| Melinda Myers | GLOBAL FEATURES/ | MMMON.mp3, MMTUE.mp3, MMWED.mp3, MMTHU.mp3, MMFRI.mp3 | +| NW Outdoors (show) | GLOBAL FEATURES/ | Original ZIP filenames (non-promo, non-date-stamped) | +| NW Outdoors (promo) | Promos/ | Original promo filename from ZIP (with tag overlay) | +| Whittler | GLOBAL FEATURES/ | Whittler1.mp3, Whittler2.mp3, Whittler3.mp3, Whittler4.mp3 | +| Clear Out West | GLOBAL FEATURES/ | COW1.mp3, COW2.mp3, ..., COWPROMO.mp3 | +| WITC (show) | GLOBAL FEATURES/ | WITC_HR{hr}_PT{pt}.mp3 | +| WITC (promo) | Spots/ | WITC_PROMO.mp3 (stable), WITC_PROMO_MM-DD-YY.mp3 (dated) | + +## Open Questions + +1. **Melinda Myers FTP date format** — Need to confirm the exact date format in filenames. Design uses flexible regex to try multiple formats. Verify against actual FTP listing before implementation. + +2. **Clear Out West via requests** — Phase 2 investigation. The login is a simple HTML form POST that may work with `requests.Session()`. If feasible, eliminate the last browser dependency entirely. + +3. **Dropbox `?dl=1` reliability** — The `?dl=1` parameter has been stable for years but is not officially documented for shared folder links. If it breaks, fall back to the Dropbox API SDK (`dropbox` package) which supports shared link downloads without authentication. \ No newline at end of file From a3d4f844e6a15fe57afec86c56e684a2c7b685d9 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 26 Jun 2026 09:44:43 -0700 Subject: [PATCH 10/11] chore: ignore docs/superpowers/ in gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b637888..15e28e2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ test_config.py # Build artifacts build/ dist/ + +# Superpowers specs/plans (AI design docs) +docs/superpowers/ From b0c873e54a544c4855fdc415747016cc14a313ef Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 26 Jun 2026 09:45:33 -0700 Subject: [PATCH 11/11] chore: add WITC promo rename batch script --- scripts/witc_promo_rename.bat | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 scripts/witc_promo_rename.bat diff --git a/scripts/witc_promo_rename.bat b/scripts/witc_promo_rename.bat new file mode 100644 index 0000000..ee94224 --- /dev/null +++ b/scripts/witc_promo_rename.bat @@ -0,0 +1,26 @@ +@echo off +REM WITC Promo Rename — copies the current week's promo to WITC_PROMO.mp3 +REM Finds the upcoming Saturday, matches WITC_PROMO_MM-DD-YY.mp3, +REM and copies it as WITC_PROMO.mp3 in the Spots folder. + +cd /d "%~dp0.." + +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + $ErrorActionPreference = 'Stop'; ^ + $config = Get-Content download_config.json -Raw | ConvertFrom-Json; ^ + $outDir = $config.output_dir; ^ + if (-not [IO.Path]::IsPathRooted($outDir)) { $outDir = Join-Path (Get-Location) $outDir }; ^ + $spotsDir = Join-Path $outDir Spots; ^ + $today = Get-Date; ^ + $dow = [int]$today.DayOfWeek; ^ + $daysUntilSat = (6 - $dow + 7) %% 7; if ($daysUntilSat -eq 0) { $daysUntilSat = 7 }; ^ + $saturday = $today.AddDays($daysUntilSat); ^ + $dateStr = '{0:D2}-{1:D2}-{2:D2}' -f $saturday.Month, $saturday.Day, ($saturday.Year %% 100); ^ + $src = Join-Path $spotsDir ('WITC_PROMO_' + $dateStr + '.mp3'); ^ + $dst = Join-Path $spotsDir 'WITC_PROMO.mp3'; ^ + if (Test-Path $src) { ^ + Copy-Item $src $dst -Force; ^ + Get-ChildItem $spotsDir -Filter 'WITC_PROMO_*.mp3' -Exclude ('WITC_PROMO_' + $dateStr + '.mp3'), 'WITC_PROMO.mp3' | Remove-Item; ^ + Write-Host ('Copied WITC_PROMO_' + $dateStr + '.mp3 -> WITC_PROMO.mp3, cleaned up old promos') ^ + } ^ + else { Write-Host ('No promo found for date ' + $dateStr) }