v4.2.0
This commit is contained in:
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.
@@ -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():
|
||||
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()
|
||||
+173
-17
@@ -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
|
||||
@@ -141,6 +275,18 @@ class DownloadThread(QThread):
|
||||
'embedsubtitles': self.merge_subs,
|
||||
})
|
||||
|
||||
if self.merge_subs:
|
||||
ydl_opts['postprocessors'].extend([
|
||||
{
|
||||
'key': 'FFmpegSubtitlesConvertor',
|
||||
'format': 'srt',
|
||||
},
|
||||
{
|
||||
'key': 'FFmpegEmbedSubtitle',
|
||||
'already_have_subtitle': False,
|
||||
}
|
||||
])
|
||||
|
||||
# Add SponsorBlock options if enabled
|
||||
if self.enable_sponsorblock:
|
||||
ydl_opts['postprocessors'].extend([{
|
||||
@@ -153,12 +299,17 @@ class DownloadThread(QThread):
|
||||
'sponsorblock_chapter_title': '[SponsorBlock]',
|
||||
'force_keyframes': False
|
||||
}])
|
||||
self.progress_signal.emit(99)
|
||||
self.status_signal.emit("Post-processing: Removing sponsor segments...")
|
||||
|
||||
# Add playlist items if specified
|
||||
if self.playlist_items:
|
||||
ydl_opts['playlist_items'] = self.playlist_items
|
||||
|
||||
try:
|
||||
# Download the video
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([self.url])
|
||||
|
||||
if not self.cancelled:
|
||||
self.finished_signal.emit()
|
||||
|
||||
# Clean up subtitle files after successful download
|
||||
@@ -173,4 +324,9 @@ class DownloadThread(QThread):
|
||||
except Exception as e:
|
||||
if str(e) == "Download cancelled by user":
|
||||
self.cleanup_partial_files()
|
||||
self.error_signal.emit(str(e))
|
||||
self.error_signal.emit("Download cancelled")
|
||||
else:
|
||||
self.error_signal.emit(f"Download failed: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
self.error_signal.emit(f"Critical error: {str(e)}")
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
""")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
"""
|
||||
+102
-34
@@ -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)
|
||||
# Use the enhanced FFmpeg check from ytsage_ffmpeg
|
||||
if check_ffmpeg_installed():
|
||||
return True
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
|
||||
# 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:
|
||||
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):
|
||||
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):
|
||||
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')
|
||||
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}")
|
||||
return False
|
||||
Reference in New Issue
Block a user