1800 lines
65 KiB
Python
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() |