diff --git a/Icon/icon.png b/Icon/icon.png deleted file mode 100644 index 8b32d05..0000000 Binary files a/Icon/icon.png and /dev/null differ diff --git a/README.md b/README.md index 0fd828b..6a3e526 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,12 @@ Download videos in any quality, extract audio, fetch subtitles, and more.
-| Core Features | Advanced Features | Extra Features | -|-----------------------------------|-----------------------------------------|------------------------------------| -| 🎥 Format Table | 🚫 SponsorBlock Integration | 💾 Save Download Path | -| 🎵 Audio Extraction | 📝 Multi-Subtitle Select & Merge | 🔄 Auto-Updates | -| ✨ Simple UI | 💾 Save Description | 🛠️ FFmpeg/yt-dlp Detection | -| 📋 Playlist Support | 🖼️ Save thumbnail | ⚙️ Custom Commands | -| 🖼️ Playlist Selector | 🚀 Speed Limiter | 🍪 Login with Cookies | +| Core Features | Advanced Features | Extra Features | +|--------------|-------------------|----------------| +| 🎥 Smart Video Quality | 🚫 SponsorBlock Integration | 💾 Save Download Path | +| 🎵 Audio Extraction | 📝 Subtitle Support & Filtering | 🔄 Auto-Updates | +| 📊 Real-time Progress | ⚙️ Custom Commands | 🛠️ FFmpeg Tools | +| 📋 Playlist Support | 🖼️ Save thumbnail | ⚠️ Error Handling |
@@ -41,7 +40,7 @@ Download videos in any quality, extract audio, fetch subtitles, and more. ### Quick Install (Recommended) ```bash -pip install ytsage +pip install YTSage ``` ```bash # Run the application @@ -53,12 +52,10 @@ ytsage ### Pre-built Executables - 🪟 Windows: `YTSage.exe` -- 🪟 Windows: `YTSage-ffmpeg.exe` (Includes FFmpeg) -- 🐧 Linux: `YTSage-x86_64.AppImage` -- 🍎 macOS: `YTSage-macOS-app.zip` - 🍎 macOS: `YTSage.dmg` +- 🐧 Linux: `YTSage.AppImage` -### Manual Installation from Source +### Manual Installation ```bash # Clone repository git clone https://github.com/oop7/YTSage.git @@ -91,8 +88,8 @@ python main.py Subtitle Options merged with Remove Sponsor Segments - Audio Format - Subtitle Options + Audio Format Selection with Save Thumbnail + Subtitle Options merged with Remove Sponsor Segments @@ -110,10 +107,9 @@ python main.py - `Audio Only` for audio extraction 5. **Choose Options:** - Enable subtitles & select language - - Enable subtitle merge + - Enable subtitle embedding - Save thumbnail - Remove sponsor segments - - Save description 6. **Select Output Directory** 7. **Click "Download"** @@ -124,9 +120,8 @@ python main.py 1. **Paste Playlist URL** 2. **Click "Analyze"** -3. **Select videos from the playlist selector (optional, defaults to all)** -4. **Choose desired format/quality** -5. **Click "Download"** +3. **Select Best Quality** +4. **Click "Download"** > 💡 The application automatically handles the download queue @@ -137,18 +132,9 @@ python main.py - **Quality Selection:** Choose the highest resolution for best quality - **Subtitle Options:** Filter languages and embed into video +- **SponsorBlock:** Automatically skip promotional content - **Custom Commands:** Access advanced yt-dlp features -- **Save Description:** Save the description of the video -- **Save Thumbnail:** Save the thumbnail of the video -- **Remove Sponsor Segments:** Remove sponsor segments from the video -- **Speed Limiter:** Limit the download speed -- **Login with Cookies:** Login with cookies -- **Save Download Path:** Save the download path -- **Update yt-dlp:** Update yt-dlp -- **FFmpeg/yt-dlp Detection:** Automatically detect FFmpeg/yt-dlp -- **Custom Commands:** Access advanced yt-dlp features - - +- **Output Directory:** Ensure sufficient storage space @@ -162,7 +148,6 @@ Pillow requests FFmpeg packaging -markdown ``` ## 👥 Contributing @@ -206,9 +191,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file | [PySide6](https://wiki.qt.io/Qt_for_Python) | GUI Framework | | [FFmpeg](https://ffmpeg.org/) | Media Processing | | [Pillow](https://python-pillow.org/) | Image Processing | -| [requests](https://requests.readthedocs.io/) | HTTP Requests | -| [packaging](https://packaging.python.org/) | Packaging | -| [markdown](https://python-markdown.github.io/) | Markdown Processing | diff --git a/requirements.txt b/requirements.txt index f5fd815..b195626 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,4 @@ PySide6 yt-dlp requests pillow -packaging -markdown \ No newline at end of file +packaging \ No newline at end of file diff --git a/ytsage_downloader.py b/ytsage_downloader.py index 5b33505..e9b88a7 100644 --- a/ytsage_downloader.py +++ b/ytsage_downloader.py @@ -1,10 +1,8 @@ -from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer +from PySide6.QtCore import QThread, Signal, QObject 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): @@ -18,32 +16,20 @@ 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_langs=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None, save_description=False, cookie_file=None, rate_limit=None): + def __init__(self, url, path, format_id, subtitle_lang=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None): super().__init__() self.url = url self.path = path self.format_id = format_id - self.subtitle_langs = subtitle_langs if subtitle_langs else [] + self.subtitle_lang = subtitle_lang 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""" @@ -60,99 +46,14 @@ 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, 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: + # Use yt-dlp to get the filename without downloading + with yt_dlp.YoutubeDL({'quiet': True, 'skip_download': True}) 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 @@ -205,147 +106,10 @@ 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 using original method + # First check if file already exists existing_file = self.check_file_exists() if existing_file: print(f"DEBUG: File exists, emitting signal: {existing_file}") @@ -353,305 +117,216 @@ class DownloadThread(QThread): return print("DEBUG: No existing file found, proceeding with download") - - # Get initial list of subtitle files to compare later - self.initial_subtitle_files = set() - if self.merge_subs: + 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: - # 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") + 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: - 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: - # 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") + 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, + }) - # 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}") + 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)}") + 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 + self.error_signal.emit(f"Critical error: {str(e)}") \ No newline at end of file diff --git a/ytsage_gui_dialogs.py b/ytsage_gui_dialogs.py index 592b7cc..5c1a69c 100644 --- a/ytsage_gui_dialogs.py +++ b/ytsage_gui_dialogs.py @@ -4,8 +4,7 @@ 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, QListWidget, - QListWidgetItem, QDialogButtonBox, QScrollArea, QGroupBox) + QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup) from PySide6.QtCore import Qt, Signal, QObject, QThread, QProcess from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap import requests @@ -76,9 +75,9 @@ class CustomCommandDialog(QDialog): self.command_input.setPlaceholderText("Enter yt-dlp arguments...") self.command_input.setStyleSheet(""" QPlainTextEdit { - background-color: #1d1e22; + background-color: #363636; color: #ffffff; - border: 2px solid #1d1e22; + border: 2px solid #3d3d3d; border-radius: 4px; padding: 8px; font-family: Consolas, monospace; @@ -97,17 +96,17 @@ class CustomCommandDialog(QDialog): QCheckBox::indicator { width: 18px; height: 18px; - border-radius: 9px; + border-radius: 9px; /* Make indicator round */ } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #1d1e22; - border-radius: 9px; + background: #2b2b2b; + border-radius: 9px; /* Make indicator round */ } QCheckBox::indicator:checked { - border: 2px solid #c90000; - background: #c90000; - border-radius: 9px; + border: 2px solid #ff0000; + background: #ff0000; + border-radius: 9px; /* Make indicator round */ } """) layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox) @@ -130,9 +129,9 @@ class CustomCommandDialog(QDialog): self.log_output.setReadOnly(True) self.log_output.setStyleSheet(""" QTextEdit { - background-color: #1d1e22; + background-color: #2b2b2b; color: #ffffff; - border: 2px solid #1d1e22; + border: 2px solid #3d3d3d; border-radius: 4px; padding: 8px; font-family: Consolas, monospace; @@ -143,18 +142,18 @@ class CustomCommandDialog(QDialog): self.setStyleSheet(""" QDialog { - background-color: #15181b; + background-color: #2b2b2b; } QPushButton { padding: 8px 15px; - background-color: #c90000; + background-color: #ff0000; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:hover { - background-color: #a50000; + background-color: #cc0000; } """) @@ -399,130 +398,6 @@ 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) @@ -557,7 +432,7 @@ class YTDLPUpdateDialog(QDialog): # Style self.setStyleSheet(""" QDialog { - background-color: #15181b; + background-color: #2b2b2b; } QLabel { color: #ffffff; @@ -566,7 +441,7 @@ class YTDLPUpdateDialog(QDialog): } QPushButton { padding: 8px 15px; - background-color: #c90000; + background-color: #ff0000; border: none; border-radius: 4px; color: white; @@ -577,723 +452,222 @@ class YTDLPUpdateDialog(QDialog): background-color: #666666; } QPushButton:hover { - background-color: #a50000; + background-color: #cc0000; } QProgressBar { - border: 2px solid #1d1e22; + border: 2px solid #3d3d3d; border-radius: 4px; text-align: center; color: white; - background-color: #1d1e22; + background-color: #363636; height: 25px; } QProgressBar::chunk { - background-color: #c90000; + background-color: #ff0000; border-radius: 2px; } """) - # Start version check in background + # Start version check 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: - # Compare versions - current_ver = version.parse(current_version) - latest_ver = version.parse(latest_version) + # Get current version using yt-dlp command + import subprocess + import sys + import os - 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) + # 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: - 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) - + 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) + def perform_update(self): - 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) + 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() class AboutDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) - 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) + self.setWindowTitle('About YTSage') + self.setMinimumWidth(500) - # Title and Version - title_label = QLabel("

YTSage

") + layout = QVBoxLayout(self) + + # App title and version + title_label = QLabel("YTSage") + title_label.setStyleSheet(""" + font-size: 24px; + font-weight: bold; + color: #ff0000; + padding: 10px; + """) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title_label) - version_label = QLabel(f"Version: {getattr(self.parent, 'version', 'N/A')}") + version_label = QLabel(f"Version {parent.version}") version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - version_label.setStyleSheet("color: #cccccc;") layout.addWidget(version_label) # 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) + 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) - # Separator - separator = QWidget() - separator.setFixedHeight(1) - separator.setStyleSheet("background-color: #1d1e22;") - layout.addWidget(separator) + # 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) - # Information Section - info_layout = QVBoxLayout() - info_layout.setSpacing(8) + # Credits + credits_label = QLabel( + "Powered by:\n" + "• yt-dlp\n" + "• PySide6\n" + "• FFmpeg" + ) + credits_label.setStyleSheet("padding: 10px;") + layout.addWidget(credits_label) - # Author - author_label = QLabel("Created by: oop7") - author_label.setOpenExternalLinks(True) - info_layout.addWidget(author_label) + # GitHub link + github_btn = QPushButton("Visit GitHub Repository") + github_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/YTSage')) + layout.addWidget(github_btn) - # GitHub Repo - repo_label = QLabel("GitHub: github.com/oop7/YTSage") - repo_label.setOpenExternalLinks(True) - info_layout.addWidget(repo_label) + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close) + layout.addWidget(close_btn) - # 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 + # Style the dialog self.setStyleSheet(""" - QDialog { background-color: #15181b; color: #ffffff; } - QLabel { color: #cccccc; } + QDialog { + background-color: #2b2b2b; + } + QLabel { + color: #ffffff; + font-size: 12px; + } QPushButton { - padding: 8px 25px; - background-color: #c90000; + padding: 8px 15px; + background-color: #ff0000; border: none; border-radius: 4px; color: white; font-weight: bold; } - 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; - } - 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; + background-color: #cc0000; } - 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 + """) \ No newline at end of file diff --git a/ytsage_gui_format_table.py b/ytsage_gui_format_table.py index 5f05fb6..d5b538d 100644 --- a/ytsage_gui_format_table.py +++ b/ytsage_gui_format_table.py @@ -19,9 +19,6 @@ 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 @@ -54,32 +51,25 @@ class FormatTableMixin: self.format_table.setStyleSheet(""" QTableWidget { - background-color: #1b2021; - border: 2px solid #1b2021; + background-color: #363636; + border: 2px solid #3d3d3d; border-radius: 4px; - gridline-color: #1b2021; + gridline-color: #3d3d3d; } QTableWidget::item { padding: 5px; - border-bottom: 1px solid #1b2021; + border-bottom: 1px solid #3d3d3d; } QTableWidget::item:selected { background-color: transparent; } QHeaderView::section { - background-color: #15181b; + background-color: #2b2b2b; padding: 5px; - border: 1px solid #1b2021; + border: 1px solid #3d3d3d; 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; @@ -87,11 +77,11 @@ class FormatTableMixin: } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #15181b; + background: #2b2b2b; } QCheckBox::indicator:checked { - border: 2px solid #c90000; - background: #c90000; + border: 2px solid #ff0000; + background: #ff0000; } QWidget { background-color: transparent; @@ -155,66 +145,21 @@ class FormatTableMixin: def _update_format_table(self, formats): self.format_table.setRowCount(0) self.format_checkboxes.clear() - - 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) + + # 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) for f in formats: row = self.format_table.rowCount() self.format_table.insertRow(row) - # Column 0: Select Checkbox (Always shown) + # Add checkbox 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) @@ -224,7 +169,7 @@ class FormatTableMixin: checkbox_layout.setSpacing(0) self.format_table.setCellWidget(row, 0, checkbox_widget) - # Column 1: Quality (Always shown) + # Quality (replacing Format ID) quality_text = self.get_quality_label(f) quality_item = QTableWidgetItem(quality_text) # Set color based on quality @@ -238,73 +183,46 @@ class FormatTableMixin: quality_item.setForeground(QColor('#ff5555')) # Red for low quality self.format_table.setItem(row, 1, quality_item) - # --- Populate columns common to both modes (Moved outside the 'if not is_playlist_mode' block) --- - - # Column 2: Resolution (Always shown) + # Extension + self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper())) + + # Resolution resolution = f.get('resolution', 'N/A') if f.get('vcodec') == 'none': resolution = 'Audio only' - self.format_table.setItem(row, 2, QTableWidgetItem(resolution)) - - # Column 3: Notes for playlist mode, Extension for normal mode - if is_playlist_mode: - # Get notes for playlist mode - notes = self.get_format_notes(f, 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) + 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') else: - # 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") + 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" audio_item = QTableWidgetItem(audio_status) if needs_audio: audio_item.setForeground(QColor('#ffa500')) - elif audio_status == "Audio Only": - audio_item.setForeground(QColor('#cccccc')) # Neutral color for audio only - else: # Has Audio (Video+Audio) - audio_item.setForeground(QColor('#00cc00')) # Green for included audio - # 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) + self.format_table.setItem(row, 6, audio_item) - - # --- Populate columns only shown in non-playlist mode --- - if not is_playlist_mode: - # Column 3: Resolution - self.format_table.setItem(row, 3, QTableWidgetItem(resolution)) - - # Column 4: File Size - filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB" - self.format_table.setItem(row, 4, QTableWidgetItem(filesize)) - - # Column 5: Codec - if f.get('vcodec') == 'none': - codec = f.get('acodec', 'N/A') - 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) + # 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) 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 6b8001b..898b096 100644 --- a/ytsage_gui_main.py +++ b/ytsage_gui_main.py @@ -4,9 +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, QMessageBox, QListWidget, - QListWidgetItem, QDialogButtonBox, QScrollArea) -from PySide6.QtCore import Qt, Signal, QObject, QThread, QMetaObject, Q_ARG, QProcess, Slot + QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QMessageBox) +from PySide6.QtCore import Qt, Signal, QObject, QThread, QMetaObject, Q_ARG, QProcess from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap import requests from io import BytesIO @@ -18,14 +17,10 @@ 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, SubtitleSelectionDialog, - PlaylistSelectionDialog, CookieLoginDialog, - DownloadSettingsDialog) # <-- Use renamed dialog +from ytsage_gui_dialogs import LogWindow, CustomCommandDialog, FFmpegCheckDialog, YTDLPUpdateDialog, AboutDialog # Import dialogs from ytsage_gui_format_table import FormatTableMixin # Import FormatTableMixin from ytsage_gui_video_info import VideoInfoMixin # Import VideoInfoMixin @@ -36,17 +31,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m if not check_ffmpeg(): self.show_ffmpeg_dialog() - self.version = "4.5.0" + self.version = "4.2.1" self.check_for_updates() self.config_file = Path.home() / '.ytsage_config.json' load_saved_path(self) - # 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.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) self.signals = SignalManager() self.download_paused = False self.current_download = None @@ -59,67 +48,60 @@ 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: #15181b; + background-color: #2b2b2b; } QWidget { - background-color: #15181b; + background-color: #2b2b2b; color: #ffffff; } QLineEdit { padding: 8px; - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; - background-color: #1b2021; + background-color: #363636; color: #ffffff; } QPushButton { padding: 8px 15px; - background-color: #c90000; + background-color: #ff0000; /* YouTube red */ border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:hover { - background-color: #a50000; + background-color: #cc0000; /* Darker red on hover */ } QPushButton:pressed { - background-color: #800000; + background-color: #990000; /* Even darker red when pressed */ } QTableWidget { - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; - background-color: #1b2021; - gridline-color: #1b2021; + background-color: #363636; + gridline-color: #3d3d3d; } QHeaderView::section { - background-color: #15181b; + background-color: #2b2b2b; padding: 5px; - border: 1px solid #1b2021; + border: 1px solid #3d3d3d; color: #ffffff; } QProgressBar { - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; text-align: center; color: white; } QProgressBar::chunk { - background-color: #c90000; + background-color: #ff0000; /* YouTube red */ border-radius: 2px; } QLabel { @@ -127,23 +109,23 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } /* Style for filter buttons */ QPushButton.filter-btn { - background-color: #1b2021; + background-color: #363636; padding: 5px 10px; margin: 0 5px; } QPushButton.filter-btn:checked { - background-color: #c90000; + background-color: #ff0000; } QPushButton.filter-btn:hover { background-color: #444444; } QPushButton.filter-btn:checked:hover { - background-color: #a50000; + background-color: #cc0000; } /* Modern Scrollbar Styling */ QScrollBar:vertical { border: none; - background: #15181b; + background: #2b2b2b; width: 14px; margin: 15px 0 15px 0; border-radius: 7px; @@ -158,7 +140,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::sub-line:vertical { border: none; - background: #15181b; + background: #2b2b2b; height: 15px; border-top-left-radius: 7px; border-top-right-radius: 7px; @@ -167,7 +149,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::add-line:vertical { border: none; - background: #15181b; + background: #2b2b2b; height: 15px; border-bottom-left-radius: 7px; border-bottom-right-radius: 7px; @@ -189,7 +171,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m /* Horizontal Scrollbar */ QScrollBar:horizontal { border: none; - background: #15181b; + background: #2b2b2b; height: 14px; margin: 0 15px 0 15px; border-radius: 7px; @@ -204,7 +186,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::sub-line:horizontal { border: none; - background: #15181b; + background: #2b2b2b; width: 15px; border-top-left-radius: 7px; border-bottom-left-radius: 7px; @@ -213,7 +195,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QScrollBar::add-line:horizontal { border: none; - background: #15181b; + background: #2b2b2b; width: 15px; border-top-right-radius: 7px; border-bottom-right-radius: 7px; @@ -235,17 +217,6 @@ 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) @@ -253,8 +224,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.5.0') - self.setMinimumSize(900, 750) + self.setWindowTitle('YTSage v4.2.1') + self.setMinimumSize(900, 650) # Main widget and layout main_widget = QWidget() @@ -266,62 +237,39 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # URL input section url_layout = QHBoxLayout() self.url_input = QLineEdit() - self.url_input.setPlaceholderText("Enter YouTube video or playlist URL") - self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key + self.url_input.setPlaceholderText('Enter YouTube URL...') - self.analyze_button = QPushButton("Analyze") - self.analyze_button.clicked.connect(self.analyze_url) + # Add Paste URL button + self.paste_btn = QPushButton('Paste URL') + self.paste_btn.clicked.connect(self.paste_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) + self.analyze_btn = QPushButton('Analyze') + self.analyze_btn.clicked.connect(self.analyze_url) + 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 + # Video info container with smaller fixed height 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 (Thumbnail | Video Details) + # Add media info layout 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) @@ -344,20 +292,20 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.video_button.setStyleSheet(""" QPushButton { padding: 8px 15px; - background-color: #1d1e22; + background-color: #363636; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:checked { - background-color: #c90000; + background-color: #ff0000; } QPushButton:hover { - background-color: #2a2d36; + background-color: #444444; } QPushButton:checked:hover { - background-color: #a50000; + background-color: #cc0000; } """) self.format_buttons.addButton(self.video_button) @@ -369,55 +317,27 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.audio_button.setStyleSheet(""" QPushButton { padding: 8px 15px; - background-color: #1d1e22; + background-color: #363636; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:checked { - background-color: #c90000; + background-color: #ff0000; } QPushButton:hover { - background-color: #2a2d36; + background-color: #444444; } QPushButton:checked:hover { - background-color: #a50000; + background-color: #cc0000; } """) self.format_buttons.addButton(self.audio_button) self.format_layout.addWidget(self.audio_button) - # Add Merge Subtitles checkbox (Moved here) - self.merge_subs_checkbox = QCheckBox("Merge Subtitles") - self.merge_subs_checkbox.setStyleSheet(""" - 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) + # Connect format buttons + self.format_buttons.buttonClicked.connect(self.handle_format_selection) # Add SponsorBlock checkbox self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") @@ -425,7 +345,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QCheckBox { color: #ffffff; padding: 5px; - margin-left: 20px; /* Consistent margin */ + margin-left: 20px; } QCheckBox::indicator { width: 18px; @@ -434,55 +354,45 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #1d1e22; + background: #2b2b2b; border-radius: 9px; } QCheckBox::indicator:checked { - border: 2px solid #c90000; - background: #c90000; + border: 2px solid #ff0000; + background: #ff0000; border-radius: 9px; } """) self.format_layout.addWidget(self.sponsorblock_checkbox) - # Add Save Thumbnail Checkbox (Moved here) + # Add Save Thumbnail checkbox with same style as SponsorBlock 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: #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; } + 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; + } """) + 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 @@ -496,21 +406,17 @@ 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) - # --- 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.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) self.download_btn = QPushButton('Download') self.download_btn.clicked.connect(self.start_download) @@ -526,10 +432,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.settings_button) + download_layout.addWidget(self.path_input) + download_layout.addWidget(self.browse_btn) download_layout.addWidget(self.download_btn) download_layout.addWidget(self.pause_btn) download_layout.addWidget(self.cancel_btn) @@ -585,6 +491,10 @@ 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: @@ -597,17 +507,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m def _analyze_url_thread(self, url): try: - self.signals.update_status.emit("Analyzing (15%)... Extracting basic info") + self.signals.update_status.emit("Analyzing (20%)... 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 - suppress warnings here too + # Initial extraction with basic options ydl_opts = { 'quiet': False, - 'no_warnings': True, # <-- Suppress warnings for initial check + 'no_warnings': False, 'extract_flat': True, 'force_generic_extractor': False, 'ignoreerrors': True, @@ -615,10 +525,6 @@ 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) @@ -628,10 +534,9 @@ 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 (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 = { + self.signals.update_status.emit("Analyzing (40%)... Extracting detailed info") + # Configure options for detailed extraction + ydl_opts.update({ 'extract_flat': False, 'format': None, 'writesubtitles': True, @@ -640,122 +545,75 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m 'playliststart': 1, 'playlistend': 1, 'youtube_include_dash_manifest': True, - 'youtube_include_hls_manifest': True, - 'no_warnings': True # <-- Add flag here for detailed extraction - } + 'youtube_include_hls_manifest': True + }) - # 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: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: try: - self.signals.update_status.emit("Analyzing (45%)... Processing video data") + self.signals.update_status.emit("Analyzing (60%)... Processing video data") if basic_info.get('_type') == 'playlist': self.is_playlist = True self.playlist_info = basic_info - self.selected_playlist_items = None # Reset selection for new playlist - self.playlist_entries = [entry for entry in basic_info.get('entries', []) if entry] # Store entries - - # 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 + + # 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)") 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 BUTTON + # Show playlist selection input QMetaObject.invokeMethod( - self, # Target object is the YTSageApp instance - 'update_playlist_button_text', # Name of the slot + self.playlist_selection_input, + "setVisible", Qt.ConnectionType.QueuedConnection, - Q_ARG(str, "Select Videos... (All selected)") # Argument for the slot - ) - QMetaObject.invokeMethod( - self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, True) ) - else: # Single video + else: self.is_playlist = False - # Use ydl_detail instance here too for consistency - self.video_info = ydl_detail.extract_info(url, download=False) - self.playlist_entries = [] # Clear entries - self.selected_playlist_items = None # Clear selection - - # 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) - ) + self.video_info = ydl.extract_info(url, download=False) + self.playlist_info_label.setVisible(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 (60%)... Processing formats") + self.signals.update_status.emit("Analyzing (80%)... 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 (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) + self.signals.update_status.emit("Analyzing (90%)... Loading thumbnail") + self.download_thumbnail(self.video_info.get('thumbnail')) # Save thumbnail if enabled - use the stored VIDEO URL if self.save_thumbnail: self.download_thumbnail_file(self.video_url, self.path_input.text()) - # --- Subtitle Handling --- - self.signals.update_status.emit("Analyzing (85%)... Processing subtitles") - # Clear previous selections when analyzing a new video - self.selected_subtitles = [] + # Update subtitles + self.signals.update_status.emit("Analyzing (95%)... Processing subtitles") self.available_subtitles = self.video_info.get('subtitles', {}) self.available_automatic_subtitles = self.video_info.get('automatic_captions', {}) - # Update the UI elements related to subtitle selection state - QMetaObject.invokeMethod(self.selected_subs_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "0 selected")) - # QMetaObject.invokeMethod(self.subtitle_select_btn, "setProperty", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "subtitlesSelected"), Q_ARG(bool, False)) # <-- COMMENT OUT THIS LINE - # REMOVE the merge_subs_checkbox update call from here - # QMetaObject.invokeMethod(self.merge_subs_checkbox, "setEnabled", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False)) - + self.update_subtitle_list() # Update format table - self.signals.update_status.emit("Analyzing (95%)... Updating format table") + self.signals.update_status.emit("Analyzing (98%)... Updating format table") self.video_button.setChecked(True) self.audio_button.setChecked(False) self.filter_formats() @@ -770,9 +628,6 @@ 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() @@ -782,55 +637,20 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m dialog = YTDLPUpdateDialog(self) dialog.exec() - 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 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 start_download(self): url = self.url_input.text().strip() - # --- Use self.last_path instead of reading from QLineEdit --- - path = self.last_path + path = self.path_input.text().strip() if not url or not path: - # More specific error message if path is missing - if not path: - self.status_label.setText("Please set a download path using 'Change Path'") - elif not url: - self.status_label.setText("Please enter a URL") - else: - self.status_label.setText("Please enter URL and set download path") + self.status_label.setText("Please enter URL and download path") return - # --- End Path Change --- # Get selected format format_id = self.get_selected_format() @@ -851,60 +671,39 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m resolution = parts[0].strip().lower() break - # Get subtitle selection if available - Now get the list - selected_subs = self.selected_subtitles if hasattr(self, 'selected_subtitles') else [] + # 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 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 + # Check if it's a playlist + is_playlist = 'playlist' in url.lower() and '/watch?' not in url - # --- 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 --- + # 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 # Save thumbnail if enabled if self.save_thumbnail: - # 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 - + self.download_thumbnail_file(url, path) # Create download thread with resolution in output template self.download_thread = DownloadThread( url=url, path=path, format_id=format_id, - subtitle_langs=selected_subs, # Pass the list of selected subs - is_playlist=self.is_playlist, # Use the flag directly + subtitle_lang=subtitle_lang, + is_playlist=is_playlist, merge_subs=self.merge_subs_checkbox.isChecked(), enable_sponsorblock=self.sponsorblock_checkbox.isChecked(), resolution=resolution, - 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 + playlist_items=playlist_items ) # Connect signals self.download_thread.progress_signal.connect(self.update_progress_bar) - self.download_thread.status_signal.connect(self.status_label.setText) - self.download_thread.update_details.connect(self.download_details_label.setText) + self.download_thread.status_signal.connect(self.signals.update_status.emit) 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) @@ -928,31 +727,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.progress_bar.setValue(100) - - # Set completion message based on the file type of last downloaded file - if self.download_thread and self.download_thread.current_filename: - filename = self.download_thread.current_filename - ext = os.path.splitext(filename)[1].lower() - - # 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!") + 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: @@ -986,65 +767,26 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Compare versions if version.parse(latest_version) > version.parse(self.version): - changelog = latest_release.get("body", "No changelog available.") # Get changelog body - self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog + self.show_update_dialog(latest_version, latest_release["html_url"]) except Exception as e: print(f"Failed to check for updates: {str(e)}") - def show_update_dialog(self, latest_version, release_url, changelog): # Added changelog parameter + def show_update_dialog(self, latest_version, release_url): msg = QDialog(self) msg.setWindowTitle("Update Available") - 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()) + msg.setMinimumWidth(400) 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() @@ -1066,7 +808,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m QLabel { color: #ffffff; font-size: 12px; - padding: 5px 0; /* Adjusted padding */ + padding: 10px; } QPushButton { padding: 8px 15px; @@ -1075,7 +817,6 @@ 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; @@ -1094,8 +835,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m def cancel_download(self): if self.current_download: self.current_download.cancelled = True - self.status_label.setText("Cancelling download...") # Set status directly - self.download_details_label.setText("") # Clear details label on cancellation + self.signals.update_status.emit("Cancelling download...") def show_ffmpeg_dialog(self): dialog = FFmpegCheckDialog(self) @@ -1104,55 +844,21 @@ 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_button.setEnabled(enabled) + self.analyze_btn.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() @@ -1163,22 +869,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.progress_bar.setValue(100) - - # Determine file type based on extension - ext = os.path.splitext(filename)[1].lower() - - # 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") + self.status_label.setText(f"⚠️ File already exists: {filename}") # Show a simple message dialog msg_box = QMessageBox() @@ -1213,55 +904,4 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m } """) - 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 + msg_box.exec() \ No newline at end of file diff --git a/ytsage_gui_video_info.py b/ytsage_gui_video_info.py index c05f591..bad0511 100644 --- a/ytsage_gui_video_info.py +++ b/ytsage_gui_video_info.py @@ -17,7 +17,6 @@ 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): @@ -56,10 +55,9 @@ 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, self.like_count_label]: + for label in [self.channel_label, self.views_label, self.date_label, self.duration_label]: label.setStyleSheet(""" QLabel { color: #cccccc; @@ -72,53 +70,115 @@ 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) - # --- Subtitle Section --- + # Create a horizontal layout for subtitle controls subtitle_layout = QHBoxLayout() - subtitle_layout.setSpacing(10) + subtitle_layout.setSpacing(5) # Reduce spacing between elements - # 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(""" + # 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(""" QPushButton { - background-color: #1d1e22; - border: 2px solid #1d1e22; + background-color: #363636; + border: 2px solid #3d3d3d; border-radius: 4px; - padding: 5px 10px; /* Adjusted padding */ + padding: 5px; + min-height: 30px; } - 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; + QPushButton:checked { + background-color: #ff0000; + border-color: #cc0000; } """) - self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling - subtitle_layout.addWidget(self.subtitle_select_btn) + subtitle_layout.addWidget(self.subtitle_check) - # 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 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) - # Add the subtitle layout to the main video info layout + # 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 video_info_layout.addLayout(subtitle_layout) - # --- End Subtitle Section --- - + video_info_layout.addLayout(filter_layout) + # Add stretch at the bottom video_info_layout.addStretch() @@ -133,11 +193,11 @@ class VideoInfoMixin: self.playlist_info_label.setStyleSheet(""" QLabel { font-size: 12px; - color: #ffffff; + color: #ff9900; padding: 5px 8px; margin: 0; - background-color: #1d1e22; - border: 1px solid #c90000; + background-color: #2b2b2b; + border: 1px solid #3d3d3d; border-radius: 4px; min-height: 30px; max-height: 30px; @@ -147,109 +207,63 @@ class VideoInfoMixin: return self.playlist_info_label def update_video_info(self, info): - 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) + # 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') else: - # 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) + formatted_date = 'Unknown date' - # Format view count with commas - views = info.get('view_count') - formatted_views = f"{views:,}" if views is not None else 'N/A' + # Format duration + duration = info.get('duration', 0) + minutes = duration // 60 + seconds = duration % 60 + duration_str = f"{minutes}:{seconds:02d}" - # 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}") + # 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}") - 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 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) - if not hasattr(self, 'selected_subtitles'): - self.selected_subtitles = [] + def update_subtitle_list(self): + self.subtitle_combo.clear() - dialog = SubtitleSelectionDialog( - self.available_subtitles, - self.available_automatic_subtitles, - self.selected_subtitles, - self # Parent for the dialog - ) + if not (self.available_subtitles or self.available_automatic_subtitles): + self.subtitle_combo.addItem("No subtitles available") + return - # 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) + # Add subtitle options + self.subtitle_combo.addItem("Select subtitle language") + # Filter and add subtitles + filter_text = self.subtitle_filter_input.text().lower() - 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 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") - # 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.") + # 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") - # 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 filter_subtitles(self): + self.subtitle_filter = self.subtitle_filter_input.text() + self.update_subtitle_list() def download_thumbnail(self, url): try: @@ -271,6 +285,10 @@ 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 6ad7f38..84c3b8d 100644 --- a/ytsage_style.py +++ b/ytsage_style.py @@ -1,24 +1,24 @@ MAIN_STYLE = """ QMainWindow { - background-color: #15181b; + background-color: #2b2b2b; } QWidget { - background-color: #15181b; + background-color: #2b2b2b; color: #ffffff; font-size: 12px; } QLineEdit { padding: 8px; - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; - background-color: #1b2021; + background-color: #363636; color: #ffffff; - selection-background-color: #c90000; + selection-background-color: #ff0000; selection-color: #ffffff; } QPushButton { padding: 8px 15px; - background-color: #c90000; + background-color: #ff0000; /* YouTube red */ border: none; border-radius: 4px; color: white; @@ -26,33 +26,33 @@ QPushButton { min-height: 20px; } QPushButton:hover { - background-color: #a50000; + background-color: #cc0000; /* Darker red on hover */ } QPushButton:pressed { - background-color: #800000; + background-color: #990000; /* Even darker red when pressed */ } QPushButton:disabled { - background-color: #666666; + background-color: #666666; /* Gray when disabled */ color: #999999; } QTableWidget { - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; - background-color: #1b2021; - gridline-color: #1b2021; - selection-background-color: #c90000; + background-color: #363636; + gridline-color: #3d3d3d; + selection-background-color: #ff0000; selection-color: #ffffff; } QHeaderView::section { - background-color: #15181b; + background-color: #2b2b2b; padding: 5px; - border: 1px solid #1b2021; + border: 1px solid #3d3d3d; color: #ffffff; font-weight: bold; } QScrollBar:vertical { border: none; - background-color: #15181b; + background-color: #2b2b2b; width: 12px; margin: 0px; } @@ -62,27 +62,27 @@ QScrollBar::handle:vertical { border-radius: 6px; } QScrollBar::handle:vertical:hover { - background-color: #c90000; + background-color: #ff0000; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } QProgressBar { - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; text-align: center; color: white; - background-color: #1b2021; + background-color: #363636; } QProgressBar::chunk { - background-color: #c90000; + background-color: #ff0000; border-radius: 2px; } QComboBox { padding: 5px; - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; - background-color: #1b2021; + background-color: #363636; color: #ffffff; min-height: 20px; } @@ -106,25 +106,25 @@ QCheckBox::indicator { } QCheckBox::indicator:unchecked { border: 2px solid #666666; - background: #15181b; + background: #2b2b2b; } QCheckBox::indicator:checked { - border: 2px solid #c90000; - background: #c90000; + border: 2px solid #ff0000; + background: #ff0000; } QLabel { color: #ffffff; } QTextEdit, QPlainTextEdit { - background-color: #1b2021; + background-color: #363636; color: #ffffff; - border: 2px solid #1b2021; + border: 2px solid #3d3d3d; border-radius: 4px; - selection-background-color: #c90000; + selection-background-color: #ff0000; selection-color: #ffffff; } QMessageBox { - background-color: #15181b; + background-color: #2b2b2b; } QMessageBox QLabel { color: #ffffff; diff --git a/ytsage_utils.py b/ytsage_utils.py index 698625d..1666c83 100644 --- a/ytsage_utils.py +++ b/ytsage_utils.py @@ -4,7 +4,6 @@ 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(): @@ -50,24 +49,45 @@ def check_ffmpeg(): return False def get_yt_dlp_path(): - """Get the yt-dlp command or path, prioritizing the system PATH.""" + """Get the appropriate yt-dlp path with enhanced error handling.""" try: - # 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 + 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') else: - # 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' - + # 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') + except Exception as e: - 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' + 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') def load_saved_path(main_window_instance): """Load saved download path with enhanced error handling."""