diff --git a/__pycache__/ytsage_downloader.cpython-313.pyc b/__pycache__/ytsage_downloader.cpython-313.pyc new file mode 100644 index 0000000..a3a2b1a Binary files /dev/null and b/__pycache__/ytsage_downloader.cpython-313.pyc differ diff --git a/__pycache__/ytsage_ffmpeg.cpython-313.pyc b/__pycache__/ytsage_ffmpeg.cpython-313.pyc new file mode 100644 index 0000000..42ef8b3 Binary files /dev/null and b/__pycache__/ytsage_ffmpeg.cpython-313.pyc differ diff --git a/__pycache__/ytsage_gui_dialogs.cpython-313.pyc b/__pycache__/ytsage_gui_dialogs.cpython-313.pyc new file mode 100644 index 0000000..c18b99d Binary files /dev/null and b/__pycache__/ytsage_gui_dialogs.cpython-313.pyc differ 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 0000000..ab68ded Binary files /dev/null and b/__pycache__/ytsage_gui_format_table.cpython-313.pyc differ diff --git a/__pycache__/ytsage_gui_main.cpython-313.pyc b/__pycache__/ytsage_gui_main.cpython-313.pyc new file mode 100644 index 0000000..b686211 Binary files /dev/null and b/__pycache__/ytsage_gui_main.cpython-313.pyc differ diff --git a/__pycache__/ytsage_gui_video_info.cpython-313.pyc b/__pycache__/ytsage_gui_video_info.cpython-313.pyc new file mode 100644 index 0000000..c06a761 Binary files /dev/null and b/__pycache__/ytsage_gui_video_info.cpython-313.pyc differ diff --git a/__pycache__/ytsage_utils.cpython-313.pyc b/__pycache__/ytsage_utils.cpython-313.pyc new file mode 100644 index 0000000..1bf3fa3 Binary files /dev/null and b/__pycache__/ytsage_utils.cpython-313.pyc differ diff --git a/main.py b/main.py index 1dab923..cb94181 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,24 @@ import sys -from PySide6.QtWidgets import QApplication -from ytsage_gui import YTSageApp # Import the main application class +from PySide6.QtWidgets import QApplication, QMessageBox +from ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main + +def show_error_dialog(message): + error_dialog = QMessageBox() + error_dialog.setIcon(QMessageBox.Icon.Critical) + error_dialog.setText("Application Error") + error_dialog.setInformativeText(message) + error_dialog.setWindowTitle("Error") + error_dialog.exec() def main(): - app = QApplication(sys.argv) - window = YTSageApp() # Instantiate the main application class - window.show() - sys.exit(app.exec()) + try: + app = QApplication(sys.argv) + window = YTSageApp() # Instantiate the main application class + window.show() + sys.exit(app.exec()) + except Exception as e: + show_error_dialog(f"Critical error: {str(e)}") + sys.exit(1) if __name__ == '__main__': main() \ No newline at end of file diff --git a/ytsage_downloader.py b/ytsage_downloader.py index 960f863..e9b88a7 100644 --- a/ytsage_downloader.py +++ b/ytsage_downloader.py @@ -3,6 +3,7 @@ import yt_dlp # Keep yt_dlp import here - only downloader uses it. import time import os import re +from pathlib import Path class SignalManager(QObject): update_formats = Signal(list) @@ -14,8 +15,9 @@ class DownloadThread(QThread): status_signal = Signal(str) finished_signal = Signal() error_signal = Signal(str) + file_exists_signal = Signal(str) # New signal for file existence - def __init__(self, url, path, format_id, subtitle_lang=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution=''): + def __init__(self, url, path, format_id, subtitle_lang=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None): super().__init__() self.url = url self.path = path @@ -25,6 +27,7 @@ class DownloadThread(QThread): self.merge_subs = merge_subs self.enable_sponsorblock = enable_sponsorblock self.resolution = resolution + self.playlist_items = playlist_items self.paused = False self.cancelled = False @@ -43,25 +46,143 @@ class DownloadThread(QThread): except Exception as e: self.error_signal.emit(f"Error cleaning partial files: {str(e)}") + def check_file_exists(self): + """Check if the file already exists before downloading""" + try: + print("DEBUG: Starting file existence check") + # Use yt-dlp to get the filename without downloading + with yt_dlp.YoutubeDL({'quiet': True, 'skip_download': True}) as ydl: + info = ydl.extract_info(self.url, download=False) + + # Get the title and sanitize it for filename + title = info.get('title', 'video') + # Don't remove colons and other special characters yet + print(f"DEBUG: Original video title: {title}") + + # Get resolution for better matching + resolution = "" + for format_info in info.get('formats', []): + if format_info.get('format_id') == self.format_id: + resolution = format_info.get('resolution', '') + break + + print(f"DEBUG: Resolution: {resolution}") + + # Create the expected filename (more specific) + if self.is_playlist and info.get('playlist_title'): + playlist_title = re.sub(r'[\\/*?"<>|]', "", info.get('playlist_title', '')).strip() + base_path = os.path.join(self.path, playlist_title) + else: + base_path = self.path + + # Normalize the path to use consistent separators + base_path = os.path.normpath(base_path) + 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