Files
SageTube/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py
T
Viren Hirpara e9de913b47 v4.9.0 - Refactor (#41)
* fix imports
remove unused imports
use full import path
sort import (1. Standard Library, 2. Third-Party, 3. Local) in alphabetic.

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

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

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

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

- imporve: return type hint for function.

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

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

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

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

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

* refactor: remove unused imports and streamline code formatting across multiple files

* - **refactor: drop `pygame` in favor of built-in `PySide6` sound**

- Removed `pygame` dependency (too heavy just for notifications).
- Replaced with `QSoundEffect`, which is lightweight and built into `PySide6`.
- Dropped `pygame.mixer` + threading → Qt handles async playback.
- Implemented sound playback with `QUrl.fromLocalFile()` and `.play()`.
- Added `setVolume(0.9)` as a configurable example.
- Converted notification sound from `.mp3` to `.wav` (only format supported).

* **refactor(utils): simplify logger module**
  - Moved `logger` to `src.utils`
  - Removed unnecessary import checks (logger is always available)
  - Replaced `raise` statements with error logging to prevent crashes
  - Use `logger.exception()` in `except` blocks to capture traceback (logged as error)

**style: remove redundant str() in f-strings**
  - Dropped explicit `str()` calls inside f-strings
  - f-strings already call `str()` under the hood

**chore: add type hints for GUI mixins**
  - Added type hints for `FormatTableMixin` (`src.gui.ytsage_gui_format_table`)
  - Added type hints for `VideoInfoMixin` (`src.gui.ytsage_gui_video_info`)
  - Improves autocomplete and type safety in IDEs

* - **introduce the `ytsage_config_manager.py` module to manage app setting.**
  - Loads settings from a JSON config file (`APP_CONFIG_FILE`).
  - Creates the config file with default values if missing or corrupt.
  - Retrieves, sets, and deletes settings using simple dot-separated keys.
  - Provides safe error handling with logging instead of raising exceptions.
  - Persists updates back to disk automatically.

- **Usage**
```python
from src.utils.ytsage_config_manager import ConfigManager

download_path = ConfigManager.get("download_path")

ConfigManager.set("download_path", "D:/Downloads")

last_check = ConfigManager.get("cached_versions.ytdlp.last_check")

ConfigManager.delete("cached_versions.ffmpeg.path")
```

* **Refactore: pkg_resources with importlib.metadata.version**
  - UserWarning: pkg_resources is deprecated as an API.
  - See https://setuptools.pypa.io/en/latest/pkg_resources.html.
  - The pkg_resources package is slated for removal as early as 2025-11-30.

* **refactore: notification sound**
  - `QSoundEffect` is chnaged back to `pyglet` as per mainter `@oop7` choise.
  - simplify the logic.

* remove: import check, it should always work.

* **fix: runtime error**
  - yt_dlp moved from `--excludes` to `--packages` in `build-windows.yml`
  - In frozen build, logger will not log to consol. insted will log to file.
  - In frozen build, `app_dir` is next to `.exe` file.

* chang back to checking import for ytdlp

* Bump version to 4.8.1

Update version references from 4.8.0b to 4.8.1 in __init__.py, main app, and About dialog to reflect the new release.

* Update asset paths in build workflows

Changed asset inclusion and screenshot removal paths from 'assets' to 'lib/assets' in Linux, macOS, and Windows build workflows to reflect new directory structure and ensure screenshots are excluded from packaged builds.

* Bump version to 4.8.2

Update version references from 4.8.1 to 4.8.2 in source files and documentation to prepare for a new patch release.

* Fix asset include path in Windows build workflow

Corrects the syntax for including asset files in the build-windows.yml workflow by changing 'assets,lib/assets' to 'assets=lib/assets'. This ensures assets are properly mapped during the build process.

* Update release tag examples in CI/CD README

Changed the example git tag commands from v4.8.0 and v4.8.2 to v4.8.1 for consistency in the CI/CD documentation.

* Fix include-files mapping in Windows build workflow

Changed the cx_Freeze --include-files argument from '=' to ':' for source:destination mapping in build-windows.yml. This resolves an issue where '=' was treated as a literal path, ensuring assets are correctly copied to the destination directory.

* Remove redundant comments in build-windows workflow

Deleted comments explaining the colon usage for source:destination mapping in the cx_Freeze CLI, as the mapping is already clear from the context.

* Refactor Windows build to use cx_Freeze setup script

Replaces direct cx_Freeze CLI calls with dynamically generated setup scripts for both standard and FFmpeg builds. This improves maintainability and flexibility of build configuration in the GitHub Actions workflow.

* Bump version to 4.8.3

Updated version references from 4.8.2 to 4.8.3 in __init__.py, main app, and About dialog to reflect the new release.

* revert(build): move yt_dlp from --packages to --excludes in build-windows.yml

---------

Co-authored-by: Your Name <mohamed.mohamed112@ai.mnu.edu.eg>
2025-09-09 23:01:19 +03:00

719 lines
26 KiB
Python

"""
Custom functionality dialogs for YTSage application.
Contains dialogs for custom commands, cookies, time ranges, and other special features.
"""
import subprocess
import threading
from pathlib import Path
from typing import TYPE_CHECKING, cast
from PySide6.QtCore import Q_ARG, QMetaObject, QObject, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPlainTextEdit,
QPushButton,
QRadioButton,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import YTDLP_DOCS_URL
if TYPE_CHECKING:
from src.gui.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
def run_command(self):
"""Run the yt-dlp command and emit signals for output"""
try:
# Split command into arguments
args = self.command.split()
# Build the full command
yt_dlp_path = get_yt_dlp_path()
base_cmd = [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(f"🔧 Full command: {' '.join(str(cmd) for cmd in base_cmd)}")
self.output_received.emit("=" * 50)
# Run the command
proc = subprocess.Popen(
base_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
# Stream output
for line in proc.stdout: # type: ignore[reportOptionalIterable]
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(f"❌ Command failed with exit code {ret}")
self.command_finished.emit(False, ret)
else:
self.output_received.emit("✅ Command completed successfully!")
self.command_finished.emit(True, ret)
except Exception as e:
self.output_received.emit("=" * 50)
self.error_occurred.emit(f"❌ Error executing command: {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("Custom Options")
self.setMinimumSize(550, 400) # Made even shorter
layout = QVBoxLayout(self)
# Create tab widget to organize content
self.tab_widget = QTabWidget()
layout.addWidget(self.tab_widget)
# === Cookies Tab ===
cookies_tab = QWidget()
cookies_layout = QVBoxLayout(cookies_tab)
# Help text
help_text = QLabel(
"Choose how to provide cookies for logging in.\n"
"This allows downloading of private videos and premium quality audio."
)
help_text.setWordWrap(True)
help_text.setStyleSheet("color: #999999; padding: 10px;")
cookies_layout.addWidget(help_text)
# Cookie source selection
cookie_source_group = QGroupBox("Cookie Source")
cookie_source_layout = QVBoxLayout(cookie_source_group)
# Radio buttons for cookie source
self.cookie_file_radio = QRadioButton("Use cookie file (Netscape format)")
self.cookie_file_radio.setChecked(True)
self.cookie_file_radio.toggled.connect(self.on_cookie_source_changed)
cookie_source_layout.addWidget(self.cookie_file_radio)
self.cookie_browser_radio = QRadioButton("Extract cookies from browser")
self.cookie_browser_radio.toggled.connect(self.on_cookie_source_changed)
cookie_source_layout.addWidget(self.cookie_browser_radio)
cookies_layout.addWidget(cookie_source_group)
# Cookie file section
self.cookie_file_group = QGroupBox("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("Path to cookies file (Netscape format)")
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("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("Browser Selection")
browser_layout = QVBoxLayout(self.cookie_browser_group)
browser_help = QLabel("Select the browser to extract cookies from. Make sure the browser is closed before extraction.")
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("Browser:"))
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("Profile (optional):"))
self.profile_input = QLineEdit()
self.profile_input.setPlaceholderText("Profile name or path (leave empty for default)")
profile_layout.addWidget(self.profile_input)
browser_layout.addLayout(profile_layout)
cookies_layout.addWidget(self.cookie_browser_group)
# Initially hide browser group
self.cookie_browser_group.setVisible(False)
# Status indicator for cookies
self.cookie_status = QLabel("")
self.cookie_status.setStyleSheet("color: #999999; font-style: italic;")
cookies_layout.addWidget(self.cookie_status)
cookies_layout.addStretch()
# === Custom Command Tab ===
command_tab = QWidget()
command_layout = QVBoxLayout(command_tab)
# Improved help text
cmd_help_text = QLabel(
"Enter your custom yt-dlp command below. The current URL will be automatically appended.<br><br>"
"For complete list of options and usage examples, "
f'<a href="{YTDLP_DOCS_URL}">click here to view the official yt-dlp documentation</a>.<br><br>'
"Note: Download path and output filename template will be automatically handled."
)
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("yt-dlp Arguments:")
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("Enter yt-dlp arguments here...\n\n" "e.g. --extract-audio --audio-format mp3")
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("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("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("Command Output:")
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("Command output will appear here...")
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)
# Add tabs to the tab widget
self.tab_widget.addTab(cookies_tab, "Login with Cookies")
self.tab_widget.addTab(command_tab, "Custom Command")
# Dialog buttons
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
# Apply global styles
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
QTabWidget::pane {
border: 1px solid #3d3d3d;
background-color: #15181b;
}
QTabBar::tab {
background-color: #1d1e22;
color: #ffffff;
padding: 8px 12px;
border: 1px solid #3d3d3d;
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
QTabBar::tab:selected {
background-color: #c90000;
}
QTabBar::tab:hover:!selected {
background-color: #2a2d36;
}
QLabel {
color: #ffffff;
}
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;
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIHZpZXdCb3g9IjAgMCAxMiAxMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgNEw2IDdMOSA0IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=);
}
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()
def _initialize_cookie_settings(self) -> None:
"""Initialize the dialog with current cookie settings from parent"""
if hasattr(self._parent, "browser_cookies_option") and self._parent.browser_cookies_option:
# Browser cookies are active
self.cookie_browser_radio.setChecked(True)
browser_parts = self._parent.browser_cookies_option.split(":")
browser = browser_parts[0]
profile = browser_parts[1] if len(browser_parts) > 1 else ""
# Set browser selection
index = self.browser_combo.findText(browser)
if index >= 0:
self.browser_combo.setCurrentIndex(index)
# Set profile if any
self.profile_input.setText(profile)
self.cookie_status.setText(f"Browser cookies active: {self._parent.browser_cookies_option}")
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
elif hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
# File cookies are active - ensure file radio is selected and update status
self.cookie_file_radio.setChecked(True)
self.cookie_status.setText(f"Cookie file active: {self._parent.cookie_file_path.name}")
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
else:
# No cookies configured - ensure file radio is selected by default
self.cookie_file_radio.setChecked(True)
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)
self.cookie_status.setText("")
else:
self.cookie_file_group.setVisible(False)
self.cookie_browser_group.setVisible(True)
self.cookie_status.setText("Browser cookies will be extracted when applied")
self.cookie_status.setStyleSheet("color: #ffaa00; font-style: italic;")
def browse_cookie_file(self) -> None:
# Open file dialog to select cookie file
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
if selected_files:
# Ensure we have a valid full path
cookie_path = Path(selected_files).resolve()
self.cookie_path_input.setText(str(cookie_path))
self.cookie_status.setText("Cookie file selected - Click OK to apply")
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
def get_cookie_file_path(self) -> Path | None:
# Return the selected cookie file path if it's not empty 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 run_custom_command(self) -> None:
url = self._parent.url_input.text().strip()
if not url:
self.log_output.append("❌ Error: No URL provided. Please enter a URL in the main window.")
return
command = self.command_input.toPlainText().strip()
if not command:
self.log_output.append("❌ Error: No command provided. Please enter yt-dlp arguments.")
return
# Get download path from parent
path = self._parent.last_path
self.log_output.clear()
self.log_output.append("🚀 Executing custom yt-dlp command")
self.log_output.append(f"📍 URL: {url}")
self.log_output.append(f"⚙️ Arguments: {command}")
if path:
self.log_output.append(f"📁 Download path: {path}")
self.log_output.append("=" * 50)
self.run_btn.setEnabled(False)
self.run_btn.setText("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("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("Run Command")
class TimeRangeDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Download Video Section")
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
# Help text explaining the feature
help_text = QLabel(
"Download only specific parts of a video by specifying time ranges.\n"
"Use HH:MM:SS format or seconds. Leave start or end empty to download from beginning or to end."
)
help_text.setWordWrap(True)
help_text.setStyleSheet("color: #999999; padding: 10px;")
layout.addWidget(help_text)
# Time range section
time_group = QGroupBox("Time Range")
time_layout = QVBoxLayout()
# Start time row
start_layout = QHBoxLayout()
start_layout.addWidget(QLabel("Start Time:"))
self.start_time_input = QLineEdit()
self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)")
start_layout.addWidget(self.start_time_input)
time_layout.addLayout(start_layout)
# End time row
end_layout = QHBoxLayout()
end_layout.addWidget(QLabel("End Time:"))
self.end_time_input = QLineEdit()
self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)")
end_layout.addWidget(self.end_time_input)
time_layout.addLayout(end_layout)
time_group.setLayout(time_layout)
layout.addWidget(time_group)
# Force keyframes option
self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)")
self.force_keyframes.setChecked(True)
self.force_keyframes.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 4px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #1d1e22;
border-radius: 4px;
}
QCheckBox::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
border-radius: 4px;
}
"""
)
layout.addWidget(self.force_keyframes)
# Buttons
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
# Apply styling
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
QGroupBox {
border: 1px solid #3d3d3d;
border-radius: 4px;
margin-top: 1.5ex;
color: #ffffff;
padding: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 5px;
}
QLabel {
color: #ffffff;
}
QLineEdit {
padding: 8px;
border: 2px solid #1b2021;
border-radius: 4px;
background-color: #1b2021;
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #a50000;
}
"""
)
# Initialize preview
# self.update_preview() # 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()