Refactor GUI package structure and update imports
Moved all files from src/gui/ to ytsage/gui/ and updated import statements to use relative imports within the new package structure. This improves modularity and prepares the codebase for distribution as a proper Python package.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
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 .ytsage_dialogs_base import AboutDialog, LogWindow
|
||||
from .ytsage_dialogs_custom import CustomOptionsDialog, TimeRangeDialog
|
||||
from .ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
|
||||
from .ytsage_dialogs_history import HistoryDialog
|
||||
from .ytsage_dialogs_selection import (
|
||||
PlaylistSelectionDialog,
|
||||
SponsorBlockCategoryDialog,
|
||||
SubtitleSelectionDialog,
|
||||
)
|
||||
from .ytsage_dialogs_settings import AutoUpdateSettingsDialog, DownloadSettingsDialog
|
||||
from .ytsage_dialogs_update import AutoUpdateThread, UpdateThread, VersionCheckThread, YTDLPUpdateDialog
|
||||
from .ytsage_dialogs_updater import UpdaterTabWidget
|
||||
|
||||
__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
|
||||
"CustomOptionsDialog",
|
||||
"TimeRangeDialog",
|
||||
|
||||
# Updater widget
|
||||
"UpdaterTabWidget",
|
||||
|
||||
# History dialog
|
||||
"HistoryDialog",
|
||||
]
|
||||
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
Base dialogs for YTSage application.
|
||||
Contains basic utility dialogs like LogWindow and AboutDialog.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ... import __version__ as APP_VERSION
|
||||
from ...utils.ytsage_localization import _
|
||||
|
||||
from ...core.ytsage_ffmpeg import get_ffmpeg_path
|
||||
from ...core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, get_deno_version, refresh_version_cache
|
||||
from ...core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path
|
||||
from ...core.ytsage_deno import check_deno_installed, get_deno_path
|
||||
|
||||
|
||||
class LogWindow(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("dialogs.ytdlp_log_title"))
|
||||
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.title"))
|
||||
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;'>"
|
||||
f"{_('about.version', version=getattr(self._parent, 'version', APP_VERSION))}</span>"
|
||||
)
|
||||
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(version_label)
|
||||
|
||||
# Description - more compact
|
||||
description_label = QLabel(_("about.description"))
|
||||
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_link = '<a href="https://github.com/oop7/" style="color: #c90000; text-decoration: none; font-size: 10px;">oop7</a>'
|
||||
author_label = QLabel(
|
||||
f"{_('about.author', author=author_link)}"
|
||||
)
|
||||
author_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(author_label)
|
||||
|
||||
repo_link = '<a href="https://github.com/oop7/YTSage/" style="color: #c90000; text-decoration: none; font-size: 10px;">YTSage</a>'
|
||||
repo_label = QLabel(
|
||||
f"{_('about.github', repo=repo_link)}"
|
||||
)
|
||||
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(_("about.system_info"))
|
||||
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(_("about.refresh"))
|
||||
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(_("about.loading"))
|
||||
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 = (
|
||||
f"<span style='color: #4CAF50;'>{_('about.detected')}</span>" if ytdlp_found else f"<span style='color: #F44336;'>{_('about.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:
|
||||
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 = (
|
||||
f"<span style='color: #4CAF50;'>{_('about.detected')}</span>"
|
||||
if ffmpeg_found
|
||||
else f"<span style='color: #F44336;'>{_('about.missing')}</span>"
|
||||
)
|
||||
ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else _('about.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:
|
||||
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)
|
||||
|
||||
# Deno Status - compact version with path (only show path if in app bin directory)
|
||||
deno_found = check_deno_installed()
|
||||
deno_status_text = (
|
||||
f"<span style='color: #4CAF50;'>{_('about.detected')}</span>" if deno_found else f"<span style='color: #F44336;'>{_('about.missing')}</span>"
|
||||
)
|
||||
deno_version = get_deno_version() if deno_found else _('about.not_available')
|
||||
|
||||
# Get Deno path - only show if in app bin directory
|
||||
deno_path_text = None
|
||||
if deno_found:
|
||||
deno_path = get_deno_path()
|
||||
# Only show path if it's not the fallback "deno" and the file exists
|
||||
if deno_path and deno_path != "deno":
|
||||
from pathlib import Path
|
||||
from ...utils.ytsage_constants import DENO_APP_BIN_PATH
|
||||
# Check if the path is our managed binary
|
||||
if Path(deno_path).resolve() == DENO_APP_BIN_PATH.resolve():
|
||||
deno_path_text = deno_path
|
||||
|
||||
# Simplified cache status for Deno
|
||||
deno_cache = _version_cache.get("deno", {})
|
||||
last_check = deno_cache.get("last_check", 0)
|
||||
cache_status = ""
|
||||
if last_check > 0 and deno_found:
|
||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>"
|
||||
|
||||
deno_item = self._create_status_item(
|
||||
"🦕",
|
||||
"Deno",
|
||||
deno_status_text,
|
||||
deno_version + cache_status,
|
||||
deno_path_text,
|
||||
)
|
||||
self.status_container.addWidget(deno_item)
|
||||
|
||||
def refresh_version_info(self) -> None:
|
||||
"""Refresh version information manually."""
|
||||
self.refresh_btn.setText(_('about.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(_('about.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(_('about.refresh_failed'))
|
||||
msg_box.setText(_('about.refresh_failed_message'))
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
FFmpeg installation dialogs for YTSage application.
|
||||
Contains dialogs and threads for checking and installing FFmpeg.
|
||||
"""
|
||||
|
||||
import webbrowser
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from ...core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
|
||||
from ...utils.ytsage_constants import ICON_PATH
|
||||
from ...utils.ytsage_localization import _
|
||||
|
||||
|
||||
class FFmpegInstallThread(QThread):
|
||||
finished = Signal(bool)
|
||||
progress = Signal(str)
|
||||
|
||||
def run(self) -> None:
|
||||
# Use a callback to capture progress instead of stdout redirection
|
||||
def progress_callback(msg: str):
|
||||
self.progress.emit(msg)
|
||||
|
||||
# Install FFmpeg with progress callback
|
||||
success = auto_install_ffmpeg(progress_callback=progress_callback)
|
||||
self.finished.emit(success)
|
||||
|
||||
|
||||
class FFmpegCheckDialog(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("ffmpeg.installation_title"))
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(280)
|
||||
self.resize(500, 300)
|
||||
|
||||
# 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(str(ICON_PATH)))
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Header with title and improved spacing
|
||||
header_text = QLabel(_("ffmpeg.installation_title"))
|
||||
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(_("ffmpeg.installation_message"))
|
||||
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
|
||||
self.progress_label = QLabel("")
|
||||
self.progress_label.setWordWrap(True)
|
||||
self.progress_label.setMinimumHeight(80)
|
||||
self.progress_label.setMaximumHeight(120)
|
||||
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
||||
self.progress_label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
background-color: #1d1e22;
|
||||
color: #cccccc;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.progress_label.hide()
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
# Add stretch to push buttons to bottom
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons container - simple approach that should work
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(12)
|
||||
|
||||
# Install button
|
||||
self.install_btn = QPushButton(_("ffmpeg.install_button"))
|
||||
self.install_btn.clicked.connect(self.start_installation)
|
||||
button_layout.addWidget(self.install_btn)
|
||||
|
||||
# Manual install button
|
||||
self.manual_btn = QPushButton(_("ffmpeg.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(_("buttons.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
|
||||
self.progress_messages = [] # Store progress messages
|
||||
|
||||
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.already_installed"))
|
||||
self.progress_label.setText(_("ffmpeg.installation_complete"))
|
||||
self.progress_label.show()
|
||||
self.install_btn.hide()
|
||||
self.manual_btn.hide()
|
||||
self.close_btn.setEnabled(True)
|
||||
return
|
||||
|
||||
self.message_label.setText(_("ffmpeg.installing"))
|
||||
self.progress_messages = [] # Clear previous messages
|
||||
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:
|
||||
# Keep only the last 5 messages to avoid overflow
|
||||
self.progress_messages.append(message)
|
||||
if len(self.progress_messages) > 5:
|
||||
self.progress_messages.pop(0)
|
||||
|
||||
# Display the messages
|
||||
self.progress_label.setText("\n".join(self.progress_messages))
|
||||
|
||||
def installation_finished(self, success) -> None:
|
||||
if success:
|
||||
self.message_label.setText(_("ffmpeg.install_success"))
|
||||
self.progress_label.setText(_("ffmpeg.installation_complete_close"))
|
||||
self.install_btn.hide()
|
||||
self.manual_btn.hide()
|
||||
else:
|
||||
self.message_label.setText(_("ffmpeg.installation_failed"))
|
||||
self.progress_label.setText(_("ffmpeg.try_manual"))
|
||||
self.install_btn.setEnabled(True)
|
||||
self.manual_btn.setEnabled(True)
|
||||
|
||||
self.close_btn.setEnabled(True)
|
||||
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
History Dialog for YTSage application.
|
||||
Displays download history with thumbnails using a virtualized list for performance.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, List, Any, Dict
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import (
|
||||
Qt, QSize, Signal, QThread, QTimer, QAbstractListModel,
|
||||
QModelIndex, QRect, QPoint, QEvent
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QPixmap, QIcon, QPainter, QColor, QFont, QBrush, QPen,
|
||||
QMouseEvent, QDesktopServices, QAction, QCursor, QPainterPath
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QListView,
|
||||
QWidget,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QStyledItemDelegate,
|
||||
QStyle,
|
||||
QApplication
|
||||
)
|
||||
|
||||
from ...utils.ytsage_history_manager import HistoryManager
|
||||
from ...utils.ytsage_constants import APP_THUMBNAILS_DIR, SUBPROCESS_CREATIONFLAGS
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class HistoryLoaderThread(QThread):
|
||||
"""Thread to load history and pre-fetch thumbnails."""
|
||||
|
||||
entries_loaded = Signal(list)
|
||||
thumbnail_loaded = Signal(str, bytes) # entry_id, image_bytes
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# Load entries from DB
|
||||
entries = HistoryManager.get_all_entries()
|
||||
self.entries_loaded.emit(entries)
|
||||
|
||||
# Background thumbnail loader
|
||||
for entry in entries:
|
||||
if self.isInterruptionRequested():
|
||||
break
|
||||
|
||||
thumbnail_url = entry.get("thumbnail_url")
|
||||
entry_id = entry.get("id", "")
|
||||
|
||||
if not thumbnail_url or not entry_id:
|
||||
continue
|
||||
|
||||
thumbnail_filename = f"{entry_id}.jpg"
|
||||
thumbnail_path = APP_THUMBNAILS_DIR / thumbnail_filename
|
||||
|
||||
if not thumbnail_path.exists():
|
||||
try:
|
||||
response = requests.get(thumbnail_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Optimize image before saving
|
||||
img_io = BytesIO(response.content)
|
||||
image = Image.open(img_io)
|
||||
|
||||
# Save to disk
|
||||
image.save(thumbnail_path, "JPEG", quality=90, optimize=True)
|
||||
|
||||
# Emit bytes for memory cache
|
||||
self.thumbnail_loaded.emit(entry_id, response.content)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error caching thumbnail: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading history: {e}")
|
||||
self.entries_loaded.emit([])
|
||||
|
||||
|
||||
class HistoryModel(QAbstractListModel):
|
||||
"""List Model for History Entries."""
|
||||
|
||||
EntryRole = Qt.ItemDataRole.UserRole + 1
|
||||
IdRole = Qt.ItemDataRole.UserRole + 2
|
||||
ThumbnailRole = Qt.ItemDataRole.UserRole + 3
|
||||
|
||||
def __init__(self, entries=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._entries = entries or []
|
||||
self.thumbnail_cache = {} # Map entry_id -> QPixmap
|
||||
|
||||
def rowCount(self, parent=QModelIndex()):
|
||||
return len(self._entries)
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid() or not (0 <= index.row() < len(self._entries)):
|
||||
return None
|
||||
|
||||
entry = self._entries[index.row()]
|
||||
entry_id = entry.get("id")
|
||||
|
||||
if role == self.EntryRole:
|
||||
return entry
|
||||
|
||||
elif role == self.IdRole:
|
||||
return entry_id
|
||||
|
||||
elif role == self.ThumbnailRole:
|
||||
return self.thumbnail_cache.get(entry_id)
|
||||
|
||||
elif role == Qt.ItemDataRole.DisplayRole:
|
||||
return entry.get("title", "")
|
||||
|
||||
return None
|
||||
|
||||
def update_entries(self, entries):
|
||||
self.beginResetModel()
|
||||
self._entries = entries
|
||||
self.endResetModel()
|
||||
|
||||
def remove_item(self, row):
|
||||
if 0 <= row < len(self._entries):
|
||||
self.beginRemoveRows(QModelIndex(), row, row)
|
||||
del self._entries[row]
|
||||
self.endRemoveRows()
|
||||
|
||||
def update_thumbnail(self, entry_id, pixmap):
|
||||
"""Update cache and notify view."""
|
||||
self.thumbnail_cache[entry_id] = pixmap
|
||||
# Find index for this ID
|
||||
for i, entry in enumerate(self._entries):
|
||||
if entry.get("id") == entry_id:
|
||||
idx = self.index(i)
|
||||
self.dataChanged.emit(idx, idx, [self.ThumbnailRole])
|
||||
break
|
||||
|
||||
|
||||
class HistoryDelegate(QStyledItemDelegate):
|
||||
"""Delegate to render history cards similar to the widgets."""
|
||||
|
||||
menu_clicked = Signal(QModelIndex, QPoint) # Signal for menu click
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.padding = 10
|
||||
self.thumb_width = 240
|
||||
self.thumb_height = 135
|
||||
# Increased card height to accommodate spacing
|
||||
self.card_height = 175
|
||||
# Define margins for spacing between cards
|
||||
self.h_margin = 10
|
||||
self.v_margin = 8
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
return QSize(option.rect.width(), self.card_height)
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
entry = index.data(HistoryModel.EntryRole)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
rect = option.rect
|
||||
# Apply margins for spacing
|
||||
card_rect = rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
|
||||
|
||||
is_hover = option.state & QStyle.StateFlag.State_MouseOver
|
||||
bg_color = QColor("#252830") if is_hover else QColor("#1d1e22")
|
||||
border_color = QColor("#3a3d46") if is_hover else QColor("#2a2d36")
|
||||
|
||||
# Draw Card
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(card_rect, 8, 8)
|
||||
|
||||
painter.fillPath(path, QBrush(bg_color))
|
||||
painter.setPen(QPen(border_color, 1))
|
||||
painter.drawPath(path)
|
||||
|
||||
# Draw Thumbnail
|
||||
thumb_rect = QRect(
|
||||
card_rect.left() + 10,
|
||||
card_rect.top() + 10,
|
||||
self.thumb_width,
|
||||
self.thumb_height
|
||||
)
|
||||
|
||||
pixmap = index.data(HistoryModel.ThumbnailRole)
|
||||
if pixmap and not pixmap.isNull():
|
||||
scaled = pixmap.scaled(
|
||||
thumb_rect.size(),
|
||||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
# Clip to rect
|
||||
painter.setClipRect(thumb_rect)
|
||||
painter.drawPixmap(thumb_rect.topLeft(), scaled)
|
||||
painter.setClipping(False)
|
||||
else:
|
||||
painter.fillRect(thumb_rect, QColor("#15181b"))
|
||||
painter.setPen(QPen(QColor("#666666")))
|
||||
icon_char = "🎵" if entry.get("is_audio_only") else "📹"
|
||||
painter.setFont(QFont("Segoe UI Emoji", 24))
|
||||
painter.drawText(thumb_rect, Qt.AlignmentFlag.AlignCenter, icon_char)
|
||||
|
||||
# Draw Border around thumb
|
||||
painter.setPen(QPen(QColor("#3d3d3d"), 2))
|
||||
painter.drawRect(thumb_rect)
|
||||
|
||||
# Text Area
|
||||
text_x = thumb_rect.right() + 12
|
||||
text_width = card_rect.right() - text_x - 85 # Leave even more space for the larger 60px button
|
||||
|
||||
# Title
|
||||
title_rect = QRect(text_x, thumb_rect.top(), text_width, 50)
|
||||
painter.setPen(QColor("#ffffff"))
|
||||
font_title = QFont()
|
||||
font_title.setBold(True)
|
||||
font_title.setPixelSize(14)
|
||||
painter.setFont(font_title)
|
||||
|
||||
# Use simple alignment flags
|
||||
painter.drawText(title_rect, Qt.AlignmentFlag.AlignLeft | Qt.TextFlag.TextWordWrap, entry.get("title", ""))
|
||||
|
||||
current_y = title_rect.bottom() + 5
|
||||
|
||||
# Channel
|
||||
channel = entry.get("channel")
|
||||
if channel:
|
||||
painter.setPen(QColor("#cccccc"))
|
||||
font_meta = QFont()
|
||||
font_meta.setPixelSize(12)
|
||||
painter.setFont(font_meta)
|
||||
painter.drawText(text_x, current_y, f"{_('video_info.channel')}: {channel}")
|
||||
current_y += 18
|
||||
|
||||
# Date
|
||||
date_str = entry.get("download_date", "")[:16].replace('T', ' ')
|
||||
if date_str:
|
||||
painter.setPen(QColor("#aaaaaa"))
|
||||
painter.setFont(QFont("Arial", 11))
|
||||
painter.drawText(text_x, current_y, f"{_('history.downloaded_on', date=date_str)}")
|
||||
current_y += 25
|
||||
|
||||
# Badge
|
||||
is_audio = entry.get("is_audio_only", False)
|
||||
badge_text = _("history.audio_download") if is_audio else _("history.video_download")
|
||||
badge_color = QColor("#0066cc") if is_audio else QColor("#c90000")
|
||||
|
||||
badge_rect = QRect(text_x, current_y, 80, 20)
|
||||
painter.setBrush(QBrush(badge_color))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(badge_rect, 3, 3)
|
||||
|
||||
painter.setPen(QColor("white"))
|
||||
font_badge = QFont()
|
||||
font_badge.setBold(True)
|
||||
font_badge.setPixelSize(10)
|
||||
painter.setFont(font_badge)
|
||||
painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge_text)
|
||||
|
||||
# File Size
|
||||
file_size = entry.get("file_size", 0)
|
||||
if file_size > 0:
|
||||
size_str = self.format_file_size(file_size)
|
||||
painter.setPen(QColor("#aaaaaa"))
|
||||
painter.setFont(QFont("Arial", 11))
|
||||
painter.drawText(badge_rect.right() + 10, current_y + 14, size_str)
|
||||
|
||||
# Menu Button
|
||||
menu_rect = self.get_menu_rect(card_rect)
|
||||
|
||||
# Check hover on menu button specifically
|
||||
mouse_pos = QCursor.pos()
|
||||
if option.widget:
|
||||
mouse_pos = option.widget.mapFromGlobal(mouse_pos)
|
||||
|
||||
if menu_rect.contains(mouse_pos):
|
||||
painter.setPen(QColor("#c90000"))
|
||||
else:
|
||||
painter.setPen(QColor("#ffffff"))
|
||||
|
||||
painter.setFont(QFont("Arial", 18, QFont.Weight.Bold))
|
||||
painter.drawText(menu_rect, Qt.AlignmentFlag.AlignCenter, "⋮")
|
||||
|
||||
painter.restore()
|
||||
|
||||
def get_menu_rect(self, card_rect):
|
||||
# Widen the clickable area significantly (60x50) and shift slightly left
|
||||
# to fix "left side not working" issues.
|
||||
w = 60
|
||||
h = 50
|
||||
margin_right = 5
|
||||
margin_top = 5
|
||||
return QRect(
|
||||
card_rect.right() - w - margin_right,
|
||||
card_rect.top() + margin_top,
|
||||
w,
|
||||
h
|
||||
)
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
"""Handle mouse clicks."""
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
# Use same margins as paint to ensure hit consistency
|
||||
card_rect = option.rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
|
||||
menu_rect = self.get_menu_rect(card_rect)
|
||||
|
||||
if menu_rect.contains(event.pos()):
|
||||
self.menu_clicked.emit(index, event.globalPos())
|
||||
return True
|
||||
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
def format_file_size(self, size_bytes: int) -> str:
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} TB"
|
||||
|
||||
|
||||
class HistoryDialog(QDialog):
|
||||
"""Dialog to display and manage download history."""
|
||||
|
||||
redownload_requested = Signal(dict)
|
||||
|
||||
def __init__(self, parent: Optional["YTSageApp"] = None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent
|
||||
|
||||
self.setup_ui()
|
||||
self.show_loading_state()
|
||||
|
||||
QTimer.singleShot(100, self.start_loading_history)
|
||||
|
||||
def setup_ui(self):
|
||||
self.setWindowTitle(_("history.title"))
|
||||
self.resize(850, 600)
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
||||
self.setStyleSheet("""
|
||||
QDialog { background-color: #15181b; }
|
||||
QLabel { color: #ffffff; }
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# --- Header ---
|
||||
header = QHBoxLayout()
|
||||
title = QLabel(_("history.title"))
|
||||
title.setStyleSheet("font-size: 18px; font-weight: bold;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
|
||||
self.clear_btn = QPushButton(_("history.clear_all"))
|
||||
self.clear_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #c90000; color: white; padding: 6px 12px;
|
||||
border: none; border-radius: 4px; font-weight: bold;
|
||||
}
|
||||
QPushButton:hover { background-color: #a50000; }
|
||||
QPushButton:disabled { background-color: #555555; color: #aaaaaa; }
|
||||
""")
|
||||
self.clear_btn.clicked.connect(self.clear_all_history)
|
||||
header.addWidget(self.clear_btn)
|
||||
layout.addLayout(header)
|
||||
|
||||
# --- Search ---
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText(_("history.search_placeholder"))
|
||||
self.search_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
padding: 8px; border: 2px solid #2a2d36; border-radius: 4px;
|
||||
background-color: #1b2021; color: white;
|
||||
}
|
||||
""")
|
||||
self.search_input.textChanged.connect(self.filter_history)
|
||||
layout.addWidget(self.search_input)
|
||||
|
||||
# --- List View ---
|
||||
self.list_view = QListView()
|
||||
self.list_view.setStyleSheet("""
|
||||
QListView {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
QListView::item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
""")
|
||||
self.list_view.setVerticalScrollMode(QListView.ScrollMode.ScrollPerPixel)
|
||||
self.list_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.list_view.setUniformItemSizes(True)
|
||||
self.list_view.setSelectionMode(QListView.SelectionMode.NoSelection)
|
||||
self.list_view.setMouseTracking(True)
|
||||
self.list_view.setResizeMode(QListView.ResizeMode.Adjust)
|
||||
|
||||
self.model = HistoryModel([], self)
|
||||
self.list_view.setModel(self.model)
|
||||
|
||||
self.delegate = HistoryDelegate(self.list_view)
|
||||
self.delegate.menu_clicked.connect(self.show_context_menu)
|
||||
self.list_view.setItemDelegate(self.delegate)
|
||||
|
||||
self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.list_view.customContextMenuRequested.connect(self.on_context_menu_requested)
|
||||
|
||||
layout.addWidget(self.list_view)
|
||||
|
||||
# --- Status ---
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
def show_loading_state(self):
|
||||
self.status_label.setText(_("history.loading"))
|
||||
self.clear_btn.setEnabled(False)
|
||||
|
||||
def start_loading_history(self):
|
||||
self.loader_thread = HistoryLoaderThread()
|
||||
self.loader_thread.entries_loaded.connect(self.on_entries_loaded)
|
||||
self.loader_thread.thumbnail_loaded.connect(self.on_thumbnail_loaded)
|
||||
self.loader_thread.start()
|
||||
|
||||
def on_entries_loaded(self, entries):
|
||||
self.model.update_entries(entries)
|
||||
|
||||
count = len(entries)
|
||||
if count == 0:
|
||||
self.status_label.setText(_("history.no_history"))
|
||||
self.clear_btn.setEnabled(False)
|
||||
else:
|
||||
self.status_label.setText(_("history.entries_count", count=count))
|
||||
self.clear_btn.setEnabled(True)
|
||||
|
||||
self.load_cached_thumbnails(entries)
|
||||
|
||||
def load_cached_thumbnails(self, entries):
|
||||
for entry in entries:
|
||||
eid = entry.get("id")
|
||||
if not eid: continue
|
||||
|
||||
p = APP_THUMBNAILS_DIR / f"{eid}.jpg"
|
||||
if p.exists():
|
||||
pix = QPixmap(str(p))
|
||||
if not pix.isNull():
|
||||
self.model.thumbnail_cache[eid] = pix
|
||||
|
||||
def on_thumbnail_loaded(self, entry_id, data_bytes):
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(data_bytes)
|
||||
if not pixmap.isNull():
|
||||
self.model.update_thumbnail(entry_id, pixmap)
|
||||
|
||||
def on_context_menu_requested(self, pos):
|
||||
index = self.list_view.indexAt(pos)
|
||||
if index.isValid():
|
||||
global_pos = self.list_view.mapToGlobal(pos)
|
||||
self.show_context_menu(index, global_pos)
|
||||
|
||||
def show_context_menu(self, index, global_pos):
|
||||
entry = index.data(HistoryModel.EntryRole)
|
||||
if not entry: return
|
||||
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("""
|
||||
QMenu {
|
||||
background-color: #2a2d36; border: 1px solid #3a3d46; color: white;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 8px 20px;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #c90000;
|
||||
}
|
||||
""")
|
||||
|
||||
act_open = menu.addAction("📁 " + _("history.open_location"))
|
||||
act_redownload = menu.addAction("⬇️ " + _("history.redownload"))
|
||||
menu.addSeparator()
|
||||
act_remove = menu.addAction("🗑️ " + _("history.remove"))
|
||||
|
||||
action = menu.exec(global_pos)
|
||||
|
||||
if action == act_open:
|
||||
self.open_file_location(entry)
|
||||
elif action == act_redownload:
|
||||
self.redownload_entry(entry)
|
||||
elif action == act_remove:
|
||||
self.remove_entry(index)
|
||||
|
||||
def open_file_location(self, entry):
|
||||
path_str = entry.get("file_path", "")
|
||||
if not path_str: return
|
||||
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
QMessageBox.warning(self, _("main_ui.error_title"), _("history.file_not_found_message", path=path))
|
||||
return
|
||||
|
||||
try:
|
||||
if os.name == "nt":
|
||||
subprocess.run(['explorer', '/select,', str(path)], creationflags=SUBPROCESS_CREATIONFLAGS)
|
||||
elif subprocess.sys.platform == "darwin":
|
||||
subprocess.run(['open', '-R', str(path)])
|
||||
else:
|
||||
folder_path = path.parent
|
||||
subprocess.run(['xdg-open', str(folder_path)])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file: {e}")
|
||||
|
||||
def redownload_entry(self, entry):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.redownload_confirm_title"),
|
||||
_("history.redownload_confirm_message", title=entry.get("title")),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.redownload_requested.emit(entry)
|
||||
self.accept()
|
||||
|
||||
def remove_entry(self, index):
|
||||
entry = index.data(HistoryModel.EntryRole)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.remove_confirm_title"),
|
||||
_("history.remove_confirm_message", title=entry.get("title")),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
if HistoryManager.remove_entry(entry.get("id")):
|
||||
self.model.remove_item(index.row())
|
||||
self.status_label.setText(
|
||||
_("history.entries_count", count=self.model.rowCount())
|
||||
)
|
||||
|
||||
def clear_all_history(self):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.clear_confirm_title"),
|
||||
_("history.clear_confirm_message"),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
HistoryManager.clear_history()
|
||||
self.model.update_entries([])
|
||||
self.clear_btn.setEnabled(False)
|
||||
self.status_label.setText(_("history.no_history"))
|
||||
|
||||
def filter_history(self, query):
|
||||
results = HistoryManager.search_entries(query)
|
||||
self.model.update_entries(results)
|
||||
self.load_cached_thumbnails(results)
|
||||
@@ -0,0 +1,730 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
from ...utils.ytsage_localization import _
|
||||
|
||||
|
||||
class SubtitleSelectionDialog(QDialog):
|
||||
def __init__(self, available_manual, available_auto, previously_selected, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("dialogs.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(_("dialogs.filter_languages_placeholder"))
|
||||
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()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
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:
|
||||
matching_text = _("dialogs.matching") if filter_text else ""
|
||||
no_subs_label = QLabel(_("dialogs.no_subtitles_available") + (f" {matching_text} '{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(_("playlist.select_videos_title"))
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(400) # Allow more vertical space
|
||||
|
||||
self.playlist_entries = playlist_entries
|
||||
self.checkboxes = []
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# Filter Input
|
||||
self.filter_input = QLineEdit()
|
||||
self.filter_input.setPlaceholderText(_("dialogs.filter_playlist_placeholder"))
|
||||
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;
|
||||
}
|
||||
"""
|
||||
)
|
||||
main_layout.addWidget(self.filter_input)
|
||||
|
||||
# Top buttons (Select/Deselect All)
|
||||
button_layout = QHBoxLayout()
|
||||
select_all_btn = QPushButton(_("buttons.select_all"))
|
||||
deselect_all_btn = QPushButton(_("buttons.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()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
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 filter_list(self, text: str) -> None:
|
||||
"""Filter the list of checkboxes based on title."""
|
||||
text = text.lower()
|
||||
for checkbox in self.checkboxes:
|
||||
title = (checkbox.property("full_title") or "").lower()
|
||||
checkbox.setVisible(text in title)
|
||||
|
||||
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}")
|
||||
|
||||
# Format duration
|
||||
duration = entry.get("duration")
|
||||
duration_str = ""
|
||||
if duration:
|
||||
try:
|
||||
m, s = divmod(int(duration), 60)
|
||||
h, m = divmod(m, 60)
|
||||
if h > 0:
|
||||
duration_str = f" [{h}:{m:02d}:{s:02d}]"
|
||||
else:
|
||||
duration_str = f" [{m:02d}:{s:02d}]"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Shorten title if too long but keep enough space for duration
|
||||
max_len = 65
|
||||
display_title = (title[:max_len] + "...") if len(title) > max_len + 3 else title
|
||||
|
||||
checkbox = QCheckBox(f"{video_index}. {display_title}{duration_str}")
|
||||
checkbox.setChecked(video_index in selected_indices)
|
||||
checkbox.setProperty("video_index", video_index) # Store index
|
||||
checkbox.setProperty("full_title", title) # Store full title for filtering
|
||||
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_key": "sponsorblock.sponsor",
|
||||
"description_key": "sponsorblock.sponsor_desc",
|
||||
"default": True,
|
||||
},
|
||||
"selfpromo": {
|
||||
"name_key": "sponsorblock.selfpromo",
|
||||
"description_key": "sponsorblock.selfpromo_desc",
|
||||
"default": True,
|
||||
},
|
||||
"interaction": {
|
||||
"name_key": "sponsorblock.interaction",
|
||||
"description_key": "sponsorblock.interaction_desc",
|
||||
"default": True,
|
||||
},
|
||||
"intro": {
|
||||
"name_key": "sponsorblock.intro",
|
||||
"description_key": "sponsorblock.intro_desc",
|
||||
"default": False,
|
||||
},
|
||||
"outro": {
|
||||
"name_key": "sponsorblock.outro",
|
||||
"description_key": "sponsorblock.outro_desc",
|
||||
"default": False,
|
||||
},
|
||||
"preview": {
|
||||
"name_key": "sponsorblock.preview",
|
||||
"description_key": "sponsorblock.preview_desc",
|
||||
"default": False,
|
||||
},
|
||||
"music_offtopic": {
|
||||
"name_key": "sponsorblock.music_offtopic",
|
||||
"description_key": "sponsorblock.music_offtopic_desc",
|
||||
"default": False,
|
||||
},
|
||||
"filler": {
|
||||
"name_key": "sponsorblock.filler",
|
||||
"description_key": "sponsorblock.filler_desc",
|
||||
"default": False,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, previously_selected=None, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("dialogs.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(_("dialogs.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(_("dialogs.sponsorblock_description"))
|
||||
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 localized name
|
||||
checkbox = QCheckBox(_(category_info["name_key"]))
|
||||
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 with localized text
|
||||
desc_label = QLabel(_(category_info["description_key"]))
|
||||
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(_("buttons.select_defaults"))
|
||||
select_defaults_btn.clicked.connect(self.select_defaults)
|
||||
select_defaults_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
select_all_btn = QPushButton(_("buttons.select_all"))
|
||||
select_all_btn.clicked.connect(self.select_all)
|
||||
select_all_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
deselect_all_btn = QPushButton(_("buttons.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()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
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,746 @@
|
||||
"""
|
||||
Settings-related dialogs for YTSage application.
|
||||
Contains dialogs for configuring download settings.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from packaging import version as version_parser
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from ...utils.ytsage_logger import logger
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
|
||||
class DownloadSettingsDialog(QDialog):
|
||||
def __init__(self, current_path, current_limit, current_unit_index, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_("settings.title"))
|
||||
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(_("settings.download_path"))
|
||||
path_layout = QVBoxLayout()
|
||||
|
||||
self.path_display = QLabel(str(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(_("settings.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(_("settings.speed_limit"))
|
||||
speed_layout = QHBoxLayout()
|
||||
|
||||
self.speed_limit_input = QLineEdit(str(self.current_limit))
|
||||
self.speed_limit_input.setPlaceholderText(_("settings.speed_limit_placeholder"))
|
||||
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)
|
||||
|
||||
# --- Output Format Settings Section ---
|
||||
output_format_group_box = QGroupBox(_("settings.output_format_settings"))
|
||||
output_format_layout = QVBoxLayout()
|
||||
|
||||
# Load current format settings from ConfigManager
|
||||
self.force_format_enabled = ConfigManager.get("force_output_format") or False
|
||||
self.preferred_format_value = ConfigManager.get("preferred_output_format") or "mp4"
|
||||
|
||||
# Enable/Disable force output format checkbox
|
||||
self.force_format_checkbox = QCheckBox(_("settings.force_output_format"))
|
||||
self.force_format_checkbox.setChecked(self.force_format_enabled)
|
||||
output_format_layout.addWidget(self.force_format_checkbox)
|
||||
|
||||
# Format selection layout
|
||||
format_select_layout = QHBoxLayout()
|
||||
format_label = QLabel(_("settings.preferred_format"))
|
||||
format_label.setStyleSheet("color: #ffffff; margin-top: 5px;")
|
||||
format_select_layout.addWidget(format_label)
|
||||
|
||||
self.format_combo = QComboBox()
|
||||
self.format_combo.addItems([
|
||||
_("settings.format_mp4"),
|
||||
_("settings.format_webm"),
|
||||
_("settings.format_mkv")
|
||||
])
|
||||
# Set current selection based on saved format
|
||||
format_index_map = {"mp4": 0, "webm": 1, "mkv": 2}
|
||||
self.format_combo.setCurrentIndex(format_index_map.get(self.preferred_format_value, 0))
|
||||
format_select_layout.addWidget(self.format_combo)
|
||||
format_select_layout.addStretch()
|
||||
output_format_layout.addLayout(format_select_layout)
|
||||
|
||||
# Help text
|
||||
help_label = QLabel(_("settings.force_format_help"))
|
||||
help_label.setWordWrap(True)
|
||||
help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 10px;")
|
||||
output_format_layout.addWidget(help_label)
|
||||
|
||||
output_format_group_box.setLayout(output_format_layout)
|
||||
layout.addWidget(output_format_group_box)
|
||||
|
||||
# --- Audio Format Settings Section (for audio-only downloads) ---
|
||||
audio_format_group_box = QGroupBox(_("settings.audio_format_settings"))
|
||||
audio_format_layout = QVBoxLayout()
|
||||
|
||||
# Load current audio format settings from ConfigManager
|
||||
self.force_audio_format_enabled = ConfigManager.get("force_audio_format") or False
|
||||
self.preferred_audio_format_value = ConfigManager.get("preferred_audio_format") or "best"
|
||||
|
||||
# Enable/Disable force audio format checkbox
|
||||
self.force_audio_format_checkbox = QCheckBox(_("settings.force_audio_format"))
|
||||
self.force_audio_format_checkbox.setChecked(self.force_audio_format_enabled)
|
||||
audio_format_layout.addWidget(self.force_audio_format_checkbox)
|
||||
|
||||
# Audio format selection layout
|
||||
audio_format_select_layout = QHBoxLayout()
|
||||
audio_format_label = QLabel(_("settings.preferred_audio_format"))
|
||||
audio_format_label.setStyleSheet("color: #ffffff; margin-top: 5px;")
|
||||
audio_format_select_layout.addWidget(audio_format_label)
|
||||
|
||||
self.audio_format_combo = QComboBox()
|
||||
self.audio_format_combo.addItems([
|
||||
_("settings.audio_format_best"),
|
||||
_("settings.audio_format_aac"),
|
||||
_("settings.audio_format_mp3"),
|
||||
_("settings.audio_format_flac"),
|
||||
_("settings.audio_format_wav"),
|
||||
_("settings.audio_format_opus"),
|
||||
_("settings.audio_format_m4a"),
|
||||
_("settings.audio_format_vorbis")
|
||||
])
|
||||
# Set current selection based on saved format
|
||||
audio_format_index_map = {"best": 0, "aac": 1, "mp3": 2, "flac": 3, "wav": 4, "opus": 5, "m4a": 6, "vorbis": 7}
|
||||
self.audio_format_combo.setCurrentIndex(audio_format_index_map.get(self.preferred_audio_format_value, 0))
|
||||
audio_format_select_layout.addWidget(self.audio_format_combo)
|
||||
audio_format_select_layout.addStretch()
|
||||
audio_format_layout.addLayout(audio_format_select_layout)
|
||||
|
||||
# Help text for audio format
|
||||
audio_help_label = QLabel(_("settings.force_audio_format_help"))
|
||||
audio_help_label.setWordWrap(True)
|
||||
audio_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 10px;")
|
||||
audio_format_layout.addWidget(audio_help_label)
|
||||
|
||||
audio_format_group_box.setLayout(audio_format_layout)
|
||||
layout.addWidget(audio_format_group_box)
|
||||
|
||||
# Dialog buttons (OK/Cancel)
|
||||
button_box = QDialogButtonBox()
|
||||
ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
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, _("dialogs.select_folder"), str(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 get_force_format_enabled(self) -> bool:
|
||||
"""Returns whether force output format is enabled."""
|
||||
return self.force_format_checkbox.isChecked()
|
||||
|
||||
def get_preferred_format(self) -> str:
|
||||
"""Returns the selected preferred format (lowercase)."""
|
||||
format_map = {0: "mp4", 1: "webm", 2: "mkv"}
|
||||
return format_map.get(self.format_combo.currentIndex(), "mp4")
|
||||
|
||||
def get_force_audio_format_enabled(self) -> bool:
|
||||
"""Returns whether force audio format is enabled."""
|
||||
return self.force_audio_format_checkbox.isChecked()
|
||||
|
||||
def get_preferred_audio_format(self) -> str:
|
||||
"""Returns the selected preferred audio format (lowercase)."""
|
||||
audio_format_map = {0: "best", 1: "aac", 2: "mp3", 3: "flac", 4: "wav", 5: "opus", 6: "m4a", 7: "vorbis"}
|
||||
return audio_format_map.get(self.audio_format_combo.currentIndex(), "best")
|
||||
|
||||
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 accept(self) -> None:
|
||||
"""Override accept to save format settings."""
|
||||
try:
|
||||
# Save output format settings
|
||||
force_format = self.get_force_format_enabled()
|
||||
preferred_format = self.get_preferred_format()
|
||||
ConfigManager.set("force_output_format", force_format)
|
||||
ConfigManager.set("preferred_output_format", preferred_format)
|
||||
|
||||
# Save audio format settings
|
||||
force_audio_format = self.get_force_audio_format_enabled()
|
||||
preferred_audio_format = self.get_preferred_audio_format()
|
||||
ConfigManager.set("force_audio_format", force_audio_format)
|
||||
ConfigManager.set("preferred_audio_format", preferred_audio_format)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
_("settings.settings_saved_title"),
|
||||
_("settings.settings_saved_message"),
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, _("settings.error_title"), _("settings.error_saving_settings", error=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(_("settings.auto_update_title"))
|
||||
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(f"<h2>{_("settings.auto_update_header")}</h2>")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel(_("settings.auto_update_description"))
|
||||
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(_("settings.enable_auto_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(_("settings.update_frequency_group"))
|
||||
frequency_layout = QVBoxLayout()
|
||||
|
||||
self.frequency_group = QButtonGroup(self)
|
||||
|
||||
self.startup_radio = QRadioButton(_("settings.check_startup"))
|
||||
self.daily_radio = QRadioButton(_("settings.check_daily"))
|
||||
self.weekly_radio = QRadioButton(_("settings.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(_("settings.current_status"))
|
||||
status_layout = QVBoxLayout()
|
||||
|
||||
self.current_version_label = QLabel(_("settings.current_version_label"))
|
||||
self.last_check_label = QLabel(_("settings.last_check_label"))
|
||||
self.next_check_label = QLabel(_("settings.next_check_label"))
|
||||
|
||||
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(_("settings.manual_check_button"))
|
||||
self.manual_check_btn.clicked.connect(self.manual_check)
|
||||
layout.addWidget(self.manual_check_btn)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.save_btn = QPushButton(_("settings.save_settings"))
|
||||
self.save_btn.clicked.connect(self.save_settings)
|
||||
|
||||
self.cancel_btn = QPushButton(_("buttons.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(_("auto_update.current_version", 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(_("auto_update.last_check", time=last_check_time))
|
||||
else:
|
||||
self.last_check_label.setText(_("auto_update.last_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.exception(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(_("auto_update.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(_("auto_update.next_check_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(_("auto_update.next_check_overdue"))
|
||||
else:
|
||||
next_check_datetime = datetime.fromtimestamp(next_check_time)
|
||||
self.next_check_label.setText(_("auto_update.next_check", time=next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')))
|
||||
|
||||
except Exception as e:
|
||||
self.next_check_label.setText(_("auto_update.next_check_error"))
|
||||
logger.exception(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(_("auto_update.checking"))
|
||||
|
||||
# Force an immediate update check
|
||||
def check_in_thread() -> None:
|
||||
try:
|
||||
result = check_and_update_ytdlp_auto()
|
||||
|
||||
# Update UI in main thread
|
||||
QTimer.singleShot(0, lambda: self.manual_check_finished(result))
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during manual check: {e}")
|
||||
QTimer.singleShot(0, lambda: self.manual_check_finished(False))
|
||||
|
||||
# Run in separate thread to avoid blocking UI
|
||||
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(_("auto_update.check_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.settings_saved_title"),
|
||||
_("settings.settings_saved_successfully"),
|
||||
)
|
||||
msg_box.exec()
|
||||
self.accept()
|
||||
else:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Icon.Warning,
|
||||
_("settings.error_title"),
|
||||
_("settings.failed_save_settings"),
|
||||
)
|
||||
msg_box.exec()
|
||||
except Exception as e:
|
||||
logger.exception(f"Error saving auto-update settings: {e}")
|
||||
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings", "error_saving", error=str(e)))
|
||||
msg_box.exec()
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Update-related dialogs and threads for YTSage application.
|
||||
Contains dialogs and background threads for checking and performing yt-dlp binary updates.
|
||||
Note: This module only handles binary updates. Python package updates have been removed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
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 ...core.ytsage_utils import get_ytdlp_version
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ...utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL
|
||||
from ...utils.ytsage_config_manager import ConfigManager
|
||||
from ...utils.ytsage_localization import LocalizationManager
|
||||
|
||||
# Shorthand for localization
|
||||
_ = LocalizationManager.get_text
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_logger import logger
|
||||
|
||||
|
||||
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:
|
||||
error_message = "yt-dlp binary not accessible."
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
error_message = "yt-dlp version check timed out."
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
except Exception as e:
|
||||
error_message = f"yt-dlp not found or accessible: {e}"
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
|
||||
# Get latest version from PyPI (yt-dlp releases are also published to 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(_('update.checking_current'))
|
||||
self.update_progress.emit(10)
|
||||
|
||||
# Get the yt-dlp path
|
||||
try:
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
self.update_status.emit(_('update.found_at', path=yt_dlp_path))
|
||||
except Exception as e:
|
||||
self.update_status.emit(_('update.error_getting_path', error=e))
|
||||
self.update_finished.emit(False, _('update.error_getting_path', error=e))
|
||||
return
|
||||
|
||||
self.update_progress.emit(20)
|
||||
|
||||
# Update the binary (no more pip-based updates)
|
||||
self.update_status.emit(_('update.updating_binary'))
|
||||
success = self._update_binary(yt_dlp_path)
|
||||
|
||||
if success:
|
||||
self.update_progress.emit(100)
|
||||
error_message = _('update.update_success')
|
||||
else:
|
||||
error_message = _('update.update_failed')
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_message = _('update.network_error', error=e)
|
||||
self.update_status.emit(error_message)
|
||||
success = False
|
||||
except Exception as e:
|
||||
error_message = _('update.general_error', error=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(_('update.binary_updated'))
|
||||
self.update_progress.emit(95)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}")
|
||||
self.update_status.emit(_('update.update_failed_stderr', error=result.stderr.strip()))
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("UpdateThread: yt-dlp update timed out.")
|
||||
self.update_status.emit(_('update.update_timeout'))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"UpdateThread: Unexpected error during update: {e}")
|
||||
self.update_status.emit(_('update.unexpected_error', error=e))
|
||||
return False
|
||||
|
||||
|
||||
class YTDLPUpdateDialog(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(_('update.title'))
|
||||
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(_('update.checking'))
|
||||
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(_('buttons.update'))
|
||||
self.update_btn.clicked.connect(self.perform_update)
|
||||
self.update_btn.setEnabled(False)
|
||||
|
||||
self.close_btn = QPushButton(_('buttons.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(_('update.checking'))
|
||||
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(_('update.could_not_determine'))
|
||||
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(
|
||||
_('update.update_available', current=current_version, latest=latest_version)
|
||||
)
|
||||
self.update_btn.setEnabled(True)
|
||||
else:
|
||||
self.status_label.setText(_('update.already_latest', 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(
|
||||
_('update.update_available_failed', current=current_version, latest=latest_version)
|
||||
)
|
||||
self.update_btn.setEnabled(True)
|
||||
else:
|
||||
self.status_label.setText(_('update.up_to_date', version=current_version))
|
||||
self.update_btn.setEnabled(False)
|
||||
except Exception as e:
|
||||
self.status_label.setText(_('update.error_comparing', error=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(_('update.updating'))
|
||||
self.status_label.setText(_('update.initializing'))
|
||||
|
||||
# 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(_('buttons.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
|
||||
ConfigManager.set("last_update_check", time.time())
|
||||
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
|
||||
ConfigManager.set("last_update_check", time.time())
|
||||
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.exception(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 binary update."""
|
||||
try:
|
||||
# Get the yt-dlp path
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
|
||||
# Always update the binary (no more pip-based updates)
|
||||
logger.info("AutoUpdateThread: Updating yt-dlp binary...")
|
||||
return self._update_binary(yt_dlp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"AutoUpdateThread: Error in _perform_update: {e}")
|
||||
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.exception(f"AutoUpdateThread: Unexpected error during update: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,971 @@
|
||||
"""
|
||||
Updater tab for Custom Options dialog.
|
||||
Handles FFmpeg version checking and yt-dlp auto-update settings.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Optional, Tuple, TYPE_CHECKING, cast
|
||||
|
||||
import requests
|
||||
from PySide6.QtCore import Qt, Signal, QThread, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.ytsage_utils import (
|
||||
get_auto_update_settings,
|
||||
get_ffmpeg_version_direct,
|
||||
update_auto_update_settings,
|
||||
)
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ...core.ytsage_deno import check_deno_update, upgrade_deno
|
||||
from .ytsage_dialogs_update import YTDLPUpdateDialog
|
||||
from ...utils.ytsage_config_manager import ConfigManager
|
||||
from ...utils.ytsage_localization import _
|
||||
from ...utils.ytsage_logger import logger
|
||||
from ...utils.ytsage_constants import (
|
||||
FFMPEG_7Z_VERSION_URL,
|
||||
OS_NAME,
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ytsage_dialogs_custom import CustomOptionsDialog
|
||||
|
||||
|
||||
# Helper functions for FFmpeg version checking (copied from removed ytsage_ffmpeg_updater.py)
|
||||
def get_latest_ffmpeg_version() -> Optional[str]:
|
||||
"""
|
||||
Fetch the latest FFmpeg version from the version URL.
|
||||
|
||||
Returns:
|
||||
str: Version string (e.g., "8.0") or None if fetch failed
|
||||
"""
|
||||
try:
|
||||
response = requests.get(FFMPEG_7Z_VERSION_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
version = response.text.strip()
|
||||
|
||||
# Validate version format (should be something like "8.0" or "7.1.1")
|
||||
if re.match(r'^\d+\.\d+(\.\d+)?$', version):
|
||||
logger.info(f"Latest FFmpeg version: {version}")
|
||||
return version
|
||||
else:
|
||||
logger.warning(f"Unexpected version format: {version}")
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch latest FFmpeg version: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error fetching FFmpeg version: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_version(version_str: str) -> Tuple[int, ...]:
|
||||
"""
|
||||
Parse version string into tuple of integers for comparison.
|
||||
|
||||
Args:
|
||||
version_str: Version string like "8.0" or "7.1.1"
|
||||
|
||||
Returns:
|
||||
Tuple of integers (e.g., (8, 0) or (7, 1, 1))
|
||||
"""
|
||||
try:
|
||||
# Extract version numbers from string
|
||||
match = re.search(r'(\d+\.\d+(?:\.\d+)?)', version_str)
|
||||
if match:
|
||||
version_str = match.group(1)
|
||||
|
||||
parts = version_str.split('.')
|
||||
return tuple(int(p) for p in parts)
|
||||
except (ValueError, AttributeError):
|
||||
logger.warning(f"Could not parse version: {version_str}")
|
||||
return (0,)
|
||||
|
||||
|
||||
def compare_versions(current: str, latest: str) -> bool:
|
||||
"""
|
||||
Compare two version strings.
|
||||
|
||||
Args:
|
||||
current: Current version string
|
||||
latest: Latest version string
|
||||
|
||||
Returns:
|
||||
True if update is needed (latest > current), False otherwise
|
||||
"""
|
||||
try:
|
||||
current_tuple = parse_version(current)
|
||||
latest_tuple = parse_version(latest)
|
||||
|
||||
logger.info(f"Comparing versions - Current: {current_tuple}, Latest: {latest_tuple}")
|
||||
|
||||
# Pad shorter version with zeros for comparison
|
||||
max_len = max(len(current_tuple), len(latest_tuple))
|
||||
current_padded = current_tuple + (0,) * (max_len - len(current_tuple))
|
||||
latest_padded = latest_tuple + (0,) * (max_len - len(latest_tuple))
|
||||
|
||||
return latest_padded > current_padded
|
||||
except Exception as e:
|
||||
logger.exception(f"Error comparing versions: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_ffmpeg_version() -> Tuple[bool, str, str]:
|
||||
"""
|
||||
Check FFmpeg version and compare with latest.
|
||||
|
||||
Returns:
|
||||
Tuple of (update_available, current_version, latest_version)
|
||||
"""
|
||||
try:
|
||||
# Get current version
|
||||
current_version = get_ffmpeg_version_direct()
|
||||
if current_version in ["Not found", "Error getting version", "Unknown version"]:
|
||||
current_version = "Not installed"
|
||||
|
||||
# Get latest version
|
||||
latest_version = get_latest_ffmpeg_version()
|
||||
if latest_version is None:
|
||||
latest_version = "Unknown"
|
||||
return False, current_version, latest_version
|
||||
|
||||
# If not installed, update is available
|
||||
if current_version == "Not installed":
|
||||
return True, current_version, latest_version
|
||||
|
||||
# Compare versions
|
||||
update_needed = compare_versions(current_version, latest_version)
|
||||
|
||||
return update_needed, current_version, latest_version
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking FFmpeg version: {e}")
|
||||
return False, "Error", "Error"
|
||||
|
||||
|
||||
class FFmpegCheckThread(QThread):
|
||||
finished = Signal(bool, str, str)
|
||||
error = Signal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
update_available, current_version, latest_version = check_ffmpeg_version()
|
||||
self.finished.emit(update_available, current_version, latest_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking FFmpeg version: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class DenoCheckThread(QThread):
|
||||
finished = Signal(bool, str, str)
|
||||
error = Signal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
update_available, current_version, latest_version = check_deno_update()
|
||||
self.finished.emit(update_available, current_version, latest_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking Deno version: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class UpdaterTabWidget(QWidget):
|
||||
"""Widget for the Updater tab in Custom Options dialog."""
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent())
|
||||
|
||||
# State variables
|
||||
self.current_version = "Unknown"
|
||||
self.latest_version = "Unknown"
|
||||
self.update_available = False
|
||||
|
||||
self._init_ui()
|
||||
self._load_auto_update_settings()
|
||||
|
||||
def _init_ui(self) -> None:
|
||||
"""Initialize the UI components."""
|
||||
# Main layout for the tab
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Create scroll area
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
scroll_area.setStyleSheet("QScrollArea { background: transparent; }")
|
||||
|
||||
# Create a widget to hold all content
|
||||
content_widget = QWidget()
|
||||
layout = QVBoxLayout(content_widget)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(_('ffmpeg_updater.description'))
|
||||
help_text.setWordWrap(True)
|
||||
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# FFmpeg Version Check Section
|
||||
ffmpeg_group = QGroupBox(_('ffmpeg_updater.title'))
|
||||
ffmpeg_layout = QVBoxLayout(ffmpeg_group)
|
||||
|
||||
# Version information layout
|
||||
version_layout = QVBoxLayout()
|
||||
|
||||
# Current version
|
||||
current_layout = QHBoxLayout()
|
||||
current_label = QLabel(_('ffmpeg_updater.current_version'))
|
||||
current_label.setStyleSheet("font-weight: bold; color: #ffffff;")
|
||||
current_layout.addWidget(current_label)
|
||||
|
||||
self.current_version_label = QLabel("...")
|
||||
self.current_version_label.setStyleSheet("color: #cccccc;")
|
||||
current_layout.addWidget(self.current_version_label)
|
||||
current_layout.addStretch()
|
||||
version_layout.addLayout(current_layout)
|
||||
|
||||
# Latest version
|
||||
latest_layout = QHBoxLayout()
|
||||
latest_label = QLabel(_('ffmpeg_updater.latest_version'))
|
||||
latest_label.setStyleSheet("font-weight: bold; color: #ffffff;")
|
||||
latest_layout.addWidget(latest_label)
|
||||
|
||||
self.latest_version_label = QLabel("...")
|
||||
self.latest_version_label.setStyleSheet("color: #cccccc;")
|
||||
latest_layout.addWidget(self.latest_version_label)
|
||||
latest_layout.addStretch()
|
||||
version_layout.addLayout(latest_layout)
|
||||
|
||||
ffmpeg_layout.addLayout(version_layout)
|
||||
|
||||
# Status label
|
||||
self.status_label = QLabel(_('ffmpeg_updater.status_idle'))
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #888888; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
ffmpeg_layout.addWidget(self.status_label)
|
||||
|
||||
# Check version button
|
||||
check_button_layout = QHBoxLayout()
|
||||
self.check_button = QPushButton(_('ffmpeg_updater.check_updates'))
|
||||
self.check_button.clicked.connect(self.check_for_updates)
|
||||
self.check_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #444444;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 120px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #555555;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #2a2a2a;
|
||||
color: #666666;
|
||||
}
|
||||
"""
|
||||
)
|
||||
check_button_layout.addWidget(self.check_button)
|
||||
check_button_layout.addStretch()
|
||||
ffmpeg_layout.addLayout(check_button_layout)
|
||||
|
||||
# Installation guide info
|
||||
guide_label = QLabel(_('ffmpeg_updater.guide_info'))
|
||||
guide_label.setWordWrap(True)
|
||||
guide_label.setOpenExternalLinks(True) # Enable clickable links
|
||||
guide_label.setTextFormat(Qt.TextFormat.RichText) # Enable HTML formatting
|
||||
guide_label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
background-color: #1a1d20;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QLabel a {
|
||||
color: #4da6ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
QLabel a:hover {
|
||||
color: #66b3ff;
|
||||
}
|
||||
"""
|
||||
)
|
||||
ffmpeg_layout.addWidget(guide_label)
|
||||
|
||||
layout.addWidget(ffmpeg_group)
|
||||
|
||||
# === Deno Version Check & Update Section ===
|
||||
deno_group = QGroupBox(_('deno_updater.title'))
|
||||
deno_layout = QVBoxLayout(deno_group)
|
||||
|
||||
# Description
|
||||
deno_desc = QLabel(_('deno_updater.description'))
|
||||
deno_desc.setWordWrap(True)
|
||||
deno_desc.setStyleSheet("color: #999999; padding: 10px;")
|
||||
deno_layout.addWidget(deno_desc)
|
||||
|
||||
# Version information layout
|
||||
deno_version_layout = QVBoxLayout()
|
||||
|
||||
# Current version
|
||||
deno_current_layout = QHBoxLayout()
|
||||
deno_current_label = QLabel(_('deno_updater.current_version'))
|
||||
deno_current_label.setStyleSheet("font-weight: bold; color: #ffffff;")
|
||||
deno_current_layout.addWidget(deno_current_label)
|
||||
|
||||
self.deno_current_version_label = QLabel("...")
|
||||
self.deno_current_version_label.setStyleSheet("color: #cccccc;")
|
||||
deno_current_layout.addWidget(self.deno_current_version_label)
|
||||
deno_current_layout.addStretch()
|
||||
deno_version_layout.addLayout(deno_current_layout)
|
||||
|
||||
# Latest version
|
||||
deno_latest_layout = QHBoxLayout()
|
||||
deno_latest_label = QLabel(_('deno_updater.latest_version'))
|
||||
deno_latest_label.setStyleSheet("font-weight: bold; color: #ffffff;")
|
||||
deno_latest_layout.addWidget(deno_latest_label)
|
||||
|
||||
self.deno_latest_version_label = QLabel("...")
|
||||
self.deno_latest_version_label.setStyleSheet("color: #cccccc;")
|
||||
deno_latest_layout.addWidget(self.deno_latest_version_label)
|
||||
deno_latest_layout.addStretch()
|
||||
deno_version_layout.addLayout(deno_latest_layout)
|
||||
|
||||
deno_layout.addLayout(deno_version_layout)
|
||||
|
||||
# Status label
|
||||
self.deno_status_label = QLabel(_('deno_updater.status_idle'))
|
||||
self.deno_status_label.setWordWrap(True)
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #888888; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
deno_layout.addWidget(self.deno_status_label)
|
||||
|
||||
# Buttons layout
|
||||
deno_button_layout = QHBoxLayout()
|
||||
|
||||
# Check for updates button
|
||||
self.deno_check_button = QPushButton(_('deno_updater.check_updates'))
|
||||
self.deno_check_button.clicked.connect(self.check_deno_updates)
|
||||
self.deno_check_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #444444;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 120px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #555555;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #2a2a2a;
|
||||
color: #666666;
|
||||
}
|
||||
"""
|
||||
)
|
||||
deno_button_layout.addWidget(self.deno_check_button)
|
||||
|
||||
# Update button (initially hidden)
|
||||
self.deno_update_button = QPushButton(_('deno_updater.update_now'))
|
||||
self.deno_update_button.clicked.connect(self.update_deno)
|
||||
self.deno_update_button.setVisible(False)
|
||||
self.deno_update_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 120px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #2a2a2a;
|
||||
color: #666666;
|
||||
}
|
||||
"""
|
||||
)
|
||||
deno_button_layout.addWidget(self.deno_update_button)
|
||||
|
||||
deno_button_layout.addStretch()
|
||||
deno_layout.addLayout(deno_button_layout)
|
||||
|
||||
layout.addWidget(deno_group)
|
||||
|
||||
# === yt-dlp Release Channel Section ===
|
||||
ytdlp_channel_group = QGroupBox(_("settings.ytdlp_channel"))
|
||||
ytdlp_channel_layout = QVBoxLayout()
|
||||
ytdlp_channel_layout.setSpacing(5) # Reduce spacing
|
||||
ytdlp_channel_layout.setContentsMargins(10, 10, 10, 10) # Reduce margins
|
||||
|
||||
# Description
|
||||
channel_desc = QLabel(_("settings.ytdlp_channel_description"))
|
||||
channel_desc.setWordWrap(True)
|
||||
channel_desc.setStyleSheet("color: #cccccc; font-size: 11px; padding: 2px;")
|
||||
ytdlp_channel_layout.addWidget(channel_desc)
|
||||
|
||||
# Radio buttons for channel selection
|
||||
self.channel_stable_radio = QRadioButton(_("settings.ytdlp_channel_stable"))
|
||||
self.channel_nightly_radio = QRadioButton(_("settings.ytdlp_channel_nightly"))
|
||||
|
||||
radio_button_style = """
|
||||
QRadioButton {
|
||||
color: #ffffff;
|
||||
spacing: 5px;
|
||||
padding: 3px;
|
||||
}
|
||||
QRadioButton::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #15181b;
|
||||
}
|
||||
QRadioButton::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
}
|
||||
"""
|
||||
self.channel_stable_radio.setStyleSheet(radio_button_style)
|
||||
self.channel_nightly_radio.setStyleSheet(radio_button_style)
|
||||
|
||||
# Connect radio button signals
|
||||
self.channel_stable_radio.toggled.connect(self._on_channel_changed)
|
||||
self.channel_nightly_radio.toggled.connect(self._on_channel_changed)
|
||||
|
||||
ytdlp_channel_layout.addWidget(self.channel_stable_radio)
|
||||
ytdlp_channel_layout.addWidget(self.channel_nightly_radio)
|
||||
|
||||
# Status label for channel operations
|
||||
self.channel_status_label = QLabel("")
|
||||
self.channel_status_label.setWordWrap(True)
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #888888; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
self.channel_status_label.setVisible(False)
|
||||
ytdlp_channel_layout.addWidget(self.channel_status_label)
|
||||
|
||||
ytdlp_channel_group.setLayout(ytdlp_channel_layout)
|
||||
layout.addWidget(ytdlp_channel_group)
|
||||
|
||||
# === Auto-Update yt-dlp Section ===
|
||||
auto_update_group_box = QGroupBox(_("settings.auto_update_ytdlp"))
|
||||
auto_update_layout = QVBoxLayout()
|
||||
|
||||
# Enable/Disable auto-update checkbox
|
||||
self.auto_update_enabled = QCheckBox(_("settings.enable_auto_updates"))
|
||||
self.auto_update_enabled.setStyleSheet(
|
||||
"""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
spacing: 5px;
|
||||
padding: 3px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox:disabled { color: #888888; }
|
||||
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
|
||||
"""
|
||||
)
|
||||
auto_update_layout.addWidget(self.auto_update_enabled)
|
||||
|
||||
# Frequency options
|
||||
frequency_label = QLabel(_("settings.update_frequency"))
|
||||
frequency_label.setStyleSheet("color: #ffffff; margin-top: 10px;")
|
||||
auto_update_layout.addWidget(frequency_label)
|
||||
|
||||
self.startup_radio = QRadioButton(_("settings.check_startup"))
|
||||
self.daily_radio = QRadioButton(_("settings.check_daily"))
|
||||
self.weekly_radio = QRadioButton(_("settings.check_weekly"))
|
||||
|
||||
self.startup_radio.setStyleSheet(
|
||||
"""
|
||||
QRadioButton {
|
||||
color: #ffffff;
|
||||
spacing: 5px;
|
||||
}
|
||||
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;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.daily_radio.setStyleSheet(self.startup_radio.styleSheet())
|
||||
self.weekly_radio.setStyleSheet(self.startup_radio.styleSheet())
|
||||
|
||||
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(_("settings.check_updates_now"))
|
||||
test_update_button.clicked.connect(self.test_update_check)
|
||||
test_update_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 120px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
"""
|
||||
)
|
||||
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)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Set the content widget to the scroll area
|
||||
scroll_area.setWidget(content_widget)
|
||||
|
||||
# Add scroll area to main layout
|
||||
main_layout.addWidget(scroll_area)
|
||||
|
||||
def _load_auto_update_settings(self) -> None:
|
||||
"""Load current auto-update settings for yt-dlp."""
|
||||
try:
|
||||
auto_settings = get_auto_update_settings()
|
||||
|
||||
# Set checkbox
|
||||
self.auto_update_enabled.setChecked(auto_settings["enabled"])
|
||||
|
||||
# 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)
|
||||
|
||||
# Load channel setting
|
||||
channel = ConfigManager.get("ytdlp_channel")
|
||||
if channel is None:
|
||||
channel = "stable" # Default to stable if not set
|
||||
|
||||
if channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
|
||||
# Update status label
|
||||
self._update_channel_status(channel)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error loading auto-update settings: {e}")
|
||||
|
||||
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 _on_channel_changed(self, checked: bool) -> None:
|
||||
"""Handle channel selection change."""
|
||||
if not checked:
|
||||
return
|
||||
|
||||
# Determine which channel was selected
|
||||
new_channel = "nightly" if self.channel_nightly_radio.isChecked() else "stable"
|
||||
current_channel = ConfigManager.get("ytdlp_channel")
|
||||
if current_channel is None:
|
||||
current_channel = "stable"
|
||||
|
||||
# If channel hasn't actually changed, just update status
|
||||
if new_channel == current_channel:
|
||||
self._update_channel_status(new_channel)
|
||||
return
|
||||
|
||||
# Show switching message
|
||||
self.channel_status_label.setText(_("settings.ytdlp_switching_channel", channel=new_channel))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ffaa00; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
self.channel_status_label.setVisible(True)
|
||||
|
||||
# Disable radio buttons during switch
|
||||
self.channel_stable_radio.setEnabled(False)
|
||||
self.channel_nightly_radio.setEnabled(False)
|
||||
|
||||
# Run channel switch in background thread
|
||||
def switch_channel():
|
||||
try:
|
||||
# Get yt-dlp path
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
|
||||
# Build the update command
|
||||
# When switching to stable from nightly, we need to get the latest stable version
|
||||
# to force the switch even if versions have the same date
|
||||
update_target = new_channel
|
||||
|
||||
if new_channel == "stable" and current_channel == "nightly":
|
||||
# Get the latest stable version tag from PyPI (no rate limiting)
|
||||
logger.info("Fetching latest stable version tag from PyPI...")
|
||||
try:
|
||||
import requests
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
response.raise_for_status()
|
||||
latest_tag = response.json()["info"]["version"]
|
||||
if latest_tag:
|
||||
update_target = f"stable@{latest_tag}"
|
||||
logger.info(f"Latest stable version tag: {latest_tag}")
|
||||
else:
|
||||
logger.warning("Could not determine latest stable tag, using 'stable'")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch latest stable tag, using 'stable': {e}")
|
||||
|
||||
# Run the update-to command
|
||||
logger.info(f"Switching yt-dlp to {new_channel} channel...")
|
||||
logger.debug(f"Running command: {yt_dlp_path} --update-to {update_target}")
|
||||
result = subprocess.run(
|
||||
[yt_dlp_path, "--update-to", update_target],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
|
||||
# Log the output for debugging
|
||||
if result.stdout:
|
||||
logger.debug(f"yt-dlp stdout: {result.stdout.strip()}")
|
||||
if result.stderr:
|
||||
logger.debug(f"yt-dlp stderr: {result.stderr.strip()}")
|
||||
logger.debug(f"yt-dlp return code: {result.returncode}")
|
||||
|
||||
if result.returncode == 0:
|
||||
# Success - save the preference
|
||||
ConfigManager.set("ytdlp_channel", new_channel)
|
||||
logger.info(f"Successfully switched to {new_channel} channel")
|
||||
|
||||
# Update UI
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #00cc00; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
import os
|
||||
os.chmod(yt_dlp_path, 0o755)
|
||||
else:
|
||||
# Failed - revert radio button
|
||||
error_msg = result.stderr.strip() if result.stderr else result.stdout.strip() if result.stdout else "Unknown error"
|
||||
logger.error(f"Failed to switch channel: {error_msg}")
|
||||
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
# Revert radio selection
|
||||
if current_channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Channel switch timed out")
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error="Timeout"))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
# Revert radio selection
|
||||
if current_channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error switching channel: {e}")
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=str(e)))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
# Revert radio selection
|
||||
if current_channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
finally:
|
||||
# Re-enable radio buttons
|
||||
self.channel_stable_radio.setEnabled(True)
|
||||
self.channel_nightly_radio.setEnabled(True)
|
||||
|
||||
# Start the thread
|
||||
thread = threading.Thread(target=switch_channel, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _update_channel_status(self, channel: str) -> None:
|
||||
"""Update the channel status label."""
|
||||
self.channel_status_label.setText(_("settings.ytdlp_current_channel", channel=channel))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #888888; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
self.channel_status_label.setVisible(True)
|
||||
|
||||
def test_update_check(self) -> None:
|
||||
"""Open the yt-dlp update dialog with proper progress tracking."""
|
||||
# Create and show the update dialog (non-modal to prevent blocking)
|
||||
dialog = YTDLPUpdateDialog(self)
|
||||
dialog.setModal(False) # Make it non-modal
|
||||
dialog.show() # Use show() instead of exec() to avoid blocking
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""Check FFmpeg version and compare with latest."""
|
||||
self.check_button.setEnabled(False)
|
||||
self.status_label.setText(_('ffmpeg_updater.status_checking'))
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #ffaa00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
self.ffmpeg_check_thread = FFmpegCheckThread()
|
||||
self.ffmpeg_check_thread.finished.connect(self._on_ffmpeg_check_finished)
|
||||
self.ffmpeg_check_thread.error.connect(self._on_ffmpeg_check_error)
|
||||
self.ffmpeg_check_thread.start()
|
||||
|
||||
@Slot(bool, str, str)
|
||||
def _on_ffmpeg_check_finished(self, update_available, current_version, latest_version):
|
||||
self.check_button.setEnabled(True)
|
||||
self._update_check_results(update_available, current_version, latest_version)
|
||||
|
||||
@Slot(str)
|
||||
def _on_ffmpeg_check_error(self, error):
|
||||
self.check_button.setEnabled(True)
|
||||
self._show_check_error(error)
|
||||
|
||||
def _update_check_results(self, update_available: bool, current_version: str, latest_version: str) -> None:
|
||||
"""Handle completion of version check."""
|
||||
self.update_available = update_available
|
||||
self.current_version = current_version
|
||||
self.latest_version = latest_version
|
||||
|
||||
# Update version labels
|
||||
self.current_version_label.setText(current_version)
|
||||
self.latest_version_label.setText(latest_version)
|
||||
|
||||
# Update status
|
||||
if current_version == "Not installed":
|
||||
self.status_label.setText(_('ffmpeg_updater.status_not_installed'))
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
elif update_available:
|
||||
self.status_label.setText(_('ffmpeg_updater.status_update_available'))
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #ffaa00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
else:
|
||||
self.status_label.setText(_('ffmpeg_updater.status_up_to_date'))
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #00cc00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
def _show_check_error(self, error: str) -> None:
|
||||
"""Handle error during version check."""
|
||||
self.status_label.setText(_('ffmpeg_updater.check_failed'))
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
def check_deno_updates(self) -> None:
|
||||
"""Check for Deno updates."""
|
||||
self.deno_check_button.setEnabled(False)
|
||||
self.deno_update_button.setVisible(False)
|
||||
self.deno_status_label.setText(_('deno_updater.status_checking'))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #ffaa00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
self.deno_check_thread = DenoCheckThread()
|
||||
self.deno_check_thread.finished.connect(self._on_deno_check_finished)
|
||||
self.deno_check_thread.error.connect(self._on_deno_check_error)
|
||||
self.deno_check_thread.start()
|
||||
|
||||
@Slot(bool, str, str)
|
||||
def _on_deno_check_finished(self, update_available, current_version, latest_version):
|
||||
self.deno_check_button.setEnabled(True)
|
||||
self._update_deno_check_results(update_available, current_version, latest_version)
|
||||
|
||||
@Slot(str)
|
||||
def _on_deno_check_error(self, error):
|
||||
self.deno_check_button.setEnabled(True)
|
||||
self._show_deno_check_error(error)
|
||||
|
||||
def _update_deno_check_results(self, update_available: bool, current_version: str, latest_version: str) -> None:
|
||||
"""Handle completion of Deno version check."""
|
||||
# Update version labels
|
||||
self.deno_current_version_label.setText(current_version)
|
||||
self.deno_latest_version_label.setText(latest_version)
|
||||
|
||||
# Update status
|
||||
if current_version in ["Not found", "Error getting version"]:
|
||||
self.deno_status_label.setText(_('deno_updater.status_not_installed'))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
elif update_available:
|
||||
self.deno_status_label.setText(_('deno_updater.status_update_available'))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #ffaa00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
# Show update button
|
||||
self.deno_update_button.setVisible(True)
|
||||
else:
|
||||
self.deno_status_label.setText(_('deno_updater.status_up_to_date'))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #00cc00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
def _show_deno_check_error(self, error: str) -> None:
|
||||
"""Handle error during Deno version check."""
|
||||
self.deno_status_label.setText(_('deno_updater.check_failed'))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
def update_deno(self) -> None:
|
||||
"""Update Deno to the latest version."""
|
||||
self.deno_check_button.setEnabled(False)
|
||||
self.deno_update_button.setEnabled(False)
|
||||
self.deno_status_label.setText(_('deno_updater.updating'))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #ffaa00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
# Run update in background thread
|
||||
def update_thread():
|
||||
try:
|
||||
success, output = upgrade_deno()
|
||||
|
||||
# Update UI in main thread
|
||||
self.deno_check_button.setEnabled(True)
|
||||
self.deno_update_button.setEnabled(True)
|
||||
self._handle_deno_update_result(success, output)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error updating Deno: {e}")
|
||||
self.deno_check_button.setEnabled(True)
|
||||
self.deno_update_button.setEnabled(True)
|
||||
self._handle_deno_update_result(False, str(e))
|
||||
|
||||
thread = threading.Thread(target=update_thread, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _handle_deno_update_result(self, success: bool, output: str) -> None:
|
||||
"""Handle Deno update completion."""
|
||||
if success:
|
||||
self.deno_status_label.setText(_('deno_updater.update_success'))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #00cc00; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
self.deno_update_button.setVisible(False)
|
||||
|
||||
# Re-check version to update display
|
||||
self.check_deno_updates()
|
||||
else:
|
||||
# Extract meaningful error from output
|
||||
error_msg = output if output else "Unknown error"
|
||||
self.deno_status_label.setText(_('deno_updater.update_failed', error=error_msg))
|
||||
self.deno_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 12px; padding: 8px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
Reference in New Issue
Block a user