Files
SageTube/ytsage_gui.py
T
Muhamed ec334c6213 v4.0.0
2025-02-21 19:02:34 +02:00

1800 lines
65 KiB
Python

import sys
import os
import webbrowser
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup)
from PySide6.QtCore import Qt, Signal, QObject, QThread
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
import requests
from io import BytesIO
from PIL import Image
from datetime import datetime
import json
from pathlib import Path
from packaging import version
import subprocess
import re
import yt_dlp # Add this line
from ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
from ytsage_utils import check_ffmpeg, get_yt_dlp_path, load_saved_path, save_path # Import utility functions
class LogWindow(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle('yt-dlp Log')
self.setMinimumSize(700, 500)
layout = QVBoxLayout(self)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setStyleSheet("""
QTextEdit {
background-color: #2b2b2b;
color: #ffffff;
font-family: Consolas, monospace;
font-size: 12px;
border: 2px solid #3d3d3d;
border-radius: 4px;
}
""")
layout.addWidget(self.log_text)
def append_log(self, message):
self.log_text.append(message)
# Auto-scroll to bottom
scrollbar = self.log_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
class CustomCommandDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.parent = parent
self.setWindowTitle('Custom yt-dlp Command')
self.setMinimumSize(600, 400)
layout = QVBoxLayout(self)
# Help text
help_text = QLabel(
"Enter custom yt-dlp commands below. The URL will be automatically appended.\n"
"Example: --extract-audio --audio-format mp3 --audio-quality 0\n"
"Note: Download path and output template will be preserved."
)
help_text.setWordWrap(True)
help_text.setStyleSheet("color: #999999; padding: 10px;")
layout.addWidget(help_text)
# Command input
self.command_input = QPlainTextEdit()
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
self.command_input.setStyleSheet("""
QPlainTextEdit {
background-color: #363636;
color: #ffffff;
border: 2px solid #3d3d3d;
border-radius: 4px;
padding: 8px;
font-family: Consolas, monospace;
}
""")
layout.addWidget(self.command_input)
# Add SponsorBlock checkbox
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
self.sponsorblock_checkbox.setStyleSheet("""
QCheckBox {
color: #ffffff;
padding: 5px;
margin-left: 20px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px; /* Make indicator round */
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #2b2b2b;
border-radius: 9px; /* Make indicator round */
}
QCheckBox::indicator:checked {
border: 2px solid #ff0000;
background: #ff0000;
border-radius: 9px; /* Make indicator round */
}
""")
layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox)
# Buttons
button_layout = QHBoxLayout()
self.run_btn = QPushButton("Run Command")
self.run_btn.clicked.connect(self.run_custom_command)
self.close_btn = QPushButton("Close")
self.close_btn.clicked.connect(self.close)
button_layout.addWidget(self.run_btn)
button_layout.addWidget(self.close_btn)
layout.addLayout(button_layout)
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet("""
QTextEdit {
background-color: #2b2b2b;
color: #ffffff;
border: 2px solid #3d3d3d;
border-radius: 4px;
padding: 8px;
font-family: Consolas, monospace;
font-size: 12px;
}
""")
layout.addWidget(self.log_output)
self.setStyleSheet("""
QDialog {
background-color: #2b2b2b;
}
QPushButton {
padding: 8px 15px;
background-color: #ff0000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #cc0000;
}
""")
def run_custom_command(self):
url = self.parent.url_input.text().strip()
if not url:
self.log_output.append("Error: No URL provided")
return
command = self.command_input.toPlainText().strip()
path = self.parent.path_input.text().strip()
self.log_output.clear()
self.log_output.append(f"Running command with URL: {url}")
self.run_btn.setEnabled(False)
# Start command in thread
import threading
threading.Thread(target=self._run_command_thread,
args=(command, url, path),
daemon=True).start()
def _run_command_thread(self, command, url, path):
try:
class CommandLogger:
def debug(self, msg):
self.dialog.log_output.append(msg)
def warning(self, msg):
self.dialog.log_output.append(f"Warning: {msg}")
def error(self, msg):
self.dialog.log_output.append(f"Error: {msg}")
def __init__(self, dialog):
self.dialog = dialog
# Split command into arguments
args = command.split()
# Base options
ydl_opts = {
'logger': CommandLogger(self),
'paths': {'home': path},
'debug_printout': True,
'postprocessors': []
}
# Add SponsorBlock options if enabled
if self.sponsorblock_checkbox.isChecked():
ydl_opts['postprocessors'].extend([{
'key': 'SponsorBlock',
'categories': ['sponsor', 'selfpromo', 'interaction'],
'api': 'https://sponsor.ajay.app'
}, {
'key': 'ModifyChapters',
'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'],
'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l',
'force_keyframes': True
}])
# Add custom arguments
for i in range(0, len(args), 2):
if i + 1 < len(args):
key = args[i].lstrip('-').replace('-', '_')
value = args[i + 1]
try:
# Try to convert to appropriate type
if value.lower() in ('true', 'false'):
value = value.lower() == 'true'
elif value.isdigit():
value = int(value)
ydl_opts[key] = value
except:
ydl_opts[key] = value
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
self.log_output.append("Command completed successfully")
except Exception as e:
self.log_output.append(f"Error: {str(e)}")
finally:
self.run_btn.setEnabled(True)
class FFmpegCheckDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle('FFmpeg Required')
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
# Message
message_label = QLabel(
"FFmpeg is not installed. Please install it.\n"
"To install FFmpeg, click the button below."
)
message_label.setWordWrap(True)
layout.addWidget(message_label)
# Install button
install_btn = QPushButton("Install FFmpeg")
install_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide'))
layout.addWidget(install_btn)
# Close button
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
# Style the dialog
self.setStyleSheet("""
QDialog {
background-color: #2b2b2b;
}
QLabel {
color: #ffffff;
font-size: 12px;
padding: 10px;
}
QPushButton {
padding: 8px 15px;
background-color: #ff0000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #cc0000;
}
""")
class YTDLPUpdateDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle('Update yt-dlp')
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
# Version info and message
self.version_label = QLabel()
self.version_label.setWordWrap(True)
layout.addWidget(self.version_label)
self.message_label = QLabel(
"Would you like to update yt-dlp to the latest version?\n"
"This will download and install the latest yt-dlp executable."
)
self.message_label.setWordWrap(True)
layout.addWidget(self.message_label)
# Progress bar (hidden initially)
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
# Status label
self.status_label = QLabel()
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
# Buttons
button_layout = QHBoxLayout()
self.update_btn = QPushButton("Update yt-dlp")
self.update_btn.clicked.connect(self.start_update)
self.update_btn.setEnabled(False) # Disabled until version check
self.close_btn = QPushButton("Close")
self.close_btn.clicked.connect(self.close)
button_layout.addWidget(self.update_btn)
button_layout.addWidget(self.close_btn)
layout.addLayout(button_layout)
# Style the dialog
self.setStyleSheet("""
QDialog {
background-color: #2b2b2b;
}
QLabel {
color: #ffffff;
font-size: 12px;
padding: 10px;
}
QPushButton {
padding: 8px 15px;
background-color: #ff0000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #cc0000;
}
QPushButton:disabled {
background-color: #666666;
}
QProgressBar {
border: 2px solid #3d3d3d;
border-radius: 4px;
text-align: center;
color: white;
}
QProgressBar::chunk {
background-color: #ff0000;
}
""")
# Check versions when dialog opens
self.check_versions()
def check_versions(self):
import threading
threading.Thread(target=self._check_versions_thread, daemon=True).start()
def _check_versions_thread(self):
try:
# Get current version with proper path
yt_dlp_path = get_yt_dlp_path()
if os.path.exists(yt_dlp_path):
try:
# Use the full path to yt-dlp executable
result = subprocess.run([yt_dlp_path, '--version'],
capture_output=True,
text=True,
timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
if result.returncode == 0:
current_version = result.stdout.strip()
else:
# Try using pip to get version as fallback
try:
current_version = yt_dlp.version.__version__
except:
current_version = "Not installed"
except (subprocess.SubprocessError, subprocess.TimeoutExpired):
# Try using pip to get version as fallback
try:
current_version = yt_dlp.version.__version__
except:
current_version = "Not installed"
else:
# Try using pip to get version as fallback
try:
current_version = yt_dlp.version.__version__
except:
current_version = "Not installed"
# Get latest version from GitHub API with timeout
try:
response = requests.get(
"https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest",
timeout=10
)
response.raise_for_status()
latest_version = response.json()["tag_name"]
except Exception as e:
raise Exception(f"Could not fetch latest version: {str(e)}")
self.version_label.setText(
f"Current version: {current_version}\n"
f"Latest version: {latest_version}"
)
if current_version == "Not installed" or current_version != latest_version:
self.update_btn.setEnabled(True)
self.message_label.setText("An update is available!")
else:
self.message_label.setText("You have the latest version installed.")
self.update_btn.setEnabled(False)
except Exception as e:
self.version_label.setText("Could not check versions")
self.message_label.setText(f"Error: {str(e)}")
self.update_btn.setEnabled(True)
def start_update(self):
self.update_btn.setEnabled(False)
self.progress_bar.setVisible(True)
self.status_label.setText("Starting update...")
# Start update in a separate thread
import threading
threading.Thread(target=self._update_thread, daemon=True).start()
def _update_thread(self):
temp_path = None
try:
# Create necessary directories
target_path = get_yt_dlp_path()
os.makedirs(os.path.dirname(target_path), exist_ok=True)
# Determine platform and get URLs
if sys.platform == 'win32':
url = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe'
else:
url = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp'
temp_path = target_path + '.download'
# Download with proper error handling
try:
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
block_size = 8192 # Increased for better performance
with open(temp_path, 'wb') as f:
for data in response.iter_content(block_size):
if data: # Filter out keep-alive chunks
f.write(data)
progress = int((f.tell() * 100) / total_size) if total_size > 0 else 0
self.progress_bar.setValue(progress)
self.status_label.setText(f"Downloading: {progress}%")
except requests.exceptions.RequestException as e:
raise Exception(f"Download failed: {str(e)}")
# Set proper permissions
if sys.platform != 'win32':
try:
os.chmod(temp_path, 0o755)
except Exception as e:
print(f"Warning: Could not set executable permissions: {e}")
# Handle file replacement
try:
if os.path.exists(target_path):
if sys.platform == 'win32':
# Windows-specific file replacement
import ctypes
if not ctypes.windll.kernel32.MoveFileExW(target_path, None, 4): # MOVEFILE_DELAY_UNTIL_REBOOT
os.remove(target_path)
else:
os.remove(target_path)
except PermissionError:
raise Exception("Cannot update while yt-dlp is in use. Please close any active downloads and try again.")
except Exception as e:
raise Exception(f"Error removing existing file: {str(e)}")
# Move temporary file to final location
try:
os.rename(temp_path, target_path)
except Exception as e:
raise Exception(f"Error moving new file into place: {str(e)}")
self.status_label.setText("yt-dlp updated successfully!")
self.close_btn.setText("Done")
# Refresh version info
self.check_versions()
except Exception as e:
self.status_label.setText(f"Error updating yt-dlp: {str(e)}")
finally:
# Clean up temp file if it exists
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
self.update_btn.setEnabled(True)
class AboutDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle('About YTSage')
self.setMinimumWidth(500)
layout = QVBoxLayout(self)
# App title and version
title_label = QLabel("YTSage")
title_label.setStyleSheet("""
font-size: 24px;
font-weight: bold;
color: #ff0000;
padding: 10px;
""")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
version_label = QLabel(f"Version {parent.version}")
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version_label)
# Description
description = QLabel(
"A modern YouTube downloader with a clean interface.\n"
"Download videos in any quality, extract audio, fetch subtitles,\n"
"and view video metadata. Built with yt-dlp for reliable performance."
)
description.setWordWrap(True)
description.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(description)
# Features list
features_label = QLabel(
"Key Features:\n"
"• Smart video quality selection\n"
"• Audio extraction\n"
"• Subtitle support\n"
"• Playlist downloads\n"
"• Real-time progress tracking\n"
"• Custom command support\n"
"• SponsorBlock Integration\n"
"• SponsorBlock Integration\n"
"• One-click updates"
)
features_label.setStyleSheet("padding: 10px;")
layout.addWidget(features_label)
# Credits
credits_label = QLabel(
"Powered by:\n"
"• yt-dlp\n"
"• PySide6\n"
"• FFmpeg"
)
credits_label.setStyleSheet("padding: 10px;")
layout.addWidget(credits_label)
# GitHub link
github_btn = QPushButton("Visit GitHub Repository")
github_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/YTSage'))
layout.addWidget(github_btn)
# Close button
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
# Style the dialog
self.setStyleSheet("""
QDialog {
background-color: #2b2b2b;
}
QLabel {
color: #ffffff;
font-size: 12px;
}
QPushButton {
padding: 8px 15px;
background-color: #ff0000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #cc0000;
}
""")
class YTSageApp(QMainWindow): # Renamed class to YTSageApp to avoid name conflict with original file name
def __init__(self):
super().__init__()
# Check for FFmpeg before proceeding
if not check_ffmpeg():
self.show_ffmpeg_dialog()
self.version = "4.0.0"
self.check_for_updates()
self.config_file = Path.home() / '.ytsage_config.json'
load_saved_path(self)
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
self.signals = SignalManager()
self.download_paused = False
self.current_download = None
self.download_cancelled = False
self.save_thumbnail = False # Initialize thumbnail state
self.thumbnail_url = None # Add this to store thumbnail URL
self.init_ui()
self.setStyleSheet("""
QMainWindow {
background-color: #2b2b2b;
}
QWidget {
background-color: #2b2b2b;
color: #ffffff;
}
QLineEdit {
padding: 8px;
border: 2px solid #3d3d3d;
border-radius: 4px;
background-color: #363636;
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
background-color: #ff0000; /* YouTube red */
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #cc0000; /* Darker red on hover */
}
QPushButton:pressed {
background-color: #990000; /* Even darker red when pressed */
}
QTableWidget {
border: 2px solid #3d3d3d;
border-radius: 4px;
background-color: #363636;
gridline-color: #3d3d3d;
}
QHeaderView::section {
background-color: #2b2b2b;
padding: 5px;
border: 1px solid #3d3d3d;
color: #ffffff;
}
QProgressBar {
border: 2px solid #3d3d3d;
border-radius: 4px;
text-align: center;
color: white;
}
QProgressBar::chunk {
background-color: #ff0000; /* YouTube red */
border-radius: 2px;
}
QLabel {
color: #ffffff;
}
/* Style for filter buttons */
QPushButton.filter-btn {
background-color: #363636;
padding: 5px 10px;
margin: 0 5px;
}
QPushButton.filter-btn:checked {
background-color: #ff0000;
}
QPushButton.filter-btn:hover {
background-color: #444444;
}
QPushButton.filter-btn:checked:hover {
background-color: #cc0000;
}
/* Modern Scrollbar Styling */
QScrollBar:vertical {
border: none;
background: #2b2b2b;
width: 14px;
margin: 15px 0 15px 0;
border-radius: 7px;
}
QScrollBar::handle:vertical {
background: #404040;
min-height: 30px;
border-radius: 7px;
}
QScrollBar::handle:vertical:hover {
background: #505050;
}
QScrollBar::sub-line:vertical {
border: none;
background: #2b2b2b;
height: 15px;
border-top-left-radius: 7px;
border-top-right-radius: 7px;
subcontrol-position: top;
subcontrol-origin: margin;
}
QScrollBar::add-line:vertical {
border: none;
background: #2b2b2b;
height: 15px;
border-bottom-left-radius: 7px;
border-bottom-right-radius: 7px;
subcontrol-position: bottom;
subcontrol-origin: margin;
}
QScrollBar::sub-line:vertical:hover,
QScrollBar::add-line:vertical:hover {
background: #404040;
}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
background: none;
width: 0;
height: 0;
}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: none;
}
/* Horizontal Scrollbar */
QScrollBar:horizontal {
border: none;
background: #2b2b2b;
height: 14px;
margin: 0 15px 0 15px;
border-radius: 7px;
}
QScrollBar::handle:horizontal {
background: #404040;
min-width: 30px;
border-radius: 7px;
}
QScrollBar::handle:horizontal:hover {
background: #505050;
}
QScrollBar::sub-line:horizontal {
border: none;
background: #2b2b2b;
width: 15px;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
subcontrol-position: left;
subcontrol-origin: margin;
}
QScrollBar::add-line:horizontal {
border: none;
background: #2b2b2b;
width: 15px;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
subcontrol-position: right;
subcontrol-origin: margin;
}
QScrollBar::sub-line:horizontal:hover,
QScrollBar::add-line:horizontal:hover {
background: #404040;
}
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {
background: none;
width: 0;
height: 0;
}
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {
background: none;
}
""")
self.signals.update_progress.connect(self.update_progress_bar)
def load_saved_path(self): # Using function from ytsage_utils now - no longer needed in class
pass # Handled in class init now via ytsage_utils.load_saved_path(self)
def save_path(self, path): # Using function from ytsage_utils now - no longer needed in class
save_path(self, path) # Call the utility function
def init_ui(self):
self.setWindowTitle('YTSage v4.0.0')
self.setMinimumSize(900, 600)
# Main widget and layout
main_widget = QWidget()
self.setCentralWidget(main_widget)
layout = QVBoxLayout(main_widget)
layout.setSpacing(10)
layout.setContentsMargins(20, 20, 20, 20)
# URL input section
url_layout = QHBoxLayout()
self.url_input = QLineEdit()
self.url_input.setPlaceholderText('Enter YouTube URL...')
# Add Paste URL button
self.paste_btn = QPushButton('Paste URL')
self.paste_btn.clicked.connect(self.paste_url)
self.analyze_btn = QPushButton('Analyze')
self.analyze_btn.clicked.connect(self.analyze_url)
url_layout.addWidget(self.url_input)
url_layout.addWidget(self.paste_btn)
url_layout.addWidget(self.analyze_btn)
layout.addLayout(url_layout)
# Create a horizontal layout for thumbnail and video info
media_info_layout = QHBoxLayout()
# Thumbnail on the left
self.thumbnail_label = QLabel()
self.thumbnail_label.setFixedSize(320, 180)
self.thumbnail_label.setStyleSheet("border: 2px solid #3d3d3d; border-radius: 4px;")
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
media_info_layout.addWidget(self.thumbnail_label)
# Video information on the right
video_info_layout = QVBoxLayout()
self.title_label = QLabel()
self.title_label.setWordWrap(True)
self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;")
self.channel_label = QLabel()
self.views_label = QLabel()
self.date_label = QLabel()
self.duration_label = QLabel()
# Style the info labels
for label in [self.channel_label, self.views_label, self.date_label, self.duration_label]:
label.setStyleSheet("""
QLabel {
color: #cccccc;
font-size: 12px;
padding: 2px 0;
}
""")
# Subtitle section with improved styling
subtitle_layout = QHBoxLayout()
self.subtitle_check = QPushButton("Download Subtitles")
self.subtitle_check.setCheckable(True)
self.subtitle_check.clicked.connect(self.toggle_subtitle_controls)
self.subtitle_check.setStyleSheet("""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
border-radius: 4px;
padding: 5px 10px;
}
QPushButton:checked {
background-color: #ff0000;
border-color: #cc0000;
}
""")
subtitle_layout.addWidget(self.subtitle_check)
# Create subtitle combo box
self.subtitle_combo = QComboBox()
self.subtitle_combo.setVisible(False)
self.subtitle_combo.setStyleSheet("""
QComboBox {
background-color: #363636;
border: 2px solid #3d3d3d;
border-radius: 4px;
padding: 5px;
min-width: 200px;
}
QComboBox::drop-down {
border: none;
}
QComboBox::down-arrow {
image: url(down_arrow.png);
width: 12px;
height: 12px;
}
QComboBox QAbstractItemView {
background-color: #363636;
selection-background-color: #ff0000;
selection-color: white;
}
""")
# Create subtitle filter input
self.subtitle_filter_input = QLineEdit()
self.subtitle_filter_input.setPlaceholderText("Filter languages (e.g., en, es)")
self.subtitle_filter_input.setMaximumWidth(200)
self.subtitle_filter_input.textChanged.connect(self.filter_subtitles)
self.subtitle_filter_input.setVisible(False)
self.subtitle_filter_input.setStyleSheet("""
QLineEdit {
background-color: #363636;
color: #ffffff;
border: 2px solid #3d3d3d;
border-radius: 4px;
padding: 5px;
}
QLineEdit:focus {
border-color: #ff0000;
}
""")
# Add filter label and components to layout
self.subtitle_filter_label = QLabel("Filter:")
self.subtitle_filter_label.setVisible(False)
subtitle_layout.addWidget(self.subtitle_filter_label)
subtitle_layout.addWidget(self.subtitle_filter_input)
subtitle_layout.addWidget(self.subtitle_combo)
# Add merge subtitle toggle button
self.merge_subs_btn = QPushButton('Merge Subtitles')
self.merge_subs_btn.setCheckable(True)
self.merge_subs_btn.setVisible(False)
self.merge_subs_btn.setStyleSheet("""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
border-radius: 4px;
padding: 5px 10px;
}
QPushButton:checked {
background-color: #ff0000;
border-color: #cc0000;
}
QPushButton:hover {
background-color: #444444;
}
QPushButton:checked:hover {
background-color: #cc0000;
}
""")
subtitle_layout.addWidget(self.merge_subs_btn)
subtitle_layout.addStretch()
# Add all info widgets to the video info layout
video_info_layout.addWidget(self.title_label)
video_info_layout.addWidget(self.channel_label)
video_info_layout.addWidget(self.views_label)
video_info_layout.addWidget(self.date_label)
video_info_layout.addWidget(self.duration_label)
video_info_layout.addLayout(subtitle_layout)
video_info_layout.addStretch()
media_info_layout.addLayout(video_info_layout)
layout.addLayout(media_info_layout)
# Add playlist information section with improved styling
self.playlist_info_label = QLabel()
self.playlist_info_label.setVisible(False)
self.playlist_info_label.setStyleSheet("""
QLabel {
font-size: 12px;
color: #ff9900;
padding: 5px;
background-color: #363636;
border-radius: 4px;
}
""")
layout.addWidget(self.playlist_info_label)
# Create format selection layout (horizontal)
self.format_layout = QHBoxLayout()
# Show formats label
self.show_formats_label = QLabel("Show formats:")
self.show_formats_label.setStyleSheet("color: white;")
self.format_layout.addWidget(self.show_formats_label)
# Format buttons group
self.format_buttons = QButtonGroup(self)
self.format_buttons.setExclusive(True)
# Video button
self.video_button = QPushButton("Video")
self.video_button.setCheckable(True)
self.video_button.setChecked(True) # Set video as default
self.video_button.setStyleSheet("""
QPushButton {
padding: 8px 15px;
background-color: #363636;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:checked {
background-color: #ff0000;
}
QPushButton:hover {
background-color: #444444;
}
QPushButton:checked:hover {
background-color: #cc0000;
}
""")
self.format_buttons.addButton(self.video_button)
self.format_layout.addWidget(self.video_button)
# Audio button
self.audio_button = QPushButton("Audio Only")
self.audio_button.setCheckable(True)
self.audio_button.setStyleSheet("""
QPushButton {
padding: 8px 15px;
background-color: #363636;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:checked {
background-color: #ff0000;
}
QPushButton:hover {
background-color: #444444;
}
QPushButton:checked:hover {
background-color: #cc0000;
}
""")
self.format_buttons.addButton(self.audio_button)
self.format_layout.addWidget(self.audio_button)
# Connect format buttons
self.format_buttons.buttonClicked.connect(self.handle_format_selection)
# Add SponsorBlock checkbox
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
self.sponsorblock_checkbox.setStyleSheet("""
QCheckBox {
color: #ffffff;
padding: 5px;
margin-left: 20px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #2b2b2b;
border-radius: 9px;
}
QCheckBox::indicator:checked {
border: 2px solid #ff0000;
background: #ff0000;
border-radius: 9px;
}
""")
self.format_layout.addWidget(self.sponsorblock_checkbox)
# Add Save Thumbnail checkbox with same style as SponsorBlock
self.save_thumbnail_checkbox = QCheckBox("Save Thumbnail")
self.save_thumbnail_checkbox.setStyleSheet("""
QCheckBox {
color: #ffffff;
padding: 5px;
margin-left: 20px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #2b2b2b;
border-radius: 9px;
}
QCheckBox::indicator:checked {
border: 2px solid #ff0000;
background: #ff0000;
border-radius: 9px;
}
""")
self.save_thumbnail_checkbox.clicked.connect(self.toggle_save_thumbnail)
self.format_layout.addWidget(self.save_thumbnail_checkbox)
self.format_layout.addStretch()
# Add format layout to main layout
layout.addLayout(self.format_layout)
# Format table with improved styling
self.format_table = QTableWidget()
self.format_table.setColumnCount(6)
self.format_table.setHorizontalHeaderLabels(['Format ID', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio'])
self.format_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.format_table.setStyleSheet("""
QTableWidget {
background-color: #363636;
border: 2px solid #3d3d3d;
border-radius: 4px;
gridline-color: #3d3d3d;
}
QTableWidget::item {
padding: 5px;
}
QTableWidget::item:selected {
background-color: #ff0000;
}
QHeaderView::section {
background-color: #2b2b2b;
padding: 5px;
border: 1px solid #3d3d3d;
font-weight: bold;
}
""")
layout.addWidget(self.format_table)
# Download section
download_layout = QHBoxLayout()
# Add existing buttons
self.custom_cmd_btn = QPushButton('Custom Command')
self.custom_cmd_btn.clicked.connect(self.show_custom_command)
self.update_ytdlp_btn = QPushButton('Update yt-dlp')
self.update_ytdlp_btn.clicked.connect(self.update_ytdlp)
self.about_btn = QPushButton('About')
self.about_btn.clicked.connect(self.show_about_dialog)
self.path_input = QLineEdit(self.last_path)
self.path_input.setPlaceholderText('Download path...')
self.browse_btn = QPushButton('Browse')
self.browse_btn.clicked.connect(self.browse_path)
self.download_btn = QPushButton('Download')
self.download_btn.clicked.connect(self.start_download)
# Add pause and cancel buttons
self.pause_btn = QPushButton('Pause')
self.pause_btn.clicked.connect(self.toggle_pause)
self.pause_btn.setVisible(False) # Hidden initially
self.cancel_btn = QPushButton('Cancel')
self.cancel_btn.clicked.connect(self.cancel_download)
self.cancel_btn.setVisible(False) # Hidden initially
# Add all buttons to layout in the correct order
download_layout.addWidget(self.custom_cmd_btn)
download_layout.addWidget(self.update_ytdlp_btn)
download_layout.addWidget(self.about_btn)
download_layout.addWidget(self.path_input)
download_layout.addWidget(self.browse_btn)
download_layout.addWidget(self.download_btn)
download_layout.addWidget(self.pause_btn)
download_layout.addWidget(self.cancel_btn)
layout.addLayout(download_layout)
# Progress section with improved styling
progress_layout = QVBoxLayout()
self.progress_bar = QProgressBar()
self.progress_bar.setStyleSheet("""
QProgressBar {
border: 2px solid #3d3d3d;
border-radius: 4px;
text-align: center;
color: white;
background-color: #363636;
height: 25px;
}
QProgressBar::chunk {
background-color: #ff0000;
border-radius: 2px;
}
""")
progress_layout.addWidget(self.progress_bar)
# Add download details label with improved styling
self.download_details_label = QLabel()
self.download_details_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.download_details_label.setStyleSheet("""
QLabel {
color: #cccccc;
font-size: 12px;
padding: 5px;
}
""")
progress_layout.addWidget(self.download_details_label)
self.status_label = QLabel('Ready')
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.status_label.setStyleSheet("""
QLabel {
color: #cccccc;
font-size: 12px;
padding: 5px;
}
""")
progress_layout.addWidget(self.status_label)
layout.addLayout(progress_layout)
# Connect signals
self.signals.update_formats.connect(self.update_format_table)
self.signals.update_status.connect(self.status_label.setText)
self.signals.update_progress.connect(self.update_progress_bar)
def analyze_url(self):
url = self.url_input.text().strip()
if not url:
self.signals.update_status.emit("Invalid URL or please enter a URL.")
return
self.signals.update_status.emit("Analyzing (0%)... Preparing request")
import threading # Import threading here as it is only used in GUI and downloader
threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start()
def _analyze_url_thread(self, url):
try:
self.signals.update_status.emit("Analyzing (20%)... Extracting basic info")
# Clean up the URL to handle both playlist and video URLs
if 'list=' in url and 'watch?v=' in url:
playlist_id = url.split('list=')[1].split('&')[0]
url = f'https://www.youtube.com/playlist?list={playlist_id}'
# Initial extraction with basic options
ydl_opts = {
'quiet': False,
'no_warnings': False,
'extract_flat': True,
'force_generic_extractor': False,
'ignoreerrors': True,
'no_color': True,
'verbose': True
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
basic_info = ydl.extract_info(url, download=False)
if not basic_info:
raise Exception("Could not extract basic video information")
except Exception as e:
print(f"First extraction failed: {str(e)}")
raise Exception("Could not extract video information, please check your link")
self.signals.update_status.emit("Analyzing (40%)... Extracting detailed info")
# Configure options for detailed extraction
ydl_opts.update({
'extract_flat': False,
'format': None,
'writesubtitles': True,
'allsubtitles': True,
'writeautomaticsub': True,
'playliststart': 1,
'playlistend': 1,
'youtube_include_dash_manifest': True,
'youtube_include_hls_manifest': True
})
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
self.signals.update_status.emit("Analyzing (60%)... Processing video data")
if basic_info.get('_type') == 'playlist':
self.is_playlist = True
self.playlist_info = basic_info
# Get the first video URL from the playlist
first_video = None
for entry in basic_info['entries']:
if entry:
first_video = entry
break
if not first_video:
raise Exception("No valid videos found in playlist")
# Get the video URL
video_url = first_video.get('url') or first_video.get('webpage_url')
if not video_url:
raise Exception("Could not extract video URL from playlist")
# Extract detailed information for the first video
self.video_info = ydl.extract_info(video_url, download=False)
# Update playlist info
playlist_text = f"Playlist: {basic_info.get('title', 'Unknown')} | {len(basic_info['entries'])} videos"
self.playlist_info_label.setText(playlist_text)
self.playlist_info_label.setVisible(True)
else:
self.is_playlist = False
self.video_info = ydl.extract_info(url, download=False)
self.playlist_info_label.setVisible(False)
# Verify we have format information
if not self.video_info or 'formats' not in self.video_info:
print(f"Debug - video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
raise Exception("No format information available")
self.signals.update_status.emit("Analyzing (80%)... Processing formats")
self.all_formats = self.video_info['formats']
# Update UI
self.update_video_info(self.video_info)
# Update thumbnail
self.signals.update_status.emit("Analyzing (90%)... Loading thumbnail")
self.download_thumbnail(self.video_info.get('thumbnail'))
# Save thumbnail if enabled - use the stored VIDEO URL
if self.save_thumbnail:
self.download_thumbnail_file(self.video_url, self.path_input.text())
# Update subtitles
self.signals.update_status.emit("Analyzing (95%)... Processing subtitles")
self.available_subtitles = self.video_info.get('subtitles', {})
self.available_automatic_subtitles = self.video_info.get('automatic_captions', {})
self.update_subtitle_list()
# Update format table
self.signals.update_status.emit("Analyzing (98%)... Updating format table")
self.video_button.setChecked(True)
self.audio_button.setChecked(False)
self.filter_formats()
self.signals.update_status.emit("Analysis complete!")
except Exception as e:
print(f"Detailed extraction failed: {str(e)}")
raise Exception(f"Failed to extract video details: {str(e)}")
except Exception as e:
error_message = str(e)
print(f"Error in analysis: {error_message}")
self.signals.update_status.emit(f"Error: {error_message}")
def update_video_info(self, info):
# Format view count with commas
views = int(info.get('view_count', 0))
formatted_views = f"{views:,}"
# Format upload date
upload_date = info.get('upload_date', '')
if upload_date:
date_obj = datetime.strptime(upload_date, '%Y%m%d')
formatted_date = date_obj.strftime('%B %d, %Y')
else:
formatted_date = 'Unknown date'
# Format duration
duration = info.get('duration', 0)
minutes = duration // 60
seconds = duration % 60
duration_str = f"{minutes}:{seconds:02d}"
# Update labels
self.title_label.setText(info.get('title', 'Unknown title'))
self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}")
self.views_label.setText(f"Views: {formatted_views}")
self.date_label.setText(f"Upload date: {formatted_date}")
self.duration_label.setText(f"Duration: {duration_str}")
def toggle_subtitle_controls(self):
is_checked = self.subtitle_check.isChecked()
self.merge_subs_btn.setVisible(is_checked)
self.subtitle_combo.setVisible(is_checked)
self.subtitle_filter_input.setVisible(is_checked)
self.subtitle_filter_label.setVisible(is_checked)
def update_subtitle_list(self):
self.subtitle_combo.clear()
if not (self.available_subtitles or self.available_automatic_subtitles):
self.subtitle_combo.addItem("No subtitles available")
return
# Add subtitle options
self.subtitle_combo.addItem("Select subtitle language")
# Filter and add subtitles
filter_text = self.subtitle_filter_input.text().lower()
# Add manual subtitles
for lang_code, subtitle_info in self.available_subtitles.items():
if not filter_text or filter_text in lang_code.lower():
self.subtitle_combo.addItem(f"{lang_code} - Manual")
# Add auto-generated subtitles
for lang_code, subtitle_info in self.available_automatic_subtitles.items():
if not filter_text or filter_text in lang_code.lower():
self.subtitle_combo.addItem(f"{lang_code} - Auto-generated")
def filter_subtitles(self):
self.subtitle_filter = self.subtitle_filter_input.text()
self.update_subtitle_list()
def download_thumbnail(self, url):
try:
# Store both thumbnail URL and video URL
self.thumbnail_url = url
self.video_url = self.url_input.text() # Get actual video URL
# Download thumbnail but don't save yet
response = requests.get(url)
self.thumbnail_image = Image.open(BytesIO(response.content))
# Display thumbnail
image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS)
img_byte_arr = BytesIO()
image.save(img_byte_arr, format='PNG')
pixmap = QPixmap()
pixmap.loadFromData(img_byte_arr.getvalue())
self.thumbnail_label.setPixmap(pixmap)
except Exception as e:
print(f"Error loading thumbnail: {str(e)}")
def filter_formats(self):
# Clear current table
self.format_table.setRowCount(0)
# Determine which formats to show
filtered_formats = []
if self.video_button.isChecked():
# Include all video formats (both with and without audio)
filtered_formats.extend([f for f in self.all_formats
if f.get('vcodec') != 'none'
and f.get('filesize') is not None])
if self.audio_button.isChecked():
# Add audio-only formats
filtered_formats.extend([f for f in self.all_formats
if (f.get('vcodec') == 'none'
or 'audio only' in f.get('format_note', '').lower())
and f.get('acodec') != 'none'
and f.get('filesize') is not None])
# Sort formats by quality
def get_quality(f):
if f.get('vcodec') != 'none':
# Extract height from resolution (e.g., "1920x1080" -> 1080)
res = f.get('resolution', '0x0').split('x')[-1]
try:
return int(res)
except ValueError:
return 0
else:
return f.get('abr', 0)
filtered_formats.sort(key=get_quality, reverse=True)
# Update table with filtered formats
self.update_format_table(filtered_formats)
def update_format_table(self, formats):
self.format_table.setRowCount(0)
for f in formats:
row = self.format_table.rowCount()
self.format_table.insertRow(row)
# Format ID
self.format_table.setItem(row, 0, QTableWidgetItem(str(f.get('format_id', ''))))
# Extension
self.format_table.setItem(row, 1, QTableWidgetItem(f.get('ext', '')))
# Resolution
resolution = f.get('resolution', 'N/A')
if f.get('vcodec') == 'none':
resolution = 'Audio only'
self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
# File Size
filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
self.format_table.setItem(row, 3, QTableWidgetItem(filesize))
# Codec
if f.get('vcodec') == 'none':
codec = f.get('acodec', 'N/A')
else:
codec = f"{f.get('vcodec', 'N/A')}"
if f.get('acodec') != 'none':
codec += f" / {f.get('acodec', 'N/A')}"
self.format_table.setItem(row, 4, QTableWidgetItem(codec))
# Audio Status
needs_audio = f.get('acodec') == 'none'
audio_status = "Will merge audio" if needs_audio else "✓ Has Audio"
audio_item = QTableWidgetItem(audio_status)
if needs_audio:
audio_item.setForeground(QColor('#ffa500')) # Orange for merge indication
self.format_table.setItem(row, 5, audio_item)
def browse_path(self):
path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.last_path)
if path:
self.path_input.setText(path)
self.save_path(path) # Use save_path utility function
self.last_path = path
def start_download(self):
url = self.url_input.text().strip()
path = self.path_input.text().strip()
if not url or not path:
self.status_label.setText("Please enter URL and download path")
return
# Get selected format
selected_items = self.format_table.selectedItems()
if not selected_items:
self.status_label.setText("Please select a format")
return
format_id = self.format_table.item(selected_items[0].row(), 0).text()
# Get resolution for filename
resolution = self.format_table.item(selected_items[0].row(), 2).text()
resolution = resolution.replace(' ', '').lower() # Clean up resolution string
# Get subtitle selection if available
subtitle_lang = None
if hasattr(self, 'subtitle_combo') and self.subtitle_combo.currentIndex() > 0:
subtitle_lang = self.subtitle_combo.currentText()
# Check if it's a playlist
is_playlist = 'playlist' in url.lower() and '/watch?' not in url
# Save thumbnail if enabled
if self.save_thumbnail:
self.download_thumbnail_file(url, path)
# Create download thread with resolution in output template
self.download_thread = DownloadThread(
url=url,
path=path,
format_id=format_id,
subtitle_lang=subtitle_lang,
is_playlist=is_playlist,
merge_subs=bool(subtitle_lang),
enable_sponsorblock=self.sponsorblock_checkbox.isChecked(),
resolution=resolution
)
# Connect signals
self.download_thread.progress_signal.connect(self.update_progress_bar)
self.download_thread.status_signal.connect(self.signals.update_status.emit)
self.download_thread.finished_signal.connect(self.download_finished)
self.download_thread.error_signal.connect(self.download_error)
# Reset download state
self.download_paused = False
self.download_cancelled = False
# Show pause/cancel buttons
self.pause_btn.setText('Pause')
self.pause_btn.setVisible(True)
self.cancel_btn.setVisible(True)
# Start download thread
self.current_download = self.download_thread
self.download_thread.start()
self.toggle_download_controls(False)
def download_finished(self):
self.toggle_download_controls(True)
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.progress_bar.setValue(100)
self.status_label.setText("Download completed!")
def download_error(self, error_message):
self.toggle_download_controls(True)
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.status_label.setText(f"Error: {error_message}")
def update_progress_bar(self, value):
try:
# Ensure the value is an integer
int_value = int(value)
self.progress_bar.setValue(int_value)
except Exception as e:
print(f"Progress bar update error: {str(e)}")
def toggle_pause(self):
if self.current_download:
self.current_download.paused = not self.current_download.paused
if self.current_download.paused:
self.pause_btn.setText('Resume')
self.signals.update_status.emit("Download paused")
else:
self.pause_btn.setText('Pause')
self.signals.update_status.emit("Download resumed")
def check_for_updates(self):
try:
# Get the latest release info from GitHub
response = requests.get(
"https://api.github.com/repos/oop7/YTSage/releases/latest",
headers={"Accept": "application/vnd.github.v3+json"}
)
response.raise_for_status()
latest_release = response.json()
latest_version = latest_release["tag_name"].lstrip('v')
# Compare versions
if version.parse(latest_version) > version.parse(self.version):
self.show_update_dialog(latest_version, latest_release["html_url"])
except Exception as e:
print(f"Failed to check for updates: {str(e)}")
def show_update_dialog(self, latest_version, release_url):
msg = QDialog(self)
msg.setWindowTitle("Update Available")
msg.setMinimumWidth(400)
layout = QVBoxLayout(msg)
# Update message
message_label = QLabel(
f"A new version of YTSage is available!\n\n"
f"Current version: {self.version}\n"
f"Latest version: {latest_version}"
)
message_label.setWordWrap(True)
layout.addWidget(message_label)
# Buttons
button_layout = QHBoxLayout()
download_btn = QPushButton("Download Update")
download_btn.clicked.connect(lambda: self.open_release_page(release_url))
remind_btn = QPushButton("Remind Me Later")
remind_btn.clicked.connect(msg.close)
button_layout.addWidget(download_btn)
button_layout.addWidget(remind_btn)
layout.addLayout(button_layout)
# Style the dialog
msg.setStyleSheet("""
QDialog {
background-color: #2b2b2b;
}
QLabel {
color: #ffffff;
font-size: 12px;
padding: 10px;
}
QPushButton {
padding: 8px 15px;
background-color: #ff0000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #cc0000;
}
""")
msg.show()
def open_release_page(self, url):
webbrowser.open(url)
def show_custom_command(self):
dialog = CustomCommandDialog(self)
dialog.exec()
def cancel_download(self):
if self.current_download:
self.current_download.cancelled = True
self.signals.update_status.emit("Cancelling download...")
def show_ffmpeg_dialog(self):
dialog = FFmpegCheckDialog(self)
dialog.exec()
def paste_url(self):
clipboard = QApplication.clipboard()
self.url_input.setText(clipboard.text())
def update_ytdlp(self):
dialog = YTDLPUpdateDialog(self)
dialog.exec()
def toggle_save_thumbnail(self):
self.save_thumbnail = self.save_thumbnail_checkbox.isChecked()
print(f"Save thumbnail toggled: {self.save_thumbnail}") # Debug print
def download_thumbnail_file(self, video_url, path):
if not self.save_thumbnail:
return False
try:
from yt_dlp import YoutubeDL
import requests # Use requests instead of urlopen
print(f"Attempting to save thumbnail for URL: {video_url}")
ydl_opts = {
'quiet': True,
'skip_download': True,
'force_generic_extractor': False,
'no_warnings': True,
'extract_flat': False
}
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
thumbnails = info.get('thumbnails', [])
if not thumbnails:
raise ValueError("No thumbnails available")
thumbnail_url = max(
thumbnails,
key=lambda t: (t.get('height', 0) or 0) * (t.get('width', 0) or 0)
).get('url')
if not thumbnail_url:
raise ValueError("Failed to extract thumbnail URL")
# Download using requests
response = requests.get(thumbnail_url)
response.raise_for_status()
# Save the thumbnail
thumb_dir = os.path.join(path, 'Thumbnails')
os.makedirs(thumb_dir, exist_ok=True)
filename = f"{self.sanitize_filename(info['title'])}.jpg"
thumbnail_path = os.path.join(thumb_dir, filename)
with open(thumbnail_path, 'wb') as f:
f.write(response.content)
print(f"Thumbnail saved to: {thumbnail_path}")
self.signals.update_status.emit(f"✅ Thumbnail saved: {filename}")
return True
except Exception as e:
error_msg = f"❌ Thumbnail error: {str(e)}"
print(f"Thumbnail Save Error: {str(e)}")
self.signals.update_status.emit(error_msg)
return False
def sanitize_filename(self, name):
"""Clean filename for filesystem safety"""
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]
def show_about_dialog(self):
dialog = AboutDialog(self)
dialog.exec()
def toggle_download_controls(self, enabled=True):
"""Enable or disable download-related controls"""
self.url_input.setEnabled(enabled)
self.analyze_btn.setEnabled(enabled)
self.format_table.setEnabled(enabled)
self.path_input.setEnabled(enabled)
self.browse_btn.setEnabled(enabled)
self.download_btn.setEnabled(enabled)
if hasattr(self, 'subtitle_combo'):
self.subtitle_combo.setEnabled(enabled)
self.video_button.setEnabled(enabled)
self.audio_button.setEnabled(enabled)
self.sponsorblock_checkbox.setEnabled(enabled)
def handle_format_selection(self, button):
# Update formats
self.filter_formats()