Files
SageTube/src/gui/ytsage_gui_main.py
T
Your Name d6fb926644 Add proxy configuration support to downloader and GUI
Introduces proxy and geo-verification proxy options to the downloader and GUI dialogs, allowing users to specify network proxies for downloads and geo-restricted content. Updates the DownloadThread and main app logic to pass and apply these proxy settings, and adds validation and status indicators in the custom options dialog.
2025-09-22 14:49:58 +03:00

1894 lines
80 KiB
Python

import json
import subprocess
import threading
import webbrowser
from pathlib import Path
import markdown
import pyglet
import requests
from packaging import version
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QCheckBox,
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QProgressBar,
QPushButton,
QStyle,
QTextEdit,
QVBoxLayout,
QWidget,
)
from src.core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
from src.core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update
from src.core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions
from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
AboutDialog,
AutoUpdateThread,
CustomOptionsDialog,
DownloadSettingsDialog,
FFmpegCheckDialog,
PlaylistSelectionDialog,
TimeRangeDialog,
YTDLPUpdateDialog,
)
from src.gui.ytsage_gui_format_table import FormatTableMixin
from src.gui.ytsage_gui_video_info import VideoInfoMixin
from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIONFLAGS
from src.utils.ytsage_logger import logger
try:
import yt_dlp
from yt_dlp.utils import DownloadError, ExtractorError
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins
def __init__(self) -> None:
super().__init__()
# Log startup warnings for missing dependencies
if not YT_DLP_AVAILABLE:
logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
# Check for FFmpeg before proceeding
if not check_ffmpeg():
self.show_ffmpeg_dialog()
# Check for yt-dlp in our app's bin directory or system PATH
ytdlp_path = get_yt_dlp_path()
if ytdlp_path == "yt-dlp": # Not found in app dir or PATH
self.show_ytdlp_setup_dialog()
else:
logger.info(f"Using yt-dlp from: {ytdlp_path}")
self.version = "4.8.3"
self.check_for_updates()
# Check for auto-updates if enabled
self.check_auto_update_ytdlp()
load_saved_path(self)
# Load custom icon
if ICON_PATH.exists():
self.setWindowIcon(QIcon(str(ICON_PATH)))
else:
logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
self.signals = SignalManager()
self.download_paused = False
self.current_download = None
self.download_cancelled = False
self.save_thumbnail = False # Initialize thumbnail state
self.thumbnail_url = None # Add this to store thumbnail URL
self.all_formats = [] # Initialize all_formats
self.available_subtitles = {}
self.available_automatic_subtitles = {}
self.is_playlist = False
self.playlist_info = None
self.video_info = None
self.playlist_entries = [] # Initialize playlist entries
self.selected_playlist_items = None # Initialize selection string
self.save_description = False # Initialize description state
self.embed_chapters = False # Initialize chapters state
self.subtitle_filter = ""
self.thumbnail_image = None
self.video_url = ""
self.selected_subtitles = [] # Initialize selected subtitles list
# Initialize cookie settings - ensure they start clean
self.cookie_file_path = None
self.browser_cookies_option = None
# Initialize proxy settings
self.proxy_url = None
self.geo_proxy_url = None
self.speed_limit_value = None # Store speed limit value
self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s)
self.download_section = None
self.force_keyframes = False
self.init_ui()
self.setStyleSheet(
"""
QMainWindow {
background-color: #15181b;
}
QWidget {
background-color: #15181b;
color: #ffffff;
}
QLineEdit {
padding: 8px;
border: 2px solid #1b2021;
border-radius: 4px;
background-color: #1b2021;
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #a50000;
}
QPushButton:pressed {
background-color: #800000;
}
QTableWidget {
border: 2px solid #1b2021;
border-radius: 4px;
background-color: #1b2021;
gridline-color: #1b2021;
}
QHeaderView::section {
background-color: #15181b;
padding: 5px;
border: 1px solid #1b2021;
color: #ffffff;
}
QProgressBar {
border: 2px solid #1b2021;
border-radius: 4px;
text-align: center;
color: white;
}
QProgressBar::chunk {
background-color: #c90000;
border-radius: 2px;
}
QLabel {
color: #ffffff;
}
/* Style for filter buttons */
QPushButton.filter-btn {
background-color: #1b2021;
padding: 5px 10px;
margin: 0 5px;
}
QPushButton.filter-btn:checked {
background-color: #c90000;
}
QPushButton.filter-btn:hover {
background-color: #444444;
}
QPushButton.filter-btn:checked:hover {
background-color: #a50000;
}
/* Modern Scrollbar Styling */
QScrollBar:vertical {
border: none;
background: #15181b;
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: #15181b;
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: #15181b;
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: #15181b;
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: #15181b;
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: #15181b;
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)
# After adding format buttons
self.video_button.clicked.connect(self.filter_formats) # Connect video button
self.audio_button.clicked.connect(self.filter_formats) # Connect audio button
# Add connections to handle video/audio mode-specific controls
self.video_button.clicked.connect(self.handle_mode_change)
self.audio_button.clicked.connect(self.handle_mode_change)
# Initialize UI state based on current mode
self.handle_mode_change()
# Init_sound method is removed, serve no purpose.
def play_notification_sound(self) -> None:
"""Play notification sound asynchronously (non-blocking)."""
try:
# Check if the notification sound file exists
if not SOUND_PATH.exists():
logger.warning(f"Notification sound file not found at: {SOUND_PATH}")
return
# Play the sound using pyglet
# no need for the thread, as .play() is async
sound = pyglet.media.load(str(SOUND_PATH), streaming=False)
sound.play()
logger.debug("Notification sound played")
except Exception as e:
logger.exception(f"Error playing notification sound: {e}")
def init_ui(self) -> None:
self.setWindowTitle(f"YTSage v{self.version}")
self.setMinimumSize(900, 750)
# Main widget and layout
main_widget = QWidget()
self.setCentralWidget(main_widget)
layout = QVBoxLayout(main_widget)
layout.setSpacing(8)
layout.setContentsMargins(20, 20, 20, 20)
# URL input section
url_layout = QHBoxLayout()
self.url_input = QLineEdit()
self.url_input.setPlaceholderText("Enter YouTube video or playlist URL")
self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key
self.analyze_button = QPushButton("Analyze")
self.analyze_button.clicked.connect(self.analyze_url)
self.paste_button = QPushButton("Paste URL")
self.paste_button.clicked.connect(self.paste_url)
url_layout.addWidget(self.url_input, 1)
url_layout.addWidget(self.paste_button)
url_layout.addWidget(self.analyze_button)
layout.addLayout(url_layout)
# Video info container
video_info_container = QWidget()
video_info_layout = QVBoxLayout(video_info_container)
video_info_layout.setSpacing(5)
video_info_layout.setContentsMargins(0, 0, 0, 0)
# Add media info layout (Thumbnail | Video Details)
media_info_layout = self.setup_video_info_section()
video_info_layout.addLayout(media_info_layout)
# Add video info container to main layout
layout.addWidget(video_info_container)
# --- Add Playlist Info Section Directly to Main Layout ---
# Add playlist info label (initially hidden)
self.playlist_info_label = self.setup_playlist_info_section()
layout.addWidget(self.playlist_info_label)
# Add playlist selection BUTTON (initially hidden) - REPLACED QLineEdit
self.playlist_select_btn = QPushButton("Select Videos...")
self.playlist_select_btn.clicked.connect(self.open_playlist_selection_dialog)
self.playlist_select_btn.setVisible(False)
self.playlist_select_btn.setStyleSheet(
"""
QPushButton {
padding: 6px 12px;
background-color: #1d1e22;
border: 1px solid #c90000;
border-radius: 4px;
color: white;
font-weight: normal;
text-align: left;
padding-left: 10px;
}
QPushButton:hover {
background-color: #2a2d36;
border-color: #a50000;
}
"""
)
layout.addWidget(self.playlist_select_btn)
# --- End Playlist Info Section ---
# Format controls section with minimal spacing
layout.addSpacing(5)
# Format selection layout (horizontal)
self.format_layout = QHBoxLayout()
# Show formats label
self.show_formats_label = QLabel("Show formats:")
self.show_formats_label.setStyleSheet("color: white;")
self.format_layout.addWidget(self.show_formats_label)
# Format buttons group
self.format_buttons = QButtonGroup(self)
self.format_buttons.setExclusive(True)
# Video button
self.video_button = QPushButton("Video")
self.video_button.setCheckable(True)
self.video_button.setChecked(True) # Set video as default
self.video_button.setStyleSheet(
"""
QPushButton {
padding: 8px 15px;
background-color: #1d1e22;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:checked {
background-color: #c90000;
}
QPushButton:hover {
background-color: #2a2d36;
}
QPushButton:checked:hover {
background-color: #a50000;
}
"""
)
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: #1d1e22;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:checked {
background-color: #c90000;
}
QPushButton:hover {
background-color: #2a2d36;
}
QPushButton:checked:hover {
background-color: #a50000;
}
"""
)
self.format_buttons.addButton(self.audio_button)
self.format_layout.addWidget(self.audio_button)
# Add Merge Subtitles checkbox (Moved here)
self.merge_subs_checkbox = QCheckBox("Merge Subtitles")
self.merge_subs_checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
margin-left: 20px; /* Consistent margin */
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #1d1e22;
border-radius: 9px;
}
QCheckBox::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
border-radius: 9px;
}
/* Add disabled state styling if needed */
QCheckBox:disabled { color: #888888; }
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
"""
)
# Initially disable it, will be enabled if subtitles are selected later
self.merge_subs_checkbox.setEnabled(False)
self.format_layout.addWidget(self.merge_subs_checkbox)
# Add Save Thumbnail Checkbox (Moved here)
self.save_thumbnail_checkbox = QCheckBox("Save Thumbnail")
self.save_thumbnail_checkbox.setChecked(False)
self.save_thumbnail_checkbox.stateChanged.connect(self.toggle_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: #1d1e22; border-radius: 9px; }
QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; }
QCheckBox:disabled { color: #888888; }
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
"""
)
self.format_layout.addWidget(self.save_thumbnail_checkbox)
# Add Save Description Checkbox (Moved here)
self.save_description_checkbox = QCheckBox("Save Description")
self.save_description_checkbox.setChecked(False)
self.save_description_checkbox.stateChanged.connect(self.toggle_save_description)
self.save_description_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: #1d1e22; border-radius: 9px; }
QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; }
QCheckBox:disabled { color: #888888; }
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
"""
)
self.format_layout.addWidget(self.save_description_checkbox)
# Add Embed Chapters Checkbox
self.embed_chapters_checkbox = QCheckBox("Embed Chapters")
self.embed_chapters_checkbox.setChecked(False)
self.embed_chapters_checkbox.stateChanged.connect(self.toggle_embed_chapters)
self.embed_chapters_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: #1d1e22; border-radius: 9px; }
QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; }
QCheckBox:disabled { color: #888888; }
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
"""
)
self.format_layout.addWidget(self.embed_chapters_checkbox)
self.format_layout.addStretch()
layout.addLayout(self.format_layout)
# Format table with stretch
format_table = self.setup_format_table()
layout.addWidget(format_table, stretch=1)
# Download section
download_layout = QHBoxLayout()
# Replace the two separate buttons with a single Custom Options button
self.custom_options_btn = QPushButton("Custom Options")
self.custom_options_btn.clicked.connect(self.show_custom_options)
self.about_btn = QPushButton("About")
self.about_btn.clicked.connect(self.show_about_dialog)
# Add new Time Range button
self.time_range_btn = QPushButton("Trim Video")
self.time_range_btn.clicked.connect(self.show_time_range_dialog)
self.update_ytdlp_btn = QPushButton("Update yt-dlp")
self.update_ytdlp_btn.clicked.connect(self.update_ytdlp)
# --- Rename Path Button to Settings Button ---
self.settings_button = QPushButton("Download Settings") # Renamed button
self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method
self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: None") # Update initial tooltip
# --- End Settings Button ---
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_options_btn)
download_layout.addWidget(self.about_btn)
download_layout.addWidget(self.time_range_btn) # New button position
download_layout.addWidget(self.update_ytdlp_btn)
download_layout.addWidget(self.settings_button)
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)
# Connect new signals
self.signals.playlist_info_label_visible.connect(self.playlist_info_label.setVisible)
self.signals.playlist_info_label_text.connect(self.playlist_info_label.setText)
self.signals.selected_subs_label_text.connect(self.selected_subs_label.setText)
self.signals.playlist_select_btn_visible.connect(self.playlist_select_btn.setVisible)
self.signals.playlist_select_btn_text.connect(self.playlist_select_btn.setText)
def analyze_url(self) -> None:
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")
threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start()
def _analyze_url_thread(self, url) -> None:
try:
self.signals.update_status.emit("Analyzing (15%)... 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}"
# Check if yt-dlp Python module is available
if not YT_DLP_AVAILABLE:
# Use subprocess to call yt-dlp executable
self._analyze_url_with_subprocess(url)
return
# Initial extraction with basic options - suppress warnings here too
ydl_opts = {
"logger": logger,
"quiet": False,
"no_warnings": True, # <-- Suppress warnings for initial check
"extract_flat": True,
"force_generic_extractor": False,
"ignoreerrors": False, # Set to False to catch errors properly
"no_color": True,
"verbose": True,
"cookiefile": None,
}
# Add cookies argument if cookie file path is set
if self.cookie_file_path:
ydl_opts["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option:
ydl_opts["cookiesfrombrowser"] = (
self.browser_cookies_option.split(":")[0],
self.browser_cookies_option.split(":")[1] if ":" in self.browser_cookies_option else None,
)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
basic_info = ydl.extract_info(url, download=False)
if not basic_info:
logger.error("Could not extract basic video information")
self.signals.update_status.emit(
"Error: Could not extract basic video information. Please check your link."
)
# Hide playlist UI on error
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
except Exception as e:
logger.exception(f"First extraction failed: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
user_friendly_error = parse_yt_dlp_error(str(e))
self.signals.update_status.emit(user_friendly_error)
return
self.signals.update_status.emit("Analyzing (30%)... Extracting detailed info")
# Configure options for detailed extraction (keep other options)
# Add no_warnings here as well, as this is where detailed info is fetched
ydl_opts_detail = {
"logger": logger,
"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,
"no_warnings": True, # <-- Add flag here for detailed extraction
}
# Add cookies argument if cookie file path is set
if self.cookie_file_path:
ydl_opts_detail["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option:
ydl_opts_detail["cookiesfrombrowser"] = (
self.browser_cookies_option.split(":")[0],
self.browser_cookies_option.split(":")[1] if ":" in self.browser_cookies_option else None,
)
# Use a separate options dict for the detailed extraction
with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail:
try:
self.signals.update_status.emit("Analyzing (45%)... Processing video data")
if basic_info.get("_type") == "playlist":
self.is_playlist = True
self.playlist_info = basic_info
self.selected_playlist_items = None # Reset selection for new playlist
self.playlist_entries = [entry for entry in basic_info.get("entries", []) if entry] # Store entries
# Ensure there are entries before proceeding
if not self.playlist_entries:
logger.error("Playlist contains no valid videos.")
self.signals.update_status.emit("Error: Playlist contains no valid videos.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
# Extract detailed info for the FIRST video in the playlist
# This provides formats/subs for the UI, assuming consistency
first_video_url = self.playlist_entries[0].get("url")
if not first_video_url:
logger.error("Could not get URL for the first playlist video.")
self.signals.update_status.emit("Error: Could not get URL for the first playlist video.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
try:
# Use the ydl_detail instance with no_warnings
self.video_info = ydl_detail.extract_info(first_video_url, download=False)
except Exception as e:
logger.exception(f"Failed to extract info for the first playlist video: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
user_friendly_error = parse_yt_dlp_error(str(e))
self.signals.update_status.emit(user_friendly_error)
return
# Update playlist info label text (remains the same)
playlist_text = (
f"Playlist: {basic_info.get('title', 'Unknown Playlist')} | " f"{len(self.playlist_entries)} videos"
) # Simplified label
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_info_label_text.emit(playlist_text)
self.signals.playlist_info_label_visible.emit(True)
# Show playlist selection BUTTON
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_select_btn_text.emit("Select Videos... (All selected)")
self.signals.playlist_select_btn_visible.emit(True)
else: # Single video
self.is_playlist = False
# Use ydl_detail instance here too for consistency
self.video_info = ydl_detail.extract_info(url, download=False)
self.playlist_entries = [] # Clear entries
self.selected_playlist_items = None # Clear selection
# Hide playlist info label and button
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
# Verify we have format information
if not self.video_info or "formats" not in self.video_info:
logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
self.signals.update_status.emit("Error: No format information available.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (60%)... 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 (75%)... Loading thumbnail")
# Try to get thumbnail from playlist info first
# Fallback to video thumbnail if playlist thumbnail not found or not a playlist
thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail")
self.download_thumbnail(thumbnail_url)
# Save thumbnail if enabled - use the stored VIDEO URL
if self.save_thumbnail:
self.download_thumbnail_file(
self.video_url, self.path_input.text() # type: ignore[reportAttributeAccessIssue]
)
# --- Subtitle Handling ---
self.signals.update_status.emit("Analyzing (85%)... Processing subtitles")
# Clear previous selections when analyzing a new video
self.selected_subtitles = []
self.available_subtitles = self.video_info.get("subtitles", {})
self.available_automatic_subtitles = self.video_info.get("automatic_captions", {})
# Update the UI elements related to subtitle selection state
# update signal method from QMetaObject.invokeMethod to signals
self.signals.selected_subs_label_text.emit("0 selected")
# QMetaObject.invokeMethod(
# self.subtitle_select_btn,
# b"setProperty",
# Qt.ConnectionType.QueuedConnection,
# Q_ARG(str, b"subtitlesSelected"),
# Q_ARG(bool, False),
# ) # <-- COMMENT OUT THIS LINE
# REMOVE the merge_subs_checkbox update call from here
# QMetaObject.invokeMethod(
# self.merge_subs_checkbox,
# b"setEnabled",
# Qt.ConnectionType.QueuedConnection,
# Q_ARG(bool, False),
# )
# Update format table
self.signals.update_status.emit("Analyzing (95%)... 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:
logger.exception(f"Detailed extraction failed: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
# Use the error parser for other extraction errors too
user_friendly_error = parse_yt_dlp_error(str(e))
self.signals.update_status.emit(user_friendly_error)
return
except Exception as e:
logger.exception(f"Error in analysis: {e}")
self.signals.update_status.emit(f"Error: {e}")
# Ensure playlist UI is hidden on error too
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
def paste_url(self) -> None:
clipboard = QApplication.clipboard()
self.url_input.setText(clipboard.text())
def update_ytdlp(self) -> None:
"""Show the yt-dlp update dialog with proper progress tracking"""
# Make the dialog non-modal to prevent blocking the main UI
dialog = YTDLPUpdateDialog(self)
dialog.setModal(False) # Make it non-modal
dialog.show() # Use show() instead of exec() to avoid blocking
def show_download_settings_dialog(self) -> None: # Renamed method
dialog = DownloadSettingsDialog(self.last_path, self.speed_limit_value, self.speed_limit_unit_index, self)
if dialog.exec():
# Update Path
new_path = dialog.get_selected_path()
path_changed = False
if new_path != self.last_path:
self.last_path = new_path
save_path(self, self.last_path) # Save the updated path
path_changed = True
logger.info(f"Download path updated to: {self.last_path}")
# Update Speed Limit
new_limit_value = dialog.get_selected_speed_limit()
new_unit_index = dialog.get_selected_unit_index()
limit_changed = False
if new_limit_value != self.speed_limit_value or new_unit_index != self.speed_limit_unit_index:
self.speed_limit_value = new_limit_value
self.speed_limit_unit_index = new_unit_index
limit_changed = True
logger.info(
f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}"
)
# Update Tooltip if anything changed
if path_changed or limit_changed:
limit_text = "None"
if self.speed_limit_value:
limit_text = f"{self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index]}"
self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: {limit_text}")
def start_download(self) -> None:
url = self.url_input.text().strip()
# --- Use self.last_path instead of reading from QLineEdit ---
path = self.last_path
if not url or not path:
# More specific error message if path is missing
if not path:
self.status_label.setText("Please set a download path using 'Change Path'")
elif not url:
self.status_label.setText("Please enter a URL")
else:
self.status_label.setText("Please enter URL and set download path")
return
# --- End Path Change ---
# Get selected format
format_id = self.get_selected_format()
if not format_id:
self.status_label.setText("Please select a format")
return
# Show preparation message
self.status_label.setText("🚀 Preparing your download...")
self.progress_bar.setValue(0)
# Get resolution for filename
resolution = "default"
for checkbox in self.format_checkboxes:
if checkbox.isChecked():
parts = checkbox.text().split("•")
if len(parts) >= 1:
resolution = parts[0].strip().lower()
break
# Get subtitle selection if available - Now get the list
selected_subs = self.selected_subtitles if hasattr(self, "selected_subtitles") else []
# Get playlist selection IF in playlist mode - USE STORED VALUE
playlist_items_to_download = None
if self.is_playlist:
playlist_items_to_download = self.selected_playlist_items # Use the stored selection string
# --- Use stored speed limit values ---
rate_limit = None
if self.speed_limit_value:
try:
limit_value = float(self.speed_limit_value)
if self.speed_limit_unit_index == 0: # KB/s
rate_limit = f"{int(limit_value * 1024)}"
elif self.speed_limit_unit_index == 1: # MB/s
rate_limit = f"{int(limit_value * 1024 * 1024)}"
except ValueError:
# Use a signal to show error in status bar, similar to URL/Path errors
self.signals.update_status.emit("❌ Error: Invalid speed limit value set in settings.")
return
# --- End speed limit update ---
# Save thumbnail if enabled
if self.save_thumbnail:
# Consider moving thumbnail download *after* successful video download
# Or handle errors more gracefully if thumbnail download fails
try:
self.download_thumbnail_file(url, path)
except Exception as e:
logger.warning(f"Thumbnail download failed: {e}", exc_info=True)
# Optionally inform the user, but don't stop the main download
# Create download thread with resolution in output template
self.download_thread = DownloadThread(
url=url,
path=path,
format_id=format_id,
subtitle_langs=selected_subs, # Pass the list of selected subs
is_playlist=self.is_playlist, # Use the flag directly
merge_subs=self.merge_subs_checkbox.isChecked(),
enable_sponsorblock=len(self.selected_sponsorblock_categories) > 0,
sponsorblock_categories=self.selected_sponsorblock_categories,
resolution=resolution,
playlist_items=playlist_items_to_download, # Pass the selection string
save_description=self.save_description, # Pass the new flag here
embed_chapters=self.embed_chapters, # Pass the embed chapters flag
cookie_file=self.cookie_file_path, # Pass the cookie file path
browser_cookies=self.browser_cookies_option, # Pass the browser cookies option
rate_limit=rate_limit, # Pass the calculated rate limit
download_section=self.download_section, # Pass the download section
force_keyframes=self.force_keyframes, # Pass the force keyframes setting
proxy_url=self.proxy_url, # Pass the proxy URL
geo_proxy_url=self.geo_proxy_url, # Pass the geo-verification proxy URL
)
# Connect signals
self.download_thread.progress_signal.connect(self.update_progress_bar)
self.download_thread.status_signal.connect(self.status_label.setText)
self.download_thread.update_details.connect(self.download_details_label.setText)
self.download_thread.finished_signal.connect(self.download_finished)
self.download_thread.error_signal.connect(self.download_error)
self.download_thread.file_exists_signal.connect(self.file_already_exists)
# Reset download state
self.download_paused = False
self.download_cancelled = False
# Show pause/cancel buttons
self.pause_btn.setText("Pause")
self.pause_btn.setVisible(True)
self.cancel_btn.setVisible(True)
# Start download thread
self.current_download = self.download_thread
self.download_thread.start()
self.toggle_download_controls(False)
def download_finished(self) -> None:
self.toggle_download_controls(True)
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.progress_bar.setValue(100)
# Set completion message based on the file type of last downloaded file
if self.download_thread and self.download_thread.current_filename:
filename = Path(self.download_thread.current_filename)
ext = filename.suffix.lower()
# Video file extensions
if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]:
self.status_label.setText(f"✅ Video download completed!")
# Audio file extensions
elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]:
self.status_label.setText(f"✅ Audio download completed!")
# Subtitle file extensions
elif ext in [".vtt", ".srt", ".ass", ".ssa"]:
self.status_label.setText(f"✅ Subtitle download completed!")
# Default case
else:
self.status_label.setText("✅ Download completed!")
# Play notification sound when download completes
self.play_notification_sound()
def download_error(self, error_message) -> None:
self.toggle_download_controls(True)
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.status_label.setText(f"Error: {error_message}")
self.download_details_label.setText("") # Clear details label on error
def update_progress_bar(self, value) -> None:
try:
# Ensure the value is an integer
int_value = int(value)
self.progress_bar.setValue(int_value)
except Exception as e:
logger.exception(f"Progress bar update error: {e}")
def toggle_pause(self) -> None:
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) -> None:
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):
changelog = latest_release.get("body", "No changelog available.") # Get changelog body
self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog
except Exception as e:
logger.exception(f"Failed to check for updates: {e}")
def show_update_dialog(self, latest_version, release_url, changelog) -> None: # Added changelog parameter
msg = QDialog(self)
msg.setWindowTitle("Update Available")
msg.setMinimumWidth(600) # Increased width for better layout
msg.setMinimumHeight(450) # Increased height for better spacing
# Set custom icon directly
try:
if self.windowIcon() and not self.windowIcon().isNull():
msg.setWindowIcon(self.windowIcon())
else:
# Fallback to icon file
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
msg.setWindowIcon(QIcon(str(ICON_PATH)))
except Exception:
pass
layout = QVBoxLayout(msg)
layout.setSpacing(15) # Increased spacing for better layout
layout.setContentsMargins(20, 20, 20, 20) # Added margins
# Header with icon and title
header_layout = QHBoxLayout()
# Add update icon
icon_label = QLabel()
icon_label.setPixmap(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload).pixmap(32, 32))
header_layout.addWidget(icon_label)
# Title
title_label = QLabel("<h2 style='color: #c90000; margin: 0;'>Update Available</h2>")
header_layout.addWidget(title_label)
header_layout.addStretch()
layout.addLayout(header_layout)
# Update message with better formatting
message_label = QLabel(
f"<div style='font-size: 13px; line-height: 1.4;'>"
f"<b style='color: #ffffff;'>A new version of YTSage is available!</b><br><br>"
f"<span style='color: #cccccc;'>Current version: <b style='color: #ffffff;'>{self.version}</b></span><br>"
f"<span style='color: #cccccc;'>Latest version: <b style='color: #00ff88;'>{latest_version}</b></span>"
f"</div>"
)
message_label.setWordWrap(True)
message_label.setStyleSheet(
"""
QLabel {
background-color: #1d1e22;
border: 1px solid #3d3d3d;
border-radius: 6px;
padding: 15px;
margin: 5px 0;
}
"""
)
layout.addWidget(message_label)
# Changelog Section
changelog_label = QLabel("<b style='color: #ffffff; font-size: 14px;'>Changelog:</b>")
changelog_label.setStyleSheet("padding: 5px 0; margin-top: 10px;")
layout.addWidget(changelog_label)
changelog_text = QTextEdit()
changelog_text.setReadOnly(True)
# Convert Markdown to HTML and set it
try:
html_changelog = markdown.markdown(
changelog,
extensions=[
"markdown.extensions.tables",
"markdown.extensions.fenced_code",
],
)
changelog_text.setHtml(html_changelog)
except Exception as e:
logger.warning(f"Error converting changelog markdown to HTML: {e}", exc_info=True)
changelog_text.setPlainText(changelog) # Fallback to plain text
changelog_text.setStyleSheet(
"""
QTextEdit {
background-color: #1d1e22;
border: 2px solid #3d3d3d;
border-radius: 6px;
color: #ffffff;
padding: 10px;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 12px;
line-height: 1.4;
}
QScrollBar:vertical {
border: none;
background: #1d1e22;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background: #404040;
min-height: 20px;
border-radius: 6px;
}
QScrollBar::handle:vertical:hover {
background: #505050;
}
"""
)
changelog_text.setMaximumHeight(180) # Limit height
layout.addWidget(changelog_text)
# Buttons with better styling
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
download_btn = QPushButton("Download Update")
download_btn.clicked.connect(lambda: self.open_release_page(release_url))
download_btn.setStyleSheet(
"""
QPushButton {
padding: 10px 20px;
background-color: #c90000;
border: none;
border-radius: 6px;
color: white;
font-weight: bold;
font-size: 13px;
min-width: 140px;
}
QPushButton:hover {
background-color: #a50000;
}
QPushButton:pressed {
background-color: #800000;
}
"""
)
remind_btn = QPushButton("Remind Me Later")
remind_btn.clicked.connect(msg.close)
remind_btn.setStyleSheet(
"""
QPushButton {
padding: 10px 20px;
background-color: #3d3d3d;
border: 1px solid #555555;
border-radius: 6px;
color: white;
font-weight: bold;
font-size: 13px;
min-width: 140px;
}
QPushButton:hover {
background-color: #4d4d4d;
border-color: #666666;
}
QPushButton:pressed {
background-color: #2d2d2d;
}
"""
)
button_layout.addStretch()
button_layout.addWidget(download_btn)
button_layout.addWidget(remind_btn)
layout.addLayout(button_layout)
# Style the dialog with improved theme matching
msg.setStyleSheet(
"""
QDialog {
background-color: #15181b;
border: 1px solid #3d3d3d;
border-radius: 8px;
}
QLabel {
color: #ffffff;
font-size: 12px;
}
"""
)
msg.show()
def open_release_page(self, url):
webbrowser.open(url)
def check_auto_update_ytdlp(self) -> None:
"""Check and perform auto-update for yt-dlp if enabled and due."""
try:
# Check if auto-update should be performed
if should_check_for_auto_update():
logger.info("Performing auto-update check for yt-dlp...")
# Perform the auto-update in a non-blocking way
# We don't want to block the UI startup for this
QTimer.singleShot(2000, self._perform_auto_update) # Delay 2 seconds after startup
except Exception as e:
logger.exception(f"Error in auto-update check: {e}")
def _perform_auto_update(self) -> None:
"""Actually perform the auto-update check and update if needed in a background thread."""
try:
# Create and start the auto-update thread to avoid blocking the UI
self.auto_update_thread = AutoUpdateThread()
self.auto_update_thread.update_finished.connect(self._on_auto_update_finished)
self.auto_update_thread.start()
except Exception as e:
logger.exception(f"Error starting auto-update thread: {e}")
def _on_auto_update_finished(self, success, message) -> None:
"""Handle auto-update completion."""
if success:
logger.info(f"Auto-update completed successfully: {message}")
else:
logger.warning(f"Auto-update completed with issues: {message}")
# Clean up the thread reference and ensure it's properly finished
if hasattr(self, "auto_update_thread"):
# Disconnect all signals to prevent further callbacks
self.auto_update_thread.update_finished.disconnect()
# Make sure thread is finished
if self.auto_update_thread.isRunning():
self.auto_update_thread.quit()
self.auto_update_thread.wait(1000) # Wait up to 1 second
# Remove the reference
delattr(self, "auto_update_thread")
def closeEvent(self, event) -> None:
"""Handle application close event to ensure proper cleanup of background threads."""
try:
# Stop the auto-update thread if it's running
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
logger.info("Stopping auto-update thread...")
self.auto_update_thread.quit()
if not self.auto_update_thread.wait(3000): # Wait up to 3 seconds for graceful shutdown
logger.warning("Force terminating auto-update thread...")
self.auto_update_thread.terminate()
self.auto_update_thread.wait(1000) # Wait for termination
# Cancel any running downloads
if self.current_download and self.current_download.isRunning():
logger.info("Canceling running download...")
self.current_download.cancel()
if not self.current_download.wait(3000): # Wait up to 3 seconds for graceful shutdown
logger.warning("Force terminating download thread...")
self.current_download.terminate()
self.current_download.wait(1000) # Wait for termination
logger.info("Application closing...")
event.accept()
except Exception as e:
logger.exception(f"Error during application close: {e}")
event.accept() # Accept the close event anyway
def show_custom_options(self) -> None:
dialog = CustomOptionsDialog(self)
if dialog.exec():
# Handle cookies
cookie_path = dialog.get_cookie_file_path()
browser_cookies = dialog.get_browser_cookies_option()
# Clear both first to avoid conflicts
self.cookie_file_path = None
self.browser_cookies_option = None
if cookie_path:
self.cookie_file_path = cookie_path
logger.info(f"Selected cookie file: {self.cookie_file_path}")
QMessageBox.information(
self,
"Cookie File Selected",
f"Cookie file selected: {self.cookie_file_path}",
)
elif browser_cookies:
self.browser_cookies_option = browser_cookies
logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
QMessageBox.information(
self,
"Browser Cookies Selected",
f"Browser cookies will be extracted from: {browser_cookies}",
)
# If neither is selected, both remain None (cleared above)
# Handle proxy options
proxy_url = dialog.get_proxy_url()
geo_proxy_url = dialog.get_geo_proxy_url()
# Clear existing proxy settings
self.proxy_url = None
self.geo_proxy_url = None
if proxy_url:
self.proxy_url = proxy_url
logger.info(f"Main proxy set: {self.proxy_url}")
QMessageBox.information(
self,
"Proxy Set",
f"Main proxy set: {proxy_url}",
)
if geo_proxy_url:
self.geo_proxy_url = geo_proxy_url
logger.info(f"Geo-verification proxy set: {self.geo_proxy_url}")
QMessageBox.information(
self,
"Geo Proxy Set",
f"Geo-verification proxy set: {geo_proxy_url}",
)
def show_about_dialog(self) -> None: # ADDED METHOD HERE
dialog = AboutDialog(self)
dialog.exec()
def file_already_exists(self, filename) -> None:
"""Handle case when file already exists - simplified version"""
self.toggle_download_controls(True)
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.progress_bar.setValue(100)
# Determine file type based on extension
ext = Path(filename).suffix.lower()
# Video file extensions
if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]:
self.status_label.setText(f"⚠️ Video file already exists")
# Audio file extensions
elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]:
self.status_label.setText(f"⚠️ Audio file already exists")
# Subtitle file extensions
elif ext in [".vtt", ".srt", ".ass", ".ssa"]:
self.status_label.setText(f"⚠️ Subtitle file already exists")
# Default case
else:
self.status_label.setText("⚠️ File already exists")
# Show a simple message dialog
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Information)
msg_box.setWindowTitle("File Already Exists")
msg_box.setText(f"The file already exists:\n{filename}")
msg_box.setInformativeText("This video has already been downloaded.")
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
# Set the window icon to match the main application
msg_box.setWindowIcon(self.windowIcon())
# Style the dialog
msg_box.setStyleSheet(
"""
QMessageBox {
background-color: #2b2b2b;
}
QLabel {
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
background-color: #ff0000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
min-width: 80px;
}
QPushButton:hover {
background-color: #cc0000;
}
"""
)
msg_box.exec()
# --- Add Toggle Methods Here ---
def toggle_save_thumbnail(self, state) -> None:
logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state
self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state)
logger.debug(f"Save thumbnail toggled: {self.save_thumbnail}")
def toggle_save_description(self, state) -> None:
logger.debug(f"Raw description state received: {state}") # Debug: Print raw state
self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state)
logger.debug(f"Save description toggled: {self.save_description}")
def toggle_embed_chapters(self, state) -> None:
logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state
self.embed_chapters = bool(state == 2) # Compare state directly with 2 (Checked state)
logger.debug(f"Embed chapters toggled: {self.embed_chapters}")
# --- End Toggle Methods ---
def open_playlist_selection_dialog(self) -> None:
if not self.is_playlist or not self.playlist_entries:
logger.info("No playlist data available to select from.")
return
dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self)
if dialog.exec():
self.selected_playlist_items = dialog.get_selected_items_string()
logger.info(f"Playlist items selected: {self.selected_playlist_items}")
# Update button text (this call is safe as it happens in the main thread after dialog closes)
if self.selected_playlist_items is None:
button_text = "Select Videos... (All selected)"
else:
selected_indices = dialog._parse_selection_string(self.selected_playlist_items)
count = len(selected_indices)
display_text = (
self.selected_playlist_items if len(self.selected_playlist_items) < 30 else f"{count} videos selected"
)
button_text = f"Select Videos... ({display_text})"
self.playlist_select_btn.setText(button_text) # Direct call is fine here
# --- New Slot for Updating Playlist Button Text ---
# moved to SignalManager as Signal and added to init_ui() method.
def toggle_download_controls(self, enabled=True) -> None:
"""Enable or disable download-related controls"""
self.url_input.setEnabled(enabled)
self.analyze_button.setEnabled(enabled)
self.format_table.setEnabled(enabled) # Changed from format_scroll_area to format_table
self.download_btn.setEnabled(enabled)
if hasattr(self, "subtitle_combo"):
self.subtitle_combo.setEnabled(enabled) # type: ignore[reportAttributeAccessIssue]
self.video_button.setEnabled(enabled)
self.audio_button.setEnabled(enabled)
if hasattr(self, "sponsorblock_select_btn"):
self.sponsorblock_select_btn.setEnabled(enabled)
self.merge_subs_checkbox.setEnabled(enabled) # Enable/disable merge subs checkbox
self.custom_options_btn.setEnabled(enabled) # Enable/disable custom options button
self.update_ytdlp_btn.setEnabled(enabled) # Enable/disable update button
self.time_range_btn.setEnabled(enabled) # Enable/disable time range button
self.settings_button.setEnabled(enabled) # Enable/disable settings button
# Clear progress/status when controls are re-enabled
if enabled:
self.progress_bar.setValue(0)
self.status_label.setText("Ready")
self.download_details_label.setText("") # Clear details label
def handle_format_selection(self, button) -> None:
# Update formats
self.filter_formats()
def handle_mode_change(self) -> None:
"""Enable or disable features based on video/audio mode"""
if self.audio_button.isChecked():
# In Audio Only mode, disable video-specific features
if hasattr(self, "sponsorblock_select_btn"):
self.sponsorblock_select_btn.setEnabled(False)
if hasattr(self, "selected_sponsorblock_categories"):
self.selected_sponsorblock_categories = [] # Clear selection when disabled
if hasattr(self, "_update_sponsorblock_display"):
self._update_sponsorblock_display()
self.merge_subs_checkbox.setEnabled(False)
self.merge_subs_checkbox.setChecked(False) # Uncheck when disabled
# Allow subtitle selection in Audio Only mode too
if hasattr(self, "subtitle_select_btn"):
self.subtitle_select_btn.setEnabled(True)
else:
# In Video mode, enable video-specific features
if hasattr(self, "sponsorblock_select_btn"):
self.sponsorblock_select_btn.setEnabled(True)
# Don't automatically restore categories - let user choose when they open the dialog
# Enable merge_subs only if subtitles are selected
has_subs_selected = len(getattr(self, "selected_subtitles", [])) > 0
self.merge_subs_checkbox.setEnabled(has_subs_selected)
# Re-enable subtitle selection button in Video mode
if hasattr(self, "subtitle_select_btn"):
self.subtitle_select_btn.setEnabled(True)
# Keep these methods for backwards compatibility - they just call the new dialog now
def show_custom_command(self) -> None:
dialog = CustomOptionsDialog(self)
dialog.tab_widget.setCurrentIndex(1) # Select the Custom Command tab
dialog.exec()
def show_cookie_login_dialog(self) -> None:
dialog = CustomOptionsDialog(self)
dialog.tab_widget.setCurrentIndex(0) # Select the Cookie Login tab
if dialog.exec():
# Handle cookies
cookie_path = dialog.get_cookie_file_path()
browser_cookies = dialog.get_browser_cookies_option()
if cookie_path:
self.cookie_file_path = cookie_path
self.browser_cookies_option = None # Clear browser cookies if file is used
logger.info(f"Selected cookie file: {self.cookie_file_path}")
QMessageBox.information(
self,
"Cookie File Selected",
f"Cookie file selected: {self.cookie_file_path}",
)
elif browser_cookies:
self.browser_cookies_option = browser_cookies
self.cookie_file_path = None # Clear file cookies if browser is used
logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
QMessageBox.information(
self,
"Browser Cookies Selected",
f"Browser cookies will be extracted from: {browser_cookies}",
)
else:
self.cookie_file_path = None # Clear path if dialog accepted but no file selected
self.browser_cookies_option = None # Clear browser cookies too
def cancel_download(self) -> None:
if self.current_download:
self.current_download.cancelled = True
self.status_label.setText("Cancelling download...") # Set status directly
self.download_details_label.setText("") # Clear details label on cancellation
def show_ffmpeg_dialog(self) -> None:
dialog = FFmpegCheckDialog(self)
dialog.exec()
# Add method for showing time range dialog
def show_time_range_dialog(self) -> None:
dialog = TimeRangeDialog(self)
if dialog.exec():
# Store the time range settings
self.download_section = dialog.get_download_sections()
self.force_keyframes = dialog.get_force_keyframes()
if self.download_section:
self.time_range_btn.setStyleSheet(
"""
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
border: 2px solid white;
}
QPushButton:hover {
background-color: #a50000;
}
"""
)
self.time_range_btn.setToolTip(f"Section set: {self.download_section}")
else:
# Reset to default style if no section is selected
self.download_section = None
self.force_keyframes = False
self.time_range_btn.setStyleSheet("")
self.time_range_btn.setToolTip("")
def show_ytdlp_setup_dialog(self) -> None:
"""Show the yt-dlp setup dialog to configure yt-dlp"""
yt_dlp_path = setup_ytdlp(self)
if yt_dlp_path != "yt-dlp":
success_dialog = QMessageBox(self)
success_dialog.setIcon(QMessageBox.Icon.Information)
success_dialog.setWindowTitle("yt-dlp Setup")
success_dialog.setText(f"yt-dlp has been successfully configured at:\n{yt_dlp_path}")
success_dialog.setWindowIcon(self.windowIcon())
success_dialog.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
}
QLabel {
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #a50000;
}
"""
)
success_dialog.exec()
def _analyze_url_with_subprocess(self, url) -> None:
"""Analyze URL using yt-dlp executable when Python module is not available"""
try:
yt_dlp_path = get_yt_dlp_path()
if not yt_dlp_path:
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
self.signals.update_status.emit("Error: yt-dlp executable not found. Please install yt-dlp first.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (30%)... Extracting info with yt-dlp executable")
# 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}"
# Build command for basic info extraction
cmd = [yt_dlp_path, "--dump-json", "--no-warnings", url]
# Add cookies if available
if self.cookie_file_path:
cmd.extend(["--cookies", str(self.cookie_file_path)])
elif self.browser_cookies_option:
cmd.extend(["--cookies-from-browser", self.browser_cookies_option])
# Add proxy settings if available
if self.proxy_url:
cmd.extend(["--proxy", self.proxy_url])
if self.geo_proxy_url:
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
# Execute command with hidden console window on Windows
# Extra logic moved to src\utils\ytsage_constants.py
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SUBPROCESS_CREATIONFLAGS)
if result.returncode != 0:
logger.error(f"yt-dlp failed: {result.stderr}")
self.signals.update_status.emit(f"Error: yt-dlp failed: {result.stderr}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
if not json_lines:
logger.error("No data returned from yt-dlp")
self.signals.update_status.emit("Error: No data returned from yt-dlp")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
try:
first_info = json.loads(json_lines[0])
except json.JSONDecodeError as e:
logger.error(f"Failed to parse yt-dlp output: {e}")
self.signals.update_status.emit(f"Error: Failed to parse yt-dlp output: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (60%)... Processing data")
if first_info.get("_type") == "playlist" or len(json_lines) > 1:
# Handle playlist
self.is_playlist = True
self.playlist_info = first_info
self.selected_playlist_items = None
self.playlist_entries = []
# Parse all entries
for line in json_lines:
try:
entry = json.loads(line)
if entry.get("_type") != "playlist": # Skip playlist metadata
self.playlist_entries.append(entry)
except json.JSONDecodeError:
continue
if not self.playlist_entries:
logger.error("Playlist contains no valid videos.")
self.signals.update_status.emit("Error: Playlist contains no valid videos.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
# Use first video for format information
self.video_info = self.playlist_entries[0]
# Update playlist info label
playlist_text = (
f"Playlist: {first_info.get('title', 'Unknown Playlist')} | " f"{len(self.playlist_entries)} videos"
)
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_info_label_text.emit(playlist_text)
self.signals.playlist_info_label_visible.emit(True)
# Show playlist selection button
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_select_btn_text.emit("Select Videos... (All selected)")
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_select_btn_visible.emit(True)
else:
# Handle single video
self.is_playlist = False
self.video_info = first_info
self.playlist_entries = []
self.selected_playlist_items = None
# Hide playlist UI
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_info_label_visible.emit(False)
# update signal method from QMetaObject.invokeMethod to signals
self.signals.playlist_select_btn_visible.emit(False)
# Verify we have format information
if not self.video_info or "formats" not in self.video_info:
logger.error("No format information available")
self.signals.update_status.emit("Error: No format information available.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (75%)... 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 (85%)... Loading thumbnail")
# Try to get thumbnail from playlist info first
# Fallback to video thumbnail if playlist thumbnail not found or not a playlist
thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail")
self.download_thumbnail(thumbnail_url)
# Save thumbnail if enabled
if self.save_thumbnail:
self.download_thumbnail_file(self.video_url, self.path_input.text()) # type: ignore[reportAttributeAccessIssue]
# Handle subtitles
self.signals.update_status.emit("Analyzing (90%)... Processing subtitles")
self.selected_subtitles = []
self.available_subtitles = self.video_info.get("subtitles", {})
self.available_automatic_subtitles = self.video_info.get("automatic_captions", {})
# Update subtitle UI
# update signal method from QMetaObject.invokeMethod to signals
self.signals.selected_subs_label_text.emit("0 selected")
# Update format table
self.signals.update_status.emit("Analyzing (95%)... Updating format table")
self.video_button.setChecked(True)
self.audio_button.setChecked(False)
self.filter_formats()
self.signals.update_status.emit("Analysis complete!")
except subprocess.TimeoutExpired:
logger.error("Analysis timed out. Please try again.")
self.signals.update_status.emit("Error: Analysis timed out. Please try again.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse yt-dlp output: {e}")
self.signals.update_status.emit(f"Error: Failed to parse yt-dlp output: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
except Exception as e:
logger.error(f"Analysis failed: {e}")
self.signals.update_status.emit(f"Error: Analysis failed: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)