This commit is contained in:
Muhamed
2025-03-10 15:34:34 +02:00
parent 05263176ac
commit ccc1105ef1
17 changed files with 2928 additions and 1874 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -6
View File
@@ -1,12 +1,24 @@
import sys import sys
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication, QMessageBox
from ytsage_gui import YTSageApp # Import the main application class 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(): def main():
app = QApplication(sys.argv) try:
window = YTSageApp() # Instantiate the main application class app = QApplication(sys.argv)
window.show() window = YTSageApp() # Instantiate the main application class
sys.exit(app.exec()) window.show()
sys.exit(app.exec())
except Exception as e:
show_error_dialog(f"Critical error: {str(e)}")
sys.exit(1)
if __name__ == '__main__': if __name__ == '__main__':
main() main()
+186 -30
View File
@@ -3,6 +3,7 @@ import yt_dlp # Keep yt_dlp import here - only downloader uses it.
import time import time
import os import os
import re import re
from pathlib import Path
class SignalManager(QObject): class SignalManager(QObject):
update_formats = Signal(list) update_formats = Signal(list)
@@ -14,8 +15,9 @@ class DownloadThread(QThread):
status_signal = Signal(str) status_signal = Signal(str)
finished_signal = Signal() finished_signal = Signal()
error_signal = Signal(str) 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__() super().__init__()
self.url = url self.url = url
self.path = path self.path = path
@@ -25,6 +27,7 @@ class DownloadThread(QThread):
self.merge_subs = merge_subs self.merge_subs = merge_subs
self.enable_sponsorblock = enable_sponsorblock self.enable_sponsorblock = enable_sponsorblock
self.resolution = resolution self.resolution = resolution
self.playlist_items = playlist_items
self.paused = False self.paused = False
self.cancelled = False self.cancelled = False
@@ -43,25 +46,143 @@ class DownloadThread(QThread):
except Exception as e: except Exception as e:
self.error_signal.emit(f"Error cleaning partial files: {str(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): def run(self):
try: 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: class DebugLogger:
def debug(self, msg): 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 # Add detection of post-processing messages
if "Downloading" in msg: 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: 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% 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']): elif any(x in msg.lower() for x in ['downloading webpage', 'downloading api']):
self.thread.status_signal.emit("Preparing for download...") 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) self.thread.progress_signal.emit(0)
def warning(self, msg): 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): 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): def __init__(self, thread):
self.thread = thread self.thread = thread
@@ -96,20 +217,32 @@ class DownloadThread(QThread):
eta_str = "N/A" eta_str = "N/A"
filename = os.path.basename(d.get('filename', '')) filename = os.path.basename(d.get('filename', ''))
status = f"Speed: {speed_str} | ETA: {eta_str} | File: {filename}" status = f"Speed: {speed_str} | ETA: {eta_str} | File: {filename}"
self.status_signal.emit(status) self.status_signal.emit(status)
except Exception as e: except Exception as e:
self.status_signal.emit("Downloading...") self.status_signal.emit("⚡ Downloading...")
elif d['status'] == 'finished': elif d['status'] == 'finished':
if self.enable_sponsorblock: if self.enable_sponsorblock:
self.progress_signal.emit(99) self.progress_signal.emit(99)
self.status_signal.emit("Post-processing: Removing sponsor segments...") self.status_signal.emit("Post-processing: Removing sponsor segments...")
else: else:
self.progress_signal.emit(100) 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 # Base yt-dlp options with resolution in filename
output_template = '%(title)s_%(resolution)s.%(ext)s' output_template = '%(title)s_%(resolution)s.%(ext)s'
@@ -120,12 +253,13 @@ class DownloadThread(QThread):
'format': f'{self.format_id}+bestaudio/best', 'format': f'{self.format_id}+bestaudio/best',
'outtmpl': os.path.join(self.path, output_template), 'outtmpl': os.path.join(self.path, output_template),
'progress_hooks': [progress_hook], 'progress_hooks': [progress_hook],
'merge_output_format': 'mkv' if self.merge_subs else 'mp4', 'merge_output_format': 'mp4',
'logger': DebugLogger(self), 'logger': DebugLogger(self),
'postprocessors': [{ 'postprocessors': [{
'key': 'FFmpegVideoConvertor', 'key': 'FFmpegVideoConvertor',
'preferedformat': 'mkv' if self.merge_subs else 'mp4' 'preferedformat': 'mp4'
}] }],
'force_overwrites': True
} }
# Add subtitle options if selected # Add subtitle options if selected
@@ -140,6 +274,18 @@ class DownloadThread(QThread):
'skip_auto_subs': not is_auto, 'skip_auto_subs': not is_auto,
'embedsubtitles': self.merge_subs, '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 # Add SponsorBlock options if enabled
if self.enable_sponsorblock: if self.enable_sponsorblock:
@@ -153,24 +299,34 @@ class DownloadThread(QThread):
'sponsorblock_chapter_title': '[SponsorBlock]', 'sponsorblock_chapter_title': '[SponsorBlock]',
'force_keyframes': False '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: # Add playlist items if specified
ydl.download([self.url]) 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 except Exception as e:
if self.merge_subs: if str(e) == "Download cancelled by user":
for filename in os.listdir(self.path): self.cleanup_partial_files()
if filename.lower().endswith(('.vtt', '.srt', '.ass')): self.error_signal.emit("Download cancelled")
try: else:
os.remove(os.path.join(self.path, filename)) self.error_signal.emit(f"Download failed: {str(e)}")
except Exception as e:
self.error_signal.emit(f"Error deleting subtitle file: {str(e)}")
except Exception as e: except Exception as e:
if str(e) == "Download cancelled by user": self.error_signal.emit(f"Critical error: {str(e)}")
self.cleanup_partial_files()
self.error_signal.emit(str(e))
+224
View File
@@ -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
-1800
View File
File diff suppressed because it is too large Load Diff
+673
View File
@@ -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;
}
""")
+328
View File
@@ -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)
+907
View File
@@ -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()
+351
View File
@@ -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]
+135
View File
@@ -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;
}
"""
+106 -38
View File
@@ -3,83 +3,151 @@ import os
import json import json
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tempfile
from ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path
def check_ffmpeg(): def check_ffmpeg():
"""Check if FFmpeg is installed and accessible with enhanced error handling."""
try: try:
subprocess.run(['ffmpeg', '-version'], # Use the enhanced FFmpeg check from ytsage_ffmpeg
stdout=subprocess.PIPE, if check_ffmpeg_installed():
stderr=subprocess.PIPE, return True
check=True)
return True # For Windows, try to add the FFmpeg path to environment
except (subprocess.SubprocessError, FileNotFoundError): 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 return False
def get_yt_dlp_path(): 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: try:
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
if sys.platform == 'darwin': if sys.platform == 'darwin':
# For macOS .app bundle # For macOS .app bundle
if 'Contents/MacOS' in sys.executable: if 'Contents/MacOS' in sys.executable:
# Inside .app bundle base_path = os.path.dirname(sys.executable)
return os.path.join(os.path.dirname(sys.executable), 'yt-dlp')
else: else:
# Fallback to user's home directory for macOS # Fallback to user's home directory for macOS
base_path = os.path.expanduser('~/Library/Application Support/YTSage') 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': elif sys.platform == 'win32':
# For Windows executable # For Windows executable
app_data = os.getenv('APPDATA') app_data = os.getenv('APPDATA')
if app_data: base_path = os.path.join(app_data, 'YTSage') if app_data else os.path.dirname(sys.executable)
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')
else: else:
# For Linux AppImage or binary # For Linux AppImage or binary
if 'APPIMAGE' in os.environ: if 'APPIMAGE' in os.environ:
# Inside AppImage
xdg_data = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) xdg_data = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
base_path = os.path.join(xdg_data, 'YTSage') base_path = os.path.join(xdg_data, 'YTSage')
else: else:
base_path = os.path.dirname(sys.executable) base_path = os.path.dirname(sys.executable)
# Create directory if it doesn't exist
try:
os.makedirs(base_path, exist_ok=True) 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: else:
# For development/script mode # For development/script mode
return os.path.join(os.path.dirname(os.path.abspath(__file__)), return os.path.join(os.path.dirname(os.path.abspath(__file__)),
'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp') 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
except Exception as e: except Exception as e:
print(f"Error determining yt-dlp path: {e}") print(f"Error determining yt-dlp path: {e}")
# Fallback to current directory # Fallback to current directory
return os.path.join(os.getcwd(), 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp') 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 def load_saved_path(main_window_instance):
config_file = main_window_instance.config_file # Access config_file via instance """Load saved download path with enhanced error handling."""
config_file = main_window_instance.config_file
try: try:
if config_file.exists(): if config_file.exists():
with open(config_file, 'r') as f: try:
config = json.load(f) with open(config_file, 'r', encoding='utf-8') as f:
saved_path = config.get('download_path', '') config = json.load(f)
if os.path.exists(saved_path): saved_path = config.get('download_path', '')
main_window_instance.last_path = saved_path # Access last_path via instance if os.path.exists(saved_path) and os.access(saved_path, os.W_OK):
else: main_window_instance.last_path = saved_path
main_window_instance.last_path = str(Path.home() / 'Downloads') 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: 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: except Exception as e:
print(f"Error loading saved settings: {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 def save_path(main_window_instance, path):
config_file = main_window_instance.config_file # Access config_file via instance """Save download path with enhanced error handling."""
config_file = main_window_instance.config_file
try: try:
config = { # Verify the path is valid and writable
'download_path': path if not os.path.exists(path):
} try:
with open(config_file, 'w') as f: os.makedirs(path, exist_ok=True)
json.dump(config, f) 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: except Exception as e:
print(f"Error saving settings: {e}") print(f"Error saving settings: {e}")
return False