diff --git a/src/gui/dialogs/ytsage_dialogs_base.py b/src/gui/dialogs/ytsage_dialogs_base.py
new file mode 100644
index 0000000..e8cb556
--- /dev/null
+++ b/src/gui/dialogs/ytsage_dialogs_base.py
@@ -0,0 +1,455 @@
+"""
+Base dialogs for YTSage application.
+Contains basic utility dialogs like LogWindow and AboutDialog.
+"""
+
+import sys
+import os
+import webbrowser
+from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QTextEdit, QWidget, QDialogButtonBox, QSizePolicy,
+ QPushButton, QMessageBox, QScrollArea)
+from PySide6.QtCore import Qt, QThread, Signal, QTimer
+from PySide6.QtGui import QIcon
+
+from ...core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_path
+from ...core.ytsage_yt_dlp import get_yt_dlp_path, check_ytdlp_installed
+from ...core.ytsage_utils import (check_ffmpeg, get_ytdlp_version, get_ffmpeg_version,
+ refresh_version_cache, _version_cache)
+
+
+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 AboutDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.parent = parent # Store parent to access version etc.
+ self.setWindowTitle("About YTSage")
+ self.setMinimumSize(460, 420) # Slightly increased to accommodate paths
+ self.resize(460, 440) # Slightly increased initial size
+ self.setMaximumSize(500, 480) # Reasonable maximum size
+
+ # Set window flags to make dialog independent of parent movement
+ self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(15) # Reduced spacing
+ layout.setContentsMargins(20, 20, 20, 20) # Reduced margins
+ layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetMinAndMaxSize)
+
+ # App Information Section
+ app_info_widget = self._create_app_info_section()
+ layout.addWidget(app_info_widget)
+
+ # Separator - subtle and compact
+ separator = QWidget()
+ separator.setFixedHeight(1)
+ separator.setStyleSheet("background-color: #2a2a2a; margin: 8px 20px;")
+ layout.addWidget(separator)
+
+ # System Information Section
+ system_info_widget = self._create_system_info_section()
+ layout.addWidget(system_info_widget)
+
+ # Close Button - compact positioning
+ layout.addSpacing(10) # Reduced space before button
+ button_layout = QHBoxLayout()
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
+ button_box.accepted.connect(self.accept)
+
+ # Center the button
+ button_layout.addStretch()
+ button_layout.addWidget(button_box)
+ button_layout.addStretch()
+ layout.addLayout(button_layout)
+
+ # Apply overall styling - improved consistency
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QLabel {
+ color: #cccccc;
+ }
+ QPushButton {
+ padding: 10px 30px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 6px;
+ color: white;
+ font-weight: bold;
+ font-size: 12px;
+ min-width: 80px;
+ }
+ QPushButton:hover {
+ background-color: #a50000;
+ }
+ QPushButton:pressed {
+ background-color: #800000;
+ }
+ QGroupBox {
+ font-weight: bold;
+ border: 1px solid #333333;
+ border-radius: 8px;
+ margin-top: 12px;
+ padding-top: 12px;
+ background-color: #15181b;
+ }
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 15px;
+ padding: 0 10px 0 10px;
+ color: #ffffff;
+ font-size: 14px;
+ }
+ """)
+
+ def _create_app_info_section(self):
+ """Create the application information section - compact version"""
+ widget = QWidget()
+ layout = QVBoxLayout(widget)
+ layout.setSpacing(6) # Reduced spacing
+
+ # Title and Version - more compact
+ title_label = QLabel("YTSage")
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(title_label)
+
+ version_label = QLabel(f"Version {getattr(self.parent, 'version', '4.7.0')}")
+ version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(version_label)
+
+ # Description - more compact
+ description_label = QLabel("A simple GUI frontend for the powerful yt-dlp video downloader.")
+ description_label.setWordWrap(True)
+ description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ description_label.setStyleSheet("color: #ffffff; font-size: 11px; margin: 6px 0;")
+ layout.addWidget(description_label)
+
+ # Author and Links - compact single line
+ info_layout = QHBoxLayout()
+ info_layout.setSpacing(15)
+
+ author_label = QLabel("By: oop7")
+ author_label.setOpenExternalLinks(True)
+ info_layout.addWidget(author_label)
+
+ repo_label = QLabel("GitHub: YTSage")
+ repo_label.setOpenExternalLinks(True)
+ info_layout.addWidget(repo_label)
+
+ # Center the info layout
+ info_container = QHBoxLayout()
+ info_container.addStretch()
+ info_container.addLayout(info_layout)
+ info_container.addStretch()
+
+ layout.addLayout(info_container)
+
+ return widget
+
+ def _create_system_info_section(self):
+ """Create the system information section with compact design"""
+ # Create main container with compact styling
+ container = QWidget()
+ container.setStyleSheet("""
+ QWidget {
+ border: 1px solid #333333;
+ border-radius: 8px;
+ background-color: #15181b;
+ margin-top: 5px;
+ }
+ """)
+
+ main_layout = QVBoxLayout(container)
+ main_layout.setSpacing(8) # Compact spacing
+ main_layout.setContentsMargins(15, 10, 15, 10)
+
+ # Create header with title and refresh button on same line
+ header_layout = QHBoxLayout()
+ header_layout.setContentsMargins(0, 0, 0, 5)
+
+ # System Information title
+ title_label = QLabel("System Information")
+ title_label.setStyleSheet("""
+ QLabel {
+ color: #ffffff;
+ font-size: 14px;
+ font-weight: bold;
+ padding: 0px;
+ margin: 0px;
+ }
+ """)
+ header_layout.addWidget(title_label)
+
+ # Add stretch to push refresh button to the right
+ header_layout.addStretch()
+
+ # Create refresh button
+ self.refresh_btn = QPushButton("🔄")
+ self.refresh_btn.setFixedSize(16, 16)
+ self.refresh_btn.setStyleSheet("""
+ QPushButton {
+ padding: 0px;
+ background-color: transparent;
+ border: none;
+ color: #cccccc;
+ font-size: 10px;
+ font-weight: normal;
+ margin: 0px;
+ }
+ QPushButton:hover {
+ color: #ffffff;
+ background-color: rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ }
+ QPushButton:pressed {
+ color: #c90000;
+ background-color: rgba(201, 0, 0, 0.1);
+ }
+ """)
+ self.refresh_btn.clicked.connect(self.refresh_version_info)
+ header_layout.addWidget(self.refresh_btn)
+
+ main_layout.addLayout(header_layout)
+
+ # Compact status grid layout
+ self.status_container = QVBoxLayout()
+ self.status_container.setSpacing(6) # Tight spacing
+ self.status_container.setContentsMargins(0, 0, 0, 0)
+
+ main_layout.addLayout(self.status_container)
+
+ # Show loading message initially
+ self._show_loading_message()
+
+ # Populate system information asynchronously
+ QTimer.singleShot(100, self.update_system_info)
+
+ return container
+
+ def _show_loading_message(self):
+ """Show a compact loading message while system information is being gathered."""
+ loading_label = QLabel("🔄 Loading system information...")
+ loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ loading_label.setStyleSheet("""
+ QLabel {
+ color: #cccccc;
+ font-size: 11px;
+ font-style: italic;
+ padding: 10px;
+ }
+ """)
+
+ self.status_container.addWidget(loading_label)
+
+ def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status=""):
+ """Create a compact status item widget"""
+ item_widget = QWidget()
+ # Adjust height based on whether we have path info
+ item_height = 50 if path_text else 35
+ item_widget.setMaximumHeight(item_height)
+ item_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+
+ # Main layout
+ item_layout = QVBoxLayout(item_widget)
+ item_layout.setContentsMargins(8, 4, 8, 4)
+ item_layout.setSpacing(2)
+
+ # First row: Icon, name, status, version
+ first_row = QHBoxLayout()
+ first_row.setSpacing(8)
+
+ # Icon and name - compact
+ name_label = QLabel(f"{icon} {name}")
+ name_label.setStyleSheet("font-size: 12px; color: #ffffff; font-weight: bold;")
+ name_label.setMinimumWidth(80)
+ first_row.addWidget(name_label)
+
+ # Status - compact
+ status_label = QLabel(status_text)
+ status_label.setStyleSheet("font-size: 11px; font-weight: bold;")
+ status_label.setMinimumWidth(70)
+ first_row.addWidget(status_label)
+
+ # Version info - improved readability
+ version_info = version_text
+ if cache_status:
+ version_info += cache_status
+
+ version_label = QLabel(version_info)
+ version_label.setStyleSheet("font-size: 11px; color: #cccccc;") # Increased from 10px
+ version_label.setWordWrap(False)
+ first_row.addWidget(version_label)
+
+ # Add stretch to push everything left
+ first_row.addStretch()
+
+ item_layout.addLayout(first_row)
+
+ # Second row: Path (if provided)
+ if path_text:
+ path_label = QLabel(f"📁 {path_text}")
+ path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color
+ path_label.setWordWrap(False)
+ # Truncate very long paths
+ if len(path_text) > 60:
+ truncated_path = "..." + path_text[-57:]
+ path_label.setText(f"📁 {truncated_path}")
+ item_layout.addWidget(path_label)
+
+ # Subtle background with minimal border
+ item_widget.setStyleSheet("""
+ QWidget {
+ background-color: rgba(45, 45, 45, 0.3);
+ border: 1px solid #2a2a2a;
+ border-radius: 4px;
+ margin: 1px;
+ }
+ QWidget:hover {
+ background-color: rgba(60, 60, 60, 0.4);
+ }
+ """)
+
+ return item_widget
+
+ def update_system_info(self):
+ """Update the system information display with compact layout."""
+ # Clear existing items
+ for i in reversed(range(self.status_container.count())):
+ child = self.status_container.itemAt(i).widget()
+ if child:
+ child.deleteLater()
+
+ # yt-dlp Status - compact version with path
+ ytdlp_found = check_ytdlp_installed()
+ ytdlp_status_text = "✓ Detected" if ytdlp_found else "✗ Missing"
+ ytdlp_version = get_ytdlp_version()
+
+ # Get yt-dlp path
+ ytdlp_path = get_yt_dlp_path() if ytdlp_found else None
+ ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None
+
+ # Simplified cache status
+ ytdlp_cache = _version_cache.get('ytdlp', {})
+ last_check = ytdlp_cache.get('last_check', 0)
+ cache_status = ""
+ if last_check > 0:
+ from datetime import datetime
+ cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
+ cache_status = f" ({cache_time})" # Increased from 9px
+
+ ytdlp_item = self._create_status_item(
+ "🎥", "yt-dlp", ytdlp_status_text, ytdlp_version + cache_status, ytdlp_path_text
+ )
+ self.status_container.addWidget(ytdlp_item)
+
+ # FFmpeg Status - compact version with path
+ ffmpeg_found = check_ffmpeg()
+ ffmpeg_status_text = "✓ Detected" if ffmpeg_found else "✗ Missing"
+ ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available"
+
+ # Get FFmpeg path
+ ffmpeg_path_text = None
+ if ffmpeg_found:
+ ffmpeg_path = get_ffmpeg_path()
+ ffmpeg_path_text = ffmpeg_path if ffmpeg_path else None
+
+ # Simplified cache status for FFmpeg
+ ffmpeg_cache = _version_cache.get('ffmpeg', {})
+ last_check = ffmpeg_cache.get('last_check', 0)
+ cache_status = ""
+ if last_check > 0 and ffmpeg_found:
+ from datetime import datetime
+ cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
+ cache_status = f" ({cache_time})" # Increased from 9px
+
+ ffmpeg_item = self._create_status_item(
+ "🎬", "FFmpeg", ffmpeg_status_text, ffmpeg_version + cache_status, ffmpeg_path_text
+ )
+ self.status_container.addWidget(ffmpeg_item)
+
+ def refresh_version_info(self):
+ """Refresh version information manually."""
+ self.refresh_btn.setText("🔄 Refreshing...")
+ self.refresh_btn.setEnabled(False)
+
+ # Perform refresh in a separate thread to avoid blocking UI
+ from PySide6.QtCore import QThread, Signal
+
+ class RefreshThread(QThread):
+ finished = Signal(bool)
+
+ def run(self):
+ success = refresh_version_cache(force=True)
+ self.finished.emit(success)
+
+ self.refresh_thread = RefreshThread()
+ self.refresh_thread.finished.connect(self.on_refresh_finished)
+ self.refresh_thread.start()
+
+ def on_refresh_finished(self, success):
+ """Handle refresh completion."""
+ self.refresh_btn.setText("🔄 Refresh")
+ self.refresh_btn.setEnabled(True)
+
+ if success:
+ self.update_system_info()
+ else:
+ # Show error message with proper styling
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(QMessageBox.Warning)
+ msg_box.setWindowTitle("Refresh Failed")
+ msg_box.setText("Failed to refresh version information.")
+ msg_box.setWindowIcon(self.windowIcon())
+ msg_box.setStyleSheet("""
+ QMessageBox {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QMessageBox QLabel {
+ color: #ffffff;
+ }
+ QMessageBox QPushButton {
+ padding: 8px 15px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ min-width: 80px;
+ }
+ QMessageBox QPushButton:hover {
+ background-color: #a50000;
+ }
+ """)
+ msg_box.exec()
diff --git a/src/gui/dialogs/ytsage_dialogs_custom.py b/src/gui/dialogs/ytsage_dialogs_custom.py
new file mode 100644
index 0000000..68a4ef7
--- /dev/null
+++ b/src/gui/dialogs/ytsage_dialogs_custom.py
@@ -0,0 +1,735 @@
+"""
+Custom functionality dialogs for YTSage application.
+Contains dialogs for custom commands, cookies, time ranges, and other special features.
+"""
+
+import os
+import sys
+import threading
+import subprocess
+from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QLineEdit, QPushButton, QTextEdit, QPlainTextEdit,
+ QCheckBox, QTabWidget, QWidget, QDialogButtonBox,
+ QFileDialog, QGroupBox)
+from PySide6.QtCore import Qt, QMetaObject, Q_ARG
+
+from ...core.ytsage_yt_dlp import get_yt_dlp_path
+
+try:
+ import yt_dlp
+ YT_DLP_AVAILABLE = True
+except ImportError:
+ YT_DLP_AVAILABLE = False
+
+
+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: #1d1e22;
+ color: #ffffff;
+ border: 2px solid #1d1e22;
+ 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;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #1d1e22;
+ border-radius: 9px;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #c90000;
+ background: #c90000;
+ border-radius: 9px;
+ }
+ """)
+ 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: #1d1e22;
+ color: #ffffff;
+ border: 2px solid #1d1e22;
+ border-radius: 4px;
+ padding: 8px;
+ font-family: Consolas, monospace;
+ font-size: 12px;
+ }
+ """)
+ layout.addWidget(self.log_output)
+
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ }
+ QPushButton {
+ padding: 8px 15px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ }
+ QPushButton:hover {
+ background-color: #a50000;
+ }
+ """)
+
+ 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
+ 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 CookieLoginDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle('Login with Cookies')
+ self.setMinimumSize(400, 150)
+
+ layout = QVBoxLayout(self)
+
+ help_text = QLabel(
+ "Select the Netscape-format cookies file for logging in.\n"
+ "This allows downloading of private videos and premium quality audio."
+ )
+ help_text.setWordWrap(True)
+ help_text.setStyleSheet("color: #999999; padding: 10px;")
+ layout.addWidget(help_text)
+
+ # File path input and browse button
+ path_layout = QHBoxLayout()
+ self.cookie_path_input = QLineEdit()
+ self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
+ path_layout.addWidget(self.cookie_path_input)
+
+ self.browse_button = QPushButton("Browse")
+ self.browse_button.clicked.connect(self.browse_cookie_file)
+ path_layout.addWidget(self.browse_button)
+
+ layout.addLayout(path_layout)
+
+ # Dialog buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ def browse_cookie_file(self):
+ # Open file dialog to select cookie file
+ file_dialog = QFileDialog(self)
+ file_dialog.setFileMode(QFileDialog.ExistingFile)
+ file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions
+ if file_dialog.exec():
+ selected_files = file_dialog.selectedFiles()
+ if selected_files:
+ self.cookie_path_input.setText(selected_files[0])
+
+ def get_cookie_file_path(self):
+ # Return the selected cookie file path
+ return self.cookie_path_input.text()
+
+
+class CustomOptionsDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.parent = parent
+ self.setWindowTitle('Custom Options')
+ self.setMinimumSize(600, 500)
+
+ layout = QVBoxLayout(self)
+
+ # Create tab widget to organize content
+ self.tab_widget = QTabWidget()
+ layout.addWidget(self.tab_widget)
+
+ # === Cookies Tab ===
+ cookies_tab = QWidget()
+ cookies_layout = QVBoxLayout(cookies_tab)
+
+ # Help text
+ help_text = QLabel(
+ "Select the Netscape-format cookies file for logging in.\n"
+ "This allows downloading of private videos and premium quality audio."
+ )
+ help_text.setWordWrap(True)
+ help_text.setStyleSheet("color: #999999; padding: 10px;")
+ cookies_layout.addWidget(help_text)
+
+ # File path input and browse button
+ path_layout = QHBoxLayout()
+ self.cookie_path_input = QLineEdit()
+ self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
+ if hasattr(parent, 'cookie_file_path') and parent.cookie_file_path:
+ self.cookie_path_input.setText(parent.cookie_file_path)
+ path_layout.addWidget(self.cookie_path_input)
+
+ self.browse_button = QPushButton("Browse")
+ self.browse_button.clicked.connect(self.browse_cookie_file)
+ path_layout.addWidget(self.browse_button)
+ cookies_layout.addLayout(path_layout) # Add the horizontal layout to cookies layout
+
+ # Status indicator for cookies
+ self.cookie_status = QLabel("")
+ self.cookie_status.setStyleSheet("color: #999999; font-style: italic;")
+ cookies_layout.addWidget(self.cookie_status)
+
+ cookies_layout.addStretch()
+
+ # === Custom Command Tab ===
+ command_tab = QWidget()
+ command_layout = QVBoxLayout(command_tab)
+
+ # Help text
+ cmd_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."
+ )
+ cmd_help_text.setWordWrap(True)
+ cmd_help_text.setStyleSheet("color: #999999; padding: 10px;")
+ command_layout.addWidget(cmd_help_text)
+
+ # Add SponsorBlock checkbox
+ self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
+ self.sponsorblock_checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ margin-left: 0px;
+ }
+ 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;
+ }
+ """)
+ command_layout.addWidget(self.sponsorblock_checkbox)
+
+ # Command input
+ self.command_input = QPlainTextEdit()
+ self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
+ self.command_input.setStyleSheet("""
+ QPlainTextEdit {
+ background-color: #1d1e22;
+ color: #ffffff;
+ border: 2px solid #1d1e22;
+ border-radius: 4px;
+ padding: 8px;
+ font-family: Consolas, monospace;
+ }
+ """)
+ command_layout.addWidget(self.command_input)
+
+ # Run command button
+ self.run_btn = QPushButton("Run Command")
+ self.run_btn.clicked.connect(self.run_custom_command)
+ command_layout.addWidget(self.run_btn)
+
+ # Log output
+ self.log_output = QTextEdit()
+ self.log_output.setReadOnly(True)
+ self.log_output.setStyleSheet("""
+ QTextEdit {
+ background-color: #1d1e22;
+ color: #ffffff;
+ border: 2px solid #1d1e22;
+ border-radius: 4px;
+ padding: 8px;
+ font-family: Consolas, monospace;
+ font-size: 12px;
+ }
+ """)
+ command_layout.addWidget(self.log_output)
+
+ # Add tabs to the tab widget
+ self.tab_widget.addTab(cookies_tab, "Login with Cookies")
+ self.tab_widget.addTab(command_tab, "Custom Command")
+
+ # Dialog buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ # Apply global styles
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ }
+ QTabWidget::pane {
+ border: 1px solid #3d3d3d;
+ background-color: #15181b;
+ }
+ QTabBar::tab {
+ background-color: #1d1e22;
+ color: #ffffff;
+ padding: 8px 12px;
+ border: 1px solid #3d3d3d;
+ border-bottom: none;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }
+ QTabBar::tab:selected {
+ background-color: #c90000;
+ }
+ QTabBar::tab:hover:!selected {
+ background-color: #2a2d36;
+ }
+ QLabel {
+ 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;
+ }
+ """)
+
+ def browse_cookie_file(self):
+ # Open file dialog to select cookie file
+ file_dialog = QFileDialog(self)
+ file_dialog.setFileMode(QFileDialog.ExistingFile)
+ file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions
+ if file_dialog.exec():
+ selected_files = file_dialog.selectedFiles()
+ if selected_files:
+ self.cookie_path_input.setText(selected_files[0])
+ self.cookie_status.setText("Cookie file selected - Click OK to apply")
+ self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
+
+ def get_cookie_file_path(self):
+ # Return the selected cookie file path if it's not empty
+ path = self.cookie_path_input.text().strip()
+ if path and os.path.exists(path):
+ return path
+ return None
+
+ 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()
+
+ # Get download path from parent
+ path = self.parent.last_path
+
+ self.log_output.clear()
+ self.log_output.append(f"Running command with URL: {url}")
+ self.run_btn.setEnabled(False)
+
+ # Start command in thread
+ 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):
+ QMetaObject.invokeMethod(
+ self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, msg)
+ )
+ def warning(self, msg):
+ QMetaObject.invokeMethod(
+ self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, f"Warning: {msg}")
+ )
+ def error(self, msg):
+ QMetaObject.invokeMethod(
+ self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, f"Error: {msg}")
+ )
+ def __init__(self, dialog):
+ self.dialog = dialog
+
+ # Split command into arguments
+ args = command.split()
+
+ # Add SponsorBlock if selected
+ yt_dlp_path = get_yt_dlp_path()
+ base_cmd = [yt_dlp_path] + args + [url]
+
+ if self.sponsorblock_checkbox.isChecked():
+ base_cmd.extend(['--sponsorblock-remove', 'sponsor,selfpromo,interaction'])
+
+ # Show the full command
+ QMetaObject.invokeMethod(
+ self.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, f"Full command: {' '.join(base_cmd)}")
+ )
+
+ # Run the command
+ proc = subprocess.Popen(
+ base_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding='utf-8',
+ errors='replace'
+ )
+
+ # Stream output
+ for line in proc.stdout:
+ QMetaObject.invokeMethod(
+ self.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, line.rstrip())
+ )
+
+ ret = proc.wait()
+ if ret != 0:
+ QMetaObject.invokeMethod(
+ self.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, f"Command exited with code {ret}")
+ )
+ else:
+ QMetaObject.invokeMethod(
+ self.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, "Command completed successfully")
+ )
+ except Exception as e:
+ QMetaObject.invokeMethod(
+ self.log_output, "append", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, f"Error: {str(e)}")
+ )
+ finally:
+ # Re-enable the run button
+ QMetaObject.invokeMethod(
+ self.run_btn, "setEnabled", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(bool, True)
+ )
+
+
+class TimeRangeDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.parent = parent
+ self.setWindowTitle('Download Video Section')
+ self.setMinimumWidth(400)
+
+ layout = QVBoxLayout(self)
+
+ # Help text explaining the feature
+ help_text = QLabel(
+ "Download only specific parts of a video by specifying time ranges.\n"
+ "Use HH:MM:SS format or seconds. Leave start or end empty to download from beginning or to end."
+ )
+ help_text.setWordWrap(True)
+ help_text.setStyleSheet("color: #999999; padding: 10px;")
+ layout.addWidget(help_text)
+
+ # Time range section
+ time_group = QGroupBox("Time Range")
+ time_layout = QVBoxLayout()
+
+ # Start time row
+ start_layout = QHBoxLayout()
+ start_layout.addWidget(QLabel("Start Time:"))
+ self.start_time_input = QLineEdit()
+ self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)")
+ start_layout.addWidget(self.start_time_input)
+ time_layout.addLayout(start_layout)
+
+ # End time row
+ end_layout = QHBoxLayout()
+ end_layout.addWidget(QLabel("End Time:"))
+ self.end_time_input = QLineEdit()
+ self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)")
+ end_layout.addWidget(self.end_time_input)
+ time_layout.addLayout(end_layout)
+
+ time_group.setLayout(time_layout)
+ layout.addWidget(time_group)
+
+ # Force keyframes option
+ self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)")
+ self.force_keyframes.setChecked(True)
+ self.force_keyframes.setStyleSheet("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #1d1e22;
+ border-radius: 4px;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #c90000;
+ background: #c90000;
+ border-radius: 4px;
+ }
+ """)
+ layout.addWidget(self.force_keyframes)
+
+ # Format preview
+ preview_group = QGroupBox("Command Preview")
+ preview_layout = QVBoxLayout()
+ self.preview_label = QLabel("--download-sections \"*-\"")
+ self.preview_label.setStyleSheet("""
+ QLabel {
+ background-color: #1d1e22;
+ color: #ffffff;
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 8px;
+ font-family: Consolas, monospace;
+ }
+ """)
+ preview_layout.addWidget(self.preview_label)
+ preview_group.setLayout(preview_layout)
+ layout.addWidget(preview_group)
+
+ # Connect signals for live preview updates
+ self.start_time_input.textChanged.connect(self.update_preview)
+ self.end_time_input.textChanged.connect(self.update_preview)
+ self.force_keyframes.stateChanged.connect(self.update_preview)
+
+ # Buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ # Apply styling
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ }
+ QGroupBox {
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ margin-top: 1.5ex;
+ color: #ffffff;
+ padding: 10px;
+ }
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ subcontrol-position: top left;
+ padding: 0 5px;
+ }
+ QLabel {
+ 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;
+ }
+ """)
+
+ # Initialize preview
+ self.update_preview()
+
+ def update_preview(self):
+ start = self.start_time_input.text().strip()
+ end = self.end_time_input.text().strip()
+
+ if start and end:
+ time_range = f"*{start}-{end}"
+ elif start:
+ time_range = f"*{start}-"
+ elif end:
+ time_range = f"*-{end}"
+ else:
+ time_range = "*-" # Full video
+
+ preview = f"--download-sections \"{time_range}\""
+ if self.force_keyframes.isChecked():
+ preview += " --force-keyframes-at-cuts"
+
+ self.preview_label.setText(preview)
+
+ def get_download_sections(self):
+ """Returns the download sections command arguments or None if no selection made"""
+ start = self.start_time_input.text().strip()
+ end = self.end_time_input.text().strip()
+
+ if not start and not end:
+ return None # No selection made
+
+ if start and end:
+ time_range = f"*{start}-{end}"
+ elif start:
+ time_range = f"*{start}-"
+ elif end:
+ time_range = f"*-{end}"
+ else:
+ return None # Shouldn't happen but just in case
+
+ return time_range
+
+ def get_force_keyframes(self):
+ """Returns whether to force keyframes at cuts"""
+ return self.force_keyframes.isChecked()
diff --git a/src/gui/dialogs/ytsage_dialogs_ffmpeg.py b/src/gui/dialogs/ytsage_dialogs_ffmpeg.py
new file mode 100644
index 0000000..a6f9314
--- /dev/null
+++ b/src/gui/dialogs/ytsage_dialogs_ffmpeg.py
@@ -0,0 +1,194 @@
+"""
+FFmpeg installation dialogs for YTSage application.
+Contains dialogs and threads for checking and installing FFmpeg.
+"""
+
+import sys
+import os
+import webbrowser
+import contextlib
+from io import StringIO
+from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QPushButton, QStyle, QSizePolicy, QDialogButtonBox)
+from PySide6.QtCore import QThread, Signal, Qt
+from PySide6.QtGui import QIcon
+
+from ...core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
+
+
+class FFmpegInstallThread(QThread):
+ finished = Signal(bool)
+ progress = Signal(str)
+
+ def run(self):
+ # Redirect stdout to capture progress messages
+ output = StringIO()
+ with contextlib.redirect_stdout(output):
+ success = auto_install_ffmpeg()
+
+ # Process captured output and emit progress signals
+ for line in output.getvalue().splitlines():
+ self.progress.emit(line)
+
+ self.finished.emit(success)
+
+
+class FFmpegCheckDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle('FFmpeg Installation')
+ self.setMinimumWidth(450)
+ self.setMinimumHeight(200)
+ self.resize(450, 220)
+
+ # Set the window icon to match the main app
+ if parent and parent.windowIcon():
+ self.setWindowIcon(parent.windowIcon())
+ else:
+ # Try to load the icon directly if parent not available
+ # Navigate from src/gui/dialogs/ to project root, then to assets/Icon/
+ current_dir = os.path.dirname(os.path.abspath(__file__)) # dialogs/
+ gui_dir = os.path.dirname(current_dir) # gui/
+ src_dir = os.path.dirname(gui_dir) # src/
+ project_root = os.path.dirname(src_dir) # project root
+ icon_path = os.path.join(project_root, 'assets', 'Icon', 'icon.png')
+ if os.path.exists(icon_path):
+ self.setWindowIcon(QIcon(icon_path))
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(15)
+ layout.setContentsMargins(20, 20, 20, 20)
+
+ # Header with title and improved spacing
+ header_text = QLabel("FFmpeg Installation")
+ header_text.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;")
+ header_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(header_text)
+
+ # Message
+ self.message_label = QLabel(
+ "YTSage needs FFmpeg to process videos.\n\n"
+ "Choose an installation option below:"
+ )
+ self.message_label.setWordWrap(True)
+ self.message_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 10px 0; line-height: 1.4;")
+ self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(self.message_label)
+
+ # Progress label with improved styling and compact height
+ self.progress_label = QLabel("")
+ self.progress_label.setWordWrap(True)
+ self.progress_label.setMinimumHeight(60) # Smaller but visible area
+ self.progress_label.setMaximumHeight(80) # Limit maximum height
+ self.progress_label.setStyleSheet("""
+ QLabel {
+ background-color: #1d1e22;
+ color: #cccccc;
+ border: 1px solid #3d3d3d;
+ border-radius: 6px;
+ padding: 8px;
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 11px;
+ line-height: 1.2;
+ }
+ """)
+ self.progress_label.hide()
+ layout.addWidget(self.progress_label)
+
+ # Add minimal stretch - just enough to push buttons down slightly
+ layout.addSpacing(10)
+
+ # Buttons container - simple approach that should work
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(15) # Simple spacing
+
+ # Install button
+ self.install_btn = QPushButton("Install FFmpeg")
+ self.install_btn.clicked.connect(self.start_installation)
+ button_layout.addWidget(self.install_btn)
+
+ # Manual install button
+ self.manual_btn = QPushButton("Manual Guide")
+ self.manual_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide'))
+ button_layout.addWidget(self.manual_btn)
+
+ # Close button
+ self.close_btn = QPushButton("Close")
+ self.close_btn.clicked.connect(self.close)
+ button_layout.addWidget(self.close_btn)
+
+ layout.addLayout(button_layout)
+
+ # Style the dialog to match app theme
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QLabel {
+ color: #cccccc;
+ }
+ QPushButton {
+ padding: 10px 20px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 6px;
+ color: white;
+ font-weight: bold;
+ font-size: 13px;
+ min-height: 20px;
+ }
+ QPushButton:hover {
+ background-color: #a50000;
+ }
+ QPushButton:pressed {
+ background-color: #800000;
+ }
+ QPushButton:disabled {
+ background-color: #666666;
+ color: #999999;
+ }
+ """)
+
+ # Initialize installation thread
+ self.install_thread = None
+
+ def start_installation(self):
+ self.install_btn.setEnabled(False)
+ self.manual_btn.setEnabled(False)
+ self.close_btn.setEnabled(False)
+
+ # Check if FFmpeg is already installed
+ if check_ffmpeg_installed():
+ self.message_label.setText("FFmpeg is already installed!")
+ self.progress_label.setText("Installation complete. You can close this dialog and continue using YTSage.")
+ self.progress_label.show()
+ self.install_btn.hide()
+ self.manual_btn.hide()
+ self.close_btn.setEnabled(True)
+ return
+
+ self.message_label.setText("Installing FFmpeg... Please wait")
+ self.progress_label.show()
+
+ self.install_thread = FFmpegInstallThread()
+ self.install_thread.finished.connect(self.installation_finished)
+ self.install_thread.progress.connect(self.update_progress)
+ self.install_thread.start()
+
+ def update_progress(self, message):
+ self.progress_label.setText(message)
+
+ def installation_finished(self, success):
+ if success:
+ self.message_label.setText("FFmpeg has been installed successfully!")
+ self.progress_label.setText("Installation complete. You can now close this dialog and continue using YTSage.")
+ self.install_btn.hide()
+ self.manual_btn.hide()
+ else:
+ self.message_label.setText("FFmpeg installation encountered an issue.")
+ self.progress_label.setText("Please try using the manual installation guide instead.")
+ self.install_btn.setEnabled(True)
+ self.manual_btn.setEnabled(True)
+
+ self.close_btn.setEnabled(True)
diff --git a/src/gui/dialogs/ytsage_dialogs_selection.py b/src/gui/dialogs/ytsage_dialogs_selection.py
new file mode 100644
index 0000000..e6bb07a
--- /dev/null
+++ b/src/gui/dialogs/ytsage_dialogs_selection.py
@@ -0,0 +1,651 @@
+"""
+Selection dialogs for YTSage application.
+Contains dialogs for selecting subtitles, playlist videos, and SponsorBlock categories.
+"""
+
+from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QLineEdit, QPushButton, QScrollArea, QWidget,
+ QCheckBox, QDialogButtonBox, QGroupBox)
+from PySide6.QtCore import Qt
+
+
+class SubtitleSelectionDialog(QDialog):
+ def __init__(self, available_manual, available_auto, previously_selected, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Select Subtitles")
+ self.setMinimumWidth(400)
+ self.setMinimumHeight(300)
+
+ self.available_manual = available_manual
+ self.available_auto = available_auto
+ self.previously_selected = set(previously_selected) # Use a set for quick lookups
+ self.selected_subtitles = list(previously_selected) # Initialize with previous selection
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(10)
+
+ # Filter input
+ self.filter_input = QLineEdit()
+ self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...")
+ self.filter_input.textChanged.connect(self.filter_list)
+ self.filter_input.setStyleSheet("""
+ QLineEdit {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px;
+ min-height: 30px;
+ color: white;
+ }
+ QLineEdit:focus {
+ border-color: #ff0000;
+ }
+ """)
+ layout.addWidget(self.filter_input)
+
+ # Scroll Area for the list
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
+ layout.addWidget(scroll_area)
+
+ # Container widget for list items (needed for scroll area)
+ self.list_container = QWidget()
+ self.list_layout = QVBoxLayout(self.list_container)
+ self.list_layout.setContentsMargins(0, 0, 0, 0)
+ self.list_layout.setSpacing(2) # Compact spacing
+ self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
+ scroll_area.setWidget(self.list_container)
+
+ # Populate the list initially
+ self.populate_list()
+
+ # OK and Cancel buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+
+ # Style the buttons
+ for button in button_box.buttons():
+ button.setStyleSheet("""
+ QPushButton {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px 15px; /* Adjust padding */
+ min-height: 30px; /* Ensure consistent height */
+ color: white;
+ }
+ QPushButton:hover {
+ background-color: #444444;
+ }
+ QPushButton:pressed {
+ background-color: #555555;
+ }
+ """)
+ # Style the OK button specifically if needed
+ if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
+ button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
+
+ layout.addWidget(button_box)
+
+ def populate_list(self, filter_text=""):
+ # Clear existing checkboxes from layout
+ while self.list_layout.count():
+ item = self.list_layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.deleteLater()
+
+ filter_text = filter_text.lower()
+ combined_subs = {}
+
+ # Add manual subs
+ for lang_code, sub_info in self.available_manual.items():
+ if not filter_text or filter_text in lang_code.lower():
+ combined_subs[lang_code] = f"{lang_code} - Manual"
+
+ # Add auto subs (only if no manual exists and matches filter)
+ for lang_code, sub_info in self.available_auto.items():
+ if lang_code not in combined_subs: # Don't overwrite manual
+ if not filter_text or filter_text in lang_code.lower():
+ combined_subs[lang_code] = f"{lang_code} - Auto-generated"
+
+ if not combined_subs:
+ no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else ""))
+ no_subs_label.setStyleSheet("color: #aaaaaa; padding: 10px;")
+ self.list_layout.addWidget(no_subs_label)
+ return
+
+ # Sort by language code
+ sorted_lang_codes = sorted(combined_subs.keys())
+
+ for lang_code in sorted_lang_codes:
+ item_text = combined_subs[lang_code]
+ checkbox = QCheckBox(item_text)
+ checkbox.setProperty("subtitle_id", item_text) # Store the identifier
+ checkbox.setChecked(item_text in self.previously_selected) # Check if previously selected
+ checkbox.stateChanged.connect(self.update_selection)
+ checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px; /* Square checkboxes */
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #2b2b2b;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #ff0000;
+ background: #ff0000;
+ }
+ """)
+ self.list_layout.addWidget(checkbox)
+
+ self.list_layout.addStretch() # Pushes items up if list is short
+
+ def filter_list(self):
+ self.populate_list(self.filter_input.text())
+
+ def update_selection(self, state):
+ sender = self.sender()
+ subtitle_id = sender.property("subtitle_id")
+ if state == Qt.CheckState.Checked.value:
+ if subtitle_id not in self.previously_selected:
+ self.previously_selected.add(subtitle_id)
+ else:
+ if subtitle_id in self.previously_selected:
+ self.previously_selected.remove(subtitle_id)
+
+ def get_selected_subtitles(self):
+ # Return the final set as a list
+ return list(self.previously_selected)
+
+ def accept(self):
+ # Update the final list before closing
+ self.selected_subtitles = self.get_selected_subtitles()
+ super().accept()
+
+
+class PlaylistSelectionDialog(QDialog):
+ def __init__(self, playlist_entries, previously_selected_string, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Select Playlist Videos")
+ self.setMinimumWidth(500)
+ self.setMinimumHeight(400) # Allow more vertical space
+
+ self.playlist_entries = playlist_entries
+ self.checkboxes = []
+
+ # Main layout
+ main_layout = QVBoxLayout(self)
+
+ # Top buttons (Select/Deselect All)
+ button_layout = QHBoxLayout()
+ select_all_btn = QPushButton("Select All")
+ deselect_all_btn = QPushButton("Deselect All")
+ select_all_btn.clicked.connect(self._select_all)
+ deselect_all_btn.clicked.connect(self._deselect_all)
+ # Style the buttons to match the subtitle dialog
+ select_all_btn.setStyleSheet("""
+ QPushButton {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px 15px;
+ min-height: 30px;
+ color: white;
+ }
+ QPushButton:hover {
+ background-color: #444444;
+ }
+ QPushButton:pressed {
+ background-color: #555555;
+ }
+ """)
+ deselect_all_btn.setStyleSheet(select_all_btn.styleSheet())
+ button_layout.addWidget(select_all_btn)
+ button_layout.addWidget(deselect_all_btn)
+ button_layout.addStretch()
+ main_layout.addLayout(button_layout)
+
+ # Scrollable area for checkboxes
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
+ scroll_widget = QWidget()
+ self.list_layout = QVBoxLayout(scroll_widget) # Layout for checkboxes
+ self.list_layout.setContentsMargins(0, 0, 0, 0)
+ self.list_layout.setSpacing(2) # Compact spacing
+ self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
+ scroll_area.setWidget(scroll_widget)
+ main_layout.addWidget(scroll_area)
+
+ # Populate checkboxes
+ self._populate_list(previously_selected_string)
+
+ # Dialog buttons (OK/Cancel)
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+
+ # Style the buttons to match subtitle dialog
+ for button in button_box.buttons():
+ button.setStyleSheet("""
+ QPushButton {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px 15px;
+ min-height: 30px;
+ color: white;
+ }
+ QPushButton:hover {
+ background-color: #444444;
+ }
+ QPushButton:pressed {
+ background-color: #555555;
+ }
+ """)
+ # Style the OK button specifically if needed
+ if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
+ button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
+
+ main_layout.addWidget(button_box)
+
+ # Apply styling to match subtitle dialog
+ self.setStyleSheet("""
+ QDialog { background-color: #15181b; }
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #2b2b2b;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #ff0000;
+ background: #ff0000;
+ }
+ QWidget { background-color: #15181b; }
+ """)
+
+ def _parse_selection_string(self, selection_string):
+ """Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices."""
+ selected_indices = set()
+ if not selection_string:
+ # If no previous selection, assume all are selected initially
+ return set(range(1, len(self.playlist_entries) + 1))
+
+ parts = selection_string.split(',')
+ for part in parts:
+ part = part.strip()
+ if '-' in part:
+ try:
+ start, end = map(int, part.split('-'))
+ if start <= end:
+ selected_indices.update(range(start, end + 1))
+ except ValueError:
+ pass # Ignore invalid ranges
+ else:
+ try:
+ selected_indices.add(int(part))
+ except ValueError:
+ pass # Ignore invalid numbers
+ return selected_indices
+
+ def _populate_list(self, previously_selected_string):
+ """Populates the scroll area with checkboxes for each video."""
+ selected_indices = self._parse_selection_string(previously_selected_string)
+
+ # Clear existing checkboxes if any (e.g., if repopulating)
+ while self.list_layout.count():
+ child = self.list_layout.takeAt(0)
+ if child.widget():
+ child.widget().deleteLater()
+ self.checkboxes.clear()
+
+ for index, entry in enumerate(self.playlist_entries):
+ if not entry:
+ continue # Skip None entries if yt-dlp returns them
+
+ video_index = index + 1 # yt-dlp uses 1-based indexing
+ title = entry.get('title', f'Video {video_index}')
+ # Shorten title if too long
+ display_title = (title[:70] + '...') if len(title) > 73 else title
+
+ checkbox = QCheckBox(f"{video_index}. {display_title}")
+ checkbox.setChecked(video_index in selected_indices)
+ checkbox.setProperty("video_index", video_index) # Store index
+ checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #2b2b2b;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #ff0000;
+ background: #ff0000;
+ }
+ """)
+ self.list_layout.addWidget(checkbox)
+ self.checkboxes.append(checkbox)
+ self.list_layout.addStretch() # Push checkboxes to the top
+
+ def _select_all(self):
+ for checkbox in self.checkboxes:
+ checkbox.setChecked(True)
+
+ def _deselect_all(self):
+ for checkbox in self.checkboxes:
+ checkbox.setChecked(False)
+
+ def _condense_indices(self, indices):
+ """Condenses a list of 1-based indices into a yt-dlp selection string."""
+ if not indices:
+ return ""
+ indices = sorted(list(set(indices)))
+ if not indices: # Check again after sorting/set conversion
+ return ""
+
+ ranges = []
+ start = indices[0]
+ end = indices[0]
+ for i in range(1, len(indices)):
+ if indices[i] == end + 1:
+ end = indices[i]
+ else:
+ if start == end:
+ ranges.append(str(start))
+ else:
+ ranges.append(f"{start}-{end}")
+ start = indices[i]
+ end = indices[i]
+ # Add the last range
+ if start == end:
+ ranges.append(str(start))
+ else:
+ ranges.append(f"{start}-{end}")
+ return ",".join(ranges)
+
+ def get_selected_items_string(self):
+ """Returns the selection string based on checked boxes."""
+ selected_indices = [
+ cb.property("video_index") for cb in self.checkboxes if cb.isChecked()
+ ]
+
+ # Check if all items are selected
+ if len(selected_indices) == len(self.playlist_entries):
+ return None # yt-dlp default is all items, so return None or empty string
+
+ return self._condense_indices(selected_indices)
+
+
+class SponsorBlockCategoryDialog(QDialog):
+ """Dialog for selecting SponsorBlock categories to remove from videos."""
+
+ # Default SponsorBlock categories with descriptions
+ SPONSORBLOCK_CATEGORIES = {
+ 'sponsor': {
+ 'name': 'Sponsor',
+ 'description': 'Paid promotion, paid referrals and direct advertisements',
+ 'default': True
+ },
+ 'selfpromo': {
+ 'name': 'Unpaid/Self Promotion',
+ 'description': 'Unpaid promotion of creators\' own content',
+ 'default': True
+ },
+ 'interaction': {
+ 'name': 'Interaction Reminder',
+ 'description': 'Asking viewers to like, subscribe, or follow social media',
+ 'default': True
+ },
+ 'intro': {
+ 'name': 'Intro',
+ 'description': 'Video introduction that can be skipped',
+ 'default': False
+ },
+ 'outro': {
+ 'name': 'Outro/End Cards',
+ 'description': 'Credits or when the video ends',
+ 'default': False
+ },
+ 'preview': {
+ 'name': 'Preview/Recap',
+ 'description': 'Quick recap of previous videos or preview of what\'s coming up',
+ 'default': False
+ },
+ 'music_offtopic': {
+ 'name': 'Non-Music Section',
+ 'description': 'Only for music videos. Marks non-music sections',
+ 'default': False
+ },
+ 'filler': {
+ 'name': 'Filler Tangent',
+ 'description': 'Tangential scenes added only for filler or humor',
+ 'default': False
+ }
+ }
+
+ def __init__(self, previously_selected=None, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("SponsorBlock Categories")
+ self.setMinimumWidth(500)
+ self.setMinimumHeight(400)
+
+ # Set the window icon to match the main app
+ if parent:
+ self.setWindowIcon(parent.windowIcon())
+
+ self.previously_selected = set(previously_selected) if previously_selected else set()
+ self.checkboxes = {}
+
+ self.init_ui()
+ self.apply_styling()
+
+ def init_ui(self):
+ layout = QVBoxLayout(self)
+
+ # Title and description
+ title_label = QLabel("SponsorBlock Categories")
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;")
+ layout.addWidget(title_label)
+
+ desc_label = QLabel(
+ "Select which types of video segments to automatically remove during download.\n"
+ "SponsorBlock uses community-submitted data to identify these segments."
+ )
+ desc_label.setWordWrap(True)
+ desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
+ layout.addWidget(desc_label)
+
+ # Scroll area for categories
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setStyleSheet("QScrollArea { border: none; }")
+
+ scroll_widget = QWidget()
+ scroll_layout = QVBoxLayout(scroll_widget)
+ scroll_layout.setContentsMargins(10, 0, 10, 0)
+ scroll_layout.setSpacing(8)
+
+ # Add category checkboxes
+ for category_id, category_info in self.SPONSORBLOCK_CATEGORIES.items():
+ # Create a container widget for each category
+ category_widget = QWidget()
+ category_layout = QVBoxLayout(category_widget)
+ category_layout.setContentsMargins(0, 0, 0, 0)
+ category_layout.setSpacing(2)
+
+ # Create checkbox with just the name
+ checkbox = QCheckBox(category_info['name'])
+ checkbox.setProperty("category_id", category_id)
+
+ # Determine if this category should be checked
+ if self.previously_selected:
+ # Use previously selected categories
+ is_checked = category_id in self.previously_selected
+ else:
+ # Use default values for first time
+ is_checked = category_info['default']
+
+ checkbox.setChecked(is_checked)
+
+ checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 4px;
+ spacing: 10px;
+ font-weight: bold;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #2b2b2b;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #ff0000;
+ background: #ff0000;
+ }
+ """)
+
+ # Create description label
+ desc_label = QLabel(category_info['description'])
+ desc_label.setStyleSheet("color: #aaaaaa; font-size: 11px; margin-left: 28px; margin-bottom: 8px;")
+ desc_label.setWordWrap(True)
+
+ category_layout.addWidget(checkbox)
+ category_layout.addWidget(desc_label)
+
+ self.checkboxes[category_id] = checkbox
+ scroll_layout.addWidget(category_widget)
+
+ scroll_layout.addStretch()
+ scroll_area.setWidget(scroll_widget)
+ layout.addWidget(scroll_area)
+
+ # Quick selection buttons
+ button_layout = QHBoxLayout()
+
+ select_defaults_btn = QPushButton("Select Defaults")
+ select_defaults_btn.clicked.connect(self.select_defaults)
+ select_defaults_btn.setStyleSheet(self._get_button_style())
+
+ select_all_btn = QPushButton("Select All")
+ select_all_btn.clicked.connect(self.select_all)
+ select_all_btn.setStyleSheet(self._get_button_style())
+
+ deselect_all_btn = QPushButton("Deselect All")
+ deselect_all_btn.clicked.connect(self.deselect_all)
+ deselect_all_btn.setStyleSheet(self._get_button_style())
+
+ button_layout.addWidget(select_defaults_btn)
+ button_layout.addWidget(select_all_btn)
+ button_layout.addWidget(deselect_all_btn)
+ button_layout.addStretch()
+
+ layout.addLayout(button_layout)
+
+ # Dialog buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+
+ # Style the dialog buttons
+ for button in button_box.buttons():
+ button.setStyleSheet(self._get_button_style())
+ if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
+ button.setStyleSheet(button.styleSheet() +
+ "QPushButton { background-color: #ff0000; border-color: #cc0000; } " +
+ "QPushButton:hover { background-color: #cc0000; }")
+
+ layout.addWidget(button_box)
+
+ def _get_button_style(self):
+ """Returns the standard button style for this dialog."""
+ return """
+ QPushButton {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px 15px;
+ min-height: 30px;
+ color: white;
+ }
+ QPushButton:hover {
+ background-color: #444444;
+ }
+ QPushButton:pressed {
+ background-color: #555555;
+ }
+ """
+
+ def apply_styling(self):
+ """Apply the dialog styling to match the rest of the application."""
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QLabel {
+ color: #ffffff;
+ }
+ QWidget {
+ background-color: #15181b;
+ }
+ """)
+
+ def select_defaults(self):
+ """Select only the default categories."""
+ for category_id, checkbox in self.checkboxes.items():
+ default_value = self.SPONSORBLOCK_CATEGORIES[category_id]['default']
+ checkbox.setChecked(default_value)
+
+ def select_all(self):
+ """Select all categories."""
+ for checkbox in self.checkboxes.values():
+ checkbox.setChecked(True)
+
+ def deselect_all(self):
+ """Deselect all categories."""
+ for checkbox in self.checkboxes.values():
+ checkbox.setChecked(False)
+
+ def get_selected_categories(self):
+ """Returns a list of selected category IDs."""
+ selected = []
+ for category_id, checkbox in self.checkboxes.items():
+ if checkbox.isChecked():
+ selected.append(category_id)
+ return selected
+
+ def get_selected_categories_string(self):
+ """Returns a comma-separated string of selected categories for yt-dlp."""
+ selected = self.get_selected_categories()
+ return ','.join(selected) if selected else ''
diff --git a/src/gui/dialogs/ytsage_dialogs_settings.py b/src/gui/dialogs/ytsage_dialogs_settings.py
new file mode 100644
index 0000000..dfe9e90
--- /dev/null
+++ b/src/gui/dialogs/ytsage_dialogs_settings.py
@@ -0,0 +1,722 @@
+"""
+Settings-related dialogs for YTSage application.
+Contains dialogs for configuring download settings and auto-update preferences.
+"""
+
+import os
+import requests
+from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QLineEdit, QPushButton, QGroupBox, QCheckBox,
+ QRadioButton, QComboBox, QDialogButtonBox,
+ QButtonGroup, QMessageBox, QFileDialog)
+from PySide6.QtCore import Qt, QThread, Signal
+from PySide6.QtGui import QIcon
+from ...core.ytsage_logging import logger
+
+from ...core.ytsage_utils import (get_auto_update_settings, update_auto_update_settings,
+ check_and_update_ytdlp_auto, get_ytdlp_version)
+
+
+class DownloadSettingsDialog(QDialog):
+ def __init__(self, current_path, current_limit, current_unit_index, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Download Settings")
+ self.setMinimumWidth(450)
+ self.setMinimumHeight(400)
+ self.current_path = current_path
+ self.current_limit = current_limit if current_limit is not None else ""
+ self.current_unit_index = current_unit_index
+
+ # Apply main app styling
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QWidget {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QLabel {
+ color: #ffffff;
+ }
+ QGroupBox {
+ color: #ffffff;
+ border: 2px solid #1b2021;
+ border-radius: 4px;
+ margin-top: 10px;
+ padding-top: 10px;
+ font-weight: bold;
+ background-color: #15181b;
+ }
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 10px;
+ padding: 0 5px 0 5px;
+ color: #ffffff;
+ }
+ QLineEdit {
+ padding: 8px;
+ border: 2px solid #1b2021;
+ border-radius: 4px;
+ background-color: #1b2021;
+ color: #ffffff;
+ selection-background-color: #c90000;
+ selection-color: #ffffff;
+ }
+ QPushButton {
+ padding: 8px 15px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ min-height: 20px;
+ }
+ QPushButton:hover {
+ background-color: #a50000;
+ }
+ QPushButton:pressed {
+ background-color: #800000;
+ }
+ QPushButton:disabled {
+ background-color: #666666;
+ color: #999999;
+ }
+ QCheckBox {
+ spacing: 5px;
+ color: #ffffff;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #15181b;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #c90000;
+ background: #c90000;
+ }
+ QRadioButton {
+ spacing: 5px;
+ color: #ffffff;
+ }
+ QRadioButton::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ }
+ QRadioButton::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #15181b;
+ }
+ QRadioButton::indicator:checked {
+ border: 2px solid #c90000;
+ background: #c90000;
+ }
+ QComboBox {
+ padding: 5px;
+ border: 2px solid #1b2021;
+ border-radius: 4px;
+ background-color: #1b2021;
+ color: #ffffff;
+ min-height: 20px;
+ }
+ QComboBox::drop-down {
+ border: none;
+ width: 20px;
+ }
+ QComboBox QAbstractItemView {
+ border: 2px solid #1b2021;
+ border-radius: 4px;
+ background-color: #15181b;
+ color: #ffffff;
+ selection-background-color: #c90000;
+ selection-color: #ffffff;
+ }
+ """)
+
+ layout = QVBoxLayout(self)
+
+ # --- Download Path Section ---
+ path_group_box = QGroupBox("Download Path")
+ path_layout = QVBoxLayout()
+
+ self.path_display = QLabel(self.current_path)
+ self.path_display.setWordWrap(True)
+ self.path_display.setStyleSheet("QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }")
+ path_layout.addWidget(self.path_display)
+
+ browse_button = QPushButton("Browse...")
+ browse_button.clicked.connect(self.browse_new_path)
+ path_layout.addWidget(browse_button)
+
+ path_group_box.setLayout(path_layout)
+ layout.addWidget(path_group_box)
+
+ # --- Speed Limit Section ---
+ speed_group_box = QGroupBox("Speed Limit")
+ speed_layout = QHBoxLayout()
+
+ self.speed_limit_input = QLineEdit(str(self.current_limit))
+ self.speed_limit_input.setPlaceholderText("None")
+ speed_layout.addWidget(self.speed_limit_input)
+
+ self.speed_limit_unit = QComboBox()
+ self.speed_limit_unit.addItems(["KB/s", "MB/s"])
+ self.speed_limit_unit.setCurrentIndex(self.current_unit_index)
+ speed_layout.addWidget(self.speed_limit_unit)
+
+ speed_group_box.setLayout(speed_layout)
+ layout.addWidget(speed_group_box)
+
+ # --- Auto-Update yt-dlp Section ---
+ auto_update_group_box = QGroupBox("Auto-Update yt-dlp")
+ auto_update_layout = QVBoxLayout()
+
+ # Load current auto-update settings
+ auto_settings = get_auto_update_settings()
+
+ # Enable/Disable auto-update checkbox
+ self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates")
+ self.auto_update_enabled.setChecked(auto_settings['enabled'])
+ auto_update_layout.addWidget(self.auto_update_enabled)
+
+ # Frequency options
+ frequency_label = QLabel("Update frequency:")
+ frequency_label.setStyleSheet("color: #ffffff; margin-top: 10px;")
+ auto_update_layout.addWidget(frequency_label)
+
+ self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
+ self.daily_radio = QRadioButton("Check daily")
+ self.weekly_radio = QRadioButton("Check weekly")
+
+ # Set current selection based on saved settings
+ current_frequency = auto_settings['frequency']
+ if current_frequency == 'startup':
+ self.startup_radio.setChecked(True)
+ elif current_frequency == 'daily':
+ self.daily_radio.setChecked(True)
+ else: # weekly
+ self.weekly_radio.setChecked(True)
+
+ auto_update_layout.addWidget(self.startup_radio)
+ auto_update_layout.addWidget(self.daily_radio)
+ auto_update_layout.addWidget(self.weekly_radio)
+
+ # Test update button
+ test_update_layout = QHBoxLayout()
+ test_update_button = QPushButton("Check for Updates Now")
+ test_update_button.clicked.connect(self.test_update_check)
+ test_update_layout.addWidget(test_update_button)
+ test_update_layout.addStretch()
+ auto_update_layout.addLayout(test_update_layout)
+
+ auto_update_group_box.setLayout(auto_update_layout)
+ layout.addWidget(auto_update_group_box)
+
+ # Dialog buttons (OK/Cancel)
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ def browse_new_path(self):
+ new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path)
+ if new_path:
+ self.current_path = new_path
+ self.path_display.setText(self.current_path)
+
+ def get_selected_path(self):
+ """Returns the confirmed path after the dialog is accepted."""
+ return self.current_path
+
+ def get_selected_speed_limit(self):
+ """Returns the entered speed limit value (as string or None)."""
+ limit_str = self.speed_limit_input.text().strip()
+ if not limit_str:
+ return None
+ try:
+ float(limit_str) # Check if convertible to float
+ return limit_str
+ except ValueError:
+ logger.info("Invalid speed limit input in dialog")
+ return None
+
+ def get_selected_unit_index(self):
+ """Returns the index of the selected speed limit unit."""
+ return self.speed_limit_unit.currentIndex()
+
+ def _create_styled_message_box(self, icon, title, text):
+ """Create a styled QMessageBox that matches the app theme."""
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(icon)
+ msg_box.setWindowTitle(title)
+ msg_box.setText(text)
+ msg_box.setWindowIcon(self.windowIcon())
+ msg_box.setStyleSheet("""
+ QMessageBox {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QMessageBox QLabel {
+ color: #ffffff;
+ }
+ QMessageBox QPushButton {
+ padding: 8px 15px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ min-width: 80px;
+ }
+ QMessageBox QPushButton:hover {
+ background-color: #a50000;
+ }
+ QMessageBox QPushButton:pressed {
+ background-color: #800000;
+ }
+ """)
+ return msg_box
+
+ def test_update_check(self):
+ """Test the update check functionality."""
+ try:
+ # Get current version
+ current_version = get_ytdlp_version()
+ if "Error" in current_version:
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Warning,
+ "Update Check",
+ "Could not determine current yt-dlp version."
+ )
+ msg_box.exec()
+ return
+
+ # Get latest version from PyPI
+ response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
+ response.raise_for_status()
+ latest_version = response.json()["info"]["version"]
+
+ # Clean up version strings
+ current_version = current_version.replace('_', '.')
+ latest_version = latest_version.replace('_', '.')
+
+ from packaging import version as version_parser
+ if version_parser.parse(latest_version) > version_parser.parse(current_version):
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Information,
+ "Update Check",
+ f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update."
+ )
+ msg_box.exec()
+ else:
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Information,
+ "Update Check",
+ f"yt-dlp is up to date!\n\nCurrent version: {current_version}"
+ )
+ msg_box.exec()
+ except Exception as e:
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Warning,
+ "Update Check",
+ f"Error checking for updates: {str(e)}"
+ )
+ msg_box.exec()
+
+ def get_auto_update_settings(self):
+ """Returns the auto-update settings from the dialog."""
+ enabled = self.auto_update_enabled.isChecked()
+
+ if self.startup_radio.isChecked():
+ frequency = 'startup'
+ elif self.daily_radio.isChecked():
+ frequency = 'daily'
+ else: # weekly_radio is checked
+ frequency = 'weekly'
+
+ return enabled, frequency
+
+ def accept(self):
+ """Override accept to save auto-update settings."""
+ try:
+ # Save auto-update settings
+ enabled, frequency = self.get_auto_update_settings()
+
+ if update_auto_update_settings(enabled, frequency):
+ QMessageBox.information(self, "Settings Saved",
+ "Auto-update settings have been saved successfully!")
+ else:
+ QMessageBox.warning(self, "Error",
+ "Failed to save auto-update settings.")
+ except Exception as e:
+ QMessageBox.critical(self, "Error",
+ f"Error saving auto-update settings: {str(e)}")
+
+ # Call the parent accept method to close the dialog
+ super().accept()
+
+
+class AutoUpdateSettingsDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Auto-Update Settings")
+ self.setMinimumWidth(400)
+ self.setMinimumHeight(300)
+
+ # Set the window icon to match the main app
+ if parent:
+ self.setWindowIcon(parent.windowIcon())
+
+ self.init_ui()
+ self.load_current_settings()
+ self.apply_styling()
+
+ def init_ui(self):
+ layout = QVBoxLayout(self)
+
+ # Title
+ title_label = QLabel("
🔄 Auto-Update Settings
")
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(title_label)
+
+ # Description
+ desc_label = QLabel("Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.")
+ desc_label.setWordWrap(True)
+ desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
+ layout.addWidget(desc_label)
+
+ # Enable/Disable auto-update
+ self.enable_checkbox = QCheckBox("Enable automatic yt-dlp updates")
+ self.enable_checkbox.setChecked(True) # Default enabled
+ self.enable_checkbox.toggled.connect(self.on_enable_toggled)
+ layout.addWidget(self.enable_checkbox)
+
+ # Frequency options
+ frequency_group = QGroupBox("Update Frequency")
+ frequency_layout = QVBoxLayout()
+
+ self.frequency_group = QButtonGroup(self)
+
+ self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
+ self.daily_radio = QRadioButton("Check daily")
+ self.weekly_radio = QRadioButton("Check weekly")
+
+ self.daily_radio.setChecked(True) # Default to daily
+
+ self.frequency_group.addButton(self.startup_radio, 0)
+ self.frequency_group.addButton(self.daily_radio, 1)
+ self.frequency_group.addButton(self.weekly_radio, 2)
+
+ frequency_layout.addWidget(self.startup_radio)
+ frequency_layout.addWidget(self.daily_radio)
+ frequency_layout.addWidget(self.weekly_radio)
+ frequency_group.setLayout(frequency_layout)
+
+ layout.addWidget(frequency_group)
+
+ # Current status
+ status_group = QGroupBox("Current Status")
+ status_layout = QVBoxLayout()
+
+ self.current_version_label = QLabel("Current yt-dlp version: Checking...")
+ self.last_check_label = QLabel("Last update check: Never")
+ self.next_check_label = QLabel("Next check: Based on settings")
+
+ status_layout.addWidget(self.current_version_label)
+ status_layout.addWidget(self.last_check_label)
+ status_layout.addWidget(self.next_check_label)
+ status_group.setLayout(status_layout)
+
+ layout.addWidget(status_group)
+
+ # Manual check button
+ self.manual_check_btn = QPushButton("🔍 Check for Updates Now")
+ self.manual_check_btn.clicked.connect(self.manual_check)
+ layout.addWidget(self.manual_check_btn)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+
+ self.save_btn = QPushButton("Save Settings")
+ self.save_btn.clicked.connect(self.save_settings)
+
+ self.cancel_btn = QPushButton("Cancel")
+ self.cancel_btn.clicked.connect(self.reject)
+
+ button_layout.addWidget(self.save_btn)
+ button_layout.addWidget(self.cancel_btn)
+
+ layout.addLayout(button_layout)
+
+ def apply_styling(self):
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QLabel {
+ color: #ffffff;
+ }
+ QGroupBox {
+ color: #ffffff;
+ border: 2px solid #1b2021;
+ border-radius: 4px;
+ margin-top: 10px;
+ padding-top: 10px;
+ font-weight: bold;
+ background-color: #15181b;
+ }
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 10px;
+ padding: 0 5px 0 5px;
+ color: #ffffff;
+ }
+ QCheckBox, QRadioButton {
+ color: #ffffff;
+ spacing: 5px;
+ margin: 5px;
+ }
+ QCheckBox::indicator, QRadioButton::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ }
+ QCheckBox::indicator:unchecked, QRadioButton::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #15181b;
+ }
+ QCheckBox::indicator:checked, QRadioButton::indicator:checked {
+ border: 2px solid #c90000;
+ background: #c90000;
+ }
+ QPushButton {
+ padding: 8px 15px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ margin: 5px;
+ min-width: 100px;
+ min-height: 20px;
+ }
+ QPushButton:hover {
+ background-color: #a50000;
+ }
+ QPushButton:pressed {
+ background-color: #800000;
+ }
+ QPushButton:disabled {
+ background-color: #666666;
+ color: #999999;
+ }
+ """)
+
+ def load_current_settings(self):
+ """Load current auto-update settings from config."""
+ try:
+ import time
+ from datetime import datetime
+
+ settings = get_auto_update_settings()
+
+ # Set checkbox
+ self.enable_checkbox.setChecked(settings['enabled'])
+
+ # Set frequency
+ frequency = settings['frequency']
+ if frequency == 'startup':
+ self.startup_radio.setChecked(True)
+ elif frequency == 'weekly':
+ self.weekly_radio.setChecked(True)
+ else: # daily
+ self.daily_radio.setChecked(True)
+
+ # Update status labels
+ current_version = get_ytdlp_version()
+ self.current_version_label.setText(f"Current yt-dlp version: {current_version}")
+
+ last_check = settings['last_check']
+ if last_check > 0:
+ last_check_time = datetime.fromtimestamp(last_check).strftime("%Y-%m-%d %H:%M:%S")
+ self.last_check_label.setText(f"Last update check: {last_check_time}")
+ else:
+ self.last_check_label.setText("Last update check: Never")
+
+ # Calculate next check time
+ self.update_next_check_label()
+
+ # Update UI state
+ self.on_enable_toggled(settings['enabled'])
+
+ except Exception as e:
+ logger.error(f"Error loading auto-update settings: {e}")
+
+ def update_next_check_label(self):
+ """Update the next check label based on current settings."""
+ try:
+ if not self.enable_checkbox.isChecked():
+ self.next_check_label.setText("Next check: Disabled")
+ return
+
+ import time
+ from datetime import datetime, timedelta
+
+ settings = get_auto_update_settings()
+ last_check = settings['last_check']
+ frequency = self.get_selected_frequency()
+
+ if last_check == 0:
+ self.next_check_label.setText("Next check: On next startup")
+ return
+
+ next_check_time = last_check
+ if frequency == 'startup':
+ next_check_time += 3600 # 1 hour
+ elif frequency == 'daily':
+ next_check_time += 86400 # 24 hours
+ elif frequency == 'weekly':
+ next_check_time += 604800 # 7 days
+
+ current_time = time.time()
+ if next_check_time <= current_time:
+ self.next_check_label.setText("Next check: Now (overdue)")
+ else:
+ next_check_datetime = datetime.fromtimestamp(next_check_time)
+ self.next_check_label.setText(f"Next check: {next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
+
+ except Exception as e:
+ self.next_check_label.setText("Next check: Error calculating")
+ logger.error(f"Error calculating next check time: {e}")
+
+ def on_enable_toggled(self, enabled):
+ """Handle enable/disable checkbox toggle."""
+ # Enable/disable frequency options
+ for i in range(self.frequency_group.buttons().__len__()):
+ self.frequency_group.button(i).setEnabled(enabled)
+
+ self.update_next_check_label()
+
+ def get_selected_frequency(self):
+ """Get the selected frequency setting."""
+ if self.startup_radio.isChecked():
+ return 'startup'
+ elif self.weekly_radio.isChecked():
+ return 'weekly'
+ else:
+ return 'daily'
+
+ def manual_check(self):
+ """Perform a manual update check."""
+ self.manual_check_btn.setEnabled(False)
+ self.manual_check_btn.setText("🔄 Checking...")
+
+ # Force an immediate update check
+ def check_in_thread():
+ try:
+ result = check_and_update_ytdlp_auto()
+
+ # Update UI in main thread
+ from PySide6.QtCore import QTimer
+ QTimer.singleShot(0, lambda: self.manual_check_finished(result))
+ except Exception as e:
+ logger.error(f"Error during manual check: {e}")
+ QTimer.singleShot(0, lambda: self.manual_check_finished(False))
+
+ # Run in separate thread to avoid blocking UI
+ import threading
+ threading.Thread(target=check_in_thread, daemon=True).start()
+
+ def _create_styled_message_box(self, icon, title, text):
+ """Create a styled QMessageBox that matches the app theme."""
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(icon)
+ msg_box.setWindowTitle(title)
+ msg_box.setText(text)
+ msg_box.setWindowIcon(self.windowIcon())
+ msg_box.setStyleSheet("""
+ QMessageBox {
+ background-color: #15181b;
+ color: #ffffff;
+ }
+ QMessageBox QLabel {
+ color: #ffffff;
+ }
+ QMessageBox QPushButton {
+ padding: 8px 15px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ min-width: 80px;
+ }
+ QMessageBox QPushButton:hover {
+ background-color: #a50000;
+ }
+ QMessageBox QPushButton:pressed {
+ background-color: #800000;
+ }
+ """)
+ return msg_box
+
+ def manual_check_finished(self, success):
+ """Handle completion of manual update check."""
+ self.manual_check_btn.setEnabled(True)
+ self.manual_check_btn.setText("🔍 Check for Updates Now")
+
+ if success:
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Information,
+ "Update Check",
+ "✅ Update check completed successfully!\nCheck the console for details."
+ )
+ msg_box.exec()
+ else:
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Warning,
+ "Update Check",
+ "❌ Update check failed.\nCheck the console for error details."
+ )
+ msg_box.exec()
+
+ # Refresh the current settings display
+ self.load_current_settings()
+
+ def save_settings(self):
+ """Save the auto-update settings."""
+ try:
+ enabled = self.enable_checkbox.isChecked()
+ frequency = self.get_selected_frequency()
+
+ if update_auto_update_settings(enabled, frequency):
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Information,
+ "Settings Saved",
+ "✅ Auto-update settings have been saved successfully!"
+ )
+ msg_box.exec()
+ self.accept()
+ else:
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Warning,
+ "Error",
+ "❌ Failed to save auto-update settings.\nPlease try again."
+ )
+ msg_box.exec()
+ except Exception as e:
+ logger.error(f"Error saving auto-update settings: {e}")
+ msg_box = self._create_styled_message_box(
+ QMessageBox.Critical,
+ "Error",
+ f"❌ Error saving settings: {str(e)}"
+ )
+ msg_box.exec()
diff --git a/src/gui/dialogs/ytsage_dialogs_update.py b/src/gui/dialogs/ytsage_dialogs_update.py
new file mode 100644
index 0000000..d02da0c
--- /dev/null
+++ b/src/gui/dialogs/ytsage_dialogs_update.py
@@ -0,0 +1,705 @@
+"""
+Update-related dialogs and threads for YTSage application.
+Contains dialogs and background threads for checking and performing yt-dlp updates.
+"""
+
+import sys
+import os
+import requests
+import subprocess
+import time
+from packaging import version
+from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QPushButton, QProgressBar, QMessageBox)
+from PySide6.QtCore import Qt, QThread, QTimer, Signal
+
+from ...core.ytsage_yt_dlp import get_yt_dlp_path
+from ...core.ytsage_utils import get_ytdlp_version, load_config, save_config
+from ...core.ytsage_logging import logger
+
+try:
+ import yt_dlp
+ YT_DLP_AVAILABLE = True
+except ImportError:
+ YT_DLP_AVAILABLE = False
+
+
+class VersionCheckThread(QThread):
+ finished = Signal(str, str, str) # current_version, latest_version, error_message
+
+ def run(self):
+ current_version = ""
+ latest_version = ""
+ error_message = ""
+
+ try:
+ # Get the yt-dlp executable path
+ yt_dlp_path = get_yt_dlp_path()
+
+ # Get current version with timeout
+ try:
+ result = subprocess.run([yt_dlp_path, '--version'],
+ capture_output=True,
+ text=True,
+ timeout=30, # 30 second timeout
+ startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE),
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
+ if result.returncode == 0:
+ current_version = result.stdout.strip()
+ else: # Try fallback if command failed
+ if YT_DLP_AVAILABLE:
+ current_version = yt_dlp.version.__version__
+ else:
+ error_message = "yt-dlp not available."
+ self.finished.emit(current_version, latest_version, error_message)
+ return
+ except subprocess.TimeoutExpired:
+ # Try fallback if timeout
+ if YT_DLP_AVAILABLE:
+ current_version = yt_dlp.version.__version__
+ else:
+ error_message = "yt-dlp version check timed out and package not found."
+ self.finished.emit(current_version, latest_version, error_message)
+ return
+ except Exception:
+ # Fallback to importing yt_dlp package directly if subprocess fails
+ if YT_DLP_AVAILABLE:
+ current_version = yt_dlp.version.__version__
+ else:
+ error_message = "yt-dlp not found or accessible."
+ self.finished.emit(current_version, latest_version, error_message)
+ return
+
+ # Get latest version from PyPI
+ response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
+ response.raise_for_status()
+ latest_version = response.json()["info"]["version"]
+
+ # Clean up version strings
+ current_version = current_version.replace('_', '.')
+ latest_version = latest_version.replace('_', '.')
+
+ except requests.RequestException as e:
+ error_message = f"Network error checking PyPI: {e}"
+ except Exception as e:
+ error_message = f"Error checking version: {e}"
+
+ self.finished.emit(current_version, latest_version, error_message)
+
+
+class UpdateThread(QThread):
+ update_status = Signal(str) # For status messages
+ update_progress = Signal(int) # For progress percentage (0-100)
+ update_finished = Signal(bool, str) # success (bool), message/error (str)
+
+ def run(self):
+ error_message = ""
+ success = False
+ try:
+ self.update_status.emit("🔍 Checking current installation...")
+ self.update_progress.emit(10)
+
+ # Get the yt-dlp path
+ try:
+ yt_dlp_path = get_yt_dlp_path()
+ self.update_status.emit(f"📍 Found yt-dlp at: {os.path.basename(yt_dlp_path)}")
+ except Exception as e:
+ self.update_status.emit(f"❌ Error getting yt-dlp path: {e}")
+ self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}")
+ return
+
+ # Create startupinfo to hide console on Windows
+ startupinfo = None
+ if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = 0 # SW_HIDE
+
+ self.update_progress.emit(20)
+
+ # Check if we're using an app-managed binary or system installation
+ app_managed_dirs = [
+ os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
+ os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
+ os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
+ ]
+
+ is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
+
+ if is_app_managed:
+ self.update_status.emit("📦 Updating app-managed yt-dlp binary...")
+ success = self._update_binary(yt_dlp_path)
+ else:
+ self.update_status.emit("🐍 Updating system yt-dlp via pip...")
+ success = self._update_via_pip(startupinfo)
+
+ if success:
+ self.update_progress.emit(100)
+ error_message = "✅ yt-dlp has been successfully updated!"
+ else:
+ error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection."
+
+ except requests.RequestException as e:
+ error_message = f"❌ Network error during update: {str(e)}"
+ self.update_status.emit(error_message)
+ success = False
+ except Exception as e:
+ error_message = f"❌ Update failed: {str(e)}"
+ self.update_status.emit(error_message)
+ success = False
+
+ self.update_finished.emit(success, error_message)
+
+ def _update_binary(self, yt_dlp_path):
+ """Update yt-dlp binary directly from GitHub releases."""
+ try:
+ self.update_status.emit("🌐 Determining download URL...")
+ self.update_progress.emit(30)
+
+ # Determine the URL based on OS
+ if sys.platform == 'win32':
+ url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
+ elif sys.platform == 'darwin':
+ url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
+ else:
+ url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
+
+ self.update_status.emit("⬇️ Downloading latest yt-dlp binary...")
+ self.update_progress.emit(40)
+
+ # Download with progress tracking and timeout
+ response = requests.get(url, stream=True, timeout=30)
+ if response.status_code != 200:
+ self.update_status.emit(f"❌ Download failed: HTTP {response.status_code}")
+ return False
+
+ total_size = int(response.headers.get('content-length', 0))
+ temp_file = f"{yt_dlp_path}.new"
+ downloaded = 0
+
+ self.update_status.emit("💾 Downloading and saving binary...")
+
+ with open(temp_file, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if chunk:
+ f.write(chunk)
+ downloaded += len(chunk)
+
+ # Update progress (40-80% for download)
+ if total_size > 0:
+ progress = 40 + int((downloaded / total_size) * 40)
+ self.update_progress.emit(progress)
+
+ self.update_status.emit("🔧 Installing updated binary...")
+ self.update_progress.emit(85)
+
+ # Make executable on Unix systems
+ if sys.platform != 'win32':
+ os.chmod(temp_file, 0o755)
+
+ # Replace the old file with the new one
+ try:
+ # On Windows, we need to remove the old file first
+ if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
+ os.remove(yt_dlp_path)
+
+ os.rename(temp_file, yt_dlp_path)
+ self.update_status.emit("✅ Binary successfully updated!")
+ self.update_progress.emit(95)
+ return True
+
+ except Exception as e:
+ self.update_status.emit(f"❌ Error installing binary: {e}")
+ # Clean up temp file if it exists
+ if os.path.exists(temp_file):
+ try:
+ os.remove(temp_file)
+ except:
+ pass
+ return False
+
+ except requests.RequestException as e:
+ self.update_status.emit(f"❌ Network error: {e}")
+ return False
+ except Exception as e:
+ self.update_status.emit(f"❌ Binary update failed: {e}")
+ return False
+
+ def _update_via_pip(self, startupinfo):
+ """Update yt-dlp via pip."""
+ try:
+ import pkg_resources
+
+ self.update_status.emit("🔍 Checking current pip installation...")
+ self.update_progress.emit(30)
+
+ # Get current version
+ try:
+ current_version = pkg_resources.get_distribution("yt-dlp").version
+ self.update_status.emit(f"📋 Current version: {current_version}")
+ except pkg_resources.DistributionNotFound:
+ self.update_status.emit("⚠️ yt-dlp not found via pip, attempting installation...")
+ current_version = "0.0.0"
+
+ self.update_progress.emit(40)
+
+ # Get the latest version from PyPI
+ self.update_status.emit("🌐 Checking for latest version...")
+ response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
+
+ if response.status_code != 200:
+ self.update_status.emit("❌ Failed to check for updates")
+ return False
+
+ data = response.json()
+ latest_version = data["info"]["version"]
+ self.update_status.emit(f"🆕 Latest version: {latest_version}")
+ self.update_progress.emit(50)
+
+ # Compare versions
+ if version.parse(latest_version) > version.parse(current_version):
+ self.update_status.emit(f"⬆️ Updating from {current_version} to {latest_version}...")
+ self.update_progress.emit(60)
+
+ try:
+ # Run pip update with timeout
+ self.update_status.emit("📦 Running pip install --upgrade...")
+ update_result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=300, # 5 minute timeout for pip install
+ startupinfo=startupinfo
+ )
+
+ self.update_progress.emit(85)
+
+ if update_result.returncode == 0:
+ self.update_status.emit("✅ Pip update completed successfully!")
+ self.update_progress.emit(95)
+ return True
+ else:
+ self.update_status.emit(f"❌ Pip update failed: {update_result.stderr}")
+ return False
+
+ except subprocess.TimeoutExpired:
+ self.update_status.emit("❌ Pip update timed out after 5 minutes")
+ return False
+ except Exception as e:
+ self.update_status.emit(f"❌ Error during pip update: {e}")
+ return False
+ else:
+ self.update_status.emit("✅ yt-dlp is already up to date!")
+ self.update_progress.emit(95)
+ return True
+
+ except Exception as e:
+ self.update_status.emit(f"❌ Pip update failed: {e}")
+ return False
+
+
+class YTDLPUpdateDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Update yt-dlp")
+ self.setMinimumWidth(450)
+ self.setMinimumHeight(200)
+ self._closing = False # Flag to track if dialog is closing
+
+ layout = QVBoxLayout(self)
+
+ # Status label
+ self.status_label = QLabel("Checking for updates...")
+ self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.status_label.setWordWrap(True)
+ self.status_label.setMinimumHeight(60)
+ layout.addWidget(self.status_label)
+
+ # Progress bar
+ self.progress_bar = QProgressBar()
+ self.progress_bar.hide() # Hide initially
+ layout.addWidget(self.progress_bar)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+ self.update_btn = QPushButton("Update")
+ self.update_btn.clicked.connect(self.perform_update)
+ self.update_btn.setEnabled(False)
+
+ self.close_btn = QPushButton("Close")
+ self.close_btn.clicked.connect(self.close)
+
+ button_layout.addWidget(self.update_btn)
+ button_layout.addWidget(self.close_btn)
+ layout.addLayout(button_layout)
+
+ # Style
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #15181b;
+ }
+ QLabel {
+ color: #ffffff;
+ font-size: 12px;
+ padding: 10px;
+ }
+ QPushButton {
+ padding: 8px 15px;
+ background-color: #c90000;
+ border: none;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ min-width: 100px;
+ }
+ QPushButton:disabled {
+ background-color: #666666;
+ }
+ QPushButton:hover {
+ background-color: #a50000;
+ }
+ QProgressBar {
+ border: 2px solid #1d1e22;
+ border-radius: 6px;
+ text-align: center;
+ color: white;
+ background-color: #1d1e22;
+ height: 30px;
+ font-weight: bold;
+ font-size: 12px;
+ }
+ QProgressBar::chunk {
+ background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0,
+ stop: 0 #e60000, stop: 0.5 #ff3333, stop: 1 #c90000);
+ border-radius: 4px;
+ margin: 1px;
+ }
+ """)
+
+ # Start version check in background
+ self.check_version()
+
+ def check_version(self):
+ self.status_label.setText("Checking for updates...")
+ self.update_btn.setEnabled(False)
+ self.version_check_thread = VersionCheckThread()
+ self.version_check_thread.finished.connect(self.on_version_check_finished)
+ self.version_check_thread.start()
+
+ def on_version_check_finished(self, current_version, latest_version, error_message):
+ # Check if dialog is closing to avoid unnecessary updates
+ if hasattr(self, '_closing') and self._closing:
+ return
+
+ if error_message:
+ self.status_label.setText(error_message)
+ self.update_btn.setEnabled(False)
+ return
+
+ if not current_version or not latest_version:
+ self.status_label.setText("Could not determine versions.")
+ self.update_btn.setEnabled(False)
+ return
+
+ try:
+ # Compare versions
+ current_ver = version.parse(current_version)
+ latest_ver = version.parse(latest_version)
+
+ if current_ver < latest_ver:
+ self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}")
+ self.update_btn.setEnabled(True)
+ else:
+ self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
+ self.update_btn.setEnabled(False)
+ except version.InvalidVersion:
+ # If version parsing fails, do a simple string comparison
+ if current_version != latest_version:
+ self.status_label.setText(f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}")
+ self.update_btn.setEnabled(True)
+ else:
+ self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
+ self.update_btn.setEnabled(False)
+ except Exception as e:
+ self.status_label.setText(f"Error comparing versions: {e}")
+ self.update_btn.setEnabled(False)
+
+ def perform_update(self):
+ # Immediate visual feedback
+ self.update_btn.setEnabled(False)
+ self.close_btn.setEnabled(False)
+ self.update_btn.setText("Updating...")
+ self.status_label.setText("🚀 Initializing update process...")
+
+ # Show progress bar immediately
+ self.progress_bar.setRange(0, 100)
+ self.progress_bar.setValue(0)
+ self.progress_bar.show()
+
+ # Start the update thread
+ self._start_update_thread()
+
+ def _start_update_thread(self):
+ """Start the actual update thread."""
+ # Create and start the update thread
+ self.update_thread = UpdateThread()
+ self.update_thread.update_status.connect(self.on_update_status)
+ self.update_thread.update_progress.connect(self.on_update_progress)
+ self.update_thread.update_finished.connect(self.on_update_finished)
+ self.update_thread.start()
+
+ def on_update_status(self, message):
+ """Slot to receive status messages from UpdateThread."""
+ if not (hasattr(self, '_closing') and self._closing):
+ self.status_label.setText(message)
+
+ def on_update_progress(self, progress):
+ """Slot to receive progress updates from UpdateThread."""
+ if not (hasattr(self, '_closing') and self._closing):
+ self.progress_bar.setValue(progress)
+
+ def on_update_finished(self, success, message):
+ """Slot called when the UpdateThread finishes."""
+ # Check if dialog is closing to avoid unnecessary updates
+ if hasattr(self, '_closing') and self._closing:
+ return
+
+ self.progress_bar.setValue(100)
+ self.status_label.setText(message)
+ self.close_btn.setEnabled(True)
+ self.update_btn.setText("Update") # Reset button text
+
+ if success:
+ # Show success briefly then auto-check version
+ QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh
+ else:
+ # Re-enable update button on failure after a short delay
+ QTimer.singleShot(3000, lambda: self.update_btn.setEnabled(True) if not (hasattr(self, '_closing') and self._closing) else None)
+
+ def closeEvent(self, event):
+ """Ensure threads are terminated if the dialog is closed prematurely."""
+ # Set a flag to indicate dialog is closing
+ self._closing = True
+
+ if hasattr(self, 'version_check_thread') and self.version_check_thread.isRunning():
+ self.version_check_thread.quit()
+ if not self.version_check_thread.wait(3000): # Wait up to 3 seconds
+ self.version_check_thread.terminate()
+
+ if hasattr(self, 'update_thread') and self.update_thread.isRunning():
+ self.update_thread.quit()
+ if not self.update_thread.wait(5000): # Wait up to 5 seconds for update to finish
+ self.update_thread.terminate()
+
+ super().closeEvent(event)
+
+
+class AutoUpdateThread(QThread):
+ """Thread for performing automatic background updates without UI feedback."""
+ update_finished = Signal(bool, str) # success (bool), message (str)
+
+ def run(self):
+ """Perform automatic yt-dlp update check and update if needed."""
+ try:
+ logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...")
+
+ # Get current version
+ current_version = get_ytdlp_version()
+ if "Error" in current_version:
+ logger.warning("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update")
+ self.update_finished.emit(False, "Could not determine current yt-dlp version")
+ return
+
+ # Get latest version from PyPI
+ try:
+ response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
+ response.raise_for_status()
+ latest_version = response.json()["info"]["version"]
+
+ # Clean up version strings
+ current_version = current_version.replace('_', '.')
+ latest_version = latest_version.replace('_', '.')
+
+ logger.info(f"AutoUpdateThread: Current yt-dlp version: {current_version}")
+ logger.info(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}")
+
+ # Compare versions
+ if version.parse(latest_version) > version.parse(current_version):
+ logger.info(f"AutoUpdateThread: Auto-updating yt-dlp from {current_version} to {latest_version}...")
+
+ # Perform the update
+ success = self._perform_update()
+
+ if success:
+ logger.info("AutoUpdateThread: Auto-update completed successfully!")
+ # Update the last check timestamp
+ config = load_config()
+ config['last_update_check'] = time.time()
+ save_config(config)
+ self.update_finished.emit(True, f"Successfully updated yt-dlp from {current_version} to {latest_version}")
+ else:
+ logger.warning("AutoUpdateThread: Auto-update failed")
+ self.update_finished.emit(False, "Auto-update failed")
+ else:
+ logger.info("AutoUpdateThread: yt-dlp is already up to date")
+ # Still update the timestamp even if no update was needed
+ config = load_config()
+ config['last_update_check'] = time.time()
+ save_config(config)
+ self.update_finished.emit(True, f"yt-dlp is already up to date (version {current_version})")
+
+ except requests.RequestException as e:
+ logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
+ self.update_finished.emit(False, f"Network error: {e}")
+ except Exception as e:
+ logger.error(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True)
+ self.update_finished.emit(False, f"Update check error: {e}")
+
+ except Exception as e:
+ logger.critical(f"AutoUpdateThread: Critical error in auto-update: {e}", exc_info=True)
+ self.update_finished.emit(False, f"Critical error: {e}")
+
+ def _perform_update(self):
+ """Perform the actual update using similar logic to UpdateThread but without UI feedback."""
+ try:
+ # Get the yt-dlp path
+ yt_dlp_path = get_yt_dlp_path()
+
+ # Check if we're using an app-managed binary or system installation
+ app_managed_dirs = [
+ os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
+ os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
+ os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
+ ]
+
+ is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
+
+ if is_app_managed:
+ logger.info("AutoUpdateThread: Updating app-managed yt-dlp binary...")
+ return self._update_binary(yt_dlp_path)
+ else:
+ logger.info("AutoUpdateThread: Updating system yt-dlp via pip...")
+ return self._update_via_pip()
+
+ except Exception as e:
+ logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True)
+ return False
+
+ def _update_binary(self, yt_dlp_path):
+ """Update yt-dlp binary directly from GitHub releases (silent version)."""
+ try:
+ # Determine the URL based on OS
+ if sys.platform == 'win32':
+ url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
+ elif sys.platform == 'darwin':
+ url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
+ else:
+ url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
+
+ logger.info("AutoUpdateThread: Downloading latest yt-dlp binary...")
+
+ # Download without progress tracking (silent)
+ response = requests.get(url, stream=True)
+ if response.status_code != 200:
+ logger.error(f"AutoUpdateThread: Download failed: HTTP {response.status_code}")
+ return False
+
+ temp_file = f"{yt_dlp_path}.new"
+
+ with open(temp_file, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if chunk:
+ f.write(chunk)
+
+ logger.info("AutoUpdateThread: Installing updated binary...")
+
+ # Make executable on Unix systems
+ if sys.platform != 'win32':
+ os.chmod(temp_file, 0o755)
+
+ # Replace the old file with the new one
+ try:
+ # On Windows, we need to remove the old file first
+ if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
+ os.remove(yt_dlp_path)
+
+ os.rename(temp_file, yt_dlp_path)
+ logger.info("AutoUpdateThread: Binary successfully updated!")
+ return True
+
+ except Exception as e:
+ logger.error(f"AutoUpdateThread: Error installing binary: {e}")
+ # Clean up temp file if it exists
+ if os.path.exists(temp_file):
+ try:
+ os.remove(temp_file)
+ except:
+ pass
+ return False
+
+ except Exception as e:
+ logger.error(f"AutoUpdateThread: Binary update failed: {e}", exc_info=True)
+ return False
+
+ def _update_via_pip(self):
+ """Update yt-dlp via pip (silent version)."""
+ try:
+ import pkg_resources
+
+ logger.info("AutoUpdateThread: Checking current pip installation...")
+
+ # Get current version
+ try:
+ current_version = pkg_resources.get_distribution("yt-dlp").version
+ logger.info(f"AutoUpdateThread: Current version: {current_version}")
+ except pkg_resources.DistributionNotFound:
+ logger.warning("AutoUpdateThread: yt-dlp not found via pip, attempting installation...")
+ current_version = "0.0.0"
+
+ # Get the latest version from PyPI
+ logger.info("AutoUpdateThread: Checking for latest version...")
+ response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
+
+ if response.status_code != 200:
+ logger.error("AutoUpdateThread: Failed to check for updates")
+ return False
+
+ data = response.json()
+ latest_version = data["info"]["version"]
+ logger.info(f"AutoUpdateThread: Latest version: {latest_version}")
+
+ # Compare versions
+ if version.parse(latest_version) > version.parse(current_version):
+ logger.info(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...")
+
+ # Create startupinfo to hide console on Windows
+ startupinfo = None
+ if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = 0 # SW_HIDE
+
+ # Run pip update
+ logger.info("AutoUpdateThread: Running pip install --upgrade...")
+ update_result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
+ capture_output=True,
+ text=True,
+ check=False,
+ startupinfo=startupinfo
+ )
+
+ if update_result.returncode == 0:
+ logger.info("AutoUpdateThread: Pip update completed successfully!")
+ return True
+ else:
+ logger.error(f"AutoUpdateThread: Pip update failed: {update_result.stderr}")
+ return False
+ else:
+ logger.info("AutoUpdateThread: yt-dlp is already up to date!")
+ return True
+
+ except Exception as e:
+ logger.error(f"AutoUpdateThread: Pip update failed: {e}", exc_info=True)
+ return False
diff --git a/src/gui/ytsage_gui_dialogs.py b/src/gui/ytsage_gui_dialogs.py
new file mode 100644
index 0000000..a925021
--- /dev/null
+++ b/src/gui/ytsage_gui_dialogs.py
@@ -0,0 +1,37 @@
+"""
+YTSage GUI Dialogs Module
+
+This module serves as a centralized import point for all dialog classes in YTSage.
+The dialogs have been split into logical modules for better maintainability:
+
+- dialogs.ytsage_dialogs_base: Base utility dialogs (LogWindow, AboutDialog)
+- dialogs.ytsage_dialogs_settings: Settings configuration dialogs
+- dialogs.ytsage_dialogs_update: Update-related dialogs and threads
+- dialogs.ytsage_dialogs_ffmpeg: FFmpeg installation dialogs
+- dialogs.ytsage_dialogs_selection: Subtitle and playlist selection dialogs
+- dialogs.ytsage_dialogs_custom: Custom functionality dialogs
+"""
+
+# Import all dialog classes from the dialogs package
+from .dialogs import *
+
+# For backward compatibility, re-export all dialog classes
+__all__ = [
+ # Base dialogs
+ 'LogWindow', 'AboutDialog',
+
+ # Settings dialogs
+ 'DownloadSettingsDialog', 'AutoUpdateSettingsDialog',
+
+ # Update dialogs and threads
+ 'VersionCheckThread', 'UpdateThread', 'YTDLPUpdateDialog', 'AutoUpdateThread',
+
+ # FFmpeg dialogs
+ 'FFmpegInstallThread', 'FFmpegCheckDialog',
+
+ # Selection dialogs
+ 'SubtitleSelectionDialog', 'PlaylistSelectionDialog', 'SponsorBlockCategoryDialog',
+
+ # Custom functionality dialogs
+ 'CustomCommandDialog', 'CookieLoginDialog', 'CustomOptionsDialog', 'TimeRangeDialog'
+]
diff --git a/ytsage_gui_dialogs.py b/ytsage_gui_dialogs.py
deleted file mode 100644
index 7507a61..0000000
--- a/ytsage_gui_dialogs.py
+++ /dev/null
@@ -1,3011 +0,0 @@
-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, QListWidget,
- QListWidgetItem, QDialogButtonBox, QScrollArea, QGroupBox, QTabWidget, QRadioButton, QMessageBox)
-from PySide6.QtCore import Qt, Signal, QObject, QThread, QProcess, QTimer
-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
-try:
- import yt_dlp
- YT_DLP_AVAILABLE = True
-except ImportError:
- YT_DLP_AVAILABLE = False
- print("Warning: yt-dlp not available at startup, will be downloaded at runtime")
-import time # Add time import for cache timestamps
-from ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
-from ytsage_yt_dlp import get_yt_dlp_path
-
-from ytsage_utils import check_ffmpeg, load_saved_path, save_path, get_ytdlp_version, get_ffmpeg_version, refresh_version_cache, _version_cache # 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: #1d1e22;
- color: #ffffff;
- border: 2px solid #1d1e22;
- 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;
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #1d1e22;
- border-radius: 9px;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #c90000;
- background: #c90000;
- border-radius: 9px;
- }
- """)
- 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: #1d1e22;
- color: #ffffff;
- border: 2px solid #1d1e22;
- border-radius: 4px;
- padding: 8px;
- font-family: Consolas, monospace;
- font-size: 12px;
- }
- """)
- layout.addWidget(self.log_output)
-
- self.setStyleSheet("""
- QDialog {
- background-color: #15181b;
- }
- QPushButton {
- padding: 8px 15px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #a50000;
- }
- """)
-
- def run_custom_command(self):
- url = self.parent.url_input.text().strip()
- if not url:
- self.log_output.append("Error: No URL provided")
- return
-
- command = self.command_input.toPlainText().strip()
- path = self.parent.path_input.text().strip()
-
- self.log_output.clear()
- self.log_output.append(f"Running command with URL: {url}")
- self.run_btn.setEnabled(False)
-
- # Start command in thread
- import threading
- threading.Thread(target=self._run_command_thread,
- args=(command, url, path),
- daemon=True).start()
-
- def _run_command_thread(self, command, url, path):
- try:
- class CommandLogger:
- def debug(self, msg):
- self.dialog.log_output.append(msg)
- def warning(self, msg):
- self.dialog.log_output.append(f"Warning: {msg}")
- def error(self, msg):
- self.dialog.log_output.append(f"Error: {msg}")
- def __init__(self, dialog):
- self.dialog = dialog
-
- # Split command into arguments
- args = command.split()
-
- # Base options
- ydl_opts = {
- 'logger': CommandLogger(self),
- 'paths': {'home': path},
- 'debug_printout': True,
- 'postprocessors': []
- }
-
- # Add SponsorBlock options if enabled
- if self.sponsorblock_checkbox.isChecked():
- ydl_opts['postprocessors'].extend([{
- 'key': 'SponsorBlock',
- 'categories': ['sponsor', 'selfpromo', 'interaction'],
- 'api': 'https://sponsor.ajay.app'
- }, {
- 'key': 'ModifyChapters',
- 'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'],
- 'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l',
- 'force_keyframes': True
- }])
-
- # Add custom arguments
- for i in range(0, len(args), 2):
- if i + 1 < len(args):
- key = args[i].lstrip('-').replace('-', '_')
- value = args[i + 1]
- try:
- # Try to convert to appropriate type
- if value.lower() in ('true', 'false'):
- value = value.lower() == 'true'
- elif value.isdigit():
- value = int(value)
- ydl_opts[key] = value
- except:
- ydl_opts[key] = value
-
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
- ydl.download([url])
-
- self.log_output.append("Command completed successfully")
-
- except Exception as e:
- self.log_output.append(f"Error: {str(e)}")
- finally:
- self.run_btn.setEnabled(True)
-
-class FFmpegInstallThread(QThread):
- finished = Signal(bool)
- progress = Signal(str)
-
- def run(self):
- # Redirect stdout to capture progress messages
- import sys
- from io import StringIO
- import contextlib
-
- output = StringIO()
- with contextlib.redirect_stdout(output):
- success = auto_install_ffmpeg()
-
- # Process captured output and emit progress signals
- for line in output.getvalue().splitlines():
- self.progress.emit(line)
-
- self.finished.emit(success)
-
-class FFmpegCheckDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle('Installing FFmpeg')
- self.setMinimumWidth(450)
- self.setMinimumHeight(250)
-
- # Set the window icon to match the main app
- self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
-
- layout = QVBoxLayout(self)
- layout.setSpacing(15)
-
- # Header with icon
- header_layout = QHBoxLayout()
- icon_label = QLabel()
- icon_label.setPixmap(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown).pixmap(32, 32))
- header_layout.addWidget(icon_label)
-
- header_text = QLabel("FFmpeg Installation")
- header_text.setStyleSheet("font-size: 16px; font-weight: bold;")
- header_layout.addWidget(header_text)
- header_layout.addStretch()
- layout.addLayout(header_layout)
-
- # Message
- self.message_label = QLabel(
- "🎥 YTSage needs FFmpeg to process videos.\n"
- "Let's set it up for you automatically!"
- )
- self.message_label.setWordWrap(True)
- self.message_label.setStyleSheet("font-size: 13px;")
- layout.addWidget(self.message_label)
-
- # Progress label with cool emojis
- self.progress_label = QLabel("")
- self.progress_label.setWordWrap(True)
- self.progress_label.setStyleSheet("""
- QLabel {
- background-color: #1e1e1e;
- border-radius: 5px;
- padding: 10px;
- font-family: 'Consolas', monospace;
- font-size: 12px;
- }
- """)
- self.progress_label.hide()
- layout.addWidget(self.progress_label)
-
- # Buttons container
- button_layout = QHBoxLayout()
-
- # Install button
- self.install_btn = QPushButton("Install FFmpeg")
- self.install_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
- self.install_btn.clicked.connect(self.start_installation)
- button_layout.addWidget(self.install_btn)
-
- # Manual install button
- self.manual_btn = QPushButton("Manual Guide")
- self.manual_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogHelpButton))
- self.manual_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide'))
- button_layout.addWidget(self.manual_btn)
-
- # Close button
- self.close_btn = QPushButton("Close")
- self.close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton))
- self.close_btn.clicked.connect(self.close)
- button_layout.addWidget(self.close_btn)
-
- layout.addLayout(button_layout)
-
- # Style the dialog
- self.setStyleSheet("""
- QDialog {
- background-color: #2b2b2b;
- }
- QLabel {
- color: #ffffff;
- }
- QPushButton {
- padding: 8px 15px;
- background-color: #3d3d3d;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- margin: 5px;
- min-width: 100px;
- }
- QPushButton:hover {
- background-color: #4d4d4d;
- }
- QPushButton:disabled {
- background-color: #2d2d2d;
- color: #666666;
- }
- """)
-
- # Initialize installation thread
- self.install_thread = None
-
- def start_installation(self):
- self.install_btn.setEnabled(False)
- self.manual_btn.setEnabled(False)
- self.close_btn.setEnabled(False)
-
- # Check if FFmpeg is already installed
- if check_ffmpeg_installed():
- self.message_label.setText("🎉 FFmpeg is already installed!")
- self.progress_label.setText("✅ You can close this dialog and continue using YTSage.")
- self.install_btn.hide()
- self.manual_btn.hide()
- self.close_btn.setEnabled(True)
- return
-
- self.message_label.setText("🚀 Installing FFmpeg... Hold tight!")
- self.progress_label.show()
-
- self.install_thread = FFmpegInstallThread()
- self.install_thread.finished.connect(self.installation_finished)
- self.install_thread.progress.connect(self.update_progress)
- self.install_thread.start()
-
- def update_progress(self, message):
- self.progress_label.setText(message)
-
- def installation_finished(self, success):
- if success:
- self.message_label.setText("🎉 FFmpeg has been installed successfully!")
- self.progress_label.setText("✅ You're all set! You can now close this dialog and continue using YTSage.")
- self.install_btn.hide()
- self.manual_btn.hide()
- else:
- self.message_label.setText("❌ Oops! FFmpeg installation encountered an issue.")
- self.progress_label.setText("💡 Try using the manual installation guide instead.")
- self.install_btn.setEnabled(True)
- self.manual_btn.setEnabled(True)
-
- self.close_btn.setEnabled(True)
-
-class VersionCheckThread(QThread):
- finished = Signal(str, str, str) # current_version, latest_version, error_message
-
- def run(self):
- current_version = ""
- latest_version = ""
- error_message = ""
-
- try:
- # Get the yt-dlp executable path using the new module
- yt_dlp_path = get_yt_dlp_path()
-
- # Get current version with timeout
- try:
- result = subprocess.run([yt_dlp_path, '--version'],
- capture_output=True,
- text=True,
- timeout=30, # 30 second timeout
- startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE), # Hide console window on Windows
- creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) # Hide console window on Windows
- if result.returncode == 0:
- current_version = result.stdout.strip()
- else: # Try fallback if command failed
- if YT_DLP_AVAILABLE:
- current_version = yt_dlp.version.__version__
- else:
- error_message = "yt-dlp not available."
- self.finished.emit(current_version, latest_version, error_message)
- return
- except subprocess.TimeoutExpired:
- # Try fallback if timeout
- if YT_DLP_AVAILABLE:
- current_version = yt_dlp.version.__version__
- else:
- error_message = "yt-dlp version check timed out and package not found."
- self.finished.emit(current_version, latest_version, error_message)
- return
- except Exception:
- # Fallback to importing yt_dlp package directly if subprocess fails
- if YT_DLP_AVAILABLE:
- current_version = yt_dlp.version.__version__
- else:
- error_message = "yt-dlp not found or accessible."
- self.finished.emit(current_version, latest_version, error_message)
- return
-
-
- # Get latest version from PyPI
- response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) # Add timeout
- response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
- latest_version = response.json()["info"]["version"]
-
- # Clean up version strings
- current_version = current_version.replace('_', '.')
- latest_version = latest_version.replace('_', '.')
-
- except requests.RequestException as e:
- error_message = f"Network error checking PyPI: {e}"
- except Exception as e:
- error_message = f"Error checking version: {e}"
-
- self.finished.emit(current_version, latest_version, error_message)
-
-
-class UpdateThread(QThread):
- update_status = Signal(str) # For status messages
- update_progress = Signal(int) # For progress percentage (0-100)
- update_finished = Signal(bool, str) # success (bool), message/error (str)
-
- def run(self):
- error_message = ""
- success = False
- try:
- import requests
- import subprocess
- import pkg_resources
- from packaging import version
- import os
- import sys
- import shutil
- from ytsage_yt_dlp import get_yt_dlp_path
-
- self.update_status.emit("🔍 Checking current installation...")
- self.update_progress.emit(10)
-
- # Get the yt-dlp path
- try:
- yt_dlp_path = get_yt_dlp_path()
- self.update_status.emit(f"📍 Found yt-dlp at: {os.path.basename(yt_dlp_path)}")
- except Exception as e:
- self.update_status.emit(f"❌ Error getting yt-dlp path: {e}")
- self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}")
- return
-
- # Create startupinfo to hide console on Windows
- startupinfo = None
- if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
- startupinfo = subprocess.STARTUPINFO()
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
- startupinfo.wShowWindow = 0 # SW_HIDE
-
- self.update_progress.emit(20)
-
- # Check if we're using an app-managed binary or system installation
- app_managed_dirs = [
- os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
- os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
- os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
- ]
-
- is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
-
- if is_app_managed:
- self.update_status.emit("📦 Updating app-managed yt-dlp binary...")
- success = self._update_binary(yt_dlp_path)
- else:
- self.update_status.emit("🐍 Updating system yt-dlp via pip...")
- success = self._update_via_pip(startupinfo)
-
- if success:
- self.update_progress.emit(100)
- error_message = "✅ yt-dlp has been successfully updated!"
- else:
- error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection."
-
- except requests.RequestException as e:
- error_message = f"❌ Network error during update: {str(e)}"
- self.update_status.emit(error_message)
- success = False
- except Exception as e:
- error_message = f"❌ Update failed: {str(e)}"
- self.update_status.emit(error_message)
- success = False
-
- self.update_finished.emit(success, error_message)
-
- def _update_binary(self, yt_dlp_path):
- """Update yt-dlp binary directly from GitHub releases."""
- try:
- self.update_status.emit("🌐 Determining download URL...")
- self.update_progress.emit(30)
-
- # Determine the URL based on OS
- if sys.platform == 'win32':
- url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
- elif sys.platform == 'darwin':
- url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
- else:
- url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
-
- self.update_status.emit("⬇️ Downloading latest yt-dlp binary...")
- self.update_progress.emit(40)
-
- # Download with progress tracking and timeout
- response = requests.get(url, stream=True, timeout=30)
- if response.status_code != 200:
- self.update_status.emit(f"❌ Download failed: HTTP {response.status_code}")
- return False
-
- total_size = int(response.headers.get('content-length', 0))
- temp_file = f"{yt_dlp_path}.new"
- downloaded = 0
-
- self.update_status.emit("💾 Downloading and saving binary...")
-
- with open(temp_file, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if chunk:
- f.write(chunk)
- downloaded += len(chunk)
-
- # Update progress (40-80% for download)
- if total_size > 0:
- progress = 40 + int((downloaded / total_size) * 40)
- self.update_progress.emit(progress)
-
- self.update_status.emit("🔧 Installing updated binary...")
- self.update_progress.emit(85)
-
- # Make executable on Unix systems
- if sys.platform != 'win32':
- os.chmod(temp_file, 0o755)
-
- # Replace the old file with the new one
- try:
- # On Windows, we need to remove the old file first
- if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
- os.remove(yt_dlp_path)
-
- os.rename(temp_file, yt_dlp_path)
- self.update_status.emit("✅ Binary successfully updated!")
- self.update_progress.emit(95)
- return True
-
- except Exception as e:
- self.update_status.emit(f"❌ Error installing binary: {e}")
- # Clean up temp file if it exists
- if os.path.exists(temp_file):
- try:
- os.remove(temp_file)
- except:
- pass
- return False
-
- except requests.RequestException as e:
- self.update_status.emit(f"❌ Network error: {e}")
- return False
- except Exception as e:
- self.update_status.emit(f"❌ Binary update failed: {e}")
- return False
-
- def _update_via_pip(self, startupinfo):
- """Update yt-dlp via pip."""
- try:
- import pkg_resources
-
- self.update_status.emit("🔍 Checking current pip installation...")
- self.update_progress.emit(30)
-
- # Get current version
- try:
- current_version = pkg_resources.get_distribution("yt-dlp").version
- self.update_status.emit(f"📋 Current version: {current_version}")
- except pkg_resources.DistributionNotFound:
- self.update_status.emit("⚠️ yt-dlp not found via pip, attempting installation...")
- current_version = "0.0.0"
-
- self.update_progress.emit(40)
-
- # Get the latest version from PyPI
- self.update_status.emit("🌐 Checking for latest version...")
- response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
-
- if response.status_code != 200:
- self.update_status.emit("❌ Failed to check for updates")
- return False
-
- data = response.json()
- latest_version = data["info"]["version"]
- self.update_status.emit(f"🆕 Latest version: {latest_version}")
- self.update_progress.emit(50)
-
- # Compare versions
- from packaging import version
- if version.parse(latest_version) > version.parse(current_version):
- self.update_status.emit(f"⬆️ Updating from {current_version} to {latest_version}...")
- self.update_progress.emit(60)
-
- try:
- # Run pip update with timeout
- self.update_status.emit("📦 Running pip install --upgrade...")
- update_result = subprocess.run(
- [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
- capture_output=True,
- text=True,
- check=False,
- timeout=300, # 5 minute timeout for pip install
- startupinfo=startupinfo
- )
-
- self.update_progress.emit(85)
-
- if update_result.returncode == 0:
- self.update_status.emit("✅ Pip update completed successfully!")
- self.update_progress.emit(95)
- return True
- else:
- self.update_status.emit(f"❌ Pip update failed: {update_result.stderr}")
- return False
-
- except subprocess.TimeoutExpired:
- self.update_status.emit("❌ Pip update timed out after 5 minutes")
- return False
- except Exception as e:
- self.update_status.emit(f"❌ Error during pip update: {e}")
- return False
- else:
- self.update_status.emit("✅ yt-dlp is already up to date!")
- self.update_progress.emit(95)
- return True
-
- except Exception as e:
- self.update_status.emit(f"❌ Pip update failed: {e}")
- return False
-
-
-class YTDLPUpdateDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Update yt-dlp")
- self.setMinimumWidth(450)
- self.setMinimumHeight(200)
- self._closing = False # Flag to track if dialog is closing
-
- layout = QVBoxLayout(self)
-
- # Status label
- self.status_label = QLabel("Checking for updates...")
- self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.status_label.setWordWrap(True)
- self.status_label.setMinimumHeight(60)
- layout.addWidget(self.status_label)
-
- # Progress bar
- self.progress_bar = QProgressBar()
- self.progress_bar.hide() # Hide initially
- layout.addWidget(self.progress_bar)
-
- # Buttons
- button_layout = QHBoxLayout()
- self.update_btn = QPushButton("Update")
- self.update_btn.clicked.connect(self.perform_update)
- self.update_btn.setEnabled(False)
-
- self.close_btn = QPushButton("Close")
- self.close_btn.clicked.connect(self.close)
-
- button_layout.addWidget(self.update_btn)
- button_layout.addWidget(self.close_btn)
- layout.addLayout(button_layout)
-
- # Style
- self.setStyleSheet("""
- QDialog {
- background-color: #15181b;
- }
- QLabel {
- color: #ffffff;
- font-size: 12px;
- padding: 10px;
- }
- QPushButton {
- padding: 8px 15px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- min-width: 100px;
- }
- QPushButton:disabled {
- background-color: #666666;
- }
- QPushButton:hover {
- background-color: #a50000;
- }
- QProgressBar {
- border: 2px solid #1d1e22;
- border-radius: 6px;
- text-align: center;
- color: white;
- background-color: #1d1e22;
- height: 30px;
- font-weight: bold;
- font-size: 12px;
- }
- QProgressBar::chunk {
- background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0,
- stop: 0 #e60000, stop: 0.5 #ff3333, stop: 1 #c90000);
- border-radius: 4px;
- margin: 1px;
- }
- """)
-
- # Start version check in background
- self.check_version()
-
- def check_version(self):
- self.status_label.setText("Checking for updates...")
- self.update_btn.setEnabled(False)
- self.version_check_thread = VersionCheckThread()
- self.version_check_thread.finished.connect(self.on_version_check_finished)
- self.version_check_thread.start()
-
- def on_version_check_finished(self, current_version, latest_version, error_message):
- # Check if dialog is closing to avoid unnecessary updates
- if hasattr(self, '_closing') and self._closing:
- return
-
- if error_message:
- self.status_label.setText(error_message)
- self.update_btn.setEnabled(False)
- return
-
- if not current_version or not latest_version:
- self.status_label.setText("Could not determine versions.")
- self.update_btn.setEnabled(False)
- return
-
- try:
- # Compare versions
- current_ver = version.parse(current_version)
- latest_ver = version.parse(latest_version)
-
- if current_ver < latest_ver:
- self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}")
- self.update_btn.setEnabled(True)
- else:
- self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
- self.update_btn.setEnabled(False)
- except version.InvalidVersion:
- # If version parsing fails, do a simple string comparison
- if current_version != latest_version:
- self.status_label.setText(f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}")
- self.update_btn.setEnabled(True)
- else:
- self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
- self.update_btn.setEnabled(False)
- except Exception as e: # Catch any other unexpected errors during comparison
- self.status_label.setText(f"Error comparing versions: {e}")
- self.update_btn.setEnabled(False)
-
- def perform_update(self):
- # Immediate visual feedback
- self.update_btn.setEnabled(False)
- self.close_btn.setEnabled(False)
- self.update_btn.setText("Updating...")
- self.status_label.setText("🚀 Initializing update process...")
-
- # Show progress bar immediately
- self.progress_bar.setRange(0, 100)
- self.progress_bar.setValue(0)
- self.progress_bar.show()
-
- # Start the update thread immediately without processEvents()
- # The QTimer delay was unnecessary and could cause issues
- self._start_update_thread()
-
- def _start_update_thread(self):
- """Start the actual update thread after giving immediate feedback."""
- # Create and start the update thread
- self.update_thread = UpdateThread()
- self.update_thread.update_status.connect(self.on_update_status)
- self.update_thread.update_progress.connect(self.on_update_progress)
- self.update_thread.update_finished.connect(self.on_update_finished)
- self.update_thread.start()
-
- def on_update_status(self, message):
- """Slot to receive status messages from UpdateThread."""
- if not (hasattr(self, '_closing') and self._closing):
- self.status_label.setText(message)
-
- def on_update_progress(self, progress):
- """Slot to receive progress updates from UpdateThread."""
- if not (hasattr(self, '_closing') and self._closing):
- self.progress_bar.setValue(progress)
-
- def on_update_finished(self, success, message):
- """Slot called when the UpdateThread finishes."""
- # Check if dialog is closing to avoid unnecessary updates
- if hasattr(self, '_closing') and self._closing:
- return
-
- self.progress_bar.setValue(100)
- self.status_label.setText(message)
- self.close_btn.setEnabled(True)
- self.update_btn.setText("Update") # Reset button text
-
- if success:
- # Show success briefly then auto-check version
- QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh
- else:
- # Re-enable update button on failure after a short delay
- QTimer.singleShot(3000, lambda: self.update_btn.setEnabled(True) if not (hasattr(self, '_closing') and self._closing) else None)
-
- def closeEvent(self, event):
- """Ensure threads are terminated if the dialog is closed prematurely."""
- # Set a flag to indicate dialog is closing
- self._closing = True
-
- if hasattr(self, 'version_check_thread') and self.version_check_thread.isRunning():
- self.version_check_thread.quit()
- if not self.version_check_thread.wait(3000): # Wait up to 3 seconds
- self.version_check_thread.terminate()
-
- if hasattr(self, 'update_thread') and self.update_thread.isRunning():
- self.update_thread.quit()
- if not self.update_thread.wait(5000): # Wait up to 5 seconds for update to finish
- self.update_thread.terminate()
-
- super().closeEvent(event)
-
-class AboutDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.parent = parent # Store parent to access version etc.
- self.setWindowTitle("About YTSage")
- self.setMinimumWidth(450)
-
- layout = QVBoxLayout(self)
- layout.setSpacing(15)
- layout.setContentsMargins(20, 20, 20, 20)
-
- # Title and Version
- title_label = QLabel("YTSage
")
- title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(title_label)
-
- version_label = QLabel(f"Version: {getattr(self.parent, 'version', 'N/A')}")
- version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- version_label.setStyleSheet("color: #cccccc;")
- layout.addWidget(version_label)
-
- # Description
- description_label = QLabel("A simple GUI frontend for the powerful yt-dlp video downloader.")
- description_label.setWordWrap(True)
- description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- description_label.setStyleSheet("color: #ffffff; padding-top: 10px;")
- layout.addWidget(description_label)
-
- # Separator
- separator = QWidget()
- separator.setFixedHeight(1)
- separator.setStyleSheet("background-color: #1d1e22;")
- layout.addWidget(separator)
-
- # Information Section
- info_layout = QVBoxLayout()
- info_layout.setSpacing(8)
-
- # Author
- author_label = QLabel("Created by: oop7")
- author_label.setOpenExternalLinks(True)
- info_layout.addWidget(author_label)
-
- # GitHub Repo
- repo_label = QLabel("GitHub: github.com/oop7/YTSage")
- repo_label.setOpenExternalLinks(True)
- info_layout.addWidget(repo_label)
-
- # Create version info container with refresh button
- version_container = QVBoxLayout()
-
- # Header with refresh button
- header_layout = QHBoxLayout()
- header_label = QLabel("System Information")
- header_label.setStyleSheet("color: #ffffff; font-size: 14px;")
- header_layout.addWidget(header_label)
-
- header_layout.addStretch()
-
- self.refresh_btn = QPushButton("Refresh")
- self.refresh_btn.setStyleSheet("""
- QPushButton {
- padding: 4px 12px;
- background-color: #3d3d3d;
- border: 1px solid #555555;
- border-radius: 4px;
- color: white;
- font-size: 11px;
- }
- QPushButton:hover {
- background-color: #4d4d4d;
- }
- QPushButton:pressed {
- background-color: #2d2d2d;
- }
- """)
- self.refresh_btn.clicked.connect(self.refresh_version_info)
- header_layout.addWidget(self.refresh_btn)
-
- version_container.addLayout(header_layout)
-
- # Version information labels (stored as instance variables for updating)
- self.version_info_layout = QVBoxLayout()
- version_container.addLayout(self.version_info_layout)
-
- # Populate version information
- self.update_version_info()
-
- info_layout.addLayout(version_container)
- layout.addLayout(info_layout)
-
- # Close Button
- button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
- button_box.accepted.connect(self.accept)
- # Center the button box
- button_layout = QHBoxLayout()
- button_layout.addStretch()
- button_layout.addWidget(button_box)
- button_layout.addStretch()
- layout.addLayout(button_layout)
-
- # Apply overall styling
- self.setStyleSheet("""
- QDialog { background-color: #15181b; color: #ffffff; }
- QLabel { color: #cccccc; }
- QPushButton {
- padding: 8px 25px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- }
- QPushButton:hover { background-color: #a50000; }
- """)
-
- def update_version_info(self):
- """Update the version information display."""
- # Clear existing labels
- for i in reversed(range(self.version_info_layout.count())):
- child = self.version_info_layout.itemAt(i).widget()
- if child:
- child.deleteLater()
-
- # yt-dlp path and version
- yt_dlp_path = get_yt_dlp_path()
- yt_dlp_path_text = yt_dlp_path if yt_dlp_path else 'yt-dlp not found in PATH'
- yt_dlp_version = get_ytdlp_version()
-
- yt_dlp_label = QLabel(f"yt-dlp Path: {yt_dlp_path_text}")
- yt_dlp_label.setWordWrap(True)
- self.version_info_layout.addWidget(yt_dlp_label)
-
- # Add cache status for yt-dlp
- ytdlp_cache = _version_cache.get('ytdlp', {})
- last_check = ytdlp_cache.get('last_check', 0)
- cache_status = ""
- if last_check > 0:
- import time
- from datetime import datetime
- cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M:%S")
- cache_status = f" (checked at {cache_time})"
-
- yt_dlp_version_label = QLabel(f"yt-dlp Version: {yt_dlp_version}{cache_status}")
- yt_dlp_version_label.setWordWrap(True)
- self.version_info_layout.addWidget(yt_dlp_version_label)
-
- # FFmpeg Status and Version
- ffmpeg_found = check_ffmpeg()
- ffmpeg_status_text = "Detected" if ffmpeg_found else "Not Detected"
- ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available"
-
- ffmpeg_label = QLabel(f"FFmpeg Status: {ffmpeg_status_text}")
- self.version_info_layout.addWidget(ffmpeg_label)
-
- # Add cache status for FFmpeg
- ffmpeg_cache = _version_cache.get('ffmpeg', {})
- last_check = ffmpeg_cache.get('last_check', 0)
- cache_status = ""
- if last_check > 0 and ffmpeg_found:
- import time
- from datetime import datetime
- cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M:%S")
- cache_status = f" (checked at {cache_time})"
-
- ffmpeg_version_label = QLabel(f"FFmpeg Version: {ffmpeg_version}{cache_status}")
- ffmpeg_version_label.setWordWrap(True)
- self.version_info_layout.addWidget(ffmpeg_version_label)
-
- def refresh_version_info(self):
- """Refresh version information manually."""
- self.refresh_btn.setText("Refreshing...")
- self.refresh_btn.setEnabled(False)
-
- # Perform refresh in a separate thread to avoid blocking UI
- from PySide6.QtCore import QThread, Signal
-
- class RefreshThread(QThread):
- finished = Signal(bool)
-
- def run(self):
- success = refresh_version_cache(force=True)
- self.finished.emit(success)
-
- self.refresh_thread = RefreshThread()
- self.refresh_thread.finished.connect(self.on_refresh_finished)
- self.refresh_thread.start()
-
- def on_refresh_finished(self, success):
- """Handle refresh completion."""
- self.refresh_btn.setText("Refresh")
- self.refresh_btn.setEnabled(True)
-
- if success:
- self.update_version_info()
- else:
- # Show error message with proper styling
- msg_box = QMessageBox(self)
- msg_box.setIcon(QMessageBox.Warning)
- msg_box.setWindowTitle("Refresh Failed")
- msg_box.setText("Failed to refresh version information.")
- msg_box.setWindowIcon(self.windowIcon())
- msg_box.setStyleSheet("""
- QMessageBox {
- background-color: #15181b;
- color: #ffffff;
- }
- QMessageBox QLabel {
- color: #ffffff;
- }
- QMessageBox QPushButton {
- padding: 8px 15px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- min-width: 80px;
- }
- QMessageBox QPushButton:hover {
- background-color: #a50000;
- }
- """)
- msg_box.exec()
-
-# --- New Subtitle Selection Dialog ---
-class SubtitleSelectionDialog(QDialog):
- def __init__(self, available_manual, available_auto, previously_selected, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Select Subtitles")
- self.setMinimumWidth(400)
- self.setMinimumHeight(300)
-
- self.available_manual = available_manual
- self.available_auto = available_auto
- self.previously_selected = set(previously_selected) # Use a set for quick lookups
- self.selected_subtitles = list(previously_selected) # Initialize with previous selection
-
- layout = QVBoxLayout(self)
- layout.setSpacing(10)
-
- # Filter input
- self.filter_input = QLineEdit()
- self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...")
- self.filter_input.textChanged.connect(self.filter_list)
- self.filter_input.setStyleSheet("""
- QLineEdit {
- background-color: #363636;
- border: 2px solid #3d3d3d;
- border-radius: 4px;
- padding: 5px;
- min-height: 30px;
- color: white;
- }
- QLineEdit:focus {
- border-color: #ff0000;
- }
- """)
- layout.addWidget(self.filter_input)
-
- # Scroll Area for the list
- scroll_area = QScrollArea()
- scroll_area.setWidgetResizable(True)
- scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
- layout.addWidget(scroll_area)
-
- # Container widget for list items (needed for scroll area)
- self.list_container = QWidget()
- self.list_layout = QVBoxLayout(self.list_container)
- self.list_layout.setContentsMargins(0, 0, 0, 0)
- self.list_layout.setSpacing(2) # Compact spacing
- self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
- scroll_area.setWidget(self.list_container)
-
- # Populate the list initially
- self.populate_list()
-
- # OK and Cancel buttons
- button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
- button_box.accepted.connect(self.accept)
- button_box.rejected.connect(self.reject)
-
- # Style the buttons
- for button in button_box.buttons():
- button.setStyleSheet("""
- QPushButton {
- background-color: #363636;
- border: 2px solid #3d3d3d;
- border-radius: 4px;
- padding: 5px 15px; /* Adjust padding */
- min-height: 30px; /* Ensure consistent height */
- color: white;
- }
- QPushButton:hover {
- background-color: #444444;
- }
- QPushButton:pressed {
- background-color: #555555;
- }
- """)
- # Style the OK button specifically if needed
- if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
- button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
-
-
- layout.addWidget(button_box)
-
- def populate_list(self, filter_text=""):
- # Clear existing checkboxes from layout
- while self.list_layout.count():
- item = self.list_layout.takeAt(0)
- widget = item.widget()
- if widget is not None:
- widget.deleteLater()
-
- filter_text = filter_text.lower()
- combined_subs = {}
-
- # Add manual subs
- for lang_code, sub_info in self.available_manual.items():
- if not filter_text or filter_text in lang_code.lower():
- combined_subs[lang_code] = f"{lang_code} - Manual"
-
- # Add auto subs (only if no manual exists and matches filter)
- for lang_code, sub_info in self.available_auto.items():
- if lang_code not in combined_subs: # Don't overwrite manual
- if not filter_text or filter_text in lang_code.lower():
- combined_subs[lang_code] = f"{lang_code} - Auto-generated"
-
- if not combined_subs:
- no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else ""))
- no_subs_label.setStyleSheet("color: #aaaaaa; padding: 10px;")
- self.list_layout.addWidget(no_subs_label)
- return
-
- # Sort by language code
- sorted_lang_codes = sorted(combined_subs.keys())
-
- for lang_code in sorted_lang_codes:
- item_text = combined_subs[lang_code]
- checkbox = QCheckBox(item_text)
- checkbox.setProperty("subtitle_id", item_text) # Store the identifier
- checkbox.setChecked(item_text in self.previously_selected) # Check if previously selected
- checkbox.stateChanged.connect(self.update_selection)
- checkbox.setStyleSheet("""
- QCheckBox {
- color: #ffffff;
- padding: 5px;
- }
- QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 4px; /* Square checkboxes */
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #2b2b2b;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
- }
- """)
- self.list_layout.addWidget(checkbox)
-
- self.list_layout.addStretch() # Pushes items up if list is short
-
- def filter_list(self):
- self.populate_list(self.filter_input.text())
-
- def update_selection(self, state):
- sender = self.sender()
- subtitle_id = sender.property("subtitle_id")
- if state == Qt.CheckState.Checked.value:
- if subtitle_id not in self.previously_selected:
- self.previously_selected.add(subtitle_id)
- else:
- if subtitle_id in self.previously_selected:
- self.previously_selected.remove(subtitle_id)
-
- def get_selected_subtitles(self):
- # Return the final set as a list
- return list(self.previously_selected)
-
- def accept(self):
- # Update the final list before closing
- self.selected_subtitles = self.get_selected_subtitles()
- super().accept()
-
-# --- End Subtitle Selection Dialog ---
-
-
-# --- Playlist Video Selection Dialog ---
-
-class PlaylistSelectionDialog(QDialog):
- def __init__(self, playlist_entries, previously_selected_string, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Select Playlist Videos")
- self.setMinimumWidth(500)
- self.setMinimumHeight(400) # Allow more vertical space
-
- self.playlist_entries = playlist_entries
- self.checkboxes = []
-
- # Main layout
- main_layout = QVBoxLayout(self)
-
- # Top buttons (Select/Deselect All)
- button_layout = QHBoxLayout()
- select_all_btn = QPushButton("Select All")
- deselect_all_btn = QPushButton("Deselect All")
- select_all_btn.clicked.connect(self._select_all)
- deselect_all_btn.clicked.connect(self._deselect_all)
- # Style the buttons to match the subtitle dialog
- select_all_btn.setStyleSheet("""
- QPushButton {
- background-color: #363636;
- border: 2px solid #3d3d3d;
- border-radius: 4px;
- padding: 5px 15px;
- min-height: 30px;
- color: white;
- }
- QPushButton:hover {
- background-color: #444444;
- }
- QPushButton:pressed {
- background-color: #555555;
- }
- """)
- deselect_all_btn.setStyleSheet(select_all_btn.styleSheet())
- button_layout.addWidget(select_all_btn)
- button_layout.addWidget(deselect_all_btn)
- button_layout.addStretch()
- main_layout.addLayout(button_layout)
-
- # Scrollable area for checkboxes
- scroll_area = QScrollArea()
- scroll_area.setWidgetResizable(True)
- scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
- scroll_widget = QWidget()
- self.list_layout = QVBoxLayout(scroll_widget) # Layout for checkboxes
- self.list_layout.setContentsMargins(0, 0, 0, 0)
- self.list_layout.setSpacing(2) # Compact spacing
- self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
- scroll_area.setWidget(scroll_widget)
- main_layout.addWidget(scroll_area)
-
- # Populate checkboxes
- self._populate_list(previously_selected_string)
-
- # Dialog buttons (OK/Cancel)
- button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
- button_box.accepted.connect(self.accept)
- button_box.rejected.connect(self.reject)
-
- # Style the buttons to match subtitle dialog
- for button in button_box.buttons():
- button.setStyleSheet("""
- QPushButton {
- background-color: #363636;
- border: 2px solid #3d3d3d;
- border-radius: 4px;
- padding: 5px 15px;
- min-height: 30px;
- color: white;
- }
- QPushButton:hover {
- background-color: #444444;
- }
- QPushButton:pressed {
- background-color: #555555;
- }
- """)
- # Style the OK button specifically if needed
- if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
- button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
-
- main_layout.addWidget(button_box)
-
- # Apply styling to match subtitle dialog
- self.setStyleSheet("""
- QDialog { background-color: #15181b; }
- QCheckBox {
- color: #ffffff;
- padding: 5px;
- }
- QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 4px;
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #2b2b2b;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
- }
- QWidget { background-color: #15181b; }
- """)
-
- def _parse_selection_string(self, selection_string):
- """Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices."""
- selected_indices = set()
- if not selection_string:
- # If no previous selection, assume all are selected initially
- return set(range(1, len(self.playlist_entries) + 1))
-
- parts = selection_string.split(',')
- for part in parts:
- part = part.strip()
- if '-' in part:
- try:
- start, end = map(int, part.split('-'))
- if start <= end:
- selected_indices.update(range(start, end + 1))
- except ValueError:
- pass # Ignore invalid ranges
- else:
- try:
- selected_indices.add(int(part))
- except ValueError:
- pass # Ignore invalid numbers
- return selected_indices
-
- def _populate_list(self, previously_selected_string):
- """Populates the scroll area with checkboxes for each video."""
- selected_indices = self._parse_selection_string(previously_selected_string)
-
- # Clear existing checkboxes if any (e.g., if repopulating)
- while self.list_layout.count():
- child = self.list_layout.takeAt(0)
- if child.widget():
- child.widget().deleteLater()
- self.checkboxes.clear()
-
- for index, entry in enumerate(self.playlist_entries):
- if not entry: continue # Skip None entries if yt-dlp returns them
-
- video_index = index + 1 # yt-dlp uses 1-based indexing
- title = entry.get('title', f'Video {video_index}')
- # Shorten title if too long
- display_title = (title[:70] + '...') if len(title) > 73 else title
-
- checkbox = QCheckBox(f"{video_index}. {display_title}")
- checkbox.setChecked(video_index in selected_indices)
- checkbox.setProperty("video_index", video_index) # Store index
- checkbox.setStyleSheet("""
- QCheckBox {
- color: #ffffff;
- padding: 5px;
- }
- QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 4px;
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #2b2b2b;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
- }
- """)
- self.list_layout.addWidget(checkbox)
- self.checkboxes.append(checkbox)
- self.list_layout.addStretch() # Push checkboxes to the top
-
- def _select_all(self):
- for checkbox in self.checkboxes:
- checkbox.setChecked(True)
-
- def _deselect_all(self):
- for checkbox in self.checkboxes:
- checkbox.setChecked(False)
-
- def _condense_indices(self, indices):
- """Condenses a list of 1-based indices into a yt-dlp selection string."""
- if not indices:
- return ""
- indices = sorted(list(set(indices)))
- if not indices: # Check again after sorting/set conversion
- return ""
-
- ranges = []
- start = indices[0]
- end = indices[0]
- for i in range(1, len(indices)):
- if indices[i] == end + 1:
- end = indices[i]
- else:
- if start == end:
- ranges.append(str(start))
- else:
- ranges.append(f"{start}-{end}")
- start = indices[i]
- end = indices[i]
- # Add the last range
- if start == end:
- ranges.append(str(start))
- else:
- ranges.append(f"{start}-{end}")
- return ",".join(ranges)
-
- def get_selected_items_string(self):
- """Returns the selection string based on checked boxes."""
- selected_indices = [
- cb.property("video_index") for cb in self.checkboxes if cb.isChecked()
- ]
-
- # Check if all items are selected
- if len(selected_indices) == len(self.playlist_entries):
- return None # yt-dlp default is all items, so return None or empty string
-
- return self._condense_indices(selected_indices)
-
- # Optional: Override accept to ensure the string is generated, although not strictly necessary
- # def accept(self):
- # self._selected_string = self.get_selected_items_string()
- # super().accept()
-
-# --- End Playlist Video Selection Dialog ---
-
-class CookieLoginDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle('Login with Cookies')
- self.setMinimumSize(400, 150)
-
- layout = QVBoxLayout(self)
-
- help_text = QLabel(
- "Select the Netscape-format cookies file for logging in.\n"
- "This allows downloading of private videos and premium quality audio."
- )
- help_text.setWordWrap(True)
- help_text.setStyleSheet("color: #999999; padding: 10px;")
- layout.addWidget(help_text)
-
- # File path input and browse button
- path_layout = QHBoxLayout()
- self.cookie_path_input = QLineEdit()
- self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
- path_layout.addWidget(self.cookie_path_input)
-
- self.browse_button = QPushButton("Browse")
- self.browse_button.clicked.connect(self.browse_cookie_file)
- path_layout.addWidget(self.browse_button)
-
- layout.addLayout(path_layout)
-
- # Dialog buttons
- button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
- button_box.accepted.connect(self.accept)
- button_box.rejected.connect(self.reject)
- layout.addWidget(button_box)
-
- def browse_cookie_file(self):
- # Open file dialog to select cookie file
- file_dialog = QFileDialog(self)
- file_dialog.setFileMode(QFileDialog.ExistingFile)
- file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Assuming common cookie file extensions
- if file_dialog.exec():
- selected_files = file_dialog.selectedFiles()
- if selected_files:
- self.cookie_path_input.setText(selected_files[0])
-
- def get_cookie_file_path(self):
- # Return the selected cookie file path
- return self.cookie_path_input.text()
-
-# === Renamed Dialog: Download Settings ===
-class DownloadSettingsDialog(QDialog): # Renamed class
- def __init__(self, current_path, current_limit, current_unit_index, parent=None): # Added limit params
- super().__init__(parent)
- self.setWindowTitle("Download Settings") # Renamed window
- self.setMinimumWidth(450)
- self.setMinimumHeight(400)
- self.current_path = current_path
- self.current_limit = current_limit if current_limit is not None else "" # Handle None
- self.current_unit_index = current_unit_index
-
- # Apply main app styling
- self.setStyleSheet("""
- QDialog {
- background-color: #15181b;
- color: #ffffff;
- }
- QWidget {
- background-color: #15181b;
- color: #ffffff;
- }
- QLabel {
- color: #ffffff;
- }
- QGroupBox {
- color: #ffffff;
- border: 2px solid #1b2021;
- border-radius: 4px;
- margin-top: 10px;
- padding-top: 10px;
- font-weight: bold;
- background-color: #15181b;
- }
- QGroupBox::title {
- subcontrol-origin: margin;
- left: 10px;
- padding: 0 5px 0 5px;
- color: #ffffff;
- }
- QLineEdit {
- padding: 8px;
- border: 2px solid #1b2021;
- border-radius: 4px;
- background-color: #1b2021;
- color: #ffffff;
- selection-background-color: #c90000;
- selection-color: #ffffff;
- }
- QPushButton {
- padding: 8px 15px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- min-height: 20px;
- }
- QPushButton:hover {
- background-color: #a50000;
- }
- QPushButton:pressed {
- background-color: #800000;
- }
- QPushButton:disabled {
- background-color: #666666;
- color: #999999;
- }
- QCheckBox {
- spacing: 5px;
- color: #ffffff;
- }
- QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 9px;
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #15181b;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #c90000;
- background: #c90000;
- }
- QRadioButton {
- spacing: 5px;
- color: #ffffff;
- }
- QRadioButton::indicator {
- width: 18px;
- height: 18px;
- border-radius: 9px;
- }
- QRadioButton::indicator:unchecked {
- border: 2px solid #666666;
- background: #15181b;
- }
- QRadioButton::indicator:checked {
- border: 2px solid #c90000;
- background: #c90000;
- }
- QComboBox {
- padding: 5px;
- border: 2px solid #1b2021;
- border-radius: 4px;
- background-color: #1b2021;
- color: #ffffff;
- min-height: 20px;
- }
- QComboBox::drop-down {
- border: none;
- width: 20px;
- }
- QComboBox QAbstractItemView {
- border: 2px solid #1b2021;
- border-radius: 4px;
- background-color: #15181b;
- color: #ffffff;
- selection-background-color: #c90000;
- selection-color: #ffffff;
- }
- """)
-
- layout = QVBoxLayout(self)
-
- # --- Download Path Section ---
- path_group_box = QGroupBox("Download Path")
- path_layout = QVBoxLayout()
-
- self.path_display = QLabel(self.current_path)
- self.path_display.setWordWrap(True)
- self.path_display.setStyleSheet("QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }")
- path_layout.addWidget(self.path_display)
-
- browse_button = QPushButton("Browse...")
- browse_button.clicked.connect(self.browse_new_path)
- path_layout.addWidget(browse_button)
-
- path_group_box.setLayout(path_layout)
- layout.addWidget(path_group_box)
- # --- End Path Section ---
-
- # --- Speed Limit Section ---
- speed_group_box = QGroupBox("Speed Limit")
- speed_layout = QHBoxLayout()
-
- self.speed_limit_input = QLineEdit(str(self.current_limit)) # Set initial value
- self.speed_limit_input.setPlaceholderText("None")
- speed_layout.addWidget(self.speed_limit_input)
-
- self.speed_limit_unit = QComboBox()
- self.speed_limit_unit.addItems(["KB/s", "MB/s"])
- self.speed_limit_unit.setCurrentIndex(self.current_unit_index) # Set initial unit
- speed_layout.addWidget(self.speed_limit_unit)
-
- speed_group_box.setLayout(speed_layout)
- layout.addWidget(speed_group_box)
- # --- End Speed Limit Section ---
-
- # --- Auto-Update yt-dlp Section ---
- auto_update_group_box = QGroupBox("Auto-Update yt-dlp")
- auto_update_layout = QVBoxLayout()
-
- # Load current auto-update settings
- from ytsage_utils import get_auto_update_settings
- auto_settings = get_auto_update_settings()
-
- # Enable/Disable auto-update checkbox
- self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates")
- self.auto_update_enabled.setChecked(auto_settings['enabled'])
- auto_update_layout.addWidget(self.auto_update_enabled)
-
- # Frequency options
- frequency_label = QLabel("Update frequency:")
- frequency_label.setStyleSheet("color: #ffffff; margin-top: 10px;")
- auto_update_layout.addWidget(frequency_label)
-
- self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
- self.daily_radio = QRadioButton("Check daily")
- self.weekly_radio = QRadioButton("Check weekly")
-
- # Set current selection based on saved settings
- current_frequency = auto_settings['frequency']
- if current_frequency == 'startup':
- self.startup_radio.setChecked(True)
- elif current_frequency == 'daily':
- self.daily_radio.setChecked(True)
- else: # weekly
- self.weekly_radio.setChecked(True)
-
- auto_update_layout.addWidget(self.startup_radio)
- auto_update_layout.addWidget(self.daily_radio)
- auto_update_layout.addWidget(self.weekly_radio)
-
- # Test update button
- test_update_layout = QHBoxLayout()
- test_update_button = QPushButton("Check for Updates Now")
- test_update_button.clicked.connect(self.test_update_check)
- test_update_layout.addWidget(test_update_button)
- test_update_layout.addStretch()
- auto_update_layout.addLayout(test_update_layout)
-
- auto_update_group_box.setLayout(auto_update_layout)
- layout.addWidget(auto_update_group_box)
- # --- End Auto-Update Section ---
-
- # Dialog buttons (OK/Cancel)
- button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
- button_box.accepted.connect(self.accept)
- button_box.rejected.connect(self.reject)
- layout.addWidget(button_box)
-
- def browse_new_path(self):
- new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path)
- if new_path:
- self.current_path = new_path
- self.path_display.setText(self.current_path)
-
- def get_selected_path(self):
- """Returns the confirmed path after the dialog is accepted."""
- return self.current_path
-
- def get_selected_speed_limit(self):
- """Returns the entered speed limit value (as string or None)."""
- limit_str = self.speed_limit_input.text().strip()
- if not limit_str:
- return None
- # Optional: Add validation to ensure it's a number
- try:
- float(limit_str) # Check if convertible to float
- return limit_str
- except ValueError:
- # Handle error? Or just return None? Returning None for simplicity.
- print("Invalid speed limit input in dialog")
- return None # Or raise an error / show message
-
- def get_selected_unit_index(self):
- """Returns the index of the selected speed limit unit."""
- return self.speed_limit_unit.currentIndex()
-
- def _create_styled_message_box(self, icon, title, text):
- """Create a styled QMessageBox that matches the app theme."""
- msg_box = QMessageBox(self)
- msg_box.setIcon(icon)
- msg_box.setWindowTitle(title)
- msg_box.setText(text)
- msg_box.setWindowIcon(self.windowIcon())
- msg_box.setStyleSheet("""
- QMessageBox {
- background-color: #15181b;
- color: #ffffff;
- }
- QMessageBox QLabel {
- color: #ffffff;
- }
- QMessageBox QPushButton {
- padding: 8px 15px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- min-width: 80px;
- }
- QMessageBox QPushButton:hover {
- background-color: #a50000;
- }
- QMessageBox QPushButton:pressed {
- background-color: #800000;
- }
- """)
- return msg_box
-
- def test_update_check(self):
- """Test the update check functionality."""
- try:
- from ytsage_utils import get_ytdlp_version
- import requests
-
- # Get current version
- current_version = get_ytdlp_version()
- if "Error" in current_version:
- msg_box = self._create_styled_message_box(
- QMessageBox.Warning,
- "Update Check",
- "Could not determine current yt-dlp version."
- )
- msg_box.exec()
- return
-
- # Get latest version from PyPI
- response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
- response.raise_for_status()
- latest_version = response.json()["info"]["version"]
-
- # Clean up version strings
- current_version = current_version.replace('_', '.')
- latest_version = latest_version.replace('_', '.')
-
- from packaging import version as version_parser
- if version_parser.parse(latest_version) > version_parser.parse(current_version):
- msg_box = self._create_styled_message_box(
- QMessageBox.Information,
- "Update Check",
- f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update."
- )
- msg_box.exec()
- else:
- msg_box = self._create_styled_message_box(
- QMessageBox.Information,
- "Update Check",
- f"yt-dlp is up to date!\n\nCurrent version: {current_version}"
- )
- msg_box.exec()
- except Exception as e:
- msg_box = self._create_styled_message_box(
- QMessageBox.Warning,
- "Update Check",
- f"Error checking for updates: {str(e)}"
- )
- msg_box.exec()
-
- def get_auto_update_settings(self):
- """Returns the auto-update settings from the dialog."""
- enabled = self.auto_update_enabled.isChecked()
-
- if self.startup_radio.isChecked():
- frequency = 'startup'
- elif self.daily_radio.isChecked():
- frequency = 'daily'
- else: # weekly_radio is checked
- frequency = 'weekly'
-
- return enabled, frequency
-
- def accept(self):
- """Override accept to save auto-update settings."""
- try:
- # Save auto-update settings
- enabled, frequency = self.get_auto_update_settings()
- from ytsage_utils import update_auto_update_settings
-
- if update_auto_update_settings(enabled, frequency):
- QMessageBox.information(self, "Settings Saved",
- "Auto-update settings have been saved successfully!")
- else:
- QMessageBox.warning(self, "Error",
- "Failed to save auto-update settings.")
- except Exception as e:
- QMessageBox.critical(self, "Error",
- f"Error saving auto-update settings: {str(e)}")
-
- # Call the parent accept method to close the dialog
- super().accept()
-
-# === New CustomOptions Dialog combining Cookies and Custom Commands ===
-class CustomOptionsDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.parent = parent
- self.setWindowTitle('Custom Options')
- self.setMinimumSize(600, 500)
-
- layout = QVBoxLayout(self)
-
- # Create tab widget to organize content
- self.tab_widget = QTabWidget()
- layout.addWidget(self.tab_widget)
-
- # === Cookies Tab ===
- cookies_tab = QWidget()
- cookies_layout = QVBoxLayout(cookies_tab)
-
- # Help text
- help_text = QLabel(
- "Select the Netscape-format cookies file for logging in.\n"
- "This allows downloading of private videos and premium quality audio."
- )
- help_text.setWordWrap(True)
- help_text.setStyleSheet("color: #999999; padding: 10px;")
- cookies_layout.addWidget(help_text)
-
- # File path input and browse button
- path_layout = QHBoxLayout()
- self.cookie_path_input = QLineEdit()
- self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
- if hasattr(parent, 'cookie_file_path') and parent.cookie_file_path:
- self.cookie_path_input.setText(parent.cookie_file_path)
- path_layout.addWidget(self.cookie_path_input)
-
- self.browse_button = QPushButton("Browse")
- self.browse_button.clicked.connect(self.browse_cookie_file)
- path_layout.addWidget(self.browse_button)
- cookies_layout.addLayout(path_layout) # Add the horizontal layout to cookies layout
-
- # Status indicator for cookies
- self.cookie_status = QLabel("")
- self.cookie_status.setStyleSheet("color: #999999; font-style: italic;")
- cookies_layout.addWidget(self.cookie_status)
-
- cookies_layout.addStretch()
-
- # === Custom Command Tab ===
- command_tab = QWidget()
- command_layout = QVBoxLayout(command_tab)
-
- # Help text
- cmd_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."
- )
- cmd_help_text.setWordWrap(True)
- cmd_help_text.setStyleSheet("color: #999999; padding: 10px;")
- command_layout.addWidget(cmd_help_text)
-
- # Add SponsorBlock checkbox
- self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
- self.sponsorblock_checkbox.setStyleSheet("""
- QCheckBox {
- color: #ffffff;
- padding: 5px;
- margin-left: 0px;
- }
- 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;
- }
- """)
- command_layout.addWidget(self.sponsorblock_checkbox)
-
- # Command input
- self.command_input = QPlainTextEdit()
- self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
- self.command_input.setStyleSheet("""
- QPlainTextEdit {
- background-color: #1d1e22;
- color: #ffffff;
- border: 2px solid #1d1e22;
- border-radius: 4px;
- padding: 8px;
- font-family: Consolas, monospace;
- }
- """)
- command_layout.addWidget(self.command_input)
-
- # Run command button
- self.run_btn = QPushButton("Run Command")
- self.run_btn.clicked.connect(self.run_custom_command)
- command_layout.addWidget(self.run_btn)
-
- # Log output
- self.log_output = QTextEdit()
- self.log_output.setReadOnly(True)
- self.log_output.setStyleSheet("""
- QTextEdit {
- background-color: #1d1e22;
- color: #ffffff;
- border: 2px solid #1d1e22;
- border-radius: 4px;
- padding: 8px;
- font-family: Consolas, monospace;
- font-size: 12px;
- }
- """)
- command_layout.addWidget(self.log_output)
-
- # Add tabs to the tab widget
- self.tab_widget.addTab(cookies_tab, "Login with Cookies")
- self.tab_widget.addTab(command_tab, "Custom Command")
-
- # Dialog buttons
- button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
- button_box.accepted.connect(self.accept)
- button_box.rejected.connect(self.reject)
- layout.addWidget(button_box)
-
- # Apply global styles
- self.setStyleSheet("""
- QDialog {
- background-color: #15181b;
- }
- QTabWidget::pane {
- border: 1px solid #3d3d3d;
- background-color: #15181b;
- }
- QTabBar::tab {
- background-color: #1d1e22;
- color: #ffffff;
- padding: 8px 12px;
- border: 1px solid #3d3d3d;
- border-bottom: none;
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
- }
- QTabBar::tab:selected {
- background-color: #c90000;
- }
- QTabBar::tab:hover:!selected {
- background-color: #2a2d36;
- }
- QLabel {
- 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;
- }
- """)
-
- def browse_cookie_file(self):
- # Open file dialog to select cookie file
- file_dialog = QFileDialog(self)
- file_dialog.setFileMode(QFileDialog.ExistingFile)
- file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Assuming common cookie file extensions
- if file_dialog.exec():
- selected_files = file_dialog.selectedFiles()
- if selected_files:
- self.cookie_path_input.setText(selected_files[0])
- self.cookie_status.setText("Cookie file selected - Click OK to apply")
- self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
-
- def get_cookie_file_path(self):
- # Return the selected cookie file path if it's not empty
- path = self.cookie_path_input.text().strip()
- if path and os.path.exists(path):
- return path
- return None
-
- 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()
-
- # Get download path from parent
- path = self.parent.last_path
-
- 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):
- from PySide6.QtCore import QMetaObject, Qt, Q_ARG
- QMetaObject.invokeMethod(
- self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, msg)
- )
- def warning(self, msg):
- from PySide6.QtCore import QMetaObject, Qt, Q_ARG
- QMetaObject.invokeMethod(
- self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, f"Warning: {msg}")
- )
- def error(self, msg):
- from PySide6.QtCore import QMetaObject, Qt, Q_ARG
- QMetaObject.invokeMethod(
- self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, 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 if selected
- if self.sponsorblock_checkbox.isChecked():
- ydl_opts['postprocessors'].append({
- 'key': 'SponsorBlock',
- 'categories': ['sponsor', 'intro', 'outro', 'selfpromo', 'preview', 'filler']
- })
-
- # Parse additional options
- yt_dlp_path = get_yt_dlp_path()
- base_cmd = [yt_dlp_path] + args + [url]
-
- # Show the full command
- from PySide6.QtCore import QMetaObject, Qt, Q_ARG
- QMetaObject.invokeMethod(
- self.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, f"Full command: {' '.join(base_cmd)}")
- )
-
- # Run the command
- proc = subprocess.Popen(
- base_cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- text=True,
- encoding='utf-8',
- errors='replace'
- )
-
- # Stream output
- for line in proc.stdout:
- QMetaObject.invokeMethod(
- self.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, line.rstrip())
- )
-
- ret = proc.wait()
- if ret != 0:
- QMetaObject.invokeMethod(
- self.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, f"Command exited with code {ret}")
- )
- else:
- QMetaObject.invokeMethod(
- self.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, "Command completed successfully")
- )
- except Exception as e:
- from PySide6.QtCore import QMetaObject, Qt, Q_ARG
- QMetaObject.invokeMethod(
- self.log_output, "append", Qt.ConnectionType.QueuedConnection,
- Q_ARG(str, f"Error: {str(e)}")
- )
- finally:
- # Re-enable the run button
- QMetaObject.invokeMethod(
- self.run_btn, "setEnabled", Qt.ConnectionType.QueuedConnection,
- Q_ARG(bool, True)
- )
-
-# === Time Range Selection Dialog ===
-class TimeRangeDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.parent = parent
- self.setWindowTitle('Download Video Section')
- self.setMinimumWidth(400)
-
- layout = QVBoxLayout(self)
-
- # Help text explaining the feature
- help_text = QLabel(
- "Download only specific parts of a video by specifying time ranges.\n"
- "Use HH:MM:SS format or seconds. Leave start or end empty to download from beginning or to end."
- )
- help_text.setWordWrap(True)
- help_text.setStyleSheet("color: #999999; padding: 10px;")
- layout.addWidget(help_text)
-
- # Time range section
- time_group = QGroupBox("Time Range")
- time_layout = QVBoxLayout()
-
- # Start time row
- start_layout = QHBoxLayout()
- start_layout.addWidget(QLabel("Start Time:"))
- self.start_time_input = QLineEdit()
- self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)")
- start_layout.addWidget(self.start_time_input)
- time_layout.addLayout(start_layout)
-
- # End time row
- end_layout = QHBoxLayout()
- end_layout.addWidget(QLabel("End Time:"))
- self.end_time_input = QLineEdit()
- self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)")
- end_layout.addWidget(self.end_time_input)
- time_layout.addLayout(end_layout)
-
- time_group.setLayout(time_layout)
- layout.addWidget(time_group)
-
- # Force keyframes option
- self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)")
- self.force_keyframes.setChecked(True)
- self.force_keyframes.setStyleSheet("""
- QCheckBox {
- color: #ffffff;
- padding: 5px;
- }
- QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 4px;
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #1d1e22;
- border-radius: 4px;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #c90000;
- background: #c90000;
- border-radius: 4px;
- }
- """)
- layout.addWidget(self.force_keyframes)
-
- # Format preview
- preview_group = QGroupBox("Command Preview")
- preview_layout = QVBoxLayout()
- self.preview_label = QLabel("--download-sections \"*-\"")
- self.preview_label.setStyleSheet("""
- QLabel {
- background-color: #1d1e22;
- color: #ffffff;
- border: 1px solid #3d3d3d;
- border-radius: 4px;
- padding: 8px;
- font-family: Consolas, monospace;
- }
- """)
- preview_layout.addWidget(self.preview_label)
- preview_group.setLayout(preview_layout)
- layout.addWidget(preview_group)
-
- # Connect signals for live preview updates
- self.start_time_input.textChanged.connect(self.update_preview)
- self.end_time_input.textChanged.connect(self.update_preview)
- self.force_keyframes.stateChanged.connect(self.update_preview)
-
- # Buttons
- button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
- button_box.accepted.connect(self.accept)
- button_box.rejected.connect(self.reject)
- layout.addWidget(button_box)
-
- # Apply styling
- self.setStyleSheet("""
- QDialog {
- background-color: #15181b;
- }
- QGroupBox {
- border: 1px solid #3d3d3d;
- border-radius: 4px;
- margin-top: 1.5ex;
- color: #ffffff;
- padding: 10px;
- }
- QGroupBox::title {
- subcontrol-origin: margin;
- subcontrol-position: top left;
- padding: 0 5px;
- }
- QLabel {
- 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;
- }
- """)
-
- # Initialize preview
- self.update_preview()
-
- def update_preview(self):
- start = self.start_time_input.text().strip()
- end = self.end_time_input.text().strip()
-
- if start and end:
- time_range = f"*{start}-{end}"
- elif start:
- time_range = f"*{start}-"
- elif end:
- time_range = f"*-{end}"
- else:
- time_range = "*-" # Full video
-
- preview = f"--download-sections \"{time_range}\""
- if self.force_keyframes.isChecked():
- preview += " --force-keyframes-at-cuts"
-
- self.preview_label.setText(preview)
-
- def get_download_sections(self):
- """Returns the download sections command arguments or None if no selection made"""
- start = self.start_time_input.text().strip()
- end = self.end_time_input.text().strip()
-
- if not start and not end:
- return None # No selection made
-
- if start and end:
- time_range = f"*{start}-{end}"
- elif start:
- time_range = f"*{start}-"
- elif end:
- time_range = f"*-{end}"
- else:
- return None # Shouldn't happen but just in case
-
- return time_range
-
- def get_force_keyframes(self):
- """Returns whether to force keyframes at cuts"""
- return self.force_keyframes.isChecked()
-
-# === Auto-Update Settings Dialog ===
-
-class AutoUpdateSettingsDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Auto-Update Settings")
- self.setMinimumWidth(400)
- self.setMinimumHeight(300)
-
- # Set the window icon to match the main app
- if parent:
- self.setWindowIcon(parent.windowIcon())
-
- self.init_ui()
- self.load_current_settings()
- self.apply_styling()
-
- def init_ui(self):
- layout = QVBoxLayout(self)
-
- # Title
- title_label = QLabel("🔄 Auto-Update Settings
")
- title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(title_label)
-
- # Description
- desc_label = QLabel("Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.")
- desc_label.setWordWrap(True)
- desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
- layout.addWidget(desc_label)
-
- # Enable/Disable auto-update
- self.enable_checkbox = QCheckBox("Enable automatic yt-dlp updates")
- self.enable_checkbox.setChecked(True) # Default enabled
- self.enable_checkbox.toggled.connect(self.on_enable_toggled)
- layout.addWidget(self.enable_checkbox)
-
- # Frequency options
- frequency_group = QGroupBox("Update Frequency")
- frequency_layout = QVBoxLayout()
-
- self.frequency_group = QButtonGroup(self)
-
- self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
- self.daily_radio = QRadioButton("Check daily")
- self.weekly_radio = QRadioButton("Check weekly")
-
- self.daily_radio.setChecked(True) # Default to daily
-
- self.frequency_group.addButton(self.startup_radio, 0)
- self.frequency_group.addButton(self.daily_radio, 1)
- self.frequency_group.addButton(self.weekly_radio, 2)
-
- frequency_layout.addWidget(self.startup_radio)
- frequency_layout.addWidget(self.daily_radio)
- frequency_layout.addWidget(self.weekly_radio)
- frequency_group.setLayout(frequency_layout)
-
- layout.addWidget(frequency_group)
-
- # Current status
- status_group = QGroupBox("Current Status")
- status_layout = QVBoxLayout()
-
- self.current_version_label = QLabel("Current yt-dlp version: Checking...")
- self.last_check_label = QLabel("Last update check: Never")
- self.next_check_label = QLabel("Next check: Based on settings")
-
- status_layout.addWidget(self.current_version_label)
- status_layout.addWidget(self.last_check_label)
- status_layout.addWidget(self.next_check_label)
- status_group.setLayout(status_layout)
-
- layout.addWidget(status_group)
-
- # Manual check button
- self.manual_check_btn = QPushButton("🔍 Check for Updates Now")
- self.manual_check_btn.clicked.connect(self.manual_check)
- layout.addWidget(self.manual_check_btn)
-
- # Buttons
- button_layout = QHBoxLayout()
-
- self.save_btn = QPushButton("Save Settings")
- self.save_btn.clicked.connect(self.save_settings)
-
- self.cancel_btn = QPushButton("Cancel")
- self.cancel_btn.clicked.connect(self.reject)
-
- button_layout.addWidget(self.save_btn)
- button_layout.addWidget(self.cancel_btn)
-
- layout.addLayout(button_layout)
-
- def apply_styling(self):
- self.setStyleSheet("""
- QDialog {
- background-color: #15181b;
- color: #ffffff;
- }
- QLabel {
- color: #ffffff;
- }
- QGroupBox {
- color: #ffffff;
- border: 2px solid #1b2021;
- border-radius: 4px;
- margin-top: 10px;
- padding-top: 10px;
- font-weight: bold;
- background-color: #15181b;
- }
- QGroupBox::title {
- subcontrol-origin: margin;
- left: 10px;
- padding: 0 5px 0 5px;
- color: #ffffff;
- }
- QCheckBox, QRadioButton {
- color: #ffffff;
- spacing: 5px;
- margin: 5px;
- }
- QCheckBox::indicator, QRadioButton::indicator {
- width: 18px;
- height: 18px;
- border-radius: 9px;
- }
- QCheckBox::indicator:unchecked, QRadioButton::indicator:unchecked {
- border: 2px solid #666666;
- background: #15181b;
- }
- QCheckBox::indicator:checked, QRadioButton::indicator:checked {
- border: 2px solid #c90000;
- background: #c90000;
- }
- QPushButton {
- padding: 8px 15px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- margin: 5px;
- min-width: 100px;
- min-height: 20px;
- }
- QPushButton:hover {
- background-color: #a50000;
- }
- QPushButton:pressed {
- background-color: #800000;
- }
- QPushButton:disabled {
- background-color: #666666;
- color: #999999;
- }
- """)
-
- def load_current_settings(self):
- """Load current auto-update settings from config."""
- try:
- from ytsage_utils import get_auto_update_settings, get_ytdlp_version
- import time
- from datetime import datetime
-
- settings = get_auto_update_settings()
-
- # Set checkbox
- self.enable_checkbox.setChecked(settings['enabled'])
-
- # Set frequency
- frequency = settings['frequency']
- if frequency == 'startup':
- self.startup_radio.setChecked(True)
- elif frequency == 'weekly':
- self.weekly_radio.setChecked(True)
- else: # daily
- self.daily_radio.setChecked(True)
-
- # Update status labels
- current_version = get_ytdlp_version()
- self.current_version_label.setText(f"Current yt-dlp version: {current_version}")
-
- last_check = settings['last_check']
- if last_check > 0:
- last_check_time = datetime.fromtimestamp(last_check).strftime("%Y-%m-%d %H:%M:%S")
- self.last_check_label.setText(f"Last update check: {last_check_time}")
- else:
- self.last_check_label.setText("Last update check: Never")
-
- # Calculate next check time
- self.update_next_check_label()
-
- # Update UI state
- self.on_enable_toggled(settings['enabled'])
-
- except Exception as e:
- print(f"Error loading auto-update settings: {e}")
-
- def update_next_check_label(self):
- """Update the next check label based on current settings."""
- try:
- if not self.enable_checkbox.isChecked():
- self.next_check_label.setText("Next check: Disabled")
- return
-
- from ytsage_utils import get_auto_update_settings
- import time
- from datetime import datetime, timedelta
-
- settings = get_auto_update_settings()
- last_check = settings['last_check']
- frequency = self.get_selected_frequency()
-
- if last_check == 0:
- self.next_check_label.setText("Next check: On next startup")
- return
-
- next_check_time = last_check
- if frequency == 'startup':
- next_check_time += 3600 # 1 hour
- elif frequency == 'daily':
- next_check_time += 86400 # 24 hours
- elif frequency == 'weekly':
- next_check_time += 604800 # 7 days
-
- current_time = time.time()
- if next_check_time <= current_time:
- self.next_check_label.setText("Next check: Now (overdue)")
- else:
- next_check_datetime = datetime.fromtimestamp(next_check_time)
- self.next_check_label.setText(f"Next check: {next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
-
- except Exception as e:
- self.next_check_label.setText("Next check: Error calculating")
- print(f"Error calculating next check time: {e}")
-
- def on_enable_toggled(self, enabled):
- """Handle enable/disable checkbox toggle."""
- # Enable/disable frequency options
- for i in range(self.frequency_group.buttons().__len__()):
- self.frequency_group.button(i).setEnabled(enabled)
-
- self.update_next_check_label()
-
- def get_selected_frequency(self):
- """Get the selected frequency setting."""
- if self.startup_radio.isChecked():
- return 'startup'
- elif self.weekly_radio.isChecked():
- return 'weekly'
- else:
- return 'daily'
-
- def manual_check(self):
- """Perform a manual update check."""
- self.manual_check_btn.setEnabled(False)
- self.manual_check_btn.setText("🔄 Checking...")
-
- # Force an immediate update check
- def check_in_thread():
- try:
- from ytsage_utils import check_and_update_ytdlp_auto
- result = check_and_update_ytdlp_auto()
-
- # Update UI in main thread
- QTimer.singleShot(0, lambda: self.manual_check_finished(result))
- except Exception as e:
- print(f"Error during manual check: {e}")
- QTimer.singleShot(0, lambda: self.manual_check_finished(False))
-
- # Run in separate thread to avoid blocking UI
- import threading
- threading.Thread(target=check_in_thread, daemon=True).start()
-
- def _create_styled_message_box(self, icon, title, text):
- """Create a styled QMessageBox that matches the app theme."""
- msg_box = QMessageBox(self)
- msg_box.setIcon(icon)
- msg_box.setWindowTitle(title)
- msg_box.setText(text)
- msg_box.setWindowIcon(self.windowIcon())
- msg_box.setStyleSheet("""
- QMessageBox {
- background-color: #15181b;
- color: #ffffff;
- }
- QMessageBox QLabel {
- color: #ffffff;
- }
- QMessageBox QPushButton {
- padding: 8px 15px;
- background-color: #c90000;
- border: none;
- border-radius: 4px;
- color: white;
- font-weight: bold;
- min-width: 80px;
- }
- QMessageBox QPushButton:hover {
- background-color: #a50000;
- }
- QMessageBox QPushButton:pressed {
- background-color: #800000;
- }
- """)
- return msg_box
-
- def manual_check_finished(self, success):
- """Handle completion of manual update check."""
- self.manual_check_btn.setEnabled(True)
- self.manual_check_btn.setText("🔍 Check for Updates Now")
-
- if success:
- msg_box = self._create_styled_message_box(
- QMessageBox.Information,
- "Update Check",
- "✅ Update check completed successfully!\nCheck the console for details."
- )
- msg_box.exec()
- else:
- msg_box = self._create_styled_message_box(
- QMessageBox.Warning,
- "Update Check",
- "❌ Update check failed.\nCheck the console for error details."
- )
- msg_box.exec()
-
- # Refresh the current settings display
- self.load_current_settings()
-
- def save_settings(self):
- """Save the auto-update settings."""
- try:
- from ytsage_utils import update_auto_update_settings
-
- enabled = self.enable_checkbox.isChecked()
- frequency = self.get_selected_frequency()
-
- if update_auto_update_settings(enabled, frequency):
- msg_box = self._create_styled_message_box(
- QMessageBox.Information,
- "Settings Saved",
- "✅ Auto-update settings have been saved successfully!"
- )
- msg_box.exec()
- self.accept()
- else:
- msg_box = self._create_styled_message_box(
- QMessageBox.Warning,
- "Error",
- "❌ Failed to save auto-update settings.\nPlease try again."
- )
- msg_box.exec()
- except Exception as e:
- print(f"Error saving auto-update settings: {e}")
- msg_box = self._create_styled_message_box(
- QMessageBox.Critical,
- "Error",
- f"❌ Error saving settings: {str(e)}"
- )
- msg_box.exec()
-
-# --- New AutoUpdateThread class ---
-class AutoUpdateThread(QThread):
- """Thread for performing automatic background updates without UI feedback."""
- update_finished = Signal(bool, str) # success (bool), message (str)
-
- def run(self):
- """Perform automatic yt-dlp update check and update if needed."""
- try:
- print("AutoUpdateThread: Performing automatic yt-dlp update check...")
-
- # Import required modules
- import requests
- import time
- from packaging import version as version_parser
- from ytsage_utils import get_ytdlp_version, load_config, save_config
- from ytsage_yt_dlp import get_yt_dlp_path
-
- # Get current version
- current_version = get_ytdlp_version()
- if "Error" in current_version:
- print("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update")
- self.update_finished.emit(False, "Could not determine current yt-dlp version")
- return
-
- # Get latest version from PyPI
- try:
- response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
- response.raise_for_status()
- latest_version = response.json()["info"]["version"]
-
- # Clean up version strings
- current_version = current_version.replace('_', '.')
- latest_version = latest_version.replace('_', '.')
-
- print(f"AutoUpdateThread: Current yt-dlp version: {current_version}")
- print(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}")
-
- # Compare versions
- if version_parser.parse(latest_version) > version_parser.parse(current_version):
- print(f"AutoUpdateThread: Auto-updating yt-dlp from {current_version} to {latest_version}...")
-
- # Perform the update using the same logic as the manual update
- success = self._perform_update()
-
- if success:
- print("AutoUpdateThread: Auto-update completed successfully!")
- # Update the last check timestamp
- config = load_config()
- config['last_update_check'] = time.time()
- save_config(config)
- self.update_finished.emit(True, f"Successfully updated yt-dlp from {current_version} to {latest_version}")
- else:
- print("AutoUpdateThread: Auto-update failed")
- self.update_finished.emit(False, "Auto-update failed")
- else:
- print("AutoUpdateThread: yt-dlp is already up to date")
- # Still update the timestamp even if no update was needed
- config = load_config()
- config['last_update_check'] = time.time()
- save_config(config)
- self.update_finished.emit(True, f"yt-dlp is already up to date (version {current_version})")
-
- except requests.RequestException as e:
- print(f"AutoUpdateThread: Network error during auto-update check: {e}")
- self.update_finished.emit(False, f"Network error: {e}")
- except Exception as e:
- print(f"AutoUpdateThread: Error during auto-update check: {e}")
- self.update_finished.emit(False, f"Update check error: {e}")
-
- except Exception as e:
- print(f"AutoUpdateThread: Critical error in auto-update: {e}")
- self.update_finished.emit(False, f"Critical error: {e}")
-
- def _perform_update(self):
- """Perform the actual update using similar logic to UpdateThread but without UI feedback."""
- try:
- import os
- import sys
- import requests
- import subprocess
- from ytsage_yt_dlp import get_yt_dlp_path
-
- # Get the yt-dlp path
- yt_dlp_path = get_yt_dlp_path()
-
- # Check if we're using an app-managed binary or system installation
- app_managed_dirs = [
- os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
- os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
- os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
- ]
-
- is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
-
- if is_app_managed:
- print("AutoUpdateThread: Updating app-managed yt-dlp binary...")
- return self._update_binary(yt_dlp_path)
- else:
- print("AutoUpdateThread: Updating system yt-dlp via pip...")
- return self._update_via_pip()
-
- except Exception as e:
- print(f"AutoUpdateThread: Error in _perform_update: {e}")
- return False
-
- def _update_binary(self, yt_dlp_path):
- """Update yt-dlp binary directly from GitHub releases (silent version)."""
- try:
- import os
- import sys
- import requests
-
- # Determine the URL based on OS
- if sys.platform == 'win32':
- url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
- elif sys.platform == 'darwin':
- url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
- else:
- url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
-
- print("AutoUpdateThread: Downloading latest yt-dlp binary...")
-
- # Download without progress tracking (silent)
- response = requests.get(url, stream=True)
- if response.status_code != 200:
- print(f"AutoUpdateThread: Download failed: HTTP {response.status_code}")
- return False
-
- temp_file = f"{yt_dlp_path}.new"
-
- with open(temp_file, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if chunk:
- f.write(chunk)
-
- print("AutoUpdateThread: Installing updated binary...")
-
- # Make executable on Unix systems
- if sys.platform != 'win32':
- os.chmod(temp_file, 0o755)
-
- # Replace the old file with the new one
- try:
- # On Windows, we need to remove the old file first
- if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
- os.remove(yt_dlp_path)
-
- os.rename(temp_file, yt_dlp_path)
- print("AutoUpdateThread: Binary successfully updated!")
- return True
-
- except Exception as e:
- print(f"AutoUpdateThread: Error installing binary: {e}")
- # Clean up temp file if it exists
- if os.path.exists(temp_file):
- try:
- os.remove(temp_file)
- except:
- pass
- return False
-
- except Exception as e:
- print(f"AutoUpdateThread: Binary update failed: {e}")
- return False
-
- def _update_via_pip(self):
- """Update yt-dlp via pip (silent version)."""
- try:
- import subprocess
- import sys
- import pkg_resources
- import requests
- from packaging import version
-
- print("AutoUpdateThread: Checking current pip installation...")
-
- # Get current version
- try:
- current_version = pkg_resources.get_distribution("yt-dlp").version
- print(f"AutoUpdateThread: Current version: {current_version}")
- except pkg_resources.DistributionNotFound:
- print("AutoUpdateThread: yt-dlp not found via pip, attempting installation...")
- current_version = "0.0.0"
-
- # Get the latest version from PyPI
- print("AutoUpdateThread: Checking for latest version...")
- response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
-
- if response.status_code != 200:
- print("AutoUpdateThread: Failed to check for updates")
- return False
-
- data = response.json()
- latest_version = data["info"]["version"]
- print(f"AutoUpdateThread: Latest version: {latest_version}")
-
- # Compare versions
- if version.parse(latest_version) > version.parse(current_version):
- print(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...")
-
- # Create startupinfo to hide console on Windows
- startupinfo = None
- if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
- startupinfo = subprocess.STARTUPINFO()
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
- startupinfo.wShowWindow = 0 # SW_HIDE
-
- # Run pip update
- print("AutoUpdateThread: Running pip install --upgrade...")
- update_result = subprocess.run(
- [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
- capture_output=True,
- text=True,
- check=False,
- startupinfo=startupinfo
- )
-
- if update_result.returncode == 0:
- print("AutoUpdateThread: Pip update completed successfully!")
- return True
- else:
- print(f"AutoUpdateThread: Pip update failed: {update_result.stderr}")
- return False
- else:
- print("AutoUpdateThread: yt-dlp is already up to date!")
- return True
-
- except Exception as e:
- print(f"AutoUpdateThread: Pip update failed: {e}")
- return False
\ No newline at end of file