v4.2.0
This commit is contained in:
1 parent
05263176ac
commit
ccc1105ef1
17 files changed
+2928
-1874
No files matched your search
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
|
import sys
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
from ytsage_gui import YTSageApp # Import the main application class
|
from ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
|
||||||
|
|
||||||
|
def show_error_dialog(message):
|
||||||
|
error_dialog = QMessageBox()
|
||||||
|
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||||
|
error_dialog.setText("Application Error")
|
||||||
|
error_dialog.setInformativeText(message)
|
||||||
|
error_dialog.setWindowTitle("Error")
|
||||||
|
error_dialog.exec()
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
try:
|
||||||
window = YTSageApp() # Instantiate the main application class
|
app = QApplication(sys.argv)
|
||||||
window.show()
|
window = YTSageApp() # Instantiate the main application class
|
||||||
sys.exit(app.exec())
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
except Exception as e:
|
||||||
|
show_error_dialog(f"Critical error: {str(e)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
+186
-30
@@ -3,6 +3,7 @@ import yt_dlp # Keep yt_dlp import here - only downloader uses it.
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
class SignalManager(QObject):
|
class SignalManager(QObject):
|
||||||
update_formats = Signal(list)
|
update_formats = Signal(list)
|
||||||
@@ -14,8 +15,9 @@ class DownloadThread(QThread):
|
|||||||
status_signal = Signal(str)
|
status_signal = Signal(str)
|
||||||
finished_signal = Signal()
|
finished_signal = Signal()
|
||||||
error_signal = Signal(str)
|
error_signal = Signal(str)
|
||||||
|
file_exists_signal = Signal(str) # New signal for file existence
|
||||||
|
|
||||||
def __init__(self, url, path, format_id, subtitle_lang=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution=''):
|
def __init__(self, url, path, format_id, subtitle_lang=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.url = url
|
self.url = url
|
||||||
self.path = path
|
self.path = path
|
||||||
@@ -25,6 +27,7 @@ class DownloadThread(QThread):
|
|||||||
self.merge_subs = merge_subs
|
self.merge_subs = merge_subs
|
||||||
self.enable_sponsorblock = enable_sponsorblock
|
self.enable_sponsorblock = enable_sponsorblock
|
||||||
self.resolution = resolution
|
self.resolution = resolution
|
||||||
|
self.playlist_items = playlist_items
|
||||||
self.paused = False
|
self.paused = False
|
||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
|
|
||||||
@@ -43,25 +46,143 @@ class DownloadThread(QThread):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error_signal.emit(f"Error cleaning partial files: {str(e)}")
|
self.error_signal.emit(f"Error cleaning partial files: {str(e)}")
|
||||||
|
|
||||||
|
def check_file_exists(self):
|
||||||
|
"""Check if the file already exists before downloading"""
|
||||||
|
try:
|
||||||
|
print("DEBUG: Starting file existence check")
|
||||||
|
# Use yt-dlp to get the filename without downloading
|
||||||
|
with yt_dlp.YoutubeDL({'quiet': True, 'skip_download': True}) as ydl:
|
||||||
|
info = ydl.extract_info(self.url, download=False)
|
||||||
|
|
||||||
|
# Get the title and sanitize it for filename
|
||||||
|
title = info.get('title', 'video')
|
||||||
|
# Don't remove colons and other special characters yet
|
||||||
|
print(f"DEBUG: Original video title: {title}")
|
||||||
|
|
||||||
|
# Get resolution for better matching
|
||||||
|
resolution = ""
|
||||||
|
for format_info in info.get('formats', []):
|
||||||
|
if format_info.get('format_id') == self.format_id:
|
||||||
|
resolution = format_info.get('resolution', '')
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"DEBUG: Resolution: {resolution}")
|
||||||
|
|
||||||
|
# Create the expected filename (more specific)
|
||||||
|
if self.is_playlist and info.get('playlist_title'):
|
||||||
|
playlist_title = re.sub(r'[\\/*?"<>|]', "", info.get('playlist_title', '')).strip()
|
||||||
|
base_path = os.path.join(self.path, playlist_title)
|
||||||
|
else:
|
||||||
|
base_path = self.path
|
||||||
|
|
||||||
|
# Normalize the path to use consistent separators
|
||||||
|
base_path = os.path.normpath(base_path)
|
||||||
|
print(f"DEBUG: Base path: {base_path}")
|
||||||
|
|
||||||
|
# Instead of trying to predict the exact filename, scan the directory
|
||||||
|
# and look for files that contain both the title and resolution
|
||||||
|
if os.path.exists(base_path):
|
||||||
|
for filename in os.listdir(base_path):
|
||||||
|
if filename.endswith('.mp4'):
|
||||||
|
# Check if both title parts and resolution are in the filename
|
||||||
|
title_words = title.lower().split()
|
||||||
|
filename_lower = filename.lower()
|
||||||
|
|
||||||
|
# Check if most title words are in the filename
|
||||||
|
title_match = all(word in filename_lower for word in title_words[:3])
|
||||||
|
resolution_match = resolution.lower() in filename_lower
|
||||||
|
|
||||||
|
print(f"DEBUG: Checking file: {filename}, Title match: {title_match}, Resolution match: {resolution_match}")
|
||||||
|
|
||||||
|
if title_match and resolution_match:
|
||||||
|
print(f"DEBUG: Found matching file: {filename}")
|
||||||
|
return filename
|
||||||
|
|
||||||
|
print("DEBUG: No matching file found")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: Error checking file existence: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
|
print("DEBUG: Starting download thread")
|
||||||
|
# First check if file already exists
|
||||||
|
existing_file = self.check_file_exists()
|
||||||
|
if existing_file:
|
||||||
|
print(f"DEBUG: File exists, emitting signal: {existing_file}")
|
||||||
|
self.file_exists_signal.emit(existing_file)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("DEBUG: No existing file found, proceeding with download")
|
||||||
class DebugLogger:
|
class DebugLogger:
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
|
# Print all debug messages to help diagnose issues
|
||||||
|
print(f"YT-DLP DEBUG: {msg}")
|
||||||
|
|
||||||
|
# Check for file exists message - look for both patterns
|
||||||
|
if "already exists" in msg or "has already been downloaded" in msg:
|
||||||
|
print(f"FILE EXISTS DETECTED: {msg}")
|
||||||
|
# Try to extract the filename
|
||||||
|
import re
|
||||||
|
match = re.search(r'File (.*?) already exists', msg)
|
||||||
|
if not match:
|
||||||
|
match = re.search(r'(.*?) has already been downloaded', msg)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
filename = os.path.basename(match.group(1))
|
||||||
|
self.thread.file_exists_signal.emit(filename)
|
||||||
|
raise Exception("FileExistsError")
|
||||||
|
|
||||||
# Add detection of post-processing messages
|
# Add detection of post-processing messages
|
||||||
if "Downloading" in msg:
|
if "Downloading" in msg:
|
||||||
self.thread.status_signal.emit("Downloading...")
|
self.thread.status_signal.emit("⚡ Downloading video...")
|
||||||
elif "Post-process" in msg or "Sponsorblock" in msg:
|
elif "Post-process" in msg or "Sponsorblock" in msg:
|
||||||
self.thread.status_signal.emit("Post-processing: Removing sponsor segments...")
|
self.thread.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
|
||||||
self.thread.progress_signal.emit(99) # Keep progress bar at 99%
|
self.thread.progress_signal.emit(99) # Keep progress bar at 99%
|
||||||
elif any(x in msg.lower() for x in ['downloading webpage', 'downloading api', 'extracting', 'downloading m3u8']):
|
elif any(x in msg.lower() for x in ['downloading webpage', 'downloading api']):
|
||||||
self.thread.status_signal.emit("Preparing for download...")
|
self.thread.status_signal.emit("🔍 Fetching video information...")
|
||||||
|
self.thread.progress_signal.emit(0)
|
||||||
|
elif 'extracting' in msg.lower():
|
||||||
|
self.thread.status_signal.emit("📦 Extracting video data...")
|
||||||
|
self.thread.progress_signal.emit(0)
|
||||||
|
elif 'downloading m3u8' in msg.lower():
|
||||||
|
self.thread.status_signal.emit("🎯 Preparing video streams...")
|
||||||
self.thread.progress_signal.emit(0)
|
self.thread.progress_signal.emit(0)
|
||||||
|
|
||||||
def warning(self, msg):
|
def warning(self, msg):
|
||||||
self.thread.status_signal.emit(f"Warning: {msg}")
|
print(f"YT-DLP WARNING: {msg}")
|
||||||
|
self.thread.status_signal.emit(f"⚠️ Warning: {msg}")
|
||||||
|
# Also check for file exists in warnings
|
||||||
|
if "already exists" in msg or "has already been downloaded" in msg:
|
||||||
|
print(f"FILE EXISTS DETECTED IN WARNING: {msg}")
|
||||||
|
import re
|
||||||
|
match = re.search(r'File (.*?) already exists', msg)
|
||||||
|
if not match:
|
||||||
|
match = re.search(r'(.*?) has already been downloaded', msg)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
filename = os.path.basename(match.group(1))
|
||||||
|
self.thread.file_exists_signal.emit(filename)
|
||||||
|
raise Exception("FileExistsError")
|
||||||
|
|
||||||
def error(self, msg):
|
def error(self, msg):
|
||||||
self.thread.status_signal.emit(f"Error: {msg}")
|
print(f"YT-DLP ERROR: {msg}")
|
||||||
|
self.thread.status_signal.emit(f"❌ Error: {msg}")
|
||||||
|
# Also check for file exists in errors
|
||||||
|
if "already exists" in msg or "has already been downloaded" in msg:
|
||||||
|
print(f"FILE EXISTS DETECTED IN ERROR: {msg}")
|
||||||
|
import re
|
||||||
|
match = re.search(r'File (.*?) already exists', msg)
|
||||||
|
if not match:
|
||||||
|
match = re.search(r'(.*?) has already been downloaded', msg)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
filename = os.path.basename(match.group(1))
|
||||||
|
self.thread.file_exists_signal.emit(filename)
|
||||||
|
raise Exception("FileExistsError")
|
||||||
|
|
||||||
def __init__(self, thread):
|
def __init__(self, thread):
|
||||||
self.thread = thread
|
self.thread = thread
|
||||||
@@ -96,20 +217,32 @@ class DownloadThread(QThread):
|
|||||||
eta_str = "N/A"
|
eta_str = "N/A"
|
||||||
|
|
||||||
filename = os.path.basename(d.get('filename', ''))
|
filename = os.path.basename(d.get('filename', ''))
|
||||||
|
|
||||||
status = f"Speed: {speed_str} | ETA: {eta_str} | File: {filename}"
|
status = f"Speed: {speed_str} | ETA: {eta_str} | File: {filename}"
|
||||||
self.status_signal.emit(status)
|
self.status_signal.emit(status)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_signal.emit("Downloading...")
|
self.status_signal.emit("⚡ Downloading...")
|
||||||
|
|
||||||
elif d['status'] == 'finished':
|
elif d['status'] == 'finished':
|
||||||
if self.enable_sponsorblock:
|
if self.enable_sponsorblock:
|
||||||
self.progress_signal.emit(99)
|
self.progress_signal.emit(99)
|
||||||
self.status_signal.emit("Post-processing: Removing sponsor segments...")
|
self.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
|
||||||
else:
|
else:
|
||||||
self.progress_signal.emit(100)
|
self.progress_signal.emit(100)
|
||||||
self.status_signal.emit("Download completed!")
|
self.status_signal.emit("✅ Download completed!")
|
||||||
|
|
||||||
|
# Get the extension from the format_id
|
||||||
|
with yt_dlp.YoutubeDL({'quiet': True}) as ydl:
|
||||||
|
try:
|
||||||
|
info = ydl.extract_info(self.url, download=False)
|
||||||
|
selected_format = next(
|
||||||
|
f for f in info['formats']
|
||||||
|
if str(f.get('format_id', '')) == self.format_id
|
||||||
|
)
|
||||||
|
output_ext = selected_format.get('ext', 'mp4')
|
||||||
|
except Exception as e:
|
||||||
|
self.error_signal.emit(f"Failed to get video information: {str(e)}")
|
||||||
|
return
|
||||||
|
|
||||||
# Base yt-dlp options with resolution in filename
|
# Base yt-dlp options with resolution in filename
|
||||||
output_template = '%(title)s_%(resolution)s.%(ext)s'
|
output_template = '%(title)s_%(resolution)s.%(ext)s'
|
||||||
@@ -120,12 +253,13 @@ class DownloadThread(QThread):
|
|||||||
'format': f'{self.format_id}+bestaudio/best',
|
'format': f'{self.format_id}+bestaudio/best',
|
||||||
'outtmpl': os.path.join(self.path, output_template),
|
'outtmpl': os.path.join(self.path, output_template),
|
||||||
'progress_hooks': [progress_hook],
|
'progress_hooks': [progress_hook],
|
||||||
'merge_output_format': 'mkv' if self.merge_subs else 'mp4',
|
'merge_output_format': 'mp4',
|
||||||
'logger': DebugLogger(self),
|
'logger': DebugLogger(self),
|
||||||
'postprocessors': [{
|
'postprocessors': [{
|
||||||
'key': 'FFmpegVideoConvertor',
|
'key': 'FFmpegVideoConvertor',
|
||||||
'preferedformat': 'mkv' if self.merge_subs else 'mp4'
|
'preferedformat': 'mp4'
|
||||||
}]
|
}],
|
||||||
|
'force_overwrites': True
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add subtitle options if selected
|
# Add subtitle options if selected
|
||||||
@@ -140,6 +274,18 @@ class DownloadThread(QThread):
|
|||||||
'skip_auto_subs': not is_auto,
|
'skip_auto_subs': not is_auto,
|
||||||
'embedsubtitles': self.merge_subs,
|
'embedsubtitles': self.merge_subs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if self.merge_subs:
|
||||||
|
ydl_opts['postprocessors'].extend([
|
||||||
|
{
|
||||||
|
'key': 'FFmpegSubtitlesConvertor',
|
||||||
|
'format': 'srt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'FFmpegEmbedSubtitle',
|
||||||
|
'already_have_subtitle': False,
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
# Add SponsorBlock options if enabled
|
# Add SponsorBlock options if enabled
|
||||||
if self.enable_sponsorblock:
|
if self.enable_sponsorblock:
|
||||||
@@ -153,24 +299,34 @@ class DownloadThread(QThread):
|
|||||||
'sponsorblock_chapter_title': '[SponsorBlock]',
|
'sponsorblock_chapter_title': '[SponsorBlock]',
|
||||||
'force_keyframes': False
|
'force_keyframes': False
|
||||||
}])
|
}])
|
||||||
self.progress_signal.emit(99)
|
|
||||||
self.status_signal.emit("Post-processing: Removing sponsor segments...")
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
# Add playlist items if specified
|
||||||
ydl.download([self.url])
|
if self.playlist_items:
|
||||||
|
ydl_opts['playlist_items'] = self.playlist_items
|
||||||
|
|
||||||
self.finished_signal.emit()
|
try:
|
||||||
|
# Download the video
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
ydl.download([self.url])
|
||||||
|
|
||||||
|
if not self.cancelled:
|
||||||
|
self.finished_signal.emit()
|
||||||
|
|
||||||
|
# Clean up subtitle files after successful download
|
||||||
|
if self.merge_subs:
|
||||||
|
for filename in os.listdir(self.path):
|
||||||
|
if filename.lower().endswith(('.vtt', '.srt', '.ass')):
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(self.path, filename))
|
||||||
|
except Exception as e:
|
||||||
|
self.error_signal.emit(f"Error deleting subtitle file: {str(e)}")
|
||||||
|
|
||||||
# Clean up subtitle files after successful download
|
except Exception as e:
|
||||||
if self.merge_subs:
|
if str(e) == "Download cancelled by user":
|
||||||
for filename in os.listdir(self.path):
|
self.cleanup_partial_files()
|
||||||
if filename.lower().endswith(('.vtt', '.srt', '.ass')):
|
self.error_signal.emit("Download cancelled")
|
||||||
try:
|
else:
|
||||||
os.remove(os.path.join(self.path, filename))
|
self.error_signal.emit(f"Download failed: {str(e)}")
|
||||||
except Exception as e:
|
|
||||||
self.error_signal.emit(f"Error deleting subtitle file: {str(e)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if str(e) == "Download cancelled by user":
|
self.error_signal.emit(f"Critical error: {str(e)}")
|
||||||
self.cleanup_partial_files()
|
|
||||||
self.error_signal.emit(str(e))
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
+106
-38
@@ -3,83 +3,151 @@ import os
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path
|
||||||
|
|
||||||
def check_ffmpeg():
|
def check_ffmpeg():
|
||||||
|
"""Check if FFmpeg is installed and accessible with enhanced error handling."""
|
||||||
try:
|
try:
|
||||||
subprocess.run(['ffmpeg', '-version'],
|
# Use the enhanced FFmpeg check from ytsage_ffmpeg
|
||||||
stdout=subprocess.PIPE,
|
if check_ffmpeg_installed():
|
||||||
stderr=subprocess.PIPE,
|
return True
|
||||||
check=True)
|
|
||||||
return True
|
# For Windows, try to add the FFmpeg path to environment
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
if sys.platform == 'win32':
|
||||||
|
ffmpeg_path = get_ffmpeg_install_path()
|
||||||
|
if os.path.exists(os.path.join(ffmpeg_path, 'ffmpeg.exe')):
|
||||||
|
try:
|
||||||
|
# Add to current session PATH
|
||||||
|
os.environ['PATH'] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating PATH: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# For macOS, check common paths
|
||||||
|
elif sys.platform == 'darwin':
|
||||||
|
common_paths = [
|
||||||
|
'/usr/local/bin/ffmpeg',
|
||||||
|
'/opt/homebrew/bin/ffmpeg',
|
||||||
|
'/usr/bin/ffmpeg'
|
||||||
|
]
|
||||||
|
for path in common_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
ffmpeg_dir = os.path.dirname(path)
|
||||||
|
os.environ['PATH'] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating PATH: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error checking FFmpeg: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_yt_dlp_path():
|
def get_yt_dlp_path():
|
||||||
"""Get the appropriate yt-dlp path based on platform and deployment method"""
|
"""Get the appropriate yt-dlp path with enhanced error handling."""
|
||||||
try:
|
try:
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
# For macOS .app bundle
|
# For macOS .app bundle
|
||||||
if 'Contents/MacOS' in sys.executable:
|
if 'Contents/MacOS' in sys.executable:
|
||||||
# Inside .app bundle
|
base_path = os.path.dirname(sys.executable)
|
||||||
return os.path.join(os.path.dirname(sys.executable), 'yt-dlp')
|
|
||||||
else:
|
else:
|
||||||
# Fallback to user's home directory for macOS
|
# Fallback to user's home directory for macOS
|
||||||
base_path = os.path.expanduser('~/Library/Application Support/YTSage')
|
base_path = os.path.expanduser('~/Library/Application Support/YTSage')
|
||||||
os.makedirs(base_path, exist_ok=True)
|
|
||||||
return os.path.join(base_path, 'yt-dlp')
|
|
||||||
elif sys.platform == 'win32':
|
elif sys.platform == 'win32':
|
||||||
# For Windows executable
|
# For Windows executable
|
||||||
app_data = os.getenv('APPDATA')
|
app_data = os.getenv('APPDATA')
|
||||||
if app_data:
|
base_path = os.path.join(app_data, 'YTSage') if app_data else os.path.dirname(sys.executable)
|
||||||
base_path = os.path.join(app_data, 'YTSage')
|
|
||||||
else:
|
|
||||||
base_path = os.path.dirname(sys.executable)
|
|
||||||
os.makedirs(base_path, exist_ok=True)
|
|
||||||
return os.path.join(base_path, 'yt-dlp.exe')
|
|
||||||
else:
|
else:
|
||||||
# For Linux AppImage or binary
|
# For Linux AppImage or binary
|
||||||
if 'APPIMAGE' in os.environ:
|
if 'APPIMAGE' in os.environ:
|
||||||
# Inside AppImage
|
|
||||||
xdg_data = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
xdg_data = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
||||||
base_path = os.path.join(xdg_data, 'YTSage')
|
base_path = os.path.join(xdg_data, 'YTSage')
|
||||||
else:
|
else:
|
||||||
base_path = os.path.dirname(sys.executable)
|
base_path = os.path.dirname(sys.executable)
|
||||||
|
|
||||||
|
# Create directory if it doesn't exist
|
||||||
|
try:
|
||||||
os.makedirs(base_path, exist_ok=True)
|
os.makedirs(base_path, exist_ok=True)
|
||||||
return os.path.join(base_path, 'yt-dlp')
|
except Exception as e:
|
||||||
|
print(f"Error creating directory: {e}")
|
||||||
|
base_path = os.path.dirname(sys.executable)
|
||||||
|
|
||||||
|
return os.path.join(base_path, 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
|
||||||
else:
|
else:
|
||||||
# For development/script mode
|
# For development/script mode
|
||||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
|
'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error determining yt-dlp path: {e}")
|
print(f"Error determining yt-dlp path: {e}")
|
||||||
# Fallback to current directory
|
# Fallback to current directory
|
||||||
return os.path.join(os.getcwd(), 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
|
return os.path.join(os.getcwd(), 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
|
||||||
|
|
||||||
def load_saved_path(main_window_instance): # Pass the main window instance
|
def load_saved_path(main_window_instance):
|
||||||
config_file = main_window_instance.config_file # Access config_file via instance
|
"""Load saved download path with enhanced error handling."""
|
||||||
|
config_file = main_window_instance.config_file
|
||||||
try:
|
try:
|
||||||
if config_file.exists():
|
if config_file.exists():
|
||||||
with open(config_file, 'r') as f:
|
try:
|
||||||
config = json.load(f)
|
with open(config_file, 'r', encoding='utf-8') as f:
|
||||||
saved_path = config.get('download_path', '')
|
config = json.load(f)
|
||||||
if os.path.exists(saved_path):
|
saved_path = config.get('download_path', '')
|
||||||
main_window_instance.last_path = saved_path # Access last_path via instance
|
if os.path.exists(saved_path) and os.access(saved_path, os.W_OK):
|
||||||
else:
|
main_window_instance.last_path = saved_path
|
||||||
main_window_instance.last_path = str(Path.home() / 'Downloads')
|
return
|
||||||
|
except (json.JSONDecodeError, UnicodeError) as e:
|
||||||
|
print(f"Error reading config file: {e}")
|
||||||
|
# If config file is corrupted, try to remove it
|
||||||
|
try:
|
||||||
|
os.remove(config_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to Downloads folder
|
||||||
|
downloads_path = str(Path.home() / 'Downloads')
|
||||||
|
if os.path.exists(downloads_path) and os.access(downloads_path, os.W_OK):
|
||||||
|
main_window_instance.last_path = downloads_path
|
||||||
else:
|
else:
|
||||||
main_window_instance.last_path = str(Path.home() / 'Downloads')
|
# Final fallback to temp directory if Downloads is not accessible
|
||||||
|
main_window_instance.last_path = tempfile.gettempdir()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading saved settings: {e}")
|
print(f"Error loading saved settings: {e}")
|
||||||
main_window_instance.last_path = str(Path.home() / 'Downloads')
|
main_window_instance.last_path = tempfile.gettempdir()
|
||||||
|
|
||||||
def save_path(main_window_instance, path): # Pass main window instance
|
def save_path(main_window_instance, path):
|
||||||
config_file = main_window_instance.config_file # Access config_file via instance
|
"""Save download path with enhanced error handling."""
|
||||||
|
config_file = main_window_instance.config_file
|
||||||
try:
|
try:
|
||||||
config = {
|
# Verify the path is valid and writable
|
||||||
'download_path': path
|
if not os.path.exists(path):
|
||||||
}
|
try:
|
||||||
with open(config_file, 'w') as f:
|
os.makedirs(path, exist_ok=True)
|
||||||
json.dump(config, f)
|
except Exception as e:
|
||||||
|
print(f"Error creating directory: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not os.access(path, os.W_OK):
|
||||||
|
print("Path is not writable")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create config directory if it doesn't exist
|
||||||
|
config_dir = config_file.parent
|
||||||
|
if not config_dir.exists():
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save the config
|
||||||
|
config = {'download_path': path}
|
||||||
|
with open(config_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(config, f, ensure_ascii=False)
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving settings: {e}")
|
print(f"Error saving settings: {e}")
|
||||||
|
return False
|
||||||
Reference in new issue
Block a user