Files
Jaroslav Beneš 4cc48ae98f Fix medium-severity defects across download, formats and tooling
- Format table: a missing acodec was treated as "has audio", skipping
  the +bestaudio merge and producing silent videos for extractors that
  omit the field.
- Progress bar: separate video/audio stream downloads each reported
  0-100%, making the bar jump backwards; per-phase scaling now maps the
  two streams onto 0-50/50-100.
- Custom commands: parse with shlex (quoted arguments with spaces were
  shredded by str.split), keep POSIX mode off on Windows so backslash
  paths survive, hide the console window like every other call site,
  close the stdout pipe, and support cancellation of a running command.
- Settings dialog: _("settings", "error_saving", ...) passed two
  positional args to the i18n helper, raising TypeError inside the
  except handler instead of showing the intended error dialog.
- ffmpeg on Windows: Path(os.getenv("LOCALAPPDATA")) crashed with
  TypeError when the variable is unset; fall back to the standard
  AppData/Local location.
- Version cache: cached path (str) was compared against a Path, so the
  cache never hit and every version query spawned a subprocess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:35:23 +02:00

1178 lines
44 KiB
Python

"""
Custom functionality dialogs for YTSage application.
Contains dialogs for custom commands, cookies, time ranges, and other special features.
"""
import os
import shlex
import subprocess
import threading
from pathlib import Path
from typing import TYPE_CHECKING, cast
from PySide6.QtCore import Q_ARG, QMetaObject, QObject, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton,
QRadioButton,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ..ytsage_smooth_tab_widget import SmoothTabWidget
from ...core.ytsage_yt_dlp import get_yt_dlp_path
from ...core.ytsage_utils import update_auto_update_settings
from ...utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS, YTDLP_DOCS_URL
from ...utils.ytsage_config_manager import ConfigManager
from ...utils.ytsage_localization import LocalizationManager, _
from ...utils.ytsage_logger import logger
from .ytsage_dialogs_updater import UpdaterTabWidget
if TYPE_CHECKING:
from ..ytsage_gui_main import YTSageApp # only for type hints (no runtime import)
class CommandWorker(QObject):
"""Worker class for running yt-dlp commands in a separate thread"""
# Signals for communicating with the main thread
output_received = Signal(str) # For command output lines
command_finished = Signal(bool, int) # For completion (success, exit_code)
error_occurred = Signal(str) # For errors
def __init__(self, command, url, path):
super().__init__()
self.command = command
self.url = url
self.path = path
self._proc = None
self._cancelled = False
def cancel(self):
"""Terminate a running command."""
self._cancelled = True
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
except Exception:
pass
def run_command(self):
"""Run the yt-dlp command and emit signals for output"""
try:
# Split command into arguments; posix=False on Windows so quoted
# backslash paths like "C:\Users\..." survive intact
args = shlex.split(self.command, posix=(os.name != "nt"))
# Build the full command
yt_dlp_path = get_yt_dlp_path()
base_cmd = [str(yt_dlp_path)] + args
# Add download path if specified
if self.path:
base_cmd.extend(["-P", self.path])
# Add URL at the end
base_cmd.append(self.url)
# Emit the full command
self.output_received.emit(_('custom_command.full_command', command=' '.join(str(cmd) for cmd in base_cmd)))
self.output_received.emit("=" * 50)
# Run the command
self._proc = subprocess.Popen(
base_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
creationflags=SUBPROCESS_CREATIONFLAGS,
)
proc = self._proc
# Stream output
with proc.stdout: # type: ignore[union-attr]
for line in proc.stdout: # type: ignore[reportOptionalIterable]
if self._cancelled:
break
if line.strip(): # Only show non-empty lines
self.output_received.emit(line.rstrip())
ret = proc.wait()
self.output_received.emit("=" * 50)
if ret != 0:
self.output_received.emit(_('custom_command.command_failed', code=ret))
self.command_finished.emit(False, ret)
else:
self.output_received.emit(_('custom_command.command_success'))
self.command_finished.emit(True, ret)
except Exception as e:
self.output_received.emit("=" * 50)
self.error_occurred.emit(_('custom_command.command_error', error=str(e)))
class CustomOptionsDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._parent: YTSageApp = cast("YTSageApp", self.parent()) # cast will help with auto complete and type hint checking.
self.setWindowTitle(_("dialogs.custom_options"))
self.setMinimumSize(550, 400) # Made even shorter
layout = QVBoxLayout(self)
# Create tab widget to organize content
self.tab_widget = SmoothTabWidget()
layout.addWidget(self.tab_widget)
# === Cookies Tab ===
cookies_tab = QWidget()
cookies_layout = QVBoxLayout(cookies_tab)
# Help text
help_text = QLabel(_('cookies.help_text'))
help_text.setWordWrap(True)
help_text.setStyleSheet("color: #999999; padding: 10px;")
cookies_layout.addWidget(help_text)
# Cookie source selection
cookie_source_group = QGroupBox(_('cookies.cookie_source'))
cookie_source_layout = QVBoxLayout(cookie_source_group)
# Radio buttons for cookie source
self.cookie_browser_radio = QRadioButton(_('cookies.extract_from_browser') + f" ({_('cookies.recommended')})")
self.cookie_browser_radio.setChecked(True)
self.cookie_browser_radio.toggled.connect(self.on_cookie_source_changed)
cookie_source_layout.addWidget(self.cookie_browser_radio)
self.cookie_file_radio = QRadioButton(_('cookies.use_cookie_file'))
self.cookie_file_radio.toggled.connect(self.on_cookie_source_changed)
cookie_source_layout.addWidget(self.cookie_file_radio)
cookies_layout.addWidget(cookie_source_group)
# Cookie file section
self.cookie_file_group = QGroupBox(_('cookies.cookie_file'))
file_layout = QVBoxLayout(self.cookie_file_group)
# File path input and browse button
path_layout = QHBoxLayout()
self.cookie_path_input = QLineEdit()
self.cookie_path_input.setPlaceholderText(_('cookies.cookie_file_placeholder'))
if hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
# Convert Path to string properly and validate
cookie_path_str = str(self._parent.cookie_file_path)
# Only set if it looks like a valid path (more than just a drive letter)
if len(cookie_path_str) > 3 and not cookie_path_str.endswith(":"):
self.cookie_path_input.setText(cookie_path_str)
path_layout.addWidget(self.cookie_path_input)
self.browse_button = QPushButton(_('buttons.browse'))
self.browse_button.clicked.connect(self.browse_cookie_file)
path_layout.addWidget(self.browse_button)
file_layout.addLayout(path_layout)
cookies_layout.addWidget(self.cookie_file_group)
# Browser selection section
self.cookie_browser_group = QGroupBox(_('cookies.browser_selection'))
browser_layout = QVBoxLayout(self.cookie_browser_group)
browser_help = QLabel(_('cookies.browser_help'))
browser_help.setWordWrap(True)
browser_help.setStyleSheet("color: #999999; font-size: 11px;")
browser_layout.addWidget(browser_help)
browser_select_layout = QHBoxLayout()
browser_select_layout.addWidget(QLabel(_('cookies.browser_label')))
self.browser_combo = QComboBox()
self.browser_combo.addItems(["chrome", "firefox", "safari", "edge", "opera", "brave", "chromium", "vivaldi"])
browser_select_layout.addWidget(self.browser_combo)
browser_layout.addLayout(browser_select_layout)
# Optional profile field
profile_layout = QHBoxLayout()
profile_layout.addWidget(QLabel(_('cookies.profile_label')))
self.profile_input = QLineEdit()
self.profile_input.setPlaceholderText(_('cookies.profile_placeholder'))
profile_layout.addWidget(self.profile_input)
browser_layout.addLayout(profile_layout)
cookies_layout.addWidget(self.cookie_browser_group)
# Initially show browser group (recommended default) and hide file group
self.cookie_file_group.setVisible(False)
# Remember Cookie Settings Checkbox
self.remember_cb = QCheckBox(_("cookies.remember_settings"))
remember_val = ConfigManager.get("cookie_remember")
self.remember_cb.setChecked(True if remember_val is None else remember_val) # Default to True
self.remember_cb.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 10px 5px;
}
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;
}
"""
)
# Connect state change directly to config save to make it work immediately without Apply button dependency if needed
self.remember_cb.toggled.connect(lambda checked: ConfigManager.set("cookie_remember", checked))
cookies_layout.addWidget(self.remember_cb)
self.cookie_browser_group.setVisible(True)
# Apply button and status indicator
apply_layout = QHBoxLayout()
# Status indicator for active cookies
self.cookies_active_status = QLabel()
self._update_cookies_active_status()
apply_layout.addWidget(self.cookies_active_status)
apply_layout.addStretch()
# Apply button
self.apply_cookies_btn = QPushButton(_('buttons.apply'))
self.apply_cookies_btn.clicked.connect(self.apply_cookies)
self.apply_cookies_btn.setStyleSheet(
"""
QPushButton {
padding: 8px 20px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
min-width: 100px;
}
QPushButton:hover {
background-color: #a50000;
}
"""
)
apply_layout.addWidget(self.apply_cookies_btn)
cookies_layout.addLayout(apply_layout)
cookies_layout.addStretch()
# === Custom Command Tab ===
command_tab = QWidget()
command_layout = QVBoxLayout(command_tab)
# Improved help text
cmd_help_text = QLabel(_('custom_command.help_text', docs_url=YTDLP_DOCS_URL))
cmd_help_text.setWordWrap(True)
cmd_help_text.setOpenExternalLinks(True) # Enable clicking links
cmd_help_text.setTextFormat(Qt.TextFormat.RichText) # Enable HTML rendering
cmd_help_text.setStyleSheet(
"""
QLabel {
color: #cccccc;
font-size: 12px;
padding: 10px;
background-color: #1a1d20;
border-radius: 6px;
line-height: 1.4;
}
QLabel a {
color: #4da6ff;
text-decoration: underline;
}
QLabel a:hover {
color: #66b3ff;
}
"""
)
command_layout.addWidget(cmd_help_text)
# Command input label
input_label = QLabel(_('custom_command.input_label'))
input_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #ffffff; margin-top: 10px;")
command_layout.addWidget(input_label)
# Command input
self.command_input = QPlainTextEdit()
self.command_input.setPlaceholderText(_('custom_command.input_placeholder'))
self.command_input.setMinimumHeight(80) # Reduced further from 100
self.command_input.setStyleSheet(
"""
QPlainTextEdit {
background-color: #1d1e22;
color: #ffffff;
border: 2px solid #2a2d36;
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
}
QPlainTextEdit:focus {
border-color: #c90000;
}
"""
)
command_layout.addWidget(self.command_input)
# Button layout
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
clear_btn = QPushButton(_('buttons.clear'))
clear_btn.clicked.connect(lambda: self.command_input.clear())
clear_btn.setStyleSheet(
"""
QPushButton {
padding: 8px 15px;
background-color: #444444;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
min-width: 80px;
}
QPushButton:hover {
background-color: #555555;
}
"""
)
button_layout.addWidget(clear_btn)
button_layout.addStretch() # Push run button to the right
# Run command button
self.run_btn = QPushButton(_('buttons.run_command'))
self.run_btn.clicked.connect(self.run_custom_command)
self.run_btn.setDefault(True)
button_layout.addWidget(self.run_btn)
command_layout.addLayout(button_layout)
# Output label
output_label = QLabel(_('custom_command.output_label'))
output_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #ffffff; margin-top: 15px;")
command_layout.addWidget(output_label)
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setPlaceholderText(_('custom_command.output_placeholder'))
self.log_output.setMinimumHeight(100) # Reduced further from 120
self.log_output.setStyleSheet(
"""
QTextEdit {
background-color: #1d1e22;
color: #ffffff;
border: 2px solid #2a2d36;
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
}
QTextEdit:focus {
border-color: #c90000;
}
"""
)
command_layout.addWidget(self.log_output)
# === Proxy Tab ===
proxy_tab = QWidget()
proxy_layout = QVBoxLayout(proxy_tab)
# Help text
proxy_help_text = QLabel(_('proxy.help_text'))
proxy_help_text.setWordWrap(True)
proxy_help_text.setStyleSheet("color: #999999; padding: 10px;")
proxy_layout.addWidget(proxy_help_text)
# Main Proxy section
main_proxy_group = QGroupBox(_('proxy.main_proxy'))
main_proxy_layout = QVBoxLayout(main_proxy_group)
main_proxy_help = QLabel(_('proxy.main_proxy_help'))
main_proxy_help.setWordWrap(True)
main_proxy_help.setStyleSheet("color: #999999; font-size: 11px;")
main_proxy_layout.addWidget(main_proxy_help)
# Main proxy input
main_proxy_input_layout = QHBoxLayout()
main_proxy_input_layout.addWidget(QLabel(_('proxy.proxy_url_label')))
self.proxy_url_input = QLineEdit()
self.proxy_url_input.setPlaceholderText(_('proxy.proxy_url_placeholder'))
self.proxy_url_input.textChanged.connect(self.validate_proxy_inputs)
main_proxy_input_layout.addWidget(self.proxy_url_input)
main_proxy_layout.addLayout(main_proxy_input_layout)
# Example text
example_label = QLabel(_('proxy.proxy_examples'))
example_label.setStyleSheet("color: #888888; font-size: 10px; font-style: italic;")
main_proxy_layout.addWidget(example_label)
proxy_layout.addWidget(main_proxy_group)
# Geo-verification Proxy section
geo_proxy_group = QGroupBox(_('proxy.geo_proxy'))
geo_proxy_layout = QVBoxLayout(geo_proxy_group)
geo_proxy_help = QLabel(_('proxy.geo_proxy_help'))
geo_proxy_help.setWordWrap(True)
geo_proxy_help.setStyleSheet("color: #999999; font-size: 11px;")
geo_proxy_layout.addWidget(geo_proxy_help)
# Geo proxy input
geo_proxy_input_layout = QHBoxLayout()
geo_proxy_input_layout.addWidget(QLabel(_('proxy.geo_proxy_url_label')))
self.geo_proxy_url_input = QLineEdit()
self.geo_proxy_url_input.setPlaceholderText(_('proxy.geo_proxy_url_placeholder'))
self.geo_proxy_url_input.textChanged.connect(self.validate_proxy_inputs)
geo_proxy_input_layout.addWidget(self.geo_proxy_url_input)
geo_proxy_layout.addLayout(geo_proxy_input_layout)
proxy_layout.addWidget(geo_proxy_group)
# Proxy status indicator
self.proxy_status = QLabel("")
self.proxy_status.setStyleSheet("color: #999999; font-style: italic;")
proxy_layout.addWidget(self.proxy_status)
# Clear buttons
clear_layout = QHBoxLayout()
clear_main_proxy_btn = QPushButton(_('proxy.clear_main_proxy'))
clear_main_proxy_btn.clicked.connect(lambda: self.proxy_url_input.clear())
clear_main_proxy_btn.setStyleSheet(
"""
QPushButton {
padding: 6px 12px;
background-color: #444444;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #555555;
}
"""
)
clear_layout.addWidget(clear_main_proxy_btn)
clear_geo_proxy_btn = QPushButton(_('proxy.clear_geo_proxy'))
clear_geo_proxy_btn.clicked.connect(lambda: self.geo_proxy_url_input.clear())
clear_geo_proxy_btn.setStyleSheet(
"""
QPushButton {
padding: 6px 12px;
background-color: #444444;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #555555;
}
"""
)
clear_layout.addWidget(clear_geo_proxy_btn)
clear_layout.addStretch()
proxy_layout.addLayout(clear_layout)
proxy_layout.addStretch()
# === Language Tab ===
language_tab = QWidget()
language_layout = QVBoxLayout(language_tab)
# Help text
language_help_text = QLabel(_("language.select_language"))
language_help_text.setWordWrap(True)
language_help_text.setStyleSheet("color: #ffffff; font-size: 14px; font-weight: bold; padding: 10px;")
language_layout.addWidget(language_help_text)
# Current language info
current_lang = ConfigManager.get("language") or "en"
available_languages = LocalizationManager.get_available_languages()
current_lang_display = available_languages.get(current_lang, current_lang.upper())
current_lang_label = QLabel(_("language.current_language", language=current_lang_display))
current_lang_label.setWordWrap(True)
current_lang_label.setStyleSheet("color: #999999; padding: 10px;")
language_layout.addWidget(current_lang_label)
# Language selection group
language_group = QGroupBox(_("language.select_language"))
language_group_layout = QVBoxLayout(language_group)
# Language selection combo box
language_select_layout = QHBoxLayout()
language_select_layout.addWidget(QLabel(_("language.select_language") + ":"))
self.language_combo = QComboBox()
# Populate language combo with available languages
for lang_code, display_name in available_languages.items():
self.language_combo.addItem(display_name, lang_code)
# Set current selection
current_index = self.language_combo.findData(current_lang)
if current_index >= 0:
self.language_combo.setCurrentIndex(current_index)
# Connect language change event
self.language_combo.currentIndexChanged.connect(self.on_language_changed)
language_select_layout.addWidget(self.language_combo)
language_group_layout.addLayout(language_select_layout)
language_layout.addWidget(language_group)
# Restart notice
self.restart_notice = QLabel(_("language.restart_required"))
self.restart_notice.setWordWrap(True)
self.restart_notice.setStyleSheet(
"color: #ffaa00; font-style: italic; padding: 10px; "
"background-color: #2a2d36; border-radius: 6px; margin: 10px;"
)
self.restart_notice.setVisible(False) # Initially hidden
language_layout.addWidget(self.restart_notice)
language_layout.addStretch()
# === Updater Tab ===
self.updater_tab = UpdaterTabWidget(self)
# Add tabs to the tab widget
self.tab_widget.addTab(cookies_tab, _("tabs.cookies"))
self.tab_widget.addTab(command_tab, _("tabs.custom_command"))
self.tab_widget.addTab(proxy_tab, _("tabs.proxy"))
self.tab_widget.addTab(self.updater_tab, _("tabs.updater"))
self.tab_widget.addTab(language_tab, _("tabs.language"))
# 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)
layout.addWidget(button_box)
# Apply global styles
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
QFrame#tabContent {
border: 1px solid #3d3d3d;
background-color: #15181b;
}
QTabBar::tab {
background-color: #1d1e22;
color: #ffffff;
padding: 8px 12px;
border: 1px solid #3d3d3d;
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
QTabBar::tab:selected {
background-color: #c90000;
}
QTabBar::tab:hover:!selected {
background-color: #2a2d36;
}
QLabel {
color: #ffffff;
}
QGroupBox {
border: 1px solid #3d3d3d;
border-radius: 4px;
margin-top: 1.5ex;
color: #ffffff;
padding: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 5px;
}
QRadioButton {
color: #ffffff;
padding: 5px;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QRadioButton::indicator:unchecked {
border: 2px solid #666666;
background: #1d1e22;
border-radius: 9px;
}
QRadioButton::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
border-radius: 9px;
}
QComboBox {
padding: 8px;
border: 2px solid #1b2021;
border-radius: 4px;
background-color: #1b2021;
color: #ffffff;
min-width: 150px;
}
QComboBox::drop-down {
border: none;
width: 20px;
}
QComboBox::down-arrow {
border: none;
width: 12px;
height: 12px;
}
QComboBox QAbstractItemView {
background-color: #1d1e22;
color: #ffffff;
border: 1px solid #3d3d3d;
selection-background-color: #c90000;
}
QLineEdit {
padding: 8px;
border: 2px solid #1b2021;
border-radius: 4px;
background-color: #1b2021;
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #a50000;
}
"""
)
# Initialize dialog with current settings (after all widgets and styles are set)
self._initialize_cookie_settings()
self._initialize_proxy_settings()
def _initialize_cookie_settings(self) -> None:
"""Initialize the dialog with current cookie settings from config"""
# Load saved cookie settings from ConfigManager
saved_source = ConfigManager.get("cookie_source") or "browser"
saved_browser = ConfigManager.get("cookie_browser") or "chrome"
saved_profile = ConfigManager.get("cookie_browser_profile") or ""
saved_file_path = ConfigManager.get("cookie_file_path")
# Set the cookie source radio button
if saved_source == "file":
self.cookie_file_radio.setChecked(True)
else:
self.cookie_browser_radio.setChecked(True)
# Set browser selection
index = self.browser_combo.findText(saved_browser)
if index >= 0:
self.browser_combo.setCurrentIndex(index)
# Set profile
self.profile_input.setText(saved_profile)
# Set file path if saved
if saved_file_path:
self.cookie_path_input.setText(str(saved_file_path))
# Update visibility based on selection
self.on_cookie_source_changed()
def _update_cookies_active_status(self) -> None:
"""Update the status indicator showing if cookies are currently active"""
if hasattr(self._parent, "browser_cookies_option") and self._parent.browser_cookies_option:
self.cookies_active_status.setText(
_("cookies.active_browser", browser=self._parent.browser_cookies_option)
)
self.cookies_active_status.setStyleSheet("color: #00cc00; font-weight: bold;")
elif hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
self.cookies_active_status.setText(
_("cookies.active_file", file=self._parent.cookie_file_path.name)
)
self.cookies_active_status.setStyleSheet("color: #00cc00; font-weight: bold;")
else:
self.cookies_active_status.setText(_("cookies.none_active"))
self.cookies_active_status.setStyleSheet("color: #888888; font-style: italic;")
def _initialize_proxy_settings(self) -> None:
"""Initialize the dialog with current proxy settings from config"""
# Load proxy settings from config
proxy_url = ConfigManager.get("proxy_url")
geo_proxy_url = ConfigManager.get("geo_proxy_url")
# Set proxy field values if they exist
if proxy_url:
self.proxy_url_input.setText(proxy_url)
if geo_proxy_url:
self.geo_proxy_url_input.setText(geo_proxy_url)
# Update validation status
self.validate_proxy_inputs()
def on_cookie_source_changed(self) -> None:
"""Handle cookie source radio button changes"""
if self.cookie_file_radio.isChecked():
self.cookie_file_group.setVisible(True)
self.cookie_browser_group.setVisible(False)
else:
self.cookie_file_group.setVisible(False)
self.cookie_browser_group.setVisible(True)
def apply_cookies(self) -> None:
"""Apply cookie settings when user clicks Apply button"""
# Handle cookies
cookie_path = self.get_cookie_file_path()
browser_cookies = self.get_browser_cookies_option()
# Clear both first to avoid conflicts
self._parent.cookie_file_path = None
self._parent.browser_cookies_option = None
# Save settings to ConfigManager for persistence
ConfigManager.set("cookie_remember", self.remember_cb.isChecked())
if self.cookie_file_radio.isChecked():
ConfigManager.set("cookie_source", "file")
ConfigManager.set("cookie_file_path", str(cookie_path) if cookie_path else None)
else:
ConfigManager.set("cookie_source", "browser")
ConfigManager.set("cookie_browser", self.browser_combo.currentText())
ConfigManager.set("cookie_browser_profile", self.profile_input.text().strip())
if cookie_path:
self._parent.cookie_file_path = cookie_path
ConfigManager.set("cookie_active", True)
logger.info(f"Applied cookie file: {self._parent.cookie_file_path}")
QMessageBox.information(
self,
_("cookies.file_selected_title"),
_("cookies.file_applied_message", path=str(cookie_path)),
)
elif browser_cookies:
self._parent.browser_cookies_option = browser_cookies
ConfigManager.set("cookie_active", True)
logger.info(f"Applied browser cookies: {self._parent.browser_cookies_option}")
QMessageBox.information(
self,
_("cookies.browser_selected_title"),
_("cookies.browser_applied_message", browser=browser_cookies),
)
else:
# Clear cookies
ConfigManager.set("cookie_active", False)
ConfigManager.set("cookie_source", "browser") # Reset to default
ConfigManager.set("cookie_file_path", None)
logger.info("Cookies cleared")
QMessageBox.information(
self,
_("cookies.cleared_title"),
_("cookies.cleared_message"),
)
# Update the status indicator
self._update_cookies_active_status()
def browse_cookie_file(self) -> None:
# Open file dialog to select cookie file
selected_files, _filter = QFileDialog.getOpenFileName(self, _("cookies.select_file_title"), "", _("cookies.file_filter"))
if selected_files:
# Ensure we have a valid full path
cookie_path = Path(selected_files).resolve()
self.cookie_path_input.setText(str(cookie_path))
def get_cookie_file_path(self) -> Path | None:
# Return the selected cookie file path if it's not empty and using file mode
if self.cookie_file_radio.isChecked():
path_text = self.cookie_path_input.text().strip()
if path_text:
path = Path(path_text)
if path.exists() and path.is_file():
return path
else:
# File doesn't exist or is not a file - still return path for user feedback
return path if len(path_text) > 3 else None # Avoid single letters like 'C'
return None
def get_browser_cookies_option(self) -> str | None:
"""Returns the --cookies-from-browser option string if browser mode is selected"""
if self.cookie_browser_radio.isChecked():
browser = self.browser_combo.currentText()
profile = self.profile_input.text().strip()
if profile:
return f"{browser}:{profile}"
else:
return browser
return None
def is_using_browser_cookies(self) -> bool:
"""Returns True if browser cookies mode is selected"""
return self.cookie_browser_radio.isChecked()
def get_proxy_url(self) -> str | None:
"""Returns the main proxy URL if specified"""
proxy_url = self.proxy_url_input.text().strip()
return proxy_url if proxy_url else None
def get_geo_proxy_url(self) -> str | None:
"""Returns the geo-verification proxy URL if specified"""
geo_proxy_url = self.geo_proxy_url_input.text().strip()
return geo_proxy_url if geo_proxy_url else None
def validate_proxy_url(self, url: str) -> bool:
"""Basic validation for proxy URL format"""
if not url:
return True # Empty is OK
# Check if it starts with a valid scheme
valid_schemes = ['http://', 'https://', 'socks5://', 'socks4://']
if not any(url.lower().startswith(scheme) for scheme in valid_schemes):
return False
# Basic URL format check (contains at least host:port)
try:
# Remove the scheme to check host:port part
for scheme in valid_schemes:
if url.lower().startswith(scheme):
host_port = url[len(scheme):]
break
# Skip user:pass@ part if present
if '@' in host_port:
host_port = host_port.split('@')[1]
# Should have at least host:port
if ':' in host_port:
host, port = host_port.split(':', 1)
if host and port.isdigit():
return True
return False
except:
return False
def validate_proxy_inputs(self) -> None:
"""Validate proxy inputs and update status"""
main_proxy = self.proxy_url_input.text().strip()
geo_proxy = self.geo_proxy_url_input.text().strip()
if not main_proxy and not geo_proxy:
# Check if there are saved settings
saved_main = ConfigManager.get("proxy_url")
saved_geo = ConfigManager.get("geo_proxy_url")
if saved_main or saved_geo:
status_parts = []
if saved_main:
status_parts.append(_("proxy.saved_main", proxy=saved_main))
if saved_geo:
status_parts.append(_("proxy.saved_geo", proxy=saved_geo))
self.proxy_status.setText(" | ".join(status_parts))
self.proxy_status.setStyleSheet("color: #888888; font-style: italic;")
else:
self.proxy_status.setText("")
return
issues = []
if main_proxy and not self.validate_proxy_url(main_proxy):
issues.append(_("proxy.invalid_main_url"))
if geo_proxy and not self.validate_proxy_url(geo_proxy):
issues.append(_("proxy.invalid_geo_url"))
if issues:
self.proxy_status.setText(" | ".join(issues))
self.proxy_status.setStyleSheet("color: #ff6666; font-style: italic;")
else:
status_parts = []
if main_proxy:
status_parts.append(_("proxy.main_configured"))
if geo_proxy:
status_parts.append(_("proxy.geo_configured"))
self.proxy_status.setText(" | ".join(status_parts))
self.proxy_status.setStyleSheet("color: #00cc00; font-style: italic;")
def run_custom_command(self) -> None:
url = self._parent.url_input.text().strip()
if not url:
self.log_output.append(_("custom_command.error_no_url"))
return
command = self.command_input.toPlainText().strip()
if not command:
self.log_output.append(_("custom_command.error_no_command"))
return
# Get download path from parent
path = self._parent.last_path
self.log_output.clear()
self.log_output.append(_("custom_command.executing"))
self.log_output.append(_("custom_command.url_label", url=url))
self.log_output.append(_("custom_command.args_label", command=command))
if path:
self.log_output.append(_("custom_command.download_path_label", path=path))
self.log_output.append(_("custom_command.separator"))
self.run_btn.setEnabled(False)
self.run_btn.setText(_("command.running"))
# Create worker and thread
self.worker = CommandWorker(command, url, path)
self.worker_thread = threading.Thread(target=self.worker.run_command, daemon=True)
# Connect worker signals to our slots
self.worker.output_received.connect(self.on_output_received)
self.worker.command_finished.connect(self.on_command_finished)
self.worker.error_occurred.connect(self.on_error_occurred)
# Start the thread
self.worker_thread.start()
def on_output_received(self, text: str):
"""Slot for receiving output from the worker"""
self.log_output.append(text)
def on_command_finished(self, success: bool, exit_code: int):
"""Slot for when command finishes"""
self.run_btn.setEnabled(True)
self.run_btn.setText(_("command.run_command"))
def on_error_occurred(self, error_msg: str):
"""Slot for handling errors"""
self.log_output.append(error_msg)
self.run_btn.setEnabled(True)
self.run_btn.setText(_("buttons.run_command"))
def on_language_changed(self) -> None:
"""Handle language selection change"""
selected_lang_code = self.language_combo.currentData()
if selected_lang_code:
current_lang = ConfigManager.get("language") or "en"
if selected_lang_code != current_lang:
# Save the new language preference
ConfigManager.set("language", selected_lang_code)
# Update LocalizationManager
LocalizationManager.set_language(selected_lang_code)
# Show restart notice
self.restart_notice.setVisible(True)
logger.info(f"Language changed to: {selected_lang_code}")
def accept(self) -> None:
"""Override accept to save auto-update settings from the updater tab."""
logger.info("CustomOptionsDialog.accept() called")
try:
# Save auto-update settings from the updater tab
enabled, frequency = self.updater_tab.get_auto_update_settings()
logger.info(f"Saving auto-update settings: enabled={enabled}, frequency={frequency}")
result = update_auto_update_settings(enabled, frequency)
logger.info(f"Auto-update settings save result: {result}")
# Save beta update setting
beta_enabled = self.updater_tab.get_beta_update_setting()
ConfigManager.set("check_beta_updates", beta_enabled)
logger.info(f"Saved beta updates setting: {beta_enabled}")
# Save app update checker setting
app_updates_enabled = self.updater_tab.get_app_update_checker_setting()
ConfigManager.set("check_app_updates", app_updates_enabled)
logger.info(f"Saved app updates checker setting: {app_updates_enabled}")
except Exception as e:
logger.exception(f"Error saving auto-update settings: {e}")
# Call the parent accept method to close the dialog
super().accept()
class TimeRangeDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(_('time_range.title'))
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
# Help text explaining the feature
help_text = QLabel(_('time_range.help_text'))
help_text.setWordWrap(True)
help_text.setStyleSheet("color: #999999; padding: 10px;")
layout.addWidget(help_text)
# Time range section
time_group = QGroupBox(_('time_range.time_range_group'))
time_layout = QVBoxLayout()
# Start time row
start_layout = QHBoxLayout()
start_layout.addWidget(QLabel(_('time_range.start_time')))
self.start_time_input = QLineEdit()
self.start_time_input.setPlaceholderText(_('time_range.start_time_placeholder'))
start_layout.addWidget(self.start_time_input)
time_layout.addLayout(start_layout)
# End time row
end_layout = QHBoxLayout()
end_layout.addWidget(QLabel(_('time_range.end_time')))
self.end_time_input = QLineEdit()
self.end_time_input.setPlaceholderText(_('time_range.end_time_placeholder'))
end_layout.addWidget(self.end_time_input)
time_layout.addLayout(end_layout)
time_group.setLayout(time_layout)
layout.addWidget(time_group)
# Force keyframes option
self.force_keyframes = QCheckBox(_('time_range.force_keyframes'))
self.force_keyframes.setChecked(True)
self.force_keyframes.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 4px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #1d1e22;
border-radius: 4px;
}
QCheckBox::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
border-radius: 4px;
}
"""
)
layout.addWidget(self.force_keyframes)
# 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)
layout.addWidget(button_box)
# Apply styling
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
QGroupBox {
border: 1px solid #3d3d3d;
border-radius: 4px;
margin-top: 1.5ex;
color: #ffffff;
padding: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 5px;
}
QLabel {
color: #ffffff;
}
QLineEdit {
padding: 8px;
border: 2px solid #1b2021;
border-radius: 4px;
background-color: #1b2021;
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #a50000;
}
"""
)
# Initialize preview
# self.update_preview() # Removed preview functionality
def get_download_sections(self) -> str | None:
"""Returns the download sections command arguments or None if no selection made"""
start = self.start_time_input.text().strip()
end = self.end_time_input.text().strip()
if not start and not end:
return None # No selection made
if start and end:
time_range = f"*{start}-{end}"
elif start:
time_range = f"*{start}-"
elif end:
time_range = f"*-{end}"
else:
return None # Shouldn't happen but just in case
return time_range
def get_force_keyframes(self) -> bool:
"""Returns whether to force keyframes at cuts"""
return self.force_keyframes.isChecked()