From 3384cef83a8bc7de7ae647084174bd146206388d Mon Sep 17 00:00:00 2001 From: Muhamed Date: Tue, 29 Apr 2025 15:07:11 +0300 Subject: [PATCH] v4.5.0 --- ytsage_downloader.py | 759 +++++++++++++++++++-------- ytsage_gui_dialogs.py | 1008 +++++++++++++++++++++++++++++------- ytsage_gui_format_table.py | 178 +++++-- ytsage_gui_main.py | 682 ++++++++++++++++++------ ytsage_gui_video_info.py | 276 +++++----- ytsage_style.py | 58 +-- ytsage_utils.py | 52 +- 7 files changed, 2184 insertions(+), 829 deletions(-) diff --git a/ytsage_downloader.py b/ytsage_downloader.py index e9b88a7..5b33505 100644 --- a/ytsage_downloader.py +++ b/ytsage_downloader.py @@ -1,8 +1,10 @@ -from PySide6.QtCore import QThread, Signal, QObject +from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer import yt_dlp # Keep yt_dlp import here - only downloader uses it. import time import os import re +import subprocess # For direct CLI command execution +import shlex # For safely parsing command arguments from pathlib import Path class SignalManager(QObject): @@ -16,20 +18,32 @@ 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 - def __init__(self, url, path, format_id, subtitle_lang=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None): + def __init__(self, url, path, format_id, subtitle_langs=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None, save_description=False, cookie_file=None, rate_limit=None): super().__init__() self.url = url self.path = path self.format_id = format_id - self.subtitle_lang = subtitle_lang + 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.resolution = resolution self.playlist_items = playlist_items + self.save_description = save_description + self.cookie_file = cookie_file + self.rate_limit = rate_limit self.paused = False self.cancelled = False + self.process = None + 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 def cleanup_partial_files(self): """Delete any partial files including .part and unmerged format-specific files""" @@ -46,14 +60,99 @@ class DownloadThread(QThread): except Exception as e: self.error_signal.emit(f"Error cleaning partial files: {str(e)}") + def cleanup_subtitle_files(self): + """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 + print(f"DEBUG: Deleted tracked subtitle file: {os.path.basename(subtitle_file)}") + except Exception as e: + print(f"Error deleting subtitle file {subtitle_file}: {str(e)}") + + print(f"DEBUG: Deleted {deleted_count} of {len(self.subtitle_files)} tracked subtitle files") + + # Method 2: Find newly created subtitle files by comparing with initial set + 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: + print(f"DEBUG: 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 + print(f"DEBUG: Deleted new subtitle file: {os.path.basename(subtitle_file)}") + except Exception as e: + print(f"Error deleting new subtitle file {subtitle_file}: {str(e)}") + except Exception as e: + print(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 + print(f"DEBUG: Deleted subtitle file by timestamp: {filename}") + except Exception as e: + print(f"Error deleting subtitle file {filename}: {str(e)}") + + print(f"DEBUG: Total subtitle files deleted: {deleted_count}") + + except Exception as e: + print(f"Error cleaning subtitle files: {str(e)}") + def check_file_exists(self): """Check if the file already exists before downloading""" try: print("DEBUG: Starting file existence check") - # Use yt-dlp to get the filename without downloading - with yt_dlp.YoutubeDL({'quiet': True, 'skip_download': True}) as ydl: + # 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 + } + if self.cookie_file: + ydl_opts_check['cookiefile'] = self.cookie_file + + 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: + print("DEBUG: Failed to extract info during file existence check. Skipping check.") + return False # Proceed with download attempt + # Get the title and sanitize it for filename title = info.get('title', 'video') # Don't remove colons and other special characters yet @@ -106,10 +205,147 @@ class DownloadThread(QThread): traceback.print_exc() return None + def _build_yt_dlp_command(self): + """Build the yt-dlp command line with all options for direct execution.""" + cmd = ["yt-dlp"] + + # 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 + + # Check if this is an audio-only format + is_audio_format = False + try: + ydl_opts = { + '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(): + is_audio_format = True + print(f"DEBUG: Detected audio-only format for ID: {clean_format_id}") + break + except Exception as e: + print(f"DEBUG: 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]) + print(f"DEBUG: Using audio-only format selection: {clean_format_id}") + else: + cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"]) + print(f"DEBUG: 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: + format_ext = None + print(f"DEBUG: Getting format information for format ID: {self.format_id} (using: {clean_format_id})") + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'skip_download': True, + } + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(self.url, download=False) + # 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') + 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') + break + + if format_ext: + print(f"DEBUG: Detected format extension: {format_ext}") + # Ensure output matches the selected format - only for video formats + cmd.extend(["--merge-output-format", format_ext]) + except Exception as e: + print(f"DEBUG: Error detecting format extension: {e}") + # If we can't determine the format, don't specify merge-output-format + pass + else: + # 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') + + # 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]) + + # 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 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_codes.append(lang_code) + except Exception as e: + print(f"Warning: 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 + + # Add embedding if requested - only applies to video formats + if self.merge_subs: + cmd.append("--embed-subs") + + # Add SponsorBlock if enabled + if self.enable_sponsorblock: + cmd.append("--sponsorblock-remove") + cmd.append("sponsor") + + # Add description saving if enabled + if self.save_description: + cmd.append("--write-description") + + # 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 the URL as the final argument + cmd.append(self.url) + + return cmd + def run(self): try: print("DEBUG: Starting download thread") - # First check if file already exists + + # First check if file already exists using original method existing_file = self.check_file_exists() if existing_file: print(f"DEBUG: File exists, emitting signal: {existing_file}") @@ -117,216 +353,305 @@ class DownloadThread(QThread): return print("DEBUG: No existing file found, proceeding with download") - class DebugLogger: - def debug(self, msg): - # Print all debug messages to help diagnose issues - print(f"YT-DLP DEBUG: {msg}") - - # Check for file exists message - look for both patterns - if "already exists" in msg or "has already been downloaded" in msg: - print(f"FILE EXISTS DETECTED: {msg}") - # Try to extract the filename - import re - match = re.search(r'File (.*?) already exists', msg) - if not match: - match = re.search(r'(.*?) has already been downloaded', msg) - - if match: - filename = os.path.basename(match.group(1)) - self.thread.file_exists_signal.emit(filename) - raise Exception("FileExistsError") - - # Add detection of post-processing messages - if "Downloading" in msg: - self.thread.status_signal.emit("⚡ Downloading video...") - elif "Post-process" in msg or "Sponsorblock" in msg: - self.thread.status_signal.emit("✨ Post-processing: Removing sponsor segments...") - self.thread.progress_signal.emit(99) # Keep progress bar at 99% - elif any(x in msg.lower() for x in ['downloading webpage', 'downloading api']): - self.thread.status_signal.emit("🔍 Fetching video information...") - self.thread.progress_signal.emit(0) - elif 'extracting' in msg.lower(): - self.thread.status_signal.emit("📦 Extracting video data...") - self.thread.progress_signal.emit(0) - elif 'downloading m3u8' in msg.lower(): - self.thread.status_signal.emit("🎯 Preparing video streams...") - self.thread.progress_signal.emit(0) - - def warning(self, msg): - print(f"YT-DLP WARNING: {msg}") - self.thread.status_signal.emit(f"⚠️ Warning: {msg}") - # Also check for file exists in warnings - if "already exists" in msg or "has already been downloaded" in msg: - print(f"FILE EXISTS DETECTED IN WARNING: {msg}") - import re - match = re.search(r'File (.*?) already exists', msg) - if not match: - match = re.search(r'(.*?) has already been downloaded', msg) - - if match: - filename = os.path.basename(match.group(1)) - self.thread.file_exists_signal.emit(filename) - raise Exception("FileExistsError") - - def error(self, msg): - print(f"YT-DLP ERROR: {msg}") - self.thread.status_signal.emit(f"❌ Error: {msg}") - # Also check for file exists in errors - if "already exists" in msg or "has already been downloaded" in msg: - print(f"FILE EXISTS DETECTED IN ERROR: {msg}") - import re - match = re.search(r'File (.*?) already exists', msg) - if not match: - match = re.search(r'(.*?) has already been downloaded', msg) - - if match: - filename = os.path.basename(match.group(1)) - self.thread.file_exists_signal.emit(filename) - raise Exception("FileExistsError") - - def __init__(self, thread): - self.thread = thread - - def progress_hook(d): - if self.cancelled: - raise Exception("Download cancelled by user") - - if d['status'] == 'downloading': - while self.paused and not self.cancelled: - time.sleep(0.1) - continue - - try: - downloaded_bytes = d.get('downloaded_bytes', 0) - total_bytes = d.get('total_bytes', 0) or d.get('total_bytes_estimate', 0) - - if total_bytes: - progress = (downloaded_bytes / total_bytes) * 100 - self.progress_signal.emit(progress) - - speed = d.get('speed', 0) - if speed: - speed_str = f"{speed/1024/1024:.1f} MB/s" - else: - speed_str = "N/A" - - eta = d.get('eta', 0) - if eta: - eta_str = f"{eta//60}:{eta%60:02d}" - else: - eta_str = "N/A" - - filename = os.path.basename(d.get('filename', '')) - status = f"Speed: {speed_str} | ETA: {eta_str} | File: {filename}" - self.status_signal.emit(status) - - except Exception as e: - self.status_signal.emit("⚡ Downloading...") - - elif d['status'] == 'finished': - if self.enable_sponsorblock: - self.progress_signal.emit(99) - self.status_signal.emit("✨ Post-processing: Removing sponsor segments...") - else: - self.progress_signal.emit(100) - self.status_signal.emit("✅ Download completed!") - - # Get the extension from the format_id - with yt_dlp.YoutubeDL({'quiet': True}) as ydl: - try: - info = ydl.extract_info(self.url, download=False) - selected_format = next( - f for f in info['formats'] - if str(f.get('format_id', '')) == self.format_id - ) - output_ext = selected_format.get('ext', 'mp4') - except Exception as e: - self.error_signal.emit(f"Failed to get video information: {str(e)}") - return - - # Base yt-dlp options with resolution in filename - output_template = '%(title)s_%(resolution)s.%(ext)s' - if self.is_playlist: - output_template = '%(playlist_title)s/%(title)s_%(resolution)s.%(ext)s' - - ydl_opts = { - 'format': f'{self.format_id}+bestaudio/best', - 'outtmpl': os.path.join(self.path, output_template), - 'progress_hooks': [progress_hook], - 'merge_output_format': 'mp4', - 'logger': DebugLogger(self), - 'postprocessors': [{ - 'key': 'FFmpegVideoConvertor', - 'preferedformat': 'mp4' - }], - 'force_overwrites': True - } - - # Add subtitle options if selected - if self.subtitle_lang: - lang_code = self.subtitle_lang.split(' - ')[0] - is_auto = 'Auto-generated' in self.subtitle_lang - ydl_opts.update({ - 'writesubtitles': True, - 'subtitleslangs': [lang_code], - 'writeautomaticsub': True, - 'skip_manual_subs': is_auto, - 'skip_auto_subs': not is_auto, - 'embedsubtitles': self.merge_subs, - }) - - if self.merge_subs: - ydl_opts['postprocessors'].extend([ - { - 'key': 'FFmpegSubtitlesConvertor', - 'format': 'srt', - }, - { - 'key': 'FFmpegEmbedSubtitle', - 'already_have_subtitle': False, - } - ]) - - # Add SponsorBlock options if enabled - if self.enable_sponsorblock: - ydl_opts['postprocessors'].extend([{ - 'key': 'SponsorBlock', - 'categories': ['sponsor'], - 'api': 'https://sponsor.ajay.app' - }, { - 'key': 'ModifyChapters', - 'remove_sponsor_segments': ['sponsor'], - 'sponsorblock_chapter_title': '[SponsorBlock]', - 'force_keyframes': False - }]) - - # Add playlist items if specified - if self.playlist_items: - ydl_opts['playlist_items'] = self.playlist_items - - try: - # Download the video - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([self.url]) - - if not self.cancelled: - self.finished_signal.emit() - - # Clean up subtitle files after successful download - if self.merge_subs: - for filename in os.listdir(self.path): - if filename.lower().endswith(('.vtt', '.srt', '.ass')): - try: - os.remove(os.path.join(self.path, filename)) - except Exception as e: - self.error_signal.emit(f"Error deleting subtitle file: {str(e)}") - except Exception as e: - if str(e) == "Download cancelled by user": - self.cleanup_partial_files() - self.error_signal.emit("Download cancelled") - else: - self.error_signal.emit(f"Download failed: {str(e)}") - + # 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)) + print(f"DEBUG: Found {len(self.initial_subtitle_files)} existing subtitle files before download") + except Exception as e: + print(f"Warning: 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: - self.error_signal.emit(f"Critical error: {str(e)}") \ No newline at end of file + # 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): + """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) + print(f"DEBUG: 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 + + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, # Line buffered + universal_newlines=True, + creationflags=creation_flags # Add this flag + ) + + # Process output line by line to update progress + for line in iter(self.process.stdout.readline, ''): + 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() + + 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: + # Add a significant delay to ensure ffmpeg has released all file handles + # and any post-processing is complete + 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 + if self.cancelled: + self.status_signal.emit("Download cancelled") + 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): + """Parse yt-dlp command output to update progress and status.""" + line = line.strip() + # print(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) + if dest_match: + try: + filepath = dest_match.group(1).strip() + self.current_filename = os.path.basename(filepath) + self.last_file_path = filepath # Store the full path for later cleanup + print(f"DEBUG: 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: + is_audio_download = True + # Check if the format ID is mentioned earlier in the line + format_match = re.search(r'Downloading format (\d+)', line) + if format_match: + format_id = format_match.group(1) + print(f"DEBUG: Detected format ID: {format_id}") + # 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() + + # Check if this is explicitly an audio stream download + 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']: + self.status_signal.emit(f"⏬ Downloading video...") + # Audio file extensions + 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']: + self.status_signal.emit(f"⏬ Downloading subtitle...") + # Default case + else: + self.status_signal.emit(f"⏬ Downloading...") + except Exception as e: + print(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 + + # 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) + 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 it's a relative path, make it absolute based on current path + subtitle_file = os.path.join(self.path, subtitle_file) + self.subtitle_files.append(subtitle_file) + print(f"DEBUG: 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: + self.status_signal.emit("🔍 Fetching video information...") + self.progress_signal.emit(0) + elif 'Downloading API JSON' in line: + self.status_signal.emit("📋 Processing playlist data...") + self.progress_signal.emit(0) + elif 'Downloading m3u8 information' in line: + self.status_signal.emit("🎯 Preparing video streams...") + self.progress_signal.emit(0) + elif '[download] Downloading video ' in line: + self.status_signal.emit("⏬ Downloading video...") + elif '[download] Downloading audio ' in line: + self.status_signal.emit("⏬ Downloading audio...") + elif 'Downloading format' in line: + # Try to detect if it's audio or video format + if ' - audio only' in line: + self.status_signal.emit("⏬ Downloading audio...") + 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) + 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: + # 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_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_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) + print(f"Error parsing download details line: {line} -> {e}") + 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: + self.status_signal.emit("✨ Post-processing: Merging formats...") + self.progress_signal.emit(95) + elif 'SponsorBlock' in line: + self.status_signal.emit("✨ Post-processing: Removing sponsor segments...") + self.progress_signal.emit(97) + elif 'Deleting original file' in line: + self.progress_signal.emit(98) + elif 'has already been downloaded' in line: + # File already exists - extract filename + match = re.search(r'(.*?) has already been downloaded', line) + if match: + filename = os.path.basename(match.group(1)) + # 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']: + self.status_signal.emit(f"⚠️ Video file already exists") + 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']: + 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: + print(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.progress_signal.emit(100) + + # Show completion message based on file type + if self.current_filename: + ext = os.path.splitext(self.current_filename)[1].lower() + + # Video file extensions + 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']: + self.status_signal.emit(f"✅ Audio download completed!") + # Subtitle file extensions + 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): + """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): + self.paused = True + + def resume(self): + self.paused = False + + def cancel(self): + 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 diff --git a/ytsage_gui_dialogs.py b/ytsage_gui_dialogs.py index 5c1a69c..592b7cc 100644 --- a/ytsage_gui_dialogs.py +++ b/ytsage_gui_dialogs.py @@ -4,7 +4,8 @@ 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) + QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QListWidget, + QListWidgetItem, QDialogButtonBox, QScrollArea, QGroupBox) from PySide6.QtCore import Qt, Signal, QObject, QThread, QProcess from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap import requests @@ -75,9 +76,9 @@ class CustomCommandDialog(QDialog): self.command_input.setPlaceholderText("Enter yt-dlp arguments...") self.command_input.setStyleSheet(""" QPlainTextEdit { - background-color: #363636; + background-color: #1d1e22; color: #ffffff; - border: 2px solid #3d3d3d; + border: 2px solid #1d1e22; border-radius: 4px; padding: 8px; font-family: Consolas, monospace; @@ -96,17 +97,17 @@ class CustomCommandDialog(QDialog): QCheckBox::indicator { width: 18px; height: 18px; - border-radius: 9px; /* Make indicator round */ + border-radius: 9px; } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #2b2b2b; - border-radius: 9px; /* Make indicator round */ + background: #1d1e22; + border-radius: 9px; } QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; - border-radius: 9px; /* Make indicator round */ + border: 2px solid #c90000; + background: #c90000; + border-radius: 9px; } """) layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox) @@ -129,9 +130,9 @@ class CustomCommandDialog(QDialog): self.log_output.setReadOnly(True) self.log_output.setStyleSheet(""" QTextEdit { - background-color: #2b2b2b; + background-color: #1d1e22; color: #ffffff; - border: 2px solid #3d3d3d; + border: 2px solid #1d1e22; border-radius: 4px; padding: 8px; font-family: Consolas, monospace; @@ -142,18 +143,18 @@ class CustomCommandDialog(QDialog): self.setStyleSheet(""" QDialog { - background-color: #2b2b2b; + background-color: #15181b; } QPushButton { padding: 8px 15px; - background-color: #ff0000; + background-color: #c90000; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:hover { - background-color: #cc0000; + background-color: #a50000; } """) @@ -398,6 +399,130 @@ class FFmpegCheckDialog(QDialog): self.close_btn.setEnabled(True) +class VersionCheckThread(QThread): + finished = Signal(str, str, str) # current_version, latest_version, error_message + + def run(self): + current_version = "" + latest_version = "" + error_message = "" + + try: + # Get the yt-dlp executable path + if getattr(sys, 'frozen', False): + if sys.platform == 'win32': + yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe') + else: + yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp') + else: + yt_dlp_path = 'yt-dlp' + + # Get current version + try: + result = subprocess.run([yt_dlp_path, '--version'], + capture_output=True, + text=True, + startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE), # Hide console window on Windows + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) # Hide console window on Windows + if result.returncode == 0: + current_version = result.stdout.strip() + else: # Try fallback if command failed + import yt_dlp + current_version = yt_dlp.version.__version__ + except Exception: + # Fallback to importing yt_dlp package directly if subprocess fails + try: + import yt_dlp + current_version = yt_dlp.version.__version__ + except ImportError: + 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) # Add timeout + response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + latest_version = response.json()["info"]["version"] + + # Clean up version strings + 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) + + +class UpdateThread(QThread): + update_status = Signal(str) # For status messages + update_finished = Signal(bool, str) # success (bool), message/error (str) + + def run(self): + error_message = "" + success = False + try: + self.update_status.emit("Starting update process...") + + # Determine paths (similar logic as before) + python_path = sys.executable # Default to current interpreter + yt_dlp_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.getcwd() + + if getattr(sys, 'frozen', False) and sys.platform == 'win32': + alt_python_path = os.path.join(os.path.dirname(sys.executable), 'python.exe') + if os.path.exists(alt_python_path): + python_path = alt_python_path + + # Create and configure QProcess + process = QProcess() + process.setWorkingDirectory(yt_dlp_dir) + process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) # Combine stdout/stderr + + # Prepare command arguments + pip_args = ['install', '--upgrade', '--no-cache-dir', 'yt-dlp'] + if sys.platform == 'win32': + command = python_path + args = ['-m', 'pip'] + pip_args + else: + # Assume pip is in PATH or use python -m pip for robustness + command = python_path + args = ['-m', 'pip'] + pip_args + # Alternative if pip is guaranteed in PATH: command = 'pip', args = pip_args + + # Start the process + self.update_status.emit(f"Running: {command} {' '.join(args)}") + process.start(command, args) + + # Wait for finish (use QProcess event loop, not blocking waitForFinished) + if not process.waitForStarted(5000): # Wait 5s for process to start + raise RuntimeError("Update process failed to start.") + + if not process.waitForFinished(-1): # Wait indefinitely for finish + raise RuntimeError("Update process failed to finish.") + + exit_code = process.exitCode() + output = process.readAll().data().decode(errors='ignore') # Read combined output + + if exit_code == 0: + self.update_status.emit("Update completed successfully!") + success = True + error_message = "Update successful. Please restart the application." + else: + self.update_status.emit(f"Update failed (Exit Code: {exit_code})") + error_message = f"Update failed.\nExit Code: {exit_code}\nOutput:\n{output}" + success = False + + except Exception as e: + error_message = f"Update failed with exception: {e}" + self.update_status.emit(error_message) + success = False + + self.update_finished.emit(success, error_message) + + class YTDLPUpdateDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) @@ -432,7 +557,7 @@ class YTDLPUpdateDialog(QDialog): # Style self.setStyleSheet(""" QDialog { - background-color: #2b2b2b; + background-color: #15181b; } QLabel { color: #ffffff; @@ -441,7 +566,7 @@ class YTDLPUpdateDialog(QDialog): } QPushButton { padding: 8px 15px; - background-color: #ff0000; + background-color: #c90000; border: none; border-radius: 4px; color: white; @@ -452,222 +577,723 @@ class YTDLPUpdateDialog(QDialog): background-color: #666666; } QPushButton:hover { - background-color: #cc0000; + background-color: #a50000; } QProgressBar { - border: 2px solid #3d3d3d; + border: 2px solid #1d1e22; border-radius: 4px; text-align: center; color: white; - background-color: #363636; + background-color: #1d1e22; height: 25px; } QProgressBar::chunk { - background-color: #ff0000; + background-color: #c90000; border-radius: 2px; } """) - # Start version check + # Start version check in background self.check_version() def check_version(self): + 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): + 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 + try: - # Get current version using yt-dlp command - import subprocess - import sys - import os - - # Get the yt-dlp executable path - if getattr(sys, 'frozen', False): - if sys.platform == 'win32': - yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe') - else: - yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp') - else: - yt_dlp_path = 'yt-dlp' - - # Get current version - try: - result = subprocess.run([yt_dlp_path, '--version'], - capture_output=True, - text=True) - current_version = result.stdout.strip() - except Exception: - import yt_dlp - current_version = yt_dlp.version.__version__ - - # Get latest version from PyPI - import requests - response = requests.get("https://pypi.org/pypi/yt-dlp/json") - latest_version = response.json()["info"]["version"] - - # Clean up version strings - current_version = current_version.replace('_', '.') - latest_version = latest_version.replace('_', '.') - # Compare versions - from packaging import version - try: - 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.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 version.InvalidVersion: - # If version parsing fails, do a simple string comparison - if current_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})") - self.update_btn.setEnabled(False) - - except Exception as e: - self.status_label.setText(f"Error checking version: {str(e)}") - self.update_btn.setEnabled(False) - + 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.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 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.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: # Catch any other unexpected errors during comparison + self.status_label.setText(f"Error comparing versions: {e}") + self.update_btn.setEnabled(False) + def perform_update(self): - try: - self.update_btn.setEnabled(False) - self.close_btn.setEnabled(False) - self.status_label.setText("Updating yt-dlp...") - self.progress_bar.setRange(0, 0) - self.progress_bar.show() - - # Get the yt-dlp path - if getattr(sys, 'frozen', False): - if sys.platform == 'win32': - yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe') - else: - yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp') - else: - yt_dlp_path = 'yt-dlp' - - # Create a QProcess for updating - process = QProcess() - process.setWorkingDirectory(os.path.dirname(yt_dlp_path)) - - if sys.platform == 'win32': - # For Windows, use pip to update - python_path = os.path.join(os.path.dirname(sys.executable), 'python.exe') - if not os.path.exists(python_path): - python_path = sys.executable - - process.start(python_path, ['-m', 'pip', 'install', '--upgrade', '--no-cache-dir', 'yt-dlp']) - else: - # For Unix systems - process.start('pip', ['install', '--upgrade', '--no-cache-dir', 'yt-dlp']) - - process.waitForFinished() - - if process.exitCode() == 0: - self.status_label.setText("Update completed successfully!\nPlease restart the application.") - self.check_version() # Recheck version after update - else: - error = process.readAllStandardError().data().decode() - self.status_label.setText(f"Update failed: {error}") - - self.progress_bar.setRange(0, 100) - self.progress_bar.setValue(100) - self.close_btn.setEnabled(True) - - except Exception as e: - self.status_label.setText(f"Update failed: {str(e)}") - self.close_btn.setEnabled(True) - self.progress_bar.hide() + self.update_btn.setEnabled(False) + self.close_btn.setEnabled(False) + self.status_label.setText("Initializing update...") + self.progress_bar.setRange(0, 0) # Indeterminate progress + self.progress_bar.show() + + # Create and start the update thread + self.update_thread = UpdateThread() + self.update_thread.update_status.connect(self.on_update_status) # Connect status signal + self.update_thread.update_finished.connect(self.on_update_finished) # Connect finished signal + self.update_thread.start() + + def on_update_status(self, message): + """Slot to receive status messages from UpdateThread.""" + self.status_label.setText(message) + + def on_update_finished(self, success, message): + """Slot called when the UpdateThread finishes.""" + self.progress_bar.setRange(0, 100) # Set determinate range + self.progress_bar.setValue(100) # Mark as complete + self.progress_bar.hide() # Optionally hide progress bar again + self.status_label.setText(message) + self.close_btn.setEnabled(True) + + if success: + # Optionally re-check version automatically after successful update + self.check_version() + else: + # Re-enable update button only if failed? + # self.update_btn.setEnabled(True) # Decide if appropriate + pass # Keep update button disabled on failure for now + + def closeEvent(self, event): + """Ensure threads are terminated if the dialog is closed prematurely.""" + if hasattr(self, 'version_check_thread') and self.version_check_thread.isRunning(): + self.version_check_thread.quit() # Ask thread to stop + self.version_check_thread.wait() # Wait for it to finish + if hasattr(self, 'update_thread') and self.update_thread.isRunning(): + self.update_thread.quit() + self.update_thread.wait() + super().closeEvent(event) class AboutDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle('About YTSage') - self.setMinimumWidth(500) - + self.parent = parent # Store parent to access version etc. + self.setWindowTitle("About YTSage") + self.setMinimumWidth(450) + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) - # App title and version - title_label = QLabel("YTSage") - title_label.setStyleSheet(""" - font-size: 24px; - font-weight: bold; - color: #ff0000; - padding: 10px; - """) + # Title and Version + title_label = QLabel("

YTSage

") title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title_label) - version_label = QLabel(f"Version {parent.version}") + version_label = QLabel(f"Version: {getattr(self.parent, 'version', 'N/A')}") version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + version_label.setStyleSheet("color: #cccccc;") layout.addWidget(version_label) # Description - description = QLabel( - "A modern YouTube downloader with a clean interface.\n" - "Download videos in any quality, extract audio, fetch subtitles,\n" - "and view video metadata. Built with yt-dlp for reliable performance." - ) - description.setWordWrap(True) - description.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(description) + description_label = QLabel("A simple GUI frontend for the powerful yt-dlp video downloader.") + description_label.setWordWrap(True) + description_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + description_label.setStyleSheet("color: #ffffff; padding-top: 10px;") + layout.addWidget(description_label) - # Features list - features_label = QLabel( - "Key Features:\n" - "• Smart video quality selection\n" - "• Audio extraction\n" - "• Subtitle support\n" - "• Playlist downloads\n" - "• Real-time progress tracking\n" - "• Custom command support\n" - "• SponsorBlock Integration\n" - "• Save Thumbnail\n" - "• One-click updates" - ) - features_label.setStyleSheet("padding: 10px;") - layout.addWidget(features_label) + # Separator + separator = QWidget() + separator.setFixedHeight(1) + separator.setStyleSheet("background-color: #1d1e22;") + layout.addWidget(separator) - # Credits - credits_label = QLabel( - "Powered by:\n" - "• yt-dlp\n" - "• PySide6\n" - "• FFmpeg" - ) - credits_label.setStyleSheet("padding: 10px;") - layout.addWidget(credits_label) + # Information Section + info_layout = QVBoxLayout() + info_layout.setSpacing(8) - # GitHub link - github_btn = QPushButton("Visit GitHub Repository") - github_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/YTSage')) - layout.addWidget(github_btn) + # Author + author_label = QLabel("Created by: oop7") + author_label.setOpenExternalLinks(True) + info_layout.addWidget(author_label) - # Close button - close_btn = QPushButton("Close") - close_btn.clicked.connect(self.close) - layout.addWidget(close_btn) + # GitHub Repo + repo_label = QLabel("GitHub: github.com/oop7/YTSage") + repo_label.setOpenExternalLinks(True) + info_layout.addWidget(repo_label) - # Style the dialog + # yt-dlp path + yt_dlp_path = get_yt_dlp_path() + yt_dlp_path_text = yt_dlp_path if yt_dlp_path else 'yt-dlp not found in PATH' + yt_dlp_label = QLabel(f"yt-dlp Path: {yt_dlp_path_text}") + yt_dlp_label.setWordWrap(True) + info_layout.addWidget(yt_dlp_label) + + # FFmpeg Status + ffmpeg_found = check_ffmpeg() + ffmpeg_status_text = "Detected" if ffmpeg_found else "Not Detected" + ffmpeg_label = QLabel(f"FFmpeg Status: {ffmpeg_status_text}") + info_layout.addWidget(ffmpeg_label) + + layout.addLayout(info_layout) + + # Close Button + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) + button_box.accepted.connect(self.accept) + # Center the button box + button_layout = QHBoxLayout() + button_layout.addStretch() + button_layout.addWidget(button_box) + button_layout.addStretch() + layout.addLayout(button_layout) + + # Apply overall styling self.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - } - QLabel { - color: #ffffff; - font-size: 12px; - } + QDialog { background-color: #15181b; color: #ffffff; } + QLabel { color: #cccccc; } QPushButton { - padding: 8px 15px; - background-color: #ff0000; + padding: 8px 25px; + background-color: #c90000; border: none; border-radius: 4px; color: white; font-weight: bold; } - QPushButton:hover { - background-color: #cc0000; + QPushButton:hover { background-color: #a50000; } + """) + +# --- New Subtitle Selection Dialog --- +class SubtitleSelectionDialog(QDialog): + def __init__(self, available_manual, available_auto, previously_selected, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Subtitles") + self.setMinimumWidth(400) + self.setMinimumHeight(300) + + self.available_manual = available_manual + self.available_auto = available_auto + self.previously_selected = set(previously_selected) # Use a set for quick lookups + self.selected_subtitles = list(previously_selected) # Initialize with previous selection + + layout = QVBoxLayout(self) + layout.setSpacing(10) + + # Filter input + 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(""" + QLineEdit { + background-color: #363636; + border: 2px solid #3d3d3d; + border-radius: 4px; + padding: 5px; + min-height: 30px; + color: white; } - """) \ No newline at end of file + QLineEdit:focus { + border-color: #ff0000; + } + """) + layout.addWidget(self.filter_input) + + # Scroll Area for the list + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area + layout.addWidget(scroll_area) + + # Container widget for list items (needed for scroll area) + self.list_container = QWidget() + self.list_layout = QVBoxLayout(self.list_container) + self.list_layout.setContentsMargins(0, 0, 0, 0) + self.list_layout.setSpacing(2) # Compact spacing + self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top + scroll_area.setWidget(self.list_container) + + # Populate the list initially + self.populate_list() + + # OK and Cancel buttons + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + # Style the buttons + for button in button_box.buttons(): + button.setStyleSheet(""" + QPushButton { + background-color: #363636; + border: 2px solid #3d3d3d; + border-radius: 4px; + padding: 5px 15px; /* Adjust padding */ + min-height: 30px; /* Ensure consistent height */ + color: white; + } + QPushButton:hover { + background-color: #444444; + } + 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; }") + + + layout.addWidget(button_box) + + def populate_list(self, filter_text=""): + # Clear existing checkboxes from layout + while self.list_layout.count(): + item = self.list_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + filter_text = filter_text.lower() + combined_subs = {} + + # 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" + + # 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 combined_subs: + no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else "")) + no_subs_label.setStyleSheet("color: #aaaaaa; padding: 10px;") + self.list_layout.addWidget(no_subs_label) + return + + # Sort by language code + sorted_lang_codes = sorted(combined_subs.keys()) + + for lang_code in sorted_lang_codes: + item_text = combined_subs[lang_code] + checkbox = QCheckBox(item_text) + 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(""" + QCheckBox { + color: #ffffff; + padding: 5px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 4px; /* Square checkboxes */ + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; + } + QCheckBox::indicator:checked { + 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): + self.populate_list(self.filter_input.text()) + + def update_selection(self, state): + sender = self.sender() + subtitle_id = sender.property("subtitle_id") + if state == Qt.CheckState.Checked.value: + if subtitle_id not in self.previously_selected: + self.previously_selected.add(subtitle_id) + else: + if subtitle_id in self.previously_selected: + self.previously_selected.remove(subtitle_id) + + def get_selected_subtitles(self): + # Return the final set as a list + return list(self.previously_selected) + + def accept(self): + # Update the final list before closing + self.selected_subtitles = self.get_selected_subtitles() + super().accept() + +# --- End Subtitle Selection Dialog --- + + +# --- Playlist Video Selection Dialog --- + +class PlaylistSelectionDialog(QDialog): + def __init__(self, playlist_entries, previously_selected_string, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Playlist Videos") + self.setMinimumWidth(500) + self.setMinimumHeight(400) # Allow more vertical space + + self.playlist_entries = playlist_entries + self.checkboxes = [] + + # Main layout + main_layout = QVBoxLayout(self) + + # Top buttons (Select/Deselect All) + button_layout = QHBoxLayout() + select_all_btn = QPushButton("Select All") + deselect_all_btn = QPushButton("Deselect All") + 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(""" + QPushButton { + background-color: #363636; + border: 2px solid #3d3d3d; + border-radius: 4px; + padding: 5px 15px; + min-height: 30px; + color: white; + } + QPushButton:hover { + background-color: #444444; + } + 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) + button_layout.addStretch() + main_layout.addLayout(button_layout) + + # Scrollable area for checkboxes + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area + scroll_widget = QWidget() + self.list_layout = QVBoxLayout(scroll_widget) # Layout for checkboxes + self.list_layout.setContentsMargins(0, 0, 0, 0) + self.list_layout.setSpacing(2) # Compact spacing + self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top + scroll_area.setWidget(scroll_widget) + main_layout.addWidget(scroll_area) + + # Populate checkboxes + self._populate_list(previously_selected_string) + + # Dialog buttons (OK/Cancel) + 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(""" + QPushButton { + background-color: #363636; + border: 2px solid #3d3d3d; + border-radius: 4px; + padding: 5px 15px; + min-height: 30px; + color: white; + } + QPushButton:hover { + background-color: #444444; + } + 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; }") + + main_layout.addWidget(button_box) + + # Apply styling to match subtitle dialog + self.setStyleSheet(""" + QDialog { background-color: #15181b; } + QCheckBox { + color: #ffffff; + padding: 5px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 4px; + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; + } + QCheckBox::indicator:checked { + border: 2px solid #ff0000; + background: #ff0000; + } + QWidget { background-color: #15181b; } + """) + + def _parse_selection_string(self, selection_string): + """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(',') + for part in parts: + part = part.strip() + if '-' in part: + try: + start, end = map(int, part.split('-')) + if start <= end: + selected_indices.update(range(start, end + 1)) + except ValueError: + pass # Ignore invalid ranges + else: + try: + selected_indices.add(int(part)) + except ValueError: + pass # Ignore invalid numbers + return selected_indices + + def _populate_list(self, previously_selected_string): + """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) + if child.widget(): + child.widget().deleteLater() + self.checkboxes.clear() + + for index, entry in enumerate(self.playlist_entries): + 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}') + # Shorten title if too long + 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(""" + QCheckBox { + color: #ffffff; + padding: 5px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 4px; + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; + } + QCheckBox::indicator:checked { + 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): + for checkbox in self.checkboxes: + checkbox.setChecked(True) + + def _deselect_all(self): + for checkbox in self.checkboxes: + checkbox.setChecked(False) + + def _condense_indices(self, indices): + """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 "" + + ranges = [] + start = indices[0] + end = indices[0] + for i in range(1, len(indices)): + if indices[i] == end + 1: + end = indices[i] + 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}") + return ",".join(ranges) + + def get_selected_items_string(self): + """Returns the selection string based on checked boxes.""" + 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 self._condense_indices(selected_indices) + + # Optional: Override accept to ensure the string is generated, although not strictly necessary + # def accept(self): + # self._selected_string = self.get_selected_items_string() + # super().accept() + +# --- End Playlist Video Selection Dialog --- + +class CookieLoginDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('Login with Cookies') + self.setMinimumSize(400, 150) + + layout = QVBoxLayout(self) + + help_text = QLabel( + "Select the Netscape-format cookies file for logging in.\n" + "This allows downloading of private videos and premium quality audio." + ) + help_text.setWordWrap(True) + help_text.setStyleSheet("color: #999999; padding: 10px;") + layout.addWidget(help_text) + + # File path input and browse button + path_layout = QHBoxLayout() + self.cookie_path_input = QLineEdit() + self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)") + 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) + + layout.addLayout(path_layout) + + # Dialog buttons + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def browse_cookie_file(self): + # Open file dialog to select cookie file + file_dialog = QFileDialog(self) + file_dialog.setFileMode(QFileDialog.ExistingFile) + file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Assuming common cookie file extensions + if file_dialog.exec(): + selected_files = file_dialog.selectedFiles() + if selected_files: + self.cookie_path_input.setText(selected_files[0]) + + def get_cookie_file_path(self): + # Return the selected cookie file path + return self.cookie_path_input.text() + +# === Renamed Dialog: Download Settings === +class DownloadSettingsDialog(QDialog): # Renamed class + def __init__(self, current_path, current_limit, current_unit_index, parent=None): # Added limit params + super().__init__(parent) + self.setWindowTitle("Download Settings") # Renamed window + self.setMinimumWidth(450) + self.current_path = current_path + self.current_limit = current_limit if current_limit is not None else "" # Handle None + self.current_unit_index = current_unit_index + + layout = QVBoxLayout(self) + + # --- Download Path Section --- + path_group_box = QGroupBox("Download Path") + path_layout = QVBoxLayout() + + self.path_display = QLabel(self.current_path) + self.path_display.setWordWrap(True) + self.path_display.setStyleSheet("QLabel { color: #cccccc; padding: 5px; border: 1px solid #3d3d3d; border-radius: 4px; background-color: #363636; }") + path_layout.addWidget(self.path_display) + + browse_button = QPushButton("Browse...") + browse_button.clicked.connect(self.browse_new_path) + path_layout.addWidget(browse_button) + + path_group_box.setLayout(path_layout) + layout.addWidget(path_group_box) + # --- End Path Section --- + + # --- Speed Limit Section --- + speed_group_box = QGroupBox("Speed Limit") + speed_layout = QHBoxLayout() + + self.speed_limit_input = QLineEdit(str(self.current_limit)) # Set initial value + self.speed_limit_input.setPlaceholderText("None") + speed_layout.addWidget(self.speed_limit_input) + + self.speed_limit_unit = QComboBox() + self.speed_limit_unit.addItems(["KB/s", "MB/s"]) + self.speed_limit_unit.setCurrentIndex(self.current_unit_index) # Set initial unit + speed_layout.addWidget(self.speed_limit_unit) + + speed_group_box.setLayout(speed_layout) + layout.addWidget(speed_group_box) + # --- End Speed Limit Section --- + + # Dialog buttons (OK/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_new_path(self): + 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): + """Returns the confirmed path after the dialog is accepted.""" + return self.current_path + + def get_selected_speed_limit(self): + """Returns the entered speed limit value (as string or None).""" + limit_str = self.speed_limit_input.text().strip() + if not limit_str: + return None + # Optional: Add validation to ensure it's a number + try: + float(limit_str) # Check if convertible to float + return limit_str + except ValueError: + # Handle error? Or just return None? Returning None for simplicity. + print("Invalid speed limit input in dialog") + return None # Or raise an error / show message + + def get_selected_unit_index(self): + """Returns the index of the selected speed limit unit.""" + return self.speed_limit_unit.currentIndex() \ No newline at end of file diff --git a/ytsage_gui_format_table.py b/ytsage_gui_format_table.py index d5b538d..5f05fb6 100644 --- a/ytsage_gui_format_table.py +++ b/ytsage_gui_format_table.py @@ -19,6 +19,9 @@ class FormatTableMixin: self.format_table.setColumnCount(8) 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 @@ -51,25 +54,32 @@ class FormatTableMixin: self.format_table.setStyleSheet(""" QTableWidget { - background-color: #363636; - border: 2px solid #3d3d3d; + background-color: #1b2021; + border: 2px solid #1b2021; border-radius: 4px; - gridline-color: #3d3d3d; + gridline-color: #1b2021; } QTableWidget::item { padding: 5px; - border-bottom: 1px solid #3d3d3d; + border-bottom: 1px solid #1b2021; } QTableWidget::item:selected { background-color: transparent; } QHeaderView::section { - background-color: #2b2b2b; + background-color: #15181b; padding: 5px; - border: 1px solid #3d3d3d; + border: 1px solid #1b2021; font-weight: bold; color: white; } + /* Style alternating rows with more contrast */ + QTableWidget::item:alternate { + background-color: #212529; + } + QTableWidget::item { + background-color: #16191b; + } QCheckBox::indicator { width: 16px; height: 16px; @@ -77,11 +87,11 @@ class FormatTableMixin: } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #2b2b2b; + background: #15181b; } QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; + border: 2px solid #c90000; + background: #c90000; } QWidget { background-color: transparent; @@ -145,21 +155,66 @@ class FormatTableMixin: def _update_format_table(self, formats): self.format_table.setRowCount(0) self.format_checkboxes.clear() - - # Find best quality format for recommendations - best_video_size = max((f.get('filesize', 0) for f in formats if f.get('vcodec') != 'none'), default=0) + + is_playlist_mode = hasattr(self, 'is_playlist') and self.is_playlist + + # Configure columns based on mode + if is_playlist_mode: + self.format_table.setColumnCount(5) + self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Resolution', 'Notes', 'Audio']) + + # Configure column visibility and resizing for playlist mode + self.format_table.setColumnHidden(5, True) + self.format_table.setColumnHidden(6, True) + self.format_table.setColumnHidden(7, True) + + # Set specific resize modes for playlist columns + self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.format_table.setColumnWidth(0, 50) + self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + 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']) + # Ensure all columns are visible + for i in range(2, 8): + 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) + self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) + self.format_table.setColumnWidth(1, 100) + self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) + self.format_table.setColumnWidth(2, 80) + self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) + self.format_table.setColumnWidth(3, 100) + self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) + self.format_table.setColumnWidth(4, 100) + self.format_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) + self.format_table.setColumnWidth(5, 150) + 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) for f in formats: row = self.format_table.rowCount() self.format_table.insertRow(row) - # Add checkbox + # Column 0: Select Checkbox (Always shown) checkbox = QCheckBox() checkbox.format_id = str(f.get('format_id', '')) checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb)) self.format_checkboxes.append(checkbox) - - # Create a widget to center the checkbox checkbox_widget = QWidget() checkbox_widget.setStyleSheet("background-color: transparent;") checkbox_layout = QHBoxLayout(checkbox_widget) @@ -169,7 +224,7 @@ class FormatTableMixin: checkbox_layout.setSpacing(0) self.format_table.setCellWidget(row, 0, checkbox_widget) - # Quality (replacing Format ID) + # Column 1: Quality (Always shown) quality_text = self.get_quality_label(f) quality_item = QTableWidgetItem(quality_text) # Set color based on quality @@ -183,46 +238,73 @@ class FormatTableMixin: quality_item.setForeground(QColor('#ff5555')) # Red for low quality self.format_table.setItem(row, 1, quality_item) - # Extension - self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper())) - - # Resolution + # --- 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' - self.format_table.setItem(row, 3, QTableWidgetItem(resolution)) - - # File Size - filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB" - self.format_table.setItem(row, 4, QTableWidgetItem(filesize)) - - # Codec - if f.get('vcodec') == 'none': - codec = f.get('acodec', 'N/A') + 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, 0) # We don't need best_video_size for simple notes + notes_item = QTableWidgetItem(notes) + if "✨ Recommended" in notes: + notes_item.setForeground(QColor('#00ff00')) # Green for recommended + elif "💾 Storage friendly" in notes: + notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly + elif "📱 Mobile friendly" in notes: + notes_item.setForeground(QColor('#ff9900')) # Orange for mobile + self.format_table.setItem(row, 3, notes_item) else: - codec = f"{f.get('vcodec', 'N/A')}" - if f.get('acodec') != 'none': - codec += f" / {f.get('acodec', 'N/A')}" - self.format_table.setItem(row, 5, QTableWidgetItem(codec)) - - # Audio Status - needs_audio = f.get('acodec') == 'none' - audio_status = "Will merge audio" if needs_audio else "✓ Has Audio" + # Extension for normal mode (column 2) + 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") audio_item = QTableWidgetItem(audio_status) if needs_audio: audio_item.setForeground(QColor('#ffa500')) - self.format_table.setItem(row, 6, audio_item) + 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 + # 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) - # Add Notes column - notes = self.get_format_notes(f, best_video_size) - notes_item = QTableWidgetItem(notes) - if "✨ Recommended" in notes: - notes_item.setForeground(QColor('#00ff00')) # Green for recommended - elif "💾 Storage friendly" in notes: - notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly - elif "📱 Mobile friendly" in notes: - notes_item.setForeground(QColor('#ff9900')) # Orange for mobile - self.format_table.setItem(row, 7, notes_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') + else: + codec = f"{f.get('vcodec', 'N/A')}" + 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, best_video_size) + notes_item = QTableWidgetItem(notes) + if "✨ Recommended" in notes: + notes_item.setForeground(QColor('#00ff00')) # Green for recommended + elif "💾 Storage friendly" in notes: + notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly + elif "📱 Mobile friendly" in notes: + notes_item.setForeground(QColor('#ff9900')) # Orange for mobile + self.format_table.setItem(row, 7, notes_item) def handle_checkbox_click(self, clicked_checkbox): for checkbox in self.format_checkboxes: diff --git a/ytsage_gui_main.py b/ytsage_gui_main.py index 898b096..6b8001b 100644 --- a/ytsage_gui_main.py +++ b/ytsage_gui_main.py @@ -4,8 +4,9 @@ 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) -from PySide6.QtCore import Qt, Signal, QObject, QThread, QMetaObject, Q_ARG, QProcess + 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 @@ -17,10 +18,14 @@ from packaging import version import subprocess import re import yt_dlp +import markdown from ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes from ytsage_utils import check_ffmpeg, get_yt_dlp_path, load_saved_path, save_path # Import utility functions -from ytsage_gui_dialogs import LogWindow, CustomCommandDialog, FFmpegCheckDialog, YTDLPUpdateDialog, AboutDialog # Import dialogs +from ytsage_gui_dialogs import (LogWindow, CustomCommandDialog, FFmpegCheckDialog, + YTDLPUpdateDialog, AboutDialog, SubtitleSelectionDialog, + PlaylistSelectionDialog, CookieLoginDialog, + DownloadSettingsDialog) # <-- Use renamed dialog from ytsage_gui_format_table import FormatTableMixin # Import FormatTableMixin from ytsage_gui_video_info import VideoInfoMixin # Import VideoInfoMixin @@ -31,11 +36,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m if not check_ffmpeg(): self.show_ffmpeg_dialog() - self.version = "4.2.1" + self.version = "4.5.0" self.check_for_updates() self.config_file = Path.home() / '.ytsage_config.json' load_saved_path(self) - self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) + # Load custom icon + icon_path = os.path.join(os.path.dirname(__file__), 'Icon', 'icon.png') + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + else: + print(f"Warning: 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 @@ -48,60 +59,67 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m 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.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.init_ui() self.setStyleSheet(""" QMainWindow { - background-color: #2b2b2b; + background-color: #15181b; } QWidget { - background-color: #2b2b2b; + background-color: #15181b; color: #ffffff; } QLineEdit { padding: 8px; - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; - background-color: #363636; + background-color: #1b2021; color: #ffffff; } QPushButton { padding: 8px 15px; - background-color: #ff0000; /* YouTube red */ + background-color: #c90000; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:hover { - background-color: #cc0000; /* Darker red on hover */ + background-color: #a50000; } QPushButton:pressed { - background-color: #990000; /* Even darker red when pressed */ + background-color: #800000; } QTableWidget { - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; - background-color: #363636; - gridline-color: #3d3d3d; + background-color: #1b2021; + gridline-color: #1b2021; } QHeaderView::section { - background-color: #2b2b2b; + background-color: #15181b; padding: 5px; - border: 1px solid #3d3d3d; + border: 1px solid #1b2021; color: #ffffff; } QProgressBar { - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; text-align: center; color: white; } QProgressBar::chunk { - background-color: #ff0000; /* YouTube red */ + background-color: #c90000; border-radius: 2px; } QLabel { @@ -109,23 +127,23 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } /* Style for filter buttons */ QPushButton.filter-btn { - background-color: #363636; + background-color: #1b2021; padding: 5px 10px; margin: 0 5px; } QPushButton.filter-btn:checked { - background-color: #ff0000; + background-color: #c90000; } QPushButton.filter-btn:hover { background-color: #444444; } QPushButton.filter-btn:checked:hover { - background-color: #cc0000; + background-color: #a50000; } /* Modern Scrollbar Styling */ QScrollBar:vertical { border: none; - background: #2b2b2b; + background: #15181b; width: 14px; margin: 15px 0 15px 0; border-radius: 7px; @@ -140,7 +158,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::sub-line:vertical { border: none; - background: #2b2b2b; + background: #15181b; height: 15px; border-top-left-radius: 7px; border-top-right-radius: 7px; @@ -149,7 +167,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::add-line:vertical { border: none; - background: #2b2b2b; + background: #15181b; height: 15px; border-bottom-left-radius: 7px; border-bottom-right-radius: 7px; @@ -171,7 +189,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m /* Horizontal Scrollbar */ QScrollBar:horizontal { border: none; - background: #2b2b2b; + background: #15181b; height: 14px; margin: 0 15px 0 15px; border-radius: 7px; @@ -186,7 +204,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::sub-line:horizontal { border: none; - background: #2b2b2b; + background: #15181b; width: 15px; border-top-left-radius: 7px; border-bottom-left-radius: 7px; @@ -195,7 +213,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::add-line:horizontal { border: none; - background: #2b2b2b; + background: #15181b; width: 15px; border-top-right-radius: 7px; border-bottom-right-radius: 7px; @@ -217,6 +235,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m """) 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() + 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) @@ -224,8 +253,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m save_path(self, path) # Call the utility function def init_ui(self): - self.setWindowTitle('YTSage v4.2.1') - self.setMinimumSize(900, 650) + self.setWindowTitle('YTSage v4.5.0') + self.setMinimumSize(900, 750) # Main widget and layout main_widget = QWidget() @@ -237,39 +266,62 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # URL input section url_layout = QHBoxLayout() self.url_input = QLineEdit() - self.url_input.setPlaceholderText('Enter YouTube URL...') + self.url_input.setPlaceholderText("Enter YouTube video or playlist URL") + self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key - # Add Paste URL button - self.paste_btn = QPushButton('Paste URL') - self.paste_btn.clicked.connect(self.paste_url) + self.analyze_button = QPushButton("Analyze") + self.analyze_button.clicked.connect(self.analyze_url) - self.analyze_btn = QPushButton('Analyze') - self.analyze_btn.clicked.connect(self.analyze_url) + self.paste_button = QPushButton("Paste URL") + self.paste_button.clicked.connect(self.paste_url) + + url_layout.addWidget(self.url_input, 1) + url_layout.addWidget(self.paste_button) + url_layout.addWidget(self.analyze_button) - url_layout.addWidget(self.url_input) - url_layout.addWidget(self.paste_btn) - url_layout.addWidget(self.analyze_btn) layout.addLayout(url_layout) - # Video info container with smaller fixed height + # Video info container video_info_container = QWidget() - video_info_container.setFixedHeight(220) video_info_layout = QVBoxLayout(video_info_container) video_info_layout.setSpacing(5) video_info_layout.setContentsMargins(0, 0, 0, 0) - # Add media info layout + # Add media info layout (Thumbnail | Video Details) media_info_layout = self.setup_video_info_section() video_info_layout.addLayout(media_info_layout) - # Add playlist info with minimal height - self.playlist_info_label = self.setup_playlist_info_section() - self.playlist_info_label.setMaximumHeight(30) - video_info_layout.addWidget(self.playlist_info_label) - # Add video info container to main layout layout.addWidget(video_info_container) + # --- 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) + + # Add playlist selection BUTTON (initially hidden) - REPLACED QLineEdit + 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(""" + QPushButton { + padding: 6px 12px; + background-color: #1d1e22; + border: 1px solid #c90000; + border-radius: 4px; + color: white; + font-weight: normal; + text-align: left; + padding-left: 10px; + } + QPushButton:hover { + background-color: #2a2d36; + border-color: #a50000; + } + """) + layout.addWidget(self.playlist_select_btn) + # --- End Playlist Info Section --- + # Format controls section with minimal spacing layout.addSpacing(5) @@ -292,20 +344,20 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.video_button.setStyleSheet(""" QPushButton { padding: 8px 15px; - background-color: #363636; + background-color: #1d1e22; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:checked { - background-color: #ff0000; + background-color: #c90000; } QPushButton:hover { - background-color: #444444; + background-color: #2a2d36; } QPushButton:checked:hover { - background-color: #cc0000; + background-color: #a50000; } """) self.format_buttons.addButton(self.video_button) @@ -317,27 +369,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.audio_button.setStyleSheet(""" QPushButton { padding: 8px 15px; - background-color: #363636; + background-color: #1d1e22; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:checked { - background-color: #ff0000; + background-color: #c90000; } QPushButton:hover { - background-color: #444444; + background-color: #2a2d36; } QPushButton:checked:hover { - background-color: #cc0000; + background-color: #a50000; } """) self.format_buttons.addButton(self.audio_button) self.format_layout.addWidget(self.audio_button) - # Connect format buttons - self.format_buttons.buttonClicked.connect(self.handle_format_selection) + # Add Merge Subtitles checkbox (Moved here) + self.merge_subs_checkbox = QCheckBox("Merge Subtitles") + self.merge_subs_checkbox.setStyleSheet(""" + QCheckBox { + color: #ffffff; + padding: 5px; + margin-left: 20px; /* Consistent margin */ + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 9px; + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #1d1e22; + border-radius: 9px; + } + QCheckBox::indicator:checked { + border: 2px solid #c90000; + background: #c90000; + border-radius: 9px; + } + /* 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) # Add SponsorBlock checkbox self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") @@ -345,7 +425,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QCheckBox { color: #ffffff; padding: 5px; - margin-left: 20px; + margin-left: 20px; /* Consistent margin */ } QCheckBox::indicator { width: 18px; @@ -354,45 +434,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #2b2b2b; + background: #1d1e22; border-radius: 9px; } QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; + border: 2px solid #c90000; + background: #c90000; border-radius: 9px; } """) self.format_layout.addWidget(self.sponsorblock_checkbox) - # Add Save Thumbnail checkbox with same style as SponsorBlock + # Add Save Thumbnail Checkbox (Moved here) 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(""" QCheckBox { color: #ffffff; padding: 5px; margin-left: 20px; } - QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 9px; - } - QCheckBox::indicator:unchecked { - border: 2px solid #666666; - background: #2b2b2b; - border-radius: 9px; - } - QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; - border-radius: 9px; - } + QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; } + QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; } + 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.save_thumbnail_checkbox.clicked.connect(self.toggle_save_thumbnail) 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(""" + QCheckBox { + color: #ffffff; + padding: 5px; + margin-left: 20px; + } + QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; } + QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; } + 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) + self.format_layout.addStretch() + layout.addLayout(self.format_layout) # Format table with stretch @@ -406,17 +496,21 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.custom_cmd_btn = QPushButton('Custom Command') self.custom_cmd_btn.clicked.connect(self.show_custom_command) + # New button for cookie login + self.cookie_login_button = QPushButton("Login with Cookies") + self.cookie_login_button.clicked.connect(self.show_cookie_login_dialog) # Connect to a new method + self.update_ytdlp_btn = QPushButton('Update yt-dlp') self.update_ytdlp_btn.clicked.connect(self.update_ytdlp) self.about_btn = QPushButton('About') self.about_btn.clicked.connect(self.show_about_dialog) - self.path_input = QLineEdit(self.last_path) - self.path_input.setPlaceholderText('Download path...') - - self.browse_btn = QPushButton('Browse') - self.browse_btn.clicked.connect(self.browse_path) + # --- 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 + # --- End Settings Button --- self.download_btn = QPushButton('Download') self.download_btn.clicked.connect(self.start_download) @@ -432,10 +526,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Add all buttons to layout in the correct order download_layout.addWidget(self.custom_cmd_btn) + download_layout.addWidget(self.cookie_login_button) download_layout.addWidget(self.update_ytdlp_btn) download_layout.addWidget(self.about_btn) - download_layout.addWidget(self.path_input) - download_layout.addWidget(self.browse_btn) + download_layout.addWidget(self.settings_button) download_layout.addWidget(self.download_btn) download_layout.addWidget(self.pause_btn) download_layout.addWidget(self.cancel_btn) @@ -491,10 +585,6 @@ 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) - # 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 - def analyze_url(self): url = self.url_input.text().strip() if not url: @@ -507,17 +597,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m def _analyze_url_thread(self, url): try: - self.signals.update_status.emit("Analyzing (20%)... Extracting basic info") + 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}' - # Initial extraction with basic options + # Initial extraction with basic options - suppress warnings here too ydl_opts = { 'quiet': False, - 'no_warnings': False, + 'no_warnings': True, # <-- Suppress warnings for initial check 'extract_flat': True, 'force_generic_extractor': False, 'ignoreerrors': True, @@ -525,6 +615,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m 'verbose': True } + # Add cookies argument if cookie file path is set + if self.cookie_file_path: + ydl_opts['cookiefile'] = self.cookie_file_path + with yt_dlp.YoutubeDL(ydl_opts) as ydl: try: basic_info = ydl.extract_info(url, download=False) @@ -534,9 +628,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m print(f"First extraction failed: {str(e)}") raise Exception("Could not extract video information, please check your link") - self.signals.update_status.emit("Analyzing (40%)... Extracting detailed info") - # Configure options for detailed extraction - ydl_opts.update({ + self.signals.update_status.emit("Analyzing (30%)... Extracting detailed info") + # 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, @@ -545,75 +640,122 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m 'playliststart': 1, 'playlistend': 1, 'youtube_include_dash_manifest': True, - 'youtube_include_hls_manifest': True - }) + 'youtube_include_hls_manifest': True, + 'no_warnings': True # <-- Add flag here for detailed extraction + } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: + # Add cookies argument if cookie file path is set + if 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 (60%)... Processing video data") + self.signals.update_status.emit("Analyzing (45%)... Processing video data") if basic_info.get('_type') == 'playlist': self.is_playlist = True self.playlist_info = basic_info - - # Store all playlist entries - self.playlist_entries = [entry for entry in basic_info['entries'] if entry] - - # Update playlist info text - playlist_text = (f"Playlist: {basic_info.get('title', 'Unknown')} | " - f"{len(self.playlist_entries)} videos | " - f"Enter video numbers (e.g. 1-5,7,9-11)") + 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: + raise Exception("Playlist contains no valid videos.") + + # 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') + 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}") + + # 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, + self.playlist_info_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, playlist_text) ) QMetaObject.invokeMethod( - self.playlist_info_label, - "setVisible", - Qt.ConnectionType.QueuedConnection, + self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, True) ) - # Show playlist selection input + # Show playlist selection BUTTON QMetaObject.invokeMethod( - self.playlist_selection_input, - "setVisible", + 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: + else: # Single video self.is_playlist = False - self.video_info = ydl.extract_info(url, download=False) - self.playlist_info_label.setVisible(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 + + # 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) + ) # Verify we have format information if not self.video_info or 'formats' not in self.video_info: print(f"Debug - 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 (80%)... Processing formats") + self.signals.update_status.emit("Analyzing (60%)... Processing 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 (90%)... Loading thumbnail") - self.download_thumbnail(self.video_info.get('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') + + # Fallback to video thumbnail if playlist thumbnail not found or not a playlist + if not thumbnail_url: + thumbnail_url = self.video_info.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()) - # Update subtitles - self.signals.update_status.emit("Analyzing (95%)... Processing subtitles") + # --- 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.update_subtitle_list() + # 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 format table - self.signals.update_status.emit("Analyzing (98%)... Updating 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() @@ -628,6 +770,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m error_message = str(e) print(f"Error in analysis: {error_message}") self.signals.update_status.emit(f"Error: {error_message}") + # 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)) def paste_url(self): clipboard = QApplication.clipboard() @@ -637,20 +782,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m dialog = YTDLPUpdateDialog(self) dialog.exec() - def browse_path(self): - path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.last_path) - if path: - self.path_input.setText(path) - self.save_path(path) # Use save_path utility function - self.last_path = path + def show_download_settings_dialog(self): # 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 + path_changed = True + print(f"Download path updated to: {self.last_path}") + + # Update Speed Limit + new_limit_value = dialog.get_selected_speed_limit() + new_unit_index = dialog.get_selected_unit_index() + limit_changed = False + if new_limit_value != self.speed_limit_value or new_unit_index != self.speed_limit_unit_index: + self.speed_limit_value = new_limit_value + self.speed_limit_unit_index = new_unit_index + limit_changed = True + print(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]}" + self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: {limit_text}") def start_download(self): url = self.url_input.text().strip() - path = self.path_input.text().strip() + # --- Use self.last_path instead of reading from QLineEdit --- + path = self.last_path if not url or not path: - self.status_label.setText("Please enter URL and download path") + # More specific error message if path is missing + if not path: + self.status_label.setText("Please set a download path using 'Change Path'") + elif not url: + self.status_label.setText("Please enter a URL") + else: + self.status_label.setText("Please enter URL and set download path") return + # --- End Path Change --- # Get selected format format_id = self.get_selected_format() @@ -671,39 +851,60 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m resolution = parts[0].strip().lower() break - # Get subtitle selection if available - subtitle_lang = None - if hasattr(self, 'subtitle_combo') and self.subtitle_combo.currentIndex() > 0: - subtitle_lang = self.subtitle_combo.currentText() + # Get subtitle selection if available - Now get the list + selected_subs = self.selected_subtitles if hasattr(self, 'selected_subtitles') else [] - # Check if it's a playlist - is_playlist = 'playlist' in url.lower() and '/watch?' not in url + # 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 - # Get playlist selection if available - playlist_items = None - if self.is_playlist and self.playlist_selection_input.isVisible(): - playlist_items = self.playlist_selection_input.text().strip() or None + # --- 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 + rate_limit = f"{int(limit_value * 1024)}" + 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.") + return + # --- End speed limit update --- # Save thumbnail if enabled if self.save_thumbnail: - self.download_thumbnail_file(url, path) + # Consider moving thumbnail download *after* successful video download + # Or handle errors more gracefully if thumbnail download fails + try: + self.download_thumbnail_file(url, path) + except Exception as e: + print(f"Warning: 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_lang=subtitle_lang, - is_playlist=is_playlist, + 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=self.sponsorblock_checkbox.isChecked(), resolution=resolution, - playlist_items=playlist_items + playlist_items=playlist_items_to_download, # Pass the selection string + save_description=self.save_description, # Pass the new flag here + cookie_file=self.cookie_file_path, # Pass the cookie file path + rate_limit=rate_limit # Pass the calculated rate limit ) # Connect signals self.download_thread.progress_signal.connect(self.update_progress_bar) - self.download_thread.status_signal.connect(self.signals.update_status.emit) + self.download_thread.status_signal.connect(self.status_label.setText) + self.download_thread.update_details.connect(self.download_details_label.setText) self.download_thread.finished_signal.connect(self.download_finished) self.download_thread.error_signal.connect(self.download_error) self.download_thread.file_exists_signal.connect(self.file_already_exists) @@ -727,13 +928,31 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.progress_bar.setValue(100) - self.status_label.setText("Download completed!") + + # 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() + + # Video file extensions + 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']: + self.status_label.setText(f"✅ Audio download completed!") + # Subtitle file extensions + elif ext in ['.vtt', '.srt', '.ass', '.ssa']: + self.status_label.setText(f"✅ Subtitle download completed!") + # Default case + else: + self.status_label.setText("✅ Download completed!") def download_error(self, error_message): 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 def update_progress_bar(self, value): try: @@ -767,26 +986,65 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Compare versions if version.parse(latest_version) > version.parse(self.version): - self.show_update_dialog(latest_version, latest_release["html_url"]) + 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: print(f"Failed to check for updates: {str(e)}") - def show_update_dialog(self, latest_version, release_url): + def show_update_dialog(self, latest_version, release_url, changelog): # Added changelog parameter msg = QDialog(self) msg.setWindowTitle("Update Available") - msg.setMinimumWidth(400) + msg.setMinimumWidth(500) # Increased width for changelog + msg.setMinimumHeight(400) # Added min height + + # Set custom icon directly + icon_path = os.path.join(os.path.dirname(__file__), 'Icon', 'icon.png') + if os.path.exists(icon_path): + msg.setWindowIcon(QIcon(icon_path)) + else: + # Fallback to main window icon if file not found + msg.setWindowIcon(self.windowIcon()) layout = QVBoxLayout(msg) + layout.setSpacing(10) # Added spacing # Update message message_label = QLabel( - f"A new version of YTSage is available!\n\n" + f"A new version of YTSage is available!\n\n" f"Current version: {self.version}\n" f"Latest version: {latest_version}" ) message_label.setWordWrap(True) layout.addWidget(message_label) + # Changelog Section + changelog_label = QLabel("Changelog:") + layout.addWidget(changelog_label) + + changelog_text = QTextEdit() + 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']) + changelog_text.setHtml(html_changelog) + except Exception as e: + print(f"Error converting changelog markdown to HTML: {e}") + changelog_text.setPlainText(changelog) # Fallback to plain text + + changelog_text.setStyleSheet(""" + QTextEdit { + background-color: #1e1e1e; + border: 1px solid #3d3d3d; + border-radius: 4px; + color: #cccccc; + padding: 5px; + font-family: Consolas, monospace; + font-size: 11px; /* Slightly smaller font */ + } + """) + changelog_text.setMaximumHeight(200) # Limit height + layout.addWidget(changelog_text) + # Buttons button_layout = QHBoxLayout() @@ -808,7 +1066,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QLabel { color: #ffffff; font-size: 12px; - padding: 10px; + padding: 5px 0; /* Adjusted padding */ } QPushButton { padding: 8px 15px; @@ -817,6 +1075,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m border-radius: 4px; color: white; font-weight: bold; + min-width: 120px; /* Adjusted min-width */ } QPushButton:hover { background-color: #cc0000; @@ -835,7 +1094,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m def cancel_download(self): if self.current_download: self.current_download.cancelled = True - self.signals.update_status.emit("Cancelling download...") + self.status_label.setText("Cancelling download...") # Set status directly + self.download_details_label.setText("") # Clear details label on cancellation def show_ffmpeg_dialog(self): dialog = FFmpegCheckDialog(self) @@ -844,21 +1104,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m def toggle_download_controls(self, enabled=True): """Enable or disable download-related controls""" self.url_input.setEnabled(enabled) - self.analyze_btn.setEnabled(enabled) + self.analyze_button.setEnabled(enabled) self.format_table.setEnabled(enabled) # Changed from format_scroll_area to format_table - self.path_input.setEnabled(enabled) - self.browse_btn.setEnabled(enabled) self.download_btn.setEnabled(enabled) if hasattr(self, 'subtitle_combo'): self.subtitle_combo.setEnabled(enabled) self.video_button.setEnabled(enabled) self.audio_button.setEnabled(enabled) self.sponsorblock_checkbox.setEnabled(enabled) + self.merge_subs_checkbox.setEnabled(enabled) # Enable/disable merge subs checkbox + self.custom_cmd_btn.setEnabled(enabled) # Enable/disable custom command button + self.cookie_login_button.setEnabled(enabled) # Enable/disable login button + self.update_ytdlp_btn.setEnabled(enabled) # Enable/disable update 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 def handle_format_selection(self, button): # Update formats self.filter_formats() + def handle_mode_change(self): + """Enable or disable features based on video/audio mode""" + if self.audio_button.isChecked(): + # In Audio Only mode, disable video-specific features + self.sponsorblock_checkbox.setEnabled(False) + self.sponsorblock_checkbox.setChecked(False) # Uncheck when disabled + 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'): + self.subtitle_select_btn.setEnabled(True) + else: + # In Video mode, enable video-specific features + self.sponsorblock_checkbox.setEnabled(True) + # Don't auto-check - leave it to user preference + + # Enable merge_subs only if subtitles are selected + 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'): + self.subtitle_select_btn.setEnabled(True) + def show_about_dialog(self): # ADDED METHOD HERE dialog = AboutDialog(self) dialog.exec() @@ -869,7 +1163,22 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.progress_bar.setValue(100) - self.status_label.setText(f"⚠️ File already exists: {filename}") + + # Determine file type based on extension + ext = os.path.splitext(filename)[1].lower() + + # Video file extensions + 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']: + self.status_label.setText(f"⚠️ Audio file already exists") + # Subtitle file extensions + 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() @@ -904,4 +1213,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } """) - msg_box.exec() \ No newline at end of file + msg_box.exec() + + # --- Add Toggle Methods Here --- + def toggle_save_thumbnail(self, state): + print(f"Raw thumbnail state received: {state}") # Debug: Print raw state + self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state) + print(f"Save thumbnail toggled: {self.save_thumbnail}") + + def toggle_save_description(self, state): + print(f"Raw description state received: {state}") # Debug: Print raw state + self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state) + print(f"Save description toggled: {self.save_description}") + # --- End Toggle Methods --- + + def open_playlist_selection_dialog(self): + if not self.is_playlist or not self.playlist_entries: + print("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() + print(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)" + 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" + 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 --- + + def show_cookie_login_dialog(self): + dialog = CookieLoginDialog(self) + if dialog.exec(): + self.cookie_file_path = dialog.get_cookie_file_path() + if self.cookie_file_path: + print(f"Selected cookie file: {self.cookie_file_path}") # For debugging + 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 \ No newline at end of file diff --git a/ytsage_gui_video_info.py b/ytsage_gui_video_info.py index bad0511..c05f591 100644 --- a/ytsage_gui_video_info.py +++ b/ytsage_gui_video_info.py @@ -17,6 +17,7 @@ from packaging import version import subprocess import re import yt_dlp +from ytsage_gui_dialogs import SubtitleSelectionDialog class VideoInfoMixin: def setup_video_info_section(self): @@ -55,9 +56,10 @@ class VideoInfoMixin: self.views_label = QLabel() self.date_label = QLabel() self.duration_label = QLabel() + self.like_count_label = QLabel() # Style the info labels - for label in [self.channel_label, self.views_label, self.date_label, self.duration_label]: + for label in [self.channel_label, self.views_label, self.date_label, self.duration_label, self.like_count_label]: label.setStyleSheet(""" QLabel { color: #cccccc; @@ -70,115 +72,53 @@ class VideoInfoMixin: video_info_layout.addWidget(self.title_label) video_info_layout.addWidget(self.channel_label) video_info_layout.addWidget(self.views_label) + video_info_layout.addWidget(self.like_count_label) video_info_layout.addWidget(self.date_label) video_info_layout.addWidget(self.duration_label) # Add spacing before subtitle section video_info_layout.addSpacing(10) - # Create a horizontal layout for subtitle controls + # --- Subtitle Section --- subtitle_layout = QHBoxLayout() - subtitle_layout.setSpacing(5) # Reduce spacing between elements + subtitle_layout.setSpacing(10) - # Create subtitle button - self.subtitle_check = QPushButton("Download Subtitles") - self.subtitle_check.setFixedHeight(30) - self.subtitle_check.setFixedWidth(150) # Set fixed width - self.subtitle_check.setCheckable(True) - self.subtitle_check.clicked.connect(self.toggle_subtitle_controls) - self.subtitle_check.setStyleSheet(""" + # Subtitle selection button + 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(""" QPushButton { - background-color: #363636; - border: 2px solid #3d3d3d; + background-color: #1d1e22; + border: 2px solid #1d1e22; border-radius: 4px; - padding: 5px; - min-height: 30px; + padding: 5px 10px; /* Adjusted padding */ } - QPushButton:checked { - background-color: #ff0000; - border-color: #cc0000; + QPushButton:hover { background-color: #2a2d36; } + /* Optional: Style differently if subtitles ARE selected */ + QPushButton[subtitlesSelected="true"] { + border-color: #c90000; /* Indicate selection */ + } + /* Style for disabled state */ + QPushButton:disabled { + background-color: #3d3d3d; + color: #888888; + border-color: #3d3d3d; } """) - subtitle_layout.addWidget(self.subtitle_check) + self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling + subtitle_layout.addWidget(self.subtitle_select_btn) - # Create subtitle combo box - self.subtitle_combo = QComboBox() - self.subtitle_combo.setFixedHeight(30) - self.subtitle_combo.setFixedWidth(200) - self.subtitle_combo.setVisible(False) - self.subtitle_combo.setStyleSheet(""" - QComboBox { - background-color: #363636; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 5px; - min-height: 30px; - } - """) - subtitle_layout.addWidget(self.subtitle_combo) + # Label to show number of selected subtitles + self.selected_subs_label = QLabel("0 selected") + self.selected_subs_label.setStyleSheet("color: #cccccc; padding-left: 5px;") + subtitle_layout.addWidget(self.selected_subs_label) - # Create merge subtitles checkbox - self.merge_subs_checkbox = QCheckBox("Merge Subtitles") - self.merge_subs_checkbox.setFixedHeight(30) - self.merge_subs_checkbox.setVisible(False) - self.merge_subs_checkbox.setStyleSheet(""" - QCheckBox { - color: #ffffff; - padding: 5px; - margin-left: 10px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 9px; - } - QCheckBox::indicator:unchecked { - border: 2px solid #666666; - background: #2b2b2b; - border-radius: 9px; - } - QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; - border-radius: 9px; - } - """) - subtitle_layout.addWidget(self.merge_subs_checkbox) - - # Add stretch to push everything to the left - subtitle_layout.addStretch() - - # Create a second row for the filter input - filter_layout = QHBoxLayout() - filter_layout.setSpacing(5) - - # Create subtitle filter input - self.subtitle_filter_input = QLineEdit() - self.subtitle_filter_input.setFixedHeight(30) - self.subtitle_filter_input.setFixedWidth(200) - self.subtitle_filter_input.setPlaceholderText("Filter languages (e.g., en, es)") - self.subtitle_filter_input.textChanged.connect(self.filter_subtitles) - self.subtitle_filter_input.setVisible(False) - self.subtitle_filter_input.setStyleSheet(""" - QLineEdit { - background-color: #363636; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 5px; - min-height: 30px; - color: white; - } - QLineEdit:focus { - border-color: #ff0000; - } - """) - filter_layout.addWidget(self.subtitle_filter_input) - filter_layout.addStretch() - - # Add both layouts to video info + # Add the subtitle layout to the main video info layout video_info_layout.addLayout(subtitle_layout) - video_info_layout.addLayout(filter_layout) - + # --- End Subtitle Section --- + # Add stretch at the bottom video_info_layout.addStretch() @@ -193,11 +133,11 @@ class VideoInfoMixin: self.playlist_info_label.setStyleSheet(""" QLabel { font-size: 12px; - color: #ff9900; + color: #ffffff; padding: 5px 8px; margin: 0; - background-color: #2b2b2b; - border: 1px solid #3d3d3d; + background-color: #1d1e22; + border: 1px solid #c90000; border-radius: 4px; min-height: 30px; max-height: 30px; @@ -207,63 +147,109 @@ class VideoInfoMixin: return self.playlist_info_label def update_video_info(self, info): - # Format view count with commas - views = int(info.get('view_count', 0)) - formatted_views = f"{views:,}" - - # Format 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') + 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.duration_label.setText(f"Total Videos: {num_videos}") + + # Hide video-specific info + self.channel_label.setText("") + self.views_label.setText("") + self.date_label.setText("") + self.like_count_label.setText("") + self.channel_label.setVisible(False) + self.views_label.setVisible(False) + self.date_label.setVisible(False) + self.like_count_label.setVisible(False) else: - formatted_date = 'Unknown date' + # Single Video Mode: Show standard video info + # Ensure labels are visible first + self.channel_label.setVisible(True) + self.views_label.setVisible(True) + self.date_label.setVisible(True) + self.like_count_label.setVisible(True) - # Format duration - duration = info.get('duration', 0) - minutes = duration // 60 - seconds = duration % 60 - duration_str = f"{minutes}:{seconds:02d}" + # Format view count with commas + views = info.get('view_count') + formatted_views = f"{views:,}" if views is not None else 'N/A' - # Update labels - 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.date_label.setText(f"Upload date: {formatted_date}") - self.duration_label.setText(f"Duration: {duration_str}") + # Format like count with commas + 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', '') + if upload_date: + date_obj = datetime.strptime(upload_date, '%Y%m%d') + formatted_date = date_obj.strftime('%B %d, %Y') + else: + formatted_date = 'Unknown date' + + # Format duration + 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.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 toggle_subtitle_controls(self): - is_checked = self.subtitle_check.isChecked() - self.subtitle_combo.setVisible(is_checked) - self.subtitle_filter_input.setVisible(is_checked) - self.merge_subs_checkbox.setVisible(is_checked) + def open_subtitle_dialog(self): + if not hasattr(self, 'available_subtitles') or not hasattr(self, 'available_automatic_subtitles'): + print("Subtitle info not loaded yet.") + return - def update_subtitle_list(self): - self.subtitle_combo.clear() + if not hasattr(self, 'selected_subtitles'): + self.selected_subtitles = [] - if not (self.available_subtitles or self.available_automatic_subtitles): - self.subtitle_combo.addItem("No subtitles available") - return + dialog = SubtitleSelectionDialog( + self.available_subtitles, + self.available_automatic_subtitles, + self.selected_subtitles, + self # Parent for the dialog + ) - # Add subtitle options - self.subtitle_combo.addItem("Select subtitle language") + # 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 + 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 + print("Warning: Cannot find main window to access merge checkbox.") + merge_checkbox = None + else: + merge_checkbox = getattr(main_window, 'merge_subs_checkbox', None) - # Filter and add subtitles - filter_text = self.subtitle_filter_input.text().lower() - # Add manual subtitles - for lang_code, subtitle_info in self.available_subtitles.items(): - if not filter_text or filter_text in lang_code.lower(): - self.subtitle_combo.addItem(f"{lang_code} - Manual") + if dialog.exec(): # If user clicks OK + self.selected_subtitles = dialog.get_selected_subtitles() + print(f"Selected subtitles: {self.selected_subtitles}") + # Update UI to reflect selection + count = len(self.selected_subtitles) + self.selected_subs_label.setText(f"{count} selected") + self.subtitle_select_btn.setProperty("subtitlesSelected", count > 0) - # Add auto-generated subtitles - for lang_code, subtitle_info in self.available_automatic_subtitles.items(): - if not filter_text or filter_text in lang_code.lower(): - self.subtitle_combo.addItem(f"{lang_code} - Auto-generated") + # 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() + # 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) + else: + print("Warning: merge_subs_checkbox not found on parent window.") - def filter_subtitles(self): - self.subtitle_filter = self.subtitle_filter_input.text() - self.update_subtitle_list() + # Re-apply stylesheet to update button border if property changed + self.subtitle_select_btn.style().unpolish(self.subtitle_select_btn) + self.subtitle_select_btn.style().polish(self.subtitle_select_btn) + # No else needed for cancel, state remains unchanged def download_thumbnail(self, url): try: @@ -285,10 +271,6 @@ class VideoInfoMixin: except Exception as e: print(f"Error loading thumbnail: {str(e)}") - def toggle_save_thumbnail(self): - self.save_thumbnail = self.save_thumbnail_checkbox.isChecked() - print(f"Save thumbnail toggled: {self.save_thumbnail}") # Debug print - def download_thumbnail_file(self, video_url, path): if not self.save_thumbnail: return False diff --git a/ytsage_style.py b/ytsage_style.py index 84c3b8d..6ad7f38 100644 --- a/ytsage_style.py +++ b/ytsage_style.py @@ -1,24 +1,24 @@ MAIN_STYLE = """ QMainWindow { - background-color: #2b2b2b; + background-color: #15181b; } QWidget { - background-color: #2b2b2b; + background-color: #15181b; color: #ffffff; font-size: 12px; } QLineEdit { padding: 8px; - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; - background-color: #363636; + background-color: #1b2021; color: #ffffff; - selection-background-color: #ff0000; + selection-background-color: #c90000; selection-color: #ffffff; } QPushButton { padding: 8px 15px; - background-color: #ff0000; /* YouTube red */ + background-color: #c90000; border: none; border-radius: 4px; color: white; @@ -26,33 +26,33 @@ QPushButton { min-height: 20px; } QPushButton:hover { - background-color: #cc0000; /* Darker red on hover */ + background-color: #a50000; } QPushButton:pressed { - background-color: #990000; /* Even darker red when pressed */ + background-color: #800000; } QPushButton:disabled { - background-color: #666666; /* Gray when disabled */ + background-color: #666666; color: #999999; } QTableWidget { - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; - background-color: #363636; - gridline-color: #3d3d3d; - selection-background-color: #ff0000; + background-color: #1b2021; + gridline-color: #1b2021; + selection-background-color: #c90000; selection-color: #ffffff; } QHeaderView::section { - background-color: #2b2b2b; + background-color: #15181b; padding: 5px; - border: 1px solid #3d3d3d; + border: 1px solid #1b2021; color: #ffffff; font-weight: bold; } QScrollBar:vertical { border: none; - background-color: #2b2b2b; + background-color: #15181b; width: 12px; margin: 0px; } @@ -62,27 +62,27 @@ QScrollBar::handle:vertical { border-radius: 6px; } QScrollBar::handle:vertical:hover { - background-color: #ff0000; + background-color: #c90000; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } QProgressBar { - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; text-align: center; color: white; - background-color: #363636; + background-color: #1b2021; } QProgressBar::chunk { - background-color: #ff0000; + background-color: #c90000; border-radius: 2px; } QComboBox { padding: 5px; - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; - background-color: #363636; + background-color: #1b2021; color: #ffffff; min-height: 20px; } @@ -106,25 +106,25 @@ QCheckBox::indicator { } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #2b2b2b; + background: #15181b; } QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; + border: 2px solid #c90000; + background: #c90000; } QLabel { color: #ffffff; } QTextEdit, QPlainTextEdit { - background-color: #363636; + background-color: #1b2021; color: #ffffff; - border: 2px solid #3d3d3d; + border: 2px solid #1b2021; border-radius: 4px; - selection-background-color: #ff0000; + selection-background-color: #c90000; selection-color: #ffffff; } QMessageBox { - background-color: #2b2b2b; + background-color: #15181b; } QMessageBox QLabel { color: #ffffff; diff --git a/ytsage_utils.py b/ytsage_utils.py index 1666c83..698625d 100644 --- a/ytsage_utils.py +++ b/ytsage_utils.py @@ -4,6 +4,7 @@ import json from pathlib import Path import subprocess import tempfile +import shutil from ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path def check_ffmpeg(): @@ -49,45 +50,24 @@ def check_ffmpeg(): return False def get_yt_dlp_path(): - """Get the appropriate yt-dlp path with enhanced error handling.""" + """Get the yt-dlp command or path, prioritizing the system PATH.""" try: - if getattr(sys, 'frozen', False): - if sys.platform == 'darwin': - # For macOS .app bundle - if 'Contents/MacOS' in sys.executable: - base_path = os.path.dirname(sys.executable) - else: - # Fallback to user's home directory for macOS - base_path = os.path.expanduser('~/Library/Application Support/YTSage') - elif sys.platform == 'win32': - # For Windows executable - app_data = os.getenv('APPDATA') - base_path = os.path.join(app_data, 'YTSage') if app_data else os.path.dirname(sys.executable) - else: - # For Linux AppImage or binary - if 'APPIMAGE' in os.environ: - xdg_data = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) - base_path = os.path.join(xdg_data, 'YTSage') - else: - base_path = os.path.dirname(sys.executable) - - # Create directory if it doesn't exist - try: - os.makedirs(base_path, exist_ok=True) - except Exception as e: - print(f"Error creating directory: {e}") - base_path = os.path.dirname(sys.executable) - - return os.path.join(base_path, 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp') + # Use shutil.which to find yt-dlp in the system's PATH + yt_dlp_executable = shutil.which('yt-dlp') + + if yt_dlp_executable: + print(f"Found yt-dlp executable in PATH: {yt_dlp_executable}") + return yt_dlp_executable else: - # For development/script mode - return os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp') - + # If not found in PATH, assume 'yt-dlp' is the command name + print("yt-dlp not found in PATH. Will attempt to use 'yt-dlp' as the command.") + return 'yt-dlp' + except Exception as e: - print(f"Error determining yt-dlp path: {e}") - # Fallback to current directory - return os.path.join(os.getcwd(), 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp') + print(f"Error finding yt-dlp path: {e}") + # Fallback to the command name on any error + print("An error occurred during yt-dlp path detection. Falling back to command 'yt-dlp'.") + return 'yt-dlp' def load_saved_path(main_window_instance): """Load saved download path with enhanced error handling."""