From ccc1105ef12679ad6b454fba743f3eb962ff26fc Mon Sep 17 00:00:00 2001 From: Muhamed Date: Mon, 10 Mar 2025 15:34:34 +0200 Subject: [PATCH] v4.2.0 --- __pycache__/ytsage_downloader.cpython-313.pyc | Bin 0 -> 18823 bytes __pycache__/ytsage_ffmpeg.cpython-313.pyc | Bin 0 -> 11274 bytes .../ytsage_gui_dialogs.cpython-313.pyc | Bin 0 -> 34343 bytes .../ytsage_gui_format_table.cpython-313.pyc | Bin 0 -> 17301 bytes __pycache__/ytsage_gui_main.cpython-313.pyc | Bin 0 -> 42296 bytes .../ytsage_gui_video_info.cpython-313.pyc | Bin 0 -> 17912 bytes __pycache__/ytsage_utils.cpython-313.pyc | Bin 0 -> 8519 bytes main.py | 24 +- ytsage_downloader.py | 216 +- ytsage_ffmpeg.py | 224 ++ ytsage_gui.py | 1800 ----------------- ytsage_gui_dialogs.py | 673 ++++++ ytsage_gui_format_table.py | 328 +++ ytsage_gui_main.py | 907 +++++++++ ytsage_gui_video_info.py | 351 ++++ ytsage_style.py | 135 ++ ytsage_utils.py | 144 +- 17 files changed, 2928 insertions(+), 1874 deletions(-) create mode 100644 __pycache__/ytsage_downloader.cpython-313.pyc create mode 100644 __pycache__/ytsage_ffmpeg.cpython-313.pyc create mode 100644 __pycache__/ytsage_gui_dialogs.cpython-313.pyc create mode 100644 __pycache__/ytsage_gui_format_table.cpython-313.pyc create mode 100644 __pycache__/ytsage_gui_main.cpython-313.pyc create mode 100644 __pycache__/ytsage_gui_video_info.cpython-313.pyc create mode 100644 __pycache__/ytsage_utils.cpython-313.pyc create mode 100644 ytsage_ffmpeg.py delete mode 100644 ytsage_gui.py create mode 100644 ytsage_gui_dialogs.py create mode 100644 ytsage_gui_format_table.py create mode 100644 ytsage_gui_main.py create mode 100644 ytsage_gui_video_info.py create mode 100644 ytsage_style.py diff --git a/__pycache__/ytsage_downloader.cpython-313.pyc b/__pycache__/ytsage_downloader.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a3a2b1a8b86a3c6d84ce8f0e917698af95272a82 GIT binary patch literal 18823 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 Zv3=bngvT`15VqHlWp_JGWEt+^{{WG8^~3-G literal 0 HcmV?d00001 diff --git a/__pycache__/ytsage_gui_dialogs.cpython-313.pyc b/__pycache__/ytsage_gui_dialogs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c18b99d693861127023a0e43b9d786c8c1ca6589 GIT binary patch literal 34343 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

dT*fVe>Zz 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 O;=SDczCd^0xc>*##38Q$ literal 0 HcmV?d00001 diff --git a/__pycache__/ytsage_gui_format_table.cpython-313.pyc b/__pycache__/ytsage_gui_format_table.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab68ded7174e625dd3543100e5447b374a8babb0 GIT binary patch literal 17301 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%0D2;Kw0+hzM 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 YFpV@(Mcb?R2L3gRZu}X8F~#No0WR(`=l}o! literal 0 HcmV?d00001 diff --git a/__pycache__/ytsage_utils.cpython-313.pyc b/__pycache__/ytsage_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1bf3fa3704b5208663af3f04c081e49a8264b2c7 GIT binary patch literal 8519 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]', "", info.get('playlist_title', '')).strip() + base_path = os.path.join(self.path, playlist_title) + else: + base_path = self.path + + # Normalize the path to use consistent separators + base_path = os.path.normpath(base_path) + print(f"DEBUG: Base path: {base_path}") + + # Instead of trying to predict the exact filename, scan the directory + # and look for files that contain both the title and resolution + if os.path.exists(base_path): + for filename in os.listdir(base_path): + if filename.endswith('.mp4'): + # Check if both title parts and resolution are in the filename + title_words = title.lower().split() + filename_lower = filename.lower() + + # Check if most title words are in the filename + title_match = all(word in filename_lower for word in title_words[:3]) + resolution_match = resolution.lower() in filename_lower + + print(f"DEBUG: Checking file: {filename}, Title match: {title_match}, Resolution match: {resolution_match}") + + if title_match and resolution_match: + print(f"DEBUG: Found matching file: {filename}") + return filename + + print("DEBUG: No matching file found") + return None + except Exception as e: + print(f"DEBUG: Error checking file existence: {str(e)}") + import traceback + traceback.print_exc() + return None + def run(self): try: + print("DEBUG: Starting download thread") + # First check if file already exists + existing_file = self.check_file_exists() + if existing_file: + print(f"DEBUG: File exists, emitting signal: {existing_file}") + self.file_exists_signal.emit(existing_file) + return + + print("DEBUG: No existing file found, proceeding with download") class DebugLogger: def debug(self, msg): + # Print all debug messages to help diagnose issues + print(f"YT-DLP DEBUG: {msg}") + + # Check for file exists message - look for both patterns + if "already exists" in msg or "has already been downloaded" in msg: + print(f"FILE EXISTS DETECTED: {msg}") + # Try to extract the filename + import re + match = re.search(r'File (.*?) already exists', msg) + if not match: + match = re.search(r'(.*?) has already been downloaded', msg) + + if match: + filename = os.path.basename(match.group(1)) + self.thread.file_exists_signal.emit(filename) + raise Exception("FileExistsError") + # Add detection of post-processing messages if "Downloading" in msg: - self.thread.status_signal.emit("Downloading...") + 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.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', 'extracting', 'downloading m3u8']): - self.thread.status_signal.emit("Preparing for download...") + 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): - self.thread.status_signal.emit(f"Warning: {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): - self.thread.status_signal.emit(f"Error: {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 @@ -96,20 +217,32 @@ class DownloadThread(QThread): 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...") + 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...") + self.status_signal.emit("✨ Post-processing: Removing sponsor segments...") else: self.progress_signal.emit(100) - self.status_signal.emit("Download completed!") + self.status_signal.emit("✅ Download completed!") + + # Get the extension from the format_id + with yt_dlp.YoutubeDL({'quiet': True}) as ydl: + try: + info = ydl.extract_info(self.url, download=False) + selected_format = next( + f for f in info['formats'] + if str(f.get('format_id', '')) == self.format_id + ) + output_ext = selected_format.get('ext', 'mp4') + except Exception as e: + self.error_signal.emit(f"Failed to get video information: {str(e)}") + return # Base yt-dlp options with resolution in filename output_template = '%(title)s_%(resolution)s.%(ext)s' @@ -120,12 +253,13 @@ class DownloadThread(QThread): 'format': f'{self.format_id}+bestaudio/best', 'outtmpl': os.path.join(self.path, output_template), 'progress_hooks': [progress_hook], - 'merge_output_format': 'mkv' if self.merge_subs else 'mp4', + 'merge_output_format': 'mp4', 'logger': DebugLogger(self), 'postprocessors': [{ 'key': 'FFmpegVideoConvertor', - 'preferedformat': 'mkv' if self.merge_subs else 'mp4' - }] + 'preferedformat': 'mp4' + }], + 'force_overwrites': True } # Add subtitle options if selected @@ -140,6 +274,18 @@ class DownloadThread(QThread): 'skip_auto_subs': not is_auto, 'embedsubtitles': self.merge_subs, }) + + if self.merge_subs: + ydl_opts['postprocessors'].extend([ + { + 'key': 'FFmpegSubtitlesConvertor', + 'format': 'srt', + }, + { + 'key': 'FFmpegEmbedSubtitle', + 'already_have_subtitle': False, + } + ]) # Add SponsorBlock options if enabled if self.enable_sponsorblock: @@ -153,24 +299,34 @@ class DownloadThread(QThread): 'sponsorblock_chapter_title': '[SponsorBlock]', 'force_keyframes': False }]) - self.progress_signal.emit(99) - self.status_signal.emit("Post-processing: Removing sponsor segments...") - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([self.url]) + # Add playlist items if specified + if self.playlist_items: + ydl_opts['playlist_items'] = self.playlist_items - self.finished_signal.emit() + 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)}") - # 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: - if str(e) == "Download cancelled by user": - self.cleanup_partial_files() - self.error_signal.emit(str(e)) \ 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_ffmpeg.py b/ytsage_ffmpeg.py new file mode 100644 index 0000000..9441cb7 --- /dev/null +++ b/ytsage_ffmpeg.py @@ -0,0 +1,224 @@ +import os +import sys +import subprocess +import requests +import shutil +import tempfile +from pathlib import Path +from PySide6.QtGui import QIcon + +def check_7zip_installed(): + """Check if 7-Zip is installed on Windows.""" + try: + subprocess.run(['7z', '--help'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) + return True + except (subprocess.SubprocessError, FileNotFoundError): + return False + +def download_file(url, dest_path, progress_callback=None): + """Download a file from URL to destination path with progress indication.""" + try: + response = requests.get(url, stream=True, timeout=30) # Added timeout + response.raise_for_status() # Check for HTTP errors + total_size = int(response.headers.get('content-length', 0)) + + with open(dest_path, 'wb') as f: + if total_size == 0: + f.write(response.content) + else: + downloaded = 0 + for data in response.iter_content(chunk_size=8192): + downloaded += len(data) + f.write(data) + if progress_callback: + progress = int((downloaded / total_size) * 100) + progress_callback(f"⚡ Downloading FFmpeg components... {progress}%") + return True + except requests.RequestException as e: + print(f"Download error: {str(e)}") + return False + +def get_ffmpeg_install_path(): + """Get the FFmpeg installation path.""" + if sys.platform == 'win32': + return os.path.join(os.getenv('LOCALAPPDATA'), 'ffmpeg', 'ffmpeg-7.1-full_build', 'bin') + elif sys.platform == 'darwin': + paths = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin'] + for path in paths: + if os.path.exists(os.path.join(path, 'ffmpeg')): + return path + return '/usr/local/bin' # Default Homebrew path + else: + return '/usr/bin' # Standard Linux path + +def check_ffmpeg_installed(): + """Check if FFmpeg is installed and accessible.""" + try: + # First try the PATH + result = subprocess.run(['ffmpeg', '-version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0, + timeout=5) # Added timeout + return True + except (subprocess.SubprocessError, FileNotFoundError): + # If not in PATH, check the installation directory + ffmpeg_path = get_ffmpeg_install_path() + if sys.platform == 'win32': + ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg.exe') + else: + ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg') + + if os.path.exists(ffmpeg_exe): + # Add to PATH if found + os.environ['PATH'] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" + return True + return False + except Exception as e: + print(f"FFmpeg check error: {str(e)}") + return False + +def install_ffmpeg_windows(): + """Install FFmpeg on Windows using either 7z or zip method.""" + ffmpeg_path = get_ffmpeg_install_path() + + # Check if already installed + if check_ffmpeg_installed(): + print("✨ FFmpeg is already installed!") + return True + + try: + # Define variables + ffmpeg_url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-full_build.7z" + zip_url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-full_build.zip" + extract_dir = os.path.join(os.getenv('LOCALAPPDATA'), 'ffmpeg') + full_build_dir = os.path.join(extract_dir, 'ffmpeg-7.1-full_build') + bin_dir = os.path.join(full_build_dir, 'bin') + + # Create extraction directory if it doesn't exist + os.makedirs(extract_dir, exist_ok=True) + + # Choose installation method based on 7-Zip availability + use_7zip = check_7zip_installed() + temp_file = tempfile.NamedTemporaryFile(delete=False, + suffix='.7z' if use_7zip else '.zip').name + + # Download with progress callback + if not download_file(ffmpeg_url if use_7zip else zip_url, temp_file, + progress_callback=lambda msg: print(msg)): + raise Exception("Failed to download FFmpeg") + + print("🔧 Extracting FFmpeg components...") + try: + if use_7zip: + # Extract using 7-Zip + subprocess.run(['7z', 'x', temp_file, f'-o{extract_dir}', '-y'], + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0, + timeout=300) # 5-minute timeout + else: + # Extract using built-in Windows tools + import zipfile + with zipfile.ZipFile(temp_file, 'r') as zip_ref: + zip_ref.extractall(extract_dir) + except Exception as e: + raise Exception(f"Extraction failed: {str(e)}") + + print("⚙️ Configuring system paths...") + # Add to System Path + user_path = os.environ.get('PATH', '') + if bin_dir not in user_path: + subprocess.run(['setx', 'PATH', f"{user_path};{bin_dir}"], + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) + os.environ['PATH'] = f"{user_path};{bin_dir}" + + # Clean up + try: + os.unlink(temp_file) + except Exception: + pass # Ignore cleanup errors + + # Verify installation + if not check_ffmpeg_installed(): + raise Exception("FFmpeg installation verification failed") + + print("✨ FFmpeg installation completed successfully!") + return True + + except Exception as e: + print(f"❌ Error installing FFmpeg: {str(e)}") + return False + +def install_ffmpeg_macos(): + """Install FFmpeg on macOS using Homebrew.""" + try: + # Check if Homebrew is installed + try: + subprocess.run(['brew', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + timeout=5) + except (subprocess.SubprocessError, FileNotFoundError): + print("Installing Homebrew...") + brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + subprocess.run(brew_install_cmd, shell=True, check=True, timeout=300) + + # Install FFmpeg + print("Installing FFmpeg...") + subprocess.run(['brew', 'install', 'ffmpeg'], check=True, timeout=300) + + # Verify installation + if not check_ffmpeg_installed(): + raise Exception("FFmpeg installation verification failed") + + return True + + except Exception as e: + print(f"Error installing FFmpeg: {str(e)}") + return False + +def install_ffmpeg_linux(): + """Install FFmpeg on Linux using appropriate package manager.""" + try: + # Detect the package manager + if shutil.which('apt'): + # Debian/Ubuntu + subprocess.run(['sudo', 'apt', 'update'], check=True, timeout=60) + subprocess.run(['sudo', 'apt', 'install', '-y', 'ffmpeg'], check=True, timeout=300) + elif shutil.which('dnf'): + # Fedora + subprocess.run(['sudo', 'dnf', 'install', '-y', 'ffmpeg'], check=True, timeout=300) + elif shutil.which('pacman'): + # Arch Linux + subprocess.run(['sudo', 'pacman', '-S', '--noconfirm', 'ffmpeg'], check=True, timeout=300) + elif shutil.which('snap'): + # Universal snap package + subprocess.run(['sudo', 'snap', 'install', 'ffmpeg'], check=True, timeout=300) + else: + raise Exception("No supported package manager found") + + # Verify installation + if not check_ffmpeg_installed(): + raise Exception("FFmpeg installation verification failed") + + return True + + except Exception as e: + print(f"Error installing FFmpeg: {str(e)}") + return False + +def auto_install_ffmpeg(): + """Automatically install FFmpeg based on the operating system.""" + if sys.platform == 'win32': + return install_ffmpeg_windows() + elif sys.platform == 'darwin': + return install_ffmpeg_macos() + elif sys.platform.startswith('linux'): + return install_ffmpeg_linux() + else: + print(f"Unsupported operating system: {sys.platform}") + return False \ No newline at end of file diff --git a/ytsage_gui.py b/ytsage_gui.py deleted file mode 100644 index 238faab..0000000 --- a/ytsage_gui.py +++ /dev/null @@ -1,1800 +0,0 @@ -import sys -import os -import webbrowser -from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, - QHBoxLayout, QLineEdit, QPushButton, QTableWidget, - QTableWidgetItem, QProgressBar, QLabel, QFileDialog, - QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup) -from PySide6.QtCore import Qt, Signal, QObject, QThread -from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap -import requests -from io import BytesIO -from PIL import Image -from datetime import datetime -import json -from pathlib import Path -from packaging import version -import subprocess -import re -import yt_dlp # Add this line - -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 - - -class LogWindow(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle('yt-dlp Log') - self.setMinimumSize(700, 500) - - layout = QVBoxLayout(self) - - self.log_text = QTextEdit() - self.log_text.setReadOnly(True) - self.log_text.setStyleSheet(""" - QTextEdit { - background-color: #2b2b2b; - color: #ffffff; - font-family: Consolas, monospace; - font-size: 12px; - border: 2px solid #3d3d3d; - border-radius: 4px; - } - """) - - layout.addWidget(self.log_text) - - def append_log(self, message): - self.log_text.append(message) - # Auto-scroll to bottom - scrollbar = self.log_text.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - -class CustomCommandDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.parent = parent - self.setWindowTitle('Custom yt-dlp Command') - self.setMinimumSize(600, 400) - - layout = QVBoxLayout(self) - - # Help text - help_text = QLabel( - "Enter custom yt-dlp commands below. The URL will be automatically appended.\n" - "Example: --extract-audio --audio-format mp3 --audio-quality 0\n" - "Note: Download path and output template will be preserved." - ) - help_text.setWordWrap(True) - help_text.setStyleSheet("color: #999999; padding: 10px;") - layout.addWidget(help_text) - - # Command input - self.command_input = QPlainTextEdit() - self.command_input.setPlaceholderText("Enter yt-dlp arguments...") - self.command_input.setStyleSheet(""" - QPlainTextEdit { - background-color: #363636; - color: #ffffff; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 8px; - font-family: Consolas, monospace; - } - """) - layout.addWidget(self.command_input) - - # Add SponsorBlock checkbox - self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") - self.sponsorblock_checkbox.setStyleSheet(""" - QCheckBox { - color: #ffffff; - padding: 5px; - margin-left: 20px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 9px; /* Make indicator round */ - } - QCheckBox::indicator:unchecked { - border: 2px solid #666666; - background: #2b2b2b; - border-radius: 9px; /* Make indicator round */ - } - QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; - border-radius: 9px; /* Make indicator round */ - } - """) - layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox) - - # Buttons - button_layout = QHBoxLayout() - - self.run_btn = QPushButton("Run Command") - self.run_btn.clicked.connect(self.run_custom_command) - - self.close_btn = QPushButton("Close") - self.close_btn.clicked.connect(self.close) - - button_layout.addWidget(self.run_btn) - button_layout.addWidget(self.close_btn) - layout.addLayout(button_layout) - - # Log output - self.log_output = QTextEdit() - self.log_output.setReadOnly(True) - self.log_output.setStyleSheet(""" - QTextEdit { - background-color: #2b2b2b; - color: #ffffff; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 8px; - font-family: Consolas, monospace; - font-size: 12px; - } - """) - layout.addWidget(self.log_output) - - self.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - } - QPushButton { - padding: 8px 15px; - background-color: #ff0000; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:hover { - background-color: #cc0000; - } - """) - - def run_custom_command(self): - url = self.parent.url_input.text().strip() - if not url: - self.log_output.append("Error: No URL provided") - return - - command = self.command_input.toPlainText().strip() - path = self.parent.path_input.text().strip() - - self.log_output.clear() - self.log_output.append(f"Running command with URL: {url}") - self.run_btn.setEnabled(False) - - # Start command in thread - import threading - threading.Thread(target=self._run_command_thread, - args=(command, url, path), - daemon=True).start() - - def _run_command_thread(self, command, url, path): - try: - class CommandLogger: - def debug(self, msg): - self.dialog.log_output.append(msg) - def warning(self, msg): - self.dialog.log_output.append(f"Warning: {msg}") - def error(self, msg): - self.dialog.log_output.append(f"Error: {msg}") - def __init__(self, dialog): - self.dialog = dialog - - # Split command into arguments - args = command.split() - - # Base options - ydl_opts = { - 'logger': CommandLogger(self), - 'paths': {'home': path}, - 'debug_printout': True, - 'postprocessors': [] - } - - # Add SponsorBlock options if enabled - if self.sponsorblock_checkbox.isChecked(): - ydl_opts['postprocessors'].extend([{ - 'key': 'SponsorBlock', - 'categories': ['sponsor', 'selfpromo', 'interaction'], - 'api': 'https://sponsor.ajay.app' - }, { - 'key': 'ModifyChapters', - 'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'], - 'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l', - 'force_keyframes': True - }]) - - # Add custom arguments - for i in range(0, len(args), 2): - if i + 1 < len(args): - key = args[i].lstrip('-').replace('-', '_') - value = args[i + 1] - try: - # Try to convert to appropriate type - if value.lower() in ('true', 'false'): - value = value.lower() == 'true' - elif value.isdigit(): - value = int(value) - ydl_opts[key] = value - except: - ydl_opts[key] = value - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - - self.log_output.append("Command completed successfully") - - except Exception as e: - self.log_output.append(f"Error: {str(e)}") - finally: - self.run_btn.setEnabled(True) - -class FFmpegCheckDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle('FFmpeg Required') - self.setMinimumWidth(400) - - layout = QVBoxLayout(self) - - # Message - message_label = QLabel( - "FFmpeg is not installed. Please install it.\n" - "To install FFmpeg, click the button below." - ) - message_label.setWordWrap(True) - layout.addWidget(message_label) - - # Install button - install_btn = QPushButton("Install FFmpeg") - install_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide')) - layout.addWidget(install_btn) - - # Close button - close_btn = QPushButton("Close") - close_btn.clicked.connect(self.close) - layout.addWidget(close_btn) - - # Style the dialog - self.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - } - QLabel { - color: #ffffff; - font-size: 12px; - padding: 10px; - } - QPushButton { - padding: 8px 15px; - background-color: #ff0000; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:hover { - background-color: #cc0000; - } - """) - - -class YTDLPUpdateDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle('Update yt-dlp') - self.setMinimumWidth(400) - - layout = QVBoxLayout(self) - - # Version info and message - self.version_label = QLabel() - self.version_label.setWordWrap(True) - layout.addWidget(self.version_label) - - self.message_label = QLabel( - "Would you like to update yt-dlp to the latest version?\n" - "This will download and install the latest yt-dlp executable." - ) - self.message_label.setWordWrap(True) - layout.addWidget(self.message_label) - - # Progress bar (hidden initially) - self.progress_bar = QProgressBar() - self.progress_bar.setVisible(False) - layout.addWidget(self.progress_bar) - - # Status label - self.status_label = QLabel() - self.status_label.setWordWrap(True) - layout.addWidget(self.status_label) - - # Buttons - button_layout = QHBoxLayout() - - self.update_btn = QPushButton("Update yt-dlp") - self.update_btn.clicked.connect(self.start_update) - self.update_btn.setEnabled(False) # Disabled until version check - - self.close_btn = QPushButton("Close") - self.close_btn.clicked.connect(self.close) - - button_layout.addWidget(self.update_btn) - button_layout.addWidget(self.close_btn) - layout.addLayout(button_layout) - - # Style the dialog - self.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - } - QLabel { - color: #ffffff; - font-size: 12px; - padding: 10px; - } - QPushButton { - padding: 8px 15px; - background-color: #ff0000; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:hover { - background-color: #cc0000; - } - QPushButton:disabled { - background-color: #666666; - } - QProgressBar { - border: 2px solid #3d3d3d; - border-radius: 4px; - text-align: center; - color: white; - } - QProgressBar::chunk { - background-color: #ff0000; - } - """) - - # Check versions when dialog opens - self.check_versions() - - def check_versions(self): - import threading - threading.Thread(target=self._check_versions_thread, daemon=True).start() - - def _check_versions_thread(self): - try: - # Get current version with proper path - yt_dlp_path = get_yt_dlp_path() - if os.path.exists(yt_dlp_path): - try: - # Use the full path to yt-dlp executable - result = subprocess.run([yt_dlp_path, '--version'], - capture_output=True, - text=True, - timeout=5, - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) - if result.returncode == 0: - current_version = result.stdout.strip() - else: - # Try using pip to get version as fallback - try: - current_version = yt_dlp.version.__version__ - except: - current_version = "Not installed" - except (subprocess.SubprocessError, subprocess.TimeoutExpired): - # Try using pip to get version as fallback - try: - current_version = yt_dlp.version.__version__ - except: - current_version = "Not installed" - else: - # Try using pip to get version as fallback - try: - current_version = yt_dlp.version.__version__ - except: - current_version = "Not installed" - - # Get latest version from GitHub API with timeout - try: - response = requests.get( - "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest", - timeout=10 - ) - response.raise_for_status() - latest_version = response.json()["tag_name"] - except Exception as e: - raise Exception(f"Could not fetch latest version: {str(e)}") - - self.version_label.setText( - f"Current version: {current_version}\n" - f"Latest version: {latest_version}" - ) - - if current_version == "Not installed" or current_version != latest_version: - self.update_btn.setEnabled(True) - self.message_label.setText("An update is available!") - else: - self.message_label.setText("You have the latest version installed.") - self.update_btn.setEnabled(False) - - except Exception as e: - self.version_label.setText("Could not check versions") - self.message_label.setText(f"Error: {str(e)}") - self.update_btn.setEnabled(True) - - - def start_update(self): - self.update_btn.setEnabled(False) - self.progress_bar.setVisible(True) - self.status_label.setText("Starting update...") - - # Start update in a separate thread - import threading - threading.Thread(target=self._update_thread, daemon=True).start() - - def _update_thread(self): - temp_path = None - try: - # Create necessary directories - target_path = get_yt_dlp_path() - os.makedirs(os.path.dirname(target_path), exist_ok=True) - - # Determine platform and get URLs - if sys.platform == 'win32': - url = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe' - else: - url = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp' - - temp_path = target_path + '.download' - - # Download with proper error handling - try: - response = requests.get(url, stream=True, timeout=30) - response.raise_for_status() - total_size = int(response.headers.get('content-length', 0)) - block_size = 8192 # Increased for better performance - - with open(temp_path, 'wb') as f: - for data in response.iter_content(block_size): - if data: # Filter out keep-alive chunks - f.write(data) - progress = int((f.tell() * 100) / total_size) if total_size > 0 else 0 - self.progress_bar.setValue(progress) - self.status_label.setText(f"Downloading: {progress}%") - except requests.exceptions.RequestException as e: - raise Exception(f"Download failed: {str(e)}") - - # Set proper permissions - if sys.platform != 'win32': - try: - os.chmod(temp_path, 0o755) - except Exception as e: - print(f"Warning: Could not set executable permissions: {e}") - - # Handle file replacement - try: - if os.path.exists(target_path): - if sys.platform == 'win32': - # Windows-specific file replacement - import ctypes - if not ctypes.windll.kernel32.MoveFileExW(target_path, None, 4): # MOVEFILE_DELAY_UNTIL_REBOOT - os.remove(target_path) - else: - os.remove(target_path) - except PermissionError: - raise Exception("Cannot update while yt-dlp is in use. Please close any active downloads and try again.") - except Exception as e: - raise Exception(f"Error removing existing file: {str(e)}") - - # Move temporary file to final location - try: - os.rename(temp_path, target_path) - except Exception as e: - raise Exception(f"Error moving new file into place: {str(e)}") - - self.status_label.setText("yt-dlp updated successfully!") - self.close_btn.setText("Done") - - # Refresh version info - self.check_versions() - - except Exception as e: - self.status_label.setText(f"Error updating yt-dlp: {str(e)}") - finally: - # Clean up temp file if it exists - if temp_path and os.path.exists(temp_path): - try: - os.remove(temp_path) - except: - pass - self.update_btn.setEnabled(True) - -class AboutDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle('About YTSage') - self.setMinimumWidth(500) - - 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 {parent.version}") - version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(version_label) - - # Description - description = QLabel( - "A modern YouTube downloader with a clean interface.\n" - "Download videos in any quality, extract audio, fetch subtitles,\n" - "and view video metadata. Built with yt-dlp for reliable performance." - ) - description.setWordWrap(True) - description.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(description) - - # 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" - "• SponsorBlock Integration\n" - "• One-click updates" - ) - features_label.setStyleSheet("padding: 10px;") - layout.addWidget(features_label) - - # Credits - credits_label = QLabel( - "Powered by:\n" - "• yt-dlp\n" - "• PySide6\n" - "• FFmpeg" - ) - credits_label.setStyleSheet("padding: 10px;") - layout.addWidget(credits_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) - - # Close button - close_btn = QPushButton("Close") - close_btn.clicked.connect(self.close) - layout.addWidget(close_btn) - - # Style the dialog - self.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - } - QLabel { - color: #ffffff; - font-size: 12px; - } - QPushButton { - padding: 8px 15px; - background-color: #ff0000; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:hover { - background-color: #cc0000; - } - """) - - -class YTSageApp(QMainWindow): # Renamed class to YTSageApp to avoid name conflict with original file name - def __init__(self): - super().__init__() - # Check for FFmpeg before proceeding - if not check_ffmpeg(): - self.show_ffmpeg_dialog() - - self.version = "4.0.0" - self.check_for_updates() - self.config_file = Path.home() / '.ytsage_config.json' - load_saved_path(self) - self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) - self.signals = SignalManager() - self.download_paused = False - self.current_download = None - self.download_cancelled = False - self.save_thumbnail = False # Initialize thumbnail state - self.thumbnail_url = None # Add this to store thumbnail URL - self.init_ui() - self.setStyleSheet(""" - QMainWindow { - background-color: #2b2b2b; - } - QWidget { - background-color: #2b2b2b; - color: #ffffff; - } - QLineEdit { - padding: 8px; - border: 2px solid #3d3d3d; - border-radius: 4px; - background-color: #363636; - color: #ffffff; - } - QPushButton { - padding: 8px 15px; - background-color: #ff0000; /* YouTube red */ - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:hover { - background-color: #cc0000; /* Darker red on hover */ - } - QPushButton:pressed { - background-color: #990000; /* Even darker red when pressed */ - } - QTableWidget { - border: 2px solid #3d3d3d; - border-radius: 4px; - background-color: #363636; - gridline-color: #3d3d3d; - } - QHeaderView::section { - background-color: #2b2b2b; - padding: 5px; - border: 1px solid #3d3d3d; - color: #ffffff; - } - QProgressBar { - border: 2px solid #3d3d3d; - border-radius: 4px; - text-align: center; - color: white; - } - QProgressBar::chunk { - background-color: #ff0000; /* YouTube red */ - border-radius: 2px; - } - QLabel { - color: #ffffff; - } - /* Style for filter buttons */ - QPushButton.filter-btn { - background-color: #363636; - padding: 5px 10px; - margin: 0 5px; - } - QPushButton.filter-btn:checked { - background-color: #ff0000; - } - QPushButton.filter-btn:hover { - background-color: #444444; - } - QPushButton.filter-btn:checked:hover { - background-color: #cc0000; - } - /* Modern Scrollbar Styling */ - QScrollBar:vertical { - border: none; - background: #2b2b2b; - width: 14px; - margin: 15px 0 15px 0; - border-radius: 7px; - } - QScrollBar::handle:vertical { - background: #404040; - min-height: 30px; - border-radius: 7px; - } - QScrollBar::handle:vertical:hover { - background: #505050; - } - QScrollBar::sub-line:vertical { - border: none; - background: #2b2b2b; - height: 15px; - border-top-left-radius: 7px; - border-top-right-radius: 7px; - subcontrol-position: top; - subcontrol-origin: margin; - } - QScrollBar::add-line:vertical { - border: none; - background: #2b2b2b; - height: 15px; - border-bottom-left-radius: 7px; - border-bottom-right-radius: 7px; - subcontrol-position: bottom; - subcontrol-origin: margin; - } - QScrollBar::sub-line:vertical:hover, - QScrollBar::add-line:vertical:hover { - background: #404040; - } - QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { - background: none; - width: 0; - height: 0; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; - } - /* Horizontal Scrollbar */ - QScrollBar:horizontal { - border: none; - background: #2b2b2b; - height: 14px; - margin: 0 15px 0 15px; - border-radius: 7px; - } - QScrollBar::handle:horizontal { - background: #404040; - min-width: 30px; - border-radius: 7px; - } - QScrollBar::handle:horizontal:hover { - background: #505050; - } - QScrollBar::sub-line:horizontal { - border: none; - background: #2b2b2b; - width: 15px; - border-top-left-radius: 7px; - border-bottom-left-radius: 7px; - subcontrol-position: left; - subcontrol-origin: margin; - } - QScrollBar::add-line:horizontal { - border: none; - background: #2b2b2b; - width: 15px; - border-top-right-radius: 7px; - border-bottom-right-radius: 7px; - subcontrol-position: right; - subcontrol-origin: margin; - } - QScrollBar::sub-line:horizontal:hover, - QScrollBar::add-line:horizontal:hover { - background: #404040; - } - QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal { - background: none; - width: 0; - height: 0; - } - QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { - background: none; - } - """) - self.signals.update_progress.connect(self.update_progress_bar) - - 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) - - def save_path(self, path): # Using function from ytsage_utils now - no longer needed in class - save_path(self, path) # Call the utility function - - def init_ui(self): - self.setWindowTitle('YTSage v4.0.0') - self.setMinimumSize(900, 600) - - # Main widget and layout - main_widget = QWidget() - self.setCentralWidget(main_widget) - layout = QVBoxLayout(main_widget) - layout.setSpacing(10) - layout.setContentsMargins(20, 20, 20, 20) - - # URL input section - url_layout = QHBoxLayout() - self.url_input = QLineEdit() - self.url_input.setPlaceholderText('Enter YouTube URL...') - - # Add Paste URL button - self.paste_btn = QPushButton('Paste URL') - self.paste_btn.clicked.connect(self.paste_url) - - 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) - - # Create a horizontal layout for thumbnail and video info - media_info_layout = QHBoxLayout() - - # Thumbnail on the left - self.thumbnail_label = QLabel() - self.thumbnail_label.setFixedSize(320, 180) - self.thumbnail_label.setStyleSheet("border: 2px solid #3d3d3d; border-radius: 4px;") - self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - media_info_layout.addWidget(self.thumbnail_label) - - # Video information on the right - video_info_layout = QVBoxLayout() - self.title_label = QLabel() - self.title_label.setWordWrap(True) - self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;") - - self.channel_label = QLabel() - self.views_label = QLabel() - self.date_label = QLabel() - self.duration_label = QLabel() - - # Style the info labels - for label in [self.channel_label, self.views_label, self.date_label, self.duration_label]: - label.setStyleSheet(""" - QLabel { - color: #cccccc; - font-size: 12px; - padding: 2px 0; - } - """) - - # Subtitle section with improved styling - subtitle_layout = QHBoxLayout() - self.subtitle_check = QPushButton("Download Subtitles") - self.subtitle_check.setCheckable(True) - self.subtitle_check.clicked.connect(self.toggle_subtitle_controls) - self.subtitle_check.setStyleSheet(""" - QPushButton { - background-color: #363636; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 5px 10px; - } - QPushButton:checked { - background-color: #ff0000; - border-color: #cc0000; - } - """) - subtitle_layout.addWidget(self.subtitle_check) - - # Create subtitle combo box - self.subtitle_combo = QComboBox() - self.subtitle_combo.setVisible(False) - self.subtitle_combo.setStyleSheet(""" - QComboBox { - background-color: #363636; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 5px; - min-width: 200px; - } - QComboBox::drop-down { - border: none; - } - QComboBox::down-arrow { - image: url(down_arrow.png); - width: 12px; - height: 12px; - } - QComboBox QAbstractItemView { - background-color: #363636; - selection-background-color: #ff0000; - selection-color: white; - } - """) - - # Create subtitle filter input - self.subtitle_filter_input = QLineEdit() - self.subtitle_filter_input.setPlaceholderText("Filter languages (e.g., en, es)") - self.subtitle_filter_input.setMaximumWidth(200) - self.subtitle_filter_input.textChanged.connect(self.filter_subtitles) - self.subtitle_filter_input.setVisible(False) - self.subtitle_filter_input.setStyleSheet(""" - QLineEdit { - background-color: #363636; - color: #ffffff; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 5px; - } - QLineEdit:focus { - border-color: #ff0000; - } - """) - - # Add filter label and components to layout - self.subtitle_filter_label = QLabel("Filter:") - self.subtitle_filter_label.setVisible(False) - subtitle_layout.addWidget(self.subtitle_filter_label) - subtitle_layout.addWidget(self.subtitle_filter_input) - subtitle_layout.addWidget(self.subtitle_combo) - - # Add merge subtitle toggle button - self.merge_subs_btn = QPushButton('Merge Subtitles') - self.merge_subs_btn.setCheckable(True) - self.merge_subs_btn.setVisible(False) - self.merge_subs_btn.setStyleSheet(""" - QPushButton { - background-color: #363636; - border: 2px solid #3d3d3d; - border-radius: 4px; - padding: 5px 10px; - } - QPushButton:checked { - background-color: #ff0000; - border-color: #cc0000; - } - QPushButton:hover { - background-color: #444444; - } - QPushButton:checked:hover { - background-color: #cc0000; - } - """) - subtitle_layout.addWidget(self.merge_subs_btn) - - subtitle_layout.addStretch() - - # Add all info widgets to the video info layout - 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.date_label) - video_info_layout.addWidget(self.duration_label) - video_info_layout.addLayout(subtitle_layout) - video_info_layout.addStretch() - - media_info_layout.addLayout(video_info_layout) - layout.addLayout(media_info_layout) - - # Add playlist information section with improved styling - self.playlist_info_label = QLabel() - self.playlist_info_label.setVisible(False) - self.playlist_info_label.setStyleSheet(""" - QLabel { - font-size: 12px; - color: #ff9900; - padding: 5px; - background-color: #363636; - border-radius: 4px; - } - """) - layout.addWidget(self.playlist_info_label) - - # Create format selection layout (horizontal) - self.format_layout = QHBoxLayout() - - # Show formats label - self.show_formats_label = QLabel("Show formats:") - self.show_formats_label.setStyleSheet("color: white;") - self.format_layout.addWidget(self.show_formats_label) - - # Format buttons group - self.format_buttons = QButtonGroup(self) - self.format_buttons.setExclusive(True) - - # Video button - self.video_button = QPushButton("Video") - self.video_button.setCheckable(True) - self.video_button.setChecked(True) # Set video as default - self.video_button.setStyleSheet(""" - QPushButton { - padding: 8px 15px; - background-color: #363636; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:checked { - background-color: #ff0000; - } - QPushButton:hover { - background-color: #444444; - } - QPushButton:checked:hover { - background-color: #cc0000; - } - """) - self.format_buttons.addButton(self.video_button) - self.format_layout.addWidget(self.video_button) - - # Audio button - self.audio_button = QPushButton("Audio Only") - self.audio_button.setCheckable(True) - self.audio_button.setStyleSheet(""" - QPushButton { - padding: 8px 15px; - background-color: #363636; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:checked { - background-color: #ff0000; - } - QPushButton:hover { - background-color: #444444; - } - QPushButton:checked:hover { - background-color: #cc0000; - } - """) - self.format_buttons.addButton(self.audio_button) - self.format_layout.addWidget(self.audio_button) - - # Connect format buttons - self.format_buttons.buttonClicked.connect(self.handle_format_selection) - - # Add SponsorBlock checkbox - self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") - self.sponsorblock_checkbox.setStyleSheet(""" - QCheckBox { - color: #ffffff; - padding: 5px; - margin-left: 20px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 9px; - } - QCheckBox::indicator:unchecked { - border: 2px solid #666666; - background: #2b2b2b; - border-radius: 9px; - } - QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; - border-radius: 9px; - } - """) - self.format_layout.addWidget(self.sponsorblock_checkbox) - - # Add Save Thumbnail checkbox with same style as SponsorBlock - self.save_thumbnail_checkbox = QCheckBox("Save Thumbnail") - self.save_thumbnail_checkbox.setStyleSheet(""" - QCheckBox { - color: #ffffff; - padding: 5px; - margin-left: 20px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 9px; - } - QCheckBox::indicator:unchecked { - border: 2px solid #666666; - background: #2b2b2b; - border-radius: 9px; - } - QCheckBox::indicator:checked { - border: 2px solid #ff0000; - background: #ff0000; - border-radius: 9px; - } - """) - self.save_thumbnail_checkbox.clicked.connect(self.toggle_save_thumbnail) - self.format_layout.addWidget(self.save_thumbnail_checkbox) - - self.format_layout.addStretch() - - # Add format layout to main layout - layout.addLayout(self.format_layout) - - # Format table with improved styling - self.format_table = QTableWidget() - self.format_table.setColumnCount(6) - self.format_table.setHorizontalHeaderLabels(['Format ID', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio']) - self.format_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.format_table.setStyleSheet(""" - QTableWidget { - background-color: #363636; - border: 2px solid #3d3d3d; - border-radius: 4px; - gridline-color: #3d3d3d; - } - QTableWidget::item { - padding: 5px; - } - QTableWidget::item:selected { - background-color: #ff0000; - } - QHeaderView::section { - background-color: #2b2b2b; - padding: 5px; - border: 1px solid #3d3d3d; - font-weight: bold; - } - """) - layout.addWidget(self.format_table) - - # Download section - download_layout = QHBoxLayout() - - # Add existing buttons - self.custom_cmd_btn = QPushButton('Custom Command') - self.custom_cmd_btn.clicked.connect(self.show_custom_command) - - self.update_ytdlp_btn = QPushButton('Update yt-dlp') - self.update_ytdlp_btn.clicked.connect(self.update_ytdlp) - - self.about_btn = QPushButton('About') - self.about_btn.clicked.connect(self.show_about_dialog) - - self.path_input = QLineEdit(self.last_path) - self.path_input.setPlaceholderText('Download path...') - - self.browse_btn = QPushButton('Browse') - self.browse_btn.clicked.connect(self.browse_path) - - self.download_btn = QPushButton('Download') - self.download_btn.clicked.connect(self.start_download) - - # Add pause and cancel buttons - self.pause_btn = QPushButton('Pause') - self.pause_btn.clicked.connect(self.toggle_pause) - self.pause_btn.setVisible(False) # Hidden initially - - self.cancel_btn = QPushButton('Cancel') - self.cancel_btn.clicked.connect(self.cancel_download) - self.cancel_btn.setVisible(False) # Hidden initially - - # Add all buttons to layout in the correct order - download_layout.addWidget(self.custom_cmd_btn) - download_layout.addWidget(self.update_ytdlp_btn) - download_layout.addWidget(self.about_btn) - download_layout.addWidget(self.path_input) - download_layout.addWidget(self.browse_btn) - download_layout.addWidget(self.download_btn) - download_layout.addWidget(self.pause_btn) - download_layout.addWidget(self.cancel_btn) - - layout.addLayout(download_layout) - - # Progress section with improved styling - progress_layout = QVBoxLayout() - self.progress_bar = QProgressBar() - self.progress_bar.setStyleSheet(""" - QProgressBar { - border: 2px solid #3d3d3d; - border-radius: 4px; - text-align: center; - color: white; - background-color: #363636; - height: 25px; - } - QProgressBar::chunk { - background-color: #ff0000; - border-radius: 2px; - } - """) - progress_layout.addWidget(self.progress_bar) - - # Add download details label with improved styling - self.download_details_label = QLabel() - self.download_details_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.download_details_label.setStyleSheet(""" - QLabel { - color: #cccccc; - font-size: 12px; - padding: 5px; - } - """) - progress_layout.addWidget(self.download_details_label) - - self.status_label = QLabel('Ready') - self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.status_label.setStyleSheet(""" - QLabel { - color: #cccccc; - font-size: 12px; - padding: 5px; - } - """) - progress_layout.addWidget(self.status_label) - - layout.addLayout(progress_layout) - - # Connect signals - self.signals.update_formats.connect(self.update_format_table) - self.signals.update_status.connect(self.status_label.setText) - self.signals.update_progress.connect(self.update_progress_bar) - - def analyze_url(self): - url = self.url_input.text().strip() - if not url: - self.signals.update_status.emit("Invalid URL or please enter a URL.") - return - - self.signals.update_status.emit("Analyzing (0%)... Preparing request") - import threading # Import threading here as it is only used in GUI and downloader - threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start() - - def _analyze_url_thread(self, url): - try: - 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 - ydl_opts = { - 'quiet': False, - 'no_warnings': False, - 'extract_flat': True, - 'force_generic_extractor': False, - 'ignoreerrors': True, - 'no_color': True, - 'verbose': True - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - try: - basic_info = ydl.extract_info(url, download=False) - if not basic_info: - raise Exception("Could not extract basic video information") - except Exception as e: - print(f"First extraction failed: {str(e)}") - raise Exception("Could not extract video information, please check your link") - - self.signals.update_status.emit("Analyzing (40%)... Extracting detailed info") - # Configure options for detailed extraction - ydl_opts.update({ - 'extract_flat': False, - 'format': None, - 'writesubtitles': True, - 'allsubtitles': True, - 'writeautomaticsub': True, - 'playliststart': 1, - 'playlistend': 1, - 'youtube_include_dash_manifest': True, - 'youtube_include_hls_manifest': True - }) - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - try: - 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 - - # Get the first video URL from the playlist - first_video = None - for entry in basic_info['entries']: - if entry: - first_video = entry - break - - if not first_video: - raise Exception("No valid videos found in playlist") - - # Get the video URL - video_url = first_video.get('url') or first_video.get('webpage_url') - if not video_url: - raise Exception("Could not extract video URL from playlist") - - # Extract detailed information for the first video - self.video_info = ydl.extract_info(video_url, download=False) - - # Update playlist info - playlist_text = f"Playlist: {basic_info.get('title', 'Unknown')} | {len(basic_info['entries'])} videos" - self.playlist_info_label.setText(playlist_text) - self.playlist_info_label.setVisible(True) - else: - self.is_playlist = 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 (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 (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()) - - # 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', {}) - self.update_subtitle_list() - - # Update 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() - - self.signals.update_status.emit("Analysis complete!") - - except Exception as e: - print(f"Detailed extraction failed: {str(e)}") - raise Exception(f"Failed to extract video details: {str(e)}") - - except Exception as e: - error_message = str(e) - print(f"Error in analysis: {error_message}") - self.signals.update_status.emit(f"Error: {error_message}") - - def update_video_info(self, info): - # Format view count with commas - views = int(info.get('view_count', 0)) - formatted_views = f"{views:,}" - - # Format upload date - upload_date = info.get('upload_date', '') - if upload_date: - date_obj = datetime.strptime(upload_date, '%Y%m%d') - formatted_date = date_obj.strftime('%B %d, %Y') - 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.date_label.setText(f"Upload date: {formatted_date}") - self.duration_label.setText(f"Duration: {duration_str}") - - def toggle_subtitle_controls(self): - is_checked = self.subtitle_check.isChecked() - self.merge_subs_btn.setVisible(is_checked) - self.subtitle_combo.setVisible(is_checked) - self.subtitle_filter_input.setVisible(is_checked) - self.subtitle_filter_label.setVisible(is_checked) - - def update_subtitle_list(self): - self.subtitle_combo.clear() - - if not (self.available_subtitles or self.available_automatic_subtitles): - self.subtitle_combo.addItem("No subtitles available") - return - - # Add subtitle options - self.subtitle_combo.addItem("Select subtitle language") - - # Filter and add subtitles - filter_text = self.subtitle_filter_input.text().lower() - - # Add manual subtitles - for lang_code, subtitle_info in self.available_subtitles.items(): - if not filter_text or filter_text in lang_code.lower(): - self.subtitle_combo.addItem(f"{lang_code} - Manual") - - # 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") - - def filter_subtitles(self): - self.subtitle_filter = self.subtitle_filter_input.text() - self.update_subtitle_list() - - def download_thumbnail(self, url): - try: - # Store both thumbnail URL and video URL - self.thumbnail_url = url - self.video_url = self.url_input.text() # Get actual video URL - - # Download thumbnail but don't save yet - response = requests.get(url) - self.thumbnail_image = Image.open(BytesIO(response.content)) - - # Display thumbnail - image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS) - img_byte_arr = BytesIO() - image.save(img_byte_arr, format='PNG') - pixmap = QPixmap() - pixmap.loadFromData(img_byte_arr.getvalue()) - self.thumbnail_label.setPixmap(pixmap) - except Exception as e: - print(f"Error loading thumbnail: {str(e)}") - - def filter_formats(self): - # Clear current table - self.format_table.setRowCount(0) - - # Determine which formats to show - filtered_formats = [] - - if self.video_button.isChecked(): - # Include all video formats (both with and without audio) - filtered_formats.extend([f for f in self.all_formats - if f.get('vcodec') != 'none' - and f.get('filesize') is not None]) - - if self.audio_button.isChecked(): - # Add audio-only formats - filtered_formats.extend([f for f in self.all_formats - if (f.get('vcodec') == 'none' - or 'audio only' in f.get('format_note', '').lower()) - and f.get('acodec') != 'none' - and f.get('filesize') is not None]) - - # Sort formats by quality - def get_quality(f): - if f.get('vcodec') != 'none': - # Extract height from resolution (e.g., "1920x1080" -> 1080) - res = f.get('resolution', '0x0').split('x')[-1] - try: - return int(res) - except ValueError: - return 0 - else: - return f.get('abr', 0) - - filtered_formats.sort(key=get_quality, reverse=True) - - # Update table with filtered formats - self.update_format_table(filtered_formats) - - def update_format_table(self, formats): - self.format_table.setRowCount(0) - for f in formats: - row = self.format_table.rowCount() - self.format_table.insertRow(row) - - # Format ID - self.format_table.setItem(row, 0, QTableWidgetItem(str(f.get('format_id', '')))) - - # Extension - self.format_table.setItem(row, 1, QTableWidgetItem(f.get('ext', ''))) - - # Resolution - resolution = f.get('resolution', 'N/A') - if f.get('vcodec') == 'none': - resolution = 'Audio only' - self.format_table.setItem(row, 2, QTableWidgetItem(resolution)) - - # File Size - filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB" - self.format_table.setItem(row, 3, QTableWidgetItem(filesize)) - - # 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, 4, 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')) # Orange for merge indication - self.format_table.setItem(row, 5, audio_item) - - 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() - path = self.path_input.text().strip() - - if not url or not path: - self.status_label.setText("Please enter URL and download path") - return - - # Get selected format - selected_items = self.format_table.selectedItems() - if not selected_items: - self.status_label.setText("Please select a format") - return - - format_id = self.format_table.item(selected_items[0].row(), 0).text() - - # Get resolution for filename - resolution = self.format_table.item(selected_items[0].row(), 2).text() - resolution = resolution.replace(' ', '').lower() # Clean up resolution string - - # 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() - - # Check if it's a playlist - is_playlist = 'playlist' in url.lower() and '/watch?' not in url - - # Save thumbnail if enabled - if self.save_thumbnail: - 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_lang=subtitle_lang, - is_playlist=is_playlist, - merge_subs=bool(subtitle_lang), - enable_sponsorblock=self.sponsorblock_checkbox.isChecked(), - resolution=resolution - ) - - # Connect signals - self.download_thread.progress_signal.connect(self.update_progress_bar) - self.download_thread.status_signal.connect(self.signals.update_status.emit) - self.download_thread.finished_signal.connect(self.download_finished) - self.download_thread.error_signal.connect(self.download_error) - - # Reset download state - self.download_paused = False - self.download_cancelled = False - - # Show pause/cancel buttons - self.pause_btn.setText('Pause') - self.pause_btn.setVisible(True) - self.cancel_btn.setVisible(True) - - # Start download thread - self.current_download = self.download_thread - self.download_thread.start() - self.toggle_download_controls(False) - - def download_finished(self): - self.toggle_download_controls(True) - self.pause_btn.setVisible(False) - self.cancel_btn.setVisible(False) - self.progress_bar.setValue(100) - 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}") - - def update_progress_bar(self, value): - try: - # Ensure the value is an integer - int_value = int(value) - self.progress_bar.setValue(int_value) - except Exception as e: - print(f"Progress bar update error: {str(e)}") - - def toggle_pause(self): - if self.current_download: - self.current_download.paused = not self.current_download.paused - if self.current_download.paused: - self.pause_btn.setText('Resume') - self.signals.update_status.emit("Download paused") - else: - self.pause_btn.setText('Pause') - self.signals.update_status.emit("Download resumed") - - def check_for_updates(self): - try: - # Get the latest release info from GitHub - response = requests.get( - "https://api.github.com/repos/oop7/YTSage/releases/latest", - headers={"Accept": "application/vnd.github.v3+json"} - ) - response.raise_for_status() - - latest_release = response.json() - latest_version = latest_release["tag_name"].lstrip('v') - - # Compare versions - if version.parse(latest_version) > version.parse(self.version): - 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): - msg = QDialog(self) - msg.setWindowTitle("Update Available") - msg.setMinimumWidth(400) - - layout = QVBoxLayout(msg) - - # Update message - message_label = QLabel( - 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) - - # Buttons - button_layout = QHBoxLayout() - - download_btn = QPushButton("Download Update") - download_btn.clicked.connect(lambda: self.open_release_page(release_url)) - - remind_btn = QPushButton("Remind Me Later") - remind_btn.clicked.connect(msg.close) - - button_layout.addWidget(download_btn) - button_layout.addWidget(remind_btn) - layout.addLayout(button_layout) - - # Style the dialog - msg.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - } - QLabel { - color: #ffffff; - font-size: 12px; - padding: 10px; - } - QPushButton { - padding: 8px 15px; - background-color: #ff0000; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - } - QPushButton:hover { - background-color: #cc0000; - } - """) - - msg.show() - - def open_release_page(self, url): - webbrowser.open(url) - - def show_custom_command(self): - dialog = CustomCommandDialog(self) - dialog.exec() - - def cancel_download(self): - if self.current_download: - self.current_download.cancelled = True - self.signals.update_status.emit("Cancelling download...") - - - def show_ffmpeg_dialog(self): - dialog = FFmpegCheckDialog(self) - dialog.exec() - - def paste_url(self): - clipboard = QApplication.clipboard() - self.url_input.setText(clipboard.text()) - - def update_ytdlp(self): - dialog = YTDLPUpdateDialog(self) - dialog.exec() - - 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 - - try: - from yt_dlp import YoutubeDL - import requests # Use requests instead of urlopen - - print(f"Attempting to save thumbnail for URL: {video_url}") - - ydl_opts = { - 'quiet': True, - 'skip_download': True, - 'force_generic_extractor': False, - 'no_warnings': True, - 'extract_flat': False - } - - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(video_url, download=False) - thumbnails = info.get('thumbnails', []) - - if not thumbnails: - raise ValueError("No thumbnails available") - - thumbnail_url = max( - thumbnails, - key=lambda t: (t.get('height', 0) or 0) * (t.get('width', 0) or 0) - ).get('url') - - if not thumbnail_url: - raise ValueError("Failed to extract thumbnail URL") - - # Download using requests - response = requests.get(thumbnail_url) - response.raise_for_status() - - # Save the thumbnail - thumb_dir = os.path.join(path, 'Thumbnails') - os.makedirs(thumb_dir, exist_ok=True) - - filename = f"{self.sanitize_filename(info['title'])}.jpg" - thumbnail_path = os.path.join(thumb_dir, filename) - - with open(thumbnail_path, 'wb') as f: - f.write(response.content) - - print(f"Thumbnail saved to: {thumbnail_path}") - self.signals.update_status.emit(f"✅ Thumbnail saved: {filename}") - return True - - except Exception as e: - error_msg = f"❌ Thumbnail error: {str(e)}" - print(f"Thumbnail Save Error: {str(e)}") - self.signals.update_status.emit(error_msg) - return False - - def sanitize_filename(self, name): - """Clean filename for filesystem safety""" - return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75] - - def show_about_dialog(self): - dialog = AboutDialog(self) - dialog.exec() - - def toggle_download_controls(self, enabled=True): - """Enable or disable download-related controls""" - self.url_input.setEnabled(enabled) - self.analyze_btn.setEnabled(enabled) - self.format_table.setEnabled(enabled) - 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) - - def handle_format_selection(self, button): - # Update formats - self.filter_formats() \ No newline at end of file diff --git a/ytsage_gui_dialogs.py b/ytsage_gui_dialogs.py new file mode 100644 index 0000000..5c1a69c --- /dev/null +++ b/ytsage_gui_dialogs.py @@ -0,0 +1,673 @@ +import sys +import os +import webbrowser +from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLineEdit, QPushButton, QTableWidget, + QTableWidgetItem, QProgressBar, QLabel, QFileDialog, + QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup) +from PySide6.QtCore import Qt, Signal, QObject, QThread, QProcess +from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap +import requests +from io import BytesIO +from PIL import Image +from datetime import datetime +import json +from pathlib import Path +from packaging import version +import subprocess +import re +import yt_dlp +from ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed + +from ytsage_utils import check_ffmpeg, get_yt_dlp_path, load_saved_path, save_path # Import utility functions + + +class LogWindow(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('yt-dlp Log') + self.setMinimumSize(700, 500) + + layout = QVBoxLayout(self) + + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + self.log_text.setStyleSheet(""" + QTextEdit { + background-color: #2b2b2b; + color: #ffffff; + font-family: Consolas, monospace; + font-size: 12px; + border: 2px solid #3d3d3d; + border-radius: 4px; + } + """) + + layout.addWidget(self.log_text) + + def append_log(self, message): + self.log_text.append(message) + # Auto-scroll to bottom + scrollbar = self.log_text.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + +class CustomCommandDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + self.setWindowTitle('Custom yt-dlp Command') + self.setMinimumSize(600, 400) + + layout = QVBoxLayout(self) + + # Help text + help_text = QLabel( + "Enter custom yt-dlp commands below. The URL will be automatically appended.\n" + "Example: --extract-audio --audio-format mp3 --audio-quality 0\n" + "Note: Download path and output template will be preserved." + ) + help_text.setWordWrap(True) + help_text.setStyleSheet("color: #999999; padding: 10px;") + layout.addWidget(help_text) + + # Command input + self.command_input = QPlainTextEdit() + self.command_input.setPlaceholderText("Enter yt-dlp arguments...") + self.command_input.setStyleSheet(""" + QPlainTextEdit { + background-color: #363636; + color: #ffffff; + border: 2px solid #3d3d3d; + border-radius: 4px; + padding: 8px; + font-family: Consolas, monospace; + } + """) + layout.addWidget(self.command_input) + + # Add SponsorBlock checkbox + self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") + self.sponsorblock_checkbox.setStyleSheet(""" + QCheckBox { + color: #ffffff; + padding: 5px; + margin-left: 20px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 9px; /* Make indicator round */ + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; + border-radius: 9px; /* Make indicator round */ + } + QCheckBox::indicator:checked { + border: 2px solid #ff0000; + background: #ff0000; + border-radius: 9px; /* Make indicator round */ + } + """) + layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox) + + # Buttons + button_layout = QHBoxLayout() + + self.run_btn = QPushButton("Run Command") + self.run_btn.clicked.connect(self.run_custom_command) + + self.close_btn = QPushButton("Close") + self.close_btn.clicked.connect(self.close) + + button_layout.addWidget(self.run_btn) + button_layout.addWidget(self.close_btn) + layout.addLayout(button_layout) + + # Log output + self.log_output = QTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setStyleSheet(""" + QTextEdit { + background-color: #2b2b2b; + color: #ffffff; + border: 2px solid #3d3d3d; + border-radius: 4px; + padding: 8px; + font-family: Consolas, monospace; + font-size: 12px; + } + """) + layout.addWidget(self.log_output) + + self.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + } + QPushButton { + padding: 8px 15px; + background-color: #ff0000; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:hover { + background-color: #cc0000; + } + """) + + def run_custom_command(self): + url = self.parent.url_input.text().strip() + if not url: + self.log_output.append("Error: No URL provided") + return + + command = self.command_input.toPlainText().strip() + path = self.parent.path_input.text().strip() + + self.log_output.clear() + self.log_output.append(f"Running command with URL: {url}") + self.run_btn.setEnabled(False) + + # Start command in thread + import threading + threading.Thread(target=self._run_command_thread, + args=(command, url, path), + daemon=True).start() + + def _run_command_thread(self, command, url, path): + try: + class CommandLogger: + def debug(self, msg): + self.dialog.log_output.append(msg) + def warning(self, msg): + self.dialog.log_output.append(f"Warning: {msg}") + def error(self, msg): + self.dialog.log_output.append(f"Error: {msg}") + def __init__(self, dialog): + self.dialog = dialog + + # Split command into arguments + args = command.split() + + # Base options + ydl_opts = { + 'logger': CommandLogger(self), + 'paths': {'home': path}, + 'debug_printout': True, + 'postprocessors': [] + } + + # Add SponsorBlock options if enabled + if self.sponsorblock_checkbox.isChecked(): + ydl_opts['postprocessors'].extend([{ + 'key': 'SponsorBlock', + 'categories': ['sponsor', 'selfpromo', 'interaction'], + 'api': 'https://sponsor.ajay.app' + }, { + 'key': 'ModifyChapters', + 'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'], + 'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l', + 'force_keyframes': True + }]) + + # Add custom arguments + for i in range(0, len(args), 2): + if i + 1 < len(args): + key = args[i].lstrip('-').replace('-', '_') + value = args[i + 1] + try: + # Try to convert to appropriate type + if value.lower() in ('true', 'false'): + value = value.lower() == 'true' + elif value.isdigit(): + value = int(value) + ydl_opts[key] = value + except: + ydl_opts[key] = value + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + self.log_output.append("Command completed successfully") + + except Exception as e: + self.log_output.append(f"Error: {str(e)}") + finally: + self.run_btn.setEnabled(True) + +class FFmpegInstallThread(QThread): + finished = Signal(bool) + progress = Signal(str) + + def run(self): + # Redirect stdout to capture progress messages + import sys + from io import StringIO + import contextlib + + output = StringIO() + with contextlib.redirect_stdout(output): + success = auto_install_ffmpeg() + + # Process captured output and emit progress signals + for line in output.getvalue().splitlines(): + self.progress.emit(line) + + self.finished.emit(success) + +class FFmpegCheckDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('Installing FFmpeg') + self.setMinimumWidth(450) + self.setMinimumHeight(250) + + # Set the window icon to match the main app + self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) + + layout = QVBoxLayout(self) + layout.setSpacing(15) + + # Header with icon + header_layout = QHBoxLayout() + icon_label = QLabel() + icon_label.setPixmap(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown).pixmap(32, 32)) + header_layout.addWidget(icon_label) + + header_text = QLabel("FFmpeg Installation") + header_text.setStyleSheet("font-size: 16px; font-weight: bold;") + header_layout.addWidget(header_text) + header_layout.addStretch() + layout.addLayout(header_layout) + + # Message + self.message_label = QLabel( + "🎥 YTSage needs FFmpeg to process videos.\n" + "Let's set it up for you automatically!" + ) + self.message_label.setWordWrap(True) + self.message_label.setStyleSheet("font-size: 13px;") + layout.addWidget(self.message_label) + + # Progress label with cool emojis + self.progress_label = QLabel("") + self.progress_label.setWordWrap(True) + self.progress_label.setStyleSheet(""" + QLabel { + background-color: #1e1e1e; + border-radius: 5px; + padding: 10px; + font-family: 'Consolas', monospace; + font-size: 12px; + } + """) + self.progress_label.hide() + layout.addWidget(self.progress_label) + + # Buttons container + button_layout = QHBoxLayout() + + # Install button + self.install_btn = QPushButton("Install FFmpeg") + self.install_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) + self.install_btn.clicked.connect(self.start_installation) + button_layout.addWidget(self.install_btn) + + # Manual install button + self.manual_btn = QPushButton("Manual Guide") + self.manual_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogHelpButton)) + self.manual_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide')) + button_layout.addWidget(self.manual_btn) + + # Close button + self.close_btn = QPushButton("Close") + self.close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton)) + self.close_btn.clicked.connect(self.close) + button_layout.addWidget(self.close_btn) + + layout.addLayout(button_layout) + + # Style the dialog + self.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + } + QLabel { + color: #ffffff; + } + QPushButton { + padding: 8px 15px; + background-color: #3d3d3d; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + margin: 5px; + min-width: 100px; + } + QPushButton:hover { + background-color: #4d4d4d; + } + QPushButton:disabled { + background-color: #2d2d2d; + color: #666666; + } + """) + + # Initialize installation thread + self.install_thread = None + + def start_installation(self): + self.install_btn.setEnabled(False) + self.manual_btn.setEnabled(False) + self.close_btn.setEnabled(False) + + # Check if FFmpeg is already installed + if check_ffmpeg_installed(): + self.message_label.setText("🎉 FFmpeg is already installed!") + self.progress_label.setText("✅ You can close this dialog and continue using YTSage.") + self.install_btn.hide() + self.manual_btn.hide() + self.close_btn.setEnabled(True) + return + + self.message_label.setText("🚀 Installing FFmpeg... Hold tight!") + self.progress_label.show() + + self.install_thread = FFmpegInstallThread() + self.install_thread.finished.connect(self.installation_finished) + self.install_thread.progress.connect(self.update_progress) + self.install_thread.start() + + def update_progress(self, message): + self.progress_label.setText(message) + + def installation_finished(self, success): + if success: + self.message_label.setText("🎉 FFmpeg has been installed successfully!") + self.progress_label.setText("✅ You're all set! You can now close this dialog and continue using YTSage.") + self.install_btn.hide() + self.manual_btn.hide() + else: + self.message_label.setText("❌ Oops! FFmpeg installation encountered an issue.") + self.progress_label.setText("💡 Try using the manual installation guide instead.") + self.install_btn.setEnabled(True) + self.manual_btn.setEnabled(True) + + self.close_btn.setEnabled(True) + +class YTDLPUpdateDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Update yt-dlp") + self.setMinimumWidth(400) + + layout = QVBoxLayout(self) + + # Status label + self.status_label = QLabel("Checking for updates...") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.status_label) + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.hide() # Hide initially + layout.addWidget(self.progress_bar) + + # Buttons + button_layout = QHBoxLayout() + self.update_btn = QPushButton("Update") + self.update_btn.clicked.connect(self.perform_update) + self.update_btn.setEnabled(False) + + self.close_btn = QPushButton("Close") + self.close_btn.clicked.connect(self.close) + + button_layout.addWidget(self.update_btn) + button_layout.addWidget(self.close_btn) + layout.addLayout(button_layout) + + # Style + self.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + } + QLabel { + color: #ffffff; + font-size: 12px; + padding: 10px; + } + QPushButton { + padding: 8px 15px; + background-color: #ff0000; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + min-width: 100px; + } + QPushButton:disabled { + background-color: #666666; + } + QPushButton:hover { + background-color: #cc0000; + } + QProgressBar { + border: 2px solid #3d3d3d; + border-radius: 4px; + text-align: center; + color: white; + background-color: #363636; + height: 25px; + } + QProgressBar::chunk { + background-color: #ff0000; + border-radius: 2px; + } + """) + + # Start version check + self.check_version() + + def check_version(self): + try: + # Get current version using yt-dlp command + import subprocess + import sys + import os + + # Get the yt-dlp executable path + if getattr(sys, 'frozen', False): + if sys.platform == 'win32': + yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe') + else: + yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp') + else: + yt_dlp_path = 'yt-dlp' + + # Get current version + try: + result = subprocess.run([yt_dlp_path, '--version'], + capture_output=True, + text=True) + current_version = result.stdout.strip() + except Exception: + import yt_dlp + current_version = yt_dlp.version.__version__ + + # Get latest version from PyPI + import requests + response = requests.get("https://pypi.org/pypi/yt-dlp/json") + latest_version = response.json()["info"]["version"] + + # Clean up version strings + current_version = current_version.replace('_', '.') + latest_version = latest_version.replace('_', '.') + + # Compare versions + from packaging import version + try: + current_ver = version.parse(current_version) + latest_ver = version.parse(latest_version) + + if current_ver < latest_ver: + self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}") + self.update_btn.setEnabled(True) + else: + self.status_label.setText(f"yt-dlp is up to date (version {current_version})") + self.update_btn.setEnabled(False) + except version.InvalidVersion: + # If version parsing fails, do a simple string comparison + if current_version != latest_version: + self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}") + self.update_btn.setEnabled(True) + else: + self.status_label.setText(f"yt-dlp is up to date (version {current_version})") + self.update_btn.setEnabled(False) + + except Exception as e: + self.status_label.setText(f"Error checking version: {str(e)}") + self.update_btn.setEnabled(False) + + def perform_update(self): + try: + self.update_btn.setEnabled(False) + self.close_btn.setEnabled(False) + self.status_label.setText("Updating yt-dlp...") + self.progress_bar.setRange(0, 0) + self.progress_bar.show() + + # Get the yt-dlp path + if getattr(sys, 'frozen', False): + if sys.platform == 'win32': + yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe') + else: + yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp') + else: + yt_dlp_path = 'yt-dlp' + + # Create a QProcess for updating + process = QProcess() + process.setWorkingDirectory(os.path.dirname(yt_dlp_path)) + + if sys.platform == 'win32': + # For Windows, use pip to update + python_path = os.path.join(os.path.dirname(sys.executable), 'python.exe') + if not os.path.exists(python_path): + python_path = sys.executable + + process.start(python_path, ['-m', 'pip', 'install', '--upgrade', '--no-cache-dir', 'yt-dlp']) + else: + # For Unix systems + process.start('pip', ['install', '--upgrade', '--no-cache-dir', 'yt-dlp']) + + process.waitForFinished() + + if process.exitCode() == 0: + self.status_label.setText("Update completed successfully!\nPlease restart the application.") + self.check_version() # Recheck version after update + else: + error = process.readAllStandardError().data().decode() + self.status_label.setText(f"Update failed: {error}") + + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(100) + self.close_btn.setEnabled(True) + + except Exception as e: + self.status_label.setText(f"Update failed: {str(e)}") + self.close_btn.setEnabled(True) + self.progress_bar.hide() + +class AboutDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('About YTSage') + self.setMinimumWidth(500) + + 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 {parent.version}") + version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(version_label) + + # Description + description = QLabel( + "A modern YouTube downloader with a clean interface.\n" + "Download videos in any quality, extract audio, fetch subtitles,\n" + "and view video metadata. Built with yt-dlp for reliable performance." + ) + description.setWordWrap(True) + description.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(description) + + # 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) + + # Credits + credits_label = QLabel( + "Powered by:\n" + "• yt-dlp\n" + "• PySide6\n" + "• FFmpeg" + ) + credits_label.setStyleSheet("padding: 10px;") + layout.addWidget(credits_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) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close) + layout.addWidget(close_btn) + + # Style the dialog + self.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + } + QLabel { + color: #ffffff; + font-size: 12px; + } + QPushButton { + padding: 8px 15px; + background-color: #ff0000; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:hover { + background-color: #cc0000; + } + """) \ No newline at end of file diff --git a/ytsage_gui_format_table.py b/ytsage_gui_format_table.py new file mode 100644 index 0000000..d5b538d --- /dev/null +++ b/ytsage_gui_format_table.py @@ -0,0 +1,328 @@ +from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLineEdit, QPushButton, QTableWidget, + QTableWidgetItem, QProgressBar, QLabel, QFileDialog, + QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, + QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QScrollArea, + QSizePolicy) +from PySide6.QtCore import Qt, Signal, QObject, QThread +from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap + +class FormatSignals(QObject): + format_update = Signal(list) + +class FormatTableMixin: + def setup_format_table(self): + self.format_signals = FormatSignals() + + # Format table with improved styling + self.format_table = QTableWidget() + self.format_table.setColumnCount(8) + self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes']) + + # Set specific column widths and resize modes + self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Select + self.format_table.setColumnWidth(0, 50) # Select column width + + self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) # Quality + self.format_table.setColumnWidth(1, 100) # Quality width + + self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Extension + self.format_table.setColumnWidth(2, 80) # Extension width + + self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Resolution + self.format_table.setColumnWidth(3, 100) # Resolution width + + self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # File Size + self.format_table.setColumnWidth(4, 100) # File Size width + + self.format_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # Codec + self.format_table.setColumnWidth(5, 150) # Codec width + + self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # Audio + self.format_table.setColumnWidth(6, 120) # Audio width + + self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) # Notes (will stretch) + + # Set vertical header (row numbers) visible to false + self.format_table.verticalHeader().setVisible(False) + + # Set selection mode to no selection (since we're using checkboxes) + self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) + + self.format_table.setStyleSheet(""" + QTableWidget { + background-color: #363636; + border: 2px solid #3d3d3d; + border-radius: 4px; + gridline-color: #3d3d3d; + } + QTableWidget::item { + padding: 5px; + border-bottom: 1px solid #3d3d3d; + } + QTableWidget::item:selected { + background-color: transparent; + } + QHeaderView::section { + background-color: #2b2b2b; + padding: 5px; + border: 1px solid #3d3d3d; + font-weight: bold; + color: white; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + border-radius: 8px; + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; + } + QCheckBox::indicator:checked { + border: 2px solid #ff0000; + background: #ff0000; + } + QWidget { + background-color: transparent; + } + """) + + # Store format checkboxes and formats + self.format_checkboxes = [] + self.all_formats = [] + + # Set table size policies + self.format_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + # Set minimum and maximum heights + self.format_table.setMinimumHeight(200) + + # Connect the signal + self.format_signals.format_update.connect(self._update_format_table) + + return self.format_table + + def filter_formats(self): + if not hasattr(self, 'all_formats'): + return + + # Clear current table + self.format_table.setRowCount(0) + self.format_checkboxes.clear() + + # Determine which formats to show + filtered_formats = [] + + if hasattr(self, 'video_button') and self.video_button.isChecked(): + filtered_formats.extend([f for f in self.all_formats + if f.get('vcodec') != 'none' + and f.get('filesize') is not None]) + + if hasattr(self, 'audio_button') and self.audio_button.isChecked(): + filtered_formats.extend([f for f in self.all_formats + if (f.get('vcodec') == 'none' + or 'audio only' in f.get('format_note', '').lower()) + and f.get('acodec') != 'none' + and f.get('filesize') is not None]) + + # Sort formats by quality + def get_quality(f): + if f.get('vcodec') != 'none': + res = f.get('resolution', '0x0').split('x')[-1] + try: + return int(res) + except ValueError: + return 0 + else: + return f.get('abr', 0) + + filtered_formats.sort(key=get_quality, reverse=True) + + # Update table with filtered formats + self.format_signals.format_update.emit(filtered_formats) + + def _update_format_table(self, formats): + self.format_table.setRowCount(0) + self.format_checkboxes.clear() + + # Find best quality format for recommendations + best_video_size = max((f.get('filesize', 0) for f in formats if f.get('vcodec') != 'none'), default=0) + + for f in formats: + row = self.format_table.rowCount() + self.format_table.insertRow(row) + + # 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) + checkbox_layout.addWidget(checkbox) + checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + checkbox_layout.setContentsMargins(0, 0, 0, 0) + checkbox_layout.setSpacing(0) + self.format_table.setCellWidget(row, 0, checkbox_widget) + + # Quality (replacing Format ID) + quality_text = self.get_quality_label(f) + quality_item = QTableWidgetItem(quality_text) + # Set color based on quality + if "Best" in quality_text: + quality_item.setForeground(QColor('#00ff00')) # Green for best quality + elif "High" in quality_text: + quality_item.setForeground(QColor('#00cc00')) # Light green for high quality + elif "Medium" in quality_text: + quality_item.setForeground(QColor('#ffaa00')) # Orange for medium quality + elif "Low" in quality_text: + quality_item.setForeground(QColor('#ff5555')) # Red for low quality + self.format_table.setItem(row, 1, quality_item) + + # Extension + self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper())) + + # Resolution + resolution = f.get('resolution', 'N/A') + if f.get('vcodec') == 'none': + resolution = 'Audio only' + self.format_table.setItem(row, 3, QTableWidgetItem(resolution)) + + # File Size + filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB" + self.format_table.setItem(row, 4, QTableWidgetItem(filesize)) + + # Codec + if f.get('vcodec') == 'none': + codec = f.get('acodec', 'N/A') + else: + codec = f"{f.get('vcodec', 'N/A')}" + if f.get('acodec') != 'none': + codec += f" / {f.get('acodec', 'N/A')}" + self.format_table.setItem(row, 5, QTableWidgetItem(codec)) + + # Audio Status + needs_audio = f.get('acodec') == 'none' + audio_status = "Will merge audio" if needs_audio else "✓ Has Audio" + audio_item = QTableWidgetItem(audio_status) + if needs_audio: + audio_item.setForeground(QColor('#ffa500')) + self.format_table.setItem(row, 6, audio_item) + + # Add Notes column + notes = self.get_format_notes(f, best_video_size) + notes_item = QTableWidgetItem(notes) + if "✨ Recommended" in notes: + notes_item.setForeground(QColor('#00ff00')) # Green for recommended + elif "💾 Storage friendly" in notes: + notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly + elif "📱 Mobile friendly" in notes: + notes_item.setForeground(QColor('#ff9900')) # Orange for mobile + self.format_table.setItem(row, 7, notes_item) + + def handle_checkbox_click(self, clicked_checkbox): + for checkbox in self.format_checkboxes: + if checkbox != clicked_checkbox: + checkbox.setChecked(False) + + def get_selected_format(self): + for checkbox in self.format_checkboxes: + if checkbox.isChecked(): + return checkbox.format_id + return None + + def update_format_table(self, formats): + self.all_formats = formats + self.format_signals.format_update.emit(formats) + + def get_quality_label(self, format_info): + """Determine quality label based on format information""" + if format_info.get('vcodec') == 'none': + # Audio quality + abr = format_info.get('abr', 0) + if abr >= 256: + return "Best Audio" + elif abr >= 192: + return "High Audio" + elif abr >= 128: + return "Medium Audio" + else: + return "Low Audio" + else: + # Video quality + height = 0 + resolution = format_info.get('resolution', '') + if resolution: + try: + height = int(resolution.split('x')[1]) + except: + pass + + if height >= 2160: + return "Best (4K)" + elif height >= 1440: + return "Best (2K)" + elif height >= 1080: + return "High (1080p)" + elif height >= 720: + return "High (720p)" + elif height >= 480: + return "Medium (480p)" + else: + return "Low Quality" + + def get_format_notes(self, format_info, best_video_size): + """Generate helpful notes about the format""" + if format_info.get('vcodec') == 'none': + # Audio format + abr = format_info.get('abr', 0) + if abr >= 256: + return "✨ Recommended for music" + elif abr >= 128: + return "📱 Mobile friendly" + return "💾 Storage friendly" + else: + # Video format + height = 0 + resolution = format_info.get('resolution', '') + if resolution: + try: + height = int(resolution.split('x')[1]) + except: + pass + + filesize = format_info.get('filesize', 0) + + notes = [] + + # Resolution-based recommendations + if height >= 1440: # 2K or 4K + if filesize == best_video_size: + notes.append("✨ Recommended for high-end displays") + else: + notes.append("🖥️ Best for large screens") + elif height == 1080: + if 'avc1' in format_info.get('vcodec', '').lower(): + notes.append("✨ Recommended for most devices") + else: + notes.append("👍 Good balance") + elif height == 720: + notes.append("📱 Mobile friendly") + else: + notes.append("💾 Storage friendly") + + # Codec-based notes + if 'av1' in format_info.get('vcodec', '').lower(): + notes.append("Better compression") + elif 'vp9' in format_info.get('vcodec', '').lower(): + notes.append("Good for Chrome") + + # File size note for large files + if filesize > 100 * 1024 * 1024: # More than 100MB + notes.append("Large file") + + return " • ".join(notes) \ No newline at end of file diff --git a/ytsage_gui_main.py b/ytsage_gui_main.py new file mode 100644 index 0000000..9efa5e5 --- /dev/null +++ b/ytsage_gui_main.py @@ -0,0 +1,907 @@ +import sys +import os +import webbrowser +from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLineEdit, QPushButton, QTableWidget, + QTableWidgetItem, QProgressBar, QLabel, QFileDialog, + QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QMessageBox) +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 +from PIL import Image +from datetime import datetime +import json +from pathlib import Path +from packaging import version +import subprocess +import re +import yt_dlp + +from ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes +from ytsage_utils import check_ffmpeg, get_yt_dlp_path, load_saved_path, save_path # Import utility functions +from ytsage_gui_dialogs import LogWindow, CustomCommandDialog, FFmpegCheckDialog, YTDLPUpdateDialog, AboutDialog # Import dialogs +from ytsage_gui_format_table import FormatTableMixin # Import FormatTableMixin +from ytsage_gui_video_info import VideoInfoMixin # Import VideoInfoMixin + +class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins + def __init__(self): + super().__init__() + # Check for FFmpeg before proceeding + if not check_ffmpeg(): + self.show_ffmpeg_dialog() + + self.version = "4.2.0" + self.check_for_updates() + self.config_file = Path.home() / '.ytsage_config.json' + load_saved_path(self) + self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) + self.signals = SignalManager() + self.download_paused = False + self.current_download = None + self.download_cancelled = False + self.save_thumbnail = False # Initialize thumbnail state + self.thumbnail_url = None # Add this to store thumbnail URL + self.all_formats = [] # Initialize all_formats + self.available_subtitles = {} + self.available_automatic_subtitles = {} + self.is_playlist = False + self.playlist_info = None + self.video_info = None + self.subtitle_filter = "" + self.thumbnail_image = None + self.video_url = "" + + self.init_ui() + self.setStyleSheet(""" + QMainWindow { + background-color: #2b2b2b; + } + QWidget { + background-color: #2b2b2b; + color: #ffffff; + } + QLineEdit { + padding: 8px; + border: 2px solid #3d3d3d; + border-radius: 4px; + background-color: #363636; + color: #ffffff; + } + QPushButton { + padding: 8px 15px; + background-color: #ff0000; /* YouTube red */ + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:hover { + background-color: #cc0000; /* Darker red on hover */ + } + QPushButton:pressed { + background-color: #990000; /* Even darker red when pressed */ + } + QTableWidget { + border: 2px solid #3d3d3d; + border-radius: 4px; + background-color: #363636; + gridline-color: #3d3d3d; + } + QHeaderView::section { + background-color: #2b2b2b; + padding: 5px; + border: 1px solid #3d3d3d; + color: #ffffff; + } + QProgressBar { + border: 2px solid #3d3d3d; + border-radius: 4px; + text-align: center; + color: white; + } + QProgressBar::chunk { + background-color: #ff0000; /* YouTube red */ + border-radius: 2px; + } + QLabel { + color: #ffffff; + } + /* Style for filter buttons */ + QPushButton.filter-btn { + background-color: #363636; + padding: 5px 10px; + margin: 0 5px; + } + QPushButton.filter-btn:checked { + background-color: #ff0000; + } + QPushButton.filter-btn:hover { + background-color: #444444; + } + QPushButton.filter-btn:checked:hover { + background-color: #cc0000; + } + /* Modern Scrollbar Styling */ + QScrollBar:vertical { + border: none; + background: #2b2b2b; + width: 14px; + margin: 15px 0 15px 0; + border-radius: 7px; + } + QScrollBar::handle:vertical { + background: #404040; + min-height: 30px; + border-radius: 7px; + } + QScrollBar::handle:vertical:hover { + background: #505050; + } + QScrollBar::sub-line:vertical { + border: none; + background: #2b2b2b; + height: 15px; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + subcontrol-position: top; + subcontrol-origin: margin; + } + QScrollBar::add-line:vertical { + border: none; + background: #2b2b2b; + height: 15px; + border-bottom-left-radius: 7px; + border-bottom-right-radius: 7px; + subcontrol-position: bottom; + subcontrol-origin: margin; + } + QScrollBar::sub-line:vertical:hover, + QScrollBar::add-line:vertical:hover { + background: #404040; + } + QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { + background: none; + width: 0; + height: 0; + } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: none; + } + /* Horizontal Scrollbar */ + QScrollBar:horizontal { + border: none; + background: #2b2b2b; + height: 14px; + margin: 0 15px 0 15px; + border-radius: 7px; + } + QScrollBar::handle:horizontal { + background: #404040; + min-width: 30px; + border-radius: 7px; + } + QScrollBar::handle:horizontal:hover { + background: #505050; + } + QScrollBar::sub-line:horizontal { + border: none; + background: #2b2b2b; + width: 15px; + border-top-left-radius: 7px; + border-bottom-left-radius: 7px; + subcontrol-position: left; + subcontrol-origin: margin; + } + QScrollBar::add-line:horizontal { + border: none; + background: #2b2b2b; + width: 15px; + border-top-right-radius: 7px; + border-bottom-right-radius: 7px; + subcontrol-position: right; + subcontrol-origin: margin; + } + QScrollBar::sub-line:horizontal:hover, + QScrollBar::add-line:horizontal:hover { + background: #404040; + } + QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal { + background: none; + width: 0; + height: 0; + } + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: none; + } + """) + self.signals.update_progress.connect(self.update_progress_bar) + + 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) + + def save_path(self, path): # Using function from ytsage_utils now - no longer needed in class + save_path(self, path) # Call the utility function + + def init_ui(self): + self.setWindowTitle('YTSage v4.2.0') + self.setMinimumSize(900, 650) + + # Main widget and layout + main_widget = QWidget() + self.setCentralWidget(main_widget) + layout = QVBoxLayout(main_widget) + layout.setSpacing(8) + layout.setContentsMargins(20, 20, 20, 20) + + # URL input section + url_layout = QHBoxLayout() + self.url_input = QLineEdit() + self.url_input.setPlaceholderText('Enter YouTube URL...') + + # Add Paste URL button + self.paste_btn = QPushButton('Paste URL') + self.paste_btn.clicked.connect(self.paste_url) + + 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 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 + 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) + + # Format controls section with minimal spacing + layout.addSpacing(5) + + # Format selection layout (horizontal) + self.format_layout = QHBoxLayout() + + # Show formats label + self.show_formats_label = QLabel("Show formats:") + self.show_formats_label.setStyleSheet("color: white;") + self.format_layout.addWidget(self.show_formats_label) + + # Format buttons group + self.format_buttons = QButtonGroup(self) + self.format_buttons.setExclusive(True) + + # Video button + self.video_button = QPushButton("Video") + self.video_button.setCheckable(True) + self.video_button.setChecked(True) # Set video as default + self.video_button.setStyleSheet(""" + QPushButton { + padding: 8px 15px; + background-color: #363636; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:checked { + background-color: #ff0000; + } + QPushButton:hover { + background-color: #444444; + } + QPushButton:checked:hover { + background-color: #cc0000; + } + """) + self.format_buttons.addButton(self.video_button) + self.format_layout.addWidget(self.video_button) + + # Audio button + self.audio_button = QPushButton("Audio Only") + self.audio_button.setCheckable(True) + self.audio_button.setStyleSheet(""" + QPushButton { + padding: 8px 15px; + background-color: #363636; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:checked { + background-color: #ff0000; + } + QPushButton:hover { + background-color: #444444; + } + QPushButton:checked:hover { + background-color: #cc0000; + } + """) + self.format_buttons.addButton(self.audio_button) + self.format_layout.addWidget(self.audio_button) + + # Connect format buttons + self.format_buttons.buttonClicked.connect(self.handle_format_selection) + + # Add SponsorBlock checkbox + self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments") + self.sponsorblock_checkbox.setStyleSheet(""" + QCheckBox { + color: #ffffff; + padding: 5px; + margin-left: 20px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 9px; + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; + border-radius: 9px; + } + QCheckBox::indicator:checked { + border: 2px solid #ff0000; + background: #ff0000; + border-radius: 9px; + } + """) + self.format_layout.addWidget(self.sponsorblock_checkbox) + + # Add Save Thumbnail checkbox with same style as SponsorBlock + self.save_thumbnail_checkbox = QCheckBox("Save Thumbnail") + self.save_thumbnail_checkbox.setStyleSheet(""" + QCheckBox { + color: #ffffff; + padding: 5px; + margin-left: 20px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 9px; + } + QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; + border-radius: 9px; + } + QCheckBox::indicator:checked { + border: 2px solid #ff0000; + background: #ff0000; + border-radius: 9px; + } + """) + self.save_thumbnail_checkbox.clicked.connect(self.toggle_save_thumbnail) + self.format_layout.addWidget(self.save_thumbnail_checkbox) + + self.format_layout.addStretch() + layout.addLayout(self.format_layout) + + # Format table with stretch + format_table = self.setup_format_table() + layout.addWidget(format_table, stretch=1) + + # Download section + download_layout = QHBoxLayout() + + # Add existing buttons + self.custom_cmd_btn = QPushButton('Custom Command') + self.custom_cmd_btn.clicked.connect(self.show_custom_command) + + self.update_ytdlp_btn = QPushButton('Update yt-dlp') + self.update_ytdlp_btn.clicked.connect(self.update_ytdlp) + + self.about_btn = QPushButton('About') + self.about_btn.clicked.connect(self.show_about_dialog) + + self.path_input = QLineEdit(self.last_path) + self.path_input.setPlaceholderText('Download path...') + + self.browse_btn = QPushButton('Browse') + self.browse_btn.clicked.connect(self.browse_path) + + self.download_btn = QPushButton('Download') + self.download_btn.clicked.connect(self.start_download) + + # Add pause and cancel buttons + self.pause_btn = QPushButton('Pause') + self.pause_btn.clicked.connect(self.toggle_pause) + self.pause_btn.setVisible(False) # Hidden initially + + self.cancel_btn = QPushButton('Cancel') + self.cancel_btn.clicked.connect(self.cancel_download) + self.cancel_btn.setVisible(False) # Hidden initially + + # Add all buttons to layout in the correct order + download_layout.addWidget(self.custom_cmd_btn) + download_layout.addWidget(self.update_ytdlp_btn) + download_layout.addWidget(self.about_btn) + download_layout.addWidget(self.path_input) + download_layout.addWidget(self.browse_btn) + download_layout.addWidget(self.download_btn) + download_layout.addWidget(self.pause_btn) + download_layout.addWidget(self.cancel_btn) + + layout.addLayout(download_layout) + + # Progress section with improved styling + progress_layout = QVBoxLayout() + self.progress_bar = QProgressBar() + self.progress_bar.setStyleSheet(""" + QProgressBar { + border: 2px solid #3d3d3d; + border-radius: 4px; + text-align: center; + color: white; + background-color: #363636; + height: 25px; + } + QProgressBar::chunk { + background-color: #ff0000; + border-radius: 2px; + } + """) + progress_layout.addWidget(self.progress_bar) + + # Add download details label with improved styling + self.download_details_label = QLabel() + self.download_details_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.download_details_label.setStyleSheet(""" + QLabel { + color: #cccccc; + font-size: 12px; + padding: 5px; + } + """) + progress_layout.addWidget(self.download_details_label) + + self.status_label = QLabel('Ready') + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.status_label.setStyleSheet(""" + QLabel { + color: #cccccc; + font-size: 12px; + padding: 5px; + } + """) + progress_layout.addWidget(self.status_label) + + layout.addLayout(progress_layout) + + # Connect signals + self.signals.update_formats.connect(self.update_format_table) + 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: + self.signals.update_status.emit("Invalid URL or please enter a URL.") + return + + self.signals.update_status.emit("Analyzing (0%)... Preparing request") + import threading # Import threading here as it is only used in GUI and downloader + threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start() + + def _analyze_url_thread(self, url): + try: + 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 + ydl_opts = { + 'quiet': False, + 'no_warnings': False, + 'extract_flat': True, + 'force_generic_extractor': False, + 'ignoreerrors': True, + 'no_color': True, + 'verbose': True + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + try: + basic_info = ydl.extract_info(url, download=False) + if not basic_info: + raise Exception("Could not extract basic video information") + except Exception as e: + print(f"First extraction failed: {str(e)}") + raise Exception("Could not extract video information, please check your link") + + self.signals.update_status.emit("Analyzing (40%)... Extracting detailed info") + # Configure options for detailed extraction + ydl_opts.update({ + 'extract_flat': False, + 'format': None, + 'writesubtitles': True, + 'allsubtitles': True, + 'writeautomaticsub': True, + 'playliststart': 1, + 'playlistend': 1, + 'youtube_include_dash_manifest': True, + 'youtube_include_hls_manifest': True + }) + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + try: + 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 + + # 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, + Q_ARG(str, playlist_text) + ) + QMetaObject.invokeMethod( + self.playlist_info_label, + "setVisible", + Qt.ConnectionType.QueuedConnection, + Q_ARG(bool, True) + ) + + # Show playlist selection input + QMetaObject.invokeMethod( + self.playlist_selection_input, + "setVisible", + Qt.ConnectionType.QueuedConnection, + Q_ARG(bool, True) + ) + else: + self.is_playlist = 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 (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 (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()) + + # 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', {}) + self.update_subtitle_list() + + # Update 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() + + self.signals.update_status.emit("Analysis complete!") + + except Exception as e: + print(f"Detailed extraction failed: {str(e)}") + raise Exception(f"Failed to extract video details: {str(e)}") + + except Exception as e: + error_message = str(e) + print(f"Error in analysis: {error_message}") + self.signals.update_status.emit(f"Error: {error_message}") + + def paste_url(self): + clipboard = QApplication.clipboard() + self.url_input.setText(clipboard.text()) + + def update_ytdlp(self): + dialog = YTDLPUpdateDialog(self) + dialog.exec() + + def browse_path(self): + path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.last_path) + if path: + self.path_input.setText(path) + self.save_path(path) # Use save_path utility function + self.last_path = path + + def start_download(self): + url = self.url_input.text().strip() + path = self.path_input.text().strip() + + if not url or not path: + self.status_label.setText("Please enter URL and download path") + return + + # Get selected format + format_id = self.get_selected_format() + if not format_id: + self.status_label.setText("Please select a format") + return + + # Show preparation message + self.status_label.setText("🚀 Preparing your download...") + self.progress_bar.setValue(0) + + # Get resolution for filename + resolution = 'default' + for checkbox in self.format_checkboxes: + if checkbox.isChecked(): + parts = checkbox.text().split('•') + if len(parts) >= 1: + resolution = parts[0].strip().lower() + break + + # Get subtitle selection if available + subtitle_lang = None + if hasattr(self, 'subtitle_combo') and self.subtitle_combo.currentIndex() > 0: + subtitle_lang = self.subtitle_combo.currentText() + + # Check if it's a playlist + is_playlist = 'playlist' in url.lower() and '/watch?' not in url + + # 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: + 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_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 + ) + + # Connect signals + self.download_thread.progress_signal.connect(self.update_progress_bar) + self.download_thread.status_signal.connect(self.signals.update_status.emit) + self.download_thread.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) + + # Reset download state + self.download_paused = False + self.download_cancelled = False + + # Show pause/cancel buttons + self.pause_btn.setText('Pause') + self.pause_btn.setVisible(True) + self.cancel_btn.setVisible(True) + + # Start download thread + self.current_download = self.download_thread + self.download_thread.start() + self.toggle_download_controls(False) + + def download_finished(self): + self.toggle_download_controls(True) + self.pause_btn.setVisible(False) + self.cancel_btn.setVisible(False) + self.progress_bar.setValue(100) + 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}") + + def update_progress_bar(self, value): + try: + # Ensure the value is an integer + int_value = int(value) + self.progress_bar.setValue(int_value) + except Exception as e: + print(f"Progress bar update error: {str(e)}") + + def toggle_pause(self): + if self.current_download: + self.current_download.paused = not self.current_download.paused + if self.current_download.paused: + self.pause_btn.setText('Resume') + self.signals.update_status.emit("Download paused") + else: + self.pause_btn.setText('Pause') + self.signals.update_status.emit("Download resumed") + + def check_for_updates(self): + try: + # Get the latest release info from GitHub + response = requests.get( + "https://api.github.com/repos/oop7/YTSage/releases/latest", + headers={"Accept": "application/vnd.github.v3+json"} + ) + response.raise_for_status() + + latest_release = response.json() + latest_version = latest_release["tag_name"].lstrip('v') + + # Compare versions + if version.parse(latest_version) > version.parse(self.version): + 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): + msg = QDialog(self) + msg.setWindowTitle("Update Available") + msg.setMinimumWidth(400) + + layout = QVBoxLayout(msg) + + # Update message + message_label = QLabel( + 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) + + # Buttons + button_layout = QHBoxLayout() + + download_btn = QPushButton("Download Update") + download_btn.clicked.connect(lambda: self.open_release_page(release_url)) + + remind_btn = QPushButton("Remind Me Later") + remind_btn.clicked.connect(msg.close) + + button_layout.addWidget(download_btn) + button_layout.addWidget(remind_btn) + layout.addLayout(button_layout) + + # Style the dialog + msg.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + } + QLabel { + color: #ffffff; + font-size: 12px; + padding: 10px; + } + QPushButton { + padding: 8px 15px; + background-color: #ff0000; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:hover { + background-color: #cc0000; + } + """) + + msg.show() + + def open_release_page(self, url): + webbrowser.open(url) + + def show_custom_command(self): + dialog = CustomCommandDialog(self) + dialog.exec() + + def cancel_download(self): + if self.current_download: + self.current_download.cancelled = True + self.signals.update_status.emit("Cancelling download...") + + def show_ffmpeg_dialog(self): + dialog = FFmpegCheckDialog(self) + dialog.exec() + + def toggle_download_controls(self, enabled=True): + """Enable or disable download-related controls""" + self.url_input.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) + + def handle_format_selection(self, button): + # Update formats + self.filter_formats() + + def show_about_dialog(self): # ADDED METHOD HERE + dialog = AboutDialog(self) + dialog.exec() + + def file_already_exists(self, filename): + """Handle case when file already exists - simplified version""" + self.toggle_download_controls(True) + self.pause_btn.setVisible(False) + self.cancel_btn.setVisible(False) + self.progress_bar.setValue(100) + self.status_label.setText(f"⚠️ File already exists: {filename}") + + # Show a simple message dialog + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Information) + msg_box.setWindowTitle("File Already Exists") + msg_box.setText(f"The file already exists:\n{filename}") + msg_box.setInformativeText("This video has already been downloaded.") + msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) + + # Set the window icon to match the main application + msg_box.setWindowIcon(self.windowIcon()) + + # Style the dialog + msg_box.setStyleSheet(""" + QMessageBox { + background-color: #2b2b2b; + } + QLabel { + color: #ffffff; + } + QPushButton { + padding: 8px 15px; + background-color: #ff0000; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + min-width: 80px; + } + QPushButton:hover { + background-color: #cc0000; + } + """) + + msg_box.exec() \ No newline at end of file diff --git a/ytsage_gui_video_info.py b/ytsage_gui_video_info.py new file mode 100644 index 0000000..bad0511 --- /dev/null +++ b/ytsage_gui_video_info.py @@ -0,0 +1,351 @@ +import sys +import os +import webbrowser +from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLineEdit, QPushButton, QTableWidget, + QTableWidgetItem, QProgressBar, QLabel, QFileDialog, + QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup) +from PySide6.QtCore import Qt, Signal, QObject, QThread +from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap +import requests +from io import BytesIO +from PIL import Image +from datetime import datetime +import json +from pathlib import Path +from packaging import version +import subprocess +import re +import yt_dlp + +class VideoInfoMixin: + def setup_video_info_section(self): + # Create a horizontal layout for thumbnail and video info + media_info_layout = QHBoxLayout() + media_info_layout.setSpacing(15) + + # Left side container for thumbnail + thumbnail_container = QWidget() + thumbnail_container.setFixedWidth(320) + thumbnail_layout = QVBoxLayout(thumbnail_container) + thumbnail_layout.setContentsMargins(0, 0, 0, 0) + + # Thumbnail on the left + self.thumbnail_label = QLabel() + self.thumbnail_label.setFixedSize(320, 180) + self.thumbnail_label.setStyleSheet("border: 2px solid #3d3d3d; border-radius: 4px;") + self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + thumbnail_layout.addWidget(self.thumbnail_label) + thumbnail_layout.addStretch() + + media_info_layout.addWidget(thumbnail_container) + + # Video information on the right + video_info_layout = QVBoxLayout() + video_info_layout.setSpacing(2) # Reduce spacing between elements + video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Title and info labels + self.title_label = QLabel() + self.title_label.setWordWrap(True) + self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;") + + # Add basic info labels + self.channel_label = QLabel() + self.views_label = QLabel() + self.date_label = QLabel() + self.duration_label = QLabel() + + # Style the info labels + for label in [self.channel_label, self.views_label, self.date_label, self.duration_label]: + label.setStyleSheet(""" + QLabel { + color: #cccccc; + font-size: 12px; + padding: 1px 0; + } + """) + + # Add labels to video info layout + video_info_layout.addWidget(self.title_label) + video_info_layout.addWidget(self.channel_label) + video_info_layout.addWidget(self.views_label) + video_info_layout.addWidget(self.date_label) + video_info_layout.addWidget(self.duration_label) + + # Add spacing before subtitle section + video_info_layout.addSpacing(10) + + # Create a horizontal layout for subtitle controls + subtitle_layout = QHBoxLayout() + subtitle_layout.setSpacing(5) # Reduce spacing between elements + + # 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: #363636; + border: 2px solid #3d3d3d; + border-radius: 4px; + padding: 5px; + min-height: 30px; + } + QPushButton:checked { + background-color: #ff0000; + border-color: #cc0000; + } + """) + subtitle_layout.addWidget(self.subtitle_check) + + # 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) + + # 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) + video_info_layout.addLayout(filter_layout) + + # Add stretch at the bottom + video_info_layout.addStretch() + + # Add video info layout to main layout + media_info_layout.addLayout(video_info_layout, stretch=1) + + return media_info_layout + + def setup_playlist_info_section(self): + self.playlist_info_label = QLabel() + self.playlist_info_label.setVisible(False) + self.playlist_info_label.setStyleSheet(""" + QLabel { + font-size: 12px; + color: #ff9900; + padding: 5px 8px; + margin: 0; + background-color: #2b2b2b; + border: 1px solid #3d3d3d; + border-radius: 4px; + min-height: 30px; + max-height: 30px; + } + """) + self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + return self.playlist_info_label + + def update_video_info(self, info): + # 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: + 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.date_label.setText(f"Upload date: {formatted_date}") + self.duration_label.setText(f"Duration: {duration_str}") + + def toggle_subtitle_controls(self): + is_checked = self.subtitle_check.isChecked() + self.subtitle_combo.setVisible(is_checked) + self.subtitle_filter_input.setVisible(is_checked) + self.merge_subs_checkbox.setVisible(is_checked) + + def update_subtitle_list(self): + self.subtitle_combo.clear() + + if not (self.available_subtitles or self.available_automatic_subtitles): + self.subtitle_combo.addItem("No subtitles available") + return + + # Add subtitle options + self.subtitle_combo.addItem("Select subtitle language") + + # Filter and add subtitles + filter_text = self.subtitle_filter_input.text().lower() + + # Add manual subtitles + for lang_code, subtitle_info in self.available_subtitles.items(): + if not filter_text or filter_text in lang_code.lower(): + self.subtitle_combo.addItem(f"{lang_code} - Manual") + + # 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") + + def filter_subtitles(self): + self.subtitle_filter = self.subtitle_filter_input.text() + self.update_subtitle_list() + + def download_thumbnail(self, url): + try: + # Store both thumbnail URL and video URL + self.thumbnail_url = url + self.video_url = self.url_input.text() # Get actual video URL + + # Download thumbnail but don't save yet + response = requests.get(url) + self.thumbnail_image = Image.open(BytesIO(response.content)) + + # Display thumbnail + image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS) + img_byte_arr = BytesIO() + image.save(img_byte_arr, format='PNG') + pixmap = QPixmap() + pixmap.loadFromData(img_byte_arr.getvalue()) + self.thumbnail_label.setPixmap(pixmap) + 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 + + try: + from yt_dlp import YoutubeDL + import requests # Use requests instead of urlopen + + print(f"Attempting to save thumbnail for URL: {video_url}") + + ydl_opts = { + 'quiet': True, + 'skip_download': True, + 'force_generic_extractor': False, + 'no_warnings': True, + 'extract_flat': False + } + + with YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(video_url, download=False) + thumbnails = info.get('thumbnails', []) + + if not thumbnails: + raise ValueError("No thumbnails available") + + thumbnail_url = max( + thumbnails, + key=lambda t: (t.get('height', 0) or 0) * (t.get('width', 0) or 0) + ).get('url') + + if not thumbnail_url: + raise ValueError("Failed to extract thumbnail URL") + + # Download using requests + response = requests.get(thumbnail_url) + response.raise_for_status() + + # Save the thumbnail + thumb_dir = os.path.join(path, 'Thumbnails') + os.makedirs(thumb_dir, exist_ok=True) + + filename = f"{self.sanitize_filename(info['title'])}.jpg" + thumbnail_path = os.path.join(thumb_dir, filename) + + with open(thumbnail_path, 'wb') as f: + f.write(response.content) + + print(f"Thumbnail saved to: {thumbnail_path}") + self.signals.update_status.emit(f"✅ Thumbnail saved: {filename}") + return True + + except Exception as e: + error_msg = f"❌ Thumbnail error: {str(e)}" + print(f"Thumbnail Save Error: {str(e)}") + self.signals.update_status.emit(error_msg) + return False + + def sanitize_filename(self, name): + """Clean filename for filesystem safety""" + return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75] \ No newline at end of file diff --git a/ytsage_style.py b/ytsage_style.py new file mode 100644 index 0000000..84c3b8d --- /dev/null +++ b/ytsage_style.py @@ -0,0 +1,135 @@ +MAIN_STYLE = """ +QMainWindow { + background-color: #2b2b2b; +} +QWidget { + background-color: #2b2b2b; + color: #ffffff; + font-size: 12px; +} +QLineEdit { + padding: 8px; + border: 2px solid #3d3d3d; + border-radius: 4px; + background-color: #363636; + color: #ffffff; + selection-background-color: #ff0000; + selection-color: #ffffff; +} +QPushButton { + padding: 8px 15px; + background-color: #ff0000; /* YouTube red */ + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + min-height: 20px; +} +QPushButton:hover { + background-color: #cc0000; /* Darker red on hover */ +} +QPushButton:pressed { + background-color: #990000; /* Even darker red when pressed */ +} +QPushButton:disabled { + background-color: #666666; /* Gray when disabled */ + color: #999999; +} +QTableWidget { + border: 2px solid #3d3d3d; + border-radius: 4px; + background-color: #363636; + gridline-color: #3d3d3d; + selection-background-color: #ff0000; + selection-color: #ffffff; +} +QHeaderView::section { + background-color: #2b2b2b; + padding: 5px; + border: 1px solid #3d3d3d; + color: #ffffff; + font-weight: bold; +} +QScrollBar:vertical { + border: none; + background-color: #2b2b2b; + width: 12px; + margin: 0px; +} +QScrollBar::handle:vertical { + background-color: #666666; + min-height: 20px; + border-radius: 6px; +} +QScrollBar::handle:vertical:hover { + background-color: #ff0000; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; +} +QProgressBar { + border: 2px solid #3d3d3d; + border-radius: 4px; + text-align: center; + color: white; + background-color: #363636; +} +QProgressBar::chunk { + background-color: #ff0000; + border-radius: 2px; +} +QComboBox { + padding: 5px; + border: 2px solid #3d3d3d; + border-radius: 4px; + background-color: #363636; + color: #ffffff; + min-height: 20px; +} +QComboBox::drop-down { + border: none; + width: 20px; +} +QComboBox::down-arrow { + image: url(down_arrow.png); + width: 12px; + height: 12px; +} +QCheckBox { + spacing: 5px; + color: #ffffff; +} +QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 9px; +} +QCheckBox::indicator:unchecked { + border: 2px solid #666666; + background: #2b2b2b; +} +QCheckBox::indicator:checked { + border: 2px solid #ff0000; + background: #ff0000; +} +QLabel { + color: #ffffff; +} +QTextEdit, QPlainTextEdit { + background-color: #363636; + color: #ffffff; + border: 2px solid #3d3d3d; + border-radius: 4px; + selection-background-color: #ff0000; + selection-color: #ffffff; +} +QMessageBox { + background-color: #2b2b2b; +} +QMessageBox QLabel { + color: #ffffff; +} +QMessageBox QPushButton { + min-width: 80px; +} +""" \ No newline at end of file diff --git a/ytsage_utils.py b/ytsage_utils.py index 9cc6971..1666c83 100644 --- a/ytsage_utils.py +++ b/ytsage_utils.py @@ -3,83 +3,151 @@ import os import json from pathlib import Path import subprocess +import tempfile +from ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path def check_ffmpeg(): + """Check if FFmpeg is installed and accessible with enhanced error handling.""" try: - subprocess.run(['ffmpeg', '-version'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True) - return True - except (subprocess.SubprocessError, FileNotFoundError): + # Use the enhanced FFmpeg check from ytsage_ffmpeg + if check_ffmpeg_installed(): + return True + + # For Windows, try to add the FFmpeg path to environment + if sys.platform == 'win32': + ffmpeg_path = get_ffmpeg_install_path() + if os.path.exists(os.path.join(ffmpeg_path, 'ffmpeg.exe')): + try: + # Add to current session PATH + os.environ['PATH'] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" + return True + except Exception as e: + print(f"Error updating PATH: {e}") + return False + + # For macOS, check common paths + elif sys.platform == 'darwin': + common_paths = [ + '/usr/local/bin/ffmpeg', + '/opt/homebrew/bin/ffmpeg', + '/usr/bin/ffmpeg' + ] + for path in common_paths: + if os.path.exists(path): + try: + ffmpeg_dir = os.path.dirname(path) + os.environ['PATH'] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}" + return True + except Exception as e: + print(f"Error updating PATH: {e}") + continue + + return False + + except Exception as e: + print(f"Error checking FFmpeg: {e}") return False def get_yt_dlp_path(): - """Get the appropriate yt-dlp path based on platform and deployment method""" + """Get the appropriate yt-dlp path with enhanced error handling.""" try: if getattr(sys, 'frozen', False): if sys.platform == 'darwin': # For macOS .app bundle if 'Contents/MacOS' in sys.executable: - # Inside .app bundle - return os.path.join(os.path.dirname(sys.executable), 'yt-dlp') + 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') - os.makedirs(base_path, exist_ok=True) - return os.path.join(base_path, 'yt-dlp') elif sys.platform == 'win32': # For Windows executable app_data = os.getenv('APPDATA') - if app_data: - base_path = os.path.join(app_data, 'YTSage') - else: - base_path = os.path.dirname(sys.executable) - os.makedirs(base_path, exist_ok=True) - return os.path.join(base_path, 'yt-dlp.exe') + 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: - # Inside AppImage 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) - return os.path.join(base_path, 'yt-dlp') + 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: # 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 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): # Pass the main window instance - config_file = main_window_instance.config_file # Access config_file via instance +def load_saved_path(main_window_instance): + """Load saved download path with enhanced error handling.""" + config_file = main_window_instance.config_file try: if config_file.exists(): - with open(config_file, 'r') as f: - config = json.load(f) - saved_path = config.get('download_path', '') - if os.path.exists(saved_path): - main_window_instance.last_path = saved_path # Access last_path via instance - else: - main_window_instance.last_path = str(Path.home() / 'Downloads') + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + saved_path = config.get('download_path', '') + if os.path.exists(saved_path) and os.access(saved_path, os.W_OK): + main_window_instance.last_path = saved_path + return + except (json.JSONDecodeError, UnicodeError) as e: + print(f"Error reading config file: {e}") + # If config file is corrupted, try to remove it + try: + os.remove(config_file) + except Exception: + pass + + # Fallback to Downloads folder + downloads_path = str(Path.home() / 'Downloads') + if os.path.exists(downloads_path) and os.access(downloads_path, os.W_OK): + main_window_instance.last_path = downloads_path else: - main_window_instance.last_path = str(Path.home() / 'Downloads') + # Final fallback to temp directory if Downloads is not accessible + main_window_instance.last_path = tempfile.gettempdir() + except Exception as e: print(f"Error loading saved settings: {e}") - main_window_instance.last_path = str(Path.home() / 'Downloads') + main_window_instance.last_path = tempfile.gettempdir() -def save_path(main_window_instance, path): # Pass main window instance - config_file = main_window_instance.config_file # Access config_file via instance +def save_path(main_window_instance, path): + """Save download path with enhanced error handling.""" + config_file = main_window_instance.config_file try: - config = { - 'download_path': path - } - with open(config_file, 'w') as f: - json.dump(config, f) + # Verify the path is valid and writable + if not os.path.exists(path): + try: + os.makedirs(path, exist_ok=True) + except Exception as e: + print(f"Error creating directory: {e}") + return False + + if not os.access(path, os.W_OK): + print("Path is not writable") + return False + + # Create config directory if it doesn't exist + config_dir = config_file.parent + if not config_dir.exists(): + config_dir.mkdir(parents=True, exist_ok=True) + + # Save the config + config = {'download_path': path} + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False) + return True + except Exception as e: - print(f"Error saving settings: {e}") \ No newline at end of file + print(f"Error saving settings: {e}") + return False \ No newline at end of file