Refactor/code cleanup (#37)

* fix imports
remove unused imports
use full import path
sort import (1. Standard Library, 2. Third-Party, 3. Local) in alphabetic.

* - remove: Method 3 from src.core.ytsage_downlader:cleanup_subtitle_file
  - it could delete the subtitle file of other movies if it present in same directory as it scane recursively.

- refactor: migrate from os.path to pathlib.Path for path handling
  - Replaced os.path methods with pathlib.Path to improve readability,
  - avoid repeatation.
  - cross-platform compatibility, and maintain cleaner code.

- improve: enhance code readability
  - Standardized string literals to use double quotes for consistency
  - Removed unnecessary spaces to maintain cleaner formatting
  - Applied code formatting for better readability and maintainability

* - add: ytsage_constants.py file for one place to store all constants.

- imporve: return type hint for function.

- remove: src/gui/ytsage_gui_dialogs.py file to avoid repetation
  - src/gui/dialogs is renamed to src/gui/ytsage_gui_dialogs for same naming convection. (future import will remains same)
  - use of src/gui/ytsage_gui_dialogs/__init__.py to import the dilogs modules.

- change: variable self.parent to self._parent so it does not overwrite the parent()
  - add type hint checking.

* - refactor: QMetaObject.invokeMethod to Signal
  - I encounter error with incokeMethod. Could not solve it.
  - So, Changed it to Signal to match app code language.

- implement: the ytsage_constants.py to code
  - remove: unnecessary logic
  - remove: repetitive code logic.

- update: yt-dlp logic for src\gui\ytsage_gui_dialogs\ytsage_dialogs_update:_update_binary
  - yt-dlp update logic will use `yt-dlp -U`

* refactor: remove unused imports and streamline code formatting across multiple files
This commit is contained in:
Viren Hirpara
2025-08-26 18:57:12 +05:30
committed by GitHub
parent 6652d7f00f
commit 9c13b4b61c
21 changed files with 2805 additions and 2628 deletions
+60
View File
@@ -0,0 +1,60 @@
"""
Dialog modules for YTSage GUI.
This package contains all dialog classes organized by functionality:
- ytsage_dialogs_base: Base utility dialogs
- ytsage_dialogs_settings: Settings configuration dialogs
- ytsage_dialogs_update: Update-related dialogs and threads
- ytsage_dialogs_ffmpeg: FFmpeg installation dialogs
- ytsage_dialogs_selection: Subtitle and playlist selection dialogs
- ytsage_dialogs_custom: Custom functionality dialogs
"""
# Re-export all dialog classes for backward compatibility
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import (
CookieLoginDialog,
CustomCommandDialog,
CustomOptionsDialog,
TimeRangeDialog,
)
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import (
PlaylistSelectionDialog,
SponsorBlockCategoryDialog,
SubtitleSelectionDialog,
)
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_settings import AutoUpdateSettingsDialog, DownloadSettingsDialog
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_update import AutoUpdateThread, UpdateThread, VersionCheckThread, YTDLPUpdateDialog
__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",
]
@@ -0,0 +1,497 @@
"""
Base dialogs for YTSage application.
Contains basic utility dialogs like LogWindow and AboutDialog.
"""
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QTextEdit,
QVBoxLayout,
QWidget,
)
from src.core.ytsage_ffmpeg import get_ffmpeg_path
from src.core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, refresh_version_cache
from src.core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path
class LogWindow(QDialog):
def __init__(self, parent=None) -> 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) -> None:
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) -> 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) -> QWidget:
"""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(
"<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>YTSage</span>"
)
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
version_label = QLabel(
f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self._parent, 'version', '4.7.0')}</span>"
)
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: <a href='https://github.com/oop7/' style='color: #c90000; text-decoration: none; font-size: 10px;'>oop7</a>"
)
author_label.setOpenExternalLinks(True)
info_layout.addWidget(author_label)
repo_label = QLabel(
"GitHub: <a href='https://github.com/oop7/YTSage/' style='color: #c90000; text-decoration: none; font-size: 10px;'>YTSage</a>"
)
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) -> QWidget:
"""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) -> None:
"""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="") -> QWidget:
"""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} <b>{name}</b>")
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(str(path_text)) > 60:
truncated_path = "..." + str(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) -> None:
"""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 = (
"<span style='color: #4CAF50;'>✓ Detected</span>" if ytdlp_found else "<span style='color: #F44336;'>✗ Missing</span>"
)
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" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # 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 = (
"<span style='color: #4CAF50;'>✓ Detected</span>"
if ffmpeg_found
else "<span style='color: #F44336;'>✗ Missing</span>"
)
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" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # 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) -> None:
"""Refresh version information manually."""
self.refresh_btn.setText("🔄 Refreshing...")
self.refresh_btn.setEnabled(False)
# Perform refresh in a separate thread to avoid blocking UI
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) -> None:
"""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.Icon.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()
@@ -0,0 +1,796 @@
"""
Custom functionality dialogs for YTSage application.
Contains dialogs for custom commands, cookies, time ranges, and other special features.
"""
import subprocess
import threading
from pathlib import Path
from typing import TYPE_CHECKING, cast
from PySide6.QtCore import Q_ARG, QMetaObject, Qt
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPlainTextEdit,
QPushButton,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from src.core.ytsage_yt_dlp import get_yt_dlp_path
try:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import)
class CustomCommandDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._parent = self.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) -> None:
url = self._parent.url_input.text().strip() # type: ignore[reportAttributeAccessIssue]
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() # type: ignore[reportAttributeAccessIssue]
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) -> None:
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) -> 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.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def browse_cookie_file(self) -> None:
# Open file dialog to select cookie file
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
if selected_files:
self.cookie_path_input.setText(selected_files[0])
def get_cookie_file_path(self) -> str:
# Return the selected cookie file path
return self.cookie_path_input.text()
class CustomOptionsDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._parent: YTSageApp = cast("YTSageApp", self.parent()) # cast will help with auto complete and type hint checking.
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(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
self.cookie_path_input.setText(self._parent.cookie_file_path.as_posix())
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.StandardButton.Ok | QDialogButtonBox.StandardButton.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) -> None:
# Open file dialog to select cookie file
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
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) -> Path | None:
# Return the selected cookie file path if it's not empty
path = Path(self.cookie_path_input.text().strip())
if path and path.exists():
return path
return None
def run_custom_command(self) -> None:
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) -> None:
try:
class CommandLogger:
def debug(self, msg):
QMetaObject.invokeMethod(
self.dialog.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, msg),
)
def warning(self, msg):
QMetaObject.invokeMethod(
self.dialog.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Warning: {msg}"),
)
def error(self, msg):
QMetaObject.invokeMethod(
self.dialog.log_output,
b"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,
b"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: # type: ignore[reportOptionalIterable]
QMetaObject.invokeMethod(
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, line.rstrip()),
)
ret = proc.wait()
if ret != 0:
QMetaObject.invokeMethod(
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Command exited with code {ret}"),
)
else:
QMetaObject.invokeMethod(
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, "Command completed successfully"),
)
except Exception as e:
QMetaObject.invokeMethod(
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Error: {str(e)}"),
)
finally:
# Re-enable the run button
QMetaObject.invokeMethod(
self.run_btn,
b"setEnabled",
Qt.ConnectionType.QueuedConnection,
Q_ARG(bool, True),
)
class TimeRangeDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(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.StandardButton.Ok | QDialogButtonBox.StandardButton.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) -> None:
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) -> str | None:
"""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) -> bool:
"""Returns whether to force keyframes at cuts"""
return self.force_keyframes.isChecked()
@@ -0,0 +1,190 @@
"""
FFmpeg installation dialogs for YTSage application.
Contains dialogs and threads for checking and installing FFmpeg.
"""
import contextlib
import webbrowser
from io import StringIO
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from src.core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
from src.utils.ytsage_constants import ICON_PATH
class FFmpegInstallThread(QThread):
finished = Signal(bool)
progress = Signal(str)
def run(self) -> None:
# 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) -> 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
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
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) -> None:
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) -> None:
self.progress_label.setText(message)
def installation_finished(self, success) -> None:
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)
@@ -0,0 +1,679 @@
"""
Selection dialogs for YTSage application.
Contains dialogs for selecting subtitles, playlist videos, and SponsorBlock categories.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
class SubtitleSelectionDialog(QDialog):
def __init__(self, available_manual, available_auto, previously_selected, parent=None) -> 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="") -> None:
# 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) -> None:
self.populate_list(self.filter_input.text())
def update_selection(self, state) -> None:
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) -> list:
# Return the final set as a list
return list(self.previously_selected)
def accept(self) -> None:
# 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) -> 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) -> set:
"""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) -> None:
"""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) -> None:
for checkbox in self.checkboxes:
checkbox.setChecked(True)
def _deselect_all(self) -> None:
for checkbox in self.checkboxes:
checkbox.setChecked(False)
def _condense_indices(self, indices: list[int]) -> str:
"""Condenses a list of 1-based indices into a yt-dlp selection string."""
if not indices:
return ""
# Remove duplicates and sort in one step
indices = sorted(set(indices))
ranges = []
start = end = indices[0]
for num in indices[1:]:
if num == end + 1:
end = num
else:
ranges.append(f"{start}-{end}" if start != end else str(start))
start = end = num
# Append the last range
ranges.append(f"{start}-{end}" if start != end else str(start))
return ",".join(ranges)
def get_selected_items_string(self) -> str | None:
"""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) -> 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) -> None:
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) -> str:
"""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) -> None:
"""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) -> None:
"""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) -> None:
"""Select all categories."""
for checkbox in self.checkboxes.values():
checkbox.setChecked(True)
def deselect_all(self) -> None:
"""Deselect all categories."""
for checkbox in self.checkboxes.values():
checkbox.setChecked(False)
def get_selected_categories(self) -> list:
"""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) -> str:
"""Returns a comma-separated string of selected categories for yt-dlp."""
selected = self.get_selected_categories()
return ",".join(selected) if selected else ""
@@ -0,0 +1,743 @@
"""
Settings-related dialogs for YTSage application.
Contains dialogs for configuring download settings and auto-update preferences.
"""
import time
from datetime import datetime
import requests
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QRadioButton,
QVBoxLayout,
)
from src.core.ytsage_logging import logger
from src.core.ytsage_utils import (
check_and_update_ytdlp_auto,
get_auto_update_settings,
get_ytdlp_version,
update_auto_update_settings,
)
class DownloadSettingsDialog(QDialog):
def __init__(self, current_path, current_limit, current_unit_index, parent=None) -> 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) -> None:
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) -> str:
"""Returns the confirmed path after the dialog is accepted."""
return self.current_path
def get_selected_speed_limit(self) -> str | None:
"""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) -> int:
"""Returns the index of the selected speed limit unit."""
return self.speed_limit_unit.currentIndex()
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
"""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) -> None:
"""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.Icon.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.Icon.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.Icon.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.Icon.Warning,
"Update Check",
f"Error checking for updates: {str(e)}",
)
msg_box.exec()
def get_auto_update_settings(self) -> tuple[bool, str]:
"""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) -> None:
"""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) -> 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) -> None:
layout = QVBoxLayout(self)
# Title
title_label = QLabel("<h2>🔄 Auto-Update Settings</h2>")
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) -> None:
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) -> None:
"""Load current auto-update settings from config."""
try:
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) -> None:
"""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
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) -> None:
"""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) -> str:
"""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) -> None:
"""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() -> None:
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) -> QMessageBox:
"""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) -> None:
"""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.Icon.Information,
"Update Check",
"✅ Update check completed successfully!\nCheck the console for details.",
)
msg_box.exec()
else:
msg_box = self._create_styled_message_box(
QMessageBox.Icon.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) -> None:
"""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.Icon.Information,
"Settings Saved",
"✅ Auto-update settings have been saved successfully!",
)
msg_box.exec()
self.accept()
else:
msg_box = self._create_styled_message_box(
QMessageBox.Icon.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.Icon.Critical, "Error", f"❌ Error saving settings: {str(e)}")
msg_box.exec()
@@ -0,0 +1,649 @@
"""
Update-related dialogs and threads for YTSage application.
Contains dialogs and background threads for checking and performing yt-dlp updates.
"""
import os
import subprocess
import sys
import time
from pathlib import Path
import requests
from packaging import version
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout
from src.core.ytsage_logging import logger
from src.core.ytsage_utils import get_ytdlp_version, load_config, save_config
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL
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) -> None:
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
creationflags=SUBPROCESS_CREATIONFLAGS,
)
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__ # type: ignore[reportAttributeAccessIssue]
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__ # type: ignore[reportAttributeAccessIssue]
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__ # type: ignore[reportAttributeAccessIssue]
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) -> None:
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: {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
# Extra logic moved to src\utils\ytsage_constants.py
self.update_progress.emit(20)
# Extra logic moved to src\utils\ytsage_constants.py
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
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()
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: Path) -> bool:
"""Update yt-dlp binary using its built-in updater (same logic as AutoUpdateThread)."""
try:
logger.info("UpdateThread: Checking for yt-dlp updates...")
result = subprocess.run(
[yt_dlp_path, "-U"],
capture_output=True,
text=True,
timeout=60,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if result.returncode == 0:
# Make executable on Unix systems
if OS_NAME != "Windows":
os.chmod(yt_dlp_path, 0o755)
logger.info("UpdateThread: yt-dlp update completed successfully.")
if result.stdout:
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
self.update_status.emit("✅ Binary successfully updated!")
self.update_progress.emit(95)
return True
else:
logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}")
self.update_status.emit(f"❌ yt-dlp update failed: {result.stderr.strip()}")
return False
except subprocess.TimeoutExpired:
logger.error("UpdateThread: yt-dlp update timed out.")
self.update_status.emit("❌ yt-dlp update timed out.")
return False
except Exception as e:
logger.error(f"UpdateThread: Unexpected error during update: {e}", exc_info=True)
self.update_status.emit(f"❌ Unexpected error during update: {e}")
return False
def _update_via_pip(self) -> bool:
"""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
creationflags=SUBPROCESS_CREATIONFLAGS,
)
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) -> 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) -> None:
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) -> None:
# 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) -> None:
# 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) -> None:
"""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) -> None:
"""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) -> None:
"""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) -> None:
"""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) -> None:
"""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) -> None:
"""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) -> bool:
"""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()
# Extra logic moved to src\utils\ytsage_constants.py
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
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: Path) -> bool:
"""Update yt-dlp binary using its built-in updater."""
try:
logger.info("AutoUpdateThread: Checking for yt-dlp updates...")
result = subprocess.run(
[yt_dlp_path, "-U"],
capture_output=True,
text=True,
timeout=60,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if result.returncode == 0:
# Make executable on Unix systems
if OS_NAME != "Windows":
os.chmod(yt_dlp_path, 0o755)
logger.info("AutoUpdateThread: yt-dlp update completed successfully.")
if result.stdout:
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
return True
else:
logger.error(f"AutoUpdateThread: yt-dlp update failed. {result.stderr.strip()}")
return False
except subprocess.TimeoutExpired:
logger.error("AutoUpdateThread: yt-dlp update timed out.")
return False
except Exception as e:
logger.error(f"AutoUpdateThread: Unexpected error during update: {e}", exc_info=True)
return False
def _update_via_pip(self) -> bool:
"""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}...")
# Extra logic moved to src\utils\ytsage_constants.py
# 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,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
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