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,5 @@
|
||||
"""
|
||||
GUI modules for YTSage.
|
||||
|
||||
This package contains all user interface components and related functionality.
|
||||
"""
|
||||
@@ -0,0 +1,366 @@
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error
|
||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class AnalysisThread(QThread):
|
||||
"""
|
||||
Thread-safe QThread for URL analysis.
|
||||
All results are passed back via signals to ensure thread safety.
|
||||
"""
|
||||
# Signals for status updates
|
||||
status_update = Signal(str)
|
||||
|
||||
# Signals for playlist UI
|
||||
playlist_info_visible = Signal(bool)
|
||||
playlist_info_text = Signal(str)
|
||||
playlist_select_btn_visible = Signal(bool)
|
||||
playlist_select_btn_text = Signal(str)
|
||||
|
||||
# Signal for analysis results - passes all data at once
|
||||
analysis_complete = Signal(dict)
|
||||
|
||||
# Signal for errors
|
||||
analysis_error = Signal(str)
|
||||
|
||||
# Signal when thread finishes (success or failure)
|
||||
analysis_finished = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
cookie_file_path: Optional[str] = None,
|
||||
browser_cookies_option: Optional[str] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
geo_proxy_url: Optional[str] = None,
|
||||
parent=None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.url = url
|
||||
self.cookie_file_path = cookie_file_path
|
||||
self.browser_cookies_option = browser_cookies_option
|
||||
self.proxy_url = proxy_url
|
||||
self.geo_proxy_url = geo_proxy_url
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Request cancellation of the analysis."""
|
||||
self._cancelled = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Main thread execution - performs URL analysis."""
|
||||
try:
|
||||
self.status_update.emit(_("main_ui.analyzing_extracting_basic"))
|
||||
|
||||
url = self.url
|
||||
# Clean up the URL to handle both playlist and video URLs
|
||||
if "list=" in url and "watch?v=" in url:
|
||||
playlist_id = url.split("list=")[1].split("&")[0]
|
||||
url = f"https://www.youtube.com/playlist?list={playlist_id}"
|
||||
|
||||
self._analyze_url_with_subprocess(url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in analysis: {e}")
|
||||
self.analysis_error.emit(_("errors.generic_error", error=str(e)))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
finally:
|
||||
self.analysis_finished.emit()
|
||||
|
||||
def _add_auth_options(self, cmd: List[str]) -> None:
|
||||
"""Add authentication and proxy options to command."""
|
||||
if self.cookie_file_path:
|
||||
cmd.extend(["--cookies", str(self.cookie_file_path)])
|
||||
elif self.browser_cookies_option:
|
||||
cmd.extend(["--cookies-from-browser", self.browser_cookies_option])
|
||||
|
||||
if self.proxy_url:
|
||||
cmd.extend(["--proxy", self.proxy_url])
|
||||
|
||||
if self.geo_proxy_url:
|
||||
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
|
||||
|
||||
def _analyze_url_with_subprocess(self, url: str) -> None:
|
||||
"""Analyze URL using yt-dlp executable."""
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
if not yt_dlp_path:
|
||||
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
|
||||
self.analysis_error.emit(_("errors.ytdlp_not_found"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_extracting_ytdlp"))
|
||||
|
||||
# Build command for basic info extraction
|
||||
cmd = [yt_dlp_path, "--dump-single-json", "--flat-playlist", "--no-warnings", url]
|
||||
self._add_auth_options(cmd)
|
||||
|
||||
logger.debug(f"Executing yt-dlp command: {cmd}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=300,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Analysis timed out")
|
||||
self.analysis_error.emit(_("errors.timeout"))
|
||||
return
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"yt-dlp failed: {result.stderr}")
|
||||
self.analysis_error.emit(_("errors.ytdlp_failed", error=result.stderr))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
||||
|
||||
if not json_lines:
|
||||
logger.error("No data returned from yt-dlp")
|
||||
self.analysis_error.emit(_("errors.no_data_returned"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
try:
|
||||
first_info = json.loads(json_lines[0])
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse yt-dlp output: {e}")
|
||||
self.analysis_error.emit(_("errors.parse_failed", error=str(e)))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_processing_data"))
|
||||
|
||||
# Prepare result data
|
||||
result_data: Dict[str, Any] = {
|
||||
"is_playlist": False,
|
||||
"playlist_info": None,
|
||||
"playlist_entries": [],
|
||||
"video_info": None,
|
||||
"all_formats": [],
|
||||
"available_subtitles": {},
|
||||
"available_automatic_subtitles": {},
|
||||
"thumbnail_url": None,
|
||||
}
|
||||
|
||||
if first_info.get("_type") == "playlist":
|
||||
result_data["is_playlist"] = True
|
||||
result_data["playlist_info"] = first_info
|
||||
playlist_entries = first_info.get("entries", [])
|
||||
result_data["playlist_entries"] = playlist_entries
|
||||
|
||||
if not playlist_entries:
|
||||
logger.error("Playlist contains no valid videos.")
|
||||
self.analysis_error.emit(_("errors.playlist_no_videos"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
# Fetch full info for the first video to get formats
|
||||
self.status_update.emit(_("main_ui.analyzing_fetching_first_video"))
|
||||
first_video_entry = playlist_entries[0]
|
||||
first_video_url = first_video_entry.get("url")
|
||||
|
||||
cmd_single = [yt_dlp_path, "--dump-single-json", "--no-warnings", first_video_url]
|
||||
self._add_auth_options(cmd_single)
|
||||
|
||||
try:
|
||||
result_single = subprocess.run(
|
||||
cmd_single, capture_output=True, text=True, timeout=60,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
if result_single.returncode == 0:
|
||||
result_data["video_info"] = json.loads(result_single.stdout)
|
||||
else:
|
||||
result_data["video_info"] = first_video_entry
|
||||
except subprocess.TimeoutExpired:
|
||||
result_data["video_info"] = first_video_entry
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Update playlist UI via signals
|
||||
playlist_text = _("playlist.display_format",
|
||||
title=first_info.get('title', _('playlist.unknown')),
|
||||
count=len(playlist_entries))
|
||||
self.playlist_info_text.emit(playlist_text)
|
||||
self.playlist_info_visible.emit(True)
|
||||
self.playlist_select_btn_text.emit(_("main_ui.select_videos_all"))
|
||||
self.playlist_select_btn_visible.emit(True)
|
||||
|
||||
else:
|
||||
# Handle single video
|
||||
result_data["is_playlist"] = False
|
||||
result_data["video_info"] = first_info
|
||||
result_data["playlist_entries"] = []
|
||||
result_data["playlist_info"] = None
|
||||
|
||||
# Hide playlist UI
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
|
||||
# Verify we have format information
|
||||
video_info = result_data["video_info"]
|
||||
if not video_info or "formats" not in video_info:
|
||||
logger.error("No format information available")
|
||||
self.analysis_error.emit(_("errors.no_format_info"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
self.playlist_select_btn_visible.emit(False)
|
||||
return
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_processing_formats_ytdlp"))
|
||||
result_data["all_formats"] = video_info.get("formats", [])
|
||||
|
||||
# Get thumbnail URL
|
||||
self.status_update.emit(_("main_ui.analyzing_loading_thumbnail_ytdlp"))
|
||||
playlist_info = result_data.get("playlist_info") or {}
|
||||
thumbnail_url = playlist_info.get("thumbnail") or video_info.get("thumbnail")
|
||||
result_data["thumbnail_url"] = thumbnail_url
|
||||
|
||||
# Handle subtitles
|
||||
self.status_update.emit(_("main_ui.analyzing_processing_subtitles_ytdlp"))
|
||||
result_data["available_subtitles"] = video_info.get("subtitles", {})
|
||||
result_data["available_automatic_subtitles"] = video_info.get("automatic_captions", {})
|
||||
|
||||
self.status_update.emit(_("main_ui.analyzing_updating_table"))
|
||||
|
||||
# Emit all results at once
|
||||
self.analysis_complete.emit(result_data)
|
||||
|
||||
|
||||
class AnalysisMixin:
|
||||
"""Mixin class providing URL analysis functionality for YTSageApp."""
|
||||
|
||||
# Track the current analysis thread
|
||||
_analysis_thread: Optional[AnalysisThread] = None
|
||||
|
||||
def analyze_url(self) -> None:
|
||||
"""Start URL analysis in a background thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
if self.is_updating_ytdlp:
|
||||
QMessageBox.warning(self, _("update.update_in_progress_title"), _("update.update_in_progress_message"))
|
||||
return
|
||||
|
||||
url = self.url_input.text().strip()
|
||||
if not url:
|
||||
self.signals.update_status.emit(_("main_ui.invalid_url_or_enter"))
|
||||
return
|
||||
|
||||
# Validate URL before processing
|
||||
is_valid, error_message = validate_video_url(url)
|
||||
if not is_valid:
|
||||
QMessageBox.warning(self, _("main_ui.error_title"), error_message)
|
||||
return
|
||||
|
||||
# Cancel any existing analysis thread
|
||||
if self._analysis_thread is not None and self._analysis_thread.isRunning():
|
||||
self._analysis_thread.cancel()
|
||||
self._analysis_thread.wait(1000) # Wait up to 1 second
|
||||
|
||||
# Reset analysis state and disable controls
|
||||
self.analysis_completed = False
|
||||
self.toggle_analysis_dependent_controls(enabled=False)
|
||||
|
||||
self.signals.update_status.emit(_("main_ui.analyzing_preparing"))
|
||||
self.is_analyzing = True
|
||||
|
||||
# Create and configure the analysis thread
|
||||
self._analysis_thread = AnalysisThread(
|
||||
url=url,
|
||||
cookie_file_path=self.cookie_file_path,
|
||||
browser_cookies_option=self.browser_cookies_option,
|
||||
proxy_url=self.proxy_url,
|
||||
geo_proxy_url=self.geo_proxy_url,
|
||||
parent=self
|
||||
)
|
||||
|
||||
# Connect signals to handlers
|
||||
self._analysis_thread.status_update.connect(self.signals.update_status.emit)
|
||||
self._analysis_thread.playlist_info_visible.connect(self.signals.playlist_info_label_visible.emit)
|
||||
self._analysis_thread.playlist_info_text.connect(self.signals.playlist_info_label_text.emit)
|
||||
self._analysis_thread.playlist_select_btn_visible.connect(self.signals.playlist_select_btn_visible.emit)
|
||||
self._analysis_thread.playlist_select_btn_text.connect(self.signals.playlist_select_btn_text.emit)
|
||||
self._analysis_thread.analysis_complete.connect(self._on_analysis_complete)
|
||||
self._analysis_thread.analysis_error.connect(self._on_analysis_error)
|
||||
self._analysis_thread.analysis_finished.connect(self._on_analysis_finished)
|
||||
|
||||
# Start the thread
|
||||
self._analysis_thread.start()
|
||||
|
||||
def _on_analysis_complete(self, result_data: Dict[str, Any]) -> None:
|
||||
"""Handle successful analysis completion - runs in main thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
# Update instance variables with results (safe - we're in main thread)
|
||||
self.is_playlist = result_data["is_playlist"]
|
||||
self.playlist_info = result_data["playlist_info"]
|
||||
self.playlist_entries = result_data["playlist_entries"]
|
||||
self.video_info = result_data["video_info"]
|
||||
self.all_formats = result_data["all_formats"]
|
||||
self.available_subtitles = result_data["available_subtitles"]
|
||||
self.available_automatic_subtitles = result_data["available_automatic_subtitles"]
|
||||
self.selected_playlist_items = None
|
||||
self.selected_subtitles = []
|
||||
|
||||
# Update UI components (safe - we're in main thread)
|
||||
self.update_video_info(self.video_info)
|
||||
|
||||
# Download thumbnail
|
||||
thumbnail_url = result_data.get("thumbnail_url")
|
||||
if thumbnail_url:
|
||||
self.download_thumbnail(thumbnail_url)
|
||||
|
||||
# Save thumbnail if enabled
|
||||
if self.save_thumbnail:
|
||||
self.download_thumbnail_file(self.video_url, self.last_path)
|
||||
|
||||
# Update subtitle UI
|
||||
self.signals.selected_subs_label_text.emit(_("main_ui.zero_selected"))
|
||||
|
||||
# Update format table
|
||||
self.video_button.setChecked(True)
|
||||
self.audio_button.setChecked(False)
|
||||
self.filter_formats()
|
||||
|
||||
self.signals.update_status.emit(_("main_ui.analysis_complete"))
|
||||
|
||||
# Mark analysis as complete and enable controls
|
||||
self.analysis_completed = True
|
||||
self.toggle_analysis_dependent_controls(enabled=True)
|
||||
|
||||
def _on_analysis_error(self, error_message: str) -> None:
|
||||
"""Handle analysis error - runs in main thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
self.signals.update_status.emit(error_message)
|
||||
|
||||
def _on_analysis_finished(self) -> None:
|
||||
"""Handle analysis thread completion - runs in main thread."""
|
||||
self = cast("YTSageApp", self)
|
||||
self.is_analyzing = False
|
||||
@@ -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;"
|
||||
)
|
||||
@@ -0,0 +1,497 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFontMetrics
|
||||
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
from ..utils.ytsage_localization import _
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class FormatSignals(QObject):
|
||||
format_update = Signal(list)
|
||||
|
||||
|
||||
class FormatTableMixin:
|
||||
def _calculate_column_width(self, label: str, min_width: int, padding: int) -> int:
|
||||
"""Calculate responsive column width based on header text length."""
|
||||
self = cast("YTSageApp", self)
|
||||
font_metrics = QFontMetrics(self.format_table.horizontalHeader().font())
|
||||
text_width = font_metrics.horizontalAdvance(label)
|
||||
return max(text_width + padding, min_width)
|
||||
|
||||
def _apply_column_widths(self, header_labels: list[str], is_playlist_mode: bool = False) -> None:
|
||||
"""Apply responsive column widths to format table."""
|
||||
self = cast("YTSageApp", self)
|
||||
|
||||
if is_playlist_mode:
|
||||
# Playlist mode: 6 columns
|
||||
configs = [
|
||||
{"min_width": 70, "padding": 40}, # Select
|
||||
{"min_width": 100, "padding": 30}, # Quality
|
||||
{"min_width": 100, "padding": 30}, # Resolution
|
||||
{"min_width": 60, "padding": 30}, # FPS
|
||||
{"min_width": 60, "padding": 30}, # HDR
|
||||
{"min_width": 100, "padding": 30}, # Audio
|
||||
]
|
||||
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
calculated_width = self._calculate_column_width(header_labels[0], configs[0]["min_width"], configs[0]["padding"])
|
||||
self.format_table.setColumnWidth(0, calculated_width)
|
||||
|
||||
# Remaining columns stretch
|
||||
for i in range(1, 6):
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch)
|
||||
else:
|
||||
# Normal mode: 9 columns
|
||||
configs = [
|
||||
{"min_width": 70, "padding": 40, "stretch": False}, # Select - fixed
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Quality - stretch
|
||||
{"min_width": 85, "padding": 30, "stretch": False}, # Extension - fixed
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Resolution - stretch
|
||||
{"min_width": 90, "padding": 30, "stretch": True}, # File Size - stretch
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Codec - stretch
|
||||
{"min_width": 100, "padding": 30, "stretch": True}, # Audio - stretch
|
||||
{"min_width": 60, "padding": 30, "stretch": False}, # FPS - fixed
|
||||
{"min_width": 60, "padding": 30, "stretch": False}, # HDR - fixed
|
||||
]
|
||||
|
||||
# Apply column widths with mixed fixed and stretch modes
|
||||
for col_index, (label, config) in enumerate(zip(header_labels, configs)):
|
||||
calculated_width = self._calculate_column_width(label, config["min_width"], config["padding"])
|
||||
|
||||
if config["stretch"]:
|
||||
# Stretchable columns for flexible content
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(col_index, QHeaderView.ResizeMode.Stretch)
|
||||
else:
|
||||
# Fixed columns for consistent sizing
|
||||
self.format_table.horizontalHeader().setSectionResizeMode(col_index, QHeaderView.ResizeMode.Fixed)
|
||||
self.format_table.setColumnWidth(col_index, calculated_width)
|
||||
|
||||
def setup_format_table(self) -> QTableWidget:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
self.format_signals = FormatSignals()
|
||||
# Format table with improved styling
|
||||
self.format_table = QTableWidget()
|
||||
self.format_table.setColumnCount(9)
|
||||
|
||||
# Get translated header labels
|
||||
header_labels = [
|
||||
_("formats.select"),
|
||||
_("formats.quality"),
|
||||
_("formats.extension"),
|
||||
_("formats.resolution"),
|
||||
_("formats.file_size"),
|
||||
_("formats.codec"),
|
||||
_("formats.audio"),
|
||||
_("formats.fps"),
|
||||
_("formats.hdr"),
|
||||
]
|
||||
self.format_table.setHorizontalHeaderLabels(header_labels)
|
||||
|
||||
# Enable alternating row colors
|
||||
self.format_table.setAlternatingRowColors(True)
|
||||
|
||||
# Apply responsive column widths
|
||||
self._apply_column_widths(header_labels, is_playlist_mode=False)
|
||||
|
||||
# Set vertical header (row numbers) visible to false
|
||||
self.format_table.verticalHeader().setVisible(False)
|
||||
|
||||
# Set selection mode to no selection (since we're using checkboxes)
|
||||
self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
|
||||
# Disable editing to prevent the selection box on double-click
|
||||
self.format_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
|
||||
self.format_table.setStyleSheet(
|
||||
"""
|
||||
QTableWidget {
|
||||
background-color: #1b2021;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
gridline-color: #1b2021;
|
||||
}
|
||||
QTableWidget::item {
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid #1b2021;
|
||||
}
|
||||
QTableWidget::item:selected {
|
||||
background-color: transparent;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background-color: #15181b;
|
||||
padding: 5px;
|
||||
border: 1px solid #1b2021;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
/* Style alternating rows with more contrast */
|
||||
QTableWidget::item:alternate {
|
||||
background-color: #212529;
|
||||
}
|
||||
QTableWidget::item {
|
||||
background-color: #16191b;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #15181b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
}
|
||||
QWidget {
|
||||
background-color: transparent;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Store format checkboxes and formats
|
||||
self.format_checkboxes = []
|
||||
self.all_formats = []
|
||||
self._row_format_type = [] # Track format type per row: 'video' or 'audio'
|
||||
self._table_built = False # Track if table has been built with current formats
|
||||
|
||||
# Set table size policies
|
||||
self.format_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
# Set minimum and maximum heights
|
||||
self.format_table.setMinimumHeight(200)
|
||||
|
||||
# Connect the signal
|
||||
self.format_signals.format_update.connect(self._update_format_table)
|
||||
|
||||
return self.format_table
|
||||
|
||||
def filter_formats(self) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if not hasattr(self, "all_formats") or not self.all_formats:
|
||||
return
|
||||
|
||||
# Check if we need to rebuild the table (first time or formats changed)
|
||||
if not self._table_built:
|
||||
self._build_full_format_table()
|
||||
return
|
||||
|
||||
# Use row visibility for fast filtering instead of rebuilding table
|
||||
show_video = hasattr(self, "video_button") and self.video_button.isChecked() # type: ignore[reportAttributeAccessIssue]
|
||||
show_audio = hasattr(self, "audio_button") and self.audio_button.isChecked() # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
for row, format_type in enumerate(self._row_format_type):
|
||||
if format_type == "video":
|
||||
self.format_table.setRowHidden(row, not show_video)
|
||||
else: # audio
|
||||
self.format_table.setRowHidden(row, not show_audio)
|
||||
|
||||
def _build_full_format_table(self) -> None:
|
||||
"""Build the complete format table once with all formats."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Clear current table
|
||||
self.format_table.setRowCount(0)
|
||||
self.format_checkboxes.clear()
|
||||
self._row_format_type.clear()
|
||||
|
||||
# Separate and filter formats
|
||||
video_formats = [f for f in self.all_formats if f.get("vcodec") != "none" and f.get("filesize") is not None]
|
||||
audio_formats = [
|
||||
f
|
||||
for f in self.all_formats
|
||||
if (f.get("vcodec") == "none" or "audio only" in f.get("format_note", "").lower())
|
||||
and f.get("acodec") != "none"
|
||||
and f.get("filesize") is not None
|
||||
]
|
||||
|
||||
# Sort formats by quality
|
||||
def get_quality(f):
|
||||
if f.get("vcodec") != "none":
|
||||
resolution = f.get("resolution", "0x0")
|
||||
if resolution is None or not isinstance(resolution, str):
|
||||
return 0
|
||||
try:
|
||||
res = resolution.split("x")[-1]
|
||||
return int(res)
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
else:
|
||||
return f.get("abr", 0)
|
||||
|
||||
video_formats.sort(key=get_quality, reverse=True)
|
||||
audio_formats.sort(key=get_quality, reverse=True)
|
||||
|
||||
# Combine: video first, then audio (maintains logical grouping)
|
||||
all_filtered = [(f, "video") for f in video_formats] + [(f, "audio") for f in audio_formats]
|
||||
|
||||
# Build table with format type tracking
|
||||
self._populate_format_table(all_filtered)
|
||||
self._table_built = True
|
||||
|
||||
# Apply initial visibility based on current button states
|
||||
self.filter_formats()
|
||||
|
||||
def _populate_format_table(self, formats_with_types: list) -> None:
|
||||
"""Populate the format table with formats and their types."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
is_playlist_mode = hasattr(self, "is_playlist") and self.is_playlist # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Configure columns based on mode
|
||||
if is_playlist_mode:
|
||||
self.format_table.setColumnCount(6)
|
||||
header_labels = [_("formats.select"), _("formats.quality"), _("formats.resolution"), _("formats.fps"), _("formats.hdr"), _("formats.audio")]
|
||||
self.format_table.setHorizontalHeaderLabels(header_labels)
|
||||
|
||||
# Configure column visibility and resizing for playlist mode
|
||||
self.format_table.setColumnHidden(6, True)
|
||||
self.format_table.setColumnHidden(7, True)
|
||||
self.format_table.setColumnHidden(8, True)
|
||||
|
||||
# Apply responsive column widths for playlist mode
|
||||
self._apply_column_widths(header_labels, is_playlist_mode=True)
|
||||
|
||||
else:
|
||||
self.format_table.setColumnCount(9)
|
||||
header_labels = [
|
||||
_("formats.select"),
|
||||
_("formats.quality"),
|
||||
_("formats.extension"),
|
||||
_("formats.resolution"),
|
||||
_("formats.file_size"),
|
||||
_("formats.codec"),
|
||||
_("formats.audio"),
|
||||
_("formats.fps"),
|
||||
_("formats.hdr"),
|
||||
]
|
||||
self.format_table.setHorizontalHeaderLabels(header_labels)
|
||||
self._apply_column_widths(header_labels, is_playlist_mode=False)
|
||||
|
||||
for f, format_type in formats_with_types:
|
||||
row = self.format_table.rowCount()
|
||||
self.format_table.insertRow(row)
|
||||
self._row_format_type.append(format_type)
|
||||
|
||||
# Create checkbox widget
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }")
|
||||
checkbox.format_id = f["format_id"]
|
||||
checkbox.is_audio_only = f.get("vcodec") == "none"
|
||||
checkbox.has_audio = f.get("acodec") != "none"
|
||||
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
|
||||
self.format_checkboxes.append(checkbox)
|
||||
|
||||
# Create a container widget for the checkbox
|
||||
checkbox_container = QWidget()
|
||||
checkbox_layout = QHBoxLayout(checkbox_container)
|
||||
checkbox_layout.addWidget(checkbox)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.format_table.setCellWidget(row, 0, checkbox_container)
|
||||
|
||||
# Quality label with color coding
|
||||
quality_label = self.get_quality_label(f)
|
||||
quality_item = QTableWidgetItem(quality_label)
|
||||
# Set color based on quality (check multiple language terms)
|
||||
quality_lower = quality_label.lower()
|
||||
if any(term.lower() in quality_lower for term in ["Best", "Óptima", "Mejor", "Melhor", "Лучшее", "最佳", "Beste", "Meilleure", "सर्वोत्तम", "Terbaik", "En iyi", "Najlepsza", "Najlepszy", "Najlepsze", "Migliore", "Miglior", "الأفضل", "أفضل", "最高"]):
|
||||
quality_item.setForeground(QColor("#00ff00")) # Green for best quality
|
||||
elif any(term.lower() in quality_lower for term in ["High", "Alta", "Alto", "Áudio Alto", "Audio Alto", "Высокое", "高清", "高质量", "Hoch", "Haute", "Élevé", "Audio élevé", "उच्च", "उच्च ऑडियो", "Tinggi", "Audio tinggi", "Yüksek", "Yüksek ses", "Wysoka", "Wysoki", "Wysokie", "Audio alto", "عالية", "عالي", "صوت عالي", "高", "高音質"]):
|
||||
quality_item.setForeground(QColor("#00cc00")) # Light green for high quality
|
||||
elif any(term.lower() in quality_lower for term in ["Medium", "Media", "Medio", "Média", "Áudio Médio", "Audio Medio", "Среднее", "中等", "Mittel", "Moyenne", "Audio moyen", "मध्यम", "मध्यम ऑडियो", "Sedang", "Audio sedang", "Orta", "Orta ses", "Średnia", "Średni", "Średnie", "Audio medio", "متوسطة", "متوسط", "صوت متوسط", "中", "中音質"]):
|
||||
quality_item.setForeground(QColor("#ffaa00")) # Orange for medium quality
|
||||
elif any(term.lower() in quality_lower for term in ["Low", "Baja", "Bajo", "Baixa", "Áudio Baixo", "Audio Bajo", "Низкое", "低质量", "Niedrig", "Niedriges Audio", "Faible", "Audio faible", "Qualité faible", "निम्न", "निम्न ऑडियो", "निम्न गुणवत्ता", "Rendah", "Audio rendah", "Kualitas rendah", "Düşük", "Düşük ses", "Düşük kalite", "Niska", "Niski", "Niskie", "Bassa", "Audio basso", "Bassa qualità", "منخفضة", "منخفض", "صوت منخفض", "جودة منخفضة", "低", "低音質", "低品質"]):
|
||||
quality_item.setForeground(QColor("#ff5555")) # Red for low quality
|
||||
self.format_table.setItem(row, 1, quality_item)
|
||||
|
||||
# Resolution
|
||||
resolution = f.get("resolution", "N/A")
|
||||
|
||||
if is_playlist_mode:
|
||||
# Column 2 for playlist mode: Resolution
|
||||
self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
|
||||
|
||||
# Column 3: FPS (Frame Rate)
|
||||
fps_value = f.get("fps")
|
||||
if fps_value is not None and fps_value >= 1:
|
||||
fps_text = f"{fps_value:.0f}fps"
|
||||
else:
|
||||
fps_text = "N/A"
|
||||
fps_item = QTableWidgetItem(fps_text)
|
||||
if fps_value and fps_value >= 60:
|
||||
fps_item.setForeground(QColor("#00ff00"))
|
||||
elif fps_value and fps_value >= 30:
|
||||
fps_item.setForeground(QColor("#ffaa00"))
|
||||
elif fps_value and fps_value >= 1:
|
||||
fps_item.setForeground(QColor("#ff5555"))
|
||||
else:
|
||||
fps_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 3, fps_item)
|
||||
|
||||
# Column 4: HDR
|
||||
if f.get("vcodec") == "none":
|
||||
hdr_text = "N/A"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
else:
|
||||
hdr_value = f.get("dynamic_range")
|
||||
if hdr_value and hdr_value != "SDR":
|
||||
hdr_text = hdr_value
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#00ffff"))
|
||||
else:
|
||||
hdr_text = "SDR"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 4, hdr_item)
|
||||
else:
|
||||
# Extension for normal mode (column 2)
|
||||
self.format_table.setItem(row, 2, QTableWidgetItem(f.get("ext", "").upper()))
|
||||
|
||||
# Audio Status column
|
||||
needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none"
|
||||
audio_status = _("formats.will_merge_audio") if needs_audio else (_("formats.has_audio") if f.get("vcodec") != "none" else _("formats.audio_only"))
|
||||
audio_item = QTableWidgetItem(audio_status)
|
||||
if needs_audio:
|
||||
audio_item.setForeground(QColor("#ffa500"))
|
||||
elif audio_status == _("formats.audio_only"):
|
||||
audio_item.setForeground(QColor("#cccccc"))
|
||||
else:
|
||||
audio_item.setForeground(QColor("#00cc00"))
|
||||
audio_column_index = 5 if is_playlist_mode else 6
|
||||
self.format_table.setItem(row, audio_column_index, audio_item)
|
||||
|
||||
# Populate columns only shown in non-playlist mode
|
||||
if not is_playlist_mode:
|
||||
# Column 3: Resolution
|
||||
self.format_table.setItem(row, 3, QTableWidgetItem(resolution))
|
||||
|
||||
# Column 4: File Size
|
||||
filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
|
||||
self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
|
||||
|
||||
# Column 5: Codec
|
||||
if f.get("vcodec") == "none":
|
||||
codec = f.get("acodec", "N/A")
|
||||
else:
|
||||
codec = f"{f.get('vcodec', 'N/A')}"
|
||||
if f.get("acodec") != "none":
|
||||
codec += f" / {f.get('acodec', 'N/A')}"
|
||||
self.format_table.setItem(row, 5, QTableWidgetItem(codec))
|
||||
|
||||
# Column 7: FPS (Frame Rate)
|
||||
fps_value = f.get("fps")
|
||||
if fps_value is not None and fps_value >= 1:
|
||||
fps_text = f"{fps_value:.0f}fps"
|
||||
else:
|
||||
fps_text = "N/A"
|
||||
|
||||
fps_item = QTableWidgetItem(fps_text)
|
||||
if fps_value and fps_value >= 60:
|
||||
fps_item.setForeground(QColor("#00ff00"))
|
||||
elif fps_value and fps_value >= 30:
|
||||
fps_item.setForeground(QColor("#ffaa00"))
|
||||
elif fps_value and fps_value >= 1:
|
||||
fps_item.setForeground(QColor("#ff5555"))
|
||||
else:
|
||||
fps_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 7, fps_item)
|
||||
|
||||
# Column 8: HDR (Dynamic Range)
|
||||
if f.get("vcodec") == "none":
|
||||
hdr_text = "N/A"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
else:
|
||||
hdr_value = f.get("dynamic_range")
|
||||
if hdr_value and hdr_value != "SDR":
|
||||
hdr_text = hdr_value
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#00ffff"))
|
||||
else:
|
||||
hdr_text = "SDR"
|
||||
hdr_item = QTableWidgetItem(hdr_text)
|
||||
hdr_item.setForeground(QColor("#888888"))
|
||||
self.format_table.setItem(row, 8, hdr_item)
|
||||
|
||||
def _update_format_table(self, formats) -> None:
|
||||
"""Signal handler that triggers a full table rebuild when formats change."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Mark table as needing rebuild and trigger it
|
||||
self._table_built = False
|
||||
self._build_full_format_table()
|
||||
|
||||
def handle_checkbox_click(self, clicked_checkbox) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
for checkbox in self.format_checkboxes:
|
||||
if checkbox != clicked_checkbox:
|
||||
checkbox.setChecked(False)
|
||||
|
||||
def get_selected_format(self):
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
for checkbox in self.format_checkboxes:
|
||||
if checkbox.isChecked():
|
||||
return {
|
||||
"format_id": checkbox.format_id,
|
||||
"is_audio_only": getattr(checkbox, "is_audio_only", False),
|
||||
"has_audio": getattr(checkbox, "has_audio", False),
|
||||
}
|
||||
return None
|
||||
|
||||
def update_format_table(self, formats) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
self.all_formats = formats
|
||||
self._table_built = False # Reset flag to trigger rebuild with new formats
|
||||
self.format_signals.format_update.emit(formats)
|
||||
|
||||
def get_quality_label(self, format_info) -> str:
|
||||
"""Determine quality label based on format information"""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if format_info.get("vcodec") == "none":
|
||||
# Audio quality
|
||||
abr = format_info.get("abr", 0)
|
||||
if abr >= 256:
|
||||
return _("formats.best_audio")
|
||||
elif abr >= 192:
|
||||
return _("formats.high_audio")
|
||||
elif abr >= 128:
|
||||
return _("formats.medium_audio")
|
||||
else:
|
||||
return _("formats.low_audio")
|
||||
else:
|
||||
# Video quality
|
||||
height = 0
|
||||
resolution = format_info.get("resolution", "")
|
||||
if resolution:
|
||||
try:
|
||||
height = int(resolution.split("x")[1])
|
||||
except:
|
||||
pass
|
||||
|
||||
if height >= 2160:
|
||||
return _("formats.best_4k")
|
||||
elif height >= 1440:
|
||||
return _("formats.best_2k")
|
||||
elif height >= 1080:
|
||||
return _("formats.high_1080p")
|
||||
elif height >= 720:
|
||||
return _("formats.high_720p")
|
||||
elif height >= 480:
|
||||
return _("formats.medium_480p")
|
||||
else:
|
||||
return _("formats.low_quality")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from .ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
|
||||
SponsorBlockCategoryDialog,
|
||||
SubtitleSelectionDialog,
|
||||
)
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ytsage_gui_main import YTSageApp
|
||||
|
||||
|
||||
class ThumbnailDownloadThread(QThread):
|
||||
"""Thread to download thumbnail image asynchronously."""
|
||||
finished = Signal(bytes)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, url):
|
||||
super().__init__()
|
||||
self.url = url
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get(self.url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
self.finished.emit(response.content)
|
||||
else:
|
||||
self.error.emit(f"HTTP Error: {response.status_code}")
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class VideoInfoMixin:
|
||||
def setup_video_info_section(self) -> QHBoxLayout:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Create a horizontal layout for thumbnail and video info
|
||||
media_info_layout = QHBoxLayout()
|
||||
media_info_layout.setSpacing(15)
|
||||
|
||||
# Left side container for thumbnail
|
||||
thumbnail_container = QWidget()
|
||||
thumbnail_container.setFixedWidth(320)
|
||||
thumbnail_layout = QVBoxLayout(thumbnail_container)
|
||||
thumbnail_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Thumbnail on the left
|
||||
self.thumbnail_label = QLabel()
|
||||
self.thumbnail_label.setFixedSize(320, 180)
|
||||
self.thumbnail_label.setStyleSheet("border: 2px solid #3d3d3d; border-radius: 4px;")
|
||||
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
thumbnail_layout.addWidget(self.thumbnail_label)
|
||||
thumbnail_layout.addStretch()
|
||||
|
||||
media_info_layout.addWidget(thumbnail_container)
|
||||
|
||||
# Video information on the right
|
||||
video_info_layout = QVBoxLayout()
|
||||
video_info_layout.setSpacing(2) # Reduce spacing between elements
|
||||
video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# Title and info labels
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setWordWrap(True)
|
||||
self.title_label.setStyleSheet("font-size: 12px; font-weight: bold;")
|
||||
|
||||
# Add basic info labels
|
||||
self.channel_label = QLabel()
|
||||
self.views_label = QLabel()
|
||||
self.date_label = QLabel()
|
||||
self.duration_label = QLabel()
|
||||
self.like_count_label = QLabel()
|
||||
|
||||
# Style the info labels
|
||||
for label in [
|
||||
self.channel_label,
|
||||
self.views_label,
|
||||
self.date_label,
|
||||
self.duration_label,
|
||||
self.like_count_label,
|
||||
]:
|
||||
label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
color: #999999;
|
||||
font-size: 11px;
|
||||
padding: 0px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Add labels to video info layout
|
||||
video_info_layout.addWidget(self.title_label)
|
||||
video_info_layout.addWidget(self.channel_label)
|
||||
video_info_layout.addWidget(self.views_label)
|
||||
video_info_layout.addWidget(self.like_count_label)
|
||||
video_info_layout.addWidget(self.date_label)
|
||||
video_info_layout.addWidget(self.duration_label)
|
||||
|
||||
# Add spacing before subtitle section
|
||||
video_info_layout.addSpacing(10)
|
||||
|
||||
# --- Subtitle Section ---
|
||||
subtitle_layout = QHBoxLayout()
|
||||
subtitle_layout.setSpacing(10)
|
||||
|
||||
# Subtitle selection button
|
||||
self.subtitle_select_btn = QPushButton(_("main_ui.select_subtitles")) # Renamed & changed text
|
||||
self.subtitle_select_btn.setFixedHeight(30)
|
||||
# self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed
|
||||
self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog)
|
||||
self.subtitle_select_btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #1d1e22;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px; /* Adjusted padding */
|
||||
}
|
||||
QPushButton:hover { background-color: #2a2d36; }
|
||||
/* Optional: Style differently if subtitles ARE selected */
|
||||
QPushButton[subtitlesSelected="true"] {
|
||||
border-color: #c90000; /* Indicate selection */
|
||||
}
|
||||
/* Style for disabled state */
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
border-color: #3d3d3d;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling
|
||||
subtitle_layout.addWidget(self.subtitle_select_btn)
|
||||
|
||||
# Label to show number of selected subtitles
|
||||
self.selected_subs_label = QLabel(_("selection.none_selected"))
|
||||
self.selected_subs_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
|
||||
subtitle_layout.addWidget(self.selected_subs_label)
|
||||
|
||||
# Add the subtitle layout to the main video info layout
|
||||
video_info_layout.addLayout(subtitle_layout)
|
||||
# --- End Subtitle Section ---
|
||||
|
||||
# Add small spacing between subtitle and sponsorblock sections
|
||||
video_info_layout.addSpacing(4)
|
||||
|
||||
# --- SponsorBlock Section ---
|
||||
sponsorblock_layout = QHBoxLayout()
|
||||
|
||||
self.sponsorblock_select_btn = QPushButton(_("main_ui.sponsorblock_categories"))
|
||||
self.sponsorblock_select_btn.setFixedHeight(30)
|
||||
self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog)
|
||||
self.sponsorblock_select_btn.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #1d1e22;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #2a2d36;
|
||||
}
|
||||
QPushButton[sponsorBlockSelected="true"] {
|
||||
border-color: #c90000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
border-color: #3d3d3d;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
||||
sponsorblock_layout.addWidget(self.sponsorblock_select_btn)
|
||||
|
||||
# Label to show selection count
|
||||
self.selected_sponsorblock_label = QLabel(_("selection.none_selected"))
|
||||
self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
|
||||
sponsorblock_layout.addWidget(self.selected_sponsorblock_label)
|
||||
|
||||
sponsorblock_layout.addStretch()
|
||||
|
||||
# Add the sponsorblock layout to the main video info layout
|
||||
video_info_layout.addLayout(sponsorblock_layout)
|
||||
# --- End SponsorBlock Section ---
|
||||
|
||||
# Initialize SponsorBlock categories as empty initially (will be set to defaults when user opens dialog)
|
||||
self.selected_sponsorblock_categories = []
|
||||
self._update_sponsorblock_display()
|
||||
|
||||
# Add stretch at the bottom
|
||||
video_info_layout.addStretch()
|
||||
|
||||
# Add video info layout to main layout
|
||||
media_info_layout.addLayout(video_info_layout, stretch=1)
|
||||
|
||||
return media_info_layout
|
||||
|
||||
def setup_playlist_info_section(self) -> QLabel:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
self.playlist_info_label = QLabel()
|
||||
self.playlist_info_label.setVisible(False)
|
||||
self.playlist_info_label.setStyleSheet(
|
||||
"""
|
||||
QLabel {
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
padding: 5px 8px;
|
||||
margin: 0;
|
||||
background-color: #1d1e22;
|
||||
border: 1px solid #c90000;
|
||||
border-radius: 4px;
|
||||
min-height: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
return self.playlist_info_label
|
||||
|
||||
def update_video_info(self, info) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if hasattr(self, "is_playlist") and self.is_playlist:
|
||||
# Playlist Mode: Show playlist title and video count
|
||||
self.title_label.setText(self.playlist_info.get("title", _("playlist.unknown")))
|
||||
|
||||
num_videos = len(getattr(self, "playlist_entries", []))
|
||||
self.duration_label.setText(_("playlist.total_videos", count=num_videos))
|
||||
|
||||
# Hide video-specific info
|
||||
self.channel_label.setText("")
|
||||
self.views_label.setText("")
|
||||
self.date_label.setText("")
|
||||
self.like_count_label.setText("")
|
||||
self.channel_label.setVisible(False)
|
||||
self.views_label.setVisible(False)
|
||||
self.date_label.setVisible(False)
|
||||
self.like_count_label.setVisible(False)
|
||||
else:
|
||||
# Single Video Mode: Show standard video info
|
||||
# Ensure labels are visible first
|
||||
self.channel_label.setVisible(True)
|
||||
self.views_label.setVisible(True)
|
||||
self.date_label.setVisible(True)
|
||||
self.like_count_label.setVisible(True)
|
||||
|
||||
# Format view count with commas
|
||||
views = info.get("view_count")
|
||||
formatted_views = f"{views:,}" if views is not None else "N/A"
|
||||
|
||||
# Format like count with commas
|
||||
likes = info.get("like_count")
|
||||
formatted_likes = f"{likes:,}" if likes is not None else "N/A"
|
||||
|
||||
# Format upload date
|
||||
upload_date = info.get("upload_date", "")
|
||||
if upload_date:
|
||||
date_obj = datetime.strptime(upload_date, "%Y%m%d")
|
||||
formatted_date = date_obj.strftime("%B %d, %Y")
|
||||
else:
|
||||
formatted_date = _("video_info.unknown_date")
|
||||
|
||||
# Format duration
|
||||
duration = info.get("duration", 0)
|
||||
minutes = duration // 60
|
||||
seconds = duration % 60
|
||||
duration_str = f"{minutes}:{seconds:02d}"
|
||||
|
||||
# Update labels with localized text
|
||||
self.title_label.setText(info.get("title", _("video_info.unknown_title")))
|
||||
self.channel_label.setText(f"{_("video_info.channel")}: {info.get('uploader', _("video_info.unknown_channel"))}")
|
||||
self.views_label.setText(f"{_("video_info.views")}: {formatted_views}")
|
||||
self.like_count_label.setText(f"{_("video_info.likes")}: {formatted_likes}")
|
||||
self.date_label.setText(f"{_("video_info.upload_date")}: {formatted_date}")
|
||||
self.duration_label.setText(f"{_("video_info.duration")}: {duration_str}")
|
||||
|
||||
def open_subtitle_dialog(self) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
|
||||
logger.warning("Subtitle info not loaded yet.")
|
||||
return
|
||||
|
||||
if not hasattr(self, "selected_subtitles"):
|
||||
self.selected_subtitles = []
|
||||
|
||||
dialog = SubtitleSelectionDialog(
|
||||
self.available_subtitles, # type: ignore[reportAttributeAccessIssue]
|
||||
self.available_automatic_subtitles, # type: ignore[reportAttributeAccessIssue]
|
||||
self.selected_subtitles,
|
||||
self, # Parent for the dialog
|
||||
)
|
||||
|
||||
# removed extra logic for mapping to main_windows
|
||||
merge_checkbox = getattr(self, "merge_subs_checkbox", None)
|
||||
|
||||
if dialog.exec(): # If user clicks OK
|
||||
self.selected_subtitles = dialog.get_selected_subtitles()
|
||||
logger.info(f"Selected subtitles: {self.selected_subtitles}")
|
||||
# Update UI to reflect selection
|
||||
count = len(self.selected_subtitles)
|
||||
self.selected_subs_label.setText(_("subtitle_selection.count_selected", count=count))
|
||||
self.subtitle_select_btn.setProperty("subtitlesSelected", count > 0)
|
||||
|
||||
# Enable/disable the merge checkbox in the parent window
|
||||
if merge_checkbox:
|
||||
# Only enable merge checkbox if we're not in Audio Only mode and analysis is complete
|
||||
is_audio_only = hasattr(self, "audio_button") and self.audio_button.isChecked()
|
||||
has_analysis = getattr(self, "analysis_completed", False)
|
||||
# In audio-only mode, we still allow subtitle selection but not merging
|
||||
should_enable = count > 0 and not is_audio_only and has_analysis
|
||||
merge_checkbox.setEnabled(should_enable)
|
||||
# Update tooltip
|
||||
if not has_analysis:
|
||||
merge_checkbox.setToolTip(_("main_ui.analyze_first_tooltip"))
|
||||
elif is_audio_only:
|
||||
merge_checkbox.setToolTip(_("main_ui.audio_mode_disabled"))
|
||||
elif count == 0:
|
||||
merge_checkbox.setToolTip(_("main_ui.select_subtitles_first"))
|
||||
else:
|
||||
merge_checkbox.setToolTip("")
|
||||
else:
|
||||
logger.warning("merge_subs_checkbox not found on parent window.")
|
||||
|
||||
# Re-apply stylesheet to update button border if property changed
|
||||
self.subtitle_select_btn.style().unpolish(self.subtitle_select_btn)
|
||||
self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
|
||||
# No else needed for cancel, state remains unchanged
|
||||
|
||||
def open_sponsorblock_dialog(self) -> None:
|
||||
"""Open the SponsorBlock category selection dialog."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Initialize selected categories if not exists or empty (first time opening)
|
||||
if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories:
|
||||
# Use None to let the dialog set its own defaults
|
||||
dialog_categories = None
|
||||
else:
|
||||
dialog_categories = self.selected_sponsorblock_categories
|
||||
|
||||
dialog = SponsorBlockCategoryDialog(dialog_categories, self)
|
||||
|
||||
if dialog.exec():
|
||||
self.selected_sponsorblock_categories = dialog.get_selected_categories()
|
||||
logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}")
|
||||
self._update_sponsorblock_display()
|
||||
|
||||
def _update_sponsorblock_display(self) -> None:
|
||||
"""Update the SponsorBlock button and label to reflect current selection."""
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
if not hasattr(self, "selected_sponsorblock_categories"):
|
||||
self.selected_sponsorblock_categories = []
|
||||
|
||||
count = len(self.selected_sponsorblock_categories)
|
||||
|
||||
# Update label text
|
||||
if count == 0:
|
||||
self.selected_sponsorblock_label.setText(_("selection.none_selected"))
|
||||
elif count == 1:
|
||||
self.selected_sponsorblock_label.setText(_("selection.one_selected"))
|
||||
else:
|
||||
self.selected_sponsorblock_label.setText(_("selection.count_selected", count=count))
|
||||
|
||||
# Update button property for styling
|
||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0)
|
||||
|
||||
# Force style refresh
|
||||
self.sponsorblock_select_btn.style().unpolish(self.sponsorblock_select_btn)
|
||||
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
|
||||
|
||||
def download_thumbnail(self, url) -> None:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
|
||||
# Store both thumbnail URL and video URL
|
||||
self.thumbnail_url = url
|
||||
self.video_url = self.url_input.text() # Get actual video URL
|
||||
|
||||
# Create and start loader thread
|
||||
# Keep reference to avoid garbage collection
|
||||
self.thumbnail_thread = ThumbnailDownloadThread(url)
|
||||
self.thumbnail_thread.finished.connect(self._on_thumbnail_downloaded)
|
||||
self.thumbnail_thread.error.connect(lambda e: logger.error(f"Error loading thumbnail: {e}"))
|
||||
self.thumbnail_thread.start()
|
||||
|
||||
def _on_thumbnail_downloaded(self, content: bytes) -> None:
|
||||
self = cast("YTSageApp", self)
|
||||
try:
|
||||
self.thumbnail_image = Image.open(BytesIO(content))
|
||||
|
||||
# Display thumbnail
|
||||
image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS)
|
||||
img_byte_arr = BytesIO()
|
||||
image.save(img_byte_arr, format="PNG")
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(img_byte_arr.getvalue())
|
||||
self.thumbnail_label.setPixmap(pixmap)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing thumbnail image: {e}")
|
||||
|
||||
def download_thumbnail_file(self, video_url, path) -> bool:
|
||||
self = cast("YTSageApp", self) # for autocompletion and type inference.
|
||||
if not self.save_thumbnail:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Use cached thumbnail image from analysis if available
|
||||
if self.thumbnail_image is None:
|
||||
logger.info("No thumbnail image cached from analysis")
|
||||
self.signals.update_status.emit(_("status.thumbnail_no_image"))
|
||||
return False
|
||||
|
||||
# Get video title from cached video_info
|
||||
video_title = "thumbnail"
|
||||
if self.video_info and "title" in self.video_info:
|
||||
video_title = self.video_info["title"]
|
||||
elif self.playlist_info and "title" in self.playlist_info:
|
||||
video_title = self.playlist_info["title"]
|
||||
|
||||
logger.debug(f"Saving cached thumbnail for: {video_title}")
|
||||
|
||||
# Save the thumbnail
|
||||
thumb_dir = Path(path).joinpath("Thumbnails")
|
||||
thumb_dir.mkdir(exist_ok=True)
|
||||
|
||||
filename = f"{self.sanitize_filename(video_title)}.jpg"
|
||||
thumbnail_path = thumb_dir.joinpath(filename)
|
||||
|
||||
# Save the cached PIL Image directly
|
||||
# Convert to RGB if necessary (in case of RGBA or other modes)
|
||||
if self.thumbnail_image.mode in ("RGBA", "P"):
|
||||
rgb_image = self.thumbnail_image.convert("RGB")
|
||||
rgb_image.save(thumbnail_path, "JPEG", quality=95)
|
||||
else:
|
||||
self.thumbnail_image.save(thumbnail_path, "JPEG", quality=95)
|
||||
|
||||
logger.info(f"Thumbnail saved to: {thumbnail_path}")
|
||||
self.signals.update_status.emit(_("status.thumbnail_saved", filename=filename))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Thumbnail Save Error: {e}")
|
||||
self.signals.update_status.emit(_("status.thumbnail_error", error=str(e)))
|
||||
return False
|
||||
|
||||
def sanitize_filename(self, name) -> str:
|
||||
"""Clean filename for filesystem safety"""
|
||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]
|
||||
@@ -0,0 +1,416 @@
|
||||
|
||||
class StyleSheet:
|
||||
MAIN = """
|
||||
QMainWindow {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QWidget {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit {
|
||||
padding: 5px 15px;
|
||||
border: 2px solid #2a2d2e;
|
||||
border-radius: 6px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
}
|
||||
QTableWidget {
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
gridline-color: #1b2021;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background-color: #15181b;
|
||||
padding: 5px;
|
||||
border: 1px solid #1b2021;
|
||||
color: #ffffff;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #c90000;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
/* Style for filter buttons */
|
||||
QPushButton.filter-btn {
|
||||
background-color: #1b2021;
|
||||
padding: 5px 10px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
QPushButton.filter-btn:checked {
|
||||
background-color: #c90000;
|
||||
}
|
||||
QPushButton.filter-btn:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton.filter-btn:checked:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
/* Modern Scrollbar Styling */
|
||||
QScrollBar:vertical {
|
||||
border: none;
|
||||
background: #15181b;
|
||||
width: 14px;
|
||||
margin: 15px 0 15px 0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #404040;
|
||||
min-height: 30px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #505050;
|
||||
}
|
||||
QScrollBar::sub-line:vertical {
|
||||
border: none;
|
||||
background: #15181b;
|
||||
height: 15px;
|
||||
border-top-left-radius: 7px;
|
||||
border-top-right-radius: 7px;
|
||||
subcontrol-position: top;
|
||||
subcontrol-origin: margin;
|
||||
}
|
||||
QScrollBar::add-line:vertical {
|
||||
border: none;
|
||||
background: #15181b;
|
||||
height: 15px;
|
||||
border-bottom-left-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
subcontrol-position: bottom;
|
||||
subcontrol-origin: margin;
|
||||
}
|
||||
QScrollBar::sub-line:vertical:hover,
|
||||
QScrollBar::add-line:vertical:hover {
|
||||
background: #404040;
|
||||
}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
|
||||
background: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
|
||||
background: none;
|
||||
}
|
||||
"""
|
||||
|
||||
PASTE_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 9px 20px;
|
||||
background-color: #1b2021;
|
||||
border: 2px solid #2a2d2e;
|
||||
border-radius: 5px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #252829;
|
||||
border-color: #3a3d3e;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #1a1d1e;
|
||||
}
|
||||
"""
|
||||
|
||||
ANALYZE_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 9px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #3d3d3d;
|
||||
color: #888888;
|
||||
}
|
||||
"""
|
||||
|
||||
PLAYLIST_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 6px 12px;
|
||||
background-color: #1d1e22;
|
||||
border: 1px solid #c90000;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #2a2d36;
|
||||
border-color: #a50000;
|
||||
}
|
||||
"""
|
||||
|
||||
FORMAT_TOGGLE_BUTTON = """
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #1d1e22;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #c90000;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #2a2d36;
|
||||
}
|
||||
QPushButton:checked:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
|
||||
CHECKBOX = """
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox:disabled { color: #888888; }
|
||||
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
|
||||
"""
|
||||
|
||||
PROGRESS_BAR = """
|
||||
QProgressBar {
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #363636;
|
||||
height: 25px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #ff0000;
|
||||
border-radius: 2px;
|
||||
}
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
"""
|
||||
|
||||
OPEN_FOLDER_BUTTON = """
|
||||
QPushButton {
|
||||
background-color: #2a2d2e;
|
||||
color: #cccccc;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
padding: 2px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3a3d3e;
|
||||
border: 1px solid #505050;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #1a1d1e;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_MESSAGE = """
|
||||
QLabel {
|
||||
background-color: #1d1e22;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_CHANGELOG = """
|
||||
QTextEdit {
|
||||
background-color: #1d1e22;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
padding: 10px;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
border: none;
|
||||
background: #1d1e22;
|
||||
width: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #404040;
|
||||
min-height: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #505050;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_DOWNLOAD_BTN = """
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-width: 140px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_REMIND_BTN = """
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #3d3d3d;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-width: 140px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4d4d4d;
|
||||
border-color: #666666;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_DIALOG_MAIN = """
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
"""
|
||||
|
||||
TIME_RANGE_BTN_ACTIVE = """
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border: 2px solid white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
|
||||
FILE_EXISTS_DIALOG = """
|
||||
QMessageBox {
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #ff0000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
"""
|
||||
|
||||
SETUP_SUCCESS_DIALOG = """
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user