diff --git a/main.py b/main.py index 00cb8dc..cfbb542 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,14 @@ import sys + from PySide6.QtWidgets import QApplication, QMessageBox -from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main -from src.core.ytsage_yt_dlp import check_ytdlp_binary, setup_ytdlp, get_ytdlp_executable_path # Import the new yt-dlp setup functions + from src.core.ytsage_logging import logger +from src.core.ytsage_yt_dlp import ( # Import the new yt-dlp setup functions + check_ytdlp_binary, + setup_ytdlp, +) +from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main + def show_error_dialog(message): error_dialog = QMessageBox() @@ -12,28 +18,29 @@ def show_error_dialog(message): error_dialog.setWindowTitle("Error") error_dialog.exec() + def main(): try: logger.info("Starting YTSage application") app = QApplication(sys.argv) - + # Get the expected binary path and check if it exists - expected_path = get_ytdlp_executable_path() if not check_ytdlp_binary(): # No app-specific binary found, show setup dialog regardless of Python package logger.warning("No yt-dlp binary found, starting setup process") yt_dlp_path = setup_ytdlp() if yt_dlp_path == "yt-dlp": # If user canceled or something went wrong logger.warning("yt-dlp not configured properly") - - window = YTSageApp() # Instantiate the main application class + + window = YTSageApp() # Instantiate the main application class window.show() logger.info("Application window shown, entering main loop") sys.exit(app.exec()) except Exception as e: - logger.critical(f"Critical application error: {str(e)}", exc_info=True) - show_error_dialog(f"Critical error: {str(e)}") + logger.critical(f"Critical application error: {e}", exc_info=True) + show_error_dialog(f"Critical error: {e}") sys.exit(1) -if __name__ == '__main__': - main() \ No newline at end of file + +if __name__ == "__main__": + main() diff --git a/src/core/ytsage_downloader.py b/src/core/ytsage_downloader.py index a65222c..e923dff 100644 --- a/src/core/ytsage_downloader.py +++ b/src/core/ytsage_downloader.py @@ -1,24 +1,34 @@ -from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer -from .ytsage_logging import logger +import re +import shlex # For safely parsing command arguments +import subprocess # For direct CLI command execution +import time +from pathlib import Path + +from PySide6.QtCore import QObject, QThread, Signal + +from src.core.ytsage_logging import logger +from src.core.ytsage_yt_dlp import get_yt_dlp_path +from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS + try: - import yt_dlp # Keep yt_dlp import here - only downloader uses it. + import yt_dlp # Keep yt_dlp import here - only downloader uses it. + YT_DLP_AVAILABLE = True except ImportError: YT_DLP_AVAILABLE = False logger.warning("yt-dlp not available at startup, will be downloaded at runtime") -import time -import os -import re -import subprocess # For direct CLI command execution -import shlex # For safely parsing command arguments -import sys # Added to get executable path information -from pathlib import Path -from .ytsage_yt_dlp import get_yt_dlp_path # Import the new yt-dlp path function + class SignalManager(QObject): update_formats = Signal(list) update_status = Signal(str) update_progress = Signal(float) + playlist_info_label_visible = Signal(bool) + playlist_info_label_text = Signal(str) + selected_subs_label_text = Signal(str) + playlist_select_btn_visible = Signal(bool) + playlist_select_btn_text = Signal(str) + class DownloadThread(QThread): progress_signal = Signal(float) @@ -26,18 +36,36 @@ class DownloadThread(QThread): finished_signal = Signal() error_signal = Signal(str) file_exists_signal = Signal(str) # New signal for file existence - update_details = Signal(str) # New signal for filename, speed, ETA + update_details = Signal(str) # New signal for filename, speed, ETA - def __init__(self, url, path, format_id, subtitle_langs=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, sponsorblock_categories=None, resolution='', playlist_items=None, save_description=False, embed_chapters=False, cookie_file=None, rate_limit=None, download_section=None, force_keyframes=False): + def __init__( + self, + url, + path, + format_id, + subtitle_langs=None, + is_playlist=False, + merge_subs=False, + enable_sponsorblock=False, + sponsorblock_categories=None, + resolution="", + playlist_items=None, + save_description=False, + embed_chapters=False, + cookie_file=None, + rate_limit=None, + download_section=None, + force_keyframes=False, + ) -> None: super().__init__() self.url = url - self.path = path + self.path = Path(path) self.format_id = format_id self.subtitle_langs = subtitle_langs if subtitle_langs else [] self.is_playlist = is_playlist self.merge_subs = merge_subs self.enable_sponsorblock = enable_sponsorblock - self.sponsorblock_categories = sponsorblock_categories if sponsorblock_categories else ['sponsor'] + self.sponsorblock_categories = sponsorblock_categories if sponsorblock_categories else ["sponsor"] self.resolution = resolution self.playlist_items = playlist_items self.save_description = save_description @@ -52,205 +80,136 @@ class DownloadThread(QThread): self.use_direct_command = True # Flag to use direct CLI command instead of Python API self.last_output_time = time.time() self.timeout_timer = None - self.current_filename = None # Initialize filename storage - self.last_file_path = None # Initialize full file path storage - self.subtitle_files = [] # Track subtitle files that are created - self.initial_subtitle_files = set() # Track initial subtitle files before download + self.current_filename = None # Initialize filename storage + self.last_file_path = None # Initialize full file path storage + self.subtitle_files = [] # Track subtitle files that are created + self.initial_subtitle_files = set() # Track initial subtitle files before download - def cleanup_partial_files(self): + def cleanup_partial_files(self) -> None: """Delete any partial files including .part and unmerged format-specific files""" try: - pattern = re.compile(r'\.f\d+\.') # Pattern to match format codes like .f243. - for filename in os.listdir(self.path): - file_path = os.path.join(self.path, filename) - if filename.endswith('.part') or pattern.search(filename): + pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243. + for file_path in self.path.iterdir(): + if file_path.suffix == ".part" or pattern.search(file_path.name): try: - if os.path.isfile(file_path): - os.remove(file_path) + file_path.unlink(missing_ok=True) except Exception as e: - logger.error(f"Error deleting {filename}: {str(e)}") + logger.error(f"Error deleting {file_path.name}: {str(e)}") except Exception as e: self.error_signal.emit(f"Error cleaning partial files: {str(e)}") - def cleanup_subtitle_files(self): + def cleanup_subtitle_files(self) -> None: """Delete subtitle files after they have been merged into the video file""" - if not self.merge_subs: - return # Only cleanup if merge_subs is enabled - - try: - deleted_count = 0 - - # Method 1: Delete tracked subtitle files from output messages - if self.subtitle_files: - for subtitle_file in self.subtitle_files: - try: - if os.path.isfile(subtitle_file): - os.remove(subtitle_file) - deleted_count += 1 - logger.debug(f"Deleted tracked subtitle file: {os.path.basename(subtitle_file)}") - except Exception as e: - logger.error(f"Error deleting subtitle file {subtitle_file}: {str(e)}") - - logger.debug(f"Deleted {deleted_count} of {len(self.subtitle_files)} tracked subtitle files") - - # Method 2: Find newly created subtitle files by comparing with initial set + deleted_count = [0, 0] + + def safe_delete(path: Path) -> bool: try: - new_subtitle_files = set() - for root, dirs, files in os.walk(self.path): - for file in files: - if file.endswith('.vtt') or file.endswith('.srt'): - full_path = os.path.join(root, file) - if full_path not in self.initial_subtitle_files: - new_subtitle_files.add(full_path) - - if new_subtitle_files: - logger.debug(f"Found {len(new_subtitle_files)} new subtitle files to delete") - for subtitle_file in new_subtitle_files: - try: - if os.path.isfile(subtitle_file): - os.remove(subtitle_file) - deleted_count += 1 - logger.debug(f"Deleted new subtitle file: {os.path.basename(subtitle_file)}") - except Exception as e: - logger.error(f"Error deleting new subtitle file {subtitle_file}: {str(e)}") + path.unlink(missing_ok=True) + logger.debug(f"Deleted subtitle file: {path.name}") + return True except Exception as e: - logger.error(f"Error in finding new subtitle files: {str(e)}") # Method 3: As a last resort, use timestamp-based approach for recently created files - if self.last_file_path and deleted_count == 0: - target_dir = os.path.dirname(self.last_file_path) - - # Look for subtitle files created in last 5 minutes - now = time.time() - for filename in os.listdir(target_dir): - if filename.endswith('.vtt') or filename.endswith('.srt'): - file_path = os.path.join(target_dir, filename) - - # Check if it was created in the last 5 minutes - file_time = os.path.getctime(file_path) - if now - file_time < 300: # 5 minutes - try: - os.remove(file_path) - deleted_count += 1 - logger.debug(f"Deleted subtitle file by timestamp: {filename}") - except Exception as e: - logger.error(f"Error deleting subtitle file {filename}: {str(e)}") - - logger.debug(f"Total subtitle files deleted: {deleted_count}") - + logger.error(f"Error deleting subtitle file {path}: {e}") + return False + + try: + # --- Method 1: Delete tracked subtitle files --- + for f in self.subtitle_files or []: + deleted_count[0] += safe_delete(path=Path(f)) + else: + logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files") + + # --- Method 2: Delete new subtitle files not in initial set --- + new_subtitle_files = { + f for f in Path(self.path).rglob("*") if f.suffix in [".vtt", ".srt"] and f not in self.initial_subtitle_files + } + for subtitle_file in new_subtitle_files: + deleted_count[1] += safe_delete(path=subtitle_file) + else: + logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files") except Exception as e: logger.error(f"Error cleaning subtitle files: {str(e)}") - def check_file_exists(self): + def check_file_exists(self) -> bool | None: """Check if the file already exists before downloading""" try: logger.debug("Starting file existence check") # Use yt-dlp to get the filename without downloading, suppressing warnings ydl_opts_check = { - 'quiet': True, - 'skip_download': True, - 'no_warnings': True, # <-- Suppress warnings during check - 'ignoreerrors': True, # Also ignore other potential errors during this check - 'outtmpl': {'default': os.path.join(self.path, '%(title)s.%(ext)s')}, - 'format': self.format_id if self.format_id else 'best' # Use selected format or best + "quiet": True, + "skip_download": True, + "no_warnings": True, # <-- Suppress warnings during check + "ignoreerrors": True, # Also ignore other potential errors during this check + "outtmpl": {"default": Path.joinpath(self.path, "%(title)s.%(ext)s")}, + "format": (self.format_id if self.format_id else "best"), # Use selected format or best } if self.cookie_file: - ydl_opts_check['cookiefile'] = self.cookie_file + ydl_opts_check["cookiefile"] = self.cookie_file if YT_DLP_AVAILABLE: with yt_dlp.YoutubeDL(ydl_opts_check) as ydl: info = ydl.extract_info(self.url, download=False) - + # Handle cases where info extraction fails silently if not info: logger.debug("Failed to extract info during file existence check. Skipping check.") - return False # Proceed with download attempt + return False # Proceed with download attempt # Get the title and sanitize it for filename - title = info.get('title', 'video') + title = info.get("title", "video") # Don't remove colons and other special characters yet logger.debug(f"Original video title: {title}") - + # Get resolution for better matching resolution = "" - for format_info in info.get('formats', []): - if format_info.get('format_id') == self.format_id: - resolution = format_info.get('resolution', '') + for format_info in info.get("formats", []): + if format_info.get("format_id") == self.format_id: + resolution = format_info.get("resolution", "") break - + logger.debug(f"Resolution: {resolution}") else: logger.debug("yt-dlp not available, skipping file existence check") return False # Proceed with download attempt - - # Create the expected filename (more specific) - if self.is_playlist and info.get('playlist_title'): - playlist_title = re.sub(r'[\\/*?"<>|]', "", info.get('playlist_title', '')).strip() - base_path = os.path.join(self.path, playlist_title) - else: - base_path = self.path - - # Normalize the path to use consistent separators - base_path = os.path.normpath(base_path) - logger.debug(f"Base path: {base_path}") - - # Instead of trying to predict the exact filename, scan the directory - # and look for files that contain both the title and resolution - if os.path.exists(base_path): - for filename in os.listdir(base_path): - if filename.endswith('.mp4'): - # Check if both title parts and resolution are in the filename - title_words = title.lower().split() - filename_lower = filename.lower() - - # Check if most title words are in the filename - title_match = all(word in filename_lower for word in title_words[:3]) - resolution_match = resolution.lower() in filename_lower - - logger.debug(f"Checking file: {filename}, Title match: {title_match}, Resolution match: {resolution_match}") - - if title_match and resolution_match: - logger.debug(f"Found matching file: {filename}") - return filename - - logger.debug("No matching file found") - return None + except Exception as e: logger.debug(f"Error checking file existence: {str(e)}") import traceback + traceback.print_exc() return None - def _build_yt_dlp_command(self): + def _build_yt_dlp_command(self) -> list: """Build the yt-dlp command line with all options for direct execution.""" # Use the new yt-dlp path function from ytsage_yt_dlp module yt_dlp_path = get_yt_dlp_path() - cmd = [yt_dlp_path] + cmd: list = [yt_dlp_path] logger.debug(f"Using yt-dlp from: {yt_dlp_path}") - + # Format selection strategy - use format ID if provided or fallback to resolution if self.format_id: # Strip the -drc suffix if present to fix issues with certain audio formats - clean_format_id = self.format_id.split('-drc')[0] if '-drc' in self.format_id else self.format_id - + clean_format_id = self.format_id.split("-drc")[0] if "-drc" in self.format_id else self.format_id + # Check if this is an audio-only format is_audio_format = False try: if YT_DLP_AVAILABLE: ydl_opts = { - 'quiet': True, - 'no_warnings': True, - 'skip_download': True, + "quiet": True, + "no_warnings": True, + "skip_download": True, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(self.url, download=False) - for fmt in info.get('formats', []): - if fmt.get('format_id') == clean_format_id: - if fmt.get('vcodec') == 'none' or 'audio only' in fmt.get('format_note', '').lower(): + info = ydl.extract_info(self.url, download=False) or {} + for fmt in info.get("formats", []): + if fmt.get("format_id") == clean_format_id: + if fmt.get("vcodec") == "none" or "audio only" in fmt.get("format_note", "").lower(): is_audio_format = True logger.debug(f"Detected audio-only format for ID: {clean_format_id}") break except Exception as e: logger.debug(f"Error checking if format is audio-only: {e}") - + # For audio-only formats, don't try to merge with video if is_audio_format: cmd.extend(["-f", clean_format_id]) @@ -258,7 +217,7 @@ class DownloadThread(QThread): else: cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"]) logger.debug(f"Using video format selection with audio: {clean_format_id}+bestaudio/best") - + # Determine output format based on the selected format ID - only for video formats if not is_audio_format: try: @@ -266,24 +225,24 @@ class DownloadThread(QThread): logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})") if YT_DLP_AVAILABLE: ydl_opts = { - 'quiet': True, - 'no_warnings': True, - 'skip_download': True, + "quiet": True, + "no_warnings": True, + "skip_download": True, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(self.url, download=False) + info = ydl.extract_info(self.url, download=False) or {} # Look for the clean format ID first - for fmt in info.get('formats', []): - if fmt.get('format_id') == clean_format_id: - format_ext = fmt.get('ext') + for fmt in info.get("formats", []): + if fmt.get("format_id") == clean_format_id: + format_ext = fmt.get("ext") break # If not found, try the original ID as fallback if not format_ext: - for fmt in info.get('formats', []): - if fmt.get('format_id') == self.format_id: - format_ext = fmt.get('ext') + for fmt in info.get("formats", []): + if fmt.get("format_id") == self.format_id: + format_ext = fmt.get("ext") break - + if format_ext: logger.debug(f"Detected format extension: {format_ext}") # Ensure output matches the selected format - only for video formats @@ -296,139 +255,136 @@ class DownloadThread(QThread): # If no specific format ID, use resolution-based sorting (-S) res_value = self.resolution if self.resolution else "720" # Default to 720p if no resolution specified cmd.extend(["-S", f"res:{res_value}"]) - + # Output template with resolution in filename - output_template = os.path.join(self.path, '%(title)s_%(resolution)s.%(ext)s') - + output_template = Path.joinpath(self.path, "%(title)s_%(resolution)s.%(ext)s") + # Handle playlist directory creation if needed if self.is_playlist: # Create output template with playlist subfolder - output_template = os.path.join(self.path, '%(playlist_title)s/%(title)s_%(resolution)s.%(ext)s') - - cmd.extend(["-o", output_template]) - + output_template = self.path.joinpath("%(playlist_title)s/%(title)s_%(resolution)s.%(ext)s") + + cmd.extend(["-o", output_template.as_posix()]) + # Add common options cmd.append("--force-overwrites") - + # Add playlist items if specified if self.is_playlist and self.playlist_items: cmd.extend(["--playlist-items", self.playlist_items]) - + # Add subtitle options if subtitles are selected if self.subtitle_langs: # Subtitles work with both audio-only and video formats # For audio-only formats, subtitles will be downloaded as separate files cmd.append("--write-subs") - + # Get language codes from subtitle selections lang_codes = [] for sub_selection in self.subtitle_langs: try: # Extract just the language code (e.g., 'en' from 'en - Manual') - lang_code = sub_selection.split(' - ')[0] + lang_code = sub_selection.split(" - ")[0] lang_codes.append(lang_code) except Exception as e: logger.warning(f"Could not parse subtitle selection '{sub_selection}': {e}") - + if lang_codes: cmd.extend(["--sub-langs", ",".join(lang_codes)]) cmd.append("--write-auto-subs") # Include auto-generated subtitles - + # Only embed subtitles if merge is enabled if self.merge_subs: cmd.append("--embed-subs") - + # Add SponsorBlock if enabled if self.enable_sponsorblock and self.sponsorblock_categories: cmd.append("--sponsorblock-remove") cmd.append(",".join(self.sponsorblock_categories)) - + # Add description saving if enabled if self.save_description: cmd.append("--write-description") - + # Add chapters embedding if enabled if self.embed_chapters: cmd.append("--embed-chapters") - + # Add cookies if specified if self.cookie_file: cmd.extend(["--cookies", self.cookie_file]) - + # Add rate limit if specified if self.rate_limit: cmd.extend(["-r", self.rate_limit]) - + # Add download section if specified if self.download_section: cmd.extend(["--download-sections", self.download_section]) - + # Add force keyframes option if enabled if self.force_keyframes: cmd.append("--force-keyframes-at-cuts") - + logger.debug(f"Added download section: {self.download_section}, Force keyframes: {self.force_keyframes}") - + # Add the URL as the final argument cmd.append(self.url) - + return cmd - - def run(self): + + def run(self) -> None: try: logger.debug("Starting download thread") - + # First check if file already exists using original method existing_file = self.check_file_exists() if existing_file: logger.debug(f"File exists, emitting signal: {existing_file}") self.file_exists_signal.emit(existing_file) return - + logger.debug("No existing file found, proceeding with download") - + # Get initial list of subtitle files to compare later self.initial_subtitle_files = set() if self.merge_subs: try: # Scan for existing subtitle files in the directory - for root, dirs, files in os.walk(self.path): - for file in files: - if file.endswith('.vtt') or file.endswith('.srt'): - self.initial_subtitle_files.add(os.path.join(root, file)) + for file in self.path.rglob("*"): + if file.suffix in {".vtt", ".srt"}: + self.initial_subtitle_files.add(file) logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download") except Exception as e: logger.warning(f"Error scanning for initial subtitle files: {e}") - + if self.use_direct_command: # Use direct CLI command instead of Python API self._run_direct_command() else: # Original method using Python API - code left for reference self._run_python_api() - + except Exception as e: # Catch errors during setup self.error_signal.emit(f"Critical error in download thread: {str(e)}") import traceback + traceback.print_exc() - - def _run_direct_command(self): + + def _run_direct_command(self) -> None: """Run yt-dlp as a direct command line process instead of using Python API.""" try: cmd = self._build_yt_dlp_command() cmd_str = " ".join(shlex.quote(str(arg)) for arg in cmd) logger.debug(f"Executing command: {cmd_str}") - + self.status_signal.emit("🚀 Starting download...") self.progress_signal.emit(0) - + # Start the process - # Add creationflags=subprocess.CREATE_NO_WINDOW to hide console on Windows - creation_flags = 0 - if os.name == 'nt': # Only use flag on Windows - creation_flags = subprocess.CREATE_NO_WINDOW - + # Extra logic moved to src\utils\ytsage_constants.py + self.process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -436,37 +392,39 @@ class DownloadThread(QThread): text=True, bufsize=1, # Line buffered universal_newlines=True, - creationflags=creation_flags # Add this flag + creationflags=SUBPROCESS_CREATIONFLAGS, ) - + # Process output line by line to update progress - for line in iter(self.process.stdout.readline, ''): + for line in iter(self.process.stdout.readline, ""): # type: ignore if self.cancelled: self.process.terminate() self.cleanup_partial_files() self.status_signal.emit("Download cancelled") return - + # Wait if paused while self.paused and not self.cancelled: time.sleep(0.1) - + # Parse the line for download progress and status updates self._parse_output_line(line) - + # Wait for process to complete return_code = self.process.wait() - + # Special handling for specific errors # return code 127 typically means command not found if return_code == 127: - self.error_signal.emit("Error: yt-dlp executable not found. This could be due to improper installation or a PATH issue.") + self.error_signal.emit( + "Error: yt-dlp executable not found. This could be due to improper installation or a PATH issue." + ) return - + if return_code == 0: self.progress_signal.emit(100) self.status_signal.emit("✅ Download completed!") - + # Clean up subtitle files if they were merged, with a small delay # to ensure the embedding process has completed if self.merge_subs: @@ -475,7 +433,7 @@ class DownloadThread(QThread): self.status_signal.emit("✅ Download completed! Cleaning up...") time.sleep(3) # Increased delay to 3 seconds self.cleanup_subtitle_files() - + self.finished_signal.emit() else: # Check if it was cancelled @@ -484,217 +442,223 @@ class DownloadThread(QThread): else: # Provide more descriptive error message for possible yt-dlp conflicts if return_code == 1: - self.error_signal.emit(f"Download failed with return code {return_code}. This may be due to a conflict with multiple yt-dlp installations. Try uninstalling any system-installed yt-dlp (e.g. through snap or apt) and restart the application.") + self.error_signal.emit( + f"Download failed with return code {return_code}. This may be due to a conflict with multiple yt-dlp installations. Try uninstalling any system-installed yt-dlp (e.g. through snap or apt) and restart the application." + ) else: self.error_signal.emit(f"Download failed with return code {return_code}") self.cleanup_partial_files() - + except Exception as e: self.error_signal.emit(f"Error in direct command: {str(e)}") self.cleanup_partial_files() - - def _parse_output_line(self, line): + + def _parse_output_line(self, line) -> None: """Parse yt-dlp command output to update progress and status.""" line = line.strip() # logger.info(f"yt-dlp: {line}") # Log all output - OPTIONALLY UNCOMMENT FOR VERBOSE DEBUG - + # Extract filename when the destination line appears # Use a slightly more robust regex looking for the start of the line - dest_match = re.search(r'^\[download\] Destination:\s*(.*)', line) + dest_match = re.search(r"^\[download\] Destination:\s*(.*)", line) if dest_match: try: filepath = dest_match.group(1).strip() - self.current_filename = os.path.basename(filepath) + self.current_filename = Path(filepath).name self.last_file_path = filepath # Store the full path for later cleanup - logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG - + logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG + # Check if this is an audio-only download by looking in the previous lines is_audio_download = False - + # Look for audio format indicators in the current line or preceding output # yt-dlp typically mentions format like "Downloading format 251 - audio only" - if ' - audio only' in line: + if " - audio only" in line: is_audio_download = True # Check if the format ID is mentioned earlier in the line - format_match = re.search(r'Downloading format (\d+)', line) + format_match = re.search(r"Downloading format (\d+)", line) if format_match: format_id = format_match.group(1) logger.debug(f"Detected format ID: {format_id}") - # Format IDs for audio typically have different patterns + # Format IDs for audio typically have different patterns # (like 140, 251 for audio vs 137, 248 for video) # This is just a heuristic since format IDs can vary - + # Determine file type based on extension and context - ext = os.path.splitext(self.current_filename)[1].lower() - + ext = Path(self.current_filename).suffix.lower() + # Check if this is explicitly an audio stream download - if is_audio_download or 'Downloading audio' in line: + if is_audio_download or "Downloading audio" in line: self.status_signal.emit(f"⏬ Downloading audio...") # Video file extensions with likely video content - elif ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']: + elif ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]: self.status_signal.emit(f"⏬ Downloading video...") # Audio file extensions - elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']: + elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]: self.status_signal.emit(f"⏬ Downloading audio...") # Subtitle file extensions - elif ext in ['.vtt', '.srt', '.ass', '.ssa']: + elif ext in [".vtt", ".srt", ".ass", ".ssa"]: self.status_signal.emit(f"⏬ Downloading subtitle...") # Default case else: self.status_signal.emit(f"⏬ Downloading...") except Exception as e: logger.error(f"Error extracting filename from line '{line}': {e}") - self.status_signal.emit("⚡ Downloading...") # Fallback status - return # Don't process this line further for speed/ETA - + self.status_signal.emit("⚡ Downloading...") # Fallback status + return # Don't process this line further for speed/ETA + # Check for specific download types in the output if "Downloading video" in line: self.status_signal.emit(f"⏬ Downloading video...") return - + elif "Downloading audio" in line: self.status_signal.emit(f"⏬ Downloading audio...") return - + # Detect subtitle file creation # Look for lines like "[info] Writing video subtitles to: filename.xx.vtt" - subtitle_match = re.search(r'(?:Writing|Downloading) (?:video )?subtitles.*?(?:to|:)\s*(.*\.(?:vtt|srt))', line, re.IGNORECASE) + subtitle_match = re.search( + r"(?:Writing|Downloading) (?:video )?subtitles.*?(?:to|:)\s*(.*\.(?:vtt|srt))", + line, + re.IGNORECASE, + ) if subtitle_match: subtitle_file = subtitle_match.group(1).strip() # Show subtitle download message self.status_signal.emit(f"⏬ Downloading subtitle...") # Store the subtitle file path for later deletion if merging is enabled if self.merge_subs: - if not os.path.isabs(subtitle_file): + if not Path(subtitle_file).is_absolute(): # If it's a relative path, make it absolute based on current path - subtitle_file = os.path.join(self.path, subtitle_file) + subtitle_file = Path.joinpath(self.path, subtitle_file) self.subtitle_files.append(subtitle_file) logger.debug(f"Tracking subtitle file for later cleanup: {subtitle_file}") return - + # Send status updates based on output line content - if 'Downloading webpage' in line or 'Extracting URL' in line: + if "Downloading webpage" in line or "Extracting URL" in line: self.status_signal.emit("🔍 Fetching video information...") self.progress_signal.emit(0) - elif 'Downloading API JSON' in line: + elif "Downloading API JSON" in line: self.status_signal.emit("📋 Processing playlist data...") self.progress_signal.emit(0) - elif 'Downloading m3u8 information' in line: + elif "Downloading m3u8 information" in line: self.status_signal.emit("🎯 Preparing video streams...") self.progress_signal.emit(0) - elif '[download] Downloading video ' in line: + elif "[download] Downloading video " in line: self.status_signal.emit("⏬ Downloading video...") - elif '[download] Downloading audio ' in line: + elif "[download] Downloading audio " in line: self.status_signal.emit("⏬ Downloading audio...") - elif 'Downloading format' in line: + elif "Downloading format" in line: # Try to detect if it's audio or video format - if ' - audio only' in line: + if " - audio only" in line: self.status_signal.emit("⏬ Downloading audio...") - elif ' - video only' in line: + elif " - video only" in line: self.status_signal.emit("⏬ Downloading video...") else: # Don't emit generic message - format is unclear pass - + # Look for download percentage - percent_match = re.search(r'(\d+\.\d+)%', line) + percent_match = re.search(r"(\d+\.\d+)%", line) if percent_match: try: percent = float(percent_match.group(1)) self.progress_signal.emit(percent) except (ValueError, IndexError): pass - + # Check for download speed and ETA - if '[download]' in line and '%' in line: + if "[download]" in line and "%" in line: # Try to extract more detailed status info try: # Look for speed - speed_match = re.search(r'at\s+(\d+\.\d+[KMG]iB/s)', line) + speed_match = re.search(r"at\s+(\d+\.\d+[KMG]iB/s)", line) speed_str = speed_match.group(1) if speed_match else "N/A" - + # Look for ETA - eta_match = re.search(r'ETA\s+(\d+:\d+)', line) + eta_match = re.search(r"ETA\s+(\d+:\d+)", line) eta_str = eta_match.group(1) if eta_match else "N/A" - + # Simplify status message to only show the speed and ETA status = f"Speed: {speed_str} | ETA: {eta_str}" self.update_details.emit(status) except Exception as e: # If parsing fails, just show basic status (maybe log the error) logger.error(f"Error parsing download details line: {line} -> {e}") - pass # Keep basic status emission below if needed, or emit generic details - + pass # Keep basic status emission below if needed, or emit generic details + # Check for post-processing - if '[Merger]' in line or 'Merging formats' in line: + if "[Merger]" in line or "Merging formats" in line: self.status_signal.emit("✨ Post-processing: Merging formats...") self.progress_signal.emit(95) - elif 'SponsorBlock' in line: + elif "SponsorBlock" in line: self.status_signal.emit("✨ Post-processing: Removing sponsor segments...") self.progress_signal.emit(97) - elif 'Deleting original file' in line: + elif "Deleting original file" in line: self.progress_signal.emit(98) - elif 'has already been downloaded' in line: + elif "has already been downloaded" in line: # File already exists - extract filename - match = re.search(r'(.*?) has already been downloaded', line) + match = re.search(r"(.*?) has already been downloaded", line) if match: - filename = os.path.basename(match.group(1)) + filename = Path(match.group(1)).name # Determine file type based on extension for existing file message - ext = os.path.splitext(filename)[1].lower() - - if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']: + ext = Path(filename).suffix.lower() + + if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]: self.status_signal.emit(f"⚠️ Video file already exists") - elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']: + elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]: self.status_signal.emit(f"⚠️ Audio file already exists") - elif ext in ['.vtt', '.srt', '.ass', '.ssa']: + elif ext in [".vtt", ".srt", ".ass", ".ssa"]: self.status_signal.emit(f"⚠️ Subtitle file already exists") else: self.status_signal.emit(f"⚠️ File already exists") - + self.file_exists_signal.emit(filename) else: logger.info(f"Could not extract filename from 'already downloaded' line: {line}") - self.status_signal.emit("⚠️ File already exists") # Fallback status - elif 'Finished downloading' in line: + self.status_signal.emit("⚠️ File already exists") # Fallback status + elif "Finished downloading" in line: self.progress_signal.emit(100) - + # Show completion message based on file type if self.current_filename: - ext = os.path.splitext(self.current_filename)[1].lower() - + ext = Path(self.current_filename).suffix.lower() + # Video file extensions - if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']: + if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]: self.status_signal.emit(f"✅ Video download completed!") # Audio file extensions - elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']: + elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]: self.status_signal.emit(f"✅ Audio download completed!") # Subtitle file extensions - elif ext in ['.vtt', '.srt', '.ass', '.ssa']: + elif ext in [".vtt", ".srt", ".ass", ".ssa"]: self.status_signal.emit(f"✅ Subtitle download completed!") # Default case else: self.status_signal.emit("✅ Download completed!") else: self.status_signal.emit("✅ Download completed!") - - self.update_details.emit("") # Clear details label on completion - - def _run_python_api(self): + + self.update_details.emit("") # Clear details label on completion + + def _run_python_api(self) -> None: """Original download method using Python API - kept for reference.""" # The existing run method code using yt_dlp.YoutubeDL starts here # This method is no longer used by default - def pause(self): + def pause(self) -> None: self.paused = True - def resume(self): + def resume(self) -> None: self.paused = False - def cancel(self): + def cancel(self) -> None: self.cancelled = True # Terminate the subprocess if it's running if self.process: try: self.process.terminate() except Exception: - pass \ No newline at end of file + pass diff --git a/src/core/ytsage_ffmpeg.py b/src/core/ytsage_ffmpeg.py index 4a423f2..c932d10 100644 --- a/src/core/ytsage_ffmpeg.py +++ b/src/core/ytsage_ffmpeg.py @@ -1,33 +1,39 @@ -import os -import sys -import subprocess -import requests -import shutil -import tempfile import hashlib +import os +import shutil +import subprocess +import tempfile from pathlib import Path -from PySide6.QtGui import QIcon -from .ytsage_logging import logger -def check_7zip_installed(): +import requests + +from src.core.ytsage_logging import logger +from src.utils.ytsage_constants import ( + FFMPEG_7Z_DOWNLOAD_URL, + FFMPEG_7Z_SHA256_URL, + FFMPEG_ZIP_DOWNLOAD_URL, + OS_NAME, + SUBPROCESS_CREATIONFLAGS, +) + + +def check_7zip_installed() -> bool: """Check if 7-Zip is installed on Windows.""" try: - subprocess.run(['7z', '--help'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) + subprocess.run(["7z", "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=SUBPROCESS_CREATIONFLAGS) return True except (subprocess.SubprocessError, FileNotFoundError): return False -def download_file(url, dest_path, progress_callback=None): + +def download_file(url, dest_path, progress_callback=None) -> bool: """Download a file from URL to destination path with progress indication.""" try: response = requests.get(url, stream=True, timeout=30) # Added timeout response.raise_for_status() # Check for HTTP errors - total_size = int(response.headers.get('content-length', 0)) - - with open(dest_path, 'wb') as f: + total_size = int(response.headers.get("content-length", 0)) + + with open(dest_path, "wb") as f: if total_size == 0: f.write(response.content) else: @@ -43,7 +49,8 @@ def download_file(url, dest_path, progress_callback=None): logger.info(f"Download error: {str(e)}") return False -def get_file_sha256(file_path): + +def get_file_sha256(file_path) -> str: """Calculate SHA-256 hash of a file.""" sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: @@ -51,17 +58,18 @@ def get_file_sha256(file_path): sha256_hash.update(chunk) return sha256_hash.hexdigest() -def verify_sha256(file_path, expected_hash_url): + +def verify_sha256(file_path, expected_hash_url) -> bool: """Verify file SHA-256 hash against expected hash from URL.""" try: # Download the SHA-256 hash response = requests.get(expected_hash_url, timeout=10) response.raise_for_status() expected_hash = response.text.strip().split()[0] # Get just the hash part - + # Calculate actual hash actual_hash = get_file_sha256(file_path) - + # Compare hashes if actual_hash.lower() == expected_hash.lower(): logger.info("SHA-256 verification successful!") @@ -75,20 +83,23 @@ def verify_sha256(file_path, expected_hash_url): logger.info(f"⚠️ SHA-256 verification error: {str(e)}") return False -def get_ffmpeg_install_path(): - """Get the FFmpeg installation path.""" - if sys.platform == 'win32': - return os.path.join(os.getenv('LOCALAPPDATA'), 'ffmpeg', 'ffmpeg-7.1.1-full_build', 'bin') - elif sys.platform == 'darwin': - paths = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin'] - for path in paths: - if os.path.exists(os.path.join(path, 'ffmpeg')): - return path - return '/usr/local/bin' # Default Homebrew path - else: - return '/usr/bin' # Standard Linux path -def get_ffmpeg_path(): +def get_ffmpeg_install_path() -> Path: + """Get the FFmpeg installation path.""" + if OS_NAME == "Windows": + return Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" / "ffmpeg-7.1.1-full_build" / "bin" # type: ignore + + elif OS_NAME == "Darwin": + paths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"] + for path in paths: + if Path(path).joinpath("ffmpeg").exists(): + return Path(path) + return Path("/usr/local/bin") # Default Homebrew path + else: + return Path("/usr/bin") # Standard Linux path + + +def get_ffmpeg_path() -> str | Path: """ Get the FFmpeg executable path, either from PATH or installation directory. Returns: @@ -96,150 +107,159 @@ def get_ffmpeg_path(): """ try: # First try to find ffmpeg in PATH using 'where' on Windows or 'which' on Unix - if sys.platform == 'win32': + if OS_NAME == "Windows": # On Windows, use 'where' command and hide console window - startupinfo = None - if hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + # Extra logic moved to src\utils\ytsage_constants.py + result = subprocess.run( - ['where', 'ffmpeg'], - capture_output=True, - text=True, + ["where", "ffmpeg"], + capture_output=True, + text=True, check=False, - startupinfo=startupinfo + creationflags=SUBPROCESS_CREATIONFLAGS, ) if result.returncode == 0 and result.stdout.strip(): - ffmpeg_path = result.stdout.strip().split('\n')[0] + ffmpeg_path = result.stdout.strip().split("\n")[0] return ffmpeg_path else: # On Unix systems, use 'which' command - result = subprocess.run(['which', 'ffmpeg'], capture_output=True, text=True, check=False) + result = subprocess.run(["which", "ffmpeg"], capture_output=True, text=True, check=False) if result.returncode == 0 and result.stdout.strip(): ffmpeg_path = result.stdout.strip() return ffmpeg_path except Exception as e: logger.error(f"Error finding ffmpeg in PATH: {e}") - + # If not found in PATH, check the installation directory ffmpeg_install_path = get_ffmpeg_install_path() - if sys.platform == 'win32': - ffmpeg_exe = os.path.join(ffmpeg_install_path, 'ffmpeg.exe') + if OS_NAME == "Windows": + ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg.exe") else: - ffmpeg_exe = os.path.join(ffmpeg_install_path, 'ffmpeg') - - if os.path.exists(ffmpeg_exe): + ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg") + + if ffmpeg_exe.exists(): return ffmpeg_exe - + # Return command name as fallback return "ffmpeg" -def check_ffmpeg_installed(): + +def check_ffmpeg_installed() -> bool: """Check if FFmpeg is installed and accessible.""" try: # First try the PATH - result = subprocess.run(['ffmpeg', '-version'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0, - timeout=5) # Added timeout + result = subprocess.run( + ["ffmpeg", "-version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + creationflags=SUBPROCESS_CREATIONFLAGS, + timeout=5, + ) # Added timeout return True except (subprocess.SubprocessError, FileNotFoundError): # If not in PATH, check the installation directory ffmpeg_path = get_ffmpeg_install_path() - if sys.platform == 'win32': - ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg.exe') + if OS_NAME == "Windows": + ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe") else: - ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg') - - if os.path.exists(ffmpeg_exe): + ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg") + + if ffmpeg_exe.exists(): # Add to PATH if found - os.environ['PATH'] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" + os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" return True return False except Exception as e: logger.info(f"FFmpeg check error: {str(e)}") return False -def install_ffmpeg_windows(): + +def install_ffmpeg_windows() -> bool: """Install FFmpeg on Windows using 7z method primarily, with zip as fallback.""" ffmpeg_path = get_ffmpeg_install_path() - + # Check if already installed if check_ffmpeg_installed(): logger.info("FFmpeg is already installed!") return True - + try: # Define variables - prioritize 7z version - ffmpeg_7z_url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.7z" - ffmpeg_zip_url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip" - sha256_url = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-7.1.1-full_build.7z.sha256" - extract_dir = os.path.join(os.getenv('LOCALAPPDATA'), 'ffmpeg') - full_build_dir = os.path.join(extract_dir, 'ffmpeg-7.1.1-full_build') - bin_dir = os.path.join(full_build_dir, 'bin') + # ffmpeg variables moved to src\utils\ytsage_constants.py + extract_dir = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore + full_build_dir = extract_dir / "ffmpeg-7.1.1-full_build" + bin_dir = full_build_dir / "bin" # Create extraction directory if it doesn't exist - os.makedirs(extract_dir, exist_ok=True) + extract_dir.mkdir(exist_ok=True) # Try 7z method first (smaller size) use_7zip = check_7zip_installed() if use_7zip: logger.info("Using 7-Zip method (smaller download size)...") - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.7z').name - + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".7z").name + # Download 7z file - if not download_file(ffmpeg_7z_url, temp_file, - progress_callback=lambda msg: logger.debug(msg)): + if not download_file( + FFMPEG_7Z_DOWNLOAD_URL, + temp_file, + progress_callback=lambda msg: logger.debug(msg), + ): logger.error("Failed to download 7z file, trying zip fallback...") use_7zip = False else: # Verify SHA-256 hash for 7z file - if verify_sha256(temp_file, sha256_url): + if verify_sha256(temp_file, FFMPEG_7Z_SHA256_URL): logger.info("Extracting FFmpeg components from 7z archive...") try: - subprocess.run(['7z', 'x', temp_file, f'-o{extract_dir}', '-y'], - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0, - timeout=300) # 5-minute timeout + subprocess.run( + ["7z", "x", temp_file, f"-o{extract_dir}", "-y"], + creationflags=SUBPROCESS_CREATIONFLAGS, + timeout=300, + ) # 5-minute timeout except Exception as e: logger.error(f"7z extraction failed: {str(e)}, trying zip fallback...") use_7zip = False else: logger.error("SHA-256 verification failed for 7z file, trying zip fallback...") use_7zip = False - + # Fallback to zip method if 7z failed or not available if not use_7zip: logger.info("Using ZIP method as fallback...") - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip').name - + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip").name + # Download zip file - if not download_file(ffmpeg_zip_url, temp_file, - progress_callback=lambda msg: logger.debug(msg)): + if not download_file( + FFMPEG_ZIP_DOWNLOAD_URL, + temp_file, + progress_callback=lambda msg: logger.debug(msg), + ): raise Exception("Failed to download FFmpeg (both 7z and zip methods failed)") logger.info("Extracting FFmpeg components from zip archive...") try: import zipfile - with zipfile.ZipFile(temp_file, 'r') as zip_ref: + + with zipfile.ZipFile(temp_file, "r") as zip_ref: zip_ref.extractall(extract_dir) except Exception as e: raise Exception(f"Extraction failed: {str(e)}") logger.info("Configuring system paths...") # Add to System Path - user_path = os.environ.get('PATH', '') - if bin_dir not in user_path: - subprocess.run(['setx', 'PATH', f"{user_path};{bin_dir}"], - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) - os.environ['PATH'] = f"{user_path};{bin_dir}" + user_path = os.environ.get("PATH", "") + if str(bin_dir) not in user_path.split(os.pathsep): + subprocess.run( + ["setx", "PATH", f"{user_path};{bin_dir}"], + creationflags=SUBPROCESS_CREATIONFLAGS, + ) + os.environ["PATH"] = f"{user_path};{bin_dir}" # Clean up try: - os.unlink(temp_file) + Path(temp_file).unlink(missing_ok=True) except Exception: pass # Ignore cleanup errors @@ -254,16 +274,19 @@ def install_ffmpeg_windows(): logger.error(f"Error installing FFmpeg: {str(e)}") return False -def install_ffmpeg_macos(): + +def install_ffmpeg_macos() -> bool: """Install FFmpeg on macOS using Homebrew.""" try: # Check if Homebrew is installed try: - subprocess.run(['brew', '--version'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - timeout=5) + subprocess.run( + ["brew", "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + timeout=5, + ) except (subprocess.SubprocessError, FileNotFoundError): logger.info("Installing Homebrew...") brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' @@ -271,56 +294,62 @@ def install_ffmpeg_macos(): # Install FFmpeg logger.info("Installing FFmpeg...") - subprocess.run(['brew', 'install', 'ffmpeg'], check=True, timeout=300) - + subprocess.run(["brew", "install", "ffmpeg"], check=True, timeout=300) + # Verify installation if not check_ffmpeg_installed(): raise Exception("FFmpeg installation verification failed") - + return True except Exception as e: logger.error(f"Error installing FFmpeg: {str(e)}") return False -def install_ffmpeg_linux(): + +def install_ffmpeg_linux() -> bool: """Install FFmpeg on Linux using appropriate package manager.""" try: # Detect the package manager - if shutil.which('apt'): + if shutil.which("apt"): # Debian/Ubuntu - subprocess.run(['sudo', 'apt', 'update'], check=True, timeout=60) - subprocess.run(['sudo', 'apt', 'install', '-y', 'ffmpeg'], check=True, timeout=300) - elif shutil.which('dnf'): + subprocess.run(["sudo", "apt", "update"], check=True, timeout=60) + subprocess.run(["sudo", "apt", "install", "-y", "ffmpeg"], check=True, timeout=300) + elif shutil.which("dnf"): # Fedora - subprocess.run(['sudo', 'dnf', 'install', '-y', 'ffmpeg'], check=True, timeout=300) - elif shutil.which('pacman'): + subprocess.run(["sudo", "dnf", "install", "-y", "ffmpeg"], check=True, timeout=300) + elif shutil.which("pacman"): # Arch Linux - subprocess.run(['sudo', 'pacman', '-S', '--noconfirm', 'ffmpeg'], check=True, timeout=300) - elif shutil.which('snap'): + subprocess.run( + ["sudo", "pacman", "-S", "--noconfirm", "ffmpeg"], + check=True, + timeout=300, + ) + elif shutil.which("snap"): # Universal snap package - subprocess.run(['sudo', 'snap', 'install', 'ffmpeg'], check=True, timeout=300) + subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300) else: raise Exception("No supported package manager found") - + # Verify installation if not check_ffmpeg_installed(): raise Exception("FFmpeg installation verification failed") - + return True except Exception as e: logger.error(f"Error installing FFmpeg: {str(e)}") return False -def auto_install_ffmpeg(): + +def auto_install_ffmpeg() -> bool: """Automatically install FFmpeg based on the operating system.""" - if sys.platform == 'win32': + if OS_NAME == "Windows": return install_ffmpeg_windows() - elif sys.platform == 'darwin': + elif OS_NAME == "Darwin": return install_ffmpeg_macos() - elif sys.platform.startswith('linux'): + elif OS_NAME == "Linux": return install_ffmpeg_linux() else: - logger.info(f"Unsupported operating system: {sys.platform}") - return False \ No newline at end of file + logger.info(f"Unsupported operating system: {OS_NAME}") + return False diff --git a/src/core/ytsage_logging.py b/src/core/ytsage_logging.py index 6e050ae..7a17589 100644 --- a/src/core/ytsage_logging.py +++ b/src/core/ytsage_logging.py @@ -5,71 +5,82 @@ This module provides centralized logging configuration for the entire YTSage app It replaces the inefficient print statements with structured logging using loguru. """ -import os import sys from pathlib import Path +from src.utils.ytsage_constants import APP_LOG_DIR + # Try to import loguru, but handle case where it might not be available try: from loguru import logger + LOGURU_AVAILABLE = True except ImportError: LOGURU_AVAILABLE = False + # Create a dummy logger class that does nothing class DummyLogger: - def info(self, *args, **kwargs): pass - def debug(self, *args, **kwargs): pass - def warning(self, *args, **kwargs): pass - def error(self, *args, **kwargs): pass - def critical(self, *args, **kwargs): pass - def remove(self, *args, **kwargs): pass - def add(self, *args, **kwargs): pass - def bind(self, *args, **kwargs): return self + def info(self, *args, **kwargs): + pass + + def debug(self, *args, **kwargs): + pass + + def warning(self, *args, **kwargs): + pass + + def error(self, *args, **kwargs): + pass + + def critical(self, *args, **kwargs): + pass + + def remove(self, *args, **kwargs): + pass + + def add(self, *args, **kwargs): + pass + + def bind(self, *args, **kwargs): + return self + @property def _core(self): class Core: handlers = [] + return Core() - + logger = DummyLogger() def setup_logging(): """ Configure loguru logging for YTSage application. - + Sets up multiple log levels and outputs: - Console output for INFO and above - - File output for DEBUG and above + - File output for DEBUG and above - Separate error log file for ERROR and above """ - + if not LOGURU_AVAILABLE: return logger - + # Remove default logger to avoid duplicate output try: logger.remove() except Exception: pass - + # Get the application data directory with fallbacks try: - if sys.platform == 'win32': - localappdata = os.environ.get('LOCALAPPDATA') - if localappdata: - log_dir = Path(localappdata) / 'YTSage' / 'logs' - else: - # Fallback for PyInstaller or when LOCALAPPDATA is not set - log_dir = Path.home() / 'AppData' / 'Local' / 'YTSage' / 'logs' - elif sys.platform == 'darwin': - log_dir = Path.home() / 'Library' / 'Application Support' / 'YTSage' / 'logs' - else: - log_dir = Path.home() / '.local' / 'share' / 'YTSage' / 'logs' + # logic moved to src\utils\ytsage_constants.py + log_dir = APP_LOG_DIR except Exception: # Ultimate fallback - use current directory - log_dir = Path.cwd() / 'logs' - + log_dir = Path.cwd() / "logs" + # Create log directory if it doesn't exist try: log_dir.mkdir(parents=True, exist_ok=True) @@ -80,11 +91,11 @@ def setup_logging(): log_dir.mkdir(exist_ok=True) except Exception: pass # If we still can't create it, we'll just log to console - + # Console handler - INFO and above, with colors # Check if stdout is available (it might be None in PyInstaller windowed apps) stdout_available = sys.stdout is not None - + if stdout_available: try: logger.add( @@ -92,7 +103,7 @@ def setup_logging(): level="INFO", format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", colorize=True, - catch=True + catch=True, ) except Exception: # Fallback to basic console logging without colors @@ -101,11 +112,11 @@ def setup_logging(): sys.stdout, level="INFO", format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", - catch=True + catch=True, ) except Exception: stdout_available = False - + # If stdout is not available, try stderr or skip console logging entirely if not stdout_available: try: @@ -114,26 +125,26 @@ def setup_logging(): sys.stderr, level="WARNING", # Only warnings and errors to stderr format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", - catch=True + catch=True, ) except Exception: # If even stderr fails, we'll rely only on file logging pass - + # Only add file handlers if we successfully created a log directory if log_dir and log_dir.exists(): try: # Main log file - DEBUG and above, with rotation logger.add( log_dir / "ytsage.log", - level="DEBUG", + level="DEBUG", format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", rotation="10 MB", # Rotate when file reaches 10MB retention="7 days", # Keep logs for 7 days compression="zip", # Compress old logs - catch=True + catch=True, ) - + # Error log file - ERROR and above only logger.add( log_dir / "ytsage_errors.log", @@ -142,12 +153,12 @@ def setup_logging(): rotation="5 MB", retention="30 days", # Keep error logs longer compression="zip", - catch=True + catch=True, ) except Exception as e: # If file logging fails, just log to console logger.warning(f"Could not set up file logging: {e}") - + # Log startup message if we have any handlers if logger._core.handlers: logger.info("YTSage logging system initialized") @@ -155,12 +166,13 @@ def setup_logging(): logger.debug(f"Log directory: {log_dir}") else: logger.warning("File logging disabled - could not create log directory") - + # If no handlers were successfully added, add a null handler to prevent errors if not logger._core.handlers: # Add a minimal handler that just discards messages # This prevents loguru from complaining about no handlers import tempfile + try: # Try to add a temporary file handler as last resort temp_log = Path(tempfile.gettempdir()) / "ytsage_temp.log" @@ -169,17 +181,17 @@ def setup_logging(): # If even that fails, we're in a very restricted environment # loguru should handle this gracefully with its internal fallbacks pass - + return logger -def get_logger(name: str = None): +def get_logger(name: str | None = None): """ Get a logger instance for a specific module. - + Args: name: Name of the module/component requesting the logger - + Returns: Configured logger instance """ @@ -191,12 +203,13 @@ def get_logger(name: str = None): # Initialize logging when module is imported - with maximum safety _setup_complete = False + def safe_setup(): """Safely initialize logging with multiple fallback strategies.""" global _setup_complete if _setup_complete: return logger - + try: setup_logging() _setup_complete = True @@ -207,12 +220,13 @@ def safe_setup(): logger.remove() except Exception: pass - + # At this point, just ensure we have something that won't crash _setup_complete = True - + return logger + # Try to set up logging, but don't let it crash the module import try: safe_setup() @@ -221,4 +235,4 @@ except Exception: pass # Export the main logger for convenience -__all__ = ['logger', 'get_logger', 'setup_logging'] +__all__ = ["logger", "get_logger", "setup_logging"] diff --git a/src/core/ytsage_style.py b/src/core/ytsage_style.py index 057313b..8c2c028 100644 --- a/src/core/ytsage_style.py +++ b/src/core/ytsage_style.py @@ -149,4 +149,4 @@ QMessageBox QLabel { QMessageBox QPushButton { min-width: 80px; } -""" \ No newline at end of file +""" diff --git a/src/core/ytsage_utils.py b/src/core/ytsage_utils.py index 19ab00a..829ae5a 100644 --- a/src/core/ytsage_utils.py +++ b/src/core/ytsage_utils.py @@ -1,191 +1,201 @@ -import sys -import os import json +import os +import subprocess +import sys +import tempfile import time from pathlib import Path -import subprocess -import tempfile -import shutil + import pkg_resources -from packaging import version import requests -from .ytsage_logging import logger -from .ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path, get_ffmpeg_path -from .ytsage_yt_dlp import get_yt_dlp_path # Import the new function to avoid import errors +from packaging import version + +from src.core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path +from src.core.ytsage_logging import logger +from src.core.ytsage_yt_dlp import get_yt_dlp_path +from src.utils.ytsage_constants import ( + APP_CONFIG_FILE, + OS_NAME, + SUBPROCESS_CREATIONFLAGS, + USER_HOME_DIR, + YTDLP_APP_BIN_PATH, + YTDLP_DOWNLOAD_URL, +) # Cache for version information to avoid delays _version_cache = { - 'ytdlp': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0}, - 'ffmpeg': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0} + "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, + "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, } # Cache expiry time in seconds (5 minutes) CACHE_EXPIRY = 300 -def get_file_mtime(filepath): + +def get_file_mtime(filepath) -> float: """Get file modification time safely.""" try: - if filepath and os.path.exists(filepath): - return os.path.getmtime(filepath) + if filepath and Path(filepath).exists(): + return Path(filepath).stat().st_mtime except Exception: pass return 0 -def should_refresh_cache(tool_name, current_path): + +def should_refresh_cache(tool_name, current_path) -> bool: """Determine if cache should be refreshed for a tool.""" cache = _version_cache.get(tool_name, {}) current_time = time.time() - + # Always refresh if no cached data - if not cache.get('version'): + if not cache.get("version"): return True - + # Refresh if path changed - if cache.get('path') != current_path: + if cache.get("path") != current_path: return True - + # Refresh if file was modified current_mtime = get_file_mtime(current_path) - if current_mtime > cache.get('path_mtime', 0): + if current_mtime > cache.get("path_mtime", 0): return True - + # Refresh if cache expired - if current_time - cache.get('last_check', 0) > CACHE_EXPIRY: + if current_time - cache.get("last_check", 0) > CACHE_EXPIRY: return True - + return False -def update_version_cache(tool_name, version_info, path, force_save=False): + +def update_version_cache(tool_name, version_info, path, force_save=False) -> None: """Update the version cache and optionally save to config.""" current_time = time.time() current_mtime = get_file_mtime(path) - + _version_cache[tool_name] = { - 'version': version_info, - 'path': path, - 'last_check': current_time, - 'path_mtime': current_mtime + "version": version_info, + "path": path, + "last_check": current_time, + "path_mtime": current_mtime, } - + # Save to persistent config if force_save: save_version_cache_to_config() -def load_version_cache_from_config(): + +def load_version_cache_from_config() -> None: """Load cached version info from config file.""" try: config = load_config() - cached_versions = config.get('cached_versions', {}) - + cached_versions = config.get("cached_versions", {}) + for tool_name, cache_data in cached_versions.items(): if tool_name in _version_cache: _version_cache[tool_name].update(cache_data) except Exception as e: logger.error(f"Error loading version cache: {e}") -def save_version_cache_to_config(): + +def save_version_cache_to_config() -> None: """Save version cache to config file.""" try: config = load_config() - config['cached_versions'] = _version_cache.copy() + config["cached_versions"] = _version_cache.copy() save_config(config) except Exception as e: logger.error(f"Error saving version cache: {e}") -def get_ytdlp_version_cached(): + +def get_ytdlp_version_cached() -> str: """Get yt-dlp version with caching support.""" try: current_path = get_yt_dlp_path() - + # Check if we need to refresh cache - if not should_refresh_cache('ytdlp', current_path): - cached_version = _version_cache['ytdlp'].get('version') + if not should_refresh_cache("ytdlp", current_path): + cached_version = _version_cache["ytdlp"].get("version") if cached_version: return cached_version - + # Get fresh version info version_info = get_ytdlp_version_direct(current_path) - + # Update cache - update_version_cache('ytdlp', version_info, current_path) - + update_version_cache("ytdlp", version_info, current_path) + return version_info except Exception as e: logger.error(f"Error getting cached yt-dlp version: {e}") return "Error getting version" -def get_ffmpeg_version_cached(): + +def get_ffmpeg_version_cached() -> str: """Get FFmpeg version with caching support.""" try: # Try to find ffmpeg path current_path = "ffmpeg" # Default to system PATH - + # Check if we need to refresh cache - if not should_refresh_cache('ffmpeg', current_path): - cached_version = _version_cache['ffmpeg'].get('version') + if not should_refresh_cache("ffmpeg", current_path): + cached_version = _version_cache["ffmpeg"].get("version") if cached_version: return cached_version - + # Get fresh version info version_info = get_ffmpeg_version_direct() - + # Update cache - update_version_cache('ffmpeg', version_info, current_path) - + update_version_cache("ffmpeg", version_info, current_path) + return version_info except Exception as e: logger.error(f"Error getting cached FFmpeg version: {e}") return "Error getting version" -def refresh_version_cache(force=False): + +def refresh_version_cache(force=False) -> bool: """Manually refresh version cache for both tools.""" try: # Refresh yt-dlp current_path = get_yt_dlp_path() version_info = get_ytdlp_version_direct(current_path) - update_version_cache('ytdlp', version_info, current_path, force_save=True) - + update_version_cache("ytdlp", version_info, current_path, force_save=True) + # Refresh FFmpeg version_info = get_ffmpeg_version_direct() - update_version_cache('ffmpeg', version_info, "ffmpeg", force_save=True) - + update_version_cache("ffmpeg", version_info, "ffmpeg", force_save=True) + return True except Exception as e: logger.error(f"Error refreshing version cache: {e}") return False -def get_ytdlp_version(): + +def get_ytdlp_version() -> str: """Get the version of yt-dlp (uses cached version for performance).""" return get_ytdlp_version_cached() -def get_ffmpeg_version(): + +def get_ffmpeg_version() -> str: """Get the version of FFmpeg (uses cached version for performance).""" return get_ffmpeg_version_cached() -def get_ytdlp_version_direct(yt_dlp_path=None): + +def get_ytdlp_version_direct(yt_dlp_path=None) -> str: """Get yt-dlp version directly without caching.""" try: if yt_dlp_path is None: yt_dlp_path = get_yt_dlp_path() - + if not yt_dlp_path or yt_dlp_path == "yt-dlp": return "Not found" - - # Create startupinfo to hide console on Windows - startupinfo = None - if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + + # Extra logic moved to src\utils\ytsage_constants.py result = subprocess.run( - [yt_dlp_path, '--version'], - capture_output=True, - text=True, - timeout=10, - startupinfo=startupinfo + [yt_dlp_path, "--version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS ) - + if result.returncode == 0: return result.stdout.strip() else: @@ -194,34 +204,25 @@ def get_ytdlp_version_direct(yt_dlp_path=None): logger.error(f"Error getting yt-dlp version: {e}") return "Error getting version" -def get_ffmpeg_version_direct(): + +def get_ffmpeg_version_direct() -> str: """Get FFmpeg version directly without caching.""" try: - # Create startupinfo to hide console on Windows - startupinfo = None - if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + # Extra logic moved to src\utils\ytsage_constants.py result = subprocess.run( - ['ffmpeg', '-version'], - capture_output=True, - text=True, - timeout=10, - startupinfo=startupinfo + ["ffmpeg", "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS ) - + if result.returncode == 0: # Parse the first line to get version info - lines = result.stdout.split('\n') + lines = result.stdout.split("\n") if lines: first_line = lines[0] # Extract version from something like "ffmpeg version 4.4.2 Copyright..." - if 'version' in first_line: + if "version" in first_line: parts = first_line.split() for i, part in enumerate(parts): - if part == 'version' and i + 1 < len(parts): + if part == "version" and i + 1 < len(parts): return parts[i + 1] return first_line.strip() return "Unknown version" @@ -231,28 +232,24 @@ def get_ffmpeg_version_direct(): # If ffmpeg is not in PATH, try the installation directory try: ffmpeg_path = get_ffmpeg_install_path() - if sys.platform == 'win32': - ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg.exe') + if OS_NAME == "Windows": + ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe") else: - ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg') - - if os.path.exists(ffmpeg_exe): + ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg") + + if ffmpeg_exe.exists(): result = subprocess.run( - [ffmpeg_exe, '-version'], - capture_output=True, - text=True, - timeout=10, - startupinfo=startupinfo + [ffmpeg_exe, "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS ) - + if result.returncode == 0: - lines = result.stdout.split('\n') + lines = result.stdout.split("\n") if lines: first_line = lines[0] - if 'version' in first_line: + if "version" in first_line: parts = first_line.split() for i, part in enumerate(parts): - if part == 'version' and i + 1 < len(parts): + if part == "version" and i + 1 < len(parts): return parts[i + 1] return first_line.strip() return "Unknown version" @@ -264,49 +261,32 @@ def get_ffmpeg_version_direct(): logger.error(f"Error getting FFmpeg version: {e}") return "Error getting version" -def get_app_data_dir(): - """Get the OS-specific application data directory.""" - if sys.platform == 'win32': - # Windows: %LOCALAPPDATA%\YTSage\data\ - return Path(os.environ.get('LOCALAPPDATA', '')) / 'YTSage' / 'data' - elif sys.platform == 'darwin': - # macOS: ~/Library/Application Support/YTSage/data/ - return Path.home() / 'Library' / 'Application Support' / 'YTSage' / 'data' - else: - # Linux: ~/.local/share/YTSage/data/ - return Path.home() / '.local' / 'share' / 'YTSage' / 'data' -def get_config_file_path(): - """Get the path to the main configuration file.""" - return get_app_data_dir() / 'ytsage_config.json' +# get_app_data_dir() moved to src\utils\ytsage_constants.py +# get_config_file_path() moved to src\utils\ytsage_constants.py +# ensure_app_data_dir() moved to src\utils\ytsage_constants.py -def ensure_app_data_dir(): - """Ensure the application data directory exists.""" - data_dir = get_app_data_dir() - data_dir.mkdir(parents=True, exist_ok=True) - return data_dir -def load_config(): +def load_config() -> dict: """Load the application configuration from file.""" - config_file = get_config_file_path() default_config = { - 'download_path': str(Path.home() / 'Downloads'), - 'speed_limit_value': None, - 'speed_limit_unit_index': 0, - 'cookie_file_path': None, - 'last_used_cookie_file': None, - 'auto_update_ytdlp': True, # Enable auto-update by default - 'auto_update_frequency': 'daily', # daily, weekly, or startup - 'last_update_check': 0, # timestamp of last check - 'cached_versions': { - 'ytdlp': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0}, - 'ffmpeg': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0} - } + "download_path": str(USER_HOME_DIR / "Downloads"), + "speed_limit_value": None, + "speed_limit_unit_index": 0, + "cookie_file_path": None, + "last_used_cookie_file": None, + "auto_update_ytdlp": True, # Enable auto-update by default + "auto_update_frequency": "daily", # daily, weekly, or startup + "last_update_check": 0, # timestamp of last check + "cached_versions": { + "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, + "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, + }, } - + try: - if config_file.exists(): - with open(config_file, 'r', encoding='utf-8') as f: + if APP_CONFIG_FILE.exists(): + with open(APP_CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) # Merge with defaults to ensure all keys exist for key, value in default_config.items(): @@ -317,178 +297,160 @@ def load_config(): logger.error(f"Error reading config file: {e}") # If config file is corrupted, create a new one with defaults save_config(default_config) - + return default_config -def save_config(config): + +def save_config(config) -> bool: """Save the application configuration to file.""" - config_file = get_config_file_path() try: - # Ensure the config directory exists - ensure_app_data_dir() - - with open(config_file, 'w', encoding='utf-8') as f: + with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False, indent=2) return True except Exception as e: logger.error(f"Error saving config: {e}") return False -def check_ffmpeg(): + +def check_ffmpeg() -> bool: """Check if FFmpeg is installed and accessible with enhanced error handling.""" try: # Use the enhanced FFmpeg check from ytsage_ffmpeg if check_ffmpeg_installed(): return True - + # For Windows, try to add the FFmpeg path to environment - if sys.platform == 'win32': + if OS_NAME == "Windows": ffmpeg_path = get_ffmpeg_install_path() - if os.path.exists(os.path.join(ffmpeg_path, 'ffmpeg.exe')): + if ffmpeg_path.joinpath("ffmpeg.exe").exists(): try: # Add to current session PATH - os.environ['PATH'] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" + os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" return True except Exception as e: logger.error(f"Error updating PATH: {e}") return False - + # For macOS, check common paths - elif sys.platform == 'darwin': + elif OS_NAME == "Darwin": common_paths = [ - '/usr/local/bin/ffmpeg', - '/opt/homebrew/bin/ffmpeg', - '/usr/bin/ffmpeg' + "/usr/local/bin/ffmpeg", + "/opt/homebrew/bin/ffmpeg", + "/usr/bin/ffmpeg", ] for path in common_paths: - if os.path.exists(path): + if Path(path).exists(): try: - ffmpeg_dir = os.path.dirname(path) - os.environ['PATH'] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}" + ffmpeg_dir = Path(path).parent + os.environ["PATH"] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}" return True except Exception as e: logger.error(f"Error updating PATH: {e}") continue - + return False - + except Exception as e: logger.error(f"Error checking FFmpeg: {e}") return False -def load_saved_path(main_window_instance): + +def load_saved_path(main_window_instance) -> None: """Load saved download path with enhanced error handling.""" - config_file = get_config_file_path() try: - if config_file.exists(): + if APP_CONFIG_FILE.exists(): try: - with open(config_file, 'r', encoding='utf-8') as f: + with open(APP_CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) - saved_path = config.get('download_path', '') - if os.path.exists(saved_path) and os.access(saved_path, os.W_OK): + saved_path = config.get("download_path", "") + if Path(saved_path).exists() and os.access(saved_path, os.W_OK): main_window_instance.last_path = saved_path return except (json.JSONDecodeError, UnicodeError) as e: logger.error(f"Error reading config file: {e}") # If config file is corrupted, try to remove it try: - os.remove(config_file) + APP_CONFIG_FILE.unlink(missing_ok=True) except Exception: pass - + # Fallback to Downloads folder - downloads_path = str(Path.home() / 'Downloads') - if os.path.exists(downloads_path) and os.access(downloads_path, os.W_OK): + downloads_path = USER_HOME_DIR / "Downloads" + if downloads_path.exists() and os.access(downloads_path, os.W_OK): main_window_instance.last_path = downloads_path else: # Final fallback to temp directory if Downloads is not accessible main_window_instance.last_path = tempfile.gettempdir() - + except Exception as e: logger.error(f"Error loading saved settings: {e}") main_window_instance.last_path = tempfile.gettempdir() -def save_path(main_window_instance, path): + +def save_path(main_window_instance, path) -> bool: """Save download path with enhanced error handling.""" - config_file = get_config_file_path() try: # Verify the path is valid and writable - if not os.path.exists(path): + if not Path(path).exists(): try: - os.makedirs(path, exist_ok=True) + Path(path).mkdir(exist_ok=True) except Exception as e: logger.error(f"Error creating directory: {e}") return False - + if not os.access(path, os.W_OK): logger.info("Path is not writable") return False - - # Ensure the config directory exists - ensure_app_data_dir() - + # Save the config - config = {'download_path': path} - with open(config_file, 'w', encoding='utf-8') as f: + config = {"download_path": path} + with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False) return True - + except Exception as e: logger.error(f"Error saving settings: {e}") return False -def update_yt_dlp(): + +def update_yt_dlp() -> bool: """Check for yt-dlp updates and update if a newer version is available.""" try: # Get the yt-dlp path yt_dlp_path = get_yt_dlp_path() - - # Create startupinfo to hide console on Windows - startupinfo = None - if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + + # Extra logic moved to src\utils\ytsage_constants.py + # For binaries downloaded with our app, use direct binary update approach - if os.path.dirname(yt_dlp_path) in [ - os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'), - os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')), - os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin')) - ]: + if yt_dlp_path.samefile(YTDLP_APP_BIN_PATH): # We're using a binary installed by our app, update directly logger.info(f"Updating yt-dlp binary at {yt_dlp_path}") - + # Determine the URL based on OS - if sys.platform == 'win32': - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" - elif sys.platform == 'darwin': - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" - else: - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" - + # Extra logic moved to src\utils\ytsage_constants.py + # Download the latest version try: - response = requests.get(url, stream=True) + response = requests.get(YTDLP_DOWNLOAD_URL, stream=True) if response.status_code == 200: # Create a temporary file temp_file = f"{yt_dlp_path}.new" - - with open(temp_file, 'wb') as f: + + with open(temp_file, "wb") as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) - + # Make executable on Unix systems - if sys.platform != 'win32': + if OS_NAME != "Windows": os.chmod(temp_file, 0o755) - + # Replace the old file with the new one try: # On Windows, we need to remove the old file first - if sys.platform == 'win32' and os.path.exists(yt_dlp_path): - os.remove(yt_dlp_path) - - os.rename(temp_file, yt_dlp_path) + if OS_NAME == "Windows" and yt_dlp_path.exists(): + yt_dlp_path.unlink(missing_ok=True) + + Path(temp_file).rename(yt_dlp_path) logger.info("yt-dlp binary successfully updated") return True except Exception as e: @@ -503,7 +465,7 @@ def update_yt_dlp(): else: # We're using a system-installed yt-dlp, use pip to update logger.info("Using pip to update yt-dlp") - + # Get current version try: current_version = pkg_resources.get_distribution("yt-dlp").version @@ -511,7 +473,7 @@ def update_yt_dlp(): except pkg_resources.DistributionNotFound: logger.info("yt-dlp not installed via pip, attempting update anyway") current_version = "0.0.0" # Assume very old version to force update - + # Get the latest version from PyPI JSON API try: response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) @@ -519,16 +481,23 @@ def update_yt_dlp(): data = response.json() latest_version = data["info"]["version"] logger.info(f"Latest available yt-dlp version: {latest_version}") - + # Compare versions and update if needed if version.parse(latest_version) > version.parse(current_version): logger.info(f"Updating yt-dlp from {current_version} to {latest_version}...") update_result = subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"], + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "yt-dlp", + ], capture_output=True, text=True, check=False, - startupinfo=startupinfo + creationflags=SUBPROCESS_CREATIONFLAGS, ) if update_result.returncode == 0: logger.info("yt-dlp successfully updated") @@ -544,75 +513,76 @@ def update_yt_dlp(): logger.error(f"Error checking for yt-dlp updates: {e}") except Exception as e: logger.info(f"Unexpected error during yt-dlp update: {e}") - + return False -def should_check_for_auto_update(): +def should_check_for_auto_update() -> bool: """Check if auto-update should be performed based on user settings.""" try: config = load_config() - + # Check if auto-update is enabled - if not config.get('auto_update_ytdlp', False): + if not config.get("auto_update_ytdlp", False): return False - - frequency = config.get('auto_update_frequency', 'daily') - last_check = config.get('last_update_check', 0) + + frequency = config.get("auto_update_frequency", "daily") + last_check = config.get("last_update_check", 0) current_time = time.time() - + # Calculate time since last check time_diff = current_time - last_check - - if frequency == 'startup': + + if frequency == "startup": # Always check on startup if we haven't checked in the last hour return time_diff > 3600 # 1 hour - elif frequency == 'daily': + elif frequency == "daily": return time_diff > 86400 # 24 hours - elif frequency == 'weekly': + elif frequency == "weekly": return time_diff > 604800 # 7 days - + return False except Exception as e: logger.error(f"Error checking auto-update schedule: {e}") return False -def check_and_update_ytdlp_auto(): +def check_and_update_ytdlp_auto() -> bool: """Perform automatic yt-dlp update check and update if needed.""" try: logger.info("Performing automatic yt-dlp update check...") - + # Get current version current_version = get_ytdlp_version() if "Error" in current_version: logger.info("Could not determine current yt-dlp version, skipping auto-update") return False - + # Get latest version from PyPI try: response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) response.raise_for_status() latest_version = response.json()["info"]["version"] - + # Clean up version strings - current_version = current_version.replace('_', '.') - latest_version = latest_version.replace('_', '.') - + current_version = current_version.replace("_", ".") + latest_version = latest_version.replace("_", ".") + logger.info(f"Current yt-dlp version: {current_version}") logger.info(f"Latest yt-dlp version: {latest_version}") - + # Compare versions from packaging import version as version_parser + if version_parser.parse(latest_version) > version_parser.parse(current_version): logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...") - + # Perform the update if update_yt_dlp(): logger.info("Auto-update completed successfully!") # Update the last check timestamp config = load_config() - config['last_update_check'] = time.time() + config["last_update_check"] = time.time() save_config(config) return True else: @@ -622,40 +592,40 @@ def check_and_update_ytdlp_auto(): logger.info("yt-dlp is already up to date") # Still update the timestamp even if no update was needed config = load_config() - config['last_update_check'] = time.time() + config["last_update_check"] = time.time() save_config(config) return True - + except requests.RequestException as e: logger.info(f"Network error during auto-update check: {e}") return False except Exception as e: logger.error(f"Error during auto-update check: {e}") return False - + except Exception as e: logger.info(f"Critical error in auto-update: {e}") return False -def get_auto_update_settings(): +def get_auto_update_settings() -> dict: """Get current auto-update settings from config.""" config = load_config() return { - 'enabled': config.get('auto_update_ytdlp', True), - 'frequency': config.get('auto_update_frequency', 'daily'), - 'last_check': config.get('last_update_check', 0) + "enabled": config.get("auto_update_ytdlp", True), + "frequency": config.get("auto_update_frequency", "daily"), + "last_check": config.get("last_update_check", 0), } -def update_auto_update_settings(enabled, frequency): +def update_auto_update_settings(enabled, frequency) -> bool: """Update auto-update settings in config.""" try: config = load_config() - config['auto_update_ytdlp'] = enabled - config['auto_update_frequency'] = frequency + config["auto_update_ytdlp"] = enabled + config["auto_update_frequency"] = frequency save_config(config) return True except Exception as e: logger.error(f"Error updating auto-update settings: {e}") - return False \ No newline at end of file + return False diff --git a/src/core/ytsage_yt_dlp.py b/src/core/ytsage_yt_dlp.py index 86ad30d..b0b2a30 100644 --- a/src/core/ytsage_yt_dlp.py +++ b/src/core/ytsage_yt_dlp.py @@ -1,87 +1,64 @@ import os -import sys -import platform import shutil import subprocess -import requests from pathlib import Path -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QLabel, QPushButton, - QProgressBar, QRadioButton, QHBoxLayout, - QMessageBox, QFileDialog, QWidget) -from PySide6.QtCore import QThread, Signal, Qt +from typing import Optional + +import requests +from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QIcon -from .ytsage_logging import logger +from PySide6.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QProgressBar, + QPushButton, + QRadioButton, + QVBoxLayout, + QWidget, +) -# Define binary URLs -YTDLP_URLS = { - "windows": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe", - "macos": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos", - "linux": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -} +from src.core.ytsage_logging import logger +from src.utils.ytsage_constants import ( + APP_BIN_DIR, + ICON_PATH, + OS_FULL_NAME, + OS_NAME, + SUBPROCESS_CREATIONFLAGS, + YTDLP_APP_BIN_PATH, + YTDLP_DOWNLOAD_URL, +) -# Define installation paths -def get_ytdlp_install_dir(): - """Get the OS-specific yt-dlp installation directory""" - if sys.platform == 'win32': - # Windows: %LOCALAPPDATA%\YTSage\bin\ - return os.path.join(os.environ.get('LOCALAPPDATA'), 'YTSage', 'bin') - elif sys.platform == 'darwin': - # macOS: ~/Library/Application Support/YTSage/bin/ - return os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')) - else: - # Linux: ~/.local/share/YTSage/bin/ - return os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin')) +# YTDLP_URLS moved to src\utils\ytsage_constants.py +# get_ytdlp_install_dir() moved to src\utils\ytsage_constants.py +# get_ytdlp_executable_path() moved to src\utils\ytsage_constants.py +# get_os_type() moved to src\utils\ytsage_constants.py +# ensure_install_dir_exists() moved to src\utils\ytsage_constants.py -def get_ytdlp_executable_path(): - """Get the full path to the yt-dlp executable based on OS""" - install_dir = get_ytdlp_install_dir() - if sys.platform == 'win32': - return os.path.join(install_dir, 'yt-dlp.exe') - else: - return os.path.join(install_dir, 'yt-dlp') - -def get_os_type(): - """Detect the operating system""" - if sys.platform == 'win32': - return "windows" - elif sys.platform == 'darwin': - return "macos" - else: - return "linux" - -def ensure_install_dir_exists(): - """Make sure the installation directory exists""" - install_dir = get_ytdlp_install_dir() - os.makedirs(install_dir, exist_ok=True) - return install_dir class DownloadYtdlpThread(QThread): progress_signal = Signal(int) finished_signal = Signal(bool, str) - - def __init__(self, os_type): + + def __init__(self): super().__init__() - self.os_type = os_type - - def run(self): + + def run(self) -> None: try: - url = YTDLP_URLS[self.os_type] - install_dir = ensure_install_dir_exists() - - if self.os_type == "windows": - exe_path = os.path.join(install_dir, "yt-dlp.exe") - else: - exe_path = os.path.join(install_dir, "yt-dlp") - + # Extra logic moved to src\utils\ytsage_constants.py + exe_path = YTDLP_APP_BIN_PATH + # Download with progress reporting - response = requests.get(url, stream=True) - total_size = int(response.headers.get('content-length', 0)) + response = requests.get(YTDLP_DOWNLOAD_URL, stream=True) + total_size = int(response.headers.get("content-length", 0)) block_size = 1024 # 1 Kibibyte - + if total_size == 0: self.progress_signal.emit(100) - - with open(exe_path, 'wb') as f: + + with open(exe_path, "wb") as f: downloaded = 0 for data in response.iter_content(block_size): f.write(data) @@ -89,44 +66,41 @@ class DownloadYtdlpThread(QThread): if total_size > 0: progress = int(downloaded / total_size * 100) self.progress_signal.emit(progress) - + # Make executable on macOS and Linux - if self.os_type != "windows": + if OS_NAME != "Windows": os.chmod(exe_path, 0o755) - + self.finished_signal.emit(True, exe_path) - + except Exception as e: self.finished_signal.emit(False, str(e)) + class YtdlpSetupDialog(QDialog): setup_complete = Signal(str) # Signal emitting the path to yt-dlp - + def __init__(self, parent=None): super().__init__(parent) - self.os_type = get_os_type() self.setWindowTitle("yt-dlp Setup Required") self.setMinimumWidth(520) self.setMinimumHeight(350) self.resize(520, 380) - + # Set the window icon to match the main app if parent and parent.windowIcon(): self.setWindowIcon(parent.windowIcon()) else: - # Try to load the icon directly if parent not available - # Navigate from src/core/ to project root, then to assets/Icon/ - current_dir = os.path.dirname(os.path.abspath(__file__)) # core/ - src_dir = os.path.dirname(current_dir) # src/ - project_root = os.path.dirname(src_dir) # project root - icon_path = os.path.join(project_root, 'assets', 'Icon', 'icon.png') - if os.path.exists(icon_path): - self.setWindowIcon(QIcon(icon_path)) - + # icon_path logic moved to src\utils\ytsage_constants.py + icon_path = ICON_PATH + if Path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path.as_posix())) + self.init_ui() - + # Apply dark theme styling to match app - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; color: #ffffff; @@ -190,55 +164,54 @@ class YtdlpSetupDialog(QDialog): border: 2px solid #c90000; background: #c90000; } - """) - - def init_ui(self): + """ + ) + + def init_ui(self) -> None: layout = QVBoxLayout() layout.setSpacing(15) layout.setContentsMargins(25, 25, 25, 25) - + # Header title title_label = QLabel("yt-dlp Setup Required") title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;") - title_label.setAlignment(Qt.AlignCenter) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title_label) - + # Information label with improved styling - if self.os_type == "windows": - os_name = "Windows" - elif self.os_type == "macos": - os_name = "macOS" - else: - os_name = "Linux" - - info_label = QLabel(f"YTSage requires yt-dlp to download videos.

" - f"yt-dlp was not found in the app's local directory. " - f"YTSage needs to set up yt-dlp for your {os_name} system.

" - f"Please choose an option below:") - info_label.setAlignment(Qt.AlignCenter) + # os_name logic moved to src\utils\ytsage_constants.py + + info_label = QLabel( + f"YTSage requires yt-dlp to download videos.

" + f"yt-dlp was not found in the app's local directory. " + f"YTSage needs to set up yt-dlp for your {OS_FULL_NAME} system.

" + f"Please choose an option below:" + ) + info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) info_label.setWordWrap(True) info_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 5px; line-height: 1.4;") layout.addWidget(info_label) - + # Radio buttons with minimal spacing option_widget = QWidget() option_layout = QVBoxLayout(option_widget) option_layout.setSpacing(8) option_layout.setContentsMargins(0, 0, 0, 0) - + self.auto_radio = QRadioButton("Download automatically (Recommended)") self.auto_radio.setChecked(True) self.manual_radio = QRadioButton("Select path manually") - + option_layout.addWidget(self.auto_radio) option_layout.addWidget(self.manual_radio) layout.addWidget(option_widget) - + # Progress bar with proper sizing self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) self.progress_bar.setFixedHeight(20) # Fixed height for consistency - self.progress_bar.setStyleSheet(""" + self.progress_bar.setStyleSheet( + """ QProgressBar { border: 1px solid #3d3d3d; border-radius: 8px; @@ -254,61 +227,62 @@ class YtdlpSetupDialog(QDialog): border-radius: 6px; margin: 1px; } - """) + """ + ) layout.addWidget(self.progress_bar) - + # Status label with better spacing self.status_label = QLabel("") - self.status_label.setAlignment(Qt.AlignCenter) + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setStyleSheet("font-size: 12px; color: #cccccc; padding: 8px 0;") self.status_label.setWordWrap(True) layout.addWidget(self.status_label) - + # Add stretch to push buttons to bottom layout.addStretch() - + # Button layout with improved spacing button_layout = QHBoxLayout() button_layout.setSpacing(15) button_layout.setContentsMargins(0, 10, 0, 0) # Add top margin for buttons - + self.setup_button = QPushButton("Setup yt-dlp") self.setup_button.clicked.connect(self.setup_ytdlp) - + self.cancel_button = QPushButton("Cancel") self.cancel_button.clicked.connect(self.reject) - + button_layout.addWidget(self.setup_button) button_layout.addWidget(self.cancel_button) layout.addLayout(button_layout) - + self.setLayout(layout) - - def setup_ytdlp(self): + + def setup_ytdlp(self) -> None: if self.auto_radio.isChecked(): self.download_ytdlp() else: self.select_ytdlp_path() - - def download_ytdlp(self): + + def download_ytdlp(self) -> None: self.progress_bar.setVisible(True) self.progress_bar.setValue(0) self.status_label.setText("Downloading yt-dlp...") self.setup_button.setEnabled(False) self.cancel_button.setEnabled(False) - - self.download_thread = DownloadYtdlpThread(self.os_type) + + self.download_thread = DownloadYtdlpThread() self.download_thread.progress_signal.connect(self.update_progress) self.download_thread.finished_signal.connect(self.download_finished) self.download_thread.start() - - def update_progress(self, value): + + def update_progress(self, value) -> None: self.progress_bar.setValue(value) - - def download_finished(self, success, result): + + def download_finished(self, success, result) -> None: self.setup_button.setEnabled(True) self.cancel_button.setEnabled(True) - + if success: self.status_label.setText("yt-dlp was successfully installed!") self.setup_complete.emit(result) @@ -316,12 +290,13 @@ class YtdlpSetupDialog(QDialog): else: self.status_label.setText(f"Error: {result}") error_dialog = QMessageBox(self) - error_dialog.setIcon(QMessageBox.Critical) + error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setWindowTitle("Download Failed") error_dialog.setText(f"Failed to download yt-dlp: {result}") # Set the window icon to match the main dialog error_dialog.setWindowIcon(self.windowIcon()) - error_dialog.setStyleSheet(""" + error_dialog.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -340,18 +315,20 @@ class YtdlpSetupDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) + """ + ) error_dialog.exec() - - def select_ytdlp_path(self): - if self.os_type == "windows": + + def select_ytdlp_path(self) -> None: + if OS_NAME == "Windows": file_filter = "Executable Files (*.exe)" else: file_filter = "All Files (*)" - + # Apply style to QFileDialog file_dialog = QFileDialog(self) - file_dialog.setStyleSheet(""" + file_dialog.setStyleSheet( + """ QFileDialog { background-color: #15181b; color: #ffffff; @@ -371,57 +348,42 @@ class YtdlpSetupDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) - - file_path, _ = file_dialog.getOpenFileName( - self, "Select yt-dlp executable", "", file_filter + """ ) - + + file_path, _ = file_dialog.getOpenFileName(self, "Select yt-dlp executable", "", file_filter) + if file_path: logger.debug(f"User selected file: {file_path}") # Verify the selected file try: - # Set up startupinfo to hide console window on Windows - startupinfo = None - if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + # Extra logic moved to src\utils\ytsage_constants.py # Try to run yt-dlp --version logger.debug(f"Verifying file with --version command") result = subprocess.run( - [file_path, "--version"], - capture_output=True, - text=True, - check=False, - startupinfo=startupinfo + [file_path, "--version"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS ) logger.debug(f"Version check result: {result.returncode}, Output: {result.stdout.strip()}") - + if result.returncode == 0: # File is valid, copy it to our app's bin directory try: # Ensure the bin directory exists - install_dir = ensure_install_dir_exists() - logger.debug(f"Install directory: {install_dir}") - + logger.debug(f"Install directory: {APP_BIN_DIR}") + # Determine the target filename based on OS - if self.os_type == "windows": - target_path = os.path.join(install_dir, "yt-dlp.exe") - else: - target_path = os.path.join(install_dir, "yt-dlp") + target_path = YTDLP_APP_BIN_PATH logger.debug(f"Target path: {target_path}") - + # Copy the file shutil.copy2(file_path, target_path) logger.debug(f"File copied successfully") - + # Set executable permissions on Unix systems - if self.os_type != "windows": + if OS_NAME != "Windows": os.chmod(target_path, 0o755) logger.debug(f"Permissions set on Unix system") - + # Return the path of the copied file self.status_label.setText(f"yt-dlp successfully copied to {target_path}") logger.debug(f"Emitting setup_complete signal with path: {target_path}") @@ -430,10 +392,11 @@ class YtdlpSetupDialog(QDialog): except Exception as copy_error: logger.debug(f"Error copying file: {str(copy_error)}") error_dialog = QMessageBox(self) - error_dialog.setIcon(QMessageBox.Critical) + error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setWindowTitle("Setup Error") error_dialog.setText(f"Error copying yt-dlp to app directory: {str(copy_error)}") - error_dialog.setStyleSheet(""" + error_dialog.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -452,15 +415,17 @@ class YtdlpSetupDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) + """ + ) error_dialog.exec() else: logger.debug(f"File verification failed with return code: {result.returncode}") error_dialog = QMessageBox(self) - error_dialog.setIcon(QMessageBox.Warning) + error_dialog.setIcon(QMessageBox.Icon.Warning) error_dialog.setWindowTitle("Invalid Executable") error_dialog.setText("The selected file does not appear to be a valid yt-dlp executable.") - error_dialog.setStyleSheet(""" + error_dialog.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -479,15 +444,17 @@ class YtdlpSetupDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) + """ + ) error_dialog.exec() except Exception as e: logger.debug(f"Exception during verification: {str(e)}") error_dialog = QMessageBox(self) - error_dialog.setIcon(QMessageBox.Critical) + error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setWindowTitle("Error") error_dialog.setText(f"Error verifying yt-dlp executable: {str(e)}") - error_dialog.setStyleSheet(""" + error_dialog.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -506,63 +473,57 @@ class YtdlpSetupDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) + """ + ) error_dialog.exec() -def check_ytdlp_binary(): + +def check_ytdlp_binary() -> Optional[Path]: """ Check if yt-dlp binary exists in the expected location. Returns: - str or None: Path to yt-dlp binary if found, None otherwise + Path or None: Path to yt-dlp binary if found, None otherwise """ - exe_path = get_ytdlp_executable_path() - if os.path.exists(exe_path): + exe_path = YTDLP_APP_BIN_PATH + if exe_path.exists(): # Make sure it's executable on Unix systems - if sys.platform != 'win32' and not os.access(exe_path, os.X_OK): + if OS_NAME != "Windows" and not os.access(exe_path, os.X_OK): try: os.chmod(exe_path, 0o755) logger.info(f"Fixed permissions on yt-dlp at {exe_path}") except Exception as e: logger.warning(f"Could not set executable permissions on {exe_path}: {e}") - return None return exe_path - + # If not found in app directory, check if yt-dlp is available in PATH try: # Use subprocess to check if yt-dlp is available - if sys.platform == 'win32': + if OS_NAME == "Windows": # On Windows, use 'where' command and hide console window - startupinfo = None - if hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + # Extra logic moved to src\utils\ytsage_constants.py + result = subprocess.run( - ['where', 'yt-dlp'], - capture_output=True, - text=True, - check=False, - startupinfo=startupinfo + ["where", "yt-dlp"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS ) if result.returncode == 0 and result.stdout.strip(): - yt_dlp_path = result.stdout.strip().split('\n')[0] + yt_dlp_path = result.stdout.strip().split("\n")[0] logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}") - return yt_dlp_path + return Path(yt_dlp_path) else: # On Unix systems, use 'which' command - result = subprocess.run(['which', 'yt-dlp'], capture_output=True, text=True, check=False) + result = subprocess.run(["which", "yt-dlp"], capture_output=True, text=True, check=False) if result.returncode == 0 and result.stdout.strip(): yt_dlp_path = result.stdout.strip() logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}") - return yt_dlp_path + return Path(yt_dlp_path) except Exception as e: logger.error(f"Error checking for yt-dlp in PATH: {e}") - - # We're only interested in our app-specific installation or system PATH + # We're only interested in our app-specific installation or system PATH + return None -def check_ytdlp_installed(): + +def check_ytdlp_installed() -> bool: """ Check if yt-dlp is installed and accessible. Returns: @@ -573,19 +534,9 @@ def check_ytdlp_installed(): if ytdlp_path: # Try to run yt-dlp --version to verify it's working try: - # Create startupinfo to hide console on Windows - startupinfo = None - if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + # Extra logic moved to src\utils\ytsage_constants.py result = subprocess.run( - [ytdlp_path, '--version'], - capture_output=True, - text=True, - timeout=5, - startupinfo=startupinfo + [ytdlp_path, "--version"], capture_output=True, text=True, timeout=5, creationflags=SUBPROCESS_CREATIONFLAGS ) return result.returncode == 0 except Exception: @@ -594,7 +545,8 @@ def check_ytdlp_installed(): except Exception: return False -def get_yt_dlp_path(): + +def get_yt_dlp_path() -> Path: """ Get the yt-dlp path, either from the app's bin directory or system PATH. This replaces the function in ytsage_utils.py. @@ -606,10 +558,11 @@ def get_yt_dlp_path(): if ytdlp_path: logger.info(f"Using yt-dlp from: {ytdlp_path}") return ytdlp_path - + # If not found anywhere, fall back to the command name as a last resort logger.info("yt-dlp not found in app directory or PATH, falling back to command name") - return "yt-dlp" + return "yt-dlp" # type: ignore[return-value] + def setup_ytdlp(parent_widget=None): """ @@ -619,33 +572,33 @@ def setup_ytdlp(parent_widget=None): """ logger.debug("Starting yt-dlp setup dialog") dialog = YtdlpSetupDialog(parent_widget) - + # Store the setup result from the signal setup_result = {"path": None} - - def on_setup_complete(path): + + def on_setup_complete(path) -> None: logger.debug(f"Received setup_complete signal with path: {path}") setup_result["path"] = path - + # Connect to the setup_complete signal dialog.setup_complete.connect(on_setup_complete) - + # Show the dialog result = dialog.exec() - logger.debug(f"Dialog result: {result} (Accepted={QDialog.Accepted})") - - if result == QDialog.Accepted: + logger.debug(f"Dialog result: {result} (Accepted={QDialog.DialogCode.Accepted})") + + if result == QDialog.DialogCode.Accepted: # First check if we received a path from the signal - if setup_result["path"] and os.path.exists(setup_result["path"]): + if setup_result["path"] and Path.exists(setup_result["path"]): logger.debug(f"Using path from signal: {setup_result['path']}") return setup_result["path"] - + # Get the expected path for verification as fallback - expected_path = get_ytdlp_executable_path() + expected_path = YTDLP_APP_BIN_PATH logger.debug(f"Expected yt-dlp path: {expected_path}") - + # Verify the path exists after dialog is accepted - if os.path.exists(expected_path): + if Path.exists(expected_path): logger.debug(f"yt-dlp successfully found at expected path: {expected_path}") return expected_path else: @@ -653,20 +606,21 @@ def setup_ytdlp(parent_widget=None): # Try to use the get_yt_dlp_path function to find yt-dlp elsewhere yt_dlp_path = get_yt_dlp_path() logger.debug(f"Alternate detection result: {yt_dlp_path}") - if yt_dlp_path != "yt-dlp" and os.path.exists(yt_dlp_path): + if yt_dlp_path != "yt-dlp" and Path.exists(yt_dlp_path): logger.debug(f"yt-dlp found at alternate location: {yt_dlp_path}") return yt_dlp_path - + # Something went wrong, show an error message logger.debug(f"Setup failed, showing error dialog") if parent_widget: error_dialog = QMessageBox(parent_widget) - error_dialog.setIcon(QMessageBox.Warning) + error_dialog.setIcon(QMessageBox.Icon.Warning) error_dialog.setWindowTitle("Setup Failed") error_dialog.setText("Failed to set up yt-dlp. Some features may not work correctly.") # Set the window icon to match the parent error_dialog.setWindowIcon(parent_widget.windowIcon()) - error_dialog.setStyleSheet(""" + error_dialog.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -685,12 +639,13 @@ def setup_ytdlp(parent_widget=None): QPushButton:hover { background-color: #a50000; } - """) + """ + ) error_dialog.exec() logger.warning(f"yt-dlp setup failed, path does not exist: {expected_path}") else: logger.debug("User cancelled the setup dialog") - + # User cancelled or setup failed, return the fallback command logger.debug("Returning fallback command 'yt-dlp'") - return "yt-dlp" \ No newline at end of file + return "yt-dlp" diff --git a/src/gui/dialogs/__init__.py b/src/gui/dialogs/__init__.py deleted file mode 100644 index a3a14b7..0000000 --- a/src/gui/dialogs/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Dialog modules for YTSage GUI. - -This package contains all dialog classes organized by functionality: -- Base dialogs (LogWindow, AboutDialog) -- Settings dialogs (DownloadSettingsDialog, AutoUpdateSettingsDialog) -- Update dialogs (YTDLPUpdateDialog, update threads) -- FFmpeg dialogs (FFmpegCheckDialog, installation) -- Selection dialogs (SubtitleSelectionDialog, PlaylistSelectionDialog) -- Custom dialogs (CustomCommandDialog, CookieLoginDialog, etc.) -""" - -# Re-export all dialog classes for backward compatibility -from .ytsage_dialogs_base import LogWindow, AboutDialog -from .ytsage_dialogs_settings import DownloadSettingsDialog, AutoUpdateSettingsDialog -from .ytsage_dialogs_update import (VersionCheckThread, UpdateThread, YTDLPUpdateDialog, - AutoUpdateThread) -from .ytsage_dialogs_ffmpeg import FFmpegInstallThread, FFmpegCheckDialog -from .ytsage_dialogs_selection import SubtitleSelectionDialog, PlaylistSelectionDialog, SponsorBlockCategoryDialog -from .ytsage_dialogs_custom import (CustomCommandDialog, CookieLoginDialog, - CustomOptionsDialog, TimeRangeDialog) - -__all__ = [ - # Base dialogs - 'LogWindow', 'AboutDialog', - - # Settings dialogs - 'DownloadSettingsDialog', 'AutoUpdateSettingsDialog', - - # Update dialogs and threads - 'VersionCheckThread', 'UpdateThread', 'YTDLPUpdateDialog', 'AutoUpdateThread', - - # FFmpeg dialogs - 'FFmpegInstallThread', 'FFmpegCheckDialog', - - # Selection dialogs - 'SubtitleSelectionDialog', 'PlaylistSelectionDialog', 'SponsorBlockCategoryDialog', - - # Custom functionality dialogs - 'CustomCommandDialog', 'CookieLoginDialog', 'CustomOptionsDialog', 'TimeRangeDialog' -] diff --git a/src/gui/ytsage_gui_dialogs.py b/src/gui/ytsage_gui_dialogs.py deleted file mode 100644 index a925021..0000000 --- a/src/gui/ytsage_gui_dialogs.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -YTSage GUI Dialogs Module - -This module serves as a centralized import point for all dialog classes in YTSage. -The dialogs have been split into logical modules for better maintainability: - -- dialogs.ytsage_dialogs_base: Base utility dialogs (LogWindow, AboutDialog) -- dialogs.ytsage_dialogs_settings: Settings configuration dialogs -- dialogs.ytsage_dialogs_update: Update-related dialogs and threads -- dialogs.ytsage_dialogs_ffmpeg: FFmpeg installation dialogs -- dialogs.ytsage_dialogs_selection: Subtitle and playlist selection dialogs -- dialogs.ytsage_dialogs_custom: Custom functionality dialogs -""" - -# Import all dialog classes from the dialogs package -from .dialogs import * - -# For backward compatibility, re-export all dialog classes -__all__ = [ - # Base dialogs - 'LogWindow', 'AboutDialog', - - # Settings dialogs - 'DownloadSettingsDialog', 'AutoUpdateSettingsDialog', - - # Update dialogs and threads - 'VersionCheckThread', 'UpdateThread', 'YTDLPUpdateDialog', 'AutoUpdateThread', - - # FFmpeg dialogs - 'FFmpegInstallThread', 'FFmpegCheckDialog', - - # Selection dialogs - 'SubtitleSelectionDialog', 'PlaylistSelectionDialog', 'SponsorBlockCategoryDialog', - - # Custom functionality dialogs - 'CustomCommandDialog', 'CookieLoginDialog', 'CustomOptionsDialog', 'TimeRangeDialog' -] diff --git a/src/gui/ytsage_gui_dialogs/__init__.py b/src/gui/ytsage_gui_dialogs/__init__.py new file mode 100644 index 0000000..013d97b --- /dev/null +++ b/src/gui/ytsage_gui_dialogs/__init__.py @@ -0,0 +1,60 @@ +""" +Dialog modules for YTSage GUI. + +This package contains all dialog classes organized by functionality: + + - ytsage_dialogs_base: Base utility dialogs + - ytsage_dialogs_settings: Settings configuration dialogs + - ytsage_dialogs_update: Update-related dialogs and threads + - ytsage_dialogs_ffmpeg: FFmpeg installation dialogs + - ytsage_dialogs_selection: Subtitle and playlist selection dialogs + - ytsage_dialogs_custom: Custom functionality dialogs +""" + +# Re-export all dialog classes for backward compatibility +from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow +from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import ( + CookieLoginDialog, + CustomCommandDialog, + CustomOptionsDialog, + TimeRangeDialog, +) +from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread +from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import ( + PlaylistSelectionDialog, + SponsorBlockCategoryDialog, + SubtitleSelectionDialog, +) +from src.gui.ytsage_gui_dialogs.ytsage_dialogs_settings import AutoUpdateSettingsDialog, DownloadSettingsDialog +from src.gui.ytsage_gui_dialogs.ytsage_dialogs_update import AutoUpdateThread, UpdateThread, VersionCheckThread, YTDLPUpdateDialog + +__all__ = [ + # Base dialogs + "LogWindow", + "AboutDialog", + + # Settings dialogs + "DownloadSettingsDialog", + "AutoUpdateSettingsDialog", + + # Update dialogs and threads + "VersionCheckThread", + "UpdateThread", + "YTDLPUpdateDialog", + "AutoUpdateThread", + + # FFmpeg dialogs + "FFmpegInstallThread", + "FFmpegCheckDialog", + + # Selection dialogs + "SubtitleSelectionDialog", + "PlaylistSelectionDialog", + "SponsorBlockCategoryDialog", + + # Custom functionality dialogs + "CustomCommandDialog", + "CookieLoginDialog", + "CustomOptionsDialog", + "TimeRangeDialog", +] diff --git a/src/gui/dialogs/ytsage_dialogs_base.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py similarity index 80% rename from src/gui/dialogs/ytsage_dialogs_base.py rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py index e8cb556..5f67b27 100644 --- a/src/gui/dialogs/ytsage_dialogs_base.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py @@ -3,32 +3,37 @@ Base dialogs for YTSage application. Contains basic utility dialogs like LogWindow and AboutDialog. """ -import sys -import os -import webbrowser -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QTextEdit, QWidget, QDialogButtonBox, QSizePolicy, - QPushButton, QMessageBox, QScrollArea) -from PySide6.QtCore import Qt, QThread, Signal, QTimer -from PySide6.QtGui import QIcon +from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QTextEdit, + QVBoxLayout, + QWidget, +) -from ...core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_path -from ...core.ytsage_yt_dlp import get_yt_dlp_path, check_ytdlp_installed -from ...core.ytsage_utils import (check_ffmpeg, get_ytdlp_version, get_ffmpeg_version, - refresh_version_cache, _version_cache) +from src.core.ytsage_ffmpeg import get_ffmpeg_path +from src.core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, refresh_version_cache +from src.core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path class LogWindow(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) - self.setWindowTitle('yt-dlp Log') + self.setWindowTitle("yt-dlp Log") self.setMinimumSize(700, 500) layout = QVBoxLayout(self) self.log_text = QTextEdit() self.log_text.setReadOnly(True) - self.log_text.setStyleSheet(""" + self.log_text.setStyleSheet( + """ QTextEdit { background-color: #2b2b2b; color: #ffffff; @@ -37,11 +42,12 @@ class LogWindow(QDialog): border: 2px solid #3d3d3d; border-radius: 4px; } - """) + """ + ) layout.addWidget(self.log_text) - def append_log(self, message): + def append_log(self, message) -> None: self.log_text.append(message) # Auto-scroll to bottom scrollbar = self.log_text.verticalScrollBar() @@ -49,17 +55,17 @@ class LogWindow(QDialog): class AboutDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) - self.parent = parent # Store parent to access version etc. + self._parent = parent # Store parent to access version etc. self.setWindowTitle("About YTSage") self.setMinimumSize(460, 420) # Slightly increased to accommodate paths self.resize(460, 440) # Slightly increased initial size self.setMaximumSize(500, 480) # Reasonable maximum size - + # Set window flags to make dialog independent of parent movement self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint) - + layout = QVBoxLayout(self) layout.setSpacing(15) # Reduced spacing layout.setContentsMargins(20, 20, 20, 20) # Reduced margins @@ -84,7 +90,7 @@ class AboutDialog(QDialog): button_layout = QHBoxLayout() button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) button_box.accepted.connect(self.accept) - + # Center the button button_layout.addStretch() button_layout.addWidget(button_box) @@ -92,7 +98,8 @@ class AboutDialog(QDialog): layout.addLayout(button_layout) # Apply overall styling - improved consistency - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; color: #ffffff; @@ -131,20 +138,25 @@ class AboutDialog(QDialog): color: #ffffff; font-size: 14px; } - """) + """ + ) - def _create_app_info_section(self): + def _create_app_info_section(self) -> QWidget: """Create the application information section - compact version""" widget = QWidget() layout = QVBoxLayout(widget) layout.setSpacing(6) # Reduced spacing - + # Title and Version - more compact - title_label = QLabel("YTSage") + title_label = QLabel( + "YTSage" + ) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title_label) - version_label = QLabel(f"Version {getattr(self.parent, 'version', '4.7.0')}") + version_label = QLabel( + f"Version {getattr(self._parent, 'version', '4.7.0')}" + ) version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(version_label) @@ -158,49 +170,56 @@ class AboutDialog(QDialog): # Author and Links - compact single line info_layout = QHBoxLayout() info_layout.setSpacing(15) - - author_label = QLabel("By: oop7") + + author_label = QLabel( + "By: oop7" + ) author_label.setOpenExternalLinks(True) info_layout.addWidget(author_label) - repo_label = QLabel("GitHub: YTSage") + repo_label = QLabel( + "GitHub: YTSage" + ) repo_label.setOpenExternalLinks(True) info_layout.addWidget(repo_label) - + # Center the info layout info_container = QHBoxLayout() info_container.addStretch() info_container.addLayout(info_layout) info_container.addStretch() - + layout.addLayout(info_container) - + return widget - def _create_system_info_section(self): + def _create_system_info_section(self) -> QWidget: """Create the system information section with compact design""" # Create main container with compact styling container = QWidget() - container.setStyleSheet(""" + container.setStyleSheet( + """ QWidget { border: 1px solid #333333; border-radius: 8px; background-color: #15181b; margin-top: 5px; } - """) - + """ + ) + main_layout = QVBoxLayout(container) main_layout.setSpacing(8) # Compact spacing main_layout.setContentsMargins(15, 10, 15, 10) - + # Create header with title and refresh button on same line header_layout = QHBoxLayout() header_layout.setContentsMargins(0, 0, 0, 5) - + # System Information title title_label = QLabel("System Information") - title_label.setStyleSheet(""" + title_label.setStyleSheet( + """ QLabel { color: #ffffff; font-size: 14px; @@ -208,16 +227,18 @@ class AboutDialog(QDialog): padding: 0px; margin: 0px; } - """) + """ + ) header_layout.addWidget(title_label) - + # Add stretch to push refresh button to the right header_layout.addStretch() - + # Create refresh button self.refresh_btn = QPushButton("🔄") self.refresh_btn.setFixedSize(16, 16) - self.refresh_btn.setStyleSheet(""" + self.refresh_btn.setStyleSheet( + """ QPushButton { padding: 0px; background-color: transparent; @@ -236,99 +257,103 @@ class AboutDialog(QDialog): color: #c90000; background-color: rgba(201, 0, 0, 0.1); } - """) + """ + ) self.refresh_btn.clicked.connect(self.refresh_version_info) header_layout.addWidget(self.refresh_btn) - + main_layout.addLayout(header_layout) - + # Compact status grid layout self.status_container = QVBoxLayout() self.status_container.setSpacing(6) # Tight spacing self.status_container.setContentsMargins(0, 0, 0, 0) - + main_layout.addLayout(self.status_container) - + # Show loading message initially self._show_loading_message() - + # Populate system information asynchronously QTimer.singleShot(100, self.update_system_info) - + return container - def _show_loading_message(self): + def _show_loading_message(self) -> None: """Show a compact loading message while system information is being gathered.""" loading_label = QLabel("🔄 Loading system information...") loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - loading_label.setStyleSheet(""" + loading_label.setStyleSheet( + """ QLabel { color: #cccccc; font-size: 11px; font-style: italic; padding: 10px; } - """) - + """ + ) + self.status_container.addWidget(loading_label) - def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status=""): + def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status="") -> QWidget: """Create a compact status item widget""" item_widget = QWidget() # Adjust height based on whether we have path info item_height = 50 if path_text else 35 item_widget.setMaximumHeight(item_height) item_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - + # Main layout item_layout = QVBoxLayout(item_widget) item_layout.setContentsMargins(8, 4, 8, 4) item_layout.setSpacing(2) - + # First row: Icon, name, status, version first_row = QHBoxLayout() first_row.setSpacing(8) - + # Icon and name - compact name_label = QLabel(f"{icon} {name}") name_label.setStyleSheet("font-size: 12px; color: #ffffff; font-weight: bold;") name_label.setMinimumWidth(80) first_row.addWidget(name_label) - + # Status - compact status_label = QLabel(status_text) status_label.setStyleSheet("font-size: 11px; font-weight: bold;") status_label.setMinimumWidth(70) first_row.addWidget(status_label) - + # Version info - improved readability version_info = version_text if cache_status: version_info += cache_status - + version_label = QLabel(version_info) version_label.setStyleSheet("font-size: 11px; color: #cccccc;") # Increased from 10px version_label.setWordWrap(False) first_row.addWidget(version_label) - + # Add stretch to push everything left first_row.addStretch() - + item_layout.addLayout(first_row) - + # Second row: Path (if provided) if path_text: path_label = QLabel(f"📁 {path_text}") path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color path_label.setWordWrap(False) # Truncate very long paths - if len(path_text) > 60: - truncated_path = "..." + path_text[-57:] + if len(str(path_text)) > 60: + truncated_path = "..." + str(path_text)[-57:] path_label.setText(f"📁 {truncated_path}") item_layout.addWidget(path_label) - + # Subtle background with minimal border - item_widget.setStyleSheet(""" + item_widget.setStyleSheet( + """ QWidget { background-color: rgba(45, 45, 45, 0.3); border: 1px solid #2a2a2a; @@ -338,11 +363,12 @@ class AboutDialog(QDialog): QWidget:hover { background-color: rgba(60, 60, 60, 0.4); } - """) - + """ + ) + return item_widget - def update_system_info(self): + def update_system_info(self) -> None: """Update the system information display with compact layout.""" # Clear existing items for i in reversed(range(self.status_container.count())): @@ -352,86 +378,101 @@ class AboutDialog(QDialog): # yt-dlp Status - compact version with path ytdlp_found = check_ytdlp_installed() - ytdlp_status_text = "✓ Detected" if ytdlp_found else "✗ Missing" + ytdlp_status_text = ( + "✓ Detected" if ytdlp_found else "✗ Missing" + ) ytdlp_version = get_ytdlp_version() - + # Get yt-dlp path ytdlp_path = get_yt_dlp_path() if ytdlp_found else None ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None - + # Simplified cache status - ytdlp_cache = _version_cache.get('ytdlp', {}) - last_check = ytdlp_cache.get('last_check', 0) + ytdlp_cache = _version_cache.get("ytdlp", {}) + last_check = ytdlp_cache.get("last_check", 0) cache_status = "" if last_check > 0: from datetime import datetime + cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M") cache_status = f" ({cache_time})" # Increased from 9px - + ytdlp_item = self._create_status_item( - "🎥", "yt-dlp", ytdlp_status_text, ytdlp_version + cache_status, ytdlp_path_text + "🎥", + "yt-dlp", + ytdlp_status_text, + ytdlp_version + cache_status, + ytdlp_path_text, ) self.status_container.addWidget(ytdlp_item) # FFmpeg Status - compact version with path ffmpeg_found = check_ffmpeg() - ffmpeg_status_text = "✓ Detected" if ffmpeg_found else "✗ Missing" + ffmpeg_status_text = ( + "✓ Detected" + if ffmpeg_found + else "✗ Missing" + ) ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available" - + # Get FFmpeg path ffmpeg_path_text = None if ffmpeg_found: ffmpeg_path = get_ffmpeg_path() ffmpeg_path_text = ffmpeg_path if ffmpeg_path else None - + # Simplified cache status for FFmpeg - ffmpeg_cache = _version_cache.get('ffmpeg', {}) - last_check = ffmpeg_cache.get('last_check', 0) + ffmpeg_cache = _version_cache.get("ffmpeg", {}) + last_check = ffmpeg_cache.get("last_check", 0) cache_status = "" if last_check > 0 and ffmpeg_found: from datetime import datetime + cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M") cache_status = f" ({cache_time})" # Increased from 9px - + ffmpeg_item = self._create_status_item( - "🎬", "FFmpeg", ffmpeg_status_text, ffmpeg_version + cache_status, ffmpeg_path_text + "🎬", + "FFmpeg", + ffmpeg_status_text, + ffmpeg_version + cache_status, + ffmpeg_path_text, ) self.status_container.addWidget(ffmpeg_item) - def refresh_version_info(self): + def refresh_version_info(self) -> None: """Refresh version information manually.""" self.refresh_btn.setText("🔄 Refreshing...") self.refresh_btn.setEnabled(False) - + # Perform refresh in a separate thread to avoid blocking UI - from PySide6.QtCore import QThread, Signal - class RefreshThread(QThread): finished = Signal(bool) - + def run(self): success = refresh_version_cache(force=True) self.finished.emit(success) - + self.refresh_thread = RefreshThread() self.refresh_thread.finished.connect(self.on_refresh_finished) self.refresh_thread.start() - - def on_refresh_finished(self, success): + + def on_refresh_finished(self, success) -> None: """Handle refresh completion.""" self.refresh_btn.setText("🔄 Refresh") self.refresh_btn.setEnabled(True) - + if success: self.update_system_info() else: # Show error message with proper styling msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Warning) + msg_box.setIcon(QMessageBox.Icon.Warning) msg_box.setWindowTitle("Refresh Failed") msg_box.setText("Failed to refresh version information.") msg_box.setWindowIcon(self.windowIcon()) - msg_box.setStyleSheet(""" + msg_box.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -451,5 +492,6 @@ class AboutDialog(QDialog): QMessageBox QPushButton:hover { background-color: #a50000; } - """) + """ + ) msg_box.exec() diff --git a/src/gui/dialogs/ytsage_dialogs_custom.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py similarity index 73% rename from src/gui/dialogs/ytsage_dialogs_custom.py rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py index 68a4ef7..23103f2 100644 --- a/src/gui/dialogs/ytsage_dialogs_custom.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py @@ -3,30 +3,47 @@ Custom functionality dialogs for YTSage application. Contains dialogs for custom commands, cookies, time ranges, and other special features. """ -import os -import sys -import threading import subprocess -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QLineEdit, QPushButton, QTextEdit, QPlainTextEdit, - QCheckBox, QTabWidget, QWidget, QDialogButtonBox, - QFileDialog, QGroupBox) -from PySide6.QtCore import Qt, QMetaObject, Q_ARG +import threading +from pathlib import Path +from typing import TYPE_CHECKING, cast -from ...core.ytsage_yt_dlp import get_yt_dlp_path +from PySide6.QtCore import Q_ARG, QMetaObject, Qt +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPlainTextEdit, + QPushButton, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from src.core.ytsage_yt_dlp import get_yt_dlp_path try: import yt_dlp + YT_DLP_AVAILABLE = True except ImportError: YT_DLP_AVAILABLE = False +if TYPE_CHECKING: + from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import) + class CustomCommandDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) - self.parent = parent - self.setWindowTitle('Custom yt-dlp Command') + self._parent = self.parent() + self.setWindowTitle("Custom yt-dlp Command") self.setMinimumSize(600, 400) layout = QVBoxLayout(self) @@ -44,7 +61,8 @@ class CustomCommandDialog(QDialog): # Command input self.command_input = QPlainTextEdit() self.command_input.setPlaceholderText("Enter yt-dlp arguments...") - self.command_input.setStyleSheet(""" + self.command_input.setStyleSheet( + """ QPlainTextEdit { background-color: #1d1e22; color: #ffffff; @@ -53,12 +71,14 @@ class CustomCommandDialog(QDialog): padding: 8px; font-family: Consolas, monospace; } - """) + """ + ) layout.addWidget(self.command_input) # Add SponsorBlock checkbox self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") - self.sponsorblock_checkbox.setStyleSheet(""" + self.sponsorblock_checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -79,7 +99,8 @@ class CustomCommandDialog(QDialog): background: #c90000; border-radius: 9px; } - """) + """ + ) layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox) # Buttons @@ -98,7 +119,8 @@ class CustomCommandDialog(QDialog): # Log output self.log_output = QTextEdit() self.log_output.setReadOnly(True) - self.log_output.setStyleSheet(""" + self.log_output.setStyleSheet( + """ QTextEdit { background-color: #1d1e22; color: #ffffff; @@ -108,10 +130,12 @@ class CustomCommandDialog(QDialog): font-family: Consolas, monospace; font-size: 12px; } - """) + """ + ) layout.addWidget(self.log_output) - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; } @@ -126,35 +150,38 @@ class CustomCommandDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) + """ + ) - def run_custom_command(self): - url = self.parent.url_input.text().strip() + def run_custom_command(self) -> None: + url = self._parent.url_input.text().strip() # type: ignore[reportAttributeAccessIssue] if not url: self.log_output.append("Error: No URL provided") return command = self.command_input.toPlainText().strip() - path = self.parent.path_input.text().strip() + path = self._parent.path_input.text().strip() # type: ignore[reportAttributeAccessIssue] self.log_output.clear() self.log_output.append(f"Running command with URL: {url}") self.run_btn.setEnabled(False) # Start command in thread - threading.Thread(target=self._run_command_thread, - args=(command, url, path), - daemon=True).start() + threading.Thread(target=self._run_command_thread, args=(command, url, path), daemon=True).start() - def _run_command_thread(self, command, url, path): + def _run_command_thread(self, command, url, path) -> None: try: + class CommandLogger: def debug(self, msg): self.dialog.log_output.append(msg) + def warning(self, msg): self.dialog.log_output.append(f"Warning: {msg}") + def error(self, msg): self.dialog.log_output.append(f"Error: {msg}") + def __init__(self, dialog): self.dialog = dialog @@ -163,34 +190,43 @@ class CustomCommandDialog(QDialog): # Base options ydl_opts = { - 'logger': CommandLogger(self), - 'paths': {'home': path}, - 'debug_printout': True, - 'postprocessors': [] + "logger": CommandLogger(self), + "paths": {"home": path}, + "debug_printout": True, + "postprocessors": [], } # Add SponsorBlock options if enabled if self.sponsorblock_checkbox.isChecked(): - ydl_opts['postprocessors'].extend([{ - 'key': 'SponsorBlock', - 'categories': ['sponsor', 'selfpromo', 'interaction'], - 'api': 'https://sponsor.ajay.app' - }, { - 'key': 'ModifyChapters', - 'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'], - 'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l', - 'force_keyframes': True - }]) + ydl_opts["postprocessors"].extend( + [ + { + "key": "SponsorBlock", + "categories": ["sponsor", "selfpromo", "interaction"], + "api": "https://sponsor.ajay.app", + }, + { + "key": "ModifyChapters", + "remove_sponsor_segments": [ + "sponsor", + "selfpromo", + "interaction", + ], + "sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l", + "force_keyframes": True, + }, + ] + ) # Add custom arguments for i in range(0, len(args), 2): if i + 1 < len(args): - key = args[i].lstrip('-').replace('-', '_') + key = args[i].lstrip("-").replace("-", "_") value = args[i + 1] try: # Try to convert to appropriate type - if value.lower() in ('true', 'false'): - value = value.lower() == 'true' + if value.lower() in ("true", "false"): + value = value.lower() == "true" elif value.isdigit(): value = int(value) ydl_opts[key] = value @@ -209,9 +245,9 @@ class CustomCommandDialog(QDialog): class CookieLoginDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) - self.setWindowTitle('Login with Cookies') + self.setWindowTitle("Login with Cookies") self.setMinimumSize(400, 150) layout = QVBoxLayout(self) @@ -237,43 +273,38 @@ class CookieLoginDialog(QDialog): layout.addLayout(path_layout) # Dialog buttons - button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) - def browse_cookie_file(self): + def browse_cookie_file(self) -> None: # Open file dialog to select cookie file - file_dialog = QFileDialog(self) - file_dialog.setFileMode(QFileDialog.ExistingFile) - file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions - if file_dialog.exec(): - selected_files = file_dialog.selectedFiles() - if selected_files: - self.cookie_path_input.setText(selected_files[0]) + selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)") + if selected_files: + self.cookie_path_input.setText(selected_files[0]) - def get_cookie_file_path(self): + def get_cookie_file_path(self) -> str: # Return the selected cookie file path return self.cookie_path_input.text() class CustomOptionsDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) - self.parent = parent - self.setWindowTitle('Custom Options') + self._parent: YTSageApp = cast("YTSageApp", self.parent()) # cast will help with auto complete and type hint checking. + self.setWindowTitle("Custom Options") self.setMinimumSize(600, 500) - layout = QVBoxLayout(self) - + # Create tab widget to organize content self.tab_widget = QTabWidget() layout.addWidget(self.tab_widget) - + # === Cookies Tab === cookies_tab = QWidget() cookies_layout = QVBoxLayout(cookies_tab) - + # Help text help_text = QLabel( "Select the Netscape-format cookies file for logging in.\n" @@ -287,26 +318,26 @@ class CustomOptionsDialog(QDialog): path_layout = QHBoxLayout() self.cookie_path_input = QLineEdit() self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)") - if hasattr(parent, 'cookie_file_path') and parent.cookie_file_path: - self.cookie_path_input.setText(parent.cookie_file_path) + if hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path: + self.cookie_path_input.setText(self._parent.cookie_file_path.as_posix()) path_layout.addWidget(self.cookie_path_input) self.browse_button = QPushButton("Browse") self.browse_button.clicked.connect(self.browse_cookie_file) path_layout.addWidget(self.browse_button) cookies_layout.addLayout(path_layout) # Add the horizontal layout to cookies layout - + # Status indicator for cookies self.cookie_status = QLabel("") self.cookie_status.setStyleSheet("color: #999999; font-style: italic;") cookies_layout.addWidget(self.cookie_status) - + cookies_layout.addStretch() - + # === Custom Command Tab === command_tab = QWidget() command_layout = QVBoxLayout(command_tab) - + # Help text cmd_help_text = QLabel( "Enter custom yt-dlp commands below. The URL will be automatically appended.\n" @@ -319,7 +350,8 @@ class CustomOptionsDialog(QDialog): # Add SponsorBlock checkbox self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") - self.sponsorblock_checkbox.setStyleSheet(""" + self.sponsorblock_checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -340,13 +372,15 @@ class CustomOptionsDialog(QDialog): background: #c90000; border-radius: 9px; } - """) + """ + ) command_layout.addWidget(self.sponsorblock_checkbox) # Command input self.command_input = QPlainTextEdit() self.command_input.setPlaceholderText("Enter yt-dlp arguments...") - self.command_input.setStyleSheet(""" + self.command_input.setStyleSheet( + """ QPlainTextEdit { background-color: #1d1e22; color: #ffffff; @@ -355,7 +389,8 @@ class CustomOptionsDialog(QDialog): padding: 8px; font-family: Consolas, monospace; } - """) + """ + ) command_layout.addWidget(self.command_input) # Run command button @@ -366,7 +401,8 @@ class CustomOptionsDialog(QDialog): # Log output self.log_output = QTextEdit() self.log_output.setReadOnly(True) - self.log_output.setStyleSheet(""" + self.log_output.setStyleSheet( + """ QTextEdit { background-color: #1d1e22; color: #ffffff; @@ -376,21 +412,23 @@ class CustomOptionsDialog(QDialog): font-family: Consolas, monospace; font-size: 12px; } - """) + """ + ) command_layout.addWidget(self.log_output) - + # Add tabs to the tab widget self.tab_widget.addTab(cookies_tab, "Login with Cookies") self.tab_widget.addTab(command_tab, "Custom Command") - + # Dialog buttons - button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) - + # Apply global styles - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; } @@ -434,65 +472,71 @@ class CustomOptionsDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) + """ + ) - def browse_cookie_file(self): + def browse_cookie_file(self) -> None: # Open file dialog to select cookie file - file_dialog = QFileDialog(self) - file_dialog.setFileMode(QFileDialog.ExistingFile) - file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions - if file_dialog.exec(): - selected_files = file_dialog.selectedFiles() - if selected_files: - self.cookie_path_input.setText(selected_files[0]) - self.cookie_status.setText("Cookie file selected - Click OK to apply") - self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;") + selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)") - def get_cookie_file_path(self): + if selected_files: + self.cookie_path_input.setText(selected_files[0]) + self.cookie_status.setText("Cookie file selected - Click OK to apply") + self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;") + + def get_cookie_file_path(self) -> Path | None: # Return the selected cookie file path if it's not empty - path = self.cookie_path_input.text().strip() - if path and os.path.exists(path): + path = Path(self.cookie_path_input.text().strip()) + if path and path.exists(): return path return None - def run_custom_command(self): - url = self.parent.url_input.text().strip() + def run_custom_command(self) -> None: + url = self._parent.url_input.text().strip() if not url: self.log_output.append("Error: No URL provided") return command = self.command_input.toPlainText().strip() - + # Get download path from parent - path = self.parent.last_path + path = self._parent.last_path self.log_output.clear() self.log_output.append(f"Running command with URL: {url}") self.run_btn.setEnabled(False) # Start command in thread - threading.Thread(target=self._run_command_thread, - args=(command, url, path), - daemon=True).start() + threading.Thread(target=self._run_command_thread, args=(command, url, path), daemon=True).start() - def _run_command_thread(self, command, url, path): + def _run_command_thread(self, command, url, path) -> None: try: + class CommandLogger: def debug(self, msg): QMetaObject.invokeMethod( - self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, msg) + self.dialog.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, msg), ) + def warning(self, msg): QMetaObject.invokeMethod( - self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, f"Warning: {msg}") + self.dialog.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"Warning: {msg}"), ) + def error(self, msg): QMetaObject.invokeMethod( - self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, f"Error: {msg}") + self.dialog.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"Error: {msg}"), ) + def __init__(self, dialog): self.dialog = dialog @@ -504,12 +548,14 @@ class CustomOptionsDialog(QDialog): base_cmd = [yt_dlp_path] + args + [url] if self.sponsorblock_checkbox.isChecked(): - base_cmd.extend(['--sponsorblock-remove', 'sponsor,selfpromo,interaction']) + base_cmd.extend(["--sponsorblock-remove", "sponsor,selfpromo,interaction"]) # Show the full command QMetaObject.invokeMethod( - self.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, f"Full command: {' '.join(base_cmd)}") + self.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"Full command: {' '.join(base_cmd)}"), ) # Run the command @@ -518,50 +564,59 @@ class CustomOptionsDialog(QDialog): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - encoding='utf-8', - errors='replace' + encoding="utf-8", + errors="replace", ) # Stream output - for line in proc.stdout: + for line in proc.stdout: # type: ignore[reportOptionalIterable] QMetaObject.invokeMethod( - self.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, line.rstrip()) + self.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, line.rstrip()), ) ret = proc.wait() if ret != 0: QMetaObject.invokeMethod( - self.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, f"Command exited with code {ret}") + self.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"Command exited with code {ret}"), ) else: QMetaObject.invokeMethod( - self.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, "Command completed successfully") + self.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, "Command completed successfully"), ) except Exception as e: QMetaObject.invokeMethod( - self.log_output, "append", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, f"Error: {str(e)}") + self.log_output, + b"append", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"Error: {str(e)}"), ) finally: # Re-enable the run button QMetaObject.invokeMethod( - self.run_btn, "setEnabled", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, True) + self.run_btn, + b"setEnabled", + Qt.ConnectionType.QueuedConnection, + Q_ARG(bool, True), ) class TimeRangeDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) - self.parent = parent - self.setWindowTitle('Download Video Section') + self.setWindowTitle("Download Video Section") self.setMinimumWidth(400) - + layout = QVBoxLayout(self) - + # Help text explaining the feature help_text = QLabel( "Download only specific parts of a video by specifying time ranges.\n" @@ -570,11 +625,11 @@ class TimeRangeDialog(QDialog): help_text.setWordWrap(True) help_text.setStyleSheet("color: #999999; padding: 10px;") layout.addWidget(help_text) - + # Time range section time_group = QGroupBox("Time Range") time_layout = QVBoxLayout() - + # Start time row start_layout = QHBoxLayout() start_layout.addWidget(QLabel("Start Time:")) @@ -582,7 +637,7 @@ class TimeRangeDialog(QDialog): self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)") start_layout.addWidget(self.start_time_input) time_layout.addLayout(start_layout) - + # End time row end_layout = QHBoxLayout() end_layout.addWidget(QLabel("End Time:")) @@ -590,14 +645,15 @@ class TimeRangeDialog(QDialog): self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)") end_layout.addWidget(self.end_time_input) time_layout.addLayout(end_layout) - + time_group.setLayout(time_layout) layout.addWidget(time_group) - + # Force keyframes option self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)") self.force_keyframes.setChecked(True) - self.force_keyframes.setStyleSheet(""" + self.force_keyframes.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -617,14 +673,16 @@ class TimeRangeDialog(QDialog): background: #c90000; border-radius: 4px; } - """) + """ + ) layout.addWidget(self.force_keyframes) - + # Format preview preview_group = QGroupBox("Command Preview") preview_layout = QVBoxLayout() - self.preview_label = QLabel("--download-sections \"*-\"") - self.preview_label.setStyleSheet(""" + self.preview_label = QLabel('--download-sections "*-"') + self.preview_label.setStyleSheet( + """ QLabel { background-color: #1d1e22; color: #ffffff; @@ -633,24 +691,26 @@ class TimeRangeDialog(QDialog): padding: 8px; font-family: Consolas, monospace; } - """) + """ + ) preview_layout.addWidget(self.preview_label) preview_group.setLayout(preview_layout) layout.addWidget(preview_group) - + # Connect signals for live preview updates self.start_time_input.textChanged.connect(self.update_preview) self.end_time_input.textChanged.connect(self.update_preview) self.force_keyframes.stateChanged.connect(self.update_preview) - + # Buttons - button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) - + # Apply styling - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; } @@ -687,15 +747,16 @@ class TimeRangeDialog(QDialog): QPushButton:hover { background-color: #a50000; } - """) - + """ + ) + # Initialize preview self.update_preview() - - def update_preview(self): + + def update_preview(self) -> None: start = self.start_time_input.text().strip() end = self.end_time_input.text().strip() - + if start and end: time_range = f"*{start}-{end}" elif start: @@ -704,21 +765,21 @@ class TimeRangeDialog(QDialog): time_range = f"*-{end}" else: time_range = "*-" # Full video - - preview = f"--download-sections \"{time_range}\"" + + preview = f'--download-sections "{time_range}"' if self.force_keyframes.isChecked(): preview += " --force-keyframes-at-cuts" - + self.preview_label.setText(preview) - - def get_download_sections(self): + + def get_download_sections(self) -> str | None: """Returns the download sections command arguments or None if no selection made""" start = self.start_time_input.text().strip() end = self.end_time_input.text().strip() - + if not start and not end: return None # No selection made - + if start and end: time_range = f"*{start}-{end}" elif start: @@ -727,9 +788,9 @@ class TimeRangeDialog(QDialog): time_range = f"*-{end}" else: return None # Shouldn't happen but just in case - + return time_range - - def get_force_keyframes(self): + + def get_force_keyframes(self) -> bool: """Returns whether to force keyframes at cuts""" return self.force_keyframes.isChecked() diff --git a/src/gui/dialogs/ytsage_dialogs_ffmpeg.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py similarity index 79% rename from src/gui/dialogs/ytsage_dialogs_ffmpeg.py rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py index a6f9314..0265e9b 100644 --- a/src/gui/dialogs/ytsage_dialogs_ffmpeg.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py @@ -3,57 +3,52 @@ FFmpeg installation dialogs for YTSage application. Contains dialogs and threads for checking and installing FFmpeg. """ -import sys -import os -import webbrowser import contextlib +import webbrowser from io import StringIO -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QStyle, QSizePolicy, QDialogButtonBox) -from PySide6.QtCore import QThread, Signal, Qt -from PySide6.QtGui import QIcon -from ...core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from src.core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed +from src.utils.ytsage_constants import ICON_PATH class FFmpegInstallThread(QThread): finished = Signal(bool) progress = Signal(str) - def run(self): + def run(self) -> None: # Redirect stdout to capture progress messages output = StringIO() with contextlib.redirect_stdout(output): success = auto_install_ffmpeg() - + # Process captured output and emit progress signals for line in output.getvalue().splitlines(): self.progress.emit(line) - + self.finished.emit(success) class FFmpegCheckDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) - self.setWindowTitle('FFmpeg Installation') + self.setWindowTitle("FFmpeg Installation") self.setMinimumWidth(450) self.setMinimumHeight(200) self.resize(450, 220) - + # Set the window icon to match the main app if parent and parent.windowIcon(): self.setWindowIcon(parent.windowIcon()) else: # Try to load the icon directly if parent not available - # Navigate from src/gui/dialogs/ to project root, then to assets/Icon/ - current_dir = os.path.dirname(os.path.abspath(__file__)) # dialogs/ - gui_dir = os.path.dirname(current_dir) # gui/ - src_dir = os.path.dirname(gui_dir) # src/ - project_root = os.path.dirname(src_dir) # project root - icon_path = os.path.join(project_root, 'assets', 'Icon', 'icon.png') - if os.path.exists(icon_path): - self.setWindowIcon(QIcon(icon_path)) + # icon_path logic moved to src\utils\ytsage_constants.py + + if ICON_PATH.exists(): + self.setWindowIcon(QIcon(ICON_PATH.as_posix())) layout = QVBoxLayout(self) layout.setSpacing(15) @@ -66,10 +61,7 @@ class FFmpegCheckDialog(QDialog): layout.addWidget(header_text) # Message - self.message_label = QLabel( - "YTSage needs FFmpeg to process videos.\n\n" - "Choose an installation option below:" - ) + self.message_label = QLabel("YTSage needs FFmpeg to process videos.\n\n" "Choose an installation option below:") self.message_label.setWordWrap(True) self.message_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 10px 0; line-height: 1.4;") self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -80,7 +72,8 @@ class FFmpegCheckDialog(QDialog): self.progress_label.setWordWrap(True) self.progress_label.setMinimumHeight(60) # Smaller but visible area self.progress_label.setMaximumHeight(80) # Limit maximum height - self.progress_label.setStyleSheet(""" + self.progress_label.setStyleSheet( + """ QLabel { background-color: #1d1e22; color: #cccccc; @@ -91,17 +84,18 @@ class FFmpegCheckDialog(QDialog): font-size: 11px; line-height: 1.2; } - """) + """ + ) self.progress_label.hide() layout.addWidget(self.progress_label) - + # Add minimal stretch - just enough to push buttons down slightly layout.addSpacing(10) # Buttons container - simple approach that should work button_layout = QHBoxLayout() button_layout.setSpacing(15) # Simple spacing - + # Install button self.install_btn = QPushButton("Install FFmpeg") self.install_btn.clicked.connect(self.start_installation) @@ -109,7 +103,7 @@ class FFmpegCheckDialog(QDialog): # Manual install button self.manual_btn = QPushButton("Manual Guide") - self.manual_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide')) + self.manual_btn.clicked.connect(lambda: webbrowser.open("https://github.com/oop7/ffmpeg-install-guide")) button_layout.addWidget(self.manual_btn) # Close button @@ -120,7 +114,8 @@ class FFmpegCheckDialog(QDialog): layout.addLayout(button_layout) # Style the dialog to match app theme - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; color: #ffffff; @@ -148,16 +143,17 @@ class FFmpegCheckDialog(QDialog): background-color: #666666; color: #999999; } - """) + """ + ) # Initialize installation thread self.install_thread = None - def start_installation(self): + def start_installation(self) -> None: self.install_btn.setEnabled(False) self.manual_btn.setEnabled(False) self.close_btn.setEnabled(False) - + # Check if FFmpeg is already installed if check_ffmpeg_installed(): self.message_label.setText("FFmpeg is already installed!") @@ -167,7 +163,7 @@ class FFmpegCheckDialog(QDialog): self.manual_btn.hide() self.close_btn.setEnabled(True) return - + self.message_label.setText("Installing FFmpeg... Please wait") self.progress_label.show() @@ -176,10 +172,10 @@ class FFmpegCheckDialog(QDialog): self.install_thread.progress.connect(self.update_progress) self.install_thread.start() - def update_progress(self, message): + def update_progress(self, message) -> None: self.progress_label.setText(message) - def installation_finished(self, success): + def installation_finished(self, success) -> None: if success: self.message_label.setText("FFmpeg has been installed successfully!") self.progress_label.setText("Installation complete. You can now close this dialog and continue using YTSage.") @@ -190,5 +186,5 @@ class FFmpegCheckDialog(QDialog): self.progress_label.setText("Please try using the manual installation guide instead.") self.install_btn.setEnabled(True) self.manual_btn.setEnabled(True) - + self.close_btn.setEnabled(True) diff --git a/src/gui/dialogs/ytsage_dialogs_selection.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py similarity index 77% rename from src/gui/dialogs/ytsage_dialogs_selection.py rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py index e6bb07a..876c606 100644 --- a/src/gui/dialogs/ytsage_dialogs_selection.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py @@ -3,14 +3,23 @@ Selection dialogs for YTSage application. Contains dialogs for selecting subtitles, playlist videos, and SponsorBlock categories. """ -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QLineEdit, QPushButton, QScrollArea, QWidget, - QCheckBox, QDialogButtonBox, QGroupBox) from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) class SubtitleSelectionDialog(QDialog): - def __init__(self, available_manual, available_auto, previously_selected, parent=None): + def __init__(self, available_manual, available_auto, previously_selected, parent=None) -> None: super().__init__(parent) self.setWindowTitle("Select Subtitles") self.setMinimumWidth(400) @@ -28,7 +37,8 @@ class SubtitleSelectionDialog(QDialog): self.filter_input = QLineEdit() self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...") self.filter_input.textChanged.connect(self.filter_list) - self.filter_input.setStyleSheet(""" + self.filter_input.setStyleSheet( + """ QLineEdit { background-color: #363636; border: 2px solid #3d3d3d; @@ -40,7 +50,8 @@ class SubtitleSelectionDialog(QDialog): QLineEdit:focus { border-color: #ff0000; } - """) + """ + ) layout.addWidget(self.filter_input) # Scroll Area for the list @@ -67,7 +78,8 @@ class SubtitleSelectionDialog(QDialog): # Style the buttons for button in button_box.buttons(): - button.setStyleSheet(""" + button.setStyleSheet( + """ QPushButton { background-color: #363636; border: 2px solid #3d3d3d; @@ -82,14 +94,18 @@ class SubtitleSelectionDialog(QDialog): QPushButton:pressed { background-color: #555555; } - """) - # Style the OK button specifically if needed - if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole: - button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }") + """ + ) + # Style the OK button specifically if needed + if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole: + button.setStyleSheet( + button.styleSheet() + + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }" + ) layout.addWidget(button_box) - def populate_list(self, filter_text=""): + def populate_list(self, filter_text="") -> None: # Clear existing checkboxes from layout while self.list_layout.count(): item = self.list_layout.takeAt(0) @@ -102,14 +118,14 @@ class SubtitleSelectionDialog(QDialog): # Add manual subs for lang_code, sub_info in self.available_manual.items(): - if not filter_text or filter_text in lang_code.lower(): - combined_subs[lang_code] = f"{lang_code} - Manual" + if not filter_text or filter_text in lang_code.lower(): + combined_subs[lang_code] = f"{lang_code} - Manual" # Add auto subs (only if no manual exists and matches filter) for lang_code, sub_info in self.available_auto.items(): if lang_code not in combined_subs: # Don't overwrite manual - if not filter_text or filter_text in lang_code.lower(): - combined_subs[lang_code] = f"{lang_code} - Auto-generated" + if not filter_text or filter_text in lang_code.lower(): + combined_subs[lang_code] = f"{lang_code} - Auto-generated" if not combined_subs: no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else "")) @@ -126,7 +142,8 @@ class SubtitleSelectionDialog(QDialog): checkbox.setProperty("subtitle_id", item_text) # Store the identifier checkbox.setChecked(item_text in self.previously_selected) # Check if previously selected checkbox.stateChanged.connect(self.update_selection) - checkbox.setStyleSheet(""" + checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -144,15 +161,16 @@ class SubtitleSelectionDialog(QDialog): border: 2px solid #ff0000; background: #ff0000; } - """) + """ + ) self.list_layout.addWidget(checkbox) self.list_layout.addStretch() # Pushes items up if list is short - def filter_list(self): + def filter_list(self) -> None: self.populate_list(self.filter_input.text()) - def update_selection(self, state): + def update_selection(self, state) -> None: sender = self.sender() subtitle_id = sender.property("subtitle_id") if state == Qt.CheckState.Checked.value: @@ -162,18 +180,18 @@ class SubtitleSelectionDialog(QDialog): if subtitle_id in self.previously_selected: self.previously_selected.remove(subtitle_id) - def get_selected_subtitles(self): + def get_selected_subtitles(self) -> list: # Return the final set as a list return list(self.previously_selected) - def accept(self): + def accept(self) -> None: # Update the final list before closing self.selected_subtitles = self.get_selected_subtitles() super().accept() class PlaylistSelectionDialog(QDialog): - def __init__(self, playlist_entries, previously_selected_string, parent=None): + def __init__(self, playlist_entries, previously_selected_string, parent=None) -> None: super().__init__(parent) self.setWindowTitle("Select Playlist Videos") self.setMinimumWidth(500) @@ -192,7 +210,8 @@ class PlaylistSelectionDialog(QDialog): select_all_btn.clicked.connect(self._select_all) deselect_all_btn.clicked.connect(self._deselect_all) # Style the buttons to match the subtitle dialog - select_all_btn.setStyleSheet(""" + select_all_btn.setStyleSheet( + """ QPushButton { background-color: #363636; border: 2px solid #3d3d3d; @@ -207,7 +226,8 @@ class PlaylistSelectionDialog(QDialog): QPushButton:pressed { background-color: #555555; } - """) + """ + ) deselect_all_btn.setStyleSheet(select_all_btn.styleSheet()) button_layout.addWidget(select_all_btn) button_layout.addWidget(deselect_all_btn) @@ -233,10 +253,11 @@ class PlaylistSelectionDialog(QDialog): button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) - + # Style the buttons to match subtitle dialog for button in button_box.buttons(): - button.setStyleSheet(""" + button.setStyleSheet( + """ QPushButton { background-color: #363636; border: 2px solid #3d3d3d; @@ -251,15 +272,20 @@ class PlaylistSelectionDialog(QDialog): QPushButton:pressed { background-color: #555555; } - """) + """ + ) # Style the OK button specifically if needed if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole: - button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }") - + button.setStyleSheet( + button.styleSheet() + + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }" + ) + main_layout.addWidget(button_box) # Apply styling to match subtitle dialog - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; } QCheckBox { color: #ffffff; @@ -279,21 +305,22 @@ class PlaylistSelectionDialog(QDialog): background: #ff0000; } QWidget { background-color: #15181b; } - """) + """ + ) - def _parse_selection_string(self, selection_string): + def _parse_selection_string(self, selection_string) -> set: """Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices.""" selected_indices = set() if not selection_string: # If no previous selection, assume all are selected initially return set(range(1, len(self.playlist_entries) + 1)) - - parts = selection_string.split(',') + + parts = selection_string.split(",") for part in parts: part = part.strip() - if '-' in part: + if "-" in part: try: - start, end = map(int, part.split('-')) + start, end = map(int, part.split("-")) if start <= end: selected_indices.update(range(start, end + 1)) except ValueError: @@ -305,10 +332,10 @@ class PlaylistSelectionDialog(QDialog): pass # Ignore invalid numbers return selected_indices - def _populate_list(self, previously_selected_string): + def _populate_list(self, previously_selected_string) -> None: """Populates the scroll area with checkboxes for each video.""" selected_indices = self._parse_selection_string(previously_selected_string) - + # Clear existing checkboxes if any (e.g., if repopulating) while self.list_layout.count(): child = self.list_layout.takeAt(0) @@ -317,18 +344,19 @@ class PlaylistSelectionDialog(QDialog): self.checkboxes.clear() for index, entry in enumerate(self.playlist_entries): - if not entry: + if not entry: continue # Skip None entries if yt-dlp returns them video_index = index + 1 # yt-dlp uses 1-based indexing - title = entry.get('title', f'Video {video_index}') + title = entry.get("title", f"Video {video_index}") # Shorten title if too long - display_title = (title[:70] + '...') if len(title) > 73 else title - + display_title = (title[:70] + "...") if len(title) > 73 else title + checkbox = QCheckBox(f"{video_index}. {display_title}") checkbox.setChecked(video_index in selected_indices) checkbox.setProperty("video_index", video_index) # Store index - checkbox.setStyleSheet(""" + checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -346,132 +374,126 @@ class PlaylistSelectionDialog(QDialog): border: 2px solid #ff0000; background: #ff0000; } - """) + """ + ) self.list_layout.addWidget(checkbox) self.checkboxes.append(checkbox) self.list_layout.addStretch() # Push checkboxes to the top - def _select_all(self): + def _select_all(self) -> None: for checkbox in self.checkboxes: checkbox.setChecked(True) - def _deselect_all(self): + def _deselect_all(self) -> None: for checkbox in self.checkboxes: checkbox.setChecked(False) - def _condense_indices(self, indices): + def _condense_indices(self, indices: list[int]) -> str: """Condenses a list of 1-based indices into a yt-dlp selection string.""" if not indices: return "" - indices = sorted(list(set(indices))) - if not indices: # Check again after sorting/set conversion - return "" - + + # Remove duplicates and sort in one step + indices = sorted(set(indices)) + ranges = [] - start = indices[0] - end = indices[0] - for i in range(1, len(indices)): - if indices[i] == end + 1: - end = indices[i] + start = end = indices[0] + + for num in indices[1:]: + if num == end + 1: + end = num else: - if start == end: - ranges.append(str(start)) - else: - ranges.append(f"{start}-{end}") - start = indices[i] - end = indices[i] - # Add the last range - if start == end: - ranges.append(str(start)) - else: - ranges.append(f"{start}-{end}") + ranges.append(f"{start}-{end}" if start != end else str(start)) + start = end = num + + # Append the last range + ranges.append(f"{start}-{end}" if start != end else str(start)) + return ",".join(ranges) - def get_selected_items_string(self): + def get_selected_items_string(self) -> str | None: """Returns the selection string based on checked boxes.""" - selected_indices = [ - cb.property("video_index") for cb in self.checkboxes if cb.isChecked() - ] - + selected_indices = [cb.property("video_index") for cb in self.checkboxes if cb.isChecked()] + # Check if all items are selected if len(selected_indices) == len(self.playlist_entries): - return None # yt-dlp default is all items, so return None or empty string + return None # yt-dlp default is all items, so return None or empty string return self._condense_indices(selected_indices) class SponsorBlockCategoryDialog(QDialog): """Dialog for selecting SponsorBlock categories to remove from videos.""" - + # Default SponsorBlock categories with descriptions SPONSORBLOCK_CATEGORIES = { - 'sponsor': { - 'name': 'Sponsor', - 'description': 'Paid promotion, paid referrals and direct advertisements', - 'default': True + "sponsor": { + "name": "Sponsor", + "description": "Paid promotion, paid referrals and direct advertisements", + "default": True, }, - 'selfpromo': { - 'name': 'Unpaid/Self Promotion', - 'description': 'Unpaid promotion of creators\' own content', - 'default': True + "selfpromo": { + "name": "Unpaid/Self Promotion", + "description": "Unpaid promotion of creators' own content", + "default": True, }, - 'interaction': { - 'name': 'Interaction Reminder', - 'description': 'Asking viewers to like, subscribe, or follow social media', - 'default': True + "interaction": { + "name": "Interaction Reminder", + "description": "Asking viewers to like, subscribe, or follow social media", + "default": True, }, - 'intro': { - 'name': 'Intro', - 'description': 'Video introduction that can be skipped', - 'default': False + "intro": { + "name": "Intro", + "description": "Video introduction that can be skipped", + "default": False, }, - 'outro': { - 'name': 'Outro/End Cards', - 'description': 'Credits or when the video ends', - 'default': False + "outro": { + "name": "Outro/End Cards", + "description": "Credits or when the video ends", + "default": False, }, - 'preview': { - 'name': 'Preview/Recap', - 'description': 'Quick recap of previous videos or preview of what\'s coming up', - 'default': False + "preview": { + "name": "Preview/Recap", + "description": "Quick recap of previous videos or preview of what's coming up", + "default": False, }, - 'music_offtopic': { - 'name': 'Non-Music Section', - 'description': 'Only for music videos. Marks non-music sections', - 'default': False + "music_offtopic": { + "name": "Non-Music Section", + "description": "Only for music videos. Marks non-music sections", + "default": False, + }, + "filler": { + "name": "Filler Tangent", + "description": "Tangential scenes added only for filler or humor", + "default": False, }, - 'filler': { - 'name': 'Filler Tangent', - 'description': 'Tangential scenes added only for filler or humor', - 'default': False - } } - - def __init__(self, previously_selected=None, parent=None): + + def __init__(self, previously_selected=None, parent=None) -> None: super().__init__(parent) self.setWindowTitle("SponsorBlock Categories") self.setMinimumWidth(500) self.setMinimumHeight(400) - + # Set the window icon to match the main app if parent: self.setWindowIcon(parent.windowIcon()) - + self.previously_selected = set(previously_selected) if previously_selected else set() self.checkboxes = {} - + self.init_ui() self.apply_styling() - - def init_ui(self): + + def init_ui(self) -> None: layout = QVBoxLayout(self) - + # Title and description title_label = QLabel("SponsorBlock Categories") title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;") layout.addWidget(title_label) - + desc_label = QLabel( "Select which types of video segments to automatically remove during download.\n" "SponsorBlock uses community-submitted data to identify these segments." @@ -480,17 +502,17 @@ class SponsorBlockCategoryDialog(QDialog): desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;") layout.addWidget(desc_label) - + # Scroll area for categories scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setStyleSheet("QScrollArea { border: none; }") - + scroll_widget = QWidget() scroll_layout = QVBoxLayout(scroll_widget) scroll_layout.setContentsMargins(10, 0, 10, 0) scroll_layout.setSpacing(8) - + # Add category checkboxes for category_id, category_info in self.SPONSORBLOCK_CATEGORIES.items(): # Create a container widget for each category @@ -498,22 +520,23 @@ class SponsorBlockCategoryDialog(QDialog): category_layout = QVBoxLayout(category_widget) category_layout.setContentsMargins(0, 0, 0, 0) category_layout.setSpacing(2) - + # Create checkbox with just the name - checkbox = QCheckBox(category_info['name']) + checkbox = QCheckBox(category_info["name"]) checkbox.setProperty("category_id", category_id) - + # Determine if this category should be checked if self.previously_selected: # Use previously selected categories is_checked = category_id in self.previously_selected else: # Use default values for first time - is_checked = category_info['default'] - + is_checked = category_info["default"] + checkbox.setChecked(is_checked) - - checkbox.setStyleSheet(""" + + checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 4px; @@ -533,61 +556,64 @@ class SponsorBlockCategoryDialog(QDialog): border: 2px solid #ff0000; background: #ff0000; } - """) - + """ + ) + # Create description label - desc_label = QLabel(category_info['description']) + desc_label = QLabel(category_info["description"]) desc_label.setStyleSheet("color: #aaaaaa; font-size: 11px; margin-left: 28px; margin-bottom: 8px;") desc_label.setWordWrap(True) - + category_layout.addWidget(checkbox) category_layout.addWidget(desc_label) - + self.checkboxes[category_id] = checkbox scroll_layout.addWidget(category_widget) - + scroll_layout.addStretch() scroll_area.setWidget(scroll_widget) layout.addWidget(scroll_area) - + # Quick selection buttons button_layout = QHBoxLayout() - + select_defaults_btn = QPushButton("Select Defaults") select_defaults_btn.clicked.connect(self.select_defaults) select_defaults_btn.setStyleSheet(self._get_button_style()) - + select_all_btn = QPushButton("Select All") select_all_btn.clicked.connect(self.select_all) select_all_btn.setStyleSheet(self._get_button_style()) - + deselect_all_btn = QPushButton("Deselect All") deselect_all_btn.clicked.connect(self.deselect_all) deselect_all_btn.setStyleSheet(self._get_button_style()) - + button_layout.addWidget(select_defaults_btn) button_layout.addWidget(select_all_btn) button_layout.addWidget(deselect_all_btn) button_layout.addStretch() - + layout.addLayout(button_layout) - + # Dialog buttons button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) - + # Style the dialog buttons for button in button_box.buttons(): button.setStyleSheet(self._get_button_style()) if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole: - button.setStyleSheet(button.styleSheet() + - "QPushButton { background-color: #ff0000; border-color: #cc0000; } " + - "QPushButton:hover { background-color: #cc0000; }") - + button.setStyleSheet( + button.styleSheet() + + "QPushButton { background-color: #ff0000; border-color: #cc0000; } " + + "QPushButton:hover { background-color: #cc0000; }" + ) + layout.addWidget(button_box) - - def _get_button_style(self): + + def _get_button_style(self) -> str: """Returns the standard button style for this dialog.""" return """ QPushButton { @@ -605,10 +631,11 @@ class SponsorBlockCategoryDialog(QDialog): background-color: #555555; } """ - - def apply_styling(self): + + def apply_styling(self) -> None: """Apply the dialog styling to match the rest of the application.""" - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; color: #ffffff; @@ -619,33 +646,34 @@ class SponsorBlockCategoryDialog(QDialog): QWidget { background-color: #15181b; } - """) - - def select_defaults(self): + """ + ) + + def select_defaults(self) -> None: """Select only the default categories.""" for category_id, checkbox in self.checkboxes.items(): - default_value = self.SPONSORBLOCK_CATEGORIES[category_id]['default'] + default_value = self.SPONSORBLOCK_CATEGORIES[category_id]["default"] checkbox.setChecked(default_value) - - def select_all(self): + + def select_all(self) -> None: """Select all categories.""" for checkbox in self.checkboxes.values(): checkbox.setChecked(True) - - def deselect_all(self): + + def deselect_all(self) -> None: """Deselect all categories.""" for checkbox in self.checkboxes.values(): checkbox.setChecked(False) - - def get_selected_categories(self): + + def get_selected_categories(self) -> list: """Returns a list of selected category IDs.""" selected = [] for category_id, checkbox in self.checkboxes.items(): if checkbox.isChecked(): selected.append(category_id) return selected - - def get_selected_categories_string(self): + + def get_selected_categories_string(self) -> str: """Returns a comma-separated string of selected categories for yt-dlp.""" selected = self.get_selected_categories() - return ','.join(selected) if selected else '' + return ",".join(selected) if selected else "" diff --git a/src/gui/dialogs/ytsage_dialogs_settings.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py similarity index 82% rename from src/gui/dialogs/ytsage_dialogs_settings.py rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py index dfe9e90..0fc0ef1 100644 --- a/src/gui/dialogs/ytsage_dialogs_settings.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py @@ -3,22 +3,39 @@ Settings-related dialogs for YTSage application. Contains dialogs for configuring download settings and auto-update preferences. """ -import os -import requests -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QLineEdit, QPushButton, QGroupBox, QCheckBox, - QRadioButton, QComboBox, QDialogButtonBox, - QButtonGroup, QMessageBox, QFileDialog) -from PySide6.QtCore import Qt, QThread, Signal -from PySide6.QtGui import QIcon -from ...core.ytsage_logging import logger +import time +from datetime import datetime -from ...core.ytsage_utils import (get_auto_update_settings, update_auto_update_settings, - check_and_update_ytdlp_auto, get_ytdlp_version) +import requests +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QRadioButton, + QVBoxLayout, +) + +from src.core.ytsage_logging import logger +from src.core.ytsage_utils import ( + check_and_update_ytdlp_auto, + get_auto_update_settings, + get_ytdlp_version, + update_auto_update_settings, +) class DownloadSettingsDialog(QDialog): - def __init__(self, current_path, current_limit, current_unit_index, parent=None): + def __init__(self, current_path, current_limit, current_unit_index, parent=None) -> None: super().__init__(parent) self.setWindowTitle("Download Settings") self.setMinimumWidth(450) @@ -28,7 +45,8 @@ class DownloadSettingsDialog(QDialog): self.current_unit_index = current_unit_index # Apply main app styling - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; color: #ffffff; @@ -137,7 +155,8 @@ class DownloadSettingsDialog(QDialog): selection-background-color: #c90000; selection-color: #ffffff; } - """) + """ + ) layout = QVBoxLayout(self) @@ -147,7 +166,9 @@ class DownloadSettingsDialog(QDialog): self.path_display = QLabel(self.current_path) self.path_display.setWordWrap(True) - self.path_display.setStyleSheet("QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }") + self.path_display.setStyleSheet( + "QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }" + ) path_layout.addWidget(self.path_display) browse_button = QPushButton("Browse...") @@ -182,7 +203,7 @@ class DownloadSettingsDialog(QDialog): # Enable/Disable auto-update checkbox self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates") - self.auto_update_enabled.setChecked(auto_settings['enabled']) + self.auto_update_enabled.setChecked(auto_settings["enabled"]) auto_update_layout.addWidget(self.auto_update_enabled) # Frequency options @@ -195,10 +216,10 @@ class DownloadSettingsDialog(QDialog): self.weekly_radio = QRadioButton("Check weekly") # Set current selection based on saved settings - current_frequency = auto_settings['frequency'] - if current_frequency == 'startup': + current_frequency = auto_settings["frequency"] + if current_frequency == "startup": self.startup_radio.setChecked(True) - elif current_frequency == 'daily': + elif current_frequency == "daily": self.daily_radio.setChecked(True) else: # weekly self.weekly_radio.setChecked(True) @@ -224,17 +245,17 @@ class DownloadSettingsDialog(QDialog): button_box.rejected.connect(self.reject) layout.addWidget(button_box) - def browse_new_path(self): + def browse_new_path(self) -> None: new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path) if new_path: self.current_path = new_path self.path_display.setText(self.current_path) - def get_selected_path(self): + def get_selected_path(self) -> str: """Returns the confirmed path after the dialog is accepted.""" return self.current_path - def get_selected_speed_limit(self): + def get_selected_speed_limit(self) -> str | None: """Returns the entered speed limit value (as string or None).""" limit_str = self.speed_limit_input.text().strip() if not limit_str: @@ -246,18 +267,19 @@ class DownloadSettingsDialog(QDialog): logger.info("Invalid speed limit input in dialog") return None - def get_selected_unit_index(self): + def get_selected_unit_index(self) -> int: """Returns the index of the selected speed limit unit.""" return self.speed_limit_unit.currentIndex() - def _create_styled_message_box(self, icon, title, text): + def _create_styled_message_box(self, icon, title, text) -> QMessageBox: """Create a styled QMessageBox that matches the app theme.""" msg_box = QMessageBox(self) msg_box.setIcon(icon) msg_box.setWindowTitle(title) msg_box.setText(text) msg_box.setWindowIcon(self.windowIcon()) - msg_box.setStyleSheet(""" + msg_box.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -280,183 +302,187 @@ class DownloadSettingsDialog(QDialog): QMessageBox QPushButton:pressed { background-color: #800000; } - """) + """ + ) return msg_box - def test_update_check(self): + def test_update_check(self) -> None: """Test the update check functionality.""" try: # Get current version current_version = get_ytdlp_version() if "Error" in current_version: msg_box = self._create_styled_message_box( - QMessageBox.Warning, + QMessageBox.Icon.Warning, "Update Check", - "Could not determine current yt-dlp version." + "Could not determine current yt-dlp version.", ) msg_box.exec() return - + # Get latest version from PyPI response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) response.raise_for_status() latest_version = response.json()["info"]["version"] - + # Clean up version strings - current_version = current_version.replace('_', '.') - latest_version = latest_version.replace('_', '.') - + current_version = current_version.replace("_", ".") + latest_version = latest_version.replace("_", ".") + from packaging import version as version_parser + if version_parser.parse(latest_version) > version_parser.parse(current_version): msg_box = self._create_styled_message_box( - QMessageBox.Information, + QMessageBox.Icon.Information, "Update Check", - f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update." + f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update.", ) msg_box.exec() else: msg_box = self._create_styled_message_box( - QMessageBox.Information, + QMessageBox.Icon.Information, "Update Check", - f"yt-dlp is up to date!\n\nCurrent version: {current_version}" + f"yt-dlp is up to date!\n\nCurrent version: {current_version}", ) msg_box.exec() except Exception as e: msg_box = self._create_styled_message_box( - QMessageBox.Warning, + QMessageBox.Icon.Warning, "Update Check", - f"Error checking for updates: {str(e)}" + f"Error checking for updates: {str(e)}", ) msg_box.exec() - def get_auto_update_settings(self): + def get_auto_update_settings(self) -> tuple[bool, str]: """Returns the auto-update settings from the dialog.""" enabled = self.auto_update_enabled.isChecked() - + if self.startup_radio.isChecked(): - frequency = 'startup' + frequency = "startup" elif self.daily_radio.isChecked(): - frequency = 'daily' + frequency = "daily" else: # weekly_radio is checked - frequency = 'weekly' - + frequency = "weekly" + return enabled, frequency - def accept(self): + def accept(self) -> None: """Override accept to save auto-update settings.""" try: # Save auto-update settings enabled, frequency = self.get_auto_update_settings() - + if update_auto_update_settings(enabled, frequency): - QMessageBox.information(self, "Settings Saved", - "Auto-update settings have been saved successfully!") + QMessageBox.information( + self, + "Settings Saved", + "Auto-update settings have been saved successfully!", + ) else: - QMessageBox.warning(self, "Error", - "Failed to save auto-update settings.") + QMessageBox.warning(self, "Error", "Failed to save auto-update settings.") except Exception as e: - QMessageBox.critical(self, "Error", - f"Error saving auto-update settings: {str(e)}") - + QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {str(e)}") + # Call the parent accept method to close the dialog super().accept() class AutoUpdateSettingsDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) self.setWindowTitle("Auto-Update Settings") self.setMinimumWidth(400) self.setMinimumHeight(300) - + # Set the window icon to match the main app if parent: self.setWindowIcon(parent.windowIcon()) - + self.init_ui() self.load_current_settings() self.apply_styling() - - def init_ui(self): + + def init_ui(self) -> None: layout = QVBoxLayout(self) - + # Title title_label = QLabel("

🔄 Auto-Update Settings

") title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title_label) - + # Description desc_label = QLabel("Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.") desc_label.setWordWrap(True) desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;") layout.addWidget(desc_label) - + # Enable/Disable auto-update self.enable_checkbox = QCheckBox("Enable automatic yt-dlp updates") self.enable_checkbox.setChecked(True) # Default enabled self.enable_checkbox.toggled.connect(self.on_enable_toggled) layout.addWidget(self.enable_checkbox) - + # Frequency options frequency_group = QGroupBox("Update Frequency") frequency_layout = QVBoxLayout() - + self.frequency_group = QButtonGroup(self) - + self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)") self.daily_radio = QRadioButton("Check daily") self.weekly_radio = QRadioButton("Check weekly") - + self.daily_radio.setChecked(True) # Default to daily - + self.frequency_group.addButton(self.startup_radio, 0) self.frequency_group.addButton(self.daily_radio, 1) self.frequency_group.addButton(self.weekly_radio, 2) - + frequency_layout.addWidget(self.startup_radio) frequency_layout.addWidget(self.daily_radio) frequency_layout.addWidget(self.weekly_radio) frequency_group.setLayout(frequency_layout) - + layout.addWidget(frequency_group) - + # Current status status_group = QGroupBox("Current Status") status_layout = QVBoxLayout() - + self.current_version_label = QLabel("Current yt-dlp version: Checking...") self.last_check_label = QLabel("Last update check: Never") self.next_check_label = QLabel("Next check: Based on settings") - + status_layout.addWidget(self.current_version_label) status_layout.addWidget(self.last_check_label) status_layout.addWidget(self.next_check_label) status_group.setLayout(status_layout) - + layout.addWidget(status_group) - + # Manual check button self.manual_check_btn = QPushButton("🔍 Check for Updates Now") self.manual_check_btn.clicked.connect(self.manual_check) layout.addWidget(self.manual_check_btn) - + # Buttons button_layout = QHBoxLayout() - + self.save_btn = QPushButton("Save Settings") self.save_btn.clicked.connect(self.save_settings) - + self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.reject) - + button_layout.addWidget(self.save_btn) button_layout.addWidget(self.cancel_btn) - + layout.addLayout(button_layout) - - def apply_styling(self): - self.setStyleSheet(""" + + def apply_styling(self) -> None: + self.setStyleSheet( + """ QDialog { background-color: #15181b; color: #ffffff; @@ -518,24 +544,22 @@ class AutoUpdateSettingsDialog(QDialog): background-color: #666666; color: #999999; } - """) - - def load_current_settings(self): + """ + ) + + def load_current_settings(self) -> None: """Load current auto-update settings from config.""" try: - import time - from datetime import datetime - settings = get_auto_update_settings() - + # Set checkbox - self.enable_checkbox.setChecked(settings['enabled']) - + self.enable_checkbox.setChecked(settings["enabled"]) + # Set frequency - frequency = settings['frequency'] - if frequency == 'startup': + frequency = settings["frequency"] + if frequency == "startup": self.startup_radio.setChecked(True) - elif frequency == 'weekly': + elif frequency == "weekly": self.weekly_radio.setChecked(True) else: # daily self.daily_radio.setChecked(True) @@ -543,106 +567,106 @@ class AutoUpdateSettingsDialog(QDialog): # Update status labels current_version = get_ytdlp_version() self.current_version_label.setText(f"Current yt-dlp version: {current_version}") - - last_check = settings['last_check'] + + last_check = settings["last_check"] if last_check > 0: last_check_time = datetime.fromtimestamp(last_check).strftime("%Y-%m-%d %H:%M:%S") self.last_check_label.setText(f"Last update check: {last_check_time}") else: self.last_check_label.setText("Last update check: Never") - + # Calculate next check time self.update_next_check_label() - + # Update UI state - self.on_enable_toggled(settings['enabled']) - + self.on_enable_toggled(settings["enabled"]) + except Exception as e: logger.error(f"Error loading auto-update settings: {e}") - - def update_next_check_label(self): + + def update_next_check_label(self) -> None: """Update the next check label based on current settings.""" try: if not self.enable_checkbox.isChecked(): self.next_check_label.setText("Next check: Disabled") return - - import time - from datetime import datetime, timedelta - + settings = get_auto_update_settings() - last_check = settings['last_check'] + last_check = settings["last_check"] frequency = self.get_selected_frequency() - + if last_check == 0: self.next_check_label.setText("Next check: On next startup") return - + next_check_time = last_check - if frequency == 'startup': + if frequency == "startup": next_check_time += 3600 # 1 hour - elif frequency == 'daily': - next_check_time += 86400 # 24 hours - elif frequency == 'weekly': + elif frequency == "daily": + next_check_time += 86400 # 24 hours + elif frequency == "weekly": next_check_time += 604800 # 7 days - + current_time = time.time() if next_check_time <= current_time: self.next_check_label.setText("Next check: Now (overdue)") else: next_check_datetime = datetime.fromtimestamp(next_check_time) self.next_check_label.setText(f"Next check: {next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')}") - + except Exception as e: self.next_check_label.setText("Next check: Error calculating") logger.error(f"Error calculating next check time: {e}") - - def on_enable_toggled(self, enabled): + + def on_enable_toggled(self, enabled) -> None: """Handle enable/disable checkbox toggle.""" # Enable/disable frequency options for i in range(self.frequency_group.buttons().__len__()): self.frequency_group.button(i).setEnabled(enabled) - + self.update_next_check_label() - - def get_selected_frequency(self): + + def get_selected_frequency(self) -> str: """Get the selected frequency setting.""" if self.startup_radio.isChecked(): - return 'startup' + return "startup" elif self.weekly_radio.isChecked(): - return 'weekly' + return "weekly" else: - return 'daily' - - def manual_check(self): + return "daily" + + def manual_check(self) -> None: """Perform a manual update check.""" self.manual_check_btn.setEnabled(False) self.manual_check_btn.setText("🔄 Checking...") - + # Force an immediate update check - def check_in_thread(): + def check_in_thread() -> None: try: result = check_and_update_ytdlp_auto() - + # Update UI in main thread from PySide6.QtCore import QTimer + QTimer.singleShot(0, lambda: self.manual_check_finished(result)) except Exception as e: logger.error(f"Error during manual check: {e}") QTimer.singleShot(0, lambda: self.manual_check_finished(False)) - + # Run in separate thread to avoid blocking UI import threading + threading.Thread(target=check_in_thread, daemon=True).start() - - def _create_styled_message_box(self, icon, title, text): + + def _create_styled_message_box(self, icon, title, text) -> QMessageBox: """Create a styled QMessageBox that matches the app theme.""" msg_box = QMessageBox(self) msg_box.setIcon(icon) msg_box.setWindowTitle(title) msg_box.setText(text) msg_box.setWindowIcon(self.windowIcon()) - msg_box.setStyleSheet(""" + msg_box.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -665,58 +689,55 @@ class AutoUpdateSettingsDialog(QDialog): QMessageBox QPushButton:pressed { background-color: #800000; } - """) + """ + ) return msg_box - - def manual_check_finished(self, success): + + def manual_check_finished(self, success) -> None: """Handle completion of manual update check.""" self.manual_check_btn.setEnabled(True) self.manual_check_btn.setText("🔍 Check for Updates Now") - + if success: msg_box = self._create_styled_message_box( - QMessageBox.Information, + QMessageBox.Icon.Information, "Update Check", - "✅ Update check completed successfully!\nCheck the console for details." + "✅ Update check completed successfully!\nCheck the console for details.", ) msg_box.exec() else: msg_box = self._create_styled_message_box( - QMessageBox.Warning, - "Update Check", - "❌ Update check failed.\nCheck the console for error details." + QMessageBox.Icon.Warning, + "Update Check", + "❌ Update check failed.\nCheck the console for error details.", ) msg_box.exec() - + # Refresh the current settings display self.load_current_settings() - - def save_settings(self): + + def save_settings(self) -> None: """Save the auto-update settings.""" try: enabled = self.enable_checkbox.isChecked() frequency = self.get_selected_frequency() - + if update_auto_update_settings(enabled, frequency): msg_box = self._create_styled_message_box( - QMessageBox.Information, + QMessageBox.Icon.Information, "Settings Saved", - "✅ Auto-update settings have been saved successfully!" + "✅ Auto-update settings have been saved successfully!", ) msg_box.exec() self.accept() else: msg_box = self._create_styled_message_box( - QMessageBox.Warning, + QMessageBox.Icon.Warning, "Error", - "❌ Failed to save auto-update settings.\nPlease try again." + "❌ Failed to save auto-update settings.\nPlease try again.", ) msg_box.exec() except Exception as e: logger.error(f"Error saving auto-update settings: {e}") - msg_box = self._create_styled_message_box( - QMessageBox.Critical, - "Error", - f"❌ Error saving settings: {str(e)}" - ) + msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {str(e)}") msg_box.exec() diff --git a/src/gui/dialogs/ytsage_dialogs_update.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py similarity index 62% rename from src/gui/dialogs/ytsage_dialogs_update.py rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py index d02da0c..3036da0 100644 --- a/src/gui/dialogs/ytsage_dialogs_update.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py @@ -3,22 +3,25 @@ Update-related dialogs and threads for YTSage application. Contains dialogs and background threads for checking and performing yt-dlp updates. """ -import sys import os -import requests import subprocess +import sys import time -from packaging import version -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QProgressBar, QMessageBox) -from PySide6.QtCore import Qt, QThread, QTimer, Signal +from pathlib import Path -from ...core.ytsage_yt_dlp import get_yt_dlp_path -from ...core.ytsage_utils import get_ytdlp_version, load_config, save_config -from ...core.ytsage_logging import logger +import requests +from packaging import version +from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout + +from src.core.ytsage_logging import logger +from src.core.ytsage_utils import get_ytdlp_version, load_config, save_config +from src.core.ytsage_yt_dlp import get_yt_dlp_path +from src.utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL try: import yt_dlp + YT_DLP_AVAILABLE = True except ImportError: YT_DLP_AVAILABLE = False @@ -26,29 +29,30 @@ except ImportError: class VersionCheckThread(QThread): finished = Signal(str, str, str) # current_version, latest_version, error_message - - def run(self): + + def run(self) -> None: current_version = "" latest_version = "" error_message = "" - + try: # Get the yt-dlp executable path yt_dlp_path = get_yt_dlp_path() - + # Get current version with timeout try: - result = subprocess.run([yt_dlp_path, '--version'], - capture_output=True, - text=True, - timeout=30, # 30 second timeout - startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE), - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) + result = subprocess.run( + [yt_dlp_path, "--version"], + capture_output=True, + text=True, + timeout=30, # 30 second timeout + creationflags=SUBPROCESS_CREATIONFLAGS, + ) if result.returncode == 0: current_version = result.stdout.strip() else: # Try fallback if command failed if YT_DLP_AVAILABLE: - current_version = yt_dlp.version.__version__ + current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue] else: error_message = "yt-dlp not available." self.finished.emit(current_version, latest_version, error_message) @@ -56,34 +60,34 @@ class VersionCheckThread(QThread): except subprocess.TimeoutExpired: # Try fallback if timeout if YT_DLP_AVAILABLE: - current_version = yt_dlp.version.__version__ + current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue] else: error_message = "yt-dlp version check timed out and package not found." self.finished.emit(current_version, latest_version, error_message) return except Exception: - # Fallback to importing yt_dlp package directly if subprocess fails + # Fallback to importing yt_dlp package directly if subprocess fails if YT_DLP_AVAILABLE: - current_version = yt_dlp.version.__version__ + current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue] else: - error_message = "yt-dlp not found or accessible." - self.finished.emit(current_version, latest_version, error_message) - return + error_message = "yt-dlp not found or accessible." + self.finished.emit(current_version, latest_version, error_message) + return # Get latest version from PyPI response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) response.raise_for_status() latest_version = response.json()["info"]["version"] - + # Clean up version strings - current_version = current_version.replace('_', '.') - latest_version = latest_version.replace('_', '.') + current_version = current_version.replace("_", ".") + latest_version = latest_version.replace("_", ".") except requests.RequestException as e: error_message = f"Network error checking PyPI: {e}" except Exception as e: error_message = f"Error checking version: {e}" - + self.finished.emit(current_version, latest_version, error_message) @@ -91,54 +95,43 @@ class UpdateThread(QThread): update_status = Signal(str) # For status messages update_progress = Signal(int) # For progress percentage (0-100) update_finished = Signal(bool, str) # success (bool), message/error (str) - - def run(self): + + def run(self) -> None: error_message = "" success = False try: self.update_status.emit("🔍 Checking current installation...") self.update_progress.emit(10) - + # Get the yt-dlp path try: yt_dlp_path = get_yt_dlp_path() - self.update_status.emit(f"📍 Found yt-dlp at: {os.path.basename(yt_dlp_path)}") + self.update_status.emit(f"📍 Found yt-dlp at: {yt_dlp_path}") except Exception as e: self.update_status.emit(f"❌ Error getting yt-dlp path: {e}") self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}") return - - # Create startupinfo to hide console on Windows - startupinfo = None - if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + + # Extra logic moved to src\utils\ytsage_constants.py + self.update_progress.emit(20) - - # Check if we're using an app-managed binary or system installation - app_managed_dirs = [ - os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'), - os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')), - os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin')) - ] - - is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs) - + + # Extra logic moved to src\utils\ytsage_constants.py + is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH) + if is_app_managed: self.update_status.emit("📦 Updating app-managed yt-dlp binary...") success = self._update_binary(yt_dlp_path) else: self.update_status.emit("🐍 Updating system yt-dlp via pip...") - success = self._update_via_pip(startupinfo) - + success = self._update_via_pip() + if success: self.update_progress.emit(100) error_message = "✅ yt-dlp has been successfully updated!" else: error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection." - + except requests.RequestException as e: error_message = f"❌ Network error during update: {str(e)}" self.update_status.emit(error_message) @@ -147,92 +140,56 @@ class UpdateThread(QThread): error_message = f"❌ Update failed: {str(e)}" self.update_status.emit(error_message) success = False - + self.update_finished.emit(success, error_message) - - def _update_binary(self, yt_dlp_path): - """Update yt-dlp binary directly from GitHub releases.""" + + def _update_binary(self, yt_dlp_path: Path) -> bool: + """Update yt-dlp binary using its built-in updater (same logic as AutoUpdateThread).""" try: - self.update_status.emit("🌐 Determining download URL...") - self.update_progress.emit(30) - - # Determine the URL based on OS - if sys.platform == 'win32': - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" - elif sys.platform == 'darwin': - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" - else: - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" - - self.update_status.emit("⬇️ Downloading latest yt-dlp binary...") - self.update_progress.emit(40) - - # Download with progress tracking and timeout - response = requests.get(url, stream=True, timeout=30) - if response.status_code != 200: - self.update_status.emit(f"❌ Download failed: HTTP {response.status_code}") - return False - - total_size = int(response.headers.get('content-length', 0)) - temp_file = f"{yt_dlp_path}.new" - downloaded = 0 - - self.update_status.emit("💾 Downloading and saving binary...") - - with open(temp_file, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - downloaded += len(chunk) - - # Update progress (40-80% for download) - if total_size > 0: - progress = 40 + int((downloaded / total_size) * 40) - self.update_progress.emit(progress) - - self.update_status.emit("🔧 Installing updated binary...") - self.update_progress.emit(85) - - # Make executable on Unix systems - if sys.platform != 'win32': - os.chmod(temp_file, 0o755) - - # Replace the old file with the new one - try: - # On Windows, we need to remove the old file first - if sys.platform == 'win32' and os.path.exists(yt_dlp_path): - os.remove(yt_dlp_path) - - os.rename(temp_file, yt_dlp_path) + logger.info("UpdateThread: Checking for yt-dlp updates...") + + result = subprocess.run( + [yt_dlp_path, "-U"], + capture_output=True, + text=True, + timeout=60, + creationflags=SUBPROCESS_CREATIONFLAGS, + ) + + if result.returncode == 0: + # Make executable on Unix systems + if OS_NAME != "Windows": + os.chmod(yt_dlp_path, 0o755) + + logger.info("UpdateThread: yt-dlp update completed successfully.") + if result.stdout: + logger.debug(f"yt-dlp output: {result.stdout.strip()}") self.update_status.emit("✅ Binary successfully updated!") self.update_progress.emit(95) return True - - except Exception as e: - self.update_status.emit(f"❌ Error installing binary: {e}") - # Clean up temp file if it exists - if os.path.exists(temp_file): - try: - os.remove(temp_file) - except: - pass + else: + logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}") + self.update_status.emit(f"❌ yt-dlp update failed: {result.stderr.strip()}") return False - - except requests.RequestException as e: - self.update_status.emit(f"❌ Network error: {e}") + + except subprocess.TimeoutExpired: + logger.error("UpdateThread: yt-dlp update timed out.") + self.update_status.emit("❌ yt-dlp update timed out.") return False + except Exception as e: - self.update_status.emit(f"❌ Binary update failed: {e}") + logger.error(f"UpdateThread: Unexpected error during update: {e}", exc_info=True) + self.update_status.emit(f"❌ Unexpected error during update: {e}") return False - - def _update_via_pip(self, startupinfo): + + def _update_via_pip(self) -> bool: """Update yt-dlp via pip.""" try: import pkg_resources - + self.update_status.emit("🔍 Checking current pip installation...") self.update_progress.emit(30) - + # Get current version try: current_version = pkg_resources.get_distribution("yt-dlp").version @@ -240,27 +197,27 @@ class UpdateThread(QThread): except pkg_resources.DistributionNotFound: self.update_status.emit("⚠️ yt-dlp not found via pip, attempting installation...") current_version = "0.0.0" - + self.update_progress.emit(40) - + # Get the latest version from PyPI self.update_status.emit("🌐 Checking for latest version...") response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) - + if response.status_code != 200: self.update_status.emit("❌ Failed to check for updates") return False - + data = response.json() latest_version = data["info"]["version"] self.update_status.emit(f"🆕 Latest version: {latest_version}") self.update_progress.emit(50) - + # Compare versions if version.parse(latest_version) > version.parse(current_version): self.update_status.emit(f"⬆️ Updating from {current_version} to {latest_version}...") self.update_progress.emit(60) - + try: # Run pip update with timeout self.update_status.emit("📦 Running pip install --upgrade...") @@ -270,11 +227,11 @@ class UpdateThread(QThread): text=True, check=False, timeout=300, # 5 minute timeout for pip install - startupinfo=startupinfo + creationflags=SUBPROCESS_CREATIONFLAGS, ) - + self.update_progress.emit(85) - + if update_result.returncode == 0: self.update_status.emit("✅ Pip update completed successfully!") self.update_progress.emit(95) @@ -282,7 +239,7 @@ class UpdateThread(QThread): else: self.update_status.emit(f"❌ Pip update failed: {update_result.stderr}") return False - + except subprocess.TimeoutExpired: self.update_status.emit("❌ Pip update timed out after 5 minutes") return False @@ -293,49 +250,50 @@ class UpdateThread(QThread): self.update_status.emit("✅ yt-dlp is already up to date!") self.update_progress.emit(95) return True - + except Exception as e: self.update_status.emit(f"❌ Pip update failed: {e}") return False class YTDLPUpdateDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) self.setWindowTitle("Update yt-dlp") self.setMinimumWidth(450) self.setMinimumHeight(200) self._closing = False # Flag to track if dialog is closing - + layout = QVBoxLayout(self) - + # Status label self.status_label = QLabel("Checking for updates...") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setWordWrap(True) self.status_label.setMinimumHeight(60) layout.addWidget(self.status_label) - + # Progress bar self.progress_bar = QProgressBar() self.progress_bar.hide() # Hide initially layout.addWidget(self.progress_bar) - + # Buttons button_layout = QHBoxLayout() self.update_btn = QPushButton("Update") self.update_btn.clicked.connect(self.perform_update) self.update_btn.setEnabled(False) - + self.close_btn = QPushButton("Close") self.close_btn.clicked.connect(self.close) - + button_layout.addWidget(self.update_btn) button_layout.addWidget(self.close_btn) layout.addLayout(button_layout) - + # Style - self.setStyleSheet(""" + self.setStyleSheet( + """ QDialog { background-color: #15181b; } @@ -375,40 +333,43 @@ class YTDLPUpdateDialog(QDialog): border-radius: 4px; margin: 1px; } - """) - + """ + ) + # Start version check in background self.check_version() - - def check_version(self): + + def check_version(self) -> None: self.status_label.setText("Checking for updates...") self.update_btn.setEnabled(False) self.version_check_thread = VersionCheckThread() self.version_check_thread.finished.connect(self.on_version_check_finished) self.version_check_thread.start() - def on_version_check_finished(self, current_version, latest_version, error_message): + def on_version_check_finished(self, current_version, latest_version, error_message) -> None: # Check if dialog is closing to avoid unnecessary updates - if hasattr(self, '_closing') and self._closing: + if hasattr(self, "_closing") and self._closing: return - + if error_message: self.status_label.setText(error_message) self.update_btn.setEnabled(False) return if not current_version or not latest_version: - self.status_label.setText("Could not determine versions.") - self.update_btn.setEnabled(False) - return + self.status_label.setText("Could not determine versions.") + self.update_btn.setEnabled(False) + return try: # Compare versions current_ver = version.parse(current_version) latest_ver = version.parse(latest_version) - + if current_ver < latest_ver: - self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}") + self.status_label.setText( + f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}" + ) self.update_btn.setEnabled(True) else: self.status_label.setText(f"yt-dlp is up to date (version {current_version})") @@ -416,31 +377,33 @@ class YTDLPUpdateDialog(QDialog): except version.InvalidVersion: # If version parsing fails, do a simple string comparison if current_version != latest_version: - self.status_label.setText(f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}") + self.status_label.setText( + f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}" + ) self.update_btn.setEnabled(True) else: self.status_label.setText(f"yt-dlp is up to date (version {current_version})") self.update_btn.setEnabled(False) except Exception as e: - self.status_label.setText(f"Error comparing versions: {e}") - self.update_btn.setEnabled(False) + self.status_label.setText(f"Error comparing versions: {e}") + self.update_btn.setEnabled(False) - def perform_update(self): + def perform_update(self) -> None: # Immediate visual feedback self.update_btn.setEnabled(False) self.close_btn.setEnabled(False) self.update_btn.setText("Updating...") self.status_label.setText("🚀 Initializing update process...") - + # Show progress bar immediately self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) self.progress_bar.show() - + # Start the update thread self._start_update_thread() - - def _start_update_thread(self): + + def _start_update_thread(self) -> None: """Start the actual update thread.""" # Create and start the update thread self.update_thread = UpdateThread() @@ -449,95 +412,102 @@ class YTDLPUpdateDialog(QDialog): self.update_thread.update_finished.connect(self.on_update_finished) self.update_thread.start() - def on_update_status(self, message): + def on_update_status(self, message) -> None: """Slot to receive status messages from UpdateThread.""" - if not (hasattr(self, '_closing') and self._closing): + if not (hasattr(self, "_closing") and self._closing): self.status_label.setText(message) - def on_update_progress(self, progress): + def on_update_progress(self, progress) -> None: """Slot to receive progress updates from UpdateThread.""" - if not (hasattr(self, '_closing') and self._closing): + if not (hasattr(self, "_closing") and self._closing): self.progress_bar.setValue(progress) - def on_update_finished(self, success, message): + def on_update_finished(self, success, message) -> None: """Slot called when the UpdateThread finishes.""" # Check if dialog is closing to avoid unnecessary updates - if hasattr(self, '_closing') and self._closing: + if hasattr(self, "_closing") and self._closing: return - + self.progress_bar.setValue(100) self.status_label.setText(message) self.close_btn.setEnabled(True) self.update_btn.setText("Update") # Reset button text - + if success: # Show success briefly then auto-check version QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh else: # Re-enable update button on failure after a short delay - QTimer.singleShot(3000, lambda: self.update_btn.setEnabled(True) if not (hasattr(self, '_closing') and self._closing) else None) + QTimer.singleShot( + 3000, + lambda: (self.update_btn.setEnabled(True) if not (hasattr(self, "_closing") and self._closing) else None), + ) - def closeEvent(self, event): + def closeEvent(self, event) -> None: """Ensure threads are terminated if the dialog is closed prematurely.""" # Set a flag to indicate dialog is closing self._closing = True - - if hasattr(self, 'version_check_thread') and self.version_check_thread.isRunning(): + + if hasattr(self, "version_check_thread") and self.version_check_thread.isRunning(): self.version_check_thread.quit() if not self.version_check_thread.wait(3000): # Wait up to 3 seconds self.version_check_thread.terminate() - - if hasattr(self, 'update_thread') and self.update_thread.isRunning(): + + if hasattr(self, "update_thread") and self.update_thread.isRunning(): self.update_thread.quit() if not self.update_thread.wait(5000): # Wait up to 5 seconds for update to finish self.update_thread.terminate() - + super().closeEvent(event) class AutoUpdateThread(QThread): """Thread for performing automatic background updates without UI feedback.""" + update_finished = Signal(bool, str) # success (bool), message (str) - - def run(self): + + def run(self) -> None: """Perform automatic yt-dlp update check and update if needed.""" try: logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...") - + # Get current version current_version = get_ytdlp_version() if "Error" in current_version: logger.warning("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update") self.update_finished.emit(False, "Could not determine current yt-dlp version") return - + # Get latest version from PyPI try: response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) response.raise_for_status() latest_version = response.json()["info"]["version"] - + # Clean up version strings - current_version = current_version.replace('_', '.') - latest_version = latest_version.replace('_', '.') - + current_version = current_version.replace("_", ".") + latest_version = latest_version.replace("_", ".") + logger.info(f"AutoUpdateThread: Current yt-dlp version: {current_version}") logger.info(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}") - + # Compare versions if version.parse(latest_version) > version.parse(current_version): logger.info(f"AutoUpdateThread: Auto-updating yt-dlp from {current_version} to {latest_version}...") - + # Perform the update success = self._perform_update() - + if success: logger.info("AutoUpdateThread: Auto-update completed successfully!") # Update the last check timestamp config = load_config() - config['last_update_check'] = time.time() + config["last_update_check"] = time.time() save_config(config) - self.update_finished.emit(True, f"Successfully updated yt-dlp from {current_version} to {latest_version}") + self.update_finished.emit( + True, + f"Successfully updated yt-dlp from {current_version} to {latest_version}", + ) else: logger.warning("AutoUpdateThread: Auto-update failed") self.update_finished.emit(False, "Auto-update failed") @@ -545,110 +515,89 @@ class AutoUpdateThread(QThread): logger.info("AutoUpdateThread: yt-dlp is already up to date") # Still update the timestamp even if no update was needed config = load_config() - config['last_update_check'] = time.time() + config["last_update_check"] = time.time() save_config(config) - self.update_finished.emit(True, f"yt-dlp is already up to date (version {current_version})") - + self.update_finished.emit( + True, + f"yt-dlp is already up to date (version {current_version})", + ) + except requests.RequestException as e: logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}") self.update_finished.emit(False, f"Network error: {e}") except Exception as e: - logger.error(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True) + logger.error( + f"AutoUpdateThread: Error during auto-update check: {e}", + exc_info=True, + ) self.update_finished.emit(False, f"Update check error: {e}") - + except Exception as e: logger.critical(f"AutoUpdateThread: Critical error in auto-update: {e}", exc_info=True) self.update_finished.emit(False, f"Critical error: {e}") - - def _perform_update(self): + + def _perform_update(self) -> bool: """Perform the actual update using similar logic to UpdateThread but without UI feedback.""" try: # Get the yt-dlp path yt_dlp_path = get_yt_dlp_path() - - # Check if we're using an app-managed binary or system installation - app_managed_dirs = [ - os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'), - os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')), - os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin')) - ] - - is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs) - + + # Extra logic moved to src\utils\ytsage_constants.py + + is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH) + if is_app_managed: logger.info("AutoUpdateThread: Updating app-managed yt-dlp binary...") return self._update_binary(yt_dlp_path) else: logger.info("AutoUpdateThread: Updating system yt-dlp via pip...") return self._update_via_pip() - + except Exception as e: logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True) return False - - def _update_binary(self, yt_dlp_path): - """Update yt-dlp binary directly from GitHub releases (silent version).""" + + def _update_binary(self, yt_dlp_path: Path) -> bool: + """Update yt-dlp binary using its built-in updater.""" try: - # Determine the URL based on OS - if sys.platform == 'win32': - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" - elif sys.platform == 'darwin': - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" - else: - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" - - logger.info("AutoUpdateThread: Downloading latest yt-dlp binary...") - - # Download without progress tracking (silent) - response = requests.get(url, stream=True) - if response.status_code != 200: - logger.error(f"AutoUpdateThread: Download failed: HTTP {response.status_code}") - return False - - temp_file = f"{yt_dlp_path}.new" - - with open(temp_file, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - - logger.info("AutoUpdateThread: Installing updated binary...") - - # Make executable on Unix systems - if sys.platform != 'win32': - os.chmod(temp_file, 0o755) - - # Replace the old file with the new one - try: - # On Windows, we need to remove the old file first - if sys.platform == 'win32' and os.path.exists(yt_dlp_path): - os.remove(yt_dlp_path) - - os.rename(temp_file, yt_dlp_path) - logger.info("AutoUpdateThread: Binary successfully updated!") + logger.info("AutoUpdateThread: Checking for yt-dlp updates...") + + result = subprocess.run( + [yt_dlp_path, "-U"], + capture_output=True, + text=True, + timeout=60, + creationflags=SUBPROCESS_CREATIONFLAGS, + ) + + if result.returncode == 0: + # Make executable on Unix systems + if OS_NAME != "Windows": + os.chmod(yt_dlp_path, 0o755) + + logger.info("AutoUpdateThread: yt-dlp update completed successfully.") + if result.stdout: + logger.debug(f"yt-dlp output: {result.stdout.strip()}") return True - - except Exception as e: - logger.error(f"AutoUpdateThread: Error installing binary: {e}") - # Clean up temp file if it exists - if os.path.exists(temp_file): - try: - os.remove(temp_file) - except: - pass + else: + logger.error(f"AutoUpdateThread: yt-dlp update failed. {result.stderr.strip()}") return False - - except Exception as e: - logger.error(f"AutoUpdateThread: Binary update failed: {e}", exc_info=True) + + except subprocess.TimeoutExpired: + logger.error("AutoUpdateThread: yt-dlp update timed out.") return False - - def _update_via_pip(self): + + except Exception as e: + logger.error(f"AutoUpdateThread: Unexpected error during update: {e}", exc_info=True) + return False + + def _update_via_pip(self) -> bool: """Update yt-dlp via pip (silent version).""" try: import pkg_resources - + logger.info("AutoUpdateThread: Checking current pip installation...") - + # Get current version try: current_version = pkg_resources.get_distribution("yt-dlp").version @@ -656,30 +605,25 @@ class AutoUpdateThread(QThread): except pkg_resources.DistributionNotFound: logger.warning("AutoUpdateThread: yt-dlp not found via pip, attempting installation...") current_version = "0.0.0" - + # Get the latest version from PyPI logger.info("AutoUpdateThread: Checking for latest version...") response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) - + if response.status_code != 200: logger.error("AutoUpdateThread: Failed to check for updates") return False - + data = response.json() latest_version = data["info"]["version"] logger.info(f"AutoUpdateThread: Latest version: {latest_version}") - + # Compare versions if version.parse(latest_version) > version.parse(current_version): logger.info(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...") - - # Create startupinfo to hide console on Windows - startupinfo = None - if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = 0 # SW_HIDE - + + # Extra logic moved to src\utils\ytsage_constants.py + # Run pip update logger.info("AutoUpdateThread: Running pip install --upgrade...") update_result = subprocess.run( @@ -687,9 +631,9 @@ class AutoUpdateThread(QThread): capture_output=True, text=True, check=False, - startupinfo=startupinfo + creationflags=SUBPROCESS_CREATIONFLAGS, ) - + if update_result.returncode == 0: logger.info("AutoUpdateThread: Pip update completed successfully!") return True @@ -699,7 +643,7 @@ class AutoUpdateThread(QThread): else: logger.info("AutoUpdateThread: yt-dlp is already up to date!") return True - + except Exception as e: logger.error(f"AutoUpdateThread: Pip update failed: {e}", exc_info=True) return False diff --git a/src/gui/ytsage_gui_format_table.py b/src/gui/ytsage_gui_format_table.py index d151cc3..828cf3c 100644 --- a/src/gui/ytsage_gui_format_table.py +++ b/src/gui/ytsage_gui_format_table.py @@ -1,58 +1,67 @@ -from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, - QHBoxLayout, QLineEdit, QPushButton, QTableWidget, - QTableWidgetItem, QProgressBar, QLabel, QFileDialog, - QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, - QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QScrollArea, - QSizePolicy) -from PySide6.QtCore import Qt, Signal, QObject, QThread -from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap +from PySide6.QtCore import QObject, Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget + class FormatSignals(QObject): format_update = Signal(list) + class FormatTableMixin: - def setup_format_table(self): + def setup_format_table(self) -> QTableWidget: self.format_signals = FormatSignals() - + # Format table with improved styling self.format_table = QTableWidget() self.format_table.setColumnCount(8) - self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes']) - + self.format_table.setHorizontalHeaderLabels( + [ + "Select", + "Quality", + "Extension", + "Resolution", + "File Size", + "Codec", + "Audio", + "Notes", + ] + ) + # Enable alternating row colors self.format_table.setAlternatingRowColors(True) - + # Set specific column widths and resize modes self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Select self.format_table.setColumnWidth(0, 50) # Select column width - + self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) # Quality self.format_table.setColumnWidth(1, 100) # Quality width - + self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Extension self.format_table.setColumnWidth(2, 80) # Extension width - + self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Resolution self.format_table.setColumnWidth(3, 100) # Resolution width - + self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # File Size self.format_table.setColumnWidth(4, 100) # File Size width - + self.format_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # Codec self.format_table.setColumnWidth(5, 150) # Codec width - + self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # Audio self.format_table.setColumnWidth(6, 120) # Audio width - + self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) # Notes (will stretch) - + # Set vertical header (row numbers) visible to false self.format_table.verticalHeader().setVisible(False) - + # Set selection mode to no selection (since we're using checkboxes) self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) - - self.format_table.setStyleSheet(""" + + self.format_table.setStyleSheet( + """ QTableWidget { background-color: #1b2021; border: 2px solid #1b2021; @@ -96,27 +105,28 @@ class FormatTableMixin: QWidget { background-color: transparent; } - """) - + """ + ) + # Store format checkboxes and formats self.format_checkboxes = [] self.all_formats = [] - + # Set table size policies self.format_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - + # Set minimum and maximum heights self.format_table.setMinimumHeight(200) - + # Connect the signal self.format_signals.format_update.connect(self._update_format_table) - + return self.format_table - def filter_formats(self): - if not hasattr(self, 'all_formats'): + def filter_formats(self) -> None: + if not hasattr(self, "all_formats"): return - + # Clear current table self.format_table.setRowCount(0) self.format_checkboxes.clear() @@ -124,44 +134,46 @@ class FormatTableMixin: # Determine which formats to show filtered_formats = [] - if hasattr(self, 'video_button') and self.video_button.isChecked(): - filtered_formats.extend([f for f in self.all_formats - if f.get('vcodec') != 'none' - and f.get('filesize') is not None]) + if hasattr(self, "video_button") and self.video_button.isChecked(): # type: ignore[reportAttributeAccessIssue] + filtered_formats.extend([f for f in self.all_formats if f.get("vcodec") != "none" and f.get("filesize") is not None]) - if hasattr(self, 'audio_button') and self.audio_button.isChecked(): - filtered_formats.extend([f for f in self.all_formats - if (f.get('vcodec') == 'none' - or 'audio only' in f.get('format_note', '').lower()) - and f.get('acodec') != 'none' - and f.get('filesize') is not None]) + if hasattr(self, "audio_button") and self.audio_button.isChecked(): # type: ignore[reportAttributeAccessIssue] + filtered_formats.extend( + [ + f + for f in self.all_formats + if (f.get("vcodec") == "none" or "audio only" in f.get("format_note", "").lower()) + and f.get("acodec") != "none" + and f.get("filesize") is not None + ] + ) # Sort formats by quality def get_quality(f): - if f.get('vcodec') != 'none': - res = f.get('resolution', '0x0').split('x')[-1] + if f.get("vcodec") != "none": + res = f.get("resolution", "0x0").split("x")[-1] try: return int(res) except ValueError: return 0 else: - return f.get('abr', 0) + return f.get("abr", 0) filtered_formats.sort(key=get_quality, reverse=True) # Update table with filtered formats self.format_signals.format_update.emit(filtered_formats) - def _update_format_table(self, formats): + def _update_format_table(self, formats) -> None: self.format_table.setRowCount(0) self.format_checkboxes.clear() - is_playlist_mode = hasattr(self, 'is_playlist') and self.is_playlist + is_playlist_mode = hasattr(self, "is_playlist") and self.is_playlist # type: ignore[reportAttributeAccessIssue] # Configure columns based on mode if is_playlist_mode: self.format_table.setColumnCount(5) - self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Resolution', 'Notes', 'Audio']) + self.format_table.setHorizontalHeaderLabels(["Select", "Quality", "Resolution", "Notes", "Audio"]) # Configure column visibility and resizing for playlist mode self.format_table.setColumnHidden(5, True) @@ -175,14 +187,25 @@ class FormatTableMixin: self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) - + else: self.format_table.setColumnCount(8) - self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes']) + self.format_table.setHorizontalHeaderLabels( + [ + "Select", + "Quality", + "Extension", + "Resolution", + "File Size", + "Codec", + "Audio", + "Notes", + ] + ) # Ensure all columns are visible for i in range(2, 8): - self.format_table.setColumnHidden(i, False) - + self.format_table.setColumnHidden(i, False) + # Reapply resize modes for non-playlist mode if needed (optional, might be okay without) self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) self.format_table.setColumnWidth(0, 50) @@ -199,20 +222,22 @@ class FormatTableMixin: self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) self.format_table.setColumnWidth(6, 120) self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) - # Find best quality format for recommendations (only needed for non-playlist mode notes) best_video_size = 0 if not is_playlist_mode: - best_video_size = max((f.get('filesize', 0) for f in formats if f.get('vcodec') != 'none'), default=0) - + best_video_size = max( + (f.get("filesize", 0) for f in formats if f.get("vcodec") != "none"), + default=0, + ) + for f in formats: row = self.format_table.rowCount() self.format_table.insertRow(row) - + # Column 0: Select Checkbox (Always shown) checkbox = QCheckBox() - checkbox.format_id = str(f.get('format_id', '')) + checkbox.format_id = str(f.get("format_id", "")) # type: ignore[reportAttributeAccessIssue] checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb)) self.format_checkboxes.append(checkbox) checkbox_widget = QWidget() @@ -229,84 +254,83 @@ class FormatTableMixin: quality_item = QTableWidgetItem(quality_text) # Set color based on quality if "Best" in quality_text: - quality_item.setForeground(QColor('#00ff00')) # Green for best quality + quality_item.setForeground(QColor("#00ff00")) # Green for best quality elif "High" in quality_text: - quality_item.setForeground(QColor('#00cc00')) # Light green for high quality + quality_item.setForeground(QColor("#00cc00")) # Light green for high quality elif "Medium" in quality_text: - quality_item.setForeground(QColor('#ffaa00')) # Orange for medium quality + quality_item.setForeground(QColor("#ffaa00")) # Orange for medium quality elif "Low" in quality_text: - quality_item.setForeground(QColor('#ff5555')) # Red for low quality + quality_item.setForeground(QColor("#ff5555")) # Red for low quality self.format_table.setItem(row, 1, quality_item) # --- Populate columns common to both modes (Moved outside the 'if not is_playlist_mode' block) --- - + # Column 2: Resolution (Always shown) - resolution = f.get('resolution', 'N/A') - if f.get('vcodec') == 'none': - resolution = 'Audio only' + resolution = f.get("resolution", "N/A") + if f.get("vcodec") == "none": + resolution = "Audio only" self.format_table.setItem(row, 2, QTableWidgetItem(resolution)) - + # Column 3: Notes for playlist mode, Extension for normal mode if is_playlist_mode: # Get notes for playlist mode notes = self._get_format_notes(f) notes_item = QTableWidgetItem(notes) if "✨ Recommended" in notes: - notes_item.setForeground(QColor('#00ff00')) # Green for recommended + notes_item.setForeground(QColor("#00ff00")) # Green for recommended elif "💾 Storage friendly" in notes: - notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly + notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly elif "📱 Mobile friendly" in notes: - notes_item.setForeground(QColor('#ff9900')) # Orange for mobile + notes_item.setForeground(QColor("#ff9900")) # Orange for mobile self.format_table.setItem(row, 3, notes_item) else: # Extension for normal mode (column 2) - self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper())) - + self.format_table.setItem(row, 2, QTableWidgetItem(f.get("ext", "").upper())) + # Column 4 in playlist mode, Column 6 in normal mode: Audio Status - needs_audio = f.get('acodec') == 'none' and f.get('vcodec') != 'none' # Only mark video-only as needing merge - audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get('vcodec') != 'none' else "Audio Only") + needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none" # Only mark video-only as needing merge + audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get("vcodec") != "none" else "Audio Only") audio_item = QTableWidgetItem(audio_status) if needs_audio: - audio_item.setForeground(QColor('#ffa500')) + audio_item.setForeground(QColor("#ffa500")) elif audio_status == "Audio Only": - audio_item.setForeground(QColor('#cccccc')) # Neutral color for audio only - else: # Has Audio (Video+Audio) - audio_item.setForeground(QColor('#00cc00')) # Green for included audio + audio_item.setForeground(QColor("#cccccc")) # Neutral color for audio only + else: # Has Audio (Video+Audio) + audio_item.setForeground(QColor("#00cc00")) # Green for included audio # Set item for correct column based on mode audio_column_index = 4 if is_playlist_mode else 6 self.format_table.setItem(row, audio_column_index, audio_item) - # --- Populate columns only shown in non-playlist mode --- if not is_playlist_mode: # Column 3: Resolution self.format_table.setItem(row, 3, QTableWidgetItem(resolution)) - + # Column 4: File Size filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB" self.format_table.setItem(row, 4, QTableWidgetItem(filesize)) - + # Column 5: Codec - if f.get('vcodec') == 'none': - codec = f.get('acodec', 'N/A') + if f.get("vcodec") == "none": + codec = f.get("acodec", "N/A") else: codec = f"{f.get('vcodec', 'N/A')}" - if f.get('acodec') != 'none': + if f.get("acodec") != "none": codec += f" / {f.get('acodec', 'N/A')}" self.format_table.setItem(row, 5, QTableWidgetItem(codec)) - + # Column 7: Notes notes = self._get_format_notes(f) notes_item = QTableWidgetItem(notes) if "✨ Recommended" in notes: - notes_item.setForeground(QColor('#00ff00')) # Green for recommended + notes_item.setForeground(QColor("#00ff00")) # Green for recommended elif "💾 Storage friendly" in notes: - notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly + notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly elif "📱 Mobile friendly" in notes: - notes_item.setForeground(QColor('#ff9900')) # Orange for mobile + notes_item.setForeground(QColor("#ff9900")) # Orange for mobile self.format_table.setItem(row, 7, notes_item) - def handle_checkbox_click(self, clicked_checkbox): + def handle_checkbox_click(self, clicked_checkbox) -> None: for checkbox in self.format_checkboxes: if checkbox != clicked_checkbox: checkbox.setChecked(False) @@ -317,15 +341,15 @@ class FormatTableMixin: return checkbox.format_id return None - def update_format_table(self, formats): + def update_format_table(self, formats) -> None: self.all_formats = formats self.format_signals.format_update.emit(formats) - def get_quality_label(self, format_info): + def get_quality_label(self, format_info) -> str: """Determine quality label based on format information""" - if format_info.get('vcodec') == 'none': + if format_info.get("vcodec") == "none": # Audio quality - abr = format_info.get('abr', 0) + abr = format_info.get("abr", 0) if abr >= 256: return "Best Audio" elif abr >= 192: @@ -337,13 +361,13 @@ class FormatTableMixin: else: # Video quality height = 0 - resolution = format_info.get('resolution', '') + resolution = format_info.get("resolution", "") if resolution: try: - height = int(resolution.split('x')[1]) + height = int(resolution.split("x")[1]) except: pass - + if height >= 2160: return "Best (4K)" elif height >= 1440: @@ -357,20 +381,20 @@ class FormatTableMixin: else: return "Low Quality" - def _get_format_notes(self, format_info): + def _get_format_notes(self, format_info) -> str: """Generate helpful format notes based on format info.""" notes = [] - + # Add storage indicator with more granular categories - file_size = format_info.get('filesize') or format_info.get('filesize_approx', 0) - resolution = format_info.get('resolution', '') + file_size = format_info.get("filesize") or format_info.get("filesize_approx", 0) + resolution = format_info.get("resolution", "") height = 0 if resolution: try: - height = int(resolution.split('x')[1]) + height = int(resolution.split("x")[1]) except: pass - + # Better file size categories if file_size > 50 * 1024 * 1024: # Over 50MB notes.append("Large size") @@ -380,20 +404,20 @@ class FormatTableMixin: notes.append("Standard size") else: # Under 5MB notes.append("Small size") - + # Add codec quality indicator - vcodec = format_info.get('vcodec', '') - if vcodec != 'none': - if 'avc1' in vcodec: # H.264 + vcodec = format_info.get("vcodec", "") + if vcodec != "none": + if "avc1" in vcodec: # H.264 notes.append("Compatible") - elif 'av01' in vcodec: # AV1 + elif "av01" in vcodec: # AV1 notes.append("Efficient") - elif 'vp9' in vcodec: # VP9 + elif "vp9" in vcodec: # VP9 notes.append("High quality") - + # Add quick mobile compatibility check - if 'avc1' in vcodec and file_size < 8 * 1024 * 1024: + if "avc1" in vcodec and file_size < 8 * 1024 * 1024: notes.append("Mobile") - + # Return simple string - return " • ".join(notes) \ No newline at end of file + return " • ".join(notes) diff --git a/src/gui/ytsage_gui_main.py b/src/gui/ytsage_gui_main.py index 740892f..e8e902f 100644 --- a/src/gui/ytsage_gui_main.py +++ b/src/gui/ytsage_gui_main.py @@ -1,65 +1,83 @@ -import sys -import os -import webbrowser -from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, - QHBoxLayout, QLineEdit, QPushButton, QTableWidget, - QTableWidgetItem, QProgressBar, QLabel, QFileDialog, - QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QMessageBox, QListWidget, - QListWidgetItem, QDialogButtonBox, QScrollArea) -from PySide6.QtCore import Qt, Signal, QObject, QThread, QMetaObject, Q_ARG, QProcess, Slot -from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap -import requests -from io import BytesIO -from PIL import Image -from datetime import datetime import json -from pathlib import Path -from packaging import version import subprocess -import re +import threading +import webbrowser +from pathlib import Path + +import markdown +import requests +from packaging import version +from PySide6.QtCore import Q_ARG, QMetaObject, Qt +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import ( + QApplication, + QButtonGroup, + QCheckBox, + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QProgressBar, + QPushButton, + QStyle, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from src.core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes +from src.core.ytsage_logging import logger +from src.core.ytsage_utils import check_ffmpeg # Import utility functions +from src.core.ytsage_utils import load_saved_path, save_path, should_check_for_auto_update +from src.core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions +from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py + AboutDialog, + AutoUpdateThread, + CustomOptionsDialog, + DownloadSettingsDialog, + FFmpegCheckDialog, + PlaylistSelectionDialog, + TimeRangeDialog, + YTDLPUpdateDialog, +) +from src.gui.ytsage_gui_format_table import FormatTableMixin +from src.gui.ytsage_gui_video_info import VideoInfoMixin +from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIONFLAGS + try: import yt_dlp + YT_DLP_AVAILABLE = True except ImportError: YT_DLP_AVAILABLE = False -import markdown + try: import pygame + PYGAME_AVAILABLE = True except ImportError: PYGAME_AVAILABLE = False -import threading -from ..core.ytsage_logging import logger -from ..core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes -from ..core.ytsage_utils import check_ffmpeg, load_saved_path, save_path, get_config_file_path, get_ytdlp_version, get_ffmpeg_version, should_check_for_auto_update, check_and_update_ytdlp_auto # Import utility functions -from ..core.ytsage_yt_dlp import check_ytdlp_binary, setup_ytdlp, get_ytdlp_executable_path, get_yt_dlp_path # Import the new yt-dlp functions -from .ytsage_gui_dialogs import (LogWindow, CustomCommandDialog, FFmpegCheckDialog, - YTDLPUpdateDialog, AboutDialog, SubtitleSelectionDialog, - PlaylistSelectionDialog, CookieLoginDialog, - DownloadSettingsDialog, CustomOptionsDialog, TimeRangeDialog, - SponsorBlockCategoryDialog) # Added SponsorBlockCategoryDialog -from .ytsage_gui_format_table import FormatTableMixin # Import FormatTableMixin -from .ytsage_gui_video_info import VideoInfoMixin # Import VideoInfoMixin - -class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins - def __init__(self): +class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins + def __init__(self) -> None: super().__init__() - + # Initialize logger for this class self.logger = logger.bind(module="YTSageApp") - + # Log startup warnings for missing dependencies if not YT_DLP_AVAILABLE: self.logger.warning("yt-dlp not available at startup, will be downloaded at runtime") if not PYGAME_AVAILABLE: self.logger.warning("pygame not available, audio notifications disabled") - + # Check for FFmpeg before proceeding if not check_ffmpeg(): self.show_ffmpeg_dialog() - + # Check for yt-dlp in our app's bin directory or system PATH ytdlp_path = get_yt_dlp_path() if ytdlp_path == "yt-dlp": # Not found in app dir or PATH @@ -69,49 +87,46 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.version = "4.7.0" self.check_for_updates() - + # Check for auto-updates if enabled self.check_auto_update_ytdlp() - - self.config_file = get_config_file_path() + load_saved_path(self) # Load custom icon - # Navigate from src/gui/ to project root, then to assets/Icon/ - project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - icon_path = os.path.join(project_root, 'assets', 'Icon', 'icon.png') - if os.path.exists(icon_path): - self.setWindowIcon(QIcon(icon_path)) + if ICON_PATH.exists(): + self.setWindowIcon(QIcon(ICON_PATH.as_posix())) else: - self.logger.warning(f"Icon file not found at {icon_path}. Using default icon.") - self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback + self.logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.") + self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback self.signals = SignalManager() self.download_paused = False self.current_download = None self.download_cancelled = False self.save_thumbnail = False # Initialize thumbnail state - self.thumbnail_url = None # Add this to store thumbnail URL - self.all_formats = [] # Initialize all_formats + self.thumbnail_url = None # Add this to store thumbnail URL + self.all_formats = [] # Initialize all_formats self.available_subtitles = {} self.available_automatic_subtitles = {} self.is_playlist = False self.playlist_info = None self.video_info = None - self.playlist_entries = [] # Initialize playlist entries - self.selected_playlist_items = None # Initialize selection string - self.save_description = False # Initialize description state - self.embed_chapters = False # Initialize chapters state + self.playlist_entries = [] # Initialize playlist entries + self.selected_playlist_items = None # Initialize selection string + self.save_description = False # Initialize description state + self.embed_chapters = False # Initialize chapters state self.subtitle_filter = "" self.thumbnail_image = None self.video_url = "" - self.selected_subtitles = [] # Initialize selected subtitles list - self.cookie_file_path = None # Initialize cookie file path - self.speed_limit_value = None # Store speed limit value - self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s) + self.selected_subtitles = [] # Initialize selected subtitles list + self.cookie_file_path = None # Initialize cookie file path + self.speed_limit_value = None # Store speed limit value + self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s) self.download_section = None self.force_keyframes = False self.init_ui() - self.setStyleSheet(""" + self.setStyleSheet( + """ QMainWindow { background-color: #15181b; } @@ -272,37 +287,36 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: none; } - """) + """ + ) self.signals.update_progress.connect(self.update_progress_bar) # After adding format buttons self.video_button.clicked.connect(self.filter_formats) # Connect video button self.audio_button.clicked.connect(self.filter_formats) # Connect audio button - + # Add connections to handle video/audio mode-specific controls self.video_button.clicked.connect(self.handle_mode_change) self.audio_button.clicked.connect(self.handle_mode_change) - + # Initialize UI state based on current mode self.handle_mode_change() - + # Initialize pygame for sound notifications self.init_sound() - def init_sound(self): + def init_sound(self) -> None: """Initialize pygame mixer for sound notifications""" try: if PYGAME_AVAILABLE: pygame.mixer.init() self.sound_enabled = True - - # Get the notification sound path - # Navigate from src/gui/ to project root, then to assets/sound/ - project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - self.notification_sound_path = os.path.join(project_root, 'assets', 'sound', 'notification.mp3') - + + # sound_path logic moved to src\utils\ytsage_constants.py + self.notification_sound_path = SOUND_PATH + # Check if the notification sound file exists - if not os.path.exists(self.notification_sound_path): + if not self.notification_sound_path.exists(): self.logger.warning(f"Notification sound file not found at: {self.notification_sound_path}") self.sound_enabled = False else: @@ -310,43 +324,39 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m else: self.sound_enabled = False self.logger.info("Sound notifications disabled - pygame not available") - + except Exception as e: self.logger.error(f"Error initializing sound: {e}") self.sound_enabled = False - def play_notification_sound(self): + def play_notification_sound(self) -> None: """Play notification sound in a separate thread to avoid blocking the UI""" if not self.sound_enabled: return - - def play_sound(): + + def play_sound() -> None: try: if PYGAME_AVAILABLE: # Load and play the sound pygame.mixer.music.load(self.notification_sound_path) pygame.mixer.music.play() - + # Wait for the sound to finish playing while pygame.mixer.music.get_busy(): pygame.time.wait(100) - + except Exception as e: self.logger.error(f"Error playing notification sound: {e}") - + # Play sound in a separate thread to avoid blocking the UI sound_thread = threading.Thread(target=play_sound) sound_thread.daemon = True sound_thread.start() - def load_saved_path(self): # Using function from ytsage_utils now - no longer needed in class - pass # Handled in class init now via ytsage_utils.load_saved_path(self) + # Removed load_saved_path and save_path methods since their functionality is now handled directly by ytsage_utils - def save_path(self, path): # Using function from ytsage_utils now - no longer needed in class - save_path(self, path) # Call the utility function - - def init_ui(self): - self.setWindowTitle(f'YTSage v{self.version}') + def init_ui(self) -> None: + self.setWindowTitle(f"YTSage v{self.version}") self.setMinimumSize(900, 750) # Main widget and layout @@ -360,7 +370,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m url_layout = QHBoxLayout() self.url_input = QLineEdit() self.url_input.setPlaceholderText("Enter YouTube video or playlist URL") - self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key + self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key self.analyze_button = QPushButton("Analyze") self.analyze_button.clicked.connect(self.analyze_url) @@ -387,7 +397,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Add video info container to main layout layout.addWidget(video_info_container) - # --- Add Playlist Info Section Directly to Main Layout --- + # --- Add Playlist Info Section Directly to Main Layout --- # Add playlist info label (initially hidden) self.playlist_info_label = self.setup_playlist_info_section() layout.addWidget(self.playlist_info_label) @@ -396,7 +406,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.playlist_select_btn = QPushButton("Select Videos...") self.playlist_select_btn.clicked.connect(self.open_playlist_selection_dialog) self.playlist_select_btn.setVisible(False) - self.playlist_select_btn.setStyleSheet(""" + self.playlist_select_btn.setStyleSheet( + """ QPushButton { padding: 6px 12px; background-color: #1d1e22; @@ -411,7 +422,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m background-color: #2a2d36; border-color: #a50000; } - """) + """ + ) layout.addWidget(self.playlist_select_btn) # --- End Playlist Info Section --- @@ -434,7 +446,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.video_button = QPushButton("Video") self.video_button.setCheckable(True) self.video_button.setChecked(True) # Set video as default - self.video_button.setStyleSheet(""" + self.video_button.setStyleSheet( + """ QPushButton { padding: 8px 15px; background-color: #1d1e22; @@ -452,14 +465,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QPushButton:checked:hover { background-color: #a50000; } - """) + """ + ) self.format_buttons.addButton(self.video_button) self.format_layout.addWidget(self.video_button) # Audio button self.audio_button = QPushButton("Audio Only") self.audio_button.setCheckable(True) - self.audio_button.setStyleSheet(""" + self.audio_button.setStyleSheet( + """ QPushButton { padding: 8px 15px; background-color: #1d1e22; @@ -477,13 +492,15 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QPushButton:checked:hover { background-color: #a50000; } - """) + """ + ) self.format_buttons.addButton(self.audio_button) self.format_layout.addWidget(self.audio_button) # Add Merge Subtitles checkbox (Moved here) self.merge_subs_checkbox = QCheckBox("Merge Subtitles") - self.merge_subs_checkbox.setStyleSheet(""" + self.merge_subs_checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -507,7 +524,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m /* Add disabled state styling if needed */ QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } - """) + """ + ) # Initially disable it, will be enabled if subtitles are selected later self.merge_subs_checkbox.setEnabled(False) self.format_layout.addWidget(self.merge_subs_checkbox) @@ -516,7 +534,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.save_thumbnail_checkbox = QCheckBox("Save Thumbnail") self.save_thumbnail_checkbox.setChecked(False) self.save_thumbnail_checkbox.stateChanged.connect(self.toggle_save_thumbnail) - self.save_thumbnail_checkbox.setStyleSheet(""" + self.save_thumbnail_checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -527,14 +546,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; } QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } - """) + """ + ) self.format_layout.addWidget(self.save_thumbnail_checkbox) # Add Save Description Checkbox (Moved here) self.save_description_checkbox = QCheckBox("Save Description") self.save_description_checkbox.setChecked(False) self.save_description_checkbox.stateChanged.connect(self.toggle_save_description) - self.save_description_checkbox.setStyleSheet(""" + self.save_description_checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -545,14 +566,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; } QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } - """) + """ + ) self.format_layout.addWidget(self.save_description_checkbox) # Add Embed Chapters Checkbox self.embed_chapters_checkbox = QCheckBox("Embed Chapters") self.embed_chapters_checkbox.setChecked(False) self.embed_chapters_checkbox.stateChanged.connect(self.toggle_embed_chapters) - self.embed_chapters_checkbox.setStyleSheet(""" + self.embed_chapters_checkbox.setStyleSheet( + """ QCheckBox { color: #ffffff; padding: 5px; @@ -563,7 +586,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; } QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } - """) + """ + ) self.format_layout.addWidget(self.embed_chapters_checkbox) self.format_layout.addStretch() @@ -578,34 +602,34 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m download_layout = QHBoxLayout() # Replace the two separate buttons with a single Custom Options button - self.custom_options_btn = QPushButton('Custom Options') + self.custom_options_btn = QPushButton("Custom Options") self.custom_options_btn.clicked.connect(self.show_custom_options) - self.about_btn = QPushButton('About') + self.about_btn = QPushButton("About") self.about_btn.clicked.connect(self.show_about_dialog) # Add new Time Range button - self.time_range_btn = QPushButton('Trim Video') + self.time_range_btn = QPushButton("Trim Video") self.time_range_btn.clicked.connect(self.show_time_range_dialog) - - self.update_ytdlp_btn = QPushButton('Update yt-dlp') + + self.update_ytdlp_btn = QPushButton("Update yt-dlp") self.update_ytdlp_btn.clicked.connect(self.update_ytdlp) # --- Rename Path Button to Settings Button --- - self.settings_button = QPushButton("Download Settings") # Renamed button - self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method - self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: None") # Update initial tooltip + self.settings_button = QPushButton("Download Settings") # Renamed button + self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method + self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: None") # Update initial tooltip # --- End Settings Button --- - self.download_btn = QPushButton('Download') + self.download_btn = QPushButton("Download") self.download_btn.clicked.connect(self.start_download) # Add pause and cancel buttons - self.pause_btn = QPushButton('Pause') + self.pause_btn = QPushButton("Pause") self.pause_btn.clicked.connect(self.toggle_pause) self.pause_btn.setVisible(False) # Hidden initially - self.cancel_btn = QPushButton('Cancel') + self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.cancel_download) self.cancel_btn.setVisible(False) # Hidden initially @@ -624,7 +648,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Progress section with improved styling progress_layout = QVBoxLayout() self.progress_bar = QProgressBar() - self.progress_bar.setStyleSheet(""" + self.progress_bar.setStyleSheet( + """ QProgressBar { border: 2px solid #3d3d3d; border-radius: 4px; @@ -637,30 +662,35 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m background-color: #ff0000; border-radius: 2px; } - """) + """ + ) progress_layout.addWidget(self.progress_bar) # Add download details label with improved styling self.download_details_label = QLabel() self.download_details_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.download_details_label.setStyleSheet(""" + self.download_details_label.setStyleSheet( + """ QLabel { color: #cccccc; font-size: 12px; padding: 5px; } - """) + """ + ) progress_layout.addWidget(self.download_details_label) - self.status_label = QLabel('Ready') + self.status_label = QLabel("Ready") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.status_label.setStyleSheet(""" + self.status_label.setStyleSheet( + """ QLabel { color: #cccccc; font-size: 12px; padding: 5px; } - """) + """ + ) progress_layout.addWidget(self.status_label) layout.addLayout(progress_layout) @@ -670,24 +700,30 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.signals.update_status.connect(self.status_label.setText) self.signals.update_progress.connect(self.update_progress_bar) - def analyze_url(self): + # Connect new signals + self.signals.playlist_info_label_visible.connect(self.playlist_info_label.setVisible) + self.signals.playlist_info_label_text.connect(self.playlist_info_label.setText) + self.signals.selected_subs_label_text.connect(self.selected_subs_label.setText) + self.signals.playlist_select_btn_visible.connect(self.playlist_select_btn.setVisible) + self.signals.playlist_select_btn_text.connect(self.playlist_select_btn.setText) + + def analyze_url(self) -> None: url = self.url_input.text().strip() if not url: self.signals.update_status.emit("Invalid URL or please enter a URL.") return self.signals.update_status.emit("Analyzing (0%)... Preparing request") - import threading # Import threading here as it is only used in GUI and downloader threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start() - def _analyze_url_thread(self, url): + def _analyze_url_thread(self, url) -> None: try: self.signals.update_status.emit("Analyzing (15%)... Extracting basic info") # Clean up the URL to handle both playlist and video URLs - if 'list=' in url and 'watch?v=' in url: - playlist_id = url.split('list=')[1].split('&')[0] - url = f'https://www.youtube.com/playlist?list={playlist_id}' + if "list=" in url and "watch?v=" in url: + playlist_id = url.split("list=")[1].split("&")[0] + url = f"https://www.youtube.com/playlist?list={playlist_id}" # Check if yt-dlp Python module is available if not YT_DLP_AVAILABLE: @@ -697,18 +733,19 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Initial extraction with basic options - suppress warnings here too ydl_opts = { - 'quiet': False, - 'no_warnings': True, # <-- Suppress warnings for initial check - 'extract_flat': True, - 'force_generic_extractor': False, - 'ignoreerrors': True, - 'no_color': True, - 'verbose': True + "quiet": False, + "no_warnings": True, # <-- Suppress warnings for initial check + "extract_flat": True, + "force_generic_extractor": False, + "ignoreerrors": True, + "no_color": True, + "verbose": True, + "cookiefile": None, } # Add cookies argument if cookie file path is set if self.cookie_file_path: - ydl_opts['cookiefile'] = self.cookie_file_path + ydl_opts["cookiefile"] = self.cookie_file_path with yt_dlp.YoutubeDL(ydl_opts) as ydl: try: @@ -723,31 +760,31 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Configure options for detailed extraction (keep other options) # Add no_warnings here as well, as this is where detailed info is fetched ydl_opts_detail = { - 'extract_flat': False, - 'format': None, - 'writesubtitles': True, - 'allsubtitles': True, - 'writeautomaticsub': True, - 'playliststart': 1, - 'playlistend': 1, - 'youtube_include_dash_manifest': True, - 'youtube_include_hls_manifest': True, - 'no_warnings': True # <-- Add flag here for detailed extraction + "extract_flat": False, + "format": None, + "writesubtitles": True, + "allsubtitles": True, + "writeautomaticsub": True, + "playliststart": 1, + "playlistend": 1, + "youtube_include_dash_manifest": True, + "youtube_include_hls_manifest": True, + "no_warnings": True, # <-- Add flag here for detailed extraction } # Add cookies argument if cookie file path is set if self.cookie_file_path: - ydl_opts_detail['cookiefile'] = self.cookie_file_path + ydl_opts_detail["cookiefile"] = self.cookie_file_path # Use a separate options dict for the detailed extraction with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail: try: self.signals.update_status.emit("Analyzing (45%)... Processing video data") - if basic_info.get('_type') == 'playlist': + if basic_info.get("_type") == "playlist": self.is_playlist = True self.playlist_info = basic_info - self.selected_playlist_items = None # Reset selection for new playlist - self.playlist_entries = [entry for entry in basic_info.get('entries', []) if entry] # Store entries + self.selected_playlist_items = None # Reset selection for new playlist + self.playlist_entries = [entry for entry in basic_info.get("entries", []) if entry] # Store entries # Ensure there are entries before proceeding if not self.playlist_entries: @@ -755,95 +792,91 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Extract detailed info for the FIRST video in the playlist # This provides formats/subs for the UI, assuming consistency - first_video_url = self.playlist_entries[0].get('url') + first_video_url = self.playlist_entries[0].get("url") if not first_video_url: raise Exception("Could not get URL for the first playlist video.") try: # Use the ydl_detail instance with no_warnings self.video_info = ydl_detail.extract_info(first_video_url, download=False) except Exception as first_video_error: - raise Exception(f"Failed to extract info for the first playlist video: {first_video_error}") + raise Exception(f"Failed to extract info for the first playlist video: {first_video_error}") # Update playlist info label text (remains the same) - playlist_text = (f"Playlist: {basic_info.get('title', 'Unknown Playlist')} | " - f"{len(self.playlist_entries)} videos") # Simplified label - QMetaObject.invokeMethod( - self.playlist_info_label, "setText", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, playlist_text) - ) - QMetaObject.invokeMethod( - self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, True) - ) + playlist_text = ( + f"Playlist: {basic_info.get('title', 'Unknown Playlist')} | " f"{len(self.playlist_entries)} videos" + ) # Simplified label + + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_info_label_text.emit(playlist_text) + self.signals.playlist_info_label_visible.emit(True) # Show playlist selection BUTTON - QMetaObject.invokeMethod( - self, # Target object is the YTSageApp instance - 'update_playlist_button_text', # Name of the slot - Qt.ConnectionType.QueuedConnection, - Q_ARG(str, "Select Videos... (All selected)") # Argument for the slot - ) - QMetaObject.invokeMethod( - self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, True) - ) - else: # Single video + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_select_btn_text.emit("Select Videos... (All selected)") + self.signals.playlist_select_btn_visible.emit(True) + + else: # Single video self.is_playlist = False # Use ydl_detail instance here too for consistency self.video_info = ydl_detail.extract_info(url, download=False) - self.playlist_entries = [] # Clear entries - self.selected_playlist_items = None # Clear selection - + self.playlist_entries = [] # Clear entries + self.selected_playlist_items = None # Clear selection + # Hide playlist info label and button - QMetaObject.invokeMethod( - self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, False) - ) - QMetaObject.invokeMethod( - self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, False) - ) + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_info_label_visible.emit(False) + self.signals.playlist_select_btn_visible.emit(False) # Verify we have format information - if not self.video_info or 'formats' not in self.video_info: + if not self.video_info or "formats" not in self.video_info: self.logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}") raise Exception("No format information available") self.signals.update_status.emit("Analyzing (60%)... Processing formats") - self.all_formats = self.video_info['formats'] + self.all_formats = self.video_info["formats"] # Update UI self.update_video_info(self.video_info) # Update thumbnail self.signals.update_status.emit("Analyzing (75%)... Loading thumbnail") - thumbnail_url = None - if self.is_playlist: - # Try to get thumbnail from playlist info first - thumbnail_url = self.playlist_info.get('thumbnail') - + # Try to get thumbnail from playlist info first # Fallback to video thumbnail if playlist thumbnail not found or not a playlist - if not thumbnail_url: - thumbnail_url = self.video_info.get('thumbnail') - + thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail") + self.download_thumbnail(thumbnail_url) # Save thumbnail if enabled - use the stored VIDEO URL if self.save_thumbnail: - self.download_thumbnail_file(self.video_url, self.path_input.text()) + self.download_thumbnail_file( + self.video_url, self.path_input.text() # type: ignore[reportAttributeAccessIssue] + ) # --- Subtitle Handling --- self.signals.update_status.emit("Analyzing (85%)... Processing subtitles") # Clear previous selections when analyzing a new video self.selected_subtitles = [] - self.available_subtitles = self.video_info.get('subtitles', {}) - self.available_automatic_subtitles = self.video_info.get('automatic_captions', {}) + self.available_subtitles = self.video_info.get("subtitles", {}) + self.available_automatic_subtitles = self.video_info.get("automatic_captions", {}) # Update the UI elements related to subtitle selection state - QMetaObject.invokeMethod(self.selected_subs_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "0 selected")) - # QMetaObject.invokeMethod(self.subtitle_select_btn, "setProperty", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "subtitlesSelected"), Q_ARG(bool, False)) # <-- COMMENT OUT THIS LINE - # REMOVE the merge_subs_checkbox update call from here - # QMetaObject.invokeMethod(self.merge_subs_checkbox, "setEnabled", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False)) + # update signal method from QMetaObject.invokeMethod to signals + self.signals.selected_subs_label_text.emit("0 selected") + # QMetaObject.invokeMethod( + # self.subtitle_select_btn, + # b"setProperty", + # Qt.ConnectionType.QueuedConnection, + # Q_ARG(str, b"subtitlesSelected"), + # Q_ARG(bool, False), + # ) # <-- COMMENT OUT THIS LINE + + # REMOVE the merge_subs_checkbox update call from here + # QMetaObject.invokeMethod( + # self.merge_subs_checkbox, + # b"setEnabled", + # Qt.ConnectionType.QueuedConnection, + # Q_ARG(bool, False), + # ) # Update format table self.signals.update_status.emit("Analyzing (95%)... Updating format table") @@ -854,42 +887,37 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.signals.update_status.emit("Analysis complete!") except Exception as e: - self.logger.error(f"Detailed extraction failed: {str(e)}", exc_info=True) + logger.error(f"Detailed extraction failed: {e}", exc_info=True) raise Exception(f"Failed to extract video details: {str(e)}") except Exception as e: - error_message = str(e) - self.logger.error(f"Error in analysis: {error_message}", exc_info=True) - self.signals.update_status.emit(f"Error: {error_message}") + self.logger.error(f"Error in analysis: {e}", exc_info=True) + self.signals.update_status.emit(f"Error: {e}") # Ensure playlist UI is hidden on error too - QMetaObject.invokeMethod(self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False)) - QMetaObject.invokeMethod(self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False)) + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_info_label_visible.emit(False) + self.signals.playlist_select_btn_visible.emit(False) - def paste_url(self): + def paste_url(self) -> None: clipboard = QApplication.clipboard() self.url_input.setText(clipboard.text()) - def update_ytdlp(self): + def update_ytdlp(self) -> None: """Show the yt-dlp update dialog with proper progress tracking""" # Make the dialog non-modal to prevent blocking the main UI dialog = YTDLPUpdateDialog(self) dialog.setModal(False) # Make it non-modal dialog.show() # Use show() instead of exec() to avoid blocking - def show_download_settings_dialog(self): # Renamed method - dialog = DownloadSettingsDialog( - self.last_path, - self.speed_limit_value, - self.speed_limit_unit_index, - self - ) + def show_download_settings_dialog(self) -> None: # Renamed method + dialog = DownloadSettingsDialog(self.last_path, self.speed_limit_value, self.speed_limit_unit_index, self) if dialog.exec(): # Update Path new_path = dialog.get_selected_path() path_changed = False if new_path != self.last_path: self.last_path = new_path - self.save_path(self.last_path) # Save the updated path + save_path(self, self.last_path) # Save the updated path path_changed = True self.logger.info(f"Download path updated to: {self.last_path}") @@ -901,28 +929,30 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.speed_limit_value = new_limit_value self.speed_limit_unit_index = new_unit_index limit_changed = True - self.logger.info(f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}") + self.logger.info( + f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}" + ) # Update Tooltip if anything changed if path_changed or limit_changed: limit_text = "None" if self.speed_limit_value: - limit_text = f"{self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index]}" + limit_text = f"{self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index]}" self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: {limit_text}") - def start_download(self): + def start_download(self) -> None: url = self.url_input.text().strip() # --- Use self.last_path instead of reading from QLineEdit --- - path = self.last_path + path = self.last_path if not url or not path: # More specific error message if path is missing if not path: - self.status_label.setText("Please set a download path using 'Change Path'") + self.status_label.setText("Please set a download path using 'Change Path'") elif not url: - self.status_label.setText("Please enter a URL") + self.status_label.setText("Please enter a URL") else: - self.status_label.setText("Please enter URL and set download path") + self.status_label.setText("Please enter URL and set download path") return # --- End Path Change --- @@ -937,34 +967,34 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.progress_bar.setValue(0) # Get resolution for filename - resolution = 'default' + resolution = "default" for checkbox in self.format_checkboxes: if checkbox.isChecked(): - parts = checkbox.text().split('•') + parts = checkbox.text().split("•") if len(parts) >= 1: resolution = parts[0].strip().lower() break # Get subtitle selection if available - Now get the list - selected_subs = self.selected_subtitles if hasattr(self, 'selected_subtitles') else [] + selected_subs = self.selected_subtitles if hasattr(self, "selected_subtitles") else [] # Get playlist selection IF in playlist mode - USE STORED VALUE playlist_items_to_download = None if self.is_playlist: - playlist_items_to_download = self.selected_playlist_items # Use the stored selection string + playlist_items_to_download = self.selected_playlist_items # Use the stored selection string # --- Use stored speed limit values --- rate_limit = None if self.speed_limit_value: try: limit_value = float(self.speed_limit_value) - if self.speed_limit_unit_index == 0: # KB/s + if self.speed_limit_unit_index == 0: # KB/s rate_limit = f"{int(limit_value * 1024)}" - elif self.speed_limit_unit_index == 1: # MB/s + elif self.speed_limit_unit_index == 1: # MB/s rate_limit = f"{int(limit_value * 1024 * 1024)}" except ValueError: # Use a signal to show error in status bar, similar to URL/Path errors - self.signals.update_status.emit("❌ Error: Invalid speed limit value set in settings.") + self.signals.update_status.emit("❌ Error: Invalid speed limit value set in settings.") return # --- End speed limit update --- @@ -978,25 +1008,24 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.logger.warning(f"Thumbnail download failed: {e}") # Optionally inform the user, but don't stop the main download - # Create download thread with resolution in output template self.download_thread = DownloadThread( url=url, path=path, format_id=format_id, - subtitle_langs=selected_subs, # Pass the list of selected subs - is_playlist=self.is_playlist, # Use the flag directly + subtitle_langs=selected_subs, # Pass the list of selected subs + is_playlist=self.is_playlist, # Use the flag directly merge_subs=self.merge_subs_checkbox.isChecked(), enable_sponsorblock=len(self.selected_sponsorblock_categories) > 0, sponsorblock_categories=self.selected_sponsorblock_categories, resolution=resolution, - playlist_items=playlist_items_to_download, # Pass the selection string - save_description=self.save_description, # Pass the new flag here - embed_chapters=self.embed_chapters, # Pass the embed chapters flag - cookie_file=self.cookie_file_path, # Pass the cookie file path - rate_limit=rate_limit, # Pass the calculated rate limit - download_section=self.download_section, # Pass the download section - force_keyframes=self.force_keyframes # Pass the force keyframes setting + playlist_items=playlist_items_to_download, # Pass the selection string + save_description=self.save_description, # Pass the new flag here + embed_chapters=self.embed_chapters, # Pass the embed chapters flag + cookie_file=self.cookie_file_path, # Pass the cookie file path + rate_limit=rate_limit, # Pass the calculated rate limit + download_section=self.download_section, # Pass the download section + force_keyframes=self.force_keyframes, # Pass the force keyframes setting ) # Connect signals @@ -1012,7 +1041,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.download_cancelled = False # Show pause/cancel buttons - self.pause_btn.setText('Pause') + self.pause_btn.setText("Pause") self.pause_btn.setVisible(True) self.cancel_btn.setVisible(True) @@ -1021,41 +1050,41 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.download_thread.start() self.toggle_download_controls(False) - def download_finished(self): + def download_finished(self) -> None: self.toggle_download_controls(True) self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.progress_bar.setValue(100) - + # Set completion message based on the file type of last downloaded file if self.download_thread and self.download_thread.current_filename: - filename = self.download_thread.current_filename - ext = os.path.splitext(filename)[1].lower() - + filename = Path(self.download_thread.current_filename) + ext = filename.suffix.lower() + # Video file extensions - if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']: + if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]: self.status_label.setText(f"✅ Video download completed!") # Audio file extensions - elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']: + elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]: self.status_label.setText(f"✅ Audio download completed!") # Subtitle file extensions - elif ext in ['.vtt', '.srt', '.ass', '.ssa']: + elif ext in [".vtt", ".srt", ".ass", ".ssa"]: self.status_label.setText(f"✅ Subtitle download completed!") # Default case else: self.status_label.setText("✅ Download completed!") - + # Play notification sound when download completes self.play_notification_sound() - def download_error(self, error_message): + def download_error(self, error_message) -> None: self.toggle_download_controls(True) self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.status_label.setText(f"Error: {error_message}") - self.download_details_label.setText("") # Clear details label on error + self.download_details_label.setText("") # Clear details label on error - def update_progress_bar(self, value): + def update_progress_bar(self, value) -> None: try: # Ensure the value is an integer int_value = int(value) @@ -1063,70 +1092,70 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m except Exception as e: self.logger.error(f"Progress bar update error: {str(e)}") - def toggle_pause(self): + def toggle_pause(self) -> None: if self.current_download: self.current_download.paused = not self.current_download.paused if self.current_download.paused: - self.pause_btn.setText('Resume') + self.pause_btn.setText("Resume") self.signals.update_status.emit("Download paused") else: - self.pause_btn.setText('Pause') + self.pause_btn.setText("Pause") self.signals.update_status.emit("Download resumed") - def check_for_updates(self): + def check_for_updates(self) -> None: try: # Get the latest release info from GitHub response = requests.get( "https://api.github.com/repos/oop7/YTSage/releases/latest", - headers={"Accept": "application/vnd.github.v3+json"} + headers={"Accept": "application/vnd.github.v3+json"}, ) response.raise_for_status() latest_release = response.json() - latest_version = latest_release["tag_name"].lstrip('v') + latest_version = latest_release["tag_name"].lstrip("v") # Compare versions if version.parse(latest_version) > version.parse(self.version): - changelog = latest_release.get("body", "No changelog available.") # Get changelog body - self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog + changelog = latest_release.get("body", "No changelog available.") # Get changelog body + self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog except Exception as e: self.logger.error(f"Failed to check for updates: {str(e)}", exc_info=True) - def show_update_dialog(self, latest_version, release_url, changelog): # Added changelog parameter + def show_update_dialog(self, latest_version, release_url, changelog) -> None: # Added changelog parameter msg = QDialog(self) msg.setWindowTitle("Update Available") - msg.setMinimumWidth(600) # Increased width for better layout - msg.setMinimumHeight(450) # Increased height for better spacing - + msg.setMinimumWidth(600) # Increased width for better layout + msg.setMinimumHeight(450) # Increased height for better spacing + # Set custom icon directly try: if self.windowIcon() and not self.windowIcon().isNull(): msg.setWindowIcon(self.windowIcon()) else: # Fallback to icon file - icon_path = os.path.join(os.path.dirname(__file__), '..', '..', 'assets', 'Icon', 'icon.png') - if os.path.exists(icon_path): - msg.setWindowIcon(QIcon(icon_path)) + # icon_path logic moved to src\utils\ytsage_constants.py + if ICON_PATH.exists(): + msg.setWindowIcon(QIcon(ICON_PATH.as_posix())) except Exception: pass layout = QVBoxLayout(msg) - layout.setSpacing(15) # Increased spacing for better layout - layout.setContentsMargins(20, 20, 20, 20) # Added margins + layout.setSpacing(15) # Increased spacing for better layout + layout.setContentsMargins(20, 20, 20, 20) # Added margins # Header with icon and title header_layout = QHBoxLayout() - + # Add update icon icon_label = QLabel() icon_label.setPixmap(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload).pixmap(32, 32)) header_layout.addWidget(icon_label) - + # Title title_label = QLabel("

Update Available

") header_layout.addWidget(title_label) header_layout.addStretch() - + layout.addLayout(header_layout) # Update message with better formatting @@ -1138,7 +1167,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m f"" ) message_label.setWordWrap(True) - message_label.setStyleSheet(""" + message_label.setStyleSheet( + """ QLabel { background-color: #1d1e22; border: 1px solid #3d3d3d; @@ -1146,7 +1176,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m padding: 15px; margin: 5px 0; } - """) + """ + ) layout.addWidget(message_label) # Changelog Section @@ -1158,13 +1189,20 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m changelog_text.setReadOnly(True) # Convert Markdown to HTML and set it try: - html_changelog = markdown.markdown(changelog, extensions=['markdown.extensions.tables', 'markdown.extensions.fenced_code']) + html_changelog = markdown.markdown( + changelog, + extensions=[ + "markdown.extensions.tables", + "markdown.extensions.fenced_code", + ], + ) changelog_text.setHtml(html_changelog) except Exception as e: self.logger.warning(f"Error converting changelog markdown to HTML: {e}") - changelog_text.setPlainText(changelog) # Fallback to plain text + changelog_text.setPlainText(changelog) # Fallback to plain text - changelog_text.setStyleSheet(""" + changelog_text.setStyleSheet( + """ QTextEdit { background-color: #1d1e22; border: 2px solid #3d3d3d; @@ -1189,8 +1227,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QScrollBar::handle:vertical:hover { background: #505050; } - """) - changelog_text.setMaximumHeight(180) # Limit height + """ + ) + changelog_text.setMaximumHeight(180) # Limit height layout.addWidget(changelog_text) # Buttons with better styling @@ -1199,7 +1238,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m download_btn = QPushButton("Download Update") download_btn.clicked.connect(lambda: self.open_release_page(release_url)) - download_btn.setStyleSheet(""" + download_btn.setStyleSheet( + """ QPushButton { padding: 10px 20px; background-color: #c90000; @@ -1216,11 +1256,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QPushButton:pressed { background-color: #800000; } - """) + """ + ) remind_btn = QPushButton("Remind Me Later") remind_btn.clicked.connect(msg.close) - remind_btn.setStyleSheet(""" + remind_btn.setStyleSheet( + """ QPushButton { padding: 10px 20px; background-color: #3d3d3d; @@ -1238,7 +1280,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QPushButton:pressed { background-color: #2d2d2d; } - """) + """ + ) button_layout.addStretch() button_layout.addWidget(download_btn) @@ -1246,7 +1289,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m layout.addLayout(button_layout) # Style the dialog with improved theme matching - msg.setStyleSheet(""" + msg.setStyleSheet( + """ QDialog { background-color: #15181b; border: 1px solid #3d3d3d; @@ -1256,14 +1300,15 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m color: #ffffff; font-size: 12px; } - """) + """ + ) msg.show() def open_release_page(self, url): webbrowser.open(url) - def check_auto_update_ytdlp(self): + def check_auto_update_ytdlp(self) -> None: """Check and perform auto-update for yt-dlp if enabled and due.""" try: # Check if auto-update should be performed @@ -1272,30 +1317,31 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Perform the auto-update in a non-blocking way # We don't want to block the UI startup for this from PySide6.QtCore import QTimer + QTimer.singleShot(2000, self._perform_auto_update) # Delay 2 seconds after startup except Exception as e: self.logger.error(f"Error in auto-update check: {e}", exc_info=True) - def _perform_auto_update(self): + def _perform_auto_update(self) -> None: """Actually perform the auto-update check and update if needed in a background thread.""" try: # Create and start the auto-update thread to avoid blocking the UI - from .ytsage_gui_dialogs import AutoUpdateThread + self.auto_update_thread = AutoUpdateThread() self.auto_update_thread.update_finished.connect(self._on_auto_update_finished) self.auto_update_thread.start() except Exception as e: self.logger.error(f"Error starting auto-update thread: {e}", exc_info=True) - def _on_auto_update_finished(self, success, message): + def _on_auto_update_finished(self, success, message) -> None: """Handle auto-update completion.""" if success: self.logger.info(f"Auto-update completed successfully: {message}") else: self.logger.warning(f"Auto-update completed with issues: {message}") - + # Clean up the thread reference and ensure it's properly finished - if hasattr(self, 'auto_update_thread'): + if hasattr(self, "auto_update_thread"): # Disconnect all signals to prevent further callbacks self.auto_update_thread.update_finished.disconnect() # Make sure thread is finished @@ -1303,20 +1349,20 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.auto_update_thread.quit() self.auto_update_thread.wait(1000) # Wait up to 1 second # Remove the reference - delattr(self, 'auto_update_thread') + delattr(self, "auto_update_thread") - def closeEvent(self, event): + def closeEvent(self, event) -> None: """Handle application close event to ensure proper cleanup of background threads.""" try: # Stop the auto-update thread if it's running - if hasattr(self, 'auto_update_thread') and self.auto_update_thread.isRunning(): + if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning(): self.logger.info("Stopping auto-update thread...") self.auto_update_thread.quit() if not self.auto_update_thread.wait(3000): # Wait up to 3 seconds for graceful shutdown self.logger.warning("Force terminating auto-update thread...") self.auto_update_thread.terminate() self.auto_update_thread.wait(1000) # Wait for termination - + # Cancel any running downloads if self.current_download and self.current_download.isRunning(): self.logger.info("Canceling running download...") @@ -1325,14 +1371,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.logger.warning("Force terminating download thread...") self.current_download.terminate() self.current_download.wait(1000) # Wait for termination - + self.logger.info("Application closing...") event.accept() except Exception as e: self.logger.error(f"Error during application close: {e}", exc_info=True) event.accept() # Accept the close event anyway - def show_custom_options(self): + def show_custom_options(self) -> None: dialog = CustomOptionsDialog(self) if dialog.exec(): # Handle cookies if set @@ -1340,38 +1386,42 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m if cookie_path: self.cookie_file_path = cookie_path self.logger.info(f"Selected cookie file: {self.cookie_file_path}") - QMessageBox.information(self, "Cookie File Selected", f"Cookie file selected: {self.cookie_file_path}") + QMessageBox.information( + self, + "Cookie File Selected", + f"Cookie file selected: {self.cookie_file_path}", + ) else: # Don't clear the cookie path if nothing was selected pass - def show_about_dialog(self): # ADDED METHOD HERE + def show_about_dialog(self) -> None: # ADDED METHOD HERE dialog = AboutDialog(self) dialog.exec() - def file_already_exists(self, filename): + def file_already_exists(self, filename) -> None: """Handle case when file already exists - simplified version""" self.toggle_download_controls(True) self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.progress_bar.setValue(100) - + # Determine file type based on extension - ext = os.path.splitext(filename)[1].lower() - + ext = Path(filename).suffix.lower() + # Video file extensions - if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']: + if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]: self.status_label.setText(f"⚠️ Video file already exists") # Audio file extensions - elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']: + elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]: self.status_label.setText(f"⚠️ Audio file already exists") # Subtitle file extensions - elif ext in ['.vtt', '.srt', '.ass', '.ssa']: + elif ext in [".vtt", ".srt", ".ass", ".ssa"]: self.status_label.setText(f"⚠️ Subtitle file already exists") # Default case else: self.status_label.setText("⚠️ File already exists") - + # Show a simple message dialog msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Icon.Information) @@ -1379,12 +1429,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m msg_box.setText(f"The file already exists:\n{filename}") msg_box.setInformativeText("This video has already been downloaded.") msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) - + # Set the window icon to match the main application msg_box.setWindowIcon(self.windowIcon()) - + # Style the dialog - msg_box.setStyleSheet(""" + msg_box.setStyleSheet( + """ QMessageBox { background-color: #2b2b2b; } @@ -1403,151 +1454,155 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QPushButton:hover { background-color: #cc0000; } - """) - + """ + ) + msg_box.exec() # --- Add Toggle Methods Here --- - def toggle_save_thumbnail(self, state): - self.logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state - self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state) + def toggle_save_thumbnail(self, state) -> None: + self.logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state + self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state) self.logger.debug(f"Save thumbnail toggled: {self.save_thumbnail}") - def toggle_save_description(self, state): - self.logger.debug(f"Raw description state received: {state}") # Debug: Print raw state - self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state) + def toggle_save_description(self, state) -> None: + self.logger.debug(f"Raw description state received: {state}") # Debug: Print raw state + self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state) self.logger.debug(f"Save description toggled: {self.save_description}") - - def toggle_embed_chapters(self, state): - self.logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state - self.embed_chapters = bool(state == 2) # Compare state directly with 2 (Checked state) + + def toggle_embed_chapters(self, state) -> None: + self.logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state + self.embed_chapters = bool(state == 2) # Compare state directly with 2 (Checked state) self.logger.debug(f"Embed chapters toggled: {self.embed_chapters}") + # --- End Toggle Methods --- - def open_playlist_selection_dialog(self): + def open_playlist_selection_dialog(self) -> None: if not self.is_playlist or not self.playlist_entries: self.logger.info("No playlist data available to select from.") return dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self) - + if dialog.exec(): self.selected_playlist_items = dialog.get_selected_items_string() self.logger.info(f"Playlist items selected: {self.selected_playlist_items}") # Update button text (this call is safe as it happens in the main thread after dialog closes) if self.selected_playlist_items is None: - button_text = "Select Videos... (All selected)" + button_text = "Select Videos... (All selected)" else: selected_indices = dialog._parse_selection_string(self.selected_playlist_items) count = len(selected_indices) - display_text = self.selected_playlist_items if len(self.selected_playlist_items) < 30 else f"{count} videos selected" + display_text = ( + self.selected_playlist_items if len(self.selected_playlist_items) < 30 else f"{count} videos selected" + ) button_text = f"Select Videos... ({display_text})" - self.playlist_select_btn.setText(button_text) # Direct call is fine here - - # --- New Slot for Updating Playlist Button Text --- - @Slot(str) - def update_playlist_button_text(self, text): - """Safely updates the playlist selection button's text from any thread.""" - if hasattr(self, 'playlist_select_btn'): - self.playlist_select_btn.setText(text) - # --- End New Slot --- + self.playlist_select_btn.setText(button_text) # Direct call is fine here - def toggle_download_controls(self, enabled=True): + # --- New Slot for Updating Playlist Button Text --- + # moved to SignalManager as Signal and added to init_ui() method. + + def toggle_download_controls(self, enabled=True) -> None: """Enable or disable download-related controls""" self.url_input.setEnabled(enabled) self.analyze_button.setEnabled(enabled) self.format_table.setEnabled(enabled) # Changed from format_scroll_area to format_table self.download_btn.setEnabled(enabled) - if hasattr(self, 'subtitle_combo'): - self.subtitle_combo.setEnabled(enabled) + if hasattr(self, "subtitle_combo"): + self.subtitle_combo.setEnabled(enabled) # type: ignore[reportAttributeAccessIssue] self.video_button.setEnabled(enabled) self.audio_button.setEnabled(enabled) - if hasattr(self, 'sponsorblock_select_btn'): + if hasattr(self, "sponsorblock_select_btn"): self.sponsorblock_select_btn.setEnabled(enabled) - self.merge_subs_checkbox.setEnabled(enabled) # Enable/disable merge subs checkbox - self.custom_options_btn.setEnabled(enabled) # Enable/disable custom options button - self.update_ytdlp_btn.setEnabled(enabled) # Enable/disable update button - self.time_range_btn.setEnabled(enabled) # Enable/disable time range button - self.settings_button.setEnabled(enabled) # Enable/disable settings button + self.merge_subs_checkbox.setEnabled(enabled) # Enable/disable merge subs checkbox + self.custom_options_btn.setEnabled(enabled) # Enable/disable custom options button + self.update_ytdlp_btn.setEnabled(enabled) # Enable/disable update button + self.time_range_btn.setEnabled(enabled) # Enable/disable time range button + self.settings_button.setEnabled(enabled) # Enable/disable settings button # Clear progress/status when controls are re-enabled if enabled: self.progress_bar.setValue(0) self.status_label.setText("Ready") - self.download_details_label.setText("") # Clear details label + self.download_details_label.setText("") # Clear details label - def handle_format_selection(self, button): + def handle_format_selection(self, button) -> None: # Update formats self.filter_formats() - def handle_mode_change(self): + def handle_mode_change(self) -> None: """Enable or disable features based on video/audio mode""" if self.audio_button.isChecked(): # In Audio Only mode, disable video-specific features - if hasattr(self, 'sponsorblock_select_btn'): + if hasattr(self, "sponsorblock_select_btn"): self.sponsorblock_select_btn.setEnabled(False) - if hasattr(self, 'selected_sponsorblock_categories'): + if hasattr(self, "selected_sponsorblock_categories"): self.selected_sponsorblock_categories = [] # Clear selection when disabled - if hasattr(self, '_update_sponsorblock_display'): + if hasattr(self, "_update_sponsorblock_display"): self._update_sponsorblock_display() self.merge_subs_checkbox.setEnabled(False) self.merge_subs_checkbox.setChecked(False) # Uncheck when disabled - + # Allow subtitle selection in Audio Only mode too - if hasattr(self, 'subtitle_select_btn'): + if hasattr(self, "subtitle_select_btn"): self.subtitle_select_btn.setEnabled(True) else: # In Video mode, enable video-specific features - if hasattr(self, 'sponsorblock_select_btn'): + if hasattr(self, "sponsorblock_select_btn"): self.sponsorblock_select_btn.setEnabled(True) # Don't automatically restore categories - let user choose when they open the dialog - + # Enable merge_subs only if subtitles are selected - has_subs_selected = len(getattr(self, 'selected_subtitles', [])) > 0 + has_subs_selected = len(getattr(self, "selected_subtitles", [])) > 0 self.merge_subs_checkbox.setEnabled(has_subs_selected) - + # Re-enable subtitle selection button in Video mode - if hasattr(self, 'subtitle_select_btn'): + if hasattr(self, "subtitle_select_btn"): self.subtitle_select_btn.setEnabled(True) # Keep these methods for backwards compatibility - they just call the new dialog now - def show_custom_command(self): + def show_custom_command(self) -> None: dialog = CustomOptionsDialog(self) dialog.tab_widget.setCurrentIndex(1) # Select the Custom Command tab dialog.exec() - - def show_cookie_login_dialog(self): + + def show_cookie_login_dialog(self) -> None: dialog = CustomOptionsDialog(self) dialog.tab_widget.setCurrentIndex(0) # Select the Cookie Login tab if dialog.exec(): self.cookie_file_path = dialog.get_cookie_file_path() if self.cookie_file_path: self.logger.info(f"Selected cookie file: {self.cookie_file_path}") - QMessageBox.information(self, "Cookie File Selected", f"Cookie file selected: {self.cookie_file_path}") + QMessageBox.information( + self, + "Cookie File Selected", + f"Cookie file selected: {self.cookie_file_path}", + ) else: - self.cookie_file_path = None # Clear path if dialog accepted but no file selected + self.cookie_file_path = None # Clear path if dialog accepted but no file selected - def cancel_download(self): + def cancel_download(self) -> None: if self.current_download: self.current_download.cancelled = True - self.status_label.setText("Cancelling download...") # Set status directly - self.download_details_label.setText("") # Clear details label on cancellation + self.status_label.setText("Cancelling download...") # Set status directly + self.download_details_label.setText("") # Clear details label on cancellation - def show_ffmpeg_dialog(self): + def show_ffmpeg_dialog(self) -> None: dialog = FFmpegCheckDialog(self) dialog.exec() # Add method for showing time range dialog - def show_time_range_dialog(self): + def show_time_range_dialog(self) -> None: dialog = TimeRangeDialog(self) if dialog.exec(): # Store the time range settings self.download_section = dialog.get_download_sections() self.force_keyframes = dialog.get_force_keyframes() - + if self.download_section: - self.time_range_btn.setStyleSheet(""" + self.time_range_btn.setStyleSheet( + """ QPushButton { padding: 8px 15px; background-color: #c90000; @@ -1560,7 +1615,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QPushButton:hover { background-color: #a50000; } - """) + """ + ) self.time_range_btn.setToolTip(f"Section set: {self.download_section}") else: # Reset to default style if no section is selected @@ -1569,16 +1625,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.time_range_btn.setStyleSheet("") self.time_range_btn.setToolTip("") - def show_ytdlp_setup_dialog(self): + def show_ytdlp_setup_dialog(self) -> None: """Show the yt-dlp setup dialog to configure yt-dlp""" yt_dlp_path = setup_ytdlp(self) if yt_dlp_path != "yt-dlp": success_dialog = QMessageBox(self) - success_dialog.setIcon(QMessageBox.Information) + success_dialog.setIcon(QMessageBox.Icon.Information) success_dialog.setWindowTitle("yt-dlp Setup") success_dialog.setText(f"yt-dlp has been successfully configured at:\n{yt_dlp_path}") success_dialog.setWindowIcon(self.windowIcon()) - success_dialog.setStyleSheet(""" + success_dialog.setStyleSheet( + """ QMessageBox { background-color: #15181b; color: #ffffff; @@ -1597,162 +1654,141 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QPushButton:hover { background-color: #a50000; } - """) + """ + ) success_dialog.exec() - def _analyze_url_with_subprocess(self, url): + def _analyze_url_with_subprocess(self, url) -> None: """Analyze URL using yt-dlp executable when Python module is not available""" - import subprocess - import json - import tempfile - + try: yt_dlp_path = get_yt_dlp_path() if not yt_dlp_path: raise Exception("yt-dlp executable not found. Please install yt-dlp first.") - + self.signals.update_status.emit("Analyzing (30%)... Extracting info with yt-dlp executable") - + # Clean up the URL to handle both playlist and video URLs - if 'list=' in url and 'watch?v=' in url: - playlist_id = url.split('list=')[1].split('&')[0] - url = f'https://www.youtube.com/playlist?list={playlist_id}' + if "list=" in url and "watch?v=" in url: + playlist_id = url.split("list=")[1].split("&")[0] + url = f"https://www.youtube.com/playlist?list={playlist_id}" # Build command for basic info extraction - cmd = [yt_dlp_path, '--dump-json', '--no-warnings', url] - + cmd = [yt_dlp_path, "--dump-json", "--no-warnings", url] + # Add cookies if available if self.cookie_file_path: - cmd.extend(['--cookies', self.cookie_file_path]) - + cmd.extend(["--cookies", self.cookie_file_path]) + # Execute command with hidden console window on Windows - import sys - if sys.platform == 'win32': - # Hide the console window on Windows - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = subprocess.SW_HIDE - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, startupinfo=startupinfo) - else: - # For other platforms, use normal subprocess call - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - + # Extra logic moved to src\utils\ytsage_constants.py + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SUBPROCESS_CREATIONFLAGS) + if result.returncode != 0: raise Exception(f"yt-dlp failed: {result.stderr}") - + # Parse JSON output - yt-dlp outputs one JSON object per line for playlists - json_lines = [line.strip() for line in result.stdout.strip().split('\n') if line.strip()] - + json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()] + if not json_lines: raise Exception("No data returned from yt-dlp") - + # Parse first JSON object to determine if it's a playlist first_info = json.loads(json_lines[0]) - + self.signals.update_status.emit("Analyzing (60%)... Processing data") - - if first_info.get('_type') == 'playlist' or len(json_lines) > 1: + + if first_info.get("_type") == "playlist" or len(json_lines) > 1: # Handle playlist self.is_playlist = True self.playlist_info = first_info self.selected_playlist_items = None self.playlist_entries = [] - + # Parse all entries for line in json_lines: try: entry = json.loads(line) - if entry.get('_type') != 'playlist': # Skip playlist metadata + if entry.get("_type") != "playlist": # Skip playlist metadata self.playlist_entries.append(entry) except json.JSONDecodeError: continue - + if not self.playlist_entries: raise Exception("Playlist contains no valid videos.") - + # Use first video for format information self.video_info = self.playlist_entries[0] - + # Update playlist info label - playlist_text = (f"Playlist: {first_info.get('title', 'Unknown Playlist')} | " - f"{len(self.playlist_entries)} videos") - QMetaObject.invokeMethod( - self.playlist_info_label, "setText", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, playlist_text) + playlist_text = ( + f"Playlist: {first_info.get('title', 'Unknown Playlist')} | " f"{len(self.playlist_entries)} videos" ) - QMetaObject.invokeMethod( - self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, True) - ) - + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_info_label_text.emit(playlist_text) + self.signals.playlist_info_label_visible.emit(True) + # Show playlist selection button - QMetaObject.invokeMethod( - self, 'update_playlist_button_text', Qt.ConnectionType.QueuedConnection, - Q_ARG(str, "Select Videos... (All selected)") - ) - QMetaObject.invokeMethod( - self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, True) - ) + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_select_btn_text.emit("Select Videos... (All selected)") + + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_select_btn_visible.emit(True) + else: # Handle single video self.is_playlist = False self.video_info = first_info self.playlist_entries = [] self.selected_playlist_items = None - + # Hide playlist UI - QMetaObject.invokeMethod( - self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, False) - ) - QMetaObject.invokeMethod( - self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(bool, False) - ) - + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_info_label_visible.emit(False) + + # update signal method from QMetaObject.invokeMethod to signals + self.signals.playlist_select_btn_visible.emit(False) + # Verify we have format information - if not self.video_info or 'formats' not in self.video_info: + if not self.video_info or "formats" not in self.video_info: raise Exception("No format information available") - + self.signals.update_status.emit("Analyzing (75%)... Processing formats") - self.all_formats = self.video_info['formats'] - + self.all_formats = self.video_info["formats"] + # Update UI self.update_video_info(self.video_info) - + # Update thumbnail self.signals.update_status.emit("Analyzing (85%)... Loading thumbnail") - thumbnail_url = None - if self.is_playlist: - thumbnail_url = self.playlist_info.get('thumbnail') - - if not thumbnail_url: - thumbnail_url = self.video_info.get('thumbnail') - + # Try to get thumbnail from playlist info first + # Fallback to video thumbnail if playlist thumbnail not found or not a playlist + thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail") + self.download_thumbnail(thumbnail_url) - + # Save thumbnail if enabled if self.save_thumbnail: - self.download_thumbnail_file(self.video_url, self.path_input.text()) - + self.download_thumbnail_file(self.video_url, self.path_input.text()) # type: ignore[reportAttributeAccessIssue] + # Handle subtitles self.signals.update_status.emit("Analyzing (90%)... Processing subtitles") self.selected_subtitles = [] - self.available_subtitles = self.video_info.get('subtitles', {}) - self.available_automatic_subtitles = self.video_info.get('automatic_captions', {}) - + self.available_subtitles = self.video_info.get("subtitles", {}) + self.available_automatic_subtitles = self.video_info.get("automatic_captions", {}) + # Update subtitle UI - QMetaObject.invokeMethod(self.selected_subs_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "0 selected")) - + # update signal method from QMetaObject.invokeMethod to signals + self.signals.selected_subs_label_text.emit("0 selected") + # Update format table self.signals.update_status.emit("Analyzing (95%)... Updating format table") self.video_button.setChecked(True) self.audio_button.setChecked(False) self.filter_formats() - + self.signals.update_status.emit("Analysis complete!") - + except subprocess.TimeoutExpired: raise Exception("Analysis timed out. Please try again.") except json.JSONDecodeError as e: diff --git a/src/gui/ytsage_gui_video_info.py b/src/gui/ytsage_gui_video_info.py index 50ab915..354e9d9 100644 --- a/src/gui/ytsage_gui_video_info.py +++ b/src/gui/ytsage_gui_video_info.py @@ -1,32 +1,24 @@ -import sys -import os -import webbrowser -from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, - QHBoxLayout, QLineEdit, QPushButton, QTableWidget, - QTableWidgetItem, QProgressBar, QLabel, QFileDialog, - QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup) -from PySide6.QtCore import Qt, Signal, QObject, QThread -from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap -import requests -from io import BytesIO -from PIL import Image -from datetime import datetime -import json -from pathlib import Path -from packaging import version -import subprocess import re -from ..core.ytsage_logging import logger -try: - import yt_dlp - YT_DLP_AVAILABLE = True -except ImportError: - YT_DLP_AVAILABLE = False - logger.warning("yt-dlp not available at startup, will be downloaded at runtime") -from .ytsage_gui_dialogs import SubtitleSelectionDialog, SponsorBlockCategoryDialog +from datetime import datetime +from io import BytesIO +from pathlib import Path + +import requests +from PIL import Image +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget +from yt_dlp import YoutubeDL + +from src.core.ytsage_logging import logger +from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py + SponsorBlockCategoryDialog, + SubtitleSelectionDialog, +) + class VideoInfoMixin: - def setup_video_info_section(self): + def setup_video_info_section(self) -> QHBoxLayout: # Create a horizontal layout for thumbnail and video info media_info_layout = QHBoxLayout() media_info_layout.setSpacing(15) @@ -36,7 +28,7 @@ class VideoInfoMixin: thumbnail_container.setFixedWidth(320) thumbnail_layout = QVBoxLayout(thumbnail_container) thumbnail_layout.setContentsMargins(0, 0, 0, 0) - + # Thumbnail on the left self.thumbnail_label = QLabel() self.thumbnail_label.setFixedSize(320, 180) @@ -44,14 +36,14 @@ class VideoInfoMixin: self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) thumbnail_layout.addWidget(self.thumbnail_label) thumbnail_layout.addStretch() - + media_info_layout.addWidget(thumbnail_container) # Video information on the right video_info_layout = QVBoxLayout() video_info_layout.setSpacing(2) # Reduce spacing between elements video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - + # Title and info labels self.title_label = QLabel() self.title_label.setWordWrap(True) @@ -65,14 +57,22 @@ class VideoInfoMixin: self.like_count_label = QLabel() # Style the info labels - for label in [self.channel_label, self.views_label, self.date_label, self.duration_label, self.like_count_label]: - label.setStyleSheet(""" + for label in [ + self.channel_label, + self.views_label, + self.date_label, + self.duration_label, + self.like_count_label, + ]: + label.setStyleSheet( + """ QLabel { color: #cccccc; font-size: 12px; padding: 1px 0; } - """) + """ + ) # Add labels to video info layout video_info_layout.addWidget(self.title_label) @@ -90,11 +90,12 @@ class VideoInfoMixin: subtitle_layout.setSpacing(10) # Subtitle selection button - self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text + self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text self.subtitle_select_btn.setFixedHeight(30) # self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog) - self.subtitle_select_btn.setStyleSheet(""" + self.subtitle_select_btn.setStyleSheet( + """ QPushButton { background-color: #1d1e22; border: 2px solid #1d1e22; @@ -112,8 +113,9 @@ class VideoInfoMixin: color: #888888; border-color: #3d3d3d; } - """) - self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling + """ + ) + self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling subtitle_layout.addWidget(self.subtitle_select_btn) # Label to show number of selected subtitles @@ -130,11 +132,12 @@ class VideoInfoMixin: # --- SponsorBlock Section --- sponsorblock_layout = QHBoxLayout() - + self.sponsorblock_select_btn = QPushButton("SponsorBlock Categories...") self.sponsorblock_select_btn.setFixedHeight(30) self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog) - self.sponsorblock_select_btn.setStyleSheet(""" + self.sponsorblock_select_btn.setStyleSheet( + """ QPushButton { background-color: #1d1e22; border: 2px solid #1d1e22; @@ -152,18 +155,19 @@ class VideoInfoMixin: color: #888888; border-color: #3d3d3d; } - """) + """ + ) self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False) self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False) sponsorblock_layout.addWidget(self.sponsorblock_select_btn) - + # Label to show selection count self.selected_sponsorblock_label = QLabel("0 selected") self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;") sponsorblock_layout.addWidget(self.selected_sponsorblock_label) - + sponsorblock_layout.addStretch() - + # Add the sponsorblock layout to the main video info layout video_info_layout.addLayout(sponsorblock_layout) # --- End SponsorBlock Section --- @@ -180,10 +184,11 @@ class VideoInfoMixin: return media_info_layout - def setup_playlist_info_section(self): + def setup_playlist_info_section(self) -> QLabel: self.playlist_info_label = QLabel() self.playlist_info_label.setVisible(False) - self.playlist_info_label.setStyleSheet(""" + self.playlist_info_label.setStyleSheet( + """ QLabel { font-size: 12px; color: #ffffff; @@ -195,18 +200,19 @@ class VideoInfoMixin: min-height: 30px; max-height: 30px; } - """) + """ + ) self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) return self.playlist_info_label - def update_video_info(self, info): - if hasattr(self, 'is_playlist') and self.is_playlist: + def update_video_info(self, info) -> None: + if hasattr(self, "is_playlist") and self.is_playlist: # Playlist Mode: Show playlist title and video count - self.title_label.setText(self.playlist_info.get('title', 'Unknown Playlist')) - - num_videos = len(getattr(self, 'playlist_entries', [])) + self.title_label.setText(self.playlist_info.get("title", "Unknown Playlist")) + + num_videos = len(getattr(self, "playlist_entries", [])) self.duration_label.setText(f"Total Videos: {num_videos}") - + # Hide video-specific info self.channel_label.setText("") self.views_label.setText("") @@ -225,63 +231,62 @@ class VideoInfoMixin: self.like_count_label.setVisible(True) # Format view count with commas - views = info.get('view_count') - formatted_views = f"{views:,}" if views is not None else 'N/A' + views = info.get("view_count") + formatted_views = f"{views:,}" if views is not None else "N/A" # Format like count with commas - likes = info.get('like_count') - formatted_likes = f"{likes:,}" if likes is not None else 'N/A' - + likes = info.get("like_count") + formatted_likes = f"{likes:,}" if likes is not None else "N/A" + # Format upload date - upload_date = info.get('upload_date', '') + upload_date = info.get("upload_date", "") if upload_date: - date_obj = datetime.strptime(upload_date, '%Y%m%d') - formatted_date = date_obj.strftime('%B %d, %Y') + date_obj = datetime.strptime(upload_date, "%Y%m%d") + formatted_date = date_obj.strftime("%B %d, %Y") else: - formatted_date = 'Unknown date' - + formatted_date = "Unknown date" + # Format duration - duration = info.get('duration', 0) + duration = info.get("duration", 0) minutes = duration // 60 seconds = duration % 60 duration_str = f"{minutes}:{seconds:02d}" - + # Update labels - self.title_label.setText(info.get('title', 'Unknown title')) + self.title_label.setText(info.get("title", "Unknown title")) self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}") self.views_label.setText(f"Views: {formatted_views}") self.like_count_label.setText(f"Likes: {formatted_likes}") self.date_label.setText(f"Upload date: {formatted_date}") self.duration_label.setText(f"Duration: {duration_str}") - def open_subtitle_dialog(self): - if not hasattr(self, 'available_subtitles') or not hasattr(self, 'available_automatic_subtitles'): - logger.warning("Subtitle info not loaded yet.") - return + def open_subtitle_dialog(self) -> None: + if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"): + logger.warning("Subtitle info not loaded yet.") + return - if not hasattr(self, 'selected_subtitles'): + if not hasattr(self, "selected_subtitles"): self.selected_subtitles = [] dialog = SubtitleSelectionDialog( - self.available_subtitles, - self.available_automatic_subtitles, + self.available_subtitles, # type: ignore[reportAttributeAccessIssue] + self.available_automatic_subtitles, # type: ignore[reportAttributeAccessIssue] self.selected_subtitles, - self # Parent for the dialog + self, # Parent for the dialog ) # Access the main application window (parent of the mixin's widget) # to find the merge checkbox - main_window = self # In this context, self should be the YTSageApp instance + main_window = self # In this context, self should be the YTSageApp instance if not isinstance(main_window, QMainWindow): - # If the structure is different, this might need adjustment - # Maybe self.parentWidget() or similar depending on how Mixin is used - logger.warning("Cannot find main window to access merge checkbox.") - merge_checkbox = None + # If the structure is different, this might need adjustment + # Maybe self.parentWidget() or similar depending on how Mixin is used + logger.warning("Cannot find main window to access merge checkbox.") + merge_checkbox = None else: - merge_checkbox = getattr(main_window, 'merge_subs_checkbox', None) + merge_checkbox = getattr(main_window, "merge_subs_checkbox", None) - - if dialog.exec(): # If user clicks OK + if dialog.exec(): # If user clicks OK self.selected_subtitles = dialog.get_selected_subtitles() logger.info(f"Selected subtitles: {self.selected_subtitles}") # Update UI to reflect selection @@ -292,7 +297,7 @@ class VideoInfoMixin: # Enable/disable the merge checkbox in the parent window if merge_checkbox: # Only enable merge checkbox if we're not in Audio Only mode - is_audio_only = hasattr(main_window, 'audio_button') and main_window.audio_button.isChecked() + is_audio_only = hasattr(main_window, "audio_button") and main_window.audio_button.isChecked() # In audio-only mode, we still allow subtitle selection but not merging should_enable = count > 0 and not is_audio_only merge_checkbox.setEnabled(should_enable) @@ -304,29 +309,29 @@ class VideoInfoMixin: self.subtitle_select_btn.style().polish(self.subtitle_select_btn) # No else needed for cancel, state remains unchanged - def open_sponsorblock_dialog(self): + def open_sponsorblock_dialog(self) -> None: """Open the SponsorBlock category selection dialog.""" # Initialize selected categories if not exists or empty (first time opening) - if not hasattr(self, 'selected_sponsorblock_categories') or not self.selected_sponsorblock_categories: + if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories: # Use None to let the dialog set its own defaults dialog_categories = None else: dialog_categories = self.selected_sponsorblock_categories - + dialog = SponsorBlockCategoryDialog(dialog_categories, self) - + if dialog.exec(): self.selected_sponsorblock_categories = dialog.get_selected_categories() logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}") self._update_sponsorblock_display() - - def _update_sponsorblock_display(self): + + def _update_sponsorblock_display(self) -> None: """Update the SponsorBlock button and label to reflect current selection.""" - if not hasattr(self, 'selected_sponsorblock_categories'): + if not hasattr(self, "selected_sponsorblock_categories"): self.selected_sponsorblock_categories = [] - + count = len(self.selected_sponsorblock_categories) - + # Update label text if count == 0: self.selected_sponsorblock_label.setText("0 selected") @@ -334,15 +339,15 @@ class VideoInfoMixin: self.selected_sponsorblock_label.setText("1 category selected") else: self.selected_sponsorblock_label.setText(f"{count} categories selected") - + # Update button property for styling self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0) - + # Force style refresh self.sponsorblock_select_btn.style().unpolish(self.sponsorblock_select_btn) self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn) - def download_thumbnail(self, url): + def download_thumbnail(self, url) -> None: try: # Store both thumbnail URL and video URL self.thumbnail_url = url @@ -355,42 +360,39 @@ class VideoInfoMixin: # Display thumbnail image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS) img_byte_arr = BytesIO() - image.save(img_byte_arr, format='PNG') + image.save(img_byte_arr, format="PNG") pixmap = QPixmap() pixmap.loadFromData(img_byte_arr.getvalue()) self.thumbnail_label.setPixmap(pixmap) except Exception as e: logger.error(f"Error loading thumbnail: {str(e)}") - def download_thumbnail_file(self, video_url, path): + def download_thumbnail_file(self, video_url, path) -> bool: if not self.save_thumbnail: return False try: - from yt_dlp import YoutubeDL - import requests # Use requests instead of urlopen - logger.debug(f"Attempting to save thumbnail for URL: {video_url}") ydl_opts = { - 'quiet': True, - 'skip_download': True, - 'force_generic_extractor': False, - 'no_warnings': True, - 'extract_flat': False + "quiet": True, + "skip_download": True, + "force_generic_extractor": False, + "no_warnings": True, + "extract_flat": False, } with YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(video_url, download=False) - thumbnails = info.get('thumbnails', []) + thumbnails = info.get("thumbnails", []) if not thumbnails: raise ValueError("No thumbnails available") thumbnail_url = max( thumbnails, - key=lambda t: (t.get('height', 0) or 0) * (t.get('width', 0) or 0) - ).get('url') + key=lambda t: (t.get("height", 0) or 0) * (t.get("width", 0) or 0), + ).get("url") if not thumbnail_url: raise ValueError("Failed to extract thumbnail URL") @@ -400,13 +402,13 @@ class VideoInfoMixin: response.raise_for_status() # Save the thumbnail - thumb_dir = os.path.join(path, 'Thumbnails') - os.makedirs(thumb_dir, exist_ok=True) + thumb_dir = Path(path).joinpath("Thumbnails") + thumb_dir.mkdir(exist_ok=True) filename = f"{self.sanitize_filename(info['title'])}.jpg" - thumbnail_path = os.path.join(thumb_dir, filename) + thumbnail_path = thumb_dir.joinpath(filename) - with open(thumbnail_path, 'wb') as f: + with open(thumbnail_path, "wb") as f: f.write(response.content) logger.info(f"Thumbnail saved to: {thumbnail_path}") @@ -419,6 +421,6 @@ class VideoInfoMixin: self.signals.update_status.emit(error_msg) return False - def sanitize_filename(self, name): + def sanitize_filename(self, name) -> str: """Clean filename for filesystem safety""" - return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75] \ No newline at end of file + return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75] diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/ytsage_constants.py b/src/utils/ytsage_constants.py new file mode 100644 index 0000000..314d0c6 --- /dev/null +++ b/src/utils/ytsage_constants.py @@ -0,0 +1,102 @@ +""" +This module defines centralized constants used across the YTSage application. +By storing shared values in one place, it improves consistency, readability, +and maintainability of the codebase. + +Constants include: +- Asset paths for icons and notification sounds. +- OS detection and platform-specific directory paths for application data, binaries, logs, and configuration. +- Download URLs for yt-dlp and ffmpeg binaries. +- SUBPROCESS_CREATIONFLAGS: Used to specify subprocess creation flags (e.g., subprocess.CREATE_NO_WINDOW on Windows to hide the console window). +Directories are automatically created when the module is imported, ensuring the required structure exists for the application. +YTSage application constants. + +""" + +import os +import platform +import subprocess +from pathlib import Path + +# Assets Constants +ICON_PATH: Path = Path("assets/Icon/icon.png") +SOUND_PATH: Path = Path("assets/sound/notification.mp3") + +OS_NAME: str = platform.system() # Windows ; Darwin ; Linux + +USER_HOME_DIR: Path = Path.home() + +# OS Specific Constants +if OS_NAME == "Windows": + OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}" + + # APP_PATH will be from system environment path or fallback to Path.home() + APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage" + APP_BIN_DIR: Path = APP_DIR / "bin" + APP_DATA_DIR: Path = APP_DIR / "data" + APP_LOG_DIR: Path = APP_DIR / "logs" + APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json" + + YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" + YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe" + + SUBPROCESS_CREATIONFLAGS: int = subprocess.CREATE_NO_WINDOW + +elif OS_NAME == "Darwin": # macOS + _mac_version = platform.mac_ver()[0] + OS_FULL_NAME: str = f"macOS {_mac_version}" if _mac_version else "macOS" + + APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "YTSage" + APP_BIN_DIR: Path = APP_DIR / "bin" + APP_DATA_DIR: Path = APP_DIR / "data" + APP_LOG_DIR: Path = APP_DIR / "logs" + APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json" + + YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" + YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp" + + SUBPROCESS_CREATIONFLAGS: int = 0 + + +else: # Linux and other UNIX-like + OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}" + + APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "YTSage" + APP_BIN_DIR: Path = APP_DIR / "bin" + APP_DATA_DIR: Path = APP_DIR / "data" + APP_LOG_DIR: Path = APP_DIR / "logs" + APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json" + + YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" + YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp" + + SUBPROCESS_CREATIONFLAGS: int = 0 + + +# ffmpeg download links +FFMPEG_7Z_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.7z" +FFMPEG_7Z_SHA256_URL = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-7.1.1-full_build.7z.sha256" +FFMPEG_ZIP_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip" + +if __name__ == "__main__": + # If this file is run directly, print directory information; if imported, create the necessary directories for the application. + info = { + "OS_NAME": OS_NAME, + "OS_FULL_NAME": OS_FULL_NAME, + "USER_HOME_DIR": str(USER_HOME_DIR), + "APP_DIR": str(APP_DIR), + "APP_BIN_DIR": str(APP_BIN_DIR), + "APP_DATA_DIR": str(APP_DATA_DIR), + "APP_LOG_DIR": str(APP_LOG_DIR), + "APP_CONFIG_FILE": str(APP_CONFIG_FILE), + "YTDLP_DOWNLOAD_URL": YTDLP_DOWNLOAD_URL, + "YTDLP_APP_BIN_PATH": YTDLP_APP_BIN_PATH, + "SUBPROCESS_CREATIONFLAGS": SUBPROCESS_CREATIONFLAGS, + } + for key, value in info.items(): + print(f"{key}: {value}") +else: + APP_DIR.mkdir(parents=True, exist_ok=True) + APP_BIN_DIR.mkdir(parents=True, exist_ok=True) + APP_DATA_DIR.mkdir(parents=True, exist_ok=True) + APP_LOG_DIR.mkdir(parents=True, exist_ok=True)